《后端架构设计·分布式与数据一致性》6.1 线性一致、顺序一致与因果一致

用支付成功后订单状态又被读回旧值的场景,区分线性一致、顺序一致与因果一致的形式判据;实现可回溯的历史检查器,用三类历史分别判定线性一致违例、顺序一致但非线性的执行,以及跳过因果上更晚写入的读,并给出订单链路的模型选型表,帮助在支付强一致与展示弱一致之间做取舍。

支付成功之后,订单详情又显示待支付

用户刚完成支付,支付服务把 Payment 置为成功并返回;订单服务读 Order,状态仍是 INVENTORY_RESERVED,页面继续展示"待支付"。用户以为没成功,再付一次。另一个版本更隐蔽:客服在后台取消订单,用户端刷新仍看到"已支付",几分钟后才变成"已取消",中间那段时间的退款判断全部基于旧状态。

两种错误直觉很常见。第一种是"写接口返回成功,后面的读就一定看得到",这把线性一致当成了系统的默认属性;第二种是"业务最终会一致,中间状态无所谓",但支付状态被读错会直接诱发重复扣款。

一致性模型不是形容词,而是对一次执行历史(history)的约束。要回答"这次读是否合法",必须同时看操作的调用时间、返回时间、返回值,以及每个客户端自己的操作顺序。本节先把三种模型压成两条约束,再用一个可运行的历史检查器判定具体历史。形式定义的完整谱系可以对照一致性模型与线性化 ,这里只保留能直接编码的部分。

三种模型,只差两条约束

把每个操作看成一个区间 [invoke, response]。如果操作 A 的返回早于操作 B 的调用,称 A 在真实时间上先于 B。三种模型都要求存在一个顺序,让每次读返回它前面最后一次写的值;差别在于这个顺序要尊重哪些约束。

模型顺序形态必须尊重并发写订单场景
线性一致全序真实时间 + 每次读看到最近写必须落在一个全局顺序里支付状态、库存余量
顺序一致全序每客户端程序顺序也必须落在一个全局顺序里单客户端会话历史
因果一致偏序会话顺序 + reads-from不同客户端可见不同顺序订单状态跨服务展示

关键区别只有两条。顺序一致不要求全序符合真实时间;因果一致连全序都不要,只要求因果相关的操作有先后。线性一致等于顺序一致加上真实时间约束,所以线性一致一定顺序一致,反之不成立。

一个常被忽略的点是:三种模型约束的都是"读返回什么",不是"副本何时追上"。副本延迟只是导致违例的原因之一;即使复制是同步的,只要读路径经过缓存或异步索引,端到端仍可能不满足模型。因此判定必须针对端到端历史,而不是存储内部日志。

顺序一致但非线性一致的经典历史

两个客户端并发写同一个寄存器 x,写 1 与写 2 的区间完全重叠;两个读都在两次写都返回之后才开始,一个读到 1,一个读到 2。

客户端操作调用返回结果
p1write(x, 1)01ok
p2write(x, 2)01ok
p3read(x)231
p4read(x)232

这个历史是顺序一致的:把全序排成 write1, read1, write2, read2,每个客户端只有一条操作,程序顺序自动满足,两个读分别看到各自前面的写。它不是线性一致的:两个写都在两个读之前完成,任何合法全序都必须把两个写都排在两个读之前,于是两个读只能看到同一个"最后一次写",不可能一个读到 1、另一个读到 2。

这个例子说明,顺序一致允许跨客户端的"时间倒流"。单看每个客户端都合理,合起来却不尊重真实时间。

因果一致允许并发写被不同顺序看到

因果一致只要求因果相关的写有先后。两个客户端并发写 status,一个写 PAID,一个写 CANCELLED,彼此没有因果关系;客户端 C 先读到 PAID,客户端 D 先读到 CANCELLED,这不违反因果一致,因为两个写本来就并发。

但一旦同一客户端先写 1 再写 2,因果边就建立了。另一个客户端如果先读到 2,随后又读到 1,就跳过了因果上更晚的写,构成违例。因果一致不要求所有客户端看到同一个顺序,只要求它们不违反已经建立的因果顺序。这条边界正是订单事件流最需要的能力:支付成功与库存释放可以并发,但"先支付、后取消"不能被看成"先取消、后支付"。

把一次执行写成历史

