Redis 缓存设计模式:Cache Aside、穿透/击穿/雪崩防御

Redis 四种经典缓存设计模式,以及缓存穿透、击穿、雪崩三大问题的成因分析与防御方案

在高并发系统中,Redis 缓存是缓解数据库压力、提升响应速度的核心组件。然而,引入缓存的同时也带来了一系列一致性、可靠性和高可用性的挑战。本文系统梳理四种经典缓存设计模式,并深入剖析缓存穿透、击穿、雪崩三大问题的成因与防御策略。

一、四种经典缓存设计模式

缓存与数据库的协作方式决定了系统的数据一致性和性能表现。业界形成了四种经典的缓存设计模式,各有适用场景。

1.1 Cache Aside(旁路缓存)

Cache Aside 是最常用的缓存模式,由应用程序直接管理缓存和数据库的读写操作。

读操作流程:

  1. 应用先查询缓存,若命中则直接返回。
  2. 若缓存未命中,则查询数据库。
  3. 将数据库查询结果写入缓存后返回给客户端。

写操作流程:

  1. 应用先更新数据库。
  2. 再删除缓存(而非更新缓存),等待下次读请求时重建缓存。

这种模式的核心思想是:以数据库为准,缓存为辅。写操作采用"删缓存"而非"更新缓存"的策略,主要是为了避免并发场景下缓存与数据库的不一致。例如,两个并发写操作分别更新同一条数据,如果采用先更新缓存再更新数据库,可能出现缓存被较旧请求覆盖的情况。

Go 实现示例:

package cache

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

type CacheAside struct {
	redisClient *redis.Client
	// 数据库查询函数,由调用方注入
	dbGetter func(ctx context.Context, key string) (interface{}, error)
}

func NewCacheAside(redisClient *redis.Client, dbGetter func(ctx context.Context, key string) (interface{}, error)) *CacheAside {
	return &CacheAside{
		redisClient: redisClient,
		dbGetter:    dbGetter,
	}
}

// Get 读操作:先查缓存,未命中则查库并回填
func (c *CacheAside) Get(ctx context.Context, key string, ttl time.Duration) (interface{}, error) {
	// 1. 先查缓存
	cached, err := c.redisClient.Get(ctx, key).Result()
	if err == nil {
		var result interface{}
		if err := json.Unmarshal([]byte(cached), &result); err == nil {
			return result, nil
		}
	}

	// 2. 缓存未命中,查询数据库
	data, err := c.dbGetter(ctx, key)
	if err != nil {
		return nil, err
	}

	// 3. 回填缓存(异步可进一步优化)
	bytes, _ := json.Marshal(data)
	c.redisClient.Set(ctx, key, bytes, ttl)

	return data, nil
}

// Set 写操作:先更新数据库,再删除缓存
func (c *CacheAside) Set(ctx context.Context, key string, value interface{}, ttl time.Duration, dbUpdater func() error) error {
	// 1. 先更新数据库
	if err := dbUpdater(); err != nil {
		return err
	}

	// 2. 删除缓存(而非更新缓存)
	if err := c.redisClient.Del(ctx, key).Err(); err != nil {
		// 记录日志:缓存删除失败可能导致短暂不一致
		fmt.Printf("cache delete failed: %v\n", err)
	}

	return nil
}

优点:

  • 实现简单,逻辑清晰,容易理解和维护。
  • 缓存仅在需要时加载,避免无效缓存占用空间。
  • 容错性好:即使缓存不可用,系统仍可降级到数据库。

缺点:

  • 存在缓存与数据库短暂不一致的窗口期(写操作删除缓存后,旧读请求可能将旧数据写回缓存)。
  • 首次访问(冷启动)时可能引发缓存击穿。

适用场景: 读多写少、一致性要求不极端严格、业务逻辑复杂的系统。这也是大多数互联网应用的首选方案。

1.2 Read Through(读穿透)

Read Through 模式下,应用将所有读请求交给缓存层处理,由缓存层自身负责从数据库加载数据。应用程序无需感知数据库的存在。

读操作流程:

  1. 应用向缓存组件发起读取请求。
  2. 缓存组件检查自身是否命中,命中则直接返回。
  3. 未命中时,由缓存组件透明地从数据库加载数据并写入自身,再返回给应用。

