在高并发系统中,Redis 缓存是缓解数据库压力、提升响应速度的核心组件。然而,引入缓存的同时也带来了一系列一致性、可靠性和高可用性的挑战。本文系统梳理四种经典缓存设计模式,并深入剖析缓存穿透、击穿、雪崩三大问题的成因与防御策略。
一、四种经典缓存设计模式
缓存与数据库的协作方式决定了系统的数据一致性和性能表现。业界形成了四种经典的缓存设计模式,各有适用场景。
1.1 Cache Aside(旁路缓存)
Cache Aside 是最常用的缓存模式,由应用程序直接管理缓存和数据库的读写操作。
读操作流程:
- 应用先查询缓存,若命中则直接返回。
- 若缓存未命中,则查询数据库。
- 将数据库查询结果写入缓存后返回给客户端。
写操作流程:
- 应用先更新数据库。
- 再删除缓存(而非更新缓存),等待下次读请求时重建缓存。
这种模式的核心思想是:以数据库为准,缓存为辅。写操作采用"删缓存"而非"更新缓存"的策略,主要是为了避免并发场景下缓存与数据库的不一致。例如,两个并发写操作分别更新同一条数据,如果采用先更新缓存再更新数据库,可能出现缓存被较旧请求覆盖的情况。
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 模式下,应用将所有读请求交给缓存层处理,由缓存层自身负责从数据库加载数据。应用程序无需感知数据库的存在。
读操作流程:
- 应用向缓存组件发起读取请求。
- 缓存组件检查自身是否命中,命中则直接返回。
- 未命中时,由缓存组件透明地从数据库加载数据并写入自身,再返回给应用。
这种模式通常需要借助支持 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 模式下,应用将写请求交给缓存层,由缓存层同步完成缓存和数据库的更新。
写操作流程:
- 应用向缓存组件发起写入请求。
- 缓存组件先更新自身数据。
- 再同步更新数据库。
- 两者都完成后返回成功给应用。
读操作通常与 Read Through 配合使用。
优点:
- 缓存与数据库强一致(单次写操作内)。
- 写操作简单,应用只需与缓存交互。
缺点:
- 写延迟较高:必须等待数据库写入完成才返回。
- 写吞吐量受限于数据库性能。
- 若数据库写入失败,缓存已更新,存在不一致风险。
适用场景: 对一致性要求极高、写频率较低的场景。实际生产中使用相对较少。
1.4 Write Behind / Write Back(异步写回)
Write Behind(又称 Write Back)模式下,应用只更新缓存,缓存组件异步批量地将变更写回数据库。
写操作流程:
- 应用向缓存发起写入请求。
- 缓存立即更新自身并返回成功。
- 缓存组件在后台异步、批量地将变更持久化到数据库。
优点:
- 写延迟极低,响应速度快。
- 可批量合并写操作,减少数据库压力。
- 写吞吐量大,适合写密集型场景。
缺点:
- 缓存与数据库存在较大不一致窗口。
- 若缓存宕机且数据未同步,可能丢失数据。
- 实现复杂,需要完善的持久化队列和重试机制。
适用场景: 写密集型、可容忍一定不一致、对性能极度敏感的场景,如日志系统、计数器、消息队列缓冲层。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。
布隆过滤器的部署方式:
- 本地内存型:每个应用实例维护一个过滤器,适用于数据量不大、更新不频繁的场景。
- Redis 模块型:使用 RedisBloom 模块,多个应用共享同一个过滤器。
- 计算型:请求时由应用根据规则计算是否合法,无需存储过滤器。
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 的 Resilience4j 或 Hystrix。
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 同时过期或缓存宕机 | 过期打散 + 多级缓存 + 熔断降级 + 限流 |
在实际生产中,建议采取「多层防御」策略:
- 基础层:严格的参数校验和合理的业务逻辑设计。
- 过滤层:布隆过滤器拦截不存在的数据查询。
- 缓存层:合理的 TTL 设计、空值缓存、互斥锁机制。
- 兜底层:熔断降级和限流保护数据库,多级缓存保障服务可用性。
缓存设计没有银弹,需要根据业务特点、数据特征、系统规模综合权衡,在一致性、可用性、性能之间找到最适合的平衡点。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。