2. 缓存一致性模型:强一致性、弱一致性、最终一致性、读写一致性模型对比

在分布式系统与缓存治理中,一致性模型决定了缓存与数据源之间允许的偏差程度。选择错误的模型会导致业务逻辑错误或性能灾难。本节从一致性强度与性能代价两个维度,对比四种核心模型。

2.1 一致性模型全景对比

模型 核心约束 读延迟 写吞吐 适用场景
强一致性 写操作完成后,所有后续读立即看到最新值 高(需同步验证) 低(串行化写入) 金融交易、库存扣减、分布式锁
弱一致性 不保证写后读可见性,只保证最终会收敛 低(直接读缓存) 高(异步批量写) 日志聚合、监控指标、社交动态
最终一致性 保证在没有新写入后,所有副本最终一致 低(允许短暂脏读) 高(异步复制) DNS、CDN、用户资料缓存
读写一致性 写后立即读自己的写,其他读可延迟 中(会话级验证) 中(写后标记) 用户编辑后刷新、购物车操作
性能调优启示: 强一致性并非“更好”,而是“更贵”。在缓存治理中,80% 的业务场景可以通过读写一致性或最终一致性获得 10 倍以上的吞吐提升。

2.2 强一致性模型

强一致性要求缓存与数据库之间保持线性一致性:任何读操作必须返回最近一次写操作的结果。实现方式通常为同步双写或分布式事务。

  • 实现路径: 写操作先锁数据库 → 更新数据库 → 失效或更新缓存 → 释放锁;读操作必须从主副本读取。
  • 性能代价: 每次写操作增加 1~2 次 RTT(往返时延),并发写时锁竞争导致吞吐下降 50%~80%。
  • 治理策略: 仅在关键路径使用,如支付状态、库存扣减;非关键字段降级为最终一致性。
实战案例: 某电商库存系统使用 Redis 分布式锁 + 数据库事务实现强一致性,单机 QPS 从 8000 降至 1200。优化方案:将库存预扣与订单确认分离,预扣阶段使用读写一致性,确认阶段使用强一致性。

2.3 弱一致性与最终一致性

弱一致性不提供任何写后读保证;最终一致性是弱一致性的一个子集,保证在没有新写入时,所有副本最终达到相同状态。

  • 最终一致性实现: 异步复制、消息队列、定期全量同步。
  • 窗口期: 从写入到所有副本一致的时间称为“不一致窗口”,通常为毫秒到秒级。
  • 性能优势: 写操作无需等待副本确认,吞吐可提升 5~20 倍。
避坑指南: 最终一致性不适用于“读后写”依赖场景。例如:用户先读取优惠券状态,再使用优惠券下单。如果缓存尚未同步,会导致超发。此时应使用读写一致性或乐观锁。

2.4 读写一致性模型

读写一致性(Read-Your-Writes Consistency)保证:写入者自己后续的读操作一定能看到自己的写入,但其他用户可能看到旧值。这是用户体验与性能的最佳折中。

  • 实现方式: 写操作后,在会话或请求上下文中标记“已写”标识;读操作先检查标识,若命中则强制读数据库或主缓存。
  • 典型应用: 用户修改个人资料后刷新页面、提交订单后查看订单详情。
  • 性能特征: 仅影响写入者自身,不影响其他用户;写后第一次读增加一次验证开销,后续读恢复正常。

2.5 模型选择决策流程

以下 SVG 图展示了从业务需求到一致性模型选择的决策路径:

缓存一致性模型选择决策树 业务读请求 是否必须立即看到自己的写入? 是 读写一致性 否 其他用户可见性要求? 必须立即 强一致性 可短暂延迟 最终一致性 决策要点: 1. 读写一致性:用户自身写后读,其他用户可延迟(推荐默认选择) 2. 强一致性:所有用户必须立即看到最新值(性能代价最高) 3. 最终一致性:允许短暂不一致窗口(吞吐最高,需业务容忍) 4. 弱一致性:无任何保证(仅用于非关键日志/监控场景)

2.6 模型性能代价量化对比

以下表格基于典型 Redis + MySQL 架构,对比不同一致性模型在读写混合场景下的性能指标:

模型 读延迟 (P99) 写延迟 (P99) 最大吞吐 (QPS) 缓存命中率 数据不一致概率
强一致性 8~15 ms 20~50 ms 1,200 85% < 0.001%
读写一致性 2~5 ms 5~15 ms 5,800 92% 0.1% (仅其他用户)
最终一致性 1~3 ms 2~8 ms 12,000 95% 1%~5% (窗口期内)
弱一致性 < 1 ms < 2 ms 25,000+ 98% 不可预测
性能调优结论: 从强一致性切换到读写一致性,吞吐可提升约 4.8 倍;切换到最终一致性可提升约 10 倍。在缓存治理中,应优先使用读写一致性作为“安全默认值”,仅在明确业务容忍度时使用最终一致性。

2.7 读写一致性实现示例

以下代码展示基于会话标记的读写一致性实现:

// 写操作:更新用户资料后标记会话
public void updateUserProfile(String userId, UserProfile profile) {
    // 1. 更新数据库
    userDao.update(profile);
    // 2. 失效缓存
    cache.delete("user:" + userId);
    // 3. 标记当前会话:该用户有未同步的写入
    CurrentSession.setAttribute("write_flag:" + userId, true);
}

// 读操作:检查会话标记决定读取源
public UserProfile getUserProfile(String userId) {
    // 1. 检查当前会话是否有写标记
    boolean hasWritten = CurrentSession.getAttribute("write_flag:" + userId);
    if (hasWritten) {
        // 2. 强制读数据库并回填缓存
        UserProfile profile = userDao.getById(userId);
        cache.set("user:" + userId, profile, 300);
        CurrentSession.removeAttribute("write_flag:" + userId);
        return profile;
    }
    // 3. 正常读缓存
    UserProfile profile = cache.get("user:" + userId);
    if (profile == null) {
        profile = userDao.getById(userId);
        cache.set("user:" + userId, profile, 300);
    }
    return profile;
}
优化点: 写标记可以使用本地线程变量(ThreadLocal)或 Redis 会话存储。标记过期时间建议设置为 5~10 秒,避免内存泄漏。对于高并发场景,可以使用布隆过滤器替代精确标记,减少内存占用。

2.8 模型选择与性能调优原则

  • 原则一: 按数据维度拆分,而非按接口维度。同一接口的不同字段可以混合使用不同模型。
  • 原则二: 读写一致性作为默认模型,覆盖 90% 的用户操作场景。
  • 原则三: 强一致性仅用于“写后立即读且影响后续决策”的关键数据,如支付状态、库存扣减。
  • 原则四: 最终一致性用于“读多写少、可容忍短暂不一致”的数据,如用户昵称、商品描述。
  • 原则五: 弱一致性仅用于日志、监控、推荐等非关键路径,且必须设置 TTL 兜底。
常见误区: 不要试图在缓存层实现全局强一致性。缓存本质是加速层,强一致性应通过数据库层的事务 + 分布式锁实现,缓存只做失效或异步同步。否则会陷入“缓存与数据库谁做主”的死循环。