2010年9月23日,Facebook经历了成立以来最严重的一次宕机——整整2.5小时,全球5亿用户无法访问。事后复盘发现,罪魁祸首竟是一个看似不起眼的配置值错误:当自动验证服务从数据库读取到一个无效值后,它删除了缓存条目,随后大量请求穿透到数据库。数据库超载后返回超时,系统将超时误判为无效值再次删除缓存,形成了无法自愈的死亡螺旋。

这不是个例。在缓存架构中,有三种故障模式会让系统在毫无预警的情况下崩溃:缓存穿透、缓存击穿和缓存雪崩。它们听起来相似,但成因和解决方案截然不同。


三种故障模式的本质差异

很多人把这三个概念混为一谈,但它们有着根本性的区别。

缓存穿透(Cache Penetration) 指的是查询一个根本不存在的数据。由于数据既不在缓存中,也不在数据库中,每次请求都会穿透缓存直达数据库。如果有人恶意构造大量不存在的key发起攻击,数据库会瞬间被打垮。

缓存击穿(Cache Breakdown/Stampede) 发生在热点key突然失效的瞬间。想象一个秒杀商品的库存信息缓存在10:00:00过期,恰好此时有1万个并发请求涌入——它们同时发现缓存为空,同时去查数据库,数据库连接池瞬间耗尽。

缓存雪崩(Cache Avalanche) 是更大规模的失效。当大量key设置了相同或相近的过期时间,它们在某个时刻集体失效,或者缓存服务器整体宕机,所有请求同时打到数据库,造成系统性崩溃。

用一个表格来区分:

问题 根因 数据是否存在 请求特征
缓存穿透 查询不存在的数据 否(缓存和DB都没有) 单个或批量无效key
缓存击穿 热点key过期 是(DB有,缓存过期) 高并发访问同一key
缓存雪崩 大量key同时失效 是(DB有,缓存批量过期) 大量不同key同时miss

缓存穿透:布隆过滤器的数学之美

解决缓存穿透最经典的方法是布隆过滤器(Bloom Filter)。它是一种空间效率极高的概率型数据结构,能以极低的内存消耗判断一个元素"可能存在"或"一定不存在"。

原理拆解

布隆过滤器的核心是一个长度为$m$的位数组和$k$个独立的哈希函数。初始时所有位都是0。

插入元素时:用$k$个哈希函数对元素进行哈希,得到$k$个位置,将这些位置设为1。

查询元素时:同样用$k$个哈希函数计算位置,如果所有位置都是1,则元素"可能存在";如果有任何位置是0,则元素"一定不存在"。

关键在于:布隆过滤器不会产生假阴性(说不存在就一定不存在),但可能产生假阳性(说存在实际可能不存在)。这正是解决缓存穿透所需的特性——我们宁可误判几个key存在,也不能放过任何攻击请求。

最优参数计算

布隆过滤器的假阳性率与参数选择密切相关。给定元素数量$n$和期望假阳性率$p$,最优参数计算公式如下:

最优位数组大小:

$$m = -\frac{n \ln p}{(\ln 2)^2}$$

最优哈希函数数量:

$$k = \frac{m}{n} \ln 2$$

实际假阳性率:

$$p \approx \left(1 - e^{-kn/m}\right)^k$$

举个例子:如果需要存储1亿个key,期望假阳性率为1%,则:

  • $m \approx 9.58 \times 10^8$ bits $\approx 114$ MB
  • $k \approx 6.64$,取7个哈希函数

这就是为什么布隆过滤器能在百MB内存中处理亿级数据的原因。

Redis中的实现

Redis 4.0之后通过RedisBloom模块原生支持布隆过滤器。基本操作如下:

# 添加元素
BF.ADD myfilter item1

# 检查元素是否存在
BF.EXISTS myfilter item1

# 批量添加
BF.MADD myfilter item1 item2 item3

对于没有RedisBloom的环境,可以用Redis的位图(bitmap)手动实现:

import mmh3
import redis
import math

class RedisBloomFilter:
    def __init__(self, redis_client, key, capacity, error_rate=0.01):
        self.redis = redis_client
        self.key = key
        # 计算最优参数:m = -n*ln(p)/(ln2)^2, k = m/n*ln2
        self.size = int(-capacity * math.log(error_rate) / (math.log(2) ** 2))
        self.hash_count = int(self.size / capacity * math.log(2))
    
    def add(self, item):
        for seed in range(self.hash_count):
            index = mmh3.hash(item, seed) % self.size
            self.redis.setbit(self.key, index, 1)
    
    def exists(self, item):
        for seed in range(self.hash_count):
            index = mmh3.hash(item, seed) % self.size
            if not self.redis.getbit(self.key, index):
                return False
        return True