这种模式通常需要借助支持 Read Through 的缓存中间件(如 Caffeine 的 CacheLoader、Ehcache 的 CacheLoader)来实现。Redis 本身不直接支持 Read Through,需要在客户端封装实现。

Java 实现示例(使用 Caffeine):

import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;

import java.util.concurrent.TimeUnit;

public class ReadThroughCache {
    // 模拟数据库服务
    private final UserRepository userRepository;

    // Caffeine 支持 CacheLoader 自动加载
    private final LoadingCache<String, User> cache;

    public ReadThroughCache(UserRepository userRepository) {
        this.userRepository = userRepository;
        this.cache = Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .build(this::loadFromDatabase);
    }

    // CacheLoader:缓存未命中时自动从数据库加载
    private User loadFromDatabase(String userId) {
        System.out.println("Cache miss, loading from DB: " + userId);
        return userRepository.findById(userId);
    }

    // 应用层只与缓存交互,完全透明
    public User getUser(String userId) {
        return cache.get(userId);
    }
}

优点:

  • 应用代码更加简洁,无需处理缓存未命中的逻辑。
  • 缓存加载逻辑集中管理,便于统一优化。

缺点:

  • 依赖缓存框架的加载能力,灵活性受限。
  • 初次加载延迟对应用可见(由缓存框架内部处理)。

适用场景: 本地缓存(Caffeine、Guava Cache)场景较多,分布式缓存需自行在客户端封装加载逻辑。

1.3 Write Through(写穿透)

Write Through 模式下,应用将写请求交给缓存层,由缓存层同步完成缓存和数据库的更新。

写操作流程:

  1. 应用向缓存组件发起写入请求。
  2. 缓存组件先更新自身数据。
  3. 再同步更新数据库。
  4. 两者都完成后返回成功给应用。

读操作通常与 Read Through 配合使用。

优点:

  • 缓存与数据库强一致(单次写操作内)。
  • 写操作简单,应用只需与缓存交互。

缺点:

  • 写延迟较高:必须等待数据库写入完成才返回。
  • 写吞吐量受限于数据库性能。
  • 若数据库写入失败,缓存已更新,存在不一致风险。

适用场景: 对一致性要求极高、写频率较低的场景。实际生产中使用相对较少。

1.4 Write Behind / Write Back(异步写回)

Write Behind(又称 Write Back)模式下,应用只更新缓存,缓存组件异步批量地将变更写回数据库。

写操作流程:

  1. 应用向缓存发起写入请求。
  2. 缓存立即更新自身并返回成功。
  3. 缓存组件在后台异步、批量地将变更持久化到数据库。

优点:

  • 写延迟极低,响应速度快。
  • 可批量合并写操作,减少数据库压力。
  • 写吞吐量大,适合写密集型场景。

缺点:

  • 缓存与数据库存在较大不一致窗口。
  • 若缓存宕机且数据未同步,可能丢失数据。
  • 实现复杂,需要完善的持久化队列和重试机制。

适用场景: 写密集型、可容忍一定不一致、对性能极度敏感的场景,如日志系统、计数器、消息队列缓冲层。Linux Page Cache 也采用了类似机制。

1.5 四种模式对比

模式读写复杂度一致性性能数据安全适用场景
Cache Aside最终一致高(数据库为准)通用,读多写少
Read Through低(应用视角)最终一致本地缓存,透明加载
Write Through低(应用视角)强一致强一致要求
Write Behind低(应用视角)弱一致极高写密集型,可丢数据

二、缓存穿透

2.1 成因分析

缓存穿透(Cache Penetration)指查询一个缓存和数据库中都不存在的数据,导致每次请求都直接打到数据库。当这类请求量巨大时,数据库压力剧增,甚至可能被拖垮。

典型场景:

  • 恶意攻击:使用大量不存在的 ID(如负数、超大数)发起请求。
  • 业务逻辑漏洞:删除了的数据仍然被查询。
  • 参数校验缺失:用户输入非法参数直接透传到查询层。

2.2 防御方案

2.2.1 空值缓存(Cache Null)

对于数据库中确实不存在的数据,也在缓存中存储一个空值(或特殊标记),并设置较短过期时间。后续相同请求直接命中缓存中的空值,不再查询数据库。

Go 实现示例:

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

