4. 读写一致性:读己之写一致性、单调读一致性、单调写一致性

在分布式缓存与数据库的混合架构中,读写一致性定义了客户端在多次读写操作中观察数据状态的行为约束。不同于强一致性要求全局线性化,读写一致性关注的是单个客户端视角下的数据可见性与顺序性。本章聚焦三种最核心的读写一致性模型:读己之写一致性、单调读一致性、单调写一致性。

4.1 三种一致性模型的定义与对比

一致性模型 核心约束 典型场景 违反后果
读己之写一致性 客户端写入后,后续读操作一定能看到自己的写入 用户提交表单后刷新页面看到自己的提交内容 用户看到“操作失败”但实际已写入
单调读一致性 客户端多次读取同一数据,不会看到旧版本回退 分页查询、消息列表滚动 用户看到数据“忽前忽后”或重复出现
单调写一致性 客户端的写操作按提交顺序被系统接受 库存扣减、状态机更新 后写入的覆盖先写入的,导致数据错乱
测试要点:这三种模型均属于“客户端侧一致性”,不要求全局所有节点一致,只保证单个客户端在特定会话或连接中的观察顺序。测试时需模拟客户端切换后端节点、网络重连、缓存失效等边界条件。

4.2 读己之写一致性(Read-Your-Writes Consistency)

读己之写一致性保证:客户端在写入数据后,后续的读操作一定能读取到该写入的值(或更新的值)。在缓存+DB架构中,写入通常先更新DB,再失效或更新缓存。如果读请求落到尚未收到失效消息的缓存副本上,就可能读到旧值。

实现策略:
  • 会话粘滞(Session Sticky):将同一客户端的读写请求路由到同一缓存节点。
  • 写入标记(Write Marker):写入时在客户端会话中记录时间戳或版本号,读请求携带该标记,缓存节点检查是否已同步。
  • 强制穿透:写入后指定时间内,读请求强制绕过缓存直接读取DB。
读己之写一致性:写入后读请求的路径选择 客户端 写入 写入代理 写DB 数据库 失效/更新缓存 缓存节点 读请求 读决策器 读缓存 穿透读DB 数据库 读决策逻辑 1. 检查会话中是否有未确认的写入标记 2. 有标记 → 强制穿透读DB 3. 无标记 → 正常读缓存

4.3 单调读一致性(Monotonic Read Consistency)

单调读一致性保证:客户端多次读取同一数据项时,不会出现“时间倒流”——即不会先读到新值,再读到旧值。这在缓存节点间异步同步、或客户端连接在不同副本间切换时尤其容易违反。

常见违反场景:客户端A先连接到缓存节点1(已同步最新数据),读到值v2;随后连接断开,重连到缓存节点2(尚未收到同步),读到旧值v1。用户感知到数据“回退”。
单调读一致性:正常 vs 违反时序 ✅ 正常(单调读) 客户端 t1:读v2 节点1(v2) t2:读v2 节点2(v2) ❌ 违反(非单调读) 客户端 t1:读v2 节点1(v2) t2:读v1(回退!) 节点2(v1) 单调读保证手段 • 客户端缓存版本号 • 读请求携带最后 已知版本号 • 节点拒绝提供 低于版本的数据

4.4 单调写一致性(Monotonic Write Consistency)

单调写一致性保证:同一客户端的写操作按照提交顺序被系统接受并生效。在分布式缓存+DB架构中,如果写操作被路由到不同的后端节点,或者缓存失效与DB更新之间存在竞态,就可能出现写顺序错乱。

典型违反案例:
  • 客户端先执行写操作W1(设置库存为50),再执行写操作W2(设置库存为30)。
  • 由于网络分区或路由不一致,W2先到达DB并被应用,W1后到达并覆盖W2。
  • 最终库存为50,而非预期的30,导致超卖或数据不一致。

4.5 三种一致性的测试设计

测试维度 读己之写 单调读 单调写
核心断言 写入后立即读取,值 >= 写入值 多次读取值单调非递减 最终状态等于最后一次写入
触发条件 写入后切换连接/节点 读请求落在不同步的副本上 写请求乱序到达后端
测试工具 会话模拟 + 连接切换 多节点读 + 版本号追踪 并发写 + 顺序标记校验
通过标准 100% 写入后读可见 0 次版本回退 最终状态 = 最后提交值

4.6 测试代码示例:单调读一致性验证

// 单调读一致性测试:模拟客户端在不同缓存节点间切换
@Test
public void testMonotonicReadConsistency() {
    // 初始化:缓存节点1有v2,节点2有v1(模拟同步延迟)
    cacheNode1.put("key", "v2");
    cacheNode2.put("key", "v1");
    
    String clientId = "client-001";
    String lastReadValue = null;
    
    // 第一次读:路由到节点1
    String value1 = readWithRouting(clientId, "key", "node1");
    assertNotNull(value1);
    lastReadValue = value1;  // v2
    
    // 模拟连接切换:第二次读路由到节点2
    String value2 = readWithRouting(clientId, "key", "node2");
    
    // 单调读断言:第二次读不能比第一次读的值旧
    assertTrue(
        "违反单调读一致性:读到旧版本回退",
        compareVersions(value2, lastReadValue) >= 0
    );
    
    // 如果违反,说明系统未实现单调读保证
    // 预期行为:节点2应拒绝服务或返回最新版本
}
测试陷阱:单调写一致性测试中,不能仅依赖最终结果校验。必须记录每个写操作的提交顺序和实际生效顺序。推荐使用逻辑时钟或单调递增序列号标记每个写操作,在测试报告中输出乱序详情。

4.7 三种一致性的实现权衡

一致性模型 实现难度 性能影响 推荐场景
读己之写 低(会话粘滞即可) 低 用户面板、表单提交
单调读 中(需版本追踪) 中(额外元数据) 消息列表、Feed流
单调写 高(需全局排序) 高(写串行化) 库存、账户余额