English· Español· Deutsch· Nederlands· Français· 日本語· ქართული· 繁體中文· 简体中文· Português· Русский· العربية· हिन्दी· Italiano· 한국어· Polski· Svenska· Türkçe· Українська· Tiếng Việt· Bahasa Indonesia

un

访客
1 / ?
返回课程列表

四步模式

缺陷存在于四个顺序、非原子的步骤中:

// 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 名用户同时请求他们的个人资料。

识别缺陷,说明触发时机,并使用 computeIfAbsent 重写。