const (
	// NullPlaceholder 表示空值的特殊标记
	NullPlaceholder = "__NULL__"
	// NullCacheTTL 空值缓存过期时间,通常较短
	NullCacheTTL = 5 * time.Minute
	// NormalCacheTTL 正常数据缓存过期时间
	NormalCacheTTL = 30 * time.Minute
)

type NullCacheService struct {
	redis *redis.Client
}

func (s *NullCacheService) GetWithNullCache(ctx context.Context, key string) (*User, error) {
	// 1. 查询缓存
	val, err := s.redis.Get(ctx, key).Result()
	if err == nil {
		// 命中缓存:判断是否是空值标记
		if val == NullPlaceholder {
			return nil, fmt.Errorf("user not found")
		}
		var user User
		if err := json.Unmarshal([]byte(val), &user); err == nil {
			return &user, nil
		}
	}

	// 2. 缓存未命中,查询数据库
	user, err := queryDatabase(key)
	if err != nil {
		// 数据库也不存在:缓存空值,防止穿透
		s.redis.Set(ctx, key, NullPlaceholder, NullCacheTTL)
		return nil, fmt.Errorf("user not found")
	}

	// 3. 数据库命中,回填正常缓存
	bytes, _ := json.Marshal(user)
	s.redis.Set(ctx, key, string(bytes), NormalCacheTTL)
	return user, nil
}

type User struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

func queryDatabase(key string) (*User, error) {
	// 模拟数据库查询
	return nil, fmt.Errorf("not found")
}

注意事项:

  • 空值缓存的过期时间应短于正常缓存,避免真实数据新增后长期无法访问。
  • 需要评估「不存在」key 的数量规模,防止缓存空间被大量空值占满。

2.2.2 布隆过滤器(Bloom Filter)

布隆过滤器是一种空间效率极高的概率型数据结构,用于快速判断一个元素「可能存在于集合中」或「一定不存在于集合中」。在缓存场景中,将所有可能存在的数据 key 预先加载到布隆过滤器中,请求到来时先查过滤器,若判定不存在则直接返回,避免查库。

Java 实现示例(Guava BloomFilter):

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

import java.nio.charset.Charset;
import java.util.List;

public class BloomFilterCache {
    // 预期数据量:100万
    private static final int EXPECTED_INSERTIONS = 1_000_000;
    // 误判率:1%
    private static final double FPP = 0.01;

    private final BloomFilter<String> bloomFilter;
    private final Cache<String, Object> cache;
    private final DatabaseService databaseService;

    public BloomFilterCache(DatabaseService databaseService, List<String> allKeys) {
        this.databaseService = databaseService;
        this.cache = Caffeine.newBuilder().maximumSize(100_000).build();

        // 初始化布隆过滤器:加载所有可能存在的数据 key
        this.bloomFilter = BloomFilter.create(
                Funnels.stringFunnel(Charset.defaultCharset()),
                EXPECTED_INSERTIONS,
                FPP
        );

        // 预热:将所有合法 key 加入布隆过滤器
        for (String key : allKeys) {
            bloomFilter.put(key);
        }
    }

    public Object getData(String key) {
        // 1. 先查布隆过滤器
        if (!bloomFilter.mightContain(key)) {
            // 一定不存在,直接返回 null
            return null;
        }

        // 2. 可能存在,查缓存
        Object cached = cache.getIfPresent(key);
        if (cached != null) {
            return cached;
        }

        // 3. 缓存未命中,查数据库(这里有 1% 概率是误判)
        Object data = databaseService.query(key);
        if (data != null) {
            cache.put(key, data);
        }
        return data;
    }

    // 数据新增时同步更新布隆过滤器
    public void addData(String key, Object data) {
        databaseService.insert(key, data);
        bloomFilter.put(key);
    }
}

布隆过滤器的特点:

  • 一定不存在:若过滤器判定不存在,则该元素一定不在集合中(无假阴性)。
  • 可能存在:若判定存在,则该元素可能在集合中(存在误判率)。
  • 不支持删除操作(可以使用 Counting Bloom Filter 改进)。
  • 空间占用极小:100 万条数据、1% 误判率仅需约 1.14 MB。

布隆过滤器的部署方式:

  1. 本地内存型:每个应用实例维护一个过滤器,适用于数据量不大、更新不频繁的场景。
  2. Redis 模块型:使用 RedisBloom 模块,多个应用共享同一个过滤器。
  3. 计算型:请求时由应用根据规则计算是否合法,无需存储过滤器。

