3. 缓存同步的常见数据一致性模型:最终一致性、读己之写、强同步的取舍

在多模块缓存同步场景中,选择哪种数据一致性模型直接影响系统的吞吐量、延迟和用户体验。不同模块对一致性的敏感度不同——订单模块要求严格一致,而商品推荐模块可以容忍短暂不一致。本章从原理、流程和实现代价三个维度剖析三种主流一致性模型,并给出模块边界内的取舍策略。

3.1 三种模型的核心定义

模型 核心保证 典型延迟 适用模块
最终一致性 如果没有新的更新,所有副本最终会达到一致状态;中间状态允许读旧数据。 毫秒~秒级(异步) 商品搜索、推荐、用户行为统计
读己之写 同一客户端(或会话)在写入后立即能读到自己的写入;其他客户端可能读到旧数据。 毫秒级(会话标记) 用户个人中心、购物车、订单提交
强同步 所有读操作都能看到最新的写入,全局线性一致。 毫秒~几十毫秒(同步等待) 支付、库存扣减、分布式锁

3.2 模型取舍的底层逻辑

选择一致性模型本质是在 可用性 (A)、延迟 (L) 和 一致性 (C) 之间做三角权衡。根据 CAP 与 PACELC 理论,在分区发生时,系统必须在一致性和可用性之间选择;在正常运行时,需要在延迟和一致性之间权衡。

  • 最终一致性:优先保证可用性和低延迟,异步复制,适合对时效性不敏感的批量读场景。
  • 读己之写:在最终一致性基础上,通过会话粘性(Session Stickiness)或版本戳为同一客户端提供“读写因果”保证,牺牲少量全局一致性换取用户体验。
  • 强同步:所有写入必须同步到所有副本或法定数量副本后才返回,线性一致性最高,但可用性受副本数量和网络延迟影响。
实践要点: 在微服务模块边界处,优先对“写后立即读”的接口使用读己之写模型;跨模块的最终一致性可以通过补偿事务或对账任务兜底;强同步尽量限定在单个模块内部,避免跨模块同步放大延迟。

3.3 三种模型数据流对比

下面的 SVG 展示了从“客户端写请求”到“客户端读返回”的完整数据流,突出同步阶段的核心差异。

三种一致性模型数据流对比 最终一致性 客户端写请求 主缓存写入 异步复制,不等待 副本后台延迟同步 客户端读请求 可能返回旧数据 高可用 · 低延迟 · 最终一致 适合非实时性场景 读己之写 (RYW) 客户端写请求 主缓存写入 + 会话标记 会话版本跟踪 同一会话读主节点 客户端读请求(同会话) 返回自己的写入 会话内一致 · 体验最佳 适合用户个性化模块 强同步 客户端写请求 主缓存写入 同步所有副本并确认 法定数量写入完成才返回 客户端读请求 返回最新数据 线性一致性 · 延迟较高 适合支付、库存、分布式锁

3.4 决策矩阵:模块场景 → 模型映射

下表从 写入频次、读写比例、容忍延迟 和 一致性敏感度 四个维度给出选择建议:

模块场景 写入频次 读写比 容忍延迟 推荐模型
商品搜索索引 低(定时更新) 1:100 秒级 最终一致性
用户购物车 中(用户操作) 1:5 < 200ms 读己之写
订单创建与查询 高(提交后查询) 1:10 < 50ms 读己之写
库存扣减 高(并发扣减) 1:3 < 30ms 强同步
分布式配置中心 极低(手动变更) 1:1000 秒级 最终一致性 + 推送

3.5 代码实现对比:三种模型的写入逻辑

以下三段伪代码展示了在同一个缓存抽象层中,通过切换同步策略实现不同一致性模型。

最终一致性写入:

// EventualConsistencyCache.java
public CompletableFuture<Boolean> set(String key, Object value) {
    // 1. 写入主节点(本地内存或 Redis 主节点)
    primaryCache.put(key, value);
    
    // 2. 发送异步同步事件,不等待副本确认
    asyncSyncQueue.publish(new CacheSyncEvent(key, value, version));
    
    // 3. 立即返回成功
    return CompletableFuture.completedFuture(true);
}

读己之写写入:

// ReadYourWritesCache.java
public boolean set(String key, Object value, String sessionId) {
    // 1. 写入主缓存
    primaryCache.put(key, value);
    
    // 2. 在会话存储中记录本次写入的版本时间戳
    sessionVersionStore.put(sessionId, key, System.nanoTime());
    
    // 3. 同步到副本(异步),但读己之写保证由会话标记兜底
    asyncSyncQueue.publish(new CacheSyncEvent(key, value, version));
    
    return true;
}

