《后端架构设计·分布式与数据一致性》3.1 单主复制:复制日志、位点与读己之写

从大促中“支付成功却显示待支付”的订单详情页切入,区分复制日志的写入、提交与应用三种位点,说明强制读主和固定 sleep 为什么都不可靠;用可运行的 Go 单主复制包实现会话令牌与读己之写,并测试主库切换后未提交尾部被丢弃、位点不足时必须显式报错。

支付成功,详情页却显示待支付

20:00 大促开始,用户下单并完成支付,接口返回 order-1001:PAID。他刷新订单详情,页面却仍是“待支付”;再点一次“去支付”,支付网关拒绝重复扣款。客服看到的是另一个版本:订单已支付,但库存预占记录还在。三个页面读的是三个副本,各自停在不同位点。

最常见的解释是“主从延迟,等一会儿就好”,于是加 sleep(200ms) 或把详情页强制读主库。两个办法都只在演示环境成立:sleep 无法给出上界,主库也可能还没应用到自己刚提交的位点;切换主库后,“主库”换了一台,旧规则立刻失效。

这里真正缺的不是更快的复制,而是一份可验证的位点契约:客户端写入后拿到“至少要看到哪个位点”的令牌,读路径检查目标副本是否已经应用到位;不满足就等待、转投或返回明确错误,而不是返回旧值。

三个位点不是一回事

位点含义谁维护能回答的问题
写入位点 lastIndex日志末尾追加到哪主库还能不能继续追加
提交位点 commitIndex多数派已持久化到哪主库(随心跳传播)这条写入是否算成功
应用位点 appliedIndex状态机执行到哪每个副本这个副本现在能读出什么

复制日志只保证“字节到了”,不保证“状态机执行了”。从库收到第 42 条日志时,commitIndex 可能还是 40,appliedIndex 可能只有 38。读请求如果只检查“连接的是从库”,就完全绕过了这三个位点。

订单场景里,支付回调把 PAID 写进日志并提交,订单详情从另一个副本读;该副本可能已经收到这条日志,但还没应用到订单状态机,于是返回 CREATED。这不是脏读,因为 PAID 确实已提交;这是读到了一个落后的物化视图。

会话令牌:把位点写进请求

会话令牌(session token)是写路径返回给客户端的一个小结构:SessionID 加 LSN(log sequence number)。客户端后续请求带上它,服务端据此判断哪个副本有资格服务这次读。

规则只有两条:

  1. 写成功后返回 LSN = commitIndex,而不是 lastIndex。未提交的尾部可能被新主库截断,不能作为承诺。
  2. 读之前检查目标副本的 appliedIndex >= LSN。不满足时返回 ErrStaleReplica,由上层选择等待、转投主库或重试。

令牌与“粘性会话”不同。粘性会话只保证同一客户端打到同一台机器,不保证那台机器已经应用;令牌可以直接跨副本、跨连接使用,判断依据是位点而不是身份。

代码:日志、提交与应用

下面的包把三个位点显式建模。Write 只有在多数派持有日志后才推进 CommitIndex 并签发令牌;Read 不修数据、不猜测,位点不足就报错。

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

import (
	"errors"
	"fmt"
)

var (
	ErrStaleReplica   = errors.New("replica has not applied the required position")
	ErrNoQuorum       = errors.New("write did not reach quorum")
	ErrUnknownSession = errors.New("unknown session")
)

type Entry struct {
	Index   uint64
	Command string
}

type SessionToken struct {
	SessionID string
	LSN       uint64
}

type Replica struct {
	ID           string
	Log          []Entry
	CommitIndex  uint64
	AppliedIndex uint64
}

func (r *Replica) LastIndex() uint64 {
	if len(r.Log) == 0 {
		return 0
	}
	return r.Log[len(r.Log)-1].Index
}

type Cluster struct {
	replicas   []*Replica
	primaryIdx int
	down       map[string]bool
	sessions   map[string]uint64
}

func NewCluster(ids ...string) (*Cluster, error) {
	if len(ids) < 3 {
		return nil, fmt.Errorf("cluster needs 3 replicas: got %d", len(ids))
	}
	c := &Cluster{down: map[string]bool{}, sessions: map[string]uint64{}}
	for _, id := range ids {
		c.replicas = append(c.replicas, &Replica{ID: id})
	}
	return c, nil
}

func (c *Cluster) quorum() int { return len(c.replicas)/2 + 1 }

func (c *Cluster) Primary() *Replica { return c.replicas[c.primaryIdx] }

func (c *Cluster) SetDown(id string, down bool) { c.down[id] = down }