布隆过滤器的局限性

布隆过滤器并非万能:

  1. 不支持删除:标准布隆过滤器无法删除元素,因为一个位可能被多个元素共享。如果需要删除功能,可以使用计数布隆过滤器(Counting Bloom Filter),但内存消耗会增加数倍。

  2. 假阳性的代价:当布隆过滤器误判时,请求会穿透到缓存层。对于大多数场景这是可接受的,但如果假阳性率设置不当,仍可能造成压力。

  3. 容量限制:布隆过滤器的容量需要在初始化时预估。如果实际元素数量超过设计容量,假阳性率会急剧上升。Scalable Bloom Filter可以动态扩容,但实现更复杂。


缓存击穿:Singleflight的并发控制艺术

缓存击穿的核心问题是:当一个热点key失效时,如何防止大量请求同时去重建缓存?

错误的方案:简单加锁

很多人的第一反应是加锁。但直接在缓存重建过程上加全局锁会造成严重的性能瓶颈——所有请求都要排队等待锁释放,吞吐量骤降。

Singleflight模式

Singleflight是一种优雅的并发控制模式,核心思想是:对同一个key的多个并发请求,只让一个请求去执行实际操作,其他请求共享结果

Go标准库中的golang.org/x/sync/singleflight提供了现成的实现:

import "golang.org/x/sync/singleflight"

var sg singleflight.Group

func GetFromCache(ctx context.Context, key string) (interface{}, error) {
    // 使用singleflight合并相同key的请求
    val, err, _ := sg.Do(key, func() (interface{}, error) {
        // 先查缓存
        val, err := redis.Get(ctx, key).Result()
        if err == nil {
            return val, nil
        }
        
        // 缓存未命中,查数据库
        val, err = db.Query(ctx, key)
        if err != nil {
            return nil, err
        }
        
        // 回填缓存,设置随机过期时间防止雪崩
        ttl := baseTTL + rand.Intn(60)
        redis.Set(ctx, key, val, time.Duration(ttl)*time.Second)
        return val, nil
    })
    return val, err
}

Singleflight的工作原理可以用一个图来描述:

sequenceDiagram
    participant R1 as 请求1
    participant R2 as 请求2
    participant R3 as 请求3
    participant SF as Singleflight
    participant DB as 数据库
    
    R1->>SF: Do("hot_key")
    SF->>SF: 创建call,获取锁
    R2->>SF: Do("hot_key")
    SF->>SF: 等待call完成
    R3->>SF: Do("hot_key")
    SF->>SF: 等待call完成
    SF->>DB: 查询数据
    DB-->>SF: 返回结果
    SF->>SF: 广播结果
    SF-->>R1: 返回结果
    SF-->>R2: 返回结果
    SF-->>R3: 返回结果

Java中的实现

Java没有标准库级别的Singleflight,但可以自行实现:

public class Singleflight<K, V> {
    private final ConcurrentHashMap<K, CompletableFuture<V>> inflight = new ConcurrentHashMap<>();
    
    public V doExecute(K key, Callable<V> loader) throws Exception {
        while (true) {
            CompletableFuture<V> future = inflight.get(key);
            if (future != null) {
                try {
                    return future.get();
                } catch (ExecutionException e) {
                    throw (Exception) e.getCause();
                }
            }
            
            CompletableFuture<V> newFuture = new CompletableFuture<>();
            CompletableFuture<V> existing = inflight.putIfAbsent(key, newFuture);
            if (existing != null) {
                continue;
            }
            
            try {
                V result = loader.call();
                newFuture.complete(result);
                return result;
            } catch (Exception e) {
                newFuture.completeExceptionally(e);
                throw e;
            } finally {
                inflight.remove(key, newFuture);
            }
        }
    }
}

分布式锁方案

在分布式环境下,Singleflight只能保护单个实例。如果多个服务实例同时访问同一个缓存,还需要分布式锁。

Redis分布式锁的经典实现是SET命令配合NX选项:

SET lock_key unique_value NX PX 10000

但简单的SET NX存在几个问题:

  1. 锁过期问题:如果业务执行时间超过锁过期时间,锁会自动释放,其他客户端可能获取锁,导致并发问题。
  2. 误删问题:客户端A的锁过期后,客户端B获取了锁,此时A执行完毕释放锁,会把B的锁也删掉。

解决方案是使用带自动续期的分布式锁,业界有两种主流方案:

方案一:看门狗模式 客户端启动一个后台线程,定期(如过期时间的1/3)续期锁,直到业务执行完毕。

方案二:RedLock算法 在多个独立的Redis实例上同时获取锁,只有大多数实例都成功才算获取成功,防止单点故障。