2.2.3 参数校验与非法请求拦截

在请求入口处进行参数校验,拦截明显非法的请求。

func (h *Handler) GetUser(ctx context.Context, req *GetUserRequest) (*User, error) {
	// 参数校验
	if req.UserID == "" || req.UserID == "0" {
		return nil, errors.New("invalid user_id")
	}

	// ID 范围校验
	userID, err := strconv.ParseInt(req.UserID, 10, 64)
	if err != nil || userID <= 0 || userID > MaxUserID {
		return nil, errors.New("user_id out of range")
	}

	// 速率限制:单个 IP 对不存在 key 的请求频率
	if h.rateLimiter.Allow(req.IP) {
		return h.cacheService.Get(ctx, req.UserID)
	}
	return nil, errors.New("rate limited")
}

2.3 缓存穿透防御方案对比

方案内存/存储开销准确率实现复杂度适用场景
空值缓存中(依赖不存在 key 数量)100%不存在 key 数量有限
布隆过滤器极低(位数组)概率型(可配置)数据量大、需要精确控制
参数校验100%所有场景(基础防线)

三、缓存击穿

3.1 成因分析

缓存击穿(Cache Breakdown)指一个热点 key 在缓存中过期失效的瞬间,大量并发请求同时涌入,直接访问数据库,造成数据库瞬间压力激增。

与缓存穿透的区别:

  • 缓存穿透是查询「不存在」的数据。
  • 缓存击穿是查询「存在但缓存刚好过期」的热点数据。

典型场景:

  • 秒杀活动的商品信息缓存过期。
  • 微博热搜数据的缓存失效。
  • 明星绯闻等突发热点事件的详情页缓存。

3.2 防御方案

3.2.1 互斥锁(Mutex Lock)

当缓存失效时,只允许一个线程去查询数据库并重建缓存,其他线程等待缓存重建完成后直接读取。这是解决缓存击穿最直接有效的方案。

Go 实现示例(基于 Redis 分布式锁):

package main

import (
	"context"
	"fmt"
	"sync"
	"time"

	"github.com/redis/go-redis/v9"
)

// HotKeyService 热点 key 防护服务
type HotKeyService struct {
	redis     *redis.Client
	localMu   sync.Map // 本地锁:针对单个 key 的互斥
}

func (s *HotKeyService) GetWithMutex(ctx context.Context, key string, ttl time.Duration, dbQuery func() (string, error)) (string, error) {
	// 1. 先查缓存
	val, err := s.redis.Get(ctx, key).Result()
	if err == nil {
		return val, nil
	}

	// 2. 缓存未命中,尝试获取互斥锁
	lockKey := "lock:" + key
	lockValue := fmt.Sprintf("%d", time.Now().UnixNano())

	// 尝试获取分布式锁(SET key value NX EX seconds)
	locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 10*time.Second).Result()
	if err != nil || !locked {
		// 3. 获取锁失败:其他线程正在重建缓存,等待后重试
		time.Sleep(100 * time.Millisecond)
		return s.GetWithMutex(ctx, key, ttl, dbQuery)
	}

	// 4. 获取锁成功:双重检查,防止等待期间缓存已被重建
	val, err = s.redis.Get(ctx, key).Result()
	if err == nil {
		s.redis.Del(ctx, lockKey) // 释放锁
		return val, nil
	}

	// 5. 查询数据库并重建缓存
	data, err := dbQuery()
	if err != nil {
		s.redis.Del(ctx, lockKey)
		return "", err
	}

	// 6. 写入缓存并释放锁
	s.redis.Set(ctx, key, data, ttl)
	s.redis.Del(ctx, lockKey)

	return data, nil
}