检查器的输入是一组 Op:客户端、键、读或写、值、调用时间、返回时间。读操作还可以标注它读到了哪一次写(ReadsFrom),因果检查需要这条边;线性一致与顺序一致检查不需要,它们靠寄存器语义自行推导。

判定线性一致的步骤可以固定为四步:

  1. 为每对操作建立真实时间边:A 的返回早于 B 的调用,则 A 必须先于 B;
  2. 从所有"前驱都已放置"的操作中选一个,尝试放入全序;
  3. 写操作更新对应键的值,读操作必须等于当前值,否则这条分支失败;
  4. 回溯尝试其他可选操作;所有操作放完即找到合法全序。

顺序一致只把第 1 步换成"同一客户端的相邻操作",其余不变。因果一致不搜索全序,而是构造会话顺序与 reads-from 的传递闭包,检查无环,并检查每条读没有跳过因果上更晚的写。

最坏情况下搜索是阶乘级,只适合几十条操作的录制历史,不能放进生产请求路径。它的价值是做一个可复现的判定器:把线上采集的调用/返回区间喂进去,得到"这段历史是否成立"的确定答案,而不是靠人眼猜。

可运行的历史检查器

下面的包只依赖标准库,包含线性一致、顺序一致与因果一致三个判定函数。回溯时按"前驱已放置"选择候选,读不匹配立即回退;因果检查用 Floyd 传递闭包求因果序。

// file: internal/platform/consistency/history.go
// tier: L1
// verify: go test ./article/consistency/ -count=1
package consistency

import "fmt"

// Kind distinguishes reads from writes in a recorded history.
type Kind int

// Operation kinds. A history is a sequence of reads and writes on typed keys.
const (
	Read Kind = iota
	Write
)

// Op is one completed operation. Invoke and Response are logical timestamps;
// two operations are concurrent exactly when their intervals overlap.
type Op struct {
	Client    string
	Key       string
	Kind      Kind
	Value     int
	Invoke    int64
	Response  int64
	ReadsFrom int // index of the observed write, -1 for the initial value
}

// History is a recorded execution, ordered by invocation time. Operations of
// one client are assumed to appear in program order and never overlap.
type History []Op

func (h History) validate() error {
	for i, op := range h {
		if op.Key == "" {
			return fmt.Errorf("consistency: op %d has empty key", i)
		}
		if op.Response < op.Invoke {
			return fmt.Errorf("consistency: op %d ends before it starts", i)
		}
		if op.Kind == Read && op.ReadsFrom >= len(h) {
			return fmt.Errorf("consistency: read %d points outside the history", i)
		}
	}
	return nil
}

// Linearizable reports whether some total order of all operations both
// respects real time (a response before a later invocation) and makes every
// read return the value of the latest write before it.
func (h History) Linearizable(initial map[string]int) (bool, error) {
	if err := h.validate(); err != nil {
		return false, err
	}
	n := len(h)
	precedes := make([][]bool, n)
	for i := range precedes {
		precedes[i] = make([]bool, n)
	}
	for i := range h {
		for j := range h {
			if i != j && h[i].Response < h[j].Invoke {
				precedes[i][j] = true
			}
		}
	}
	return h.search(initial, precedes), nil
}

// Sequential reports whether some total order respects each client's program
// order, without requiring that order to match real time.
func (h History) Sequential(initial map[string]int) (bool, error) {
	if err := h.validate(); err != nil {
		return false, err
	}
	n := len(h)
	precedes := make([][]bool, n)
	for i := range precedes {
		precedes[i] = make([]bool, n)
	}
	last := make(map[string]int, n)
	for i, op := range h {
		if prev, ok := last[op.Client]; ok {
			precedes[prev][i] = true
		}
		last[op.Client] = i
	}
	return h.search(initial, precedes), nil
}

// search backtracks over every operation whose predecessors are already
// placed, applying register semantics. It is exponential in the worst case
// and is intended for small recorded histories, not production traffic.
func (h History) search(initial map[string]int, precedes [][]bool) bool {
	state := make(map[string]int, len(initial))
	for k, v := range initial {
		state[k] = v
	}
	placed := make([]bool, len(h))

	var walk func(done int) bool
	walk = func(done int) bool {
		if done == len(h) {
			return true
		}
		for i := range h {
			if placed[i] {
				continue
			}
			blocked := false
			for j := range h {
				if !placed[j] && precedes[j][i] {
					blocked = true
					break
				}
			}
			if blocked {
				continue
			}

			prev, had := state[h[i].Key]
			ok := true
			if h[i].Kind == Write {
				state[h[i].Key] = h[i].Value
			} else {
				ok = h[i].Value == prev
			}
			placed[i] = true
			if ok && walk(done+1) {
				return true
			}
			placed[i] = false
			if had {
				state[h[i].Key] = prev
			} else {
				delete(state, h[i].Key)
			}
		}
		return false
	}
	return walk(0)
}