缓存雪崩:从Netflix看大规模缓存的防御工程

缓存雪崩的防御需要系统性思维,从架构设计到运维实践都要考虑。

随机过期时间

最简单的预防措施是给缓存设置随机过期时间。假设基础TTL是1小时,可以加上一个随机因子:

import random

def set_cache_with_jitter(key, value, base_ttl=3600):
    # 加上 0-20% 的随机时间
    jitter = random.randint(0, int(base_ttl * 0.2))
    ttl = base_ttl + jitter
    redis.setex(key, ttl, value)

这样即使同时设置大量缓存,它们的过期时间也会分散开来。

熔断与降级

当缓存大面积失效时,熔断器可以保护数据库不被打垮。基本思路是:

  1. 监控数据库的负载和响应时间
  2. 当负载超过阈值时,熔断器打开
  3. 熔断期间直接返回降级数据(如空结果、默认值或缓存副本)
  4. 一段时间后尝试半开状态,看是否恢复

Netflix的Hystrix(已停止维护)和Resilience4j都提供了成熟的熔断实现。

缓存预热

新缓存集群上线或大规模扩容时,冷启动会造成大量缓存miss。Netflix开发了专门的缓存预热系统(Cache Warmer),核心架构包含三个组件:

Controller:协调器,负责创建环境、调度任务、清理资源。

Dumper:数据导出器,运行在每个缓存节点的sidecar中,负责将本地数据导出为数据块并上传到S3。

Populator:数据填充器,消费S3中的数据块,填充到新缓存节点。

Netflix的实践数据令人印象深刻:

  • 预热5亿条数据、12TB数据量,仅需2小时
  • 最大规模预热:700TB数据、460亿条记录,380节点集群,24小时完成

高可用架构

缓存雪崩的终极防御是高可用架构设计:

主从复制:Redis的主从架构可以在主节点故障时快速切换。

Redis Cluster:支持自动分片和故障转移,单个节点故障不影响整体可用性。

多级缓存:本地缓存(如Caffeine)+ 分布式缓存(如Redis)。即使Redis全挂,本地缓存仍能承接部分流量。

异地多活:对于关键业务,可以部署跨地域的缓存集群,防止单地域故障。


真实事故的启示

回到Facebook 2010年的事故,这次宕机暴露了三个设计缺陷:

自动验证系统的错误处理不当:系统没有区分"数据库返回无效值"和"数据库超时/错误"两种情况,错误地删除了缓存。

缺乏限流保护:当数据库压力飙升时,没有任何机制阻止新的请求涌入。

恶性循环:数据库超时被当作无效值处理,触发更多缓存删除,加剧了数据库压力。

正确的做法应该包括:

  1. 区分错误类型:超时、连接失败等系统错误不应该触发缓存删除。
  2. 实现限流:当数据库负载过高时,主动拒绝部分请求。
  3. 添加熔断机制:连续失败达到阈值时,自动熔断,避免雪崩。
  4. 引入抖动(Jitter):重试时加入随机延迟,避免所有请求同时重试。

防御清单

在生产环境中,完整的缓存防御体系应该包含:

防御措施 针对问题 实现难度 优先级
布隆过滤器 缓存穿透
缓存空值 缓存穿透
Singleflight 缓存击穿
分布式锁 缓存击穿
随机TTL 缓存雪崩
多级缓存 缓存雪崩
熔断降级 所有
缓存预热 缓存雪崩
限流保护 所有

缓存不是银弹,而是一种权衡。正确的缓存设计需要在命中率、一致性、可用性和复杂度之间找到平衡。理解这三种故障模式的本质区别,是构建健壮缓存系统的第一步。


参考资料

  1. Redis Documentation: Distributed Locks with Redis - https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
  2. Netflix TechBlog: Cache warming: Agility for a stateful service - https://netflixtechblog.com/cache-warming-agility-for-a-stateful-service-2d3b1da82642
  3. ByteByteGo: A Crash Course in Caching - Final Part - https://blog.bytebytego.com/p/a-crash-course-in-caching-final-part
  4. Engineering at Scale: Facebook’s 2010 outage: Cache invalidation gone wrong - https://engineeringatscale.substack.com/p/facebook-2010-outage-cache-invalidation-analysis
  5. AlgoMaster: Bloom Filters - https://algomaster.io/learn/system-design/bloom-filters
  6. AWS Whitepaper: Database Caching Strategies Using Redis - https://docs.aws.amazon.com/whitepapers/latest/database-caching-strategies-using-redis/caching-patterns.html
  7. Redis.io: LFU vs. LRU: How to choose the right cache eviction policy - https://redis.io/blog/lfu-vs-lru-how-to-choose-the-right-cache-eviction-policy/