16.3 顺序一致性
一句话
顺序一致(Lamport, 1979)要求所有操作存在一个全局全序,且每个进程看到的顺序与自己的程序顺序一致;它不要求这个全序与实时时间一致,所以允许读到旧值——这是一条比线性一致便宜得多的路。
定义:只约束「顺序」,不约束「时间」
历史 H 顺序一致,当且仅当存在一个全序 S,满足:
S尊重每个进程的程序顺序(同一进程内先发起的操作在前);- 按
S依次执行,每个读的返回值与单副本语义一致。
和 16.2 线性一致性 只差一条:S 不需要尊重实时顺序 <_H。操作 A 的返回早于操作 B 的发起,只说明它们在物理上不重叠;顺序一致不要求 A 在 S 里排在 B 前。
线性一致:S 必须尊重实时顺序 → 写完返回后,读必须看见(或看见更新的写)
顺序一致:S 只尊重程序顺序 → 写可以「晚一点」才在全局顺序里生效
用同一段历史对比(判定器输出见 16.2):
| 历史 | 实时顺序 | 程序顺序 | 线性一致 | 顺序一致 |
|---|---|---|---|---|
| H1 写完成后读,读到新值 | 满足 | 满足 | ✅ | ✅ |
| H2 写完成后仍读到旧值 | 违反 | 满足 | ❌ | ✅ |
| H3 同进程读 1 再读 0 | 满足 | 违反 | ❌ | ❌ |
| H4 两进程看到相反顺序 | 满足 | 满足 | ❌ | ❌ |
| H5 并发写,两个读各看一个 | 违反 | 满足 | ❌ | ✅ |
H2 是全章的钥匙:从物理上看,写已经返回、读才开始,读到旧值「不可思议」;但顺序一致只要求存在一个全序,把这次读排在写之前即可——所有进程都同意「读发生在写之前」,世界的顺序就自洽了,只是这个顺序与墙上时钟不一致。
🔑 顺序一致的核心是「所有进程对操作顺序有一致的看法」,而不是「这个看法与真实时间一致」。
它允许什么、不允许什么
| 行为 | 顺序一致 | 原因 |
|---|---|---|
| 读到别的进程的旧值 | ✅ 允许 | 全序可以把读排在写之前 |
| 读到自己写之前的旧值 | ❌ 不允许 | 程序顺序要求自己的写排在读之前 |
| 两个进程看到不同的写顺序 | ❌ 不允许 | 必须存在唯一全序 |
| 同一进程读到 1 之后又读到 0 | ❌ 不允许 | 程序顺序 + 单副本语义 |
| 把写「延迟生效」到稍后 | ✅ 允许 | 只要全序一致 |
第三条最容易被忽视:顺序一致是一个全局承诺,不是「每个副本各自有序」就行。两个副本各自按自己的顺序应用写,但顺序不同,就找不到统一的全序,不是顺序一致。
「读到自己的写」这一条值得单独记:在理论模型里,客户端是一个进程,它的写和读都带程序顺序,所以顺序一致天然包含读己之写。工程里客户端可能写到 leader、读走另一个副本,甚至换连接、换设备——这时「一个进程」的假设就破了,才需要显式的会话保证。这也是为什么「顺序一致」在理论上不弱,在工程上却常常不够用。
实现:全序广播 + 本地读
顺序一致的经典实现只有两步:
- 写走全序广播:由一个 sequencer 或共识协议给每个写分配全局序号,所有副本按序号应用;
- 读走本地:从任意副本的本地状态读,不做协调。
客户端 ──写──▶ Sequencer / 共识组 ──序号 1,2,3…──▶ 副本 A
├──▶ 副本 B
└──▶ 副本 C
客户端 ──读──▶ 任意副本(本地状态,可能是旧前缀)
正确性直觉:所有副本按同一序号应用写,所以「副本状态」永远是全局全序的一个前缀;不同副本可能停在不同长度的前缀上,但前缀之间是嵌套的(短的被长的包含)。所有读都可以放进这个全序的对应位置,因此存在一个全局全序——这正是顺序一致的定义。
这个结构带来非常实际的取舍:
| 指标 | 顺序一致(全序广播 + 本地读) | 线性一致(读也确认最新) |
|---|---|---|
| 写延迟 | 1 轮共识(跨地域 1 RTT + 落盘) | 同样 |
| 读延迟 | 本地,µs 级 | 1 RTT(read-index)或租约 |
| 读新鲜度 | 可能落后(复制/应用延迟) | 返回最新已提交值 |
| 分区时 | 写不可用(多数派一侧可写),读可能可用 | 少数派侧读也不可用(无法确认最新) |
| 实现复杂度 | 中(需要全序,但读简单) | 高(读路径要证明新鲜) |
🔑 顺序一致把「协调」全部压到写路径,读路径零协调——这是读多写少系统(配置、元数据、目录)最常用的性价比方案。
工程实例:etcd 的 serializable 读
etcd 的写走 Raft,所有副本按同一日志顺序应用;默认读走 ReadIndex(线性一致),但显式指定 serializable 时,读直接从本地状态返回:
etcdctl get /config/foo --consistency=s # 本地读,可能陈旧,但顺序一致
etcdctl get /config/foo # 默认 ReadIndex,线性一致
因为写是全序的、副本按序应用,本地读返回的是某个前缀——这满足顺序一致,但不满足线性一致。ZooKeeper 的读也类似:写由 ZAB 全序提交,读默认由本地副本服务,可能陈旧;需要新鲜读时要先 sync()。不要把它们宣传成「读也线性一致」。
顺序一致不是可组合的
这是它和线性一致最重要的理论差别,也是工程选型的硬约束。
反例:两个对象 X、Y,初值都是 0,两个进程:
P1: W(X,1) ──▶ W(Y,1)
P2: R(Y)=1 ──▶ R(X)=0
| 看单个对象 | 历史 | 是否顺序一致 | 为什么 |
|---|---|---|---|
| 只看 X | W(X,1) 与 R(X)=0 | ✅ | 全序可以是 R(X)=0 在前、W(X,1) 在后(两个进程各自只有一个操作,程序顺序无约束) |
| 只看 Y | W(Y,1) 与 R(Y)=1 | ✅ | 全序必须是 W(Y,1) 在前、R(Y)=1 在后 |
| 合并看 | 四个操作 | ❌ | 见下 |
合并后的约束链:
P1 程序顺序: W(X,1) < W(Y,1)
P2 程序顺序: R(Y)=1 < R(X)=0
Y 的读值: W(Y,1) < R(Y)=1
X 的读值: R(X)=0 < W(X,1) (读到 0,说明写在它之后)
合并: W(X,1) < W(Y,1) < R(Y)=1 < R(X)=0 < W(X,1) ← 环
出现环,说明不存在统一全序:两个对象各自顺序一致,组合起来却不是。线性一致没有这个问题——每个操作在各自的线性化点生效,所有对象的线性化点可以合并成一个全序,所以线性一致是可组合的(局部性质)。
工程含义很直接:
- 用多个顺序一致的对象拼一个事务/不变量(例如「X 和 Y 必须一起变」),得不到整体顺序一致;
- 要做跨对象不变式,要么用线性一致的对象,要么用事务(可串行化)把它们包起来;
- 反过来,只读单个对象的业务,顺序一致足够,且读便宜得多。
顺序一致 vs 可串行化
这两个词都带「顺序」,但约束的对象不同:
| 维度 | 顺序一致 | 可串行化 |
|---|---|---|
| 约束单位 | 单个操作 | 事务(一组操作) |
| 全序对象 | 所有操作 | 所有事务 |
| 事务内部 | 无原子性保证 | 原子(要么全见,要么全不见) |
| 实时顺序 | 不要求 | 不要求(严格可串行化才要求) |
| 典型问题 | 读到旧值 | 丢失更新、写偏斜、幻读 |
二者不可互相推出:一个可串行化系统可以把事务排成与实时不同的顺序(不满足顺序一致);一个顺序一致的系统不对事务提供原子性(不满足可串行化)。要同时要,得选「严格可串行化」。
反模式
| 反模式 | 后果 |
|---|---|
| 把「写有全序」当成「读也线性一致」 | 本地读陈旧,业务读到旧配置/旧状态 |
| 用顺序一致的对象组合跨对象事务 | 组合后不满足顺序一致(上面的环) |
| 把「每个副本内部有序」当成顺序一致 | 副本间顺序不同,不存在全局全序 |
| 用 FIFO 客户端顺序替代全局顺序 | 只能保证单个客户端的写序,跨客户端无全序 |
| 读走缓存还宣称顺序一致 | 缓存的前缀可能比副本更旧,且不同节点前缀不嵌套 |
| 切主期间读旧 leader | 旧 leader 的本地状态可能是更短的前缀,读到更旧的值 |
| 把顺序一致当「最终一致 + 快一点」 | 顺序一致需要全序广播(共识),成本远高于最终一致 |
自检
Q1 顺序一致允许「写完成后读到旧值」,那它和最终一致有什么区别?
▸ 答案:顺序一致要求存在一个全局全序,所有进程看到的写顺序一致,且每个读对应全序中的某个位置;它禁止「两个进程看到不同的写顺序」「同一进程读到 1 又读到 0」这类矛盾。最终一致只承诺「停止写入后最终收敛」,中间过程可以任意不一致,甚至不同副本长期分歧。顺序一致是「视图可能旧,但全世界对顺序有共识」;最终一致是「现在可以各说各话,最后再对齐」。
Q2 为什么「全序广播 + 本地读」能实现顺序一致,却不能实现线性一致?
▸ 答案:全序广播保证所有副本按同一顺序应用写,所以每个副本的状态都是全局全序的一个前缀,任意读都能放进这个全序——满足顺序一致。但本地读可能停在较短的前缀上,读到的是「最新已提交写之前」的值;线性一致要求读之前确认自己看到了最新已提交值,必须走 read-index/租约/leader,本地读做不到。
Q3 用两个顺序一致的寄存器实现一个「跨对象原子写」,为什么可能出错?给出反例。
▸ 答案:顺序一致不是可组合的。反例:P1: W(X,1); W(Y,1),P2: R(Y)=1; R(X)=0。单独看 X、Y 都顺序一致,但合并后约束链 W(X,1) < W(Y,1) < R(Y)=1 < R(X)=0 < W(X,1) 成环,不存在统一全序。工程上这意味着:不要把「各自顺序一致的组件」直接拼成需要跨对象不变式的系统,要么用线性一致对象,要么上事务。
Q4 一个读多写少的配置中心,写走 Raft、读走本地。它提供什么一致性?业务说「改完配置要立刻生效」,怎么办?
▸ 答案:提供顺序一致(写全序、读本地前缀),不是线性一致;改完配置后,其他节点的读可能仍旧。若业务要求「写返回后任何节点读都是新值」,要么读走 ReadIndex/leader(付 1 RTT),要么在客户端加版本号,读到版本不足时等待/重试(等价于会话级的新鲜度保证,见 16.6 会话保证);也可以用 watch 推送变更,让客户端在收到新版本前不使用旧值。
Q5 「顺序一致」和「可串行化」哪个更强?
▸ 答案:不可比。顺序一致约束的是单个操作的全局顺序,不提供事务原子性;可串行化约束的是事务之间的等价串行执行,不约束操作的实时/全局顺序。一个系统可以顺序一致但不可串行化(读到一致顺序的中间状态),也可以可串行化但不顺序一致(事务顺序与实时/全局操作序不符)。要两者兼得,选严格可串行化。
跨篇相关:实时约束加回去就是线性一致,见 16.2 线性一致性;不可实现的顶点见 16.1 严格一致性;只约束因果关系的进一步弱化见 16.4 因果一致性;会话级的「读己之写」见 16.6 会话保证;全序从哪来(共识与日志复制)见 21.6 Raft:日志复制、21.3 Basic Paxos;写入的持久化与复制延迟见 18.2 同步、半同步、异步、18.4 复制延迟与读一致性;分区时写不可用见 17.3 分区期间的取舍;缓存带来的陈旧上界见 41.7 多级缓存;整套谱系见 16.7 一致性模型谱系图。