四步模式
缺陷存在于四个顺序、非原子的步骤中:
// DEFECT
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
步骤 1:检查缓存。步骤 2:未命中。步骤 3:计算。步骤 4:存储。这四个步骤不是原子的。在步骤 1 与步骤 4 之间,任意数量的线程都可能执行步骤 1,并且都看到 null。
幂等性陷阱
保护此模式的推理是:“如果两个线程计算并存储相同的值是可以的。结果是幂等的,不会发生数据损坏。”
此推理在正确性上是正确的,但在成本上是致命的。
在缓存未命中时有 1,000 个线程:1,000 个线程各自执行 expensiveCompute(key)。如果 expensiveCompute 查询数据库,则会同时发出 1,000 次数据库查询。如果它调用外部服务,则会同时发出 1,000 次 HTTP 请求。系统虽然能产生正确结果,但却会因生成这些结果的成本而崩溃。
三个触发因素
当一个缓存键同时在多个线程中从热变为冷时,就会触发“惊群效应”:
冷启动: 服务重启后缓存为空。首波请求到来时,所有键均未命中,所有计算同时执行。
服务重启: 滚动重启会重置各实例的缓存。流量重新分配到冷实例上。
TTL 过期: 高流量键过期。N 个线程同时检查、同时未命中、同时在第一个线程存储结果前执行计算。
所有三个触发器:均与流量峰值相关。缓存冷启动时,流量峰值触发 herd 效应。时机最差。
Elasticsearch EnrichCache 示例
Elasticsearch EnrichCache:文档注释写着“为简化起见故意不加锁……在竞态条件下重复 put 相同键值是可接受的”。在每秒 10,000 条文档且 enrich cache 冷启动时:全部 10,000 个请求同时命中 enrich 索引。该索引本为偶发查询设计,却面临 10,000 个并发查询,集群因此失稳。
幂等性推理:代码注释中的结论在逻辑上是正确的,但在每秒 10,000 条文档的场景下却带来灾难性后果。
与 MOAD-0001 的耦合
MOAD-0001(沉积缺陷)在高吞吐系统中制造 O(N²) 瓶颈。修复 MOAD-0001(将 O(N²) 降至 O(N))后,工作站不再受阻。更快的吞吐量将更多请求推向下游。此前因 MOAD-0001 瓶颈而受保护的下游缓存,现在迎来相关流量峰值。MOAD-0005 在此前从未触发过的缓存中被触发。修复一个 MOAD,埋下另一个 MOAD。
幂等性陷阱的误区
Elasticsearch 的注释体现了将严谨工程推理应用于错误问题。幂等性:值得分析的真实属性。陷阱:仅停留在正确性分析,而未继续评估成本。
computeIfAbsent 与 singleflight
修复方法:将检查与计算操作原子化。只有一个线程执行计算,其余线程等待该结果。
Java: computeIfAbsent
// 缺陷:四个非原子步骤
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
// 修复:原子性检查并计算
return cache.computeIfAbsent(key, k -> expensiveCompute(k));
computeIfAbsent:若键不存在,则恰好计算一次、存储并返回。所有其他线程在调用同一键的 computeIfAbsent 时会等待首次计算完成。不会出现 N 次重复计算,也不会出现惊群效应。
Go: singleflight.Group
var g singleflight.Group
func getOrCompute(key string) (Value, error) {
v, err, _ := g.Do(key, func() (interface{}, error) {
return expensiveCompute(key)
})
return v.(Value), err
}
singleflight:如果某个 key 的计算正在进行,所有请求该 key 的调用者都会等待并共享同一个结果。一次计算,N 个等待者,共享一个结果。'flight' 抽象:对正在进行的请求进行去重。
锁 vs singleflight
朴素的 per-key 锁会串行化:线程 1 计算,线程 2 等待,线程 3 等待。线程 1 完成后,线程 2 进入并检查缓存(命中)。线程 3 进入并检查缓存(命中)。共 N-1 次锁获取与缓存读取。
singleflight 去重:线程 1 计算,线程 2 到 N 都等待线程 1 的结果。没有额外的锁获取。没有额外的缓存读取。一次计算、一个结果,分发给 N 个等待者。比 per-key 锁更少的操作。
两者都能防止惊群。singleflight 更彻底地避免了冗余工作。
重写该模式
将修复应用于具体场景。
// 高流量 Java 服务中的用户资料缓存
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId); // 昂贵操作:50ms 数据库查询
profileCache.put(userId, profile);
}
return profile;
}
每天早上 2 点服务重启。早上 8 点,10,000 名用户同时请求他们的个人资料。