// CausallyConsistent reports whether the annotated history admits a causal
// order: session order plus reads-from edges must be acyclic, and a read may
// not skip a write that is causally after the write it observed.
func (h History) CausallyConsistent() (bool, error) {
	if err := h.validate(); err != nil {
		return false, err
	}
	n := len(h)
	adj := make([][]bool, n)
	for i := range adj {
		adj[i] = make([]bool, n)
	}
	last := make(map[string]int, n)
	for i, op := range h {
		if prev, ok := last[op.Client]; ok {
			adj[prev][i] = true
		}
		last[op.Client] = i
	}
	for i, op := range h {
		if op.Kind != Read || op.ReadsFrom < 0 {
			continue
		}
		w := h[op.ReadsFrom]
		if w.Kind != Write || w.Key != op.Key {
			return false, fmt.Errorf("consistency: read %d observes non-write or other key", i)
		}
		adj[op.ReadsFrom][i] = true
	}

	for k := 0; k < n; k++ {
		for i := 0; i < n; i++ {
			if !adj[i][k] {
				continue
			}
			for j := 0; j < n; j++ {
				if adj[k][j] {
					adj[i][j] = true
				}
			}
		}
	}
	for i := 0; i < n; i++ {
		if adj[i][i] {
			return false, nil
		}
	}

	for i, op := range h {
		if op.Kind != Read || op.ReadsFrom < 0 {
			continue
		}
		for w := range h {
			if w == op.ReadsFrom || h[w].Kind != Write || h[w].Key != op.Key {
				continue
			}
			if adj[op.ReadsFrom][w] && adj[w][i] {
				return false, nil
			}
		}
	}
	return true, nil
}

判定器本身不知道网络、时钟和副本,只消费历史。这也意味着它不能替代故障注入:它只能告诉你"这段录制历史违反了哪个模型",不能告诉你"系统在所有执行下都满足模型"。

测试读数

用一个表驱动测试同时覆盖三条边界:线性历史返回 true;顺序一致但非线性一致的历史,线性判定 false、顺序判定 true;因果上跳过了更晚写的历史,因果判定 false。

package consistency

import "testing"

func TestModelBoundaries(t *testing.T) {
	linear := History{
		{Client: "a", Key: "stock", Kind: Write, Value: 1, Invoke: 0, Response: 1},
		{Client: "b", Key: "stock", Kind: Read, Value: 1, Invoke: 2, Response: 3},
	}
	sequentialOnly := History{
		{Client: "p1", Key: "x", Kind: Write, Value: 1, Invoke: 0, Response: 1},
		{Client: "p2", Key: "x", Kind: Write, Value: 2, Invoke: 0, Response: 1},
		{Client: "p3", Key: "x", Kind: Read, Value: 1, Invoke: 2, Response: 3},
		{Client: "p4", Key: "x", Kind: Read, Value: 2, Invoke: 2, Response: 3},
	}
	staleCausalRead := History{
		{Client: "a", Key: "s", Kind: Write, Value: 1, Invoke: 0, Response: 1, ReadsFrom: -1},
		{Client: "a", Key: "s", Kind: Write, Value: 2, Invoke: 2, Response: 3, ReadsFrom: -1},
		{Client: "c", Key: "s", Kind: Read, Value: 2, Invoke: 4, Response: 5, ReadsFrom: 1},
		{Client: "c", Key: "s", Kind: Read, Value: 1, Invoke: 6, Response: 7, ReadsFrom: 0},
	}
	if ok, err := linear.Linearizable(nil); err != nil || !ok {
		t.Fatalf("linear history = %v, %v; want true, nil", ok, err)
	}
	if ok, err := sequentialOnly.Linearizable(nil); err != nil || ok {
		t.Fatalf("sequential history is linearizable = %v, %v; want false, nil", ok, err)
	}
	if ok, err := sequentialOnly.Sequential(nil); err != nil || !ok {
		t.Fatalf("sequential history = %v, %v; want true, nil", ok, err)
	}
	if ok, err := staleCausalRead.CausallyConsistent(); err != nil || ok {
		t.Fatalf("stale causal read = %v, %v; want false, nil", ok, err)
	}
}
$ go test ./article/consistency/ -count=1 -v
=== RUN   TestLinearizableHistory
=== RUN   TestLinearizableHistory/read_sees_a_completed_write
=== RUN   TestLinearizableHistory/stale_read_after_the_write_completed
=== RUN   TestLinearizableHistory/concurrent_read_may_observe_the_old_value
--- PASS: TestLinearizableHistory (0.00s)
    --- PASS: TestLinearizableHistory/read_sees_a_completed_write (0.00s)
    --- PASS: TestLinearizableHistory/stale_read_after_the_write_completed (0.00s)
    --- PASS: TestLinearizableHistory/concurrent_read_may_observe_the_old_value (0.00s)
