un

guest
1 / ?
back to lessons

四步骤模式

缺陷存在于四个顺序的非原子操作中:

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

识别缺陷,命名触发时刻,并使用 computeIfAbsent 进行重写。