四步骤模式
缺陷存在于四个顺序的非原子操作中:
// 缺陷
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
第1步:检查缓存。第2步:miss。第3步:计算。第4步:存储。四个步骤:非原子。第1步与第4步之间,任何数量的线程可以执行第1步,并看到null。
幂等性陷阱
保护这个模式的推理:'两条线程计算并存储相同的值完全OK。结果是幂等的。不会发生数据损坏。'
这种推理:关于正确性是正确的,但关于成本是致命的。
在1,000个线程的情况下,在缓存miss时:1,000个线程各自执行expensiveCompute(key)。如果expensiveCompute查询数据库,那么会同时触发1,000个数据库查询。如果它调用外部服务,那么会同时触发1,000个HTTP请求。结果正确但成本高昂的系统在生产结果时崩溃。
三个触发器
一群雷鸣的动物在缓存关键字从温到冷同时跨越许多线程时触发:
冷启动:服务重启时缓存为空。第一个请求波浪:所有关键字都miss。所有线程同时计算。
服务重启:滚动重启在实例之间重置缓存。流量重新分配到冷实例。
TTL过期:高并发关键字过期。N个线程都检查,都miss,在第一个线程存储结果之前所有线程同时计算。
三个触发器:与流量激增相关。群雷在流量高峰时和缓存冷却时触发。最糟糕的时间。
Elasticsearch EnrichCache 示例
Elasticsearch EnrichCache:文档注释读作'为了简单性,故意不上锁...在竞争条件下重放相同的key/value完全OK'。在10,000个文档/秒的情况下,当enrich缓存是冷的时,所有10,000个请求都会同时访问enrich索引。设计用于偶尔查询的enrich索引面临1,000个并发查询。集群失去稳定性。
幂等性推理:在代码注释中是正确的,但在1,000个文档/秒的情况下是灾难性的。
与MOAD-0001的耦合
MOAD-0001 (地层缺陷) 在高吞吐量系统中创建了 O(N²) 的瓶颈。修复 MOAD-0001 (O(N²) 到 O(N)) 可以解除该工作站的瓶颈。更快的吞吐量会向下游发送更多请求。之前由 MOAD-0001 瓶颈保护的下游缓存现在接收到相关的流量激增。MOAD-0005 在从未触发过它的缓存中触发。修复一个 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: 如果 key 不存在,则仅计算一次,存储,返回。对于同一 key 的所有其他线程调用 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' 抽象: 在飞行中的请求进行去重。
Lock vs singleflight
一个简单的 per-key 锁会序列化:线程 1 计算,线程 2 等待,线程 3 等待。线程 1 完成后,线程 2 进入并检查缓存(命中)。线程 3 进入并检查缓存(命中)。N-1 个锁定获取和缓存读取。
singleflight 会去重:线程 1 计算,线程 2 通过 N 等待线程 1 的结果。没有单独的锁定获取。没有单独的缓存读取。一个计算,一个结果,分发给 N 个等待者。比 per-key 锁执行的操作更少。
它们都防止了拥堵。singleflight 更完全地防止了冗余工作。
重写模式
将修复应用于一个具体的场景。
// 在高并发 Java 服务中实现用户-profile 缓存
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId); // 贵重: 50ms DB 查询
profileCache.put(userId, profile);
}
return profile;
}
服务每天凌晨2点重启。第二天早上8点,10000名用户同时请求他们的用户资料。