支付成功之后,订单详情又显示待支付
用户刚完成支付,支付服务把 Payment 置为成功并返回;订单服务读 Order,状态仍是 INVENTORY_RESERVED,页面继续展示"待支付"。用户以为没成功,再付一次。另一个版本更隐蔽:客服在后台取消订单,用户端刷新仍看到"已支付",几分钟后才变成"已取消",中间那段时间的退款判断全部基于旧状态。
两种错误直觉很常见。第一种是"写接口返回成功,后面的读就一定看得到",这把线性一致当成了系统的默认属性;第二种是"业务最终会一致,中间状态无所谓",但支付状态被读错会直接诱发重复扣款。
一致性模型不是形容词,而是对一次执行历史(history)的约束。要回答"这次读是否合法",必须同时看操作的调用时间、返回时间、返回值,以及每个客户端自己的操作顺序。本节先把三种模型压成两条约束,再用一个可运行的历史检查器判定具体历史。形式定义的完整谱系可以对照一致性模型与线性化 ,这里只保留能直接编码的部分。
三种模型,只差两条约束
把每个操作看成一个区间 [invoke, response]。如果操作 A 的返回早于操作 B 的调用,称 A 在真实时间上先于 B。三种模型都要求存在一个顺序,让每次读返回它前面最后一次写的值;差别在于这个顺序要尊重哪些约束。
| 模型 | 顺序形态 | 必须尊重 | 并发写 | 订单场景 |
|---|---|---|---|---|
| 线性一致 | 全序 | 真实时间 + 每次读看到最近写 | 必须落在一个全局顺序里 | 支付状态、库存余量 |
| 顺序一致 | 全序 | 每客户端程序顺序 | 也必须落在一个全局顺序里 | 单客户端会话历史 |
| 因果一致 | 偏序 | 会话顺序 + reads-from | 不同客户端可见不同顺序 | 订单状态跨服务展示 |
关键区别只有两条。顺序一致不要求全序符合真实时间;因果一致连全序都不要,只要求因果相关的操作有先后。线性一致等于顺序一致加上真实时间约束,所以线性一致一定顺序一致,反之不成立。
一个常被忽略的点是:三种模型约束的都是"读返回什么",不是"副本何时追上"。副本延迟只是导致违例的原因之一;即使复制是同步的,只要读路径经过缓存或异步索引,端到端仍可能不满足模型。因此判定必须针对端到端历史,而不是存储内部日志。
顺序一致但非线性一致的经典历史
两个客户端并发写同一个寄存器 x,写 1 与写 2 的区间完全重叠;两个读都在两次写都返回之后才开始,一个读到 1,一个读到 2。
| 客户端 | 操作 | 调用 | 返回 | 结果 |
|---|---|---|---|---|
| p1 | write(x, 1) | 0 | 1 | ok |
| p2 | write(x, 2) | 0 | 1 | ok |
| p3 | read(x) | 2 | 3 | 1 |
| p4 | read(x) | 2 | 3 | 2 |
这个历史是顺序一致的:把全序排成 write1, read1, write2, read2,每个客户端只有一条操作,程序顺序自动满足,两个读分别看到各自前面的写。它不是线性一致的:两个写都在两个读之前完成,任何合法全序都必须把两个写都排在两个读之前,于是两个读只能看到同一个"最后一次写",不可能一个读到 1、另一个读到 2。
这个例子说明,顺序一致允许跨客户端的"时间倒流"。单看每个客户端都合理,合起来却不尊重真实时间。
因果一致允许并发写被不同顺序看到
因果一致只要求因果相关的写有先后。两个客户端并发写 status,一个写 PAID,一个写 CANCELLED,彼此没有因果关系;客户端 C 先读到 PAID,客户端 D 先读到 CANCELLED,这不违反因果一致,因为两个写本来就并发。
但一旦同一客户端先写 1 再写 2,因果边就建立了。另一个客户端如果先读到 2,随后又读到 1,就跳过了因果上更晚的写,构成违例。因果一致不要求所有客户端看到同一个顺序,只要求它们不违反已经建立的因果顺序。这条边界正是订单事件流最需要的能力:支付成功与库存释放可以并发,但"先支付、后取消"不能被看成"先取消、后支付"。
把一次执行写成历史
检查器的输入是一组 Op:客户端、键、读或写、值、调用时间、返回时间。读操作还可以标注它读到了哪一次写(ReadsFrom),因果检查需要这条边;线性一致与顺序一致检查不需要,它们靠寄存器语义自行推导。
判定线性一致的步骤可以固定为四步:
- 为每对操作建立真实时间边:A 的返回早于 B 的调用,则 A 必须先于 B;
- 从所有"前驱都已放置"的操作中选一个,尝试放入全序;
- 写操作更新对应键的值,读操作必须等于当前值,否则这条分支失败;
- 回溯尝试其他可选操作;所有操作放完即找到合法全序。
顺序一致只把第 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 的历史就是反例。
第三,用最终一致承载支付状态。最终一致只承诺收敛,不承诺收敛前读到的值可用;支付页面读到旧状态会直接触发重复支付,这不是"稍后会对"能补救的。
第四,以为检查器能证明系统正确。检查器只判定给定历史,历史没覆盖到的并发交错仍可能违例;它适合做回归与故障复现,不是形式化证明。跨键事务、多对象不变量也不在单键寄存器模型的覆盖范围内。
边界在于:线性一致是单对象的属性,跨对象要靠事务、共识或补偿把多个单键操作组合起来。历史检查器的搜索复杂度也决定了它只能离线运行,线上需要的是可观测的版本水位与断言。
小结
线性一致、顺序一致与因果一致的差别可以压缩成两条约束:是否要求全序,以及全序是否必须尊重真实时间。历史检查器把"这次读是否合法"变成可执行的搜索,让模型讨论从口头定义落到可复现的输入输出。订单链路里,支付与库存必须按单键线性一致设计,展示类可以退到因果或最终一致。下一节把镜头拉近到单个客户端:不改变全局模型,只靠会话令牌和水位,如何保证读己之写、单调读与一致前缀。
阅读导航:上一节:请求路由、元数据与扩容期间的错误预算 · 下一节:会话保证:读己之写、单调读与一致前缀 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。