bigywater.tech
分布式事务 · 25

线性一致与最终一致的实验对比

线性一致和最终一致到底差在哪?读 follower 会读到多旧的数据?把读请求变成线性一致要付出多少延迟?最终一致“最终”要多久?这篇用真实的 3 节点 etcd 和 Redis 主从,加上一个手写的线性一致性检查器,把它们的差别量出来。

101 / 300 → 0 / 300
无额外延迟时,etcd follower 上可串行化读读到旧值的次数 → 线性一致读读到旧值的次数
0.91 → 93.45 ms
加 20 ms 的 peer 延迟后,线性一致读的延迟中位数(从 0.91 ms 涨上来)
46 → 100 / 100
检查器判定为不可线性化的历史段数:可串行化读,无延迟 → 加 20 ms 延迟

读 follower

实测 · 本机执行输出
  条件                 | 可串行化读读到旧值的次数 | 可串行化读延迟中位数 | 线性一致读读到旧值的次数 | 线性一致读延迟中位数 | 线性一致读 p99
  -------------------+--------------+------------+--------------+------------+----------
  网络无额外延迟            | 101 / 300    | 0.35 ms    | 0 / 300      | 0.91 ms    | 7.0 ms   
  peer 通信每个方向 +20 ms | 300 / 300    | 0.57 ms    | 0 / 300      | 93.45 ms   | 102.2 ms 
  可串行化读不需要问 leader,所以快,但读到的是这个 follower 当前应用到的状态——可能比刚确认的写入旧;线性一致读要先向 leader 确认当前的提交位置再读,保证读到的不比任何已完成的写入旧,代价是多一次与 leader 的往返

可串行化读只看 follower 自己的状态,快但可能比刚确认的写入旧;线性一致读要向 leader 确认,保证不旧,延迟随 leader 的往返上升。

异步复制

实测 · 本机执行输出
  条件                          | 异步复制:读 replica 读到旧值的次数 | 写 + 读的总耗时(中位数) | 写后 WAIT 1 0 再读:读到旧值的次数 | 写 + WAIT + 读的总耗时(中位数)
  ----------------------------+------------------------+----------------+------------------------+----------------------
  无额外延迟                       | 73 / 300               | 0.14 ms        | 0 / 300                | 0.22 ms              
  master ↔ replica 每个方向 +5 ms | 300 / 300              | 0.21 ms        | 0 / 300                | 12.94 ms             
  每个方向 +20 ms                 | 300 / 300              | 0.17 ms        | 0 / 300                | 48.24 ms             
  Redis 的复制是异步的:master 回复写入时 replica 往往还没收到。WAIT 1 0 让写入等到 1 个 replica 确认,之后读这个 replica 就不会旧,代价是每次写多一个往返(不是线性一致:WAIT 只保证到这个 replica 已确认,别的读者和别的 replica 不在保证里)

Redis master 回复写入时 replica 常常还没收到,写后读 replica 常是旧值;WAIT 1 0 能让这个 replica 不旧,但不是线性一致。

收敛时间

实测 · 本机执行输出
  条件          | 收敛时间中位数  | p99      | 最大      
  ------------+----------+----------+---------
  无额外延迟       | 0.21 ms  | 0.38 ms  | 0.48 ms 
  每个方向 +5 ms  | 6.38 ms  | 6.50 ms  | 6.60 ms 
  每个方向 +20 ms | 24.29 ms | 25.25 ms | 25.42 ms
  “最终”有多久,取决于复制延迟:无延迟时是亚毫秒到毫秒,链路延迟越大越长;没有人保证它有上限(链路断了就一直不收敛)

最终一致的“最终”约等于复制链路的延迟,没有上限保证。

线性一致性检查器

实测 · 本机执行输出
  一段不可线性化的历史(每段开头先写 init、再写 v0,两次写入在其它操作开始之前就已经确认;下面是之后的操作,按开始时间排序,时间相对第一个操作,ms;读到 init 的读,读到的比已经确认的 v0 还旧):
       0.00 →    0.79  读 init
       0.80 →    1.38  读 init
       6.01 →    6.42  读 init
       8.81 →    9.78  读 init
       8.83 →    9.93  读 init
       9.31 →   56.67  写 w1
      10.65 →   62.75  写 w2
      12.85 →   65.86  写 w3
      15.05 →  146.69  写 w4
      62.28 →  146.56  写 w5
      73.20 →   73.78  读 v0
     152.86 →  197.47  写 w6
  条件                 | 读的方式               | 历史段数 | 不可线性化的段数
  -------------------+--------------------+------+---------
  网络无额外延迟            | 读走 follower 的可串行化读 | 100  | 46      
  网络无额外延迟            | 读走 follower 的线性一致读 | 100  | 0       
  peer 通信每个方向 +20 ms | 读走 follower 的可串行化读 | 100  | 100     
  peer 通信每个方向 +20 ms | 读走 follower 的线性一致读 | 100  | 0       
  线性一致读下 100 段历史全部可以找到合法的总顺序;可串行化读在 follower 落后(这里靠加延迟制造)时,会出现“某个写已经确认、之后开始的读却读到更旧的值”的历史,检查器找不到任何合法的顺序

记录每个操作的开始和结束时间,找不到合法总顺序的历史就不是线性一致的;线性一致读 0 段违反,可串行化读大量违反。

分步演示

线性一致与最终一致
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用真实的 3 节点 etcd 3.7.2(HTTP/JSON 网关长连接读写;peer 通信经过我加的延迟代理,延迟是对每个方向各加)、一主一从的 Redis 8.10.2(异步复制,replica 到 master 的连接也经过同样的代理)和我自己写的线性一致性检查器(单个寄存器、初值 None,深度优先 + 记忆化,历史只有 13 个操作左右);Redis 实验先预热复制链路并等偏移量对齐;每段 etcd 历史由 4 个线程各做 3 次操作,写走 leader、读走两个 follower
    • 没有验证:多个 key / 事务下的线性一致性(检查器只处理单个寄存器);etcd 的 ReadIndex 与租约读的差别(这里只比较了默认读和 serializable);Redis Sentinel / Cluster 下的故障转移与丢写;WAIT 的 timeout 非 0 的行为;因果一致(下一篇);我调试过程中发现我自己的延迟代理曾因给连接设了 2 秒超时而悄悄断开空闲连接,已修正,最终数字是修正后的结果
    京ICP备2026067502号-1京公网安备11010502063487号