// Write returns a token only after a majority stores the entry.
func (c *Cluster) Write(sessionID, command string) (SessionToken, error) {
	leader := c.Primary()
	if c.down[leader.ID] {
		return SessionToken{}, fmt.Errorf("leader %s is down", leader.ID)
	}
	entry := Entry{Index: leader.LastIndex() + 1, Command: command}
	leader.Log = append(leader.Log, entry)
	acks := 1
	for _, r := range c.replicas {
		if r == leader || c.down[r.ID] {
			continue
		}
		r.Log = append(r.Log, entry)
		acks++
	}
	if acks < c.quorum() {
		return SessionToken{}, fmt.Errorf("%w: acks=%d quorum=%d", ErrNoQuorum, acks, c.quorum())
	}
	leader.CommitIndex = entry.Index
	for _, r := range c.replicas {
		if r != leader && r.LastIndex() >= entry.Index {
			r.CommitIndex = entry.Index
		}
	}
	c.sessions[sessionID] = entry.Index
	return SessionToken{SessionID: sessionID, LSN: entry.Index}, nil
}

func (c *Cluster) Apply(replicaID string) (uint64, error) {
	for _, r := range c.replicas {
		if r.ID == replicaID {
			r.AppliedIndex = r.CommitIndex
			return r.AppliedIndex, nil
		}
	}
	return 0, fmt.Errorf("unknown replica %q", replicaID)
}

// Read enforces the session's minimum visible position.
func (c *Cluster) Read(sessionID, replicaID string) (string, error) {
	lsn, ok := c.sessions[sessionID]
	if !ok {
		return "", fmt.Errorf("%w: %s", ErrUnknownSession, sessionID)
	}
	for _, r := range c.replicas {
		if r.ID != replicaID {
			continue
		}
		if r.AppliedIndex < lsn {
			return "", fmt.Errorf("%w: %s applied=%d required=%d", ErrStaleReplica, r.ID, r.AppliedIndex, lsn)
		}
		for _, e := range r.Log {
			if e.Index == lsn {
				return e.Command, nil
			}
		}
		return "", fmt.Errorf("%w: lsn=%d missing", ErrStaleReplica, lsn)
	}
	return "", fmt.Errorf("unknown replica %q", replicaID)
}

// Failover promotes the reachable replica with the longest log.
func (c *Cluster) Failover() (int, error) {
	up, best := 0, -1
	for i, r := range c.replicas {
		if c.down[r.ID] {
			continue
		}
		up++
		if best == -1 || r.LastIndex() > c.replicas[best].LastIndex() ||
			(r.LastIndex() == c.replicas[best].LastIndex() && r.ID < c.replicas[best].ID) {
			best = i
		}
	}
	if up < c.quorum() {
		return 0, fmt.Errorf("%w: up=%d quorum=%d", ErrNoQuorum, up, c.quorum())
	}
	leader := c.replicas[best]
	c.primaryIdx = best
	dropped := 0
	for _, r := range c.replicas {
		if r.ID == leader.ID || c.down[r.ID] || len(r.Log) <= len(leader.Log) {
			continue
		}
		dropped += len(r.Log) - len(leader.Log)
		r.Log = r.Log[:len(leader.Log)]
		if r.CommitIndex > leader.LastIndex() {
			r.CommitIndex = leader.LastIndex()
		}
		if r.AppliedIndex > r.CommitIndex {
			r.AppliedIndex = r.CommitIndex
		}
	}
	commit := uint64(0)
	for idx := leader.LastIndex(); idx > 0; idx-- {
		count := 0
		for _, r := range c.replicas {
			if r.LastIndex() >= idx {
				count++
			}
		}
		if count >= c.quorum() {
			commit = idx
			break
		}
	}
	leader.CommitIndex = commit
	if leader.AppliedIndex > commit {
		leader.AppliedIndex = commit
	}
	return dropped, nil
}

两个必须写进测试的边界

第一个边界是“提交了但没应用”。主库签发令牌后,从库可能只到 commitIndex,读仍然必须被拒绝。第二个边界是“写失败但日志已追加”。Write 在凑不够多数派时返回错误,尾部留在主库;主库再失败后,新主库可能没有这段尾部,它必须被丢弃。

// file: internal/platform/replication/replication_test.go
// tier: L1
// verify: go test ./internal/platform/replication/ -count=1
package replication

import (
	"errors"
	"testing"
)

func TestSessionRead(t *testing.T) {
	tests := []struct {
		name    string
		apply   bool
		wantErr error
		want    string
	}{
		{"lagging follower rejects", false, ErrStaleReplica, ""},
		{"applied follower serves", true, nil, "order-1001:CREATED"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c, err := NewCluster("n1", "n2", "n3")
			if err != nil {
				t.Fatalf("NewCluster: %v", err)
			}
			if _, err := c.Write("s1", "order-1001:CREATED"); err != nil {
				t.Fatalf("Write: %v", err)
			}
			if tt.apply {
				if _, err := c.Apply("n2"); err != nil {
					t.Fatalf("Apply: %v", err)
				}
			}
			got, err := c.Read("s1", "n2")
			if tt.wantErr != nil {
				if !errors.Is(err, tt.wantErr) {
					t.Fatalf("Read error = %v, want %v", err, tt.wantErr)
				}
				return
			}
			if err != nil || got != tt.want {
				t.Fatalf("Read = %q, %v; want %q, nil", got, err, tt.want)
			}
		})
	}
}