=== RUN   TestSequentialWithoutRealTime
--- PASS: TestSequentialWithoutRealTime (0.00s)
=== RUN   TestCausalConcurrentWrites
--- PASS: TestCausalConcurrentWrites (0.00s)
=== RUN   TestCausalRejectsSkippedWrite
--- PASS: TestCausalRejectsSkippedWrite (0.00s)
=== RUN   TestModelBoundaries
--- PASS: TestModelBoundaries (0.00s)
PASS
ok  	archlab/article/consistency	0.263s

三个结果都来自本机 Go 1.26.0 的真实运行。第一条历史的读在写返回之后开始,必须看到 1,判定通过;第二条历史两个读都在两个写之后,线性判定找不到合法全序,而顺序判定可以排成 w1, r1, w2, r2;第三条历史里 w1 → w2 是会话顺序,r2 又因果晚于 w2,却返回 w1,因此被拒绝。

订单链路怎么选

读路径可接受的最弱模型不应接受判据与退出条件
支付状态轮询、退款前查询线性一致无会话保证的副本读读必须看到最后一次已确认的支付结果;切换副本后仍成立
库存余量与预占结果单键线性一致最终一致 + 事后对账条件更新由存储原子执行;超卖不可补偿
本人下单后的订单详情读己之写任意副本读会话内写后读必须命中同版本或更新;下一节展开
订单状态跨服务展示因果一致任意乱序先支付后取消不能被展示成先取消后支付
商品销量、评价数最终一致线性一致偏差可接受,且有对账或重算路径

选型时先问"读错一次能不能补"。支付和库存不能补,必须收紧到单键线性一致;展示类可以补,放松到因果或最终一致能省掉跨地域协调。注意"单键线性一致"只保护一个键:订单与库存跨聚合时,单键条件更新不够,需要后续章节的事务或补偿方案。

常见错误与边界

第一,把时间戳排序当成线性化。线性一致的顺序受真实时间区间约束,不受墙上时钟约束;两个并发操作可以任意排序,用时间戳强行排序会制造并不存在的先后。

第二,把顺序一致当成线性一致。顺序一致允许跨客户端重排,只要每个客户端自己的顺序不变;上面那个 w1, r1, w2, r2 的历史就是反例。

第三,用最终一致承载支付状态。最终一致只承诺收敛,不承诺收敛前读到的值可用;支付页面读到旧状态会直接触发重复支付,这不是"稍后会对"能补救的。

第四,以为检查器能证明系统正确。检查器只判定给定历史,历史没覆盖到的并发交错仍可能违例;它适合做回归与故障复现,不是形式化证明。跨键事务、多对象不变量也不在单键寄存器模型的覆盖范围内。

边界在于:线性一致是单对象的属性,跨对象要靠事务、共识或补偿把多个单键操作组合起来。历史检查器的搜索复杂度也决定了它只能离线运行,线上需要的是可观测的版本水位与断言。

小结

线性一致、顺序一致与因果一致的差别可以压缩成两条约束:是否要求全序,以及全序是否必须尊重真实时间。历史检查器把"这次读是否合法"变成可执行的搜索,让模型讨论从口头定义落到可复现的输入输出。订单链路里,支付与库存必须按单键线性一致设计,展示类可以退到因果或最终一致。下一节把镜头拉近到单个客户端:不改变全局模型,只靠会话令牌和水位,如何保证读己之写、单调读与一致前缀。

阅读导航:上一节:请求路由、元数据与扩容期间的错误预算 · 下一节:会话保证:读己之写、单调读与一致前缀 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查