Java 实现示例(基于本地锁 + 双重检查):

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class HotKeyProtectionCache {
    private final RedisTemplate<String, Object> redisTemplate;
    private final DatabaseService databaseService;

    // 本地锁映射:每个 key 对应一个锁
    private final ConcurrentHashMap<String, ReentrantLock> locks = new ConcurrentHashMap<>();

    public Object getWithMutex(String key, long ttlSeconds) {
        // 1. 先查缓存
        Object cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }

        // 2. 获取或创建该 key 的锁
        ReentrantLock lock = locks.computeIfAbsent(key, k -> new ReentrantLock());

        try {
            // 3. 尝试获取锁,带超时
            if (!lock.tryLock(3, TimeUnit.SECONDS)) {
                // 获取锁超时,降级返回或重试
                throw new RuntimeException("获取锁超时");
            }

            try {
                // 4. 双重检查:等待期间缓存可能已被重建
                cached = redisTemplate.opsForValue().get(key);
                if (cached != null) {
                    return cached;
                }

                // 5. 查询数据库
                Object data = databaseService.query(key);

                // 6. 写入缓存
                if (data != null) {
                    redisTemplate.opsForValue().set(key, data, ttlSeconds, TimeUnit.SECONDS);
                }
                return data;
            } finally {
                lock.unlock();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("线程被中断", e);
        }
    }
}

锁的设计要点:

  • 本地锁适合单机部署,分布式环境需使用 Redis 分布式锁或 ZooKeeper。
  • 锁的持有时间应合理设置,防止死锁(如查询超时未释放锁)。
  • 必须包含「双重检查」机制:获取锁后再次确认缓存是否已被重建。

3.2.2 逻辑过期(Logical Expiration)

物理过期改为逻辑过期:缓存数据永不过期(或设置很长的物理过期时间),但在数据中维护一个「逻辑过期时间」。当读取时发现逻辑过期,由一个后台线程异步更新,当前请求返回旧数据。

type CacheItem struct {
	Data       string    `json:"data"`
	ExpireTime time.Time `json:"expire_time"` // 逻辑过期时间
}

func (s *HotKeyService) GetWithLogicalExpire(ctx context.Context, key string, ttl time.Duration, dbQuery func() (string, error)) (string, error) {
	// 1. 查询缓存(永不过期)
	val, err := s.redis.Get(ctx, key).Result()
	if err != nil {
		return "", err
	}

	var item CacheItem
	if err := json.Unmarshal([]byte(val), &item); err != nil {
		return "", err
	}

	// 2. 逻辑未过期,直接返回
	if time.Now().Before(item.ExpireTime) {
		return item.Data, nil
	}

	// 3. 逻辑已过期,尝试获取重建锁(非阻塞)
	lockKey := "lock:rebuild:" + key
	locked, _ := s.redis.SetNX(ctx, lockKey, "1", 10*time.Second).Result()

	if locked {
		// 4. 获取锁成功,异步重建缓存(避免阻塞当前请求)
		go func() {
			defer s.redis.Del(ctx, lockKey)
			data, err := dbQuery()
			if err != nil {
				return
			}
			newItem := CacheItem{Data: data, ExpireTime: time.Now().Add(ttl)}
			bytes, _ := json.Marshal(newItem)
			s.redis.Set(ctx, key, string(bytes), 0) // 0 = 永不过期
		}()
	}

	// 5. 无论是否获取锁,都返回旧数据(保证可用性)
	return item.Data, nil
}

适用场景: 对一致性要求不高、但对可用性要求极高的热点数据(如配置项、广告位、大盘数据)。

3.2.3 热点数据预加载与永不过期

对于已知的热点 key(如秒杀商品),可以通过以下方式避免击穿:

  • 预加载:系统启动或活动开始前,提前将热点数据加载到缓存。
  • 永不过期 + 主动更新:设置缓存永不过期,通过定时任务或消息队列主动更新。
  • 热点识别与监控:通过访问日志分析识别热点 key,对热点 key 采取特殊策略。

四、缓存雪崩

4.1 成因分析

缓存雪崩(Cache Avalanche)指在某一时刻,大量缓存 key 同时过期失效(或 Redis 宕机),导致大量请求直接打到数据库,数据库瞬间承受巨大压力甚至崩溃。

典型场景:

  • 缓存集中设置相同的过期时间,导致大量 key 同时失效。
  • Redis 节点宕机或网络分区,缓存集群大面积不可用。
  • 缓存系统重启后的冷启动。

4.2 防御方案

4.2.1 过期时间打散(Randomized TTL)

在基础过期时间上增加一个随机值,避免大量 key 同时过期。

Go 实现示例:

package main

import (
	"math/rand"
	"time"
)

// RandomTTL 生成随机过期时间:基础 TTL + 随机偏移
func RandomTTL(baseTTL time.Duration, maxJitter time.Duration) time.Duration {
	if maxJitter <= 0 {
		return baseTTL
	}
	// 0 ~ maxJitter 的随机偏移
	jitter := time.Duration(rand.Int63n(int64(maxJitter)))
	return baseTTL + jitter
}