func TestQuorumAndFailoverBoundary(t *testing.T) {
	c, _ := NewCluster("n1", "n2", "n3")
	session := "s1"
	if _, err := c.Write(session, "order-1001:CREATED"); err != nil {
		t.Fatalf("first write: %v", err)
	}

	// A write that reaches only the leader is not acknowledged.
	c.SetDown("n2", true)
	c.SetDown("n3", true)
	if _, err := c.Write(session, "order-1001:PAID"); !errors.Is(err, ErrNoQuorum) {
		t.Fatalf("second write error = %v, want ErrNoQuorum", err)
	}

	// A majority elects n2, which never saw the uncommitted tail.
	c.SetDown("n1", true)
	c.SetDown("n2", false)
	c.SetDown("n3", false)
	if _, err := c.Failover(); err != nil {
		t.Fatalf("Failover: %v", err)
	}
	if got := c.Primary().CommitIndex; got != 1 {
		t.Fatalf("commit index = %d, want 1", got)
	}
	if _, err := c.Apply("n2"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	got, err := c.Read(session, "n2")
	if err != nil || got != "order-1001:CREATED" {
		t.Fatalf("Read = %q, %v; want CREATED, nil", got, err)
	}
}

本机在验证模块里运行的真实输出:

$ go test ./article/replication/ -count=1
--- PASS: TestSessionRead (0.00s)
    --- PASS: TestSessionRead/lagging_follower_rejects (0.00s)
    --- PASS: TestSessionRead/applied_follower_serves (0.00s)
--- PASS: TestQuorumAndFailoverBoundary (0.00s)
PASS
ok  	archlab/article/replication	0.241s

lagging_follower_rejects 证明“已提交但未应用”会被拒绝;TestQuorumAndFailoverBoundary 则走完“写失败留下尾部 → 新主库没有尾部 → 提交位点停在 1”的路径。测试里的具体毫秒数只是本机调度结果,不能当作复制的延迟指标。

切换后的位点边界

主库切换时,最危险的动作是把“旧主库的 lastIndex”当成新主库的承诺。新主库只知道多数派持有的位置:它从自己的日志末端向前找,第一个被多数派持有的索引才是 commitIndex。旧主库独有的未提交尾部必须被截断,否则下一次追加会在日志里留下空洞。

切换前的事实切换后的正确行为错误做法
写已提交,令牌 LSN=42新主库恢复提交位点 42;副本应用后可读只按连接是否成功判断可读
写返回 ErrNoQuorum,日志在第 43 条第 43 条被截断,令牌不存在把第 43 条当已提交并返回旧值
新主库尚未应用Read 返回 ErrStaleReplica读路径顺手“修复”或返回默认值

令牌只在“写成功”时签发,因此它天然不会指向未提交尾部。但如果实现把令牌建在 lastIndex 上,切换就会把客户端引向一条不存在的日志;这是本节最值得写进代码评审的检查点。

判断表:这次读该去哪里

条件选择退出条件
读自己的写,且令牌 LSN 已应用就近副本appliedIndex >= LSN
令牌未应用,延迟可接受等待副本追平超时后转主库或报错
令牌未应用,且要求强一致读主库并等应用主库 appliedIndex >= LSN
无会话令牌的列表页任意副本接受滞后,页面标注“可能延迟”
切换刚完成新主库 + 位点校验新主库 commitIndex 已恢复

这张表的关键不是“读主还是读从”,而是每一行都有可检查的退出条件。没有退出条件的“等一会儿”会把延迟问题变成偶发超时。

常见错误与边界

第一,把 lastIndex 当提交位点。追加成功不代表多数派持有,主库崩溃后这段尾部可能消失;令牌必须绑定 commitIndex。

第二,把“收到日志”当“可以读”。接收位点、提交位点、应用位点是三个值,读路径只认应用位点。

第三,用固定 sleep 掩盖滞后。sleep 既没有上界,也没有失败信号;正确做法是返回 ErrStaleReplica 并让上层决定重试预算。

第四,切换后立刻放开读。新主库的提交位点需要重新计算,应用位点可能更靠后;先完成位点恢复与应用,再摘除读保护。

边界也同样明确:这套令牌只保证单会话的读己之写,不提供跨会话的线性一致;多个客户端并发改同一订单仍需要版本号或条件更新,这属于第 6 章的一致性模型范围,可参考线性一致性与会话保证 。

小结

单主复制的读问题,本质是三个位点没有对齐:写入位点回答“还能追加吗”,提交位点回答“算成功吗”,应用位点回答“能读吗”。会话令牌把提交位点带给读路径,读侧用应用位点做门禁,不满足就显式失败。切换时新主库必须重新计算提交位点并丢弃未提交尾部,否则令牌会指向不存在的日志。下一节把单主换成多主与无主:没有唯一日志时,冲突检测和合并就成了新的主问题。

阅读导航:上一节:混合逻辑时钟与版本向量在 arch-lab 的落地 · 下一节:多主与无主复制:冲突检测与合并 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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