支付成功,详情页却显示待支付
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)。客户端后续请求带上它,服务端据此判断哪个副本有资格服务这次读。
规则只有两条:
- 写成功后返回
LSN = commitIndex,而不是lastIndex。未提交的尾部可能被新主库截断,不能作为承诺。 - 读之前检查目标副本的
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 的落地 · 下一节:多主与无主复制:冲突检测与合并 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。