// 使用示例:基础 30 分钟,最多偏移 10 分钟
func (s *Service) SetWithRandomTTL(ctx context.Context, key string, value string) {
	ttl := RandomTTL(30*time.Minute, 10*time.Minute)
	s.redis.Set(ctx, key, value, ttl)
}

最佳实践:

  • 随机偏移量建议为基础 TTL 的 10%-30%。
  • 对于按批次导入的数据(如整点刷新),尤其需要注意打散过期时间。

4.2.2 多级缓存

构建多级缓存体系:L1(本地缓存,如 Caffeine)+ L2(分布式缓存,如 Redis)+ L3(数据库)。当某一级缓存失效时,由下一级继续提供兜底。

public class MultiLevelCache {
    // L1: 本地缓存(Caffeine)
    private final LoadingCache<String, Object> localCache;
    // L2: 分布式缓存(Redis)
    private final StringRedisTemplate redisTemplate;
    // L3: 数据库
    private final DatabaseService databaseService;

    public Object get(String key) {
        // 1. 查 L1 本地缓存
        Object value = localCache.getIfPresent(key);
        if (value != null) {
            return value;
        }

        // 2. 查 L2 Redis
        String redisValue = redisTemplate.opsForValue().get(key);
        if (redisValue != null) {
            // 回填 L1
            localCache.put(key, redisValue);
            return redisValue;
        }

        // 3. 查 L3 数据库(带互斥锁)
        value = queryDatabaseWithLock(key);
        if (value != null) {
            // 回填 L2 和 L1
            redisTemplate.opsForValue().set(key, value.toString(), 30, TimeUnit.MINUTES);
            localCache.put(key, value);
        }
        return value;
    }
}

多级缓存的设计原则:

  • L1 本地缓存的 TTL 短于 L2,数据量远小于 L2。
  • 写操作需要同时更新或失效多级缓存。
  • 注意本地缓存的一致性问题(可使用消息广播实现失效同步)。

4.2.3 熔断降级(Circuit Breaker)

当数据库压力过大或响应异常时,通过熔断器快速失败,保护数据库。

package main

import (
	"errors"
	"sync"
	"time"
)

// CircuitBreaker 简易熔断器
type CircuitBreaker struct {
	mu                sync.RWMutex
	state             State          // 当前状态
	failureCount      int            // 连续失败次数
	failureThreshold  int            // 熔断触发阈值
	timeoutDuration   time.Duration  // 熔断后等待时间
	lastFailureTime   time.Time
	halfMaxRequests   int            // 半开状态允许的最大试探请求数
}

type State int

const (
	StateClosed    State = iota // 关闭:正常通过
	StateOpen                   // 打开:熔断,快速失败
	StateHalfOpen               // 半开:允许少量试探请求
)

func (cb *CircuitBreaker) Call(fn func() (interface{}, error)) (interface{}, error) {
	cb.mu.Lock()
	state := cb.state
	cb.mu.Unlock()

	switch state {
	case StateOpen:
		// 检查是否已过冷却期
		cb.mu.Lock()
		if time.Since(cb.lastFailureTime) > cb.timeoutDuration {
			cb.state = StateHalfOpen
			cb.failureCount = 0
			state = StateHalfOpen
		}
		cb.mu.Unlock()
		if state == StateOpen {
			return nil, errors.New("circuit breaker is open")
		}
		fallthrough
	case StateHalfOpen, StateClosed:
		result, err := fn()
		cb.recordResult(err)
		return result, err
	}
	return nil, errors.New("unknown state")
}

func (cb *CircuitBreaker) recordResult(err error) {
	cb.mu.Lock()
	defer cb.mu.Unlock()

	if err != nil {
		cb.failureCount++
		cb.lastFailureTime = time.Now()
		if cb.failureCount >= cb.failureThreshold {
			cb.state = StateOpen
		}
	} else {
		cb.failureCount = 0
		cb.state = StateClosed
	}
}

生产环境建议: 使用成熟的熔断器实现,如 Go 的 gobreaker、Java 的 Resilience4jHystrix

4.2.4 限流(Rate Limiting)

在系统入口处限制请求速率,防止突发流量压垮系统。

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

