下单成功,订单列表里却没有这笔订单
用户在结算页下单,接口返回订单号,页面跳到"我的订单"。列表请求被路由到一个还没追上主库的从库,返回的列表里没有刚下的订单。用户以为下单失败,回到结算页又下了一单,最后两笔订单都扣了库存。
团队的直觉通常是"主从延迟只有几十毫秒,用户碰不到"。这个判断依赖三个假设:延迟稳定、读请求一定落在同一从库、用户操作足够慢。三者都不成立。复制延迟会因大事务、网络抖动、从库回放慢而突然拉长;负载均衡每次请求都可能换副本;用户在看到空列表后会立刻重试。
另一条错误直觉是"用粘性会话就够了"。粘性只保证同一会话打到同一台副本,不保证这台副本已经应用了本会话的写。副本故障切换后,新副本可能更旧,用户的读就倒退。会话保证要解决的是"读到的版本不低于本会话已经建立的水位",不是"请求落到哪台机器"。
站内一致性模型与线性化 已经把会话保证放在模型谱系里;本节只做一件事:把四种会话保证写成可运行的水位检查,并逐项验证。
四种保证,各自防什么
| 保证 | 一句话定义 | 防止的故障 | 需要的状态 |
|---|---|---|---|
| 读己之写(RYW) | 会话内写成功后,后续读看到该写或更新 | 下单后列表看不到、改资料后仍显示旧值 | 写水位 |
| 单调读 | 会话内已看到版本 v,后续读不会看到小于 v 的版本 | 刷新页面后数据倒退 | 读水位 |
| 单调写 | 同一会话的写按发出顺序生效 | 重试把旧值覆盖新值、两次写乱序 | 写序列号 |
| 一致前缀 | 读看到全局日志的一个前缀,不出现"后写可见、前写缺失" | 列表里只有部分更新、事件流跳号 | 全局版本 + 前缀应用 |
四种保证强度不同,也不能互相替代。RYW 只保护"自己写过的那些键",不保护自己没写过的键;单调读只保证版本不倒退,不保证看到最新;单调写约束写侧顺序,不约束读侧;一致前缀要求副本按日志顺序应用,是前三种能成立的基础设施前提。
它们都弱于线性一致:线性一致要求所有客户端看到同一个全局顺序,会话保证只约束单个会话。实现成本也低得多——不需要每次读都走多数派,只需要在会话里带几个水位,读时做一次比较。
水位怎么工作
把存储建模成一条全局日志,每条写有一个单调递增的版本号。每个副本记录自己已应用的最高版本 applied,并且严格按版本顺序应用,所以 applied = n 意味着它恰好持有前 n 条写,这就是一致前缀。
会话维护两个水位:
writeWatermark:本会话最后一次写成功时的版本;readWatermark:本会话所有读中见过的最高副本版本。
一次读要满足 replica.applied >= max(writeWatermark, readWatermark),否则拒绝并让调用方等待追平或改走主库。写水位保证 RYW,读水位保证单调读,两者取最大值一次比较即可。读成功后把 readWatermark 推到本次 applied,于是"下一次读不能比这一次旧"。
单调写靠写序列号:会话的第 n 次写必须带 seq = n,协调者拒绝重复序号和跳号。这样即使客户端超时重试,旧序号也不会覆盖新写;协调者还按客户端记录已接受的最大序号,防止会话状态丢失后重放。
粘性路由是优化:固定副本可以减少跨副本跳转和追平等待,但它不提供任何保证。路由函数应当把水位作为筛选条件,只从 applied 足够的副本里选;一个都选不到时,正确动作是等待、读主或报错,而不是降级到旧副本。
可运行实现
下面的包只依赖标准库,包含日志、副本、会话、写入序列检查、带水位的读和路由选择。副本的 apply 只接受连续前缀;Read 在会话锁内比较两个水位后读取,因此 RYW 与单调读一次完成。
// file: internal/platform/session/session.go
// tier: L1
// verify: go test ./article/session/ -count=1
package session
import (
"errors"
"fmt"
"sync"
)
// Sentinel errors returned by the session layer.
var (
ErrStaleReplica = errors.New("session: replica is behind the session watermark")
ErrWriteGap = errors.New("session: write sequence gap")
ErrDuplicateWrite = errors.New("session: duplicate write sequence")
)
// Entry is one committed write in the global log.
type Entry struct {
Version uint64
Key string
Value string
}
// Replica stores a prefix of the log. Applied is the highest version applied,
// so a read at Applied=n sees exactly the first n writes.
type Replica struct {
mu sync.Mutex
name string
applied uint64
data map[string]string
}
func newReplica(name string) *Replica {
return &Replica{name: name, data: make(map[string]string)}
}
// Name returns the replica identifier.
func (r *Replica) Name() string { return r.name }
// Applied returns the highest log version applied on this replica.
func (r *Replica) Applied() uint64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.applied
}
func (r *Replica) apply(entries []Entry) {
r.mu.Lock()
defer r.mu.Unlock()
for _, e := range entries {
r.data[e.Key] = e.Value
r.applied = e.Version
}
}
// Session carries the watermarks that turn per-replica reads into session
// guarantees. WriteWatermark is the version of the session's last write;
// ReadWatermark is the highest version any read in the session has observed.
type Session struct {
mu sync.Mutex
Client string
lastWriteSeq uint64
writeWatermark uint64
readWatermark uint64
}
// NewSession creates an empty session for one client.
func NewSession(client string) *Session { return &Session{Client: client} }
// Watermarks returns the write and read watermarks as one atomic snapshot.
func (s *Session) Watermarks() (write, read uint64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.writeWatermark, s.readWatermark
}
// Cluster is a single-leader log with lagging followers. The replica map is
// immutable after construction, so readers may use it without the log lock.
type Cluster struct {
mu sync.Mutex
log []Entry
replicas map[string]*Replica
leader string
lastSeq map[string]uint64
}
// NewCluster creates a leader plus the named followers.
func NewCluster(leader string, followers ...string) *Cluster {
c := &Cluster{
replicas: make(map[string]*Replica, len(followers)+1),
leader: leader,
lastSeq: make(map[string]uint64),
}
c.replicas[leader] = newReplica(leader)
for _, name := range followers {
c.replicas[name] = newReplica(name)
}
return c
}
// Leader returns the leader replica name.
func (c *Cluster) Leader() string { return c.leader }
// Write appends one entry with the session's next sequence number.
func (c *Cluster) Write(s *Session, key, value string) (uint64, error) {
s.mu.Lock()
seq := s.lastWriteSeq + 1
s.mu.Unlock()
return c.WriteWithSeq(s, seq, key, value)
}
// WriteWithSeq appends one entry with an explicit session sequence. The
// coordinator rejects duplicates and gaps, which enforces monotonic writes
// even when a client retries after a timeout.
func (c *Cluster) WriteWithSeq(s *Session, seq uint64, key, value string) (uint64, error) {
c.mu.Lock()
defer c.mu.Unlock()
s.mu.Lock()
defer s.mu.Unlock()
if seq != s.lastWriteSeq+1 {
if seq <= s.lastWriteSeq {
return 0, fmt.Errorf("%w: client %s seq %d, last %d", ErrDuplicateWrite, s.Client, seq, s.lastWriteSeq)
}
return 0, fmt.Errorf("%w: client %s seq %d, last %d", ErrWriteGap, s.Client, seq, s.lastWriteSeq)
}
if last := c.lastSeq[s.Client]; seq <= last {
return 0, fmt.Errorf("%w: coordinator has seq %d for %s", ErrDuplicateWrite, last, s.Client)
}
version := uint64(len(c.log)) + 1
c.log = append(c.log, Entry{Version: version, Key: key, Value: value})
c.lastSeq[s.Client] = seq
s.lastWriteSeq = seq
s.writeWatermark = version
leader := c.replicas[c.leader]
leader.apply(c.log[len(c.log)-1:])
return version, nil
}
// Replicate applies every entry up to version upto on the named follower.
// Entries are applied in order, so the replica always holds a log prefix.
func (c *Cluster) Replicate(name string, upto uint64) error {
c.mu.Lock()
defer c.mu.Unlock()
r, ok := c.replicas[name]
if !ok {
return fmt.Errorf("session: unknown replica %q", name)
}
if upto > uint64(len(c.log)) {
return fmt.Errorf("session: version %d beyond log length %d", upto, len(c.log))
}
r.mu.Lock()
from := r.applied
r.mu.Unlock()
if upto <= from {
return nil
}
r.apply(c.log[from:upto])
return nil
}
// Read serves one key from a replica, refusing to move backwards in time.
// It enforces read-your-writes and monotonic reads simultaneously.
func (c *Cluster) Read(s *Session, replica, key string) (string, uint64, bool, error) {
r, ok := c.replicas[replica]
if !ok {
return "", 0, false, fmt.Errorf("session: unknown replica %q", replica)
}
s.mu.Lock()
defer s.mu.Unlock()
need := s.writeWatermark
if s.readWatermark > need {
need = s.readWatermark
}
r.mu.Lock()
defer r.mu.Unlock()
if r.applied < need {
return "", r.applied, false, fmt.Errorf("%w: need %d, replica %s has %d", ErrStaleReplica, need, replica, r.applied)
}
value, found := r.data[key]
if r.applied > s.readWatermark {
s.readWatermark = r.applied
}
return value, r.applied, found, nil
}
// Pick returns the first candidate that satisfies the session watermark, the
// routing half of sticky sessions.
func (c *Cluster) Pick(s *Session, candidates []string) (string, error) {
write, read := s.Watermarks()
need := write
if read > need {
need = read
}
for _, name := range candidates {
r, ok := c.replicas[name]
if !ok {
continue
}
if r.Applied() >= need {
return name, nil
}
}
return "", fmt.Errorf("%w: no candidate has version %d", ErrStaleReplica, need)
}
实现里有两个容易被忽略的细节。第一,WriteWithSeq 同时检查会话本地序号与协调者记录的序号:前者防客户端乱序,后者防客户端丢状态后重放。第二,Read 在拒绝时返回副本当前的 applied,调用方可以据此决定等待多久,而不是盲目重试。
测试读数
先看 RYW 与单调读两项:写主库后立刻读落后从库必须被拒绝;副本追平后读到新值;一个会话先从版本 3 的副本读,再读版本 1 的副本必须被拒绝。
package session
import (
"errors"
"testing"
)
func TestReadYourWrites(t *testing.T) {
c := NewCluster("leader", "follower")
s := NewSession("u1")
version, err := c.Write(s, "order-status", "PAID")
if err != nil {
t.Fatalf("Write: %v", err)
}
if _, _, _, err := c.Read(s, "follower", "order-status"); !errors.Is(err, ErrStaleReplica) {
t.Fatalf("lagging read err = %v, want ErrStaleReplica", err)
}
if err := c.Replicate("follower", version); err != nil {
t.Fatalf("Replicate: %v", err)
}
got, ver, ok, err := c.Read(s, "follower", "order-status")
if err != nil || !ok || got != "PAID" || ver != version {
t.Fatalf("read = %q, %d, %v, %v; want PAID, %d, true, nil", got, ver, ok, err, version)
}
}
func TestMonotonicReads(t *testing.T) {
c := NewCluster("leader", "r1", "r2")
writer := NewSession("u2")
for _, kv := range [][2]string{{"a", "1"}, {"b", "2"}, {"c", "3"}} {
if _, err := c.Write(writer, kv[0], kv[1]); err != nil {
t.Fatalf("Write: %v", err)
}
}
if err := c.Replicate("r1", 3); err != nil {
t.Fatalf("Replicate r1: %v", err)
}
if err := c.Replicate("r2", 1); err != nil {
t.Fatalf("Replicate r2: %v", err)
}
reader := NewSession("reader")
if _, ver, _, err := c.Read(reader, "r1", "a"); err != nil || ver != 3 {
t.Fatalf("first read = version %d, %v; want 3, nil", ver, err)
}
if _, _, _, err := c.Read(reader, "r2", "a"); !errors.Is(err, ErrStaleReplica) {
t.Fatalf("second read err = %v, want ErrStaleReplica", err)
}
}
$ go test ./article/session/ -count=1 -v
=== RUN TestReadYourWrites
--- PASS: TestReadYourWrites (0.00s)
=== RUN TestMonotonicReads
--- PASS: TestMonotonicReads (0.00s)
=== RUN TestMonotonicWrites
--- PASS: TestMonotonicWrites (0.00s)
=== RUN TestConsistentPrefix
--- PASS: TestConsistentPrefix (0.00s)
=== RUN TestStickyRouteByWatermark
--- PASS: TestStickyRouteByWatermark (0.00s)
PASS
ok archlab/article/session 0.425s
五项测试在本机全部通过。TestReadYourWrites 的第一次读返回 ErrStaleReplica,追平后读到 PAID 和版本 1;TestMonotonicReads 第一次读把读水位推到 3,第二次读版本 1 的副本被拒绝,证明不会倒退;TestMonotonicWrites 验证跳号与重复序号都被拒绝,换一个新会话重放旧序号也会被协调者挡住;TestConsistentPrefix 在版本 0 到 3 的每个前缀上逐键比对,确认看不到未来写;TestStickyRouteByWatermark 确认路由会跳过落后副本。
这些是判定结果,不是吞吐或延迟指标。真实系统里,水位比较的代价是一次内存读,真正贵的是等待追平;如果业务不能等,就要在写路径上同步复制,或把该读固定到主库。
订单读路径怎么选
| 读路径 | 需要的保证 | 实现 | 退出条件 |
|---|---|---|---|
| 下单后的订单详情 | RYW | 会话带写水位,读主或等副本追平 | 写后首次读 100% 命中;切换副本仍成立 |
| 我的订单列表翻页 | 单调读 + 一致前缀 | 读水位 + 前缀应用 | 翻页不出现旧页覆盖新页、不跳号 |
| 个人资料修改后回显 | RYW + 单调写 | 写序列号 + 写水位 | 重试不会把旧值写回 |
| 订单事件时间线 | 一致前缀 | 全局版本 + 单分区有序 | 不发生"后事件可见、前事件缺失" |
| 商品推荐、销量 | 最终一致 | 无会话要求 | 偏差可接受且有重算路径 |
选型的判据是"用户会不会因为读旧值做出错误动作"。会重复下单、重复支付的路径必须上 RYW;只是展示不一致的路径可以只靠最终一致。会话保证不能跨设备传递:用户在手机上下单、在电脑上查看,需要服务端把水位存进账户级令牌,或者退回到因果一致。
常见错误与边界
第一,把粘性会话当成保证。粘性只固定路由,不检查副本进度;副本重启、主从切换、扩缩容都会让同一会话落到更旧的副本,读水位必须独立存在。
第二,只记写水位,不记读水位。用户先从一个较新的副本读到版本 5,下一次请求被路由到版本 3 的副本,页面就会倒退;单调读需要单独的读水位。
第三,用客户端时间戳代替版本号。多台客户端时钟不同步,时间戳无法比较先后;水位必须是服务端分配的单调版本或日志位点。
第四,让客户端自己生成或修改会话令牌。令牌是可被伪造的断言,客户端改大水位会导致读到未提交数据或永久等待;令牌应不透明、服务端签发、绑定会话。
边界在于:水位只覆盖单会话、单逻辑日志。跨分区读需要一致快照或分别带水位;跨设备需要账户级状态;写水位要求写路径返回版本号,异步写没有版本可取时只能退化为"读主一段时间"。这些限制要在接口契约里写清,不能靠默认行为。
小结
会话保证用很小的状态换掉了大部分用户可见的旧读:写水位保证读己之写,读水位保证单调读,写序列号保证单调写,前缀应用保证一致前缀。粘性路由只是减少追平等待的优化,不能替代水位检查。实现的关键不是记住四个名词,而是让每次读都显式比较 max(writeWatermark, readWatermark) 与副本 applied。下一节换一个方向:不再问"系统提供什么模型",而是从库存、订单状态和支付这些业务不变量出发,反推最弱但足够的模型。
阅读导航:上一节:线性一致、顺序一致与因果一致 · 下一节:从一致性模型到业务不变量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。