// 读取时检查会话版本,决定从主节点还是副本读取
public Object get(String key, String sessionId) {
    Long writeVersion = sessionVersionStore.get(sessionId, key);
    if (writeVersion != null) {
        // 该会话有未同步的写入,直接从主节点读取
        return primaryCache.get(key);
    } else {
        // 没有归属写入,从副本读取降低主节点压力
        return replicaCache.get(key);
    }
}

强同步写入:

// StrongConsistencyCache.java
public boolean set(String key, Object value, int quorum) {
    // 1. 写入主节点
    primaryCache.put(key, value);
    
    // 2. 并行写入所有副本,收集确认
    AtomicInteger ackCount = new AtomicInteger(1); // 主节点自身算 1 个确认
    CountDownLatch latch = new CountDownLatch(replicas.size());
    
    for (CacheReplica replica : replicas) {
        executor.submit(() -> {
            if (replica.put(key, value)) {
                ackCount.incrementAndGet();
            }
            latch.countDown();
        });
    }
    
    // 3. 等待法定数量确认(超时时间 500ms)
    boolean finished = latch.await(500, TimeUnit.MILLISECONDS);
    if (ackCount.get() >= quorum) {
        return true;   // 写入成功,所有后续读都能看到
    } else {
        // 回滚或标记失败
        primaryCache.delete(key);
        return false;  // 未达到法定确认,告知调用方失败
    }
}
⚠ 强同步陷阱: 当 quorum 设置过大(例如 replicas 总数 5,quorum=5),一旦某个副本宕机或网络抖动,写入会持续超时失败,导致模块接口可用性下降。生产环境中建议 quorum = N/2 + 1(多数派),并在超时后提供降级策略(如切换到读己之写模式)。

3.6 模块边界隔离与模型选择

在多模块架构中,每个模块应独立选择其缓存一致性模型,并通过明确的模块边界 API 文档对外暴露保证等级。以下是一个模块边界描述的例子:

// ============================================================
// 模块: InventoryService (库存服务)
// 缓存一致性保证: 强同步
// 对外契约:
//   - deStock(itemId, quantity) 返回 true 表示库存已扣减,
//     所有后续读库存请求(包括跨模块)均能看到最新库存。
//   - 返回 false 表示扣减失败,库存未变更。
// 降级策略:
//   - 当强同步超时 > 200ms 时,自动切换为读己之写模式,
//     并在响应头中附加 X-Consistency: read-your-writes 标记。
// ============================================================

这种方式让调用方(其他模块)明确知道当前返回的数据一致性等级,避免因缓存不一致导致跨模块数据错误。

3.7 常见错误与避坑清单

  • 误用最终一致性处理支付回调: 支付状态变更后,库存和订单模块必须立即读到最新状态,应使用强同步或至少读己之写,否则会导致超卖。
  • 忽略会话粘性丢失: 读己之写依赖会话标记,如果客户端请求在模块间轮询(例如网关层负载均衡切换节点),会话标记丢失会导致读到旧数据。解决方案:将会话标记存入全局缓存(如 Redis)而非本地内存。
  • 强同步的写放大: 每次写入等待所有副本确认,写入吞吐量随副本数增加而下降。当副本数 > 3 时,考虑使用多数派 quorum 而非全量确认。
  • 混淆“最终一致性”与“无一致性”: 最终一致性要求副本最终达到一致,需要引入版本向量或 CRDT 来解决冲突,而不是简单地丢弃冲突。
💡 最佳实践: 在同一模块中,可以针对不同 key 使用不同模型。例如使用 key 前缀区分:stock:* 走强同步,product:* 走最终一致性。缓存客户端通过配置路由规则即可实现,无需为每个模型单独部署缓存集群。

3.8 性能指标参考

模型 P99 写延迟 (ms) P99 读延迟 (ms) 最大吞吐 (ops/s) 副本数影响
最终一致性 0.8 ~ 1.5 0.5 ~ 1.2 120,000+ 几乎无影响
读己之写 1.0 ~ 2.0 0.8 ~ 1.8 (会话命中走主节点) 80,000+ 低(仅会话标记写入)
强同步 (quorum=2) 3.0 ~ 8.0 0.5 ~ 1.2 35,000+ 中等(等待多数派)
强同步 (quorum=3) 5.0 ~ 15.0 0.5 ~ 1.2 18,000+ 高(等待全部或多数)

测试条件: 3 节点 Redis 集群,主节点写 + 2 副本同步,千兆网络,value 大小 1KB,客户端连接池 50。

以上数据表明,强同步的写入代价远高于其他两种模型,且随副本数量增加近似线性上升。在模块边界设计时,应将高频写入的 key 尽量分配到最终一致性或读己之写模型,仅在数据一致性出现争议时(如支付、库存)才使用强同步。

决策口诀: “读多写少用最终,写后即读用己写,金钱库存强同步,模块边界写清楚。”