// SlidingWindowLimiter 滑动窗口限流器
type SlidingWindowLimiter struct {
	redis      *redis.Client
	windowSize time.Duration // 窗口大小
	limit      int           // 窗口内最大请求数
	keyPrefix  string
}

func (l *SlidingWindowLimiter) Allow(ctx context.Context, identifier string) bool {
	now := time.Now().UnixMilli()
	windowStart := now - l.windowSize.Milliseconds()
	key := fmt.Sprintf("%s:%s", l.keyPrefix, identifier)

	pipe := l.redis.Pipeline()

	// 1. 移除窗口外的旧记录
	pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))

	// 2. 添加当前请求记录
	pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: now})

	// 3. 统计当前窗口内的请求数
	countCmd := pipe.ZCard(ctx, key)

	// 4. 设置 key 过期时间
	pipe.Expire(ctx, key, l.windowSize)

	_, err := pipe.Exec(ctx)
	if err != nil {
		return false
	}

	return countCmd.Val() <= int64(l.limit)
}

五、综合防御架构

生产环境中的缓存防护不是单一方案的选择,而是多道防线的叠加。

5.1 架构层次

┌─────────────────────────────────────────────────────────────┐
│                    请求入口层                                │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐       │
│  │   参数校验    │  │   限流控制    │  │   黑名单过滤  │       │
│  └──────────────┘  └──────────────┘  └──────────────┘       │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                    布隆过滤器层                              │
│         拦截一定不存在的数据查询请求                          │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                    L1 本地缓存(Caffeine)                   │
│         进程内缓存,响应延迟 < 1ms                          │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                    L2 分布式缓存(Redis)                     │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐       │
│  │   空值缓存    │  │  随机过期时间 │  │  互斥锁防击穿 │       │
│  └──────────────┘  └──────────────┘  └──────────────┘       │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                    数据库访问层                              │
│  ┌──────────────┐  ┌──────────────┐                        │
│  │   熔断保护    │  │   连接池限流  │                        │
│  └──────────────┘  └──────────────┘                        │
└─────────────────────────────────────────────────────────────┘

5.2 各层级职责

层级防御目标具体手段
请求入口非法请求、突发流量参数校验、限流、WAF
布隆过滤器缓存穿透快速判定数据是否存在
L1 本地缓存热点数据、减少网络开销Caffeine/Guava Cache
L2 Redis 缓存缓存穿透/击穿/雪崩空值缓存、随机 TTL、互斥锁、逻辑过期
数据库层最终兜底保护熔断降级、连接池限制

5.3 高可用 Redis 部署

缓存雪崩的一个重要诱因是 Redis 本身的单点故障。生产环境建议:

  • 主从 + 哨兵模式:自动故障转移,保障可用性。
  • Redis Cluster:数据分片,支持水平扩展,天然高可用。
  • 持久化策略:RDB 快照 + AOF 日志,加速故障恢复。
  • 多可用区部署:跨机房部署,避免单点机房故障。

六、总结

缓存设计是高并发系统的核心课题。四种经典的缓存模式(Cache Aside、Read Through、Write Through、Write Behind)各有适用场景,Cache Aside 因其简单高效成为业界主流选择。

缓存三大问题的本质和防御策略总结如下:

问题本质核心防御
缓存穿透查询「不存在」的数据布隆过滤器 + 空值缓存 + 参数校验
缓存击穿热点 key 过期瞬间的并发互斥锁 + 逻辑过期 + 热点预加载
缓存雪崩大量 key 同时过期或缓存宕机过期打散 + 多级缓存 + 熔断降级 + 限流

在实际生产中,建议采取「多层防御」策略:

  1. 基础层:严格的参数校验和合理的业务逻辑设计。
  2. 过滤层:布隆过滤器拦截不存在的数据查询。
  3. 缓存层:合理的 TTL 设计、空值缓存、互斥锁机制。
  4. 兜底层:熔断降级和限流保护数据库,多级缓存保障服务可用性。

缓存设计没有银弹,需要根据业务特点、数据特征、系统规模综合权衡,在一致性、可用性、性能之间找到最适合的平衡点。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. 缓存架构演进之路:从单机 Redis 到亿级分布式多级缓存体系
  2. Redis 7.x 重大新特性与架构升级深度解析
  3. Redis 消息队列深度对比:Pub/Sub、Streams 与 Kafka/RabbitMQ 选型指南