《后端架构设计·高性能与高可用》11.3 读写分离的延迟预算与一致性兜底

在 arch-lab 订单系统实现副本路由、复制延迟跟踪、read-your-writes 回退主库和超预算降级;用 10 万次确定性延迟分布真实跑到 17%–19% 的回退率,并覆盖刚写完立即读、副本滞后、副本不可用、主库过载四类测试,最后区分哪些读可以陈旧、哪些绝不可以。

读写分离先把“读到什么”说清楚

读写分离常被描述成“写主库、读从库”,真正决定正确性的是另一句话:这个读能不能接受副本尚未应用的最新写入。把读分成三类,路由才有可讨论的边界:ReadYourWrites 必须看到本会话刚刚写入的版本;ReadFresh 不接受任何已知滞后,只能走主库;ReadStaleOK 允许有界陈旧,例如商品目录、推荐位和历史报表。

arch-lab 的下单链路里,创建订单后立刻跳转详情页属于第一类;库存扣减前的可用量校验属于第二类;运营看板属于第三类。站内 高可用模式 讨论过副本与故障域,本节只处理一个问题:副本延迟在预算内时怎么读,超预算时退到哪里,以及退不过去时返回什么错误。

路由器的四个输入

路由器只需要四个输入:主库与副本的健康状态、副本已应用的版本、副本的复制延迟、主库的当前负载。输出是一个明确的决策,包含节点、原因和是否降级。原因是给排障用的:同样走主库,“会话写入尚未复制”和“主库过载”是两种完全不同的告警。

// file: internal/perf/readsplit/router.go
// tier: L1
// verify: go test ./internal/perf/readsplit/ -count=1 -v
package readsplit

import (
	"errors"
	"math"
	"sort"
	"time"
)

type Role int

const (
	Primary Role = iota
	Replica
)

type Node struct {
	Name           string
	Role           Role
	Healthy        bool
	AppliedVersion uint64
	Lag            time.Duration
	InFlight       int
	Capacity       int
}

func (n *Node) Overloaded() bool { return n.Capacity > 0 && n.InFlight >= n.Capacity }

type ReadKind int

const (
	// ReadYourWrites must observe the session's own last write.
	ReadYourWrites ReadKind = iota
	// ReadFresh cannot tolerate any stale replica, so it uses the primary.
	ReadFresh
	// ReadStaleOK accepts bounded staleness, e.g. dashboards.
	ReadStaleOK
)

type Request struct {
	Kind           ReadKind
	SessionVersion uint64
}

type Decision struct {
	Node     *Node
	Reason   string
	Degraded bool
}

var (
	ErrNoPrimary        = errors.New("readsplit: no healthy primary")
	ErrOverloaded       = errors.New("readsplit: primary overloaded")
	ErrStaleBeyondBound = errors.New("readsplit: no replica within hard lag bound")
)

type Router struct {
	Primary    *Node
	Replicas   []*Node
	MaxLag     time.Duration
	HardMaxLag time.Duration
}

// Route never sends a read-your-writes request to a replica that has not
// applied the session version, even when that replica is much less loaded.
func (r *Router) Route(req Request) (Decision, error) {
	if req.Kind == ReadStaleOK {
		if n := r.bestReplica(r.HardMaxLag); n != nil {
			return Decision{Node: n, Reason: "stale-ok replica", Degraded: n.Lag > r.MaxLag}, nil
		}
		if r.Primary != nil && r.Primary.Healthy && !r.Primary.Overloaded() {
			return Decision{Node: r.Primary, Reason: "no replica within hard bound"}, nil
		}
		return Decision{}, ErrStaleBeyondBound
	}
	if r.Primary == nil || !r.Primary.Healthy {
		return Decision{}, ErrNoPrimary
	}
	if req.Kind == ReadYourWrites {
		if n := r.caughtUpReplica(req.SessionVersion); n != nil {
			return Decision{Node: n, Reason: "replica caught up to session"}, nil
		}
		if r.Primary.Overloaded() {
			return Decision{}, ErrOverloaded
		}
		return Decision{Node: r.Primary, Reason: "session write not replicated"}, nil
	}
	if r.Primary.Overloaded() {
		return Decision{}, ErrOverloaded
	}
	return Decision{Node: r.Primary, Reason: "fresh read requires primary"}, nil
}

func (r *Router) bestReplica(maxLag time.Duration) *Node {
	var best *Node
	for _, n := range r.Replicas {
		if !n.Healthy || n.Overloaded() || n.Lag > maxLag {
			continue
		}
		if best == nil || n.InFlight < best.InFlight {
			best = n
		}
	}
	return best
}

func (r *Router) caughtUpReplica(sessionVersion uint64) *Node {
	var best *Node
	for _, n := range r.Replicas {
		if !n.Healthy || n.Overloaded() || n.AppliedVersion < sessionVersion {
			continue
		}
		if best == nil || n.InFlight < best.InFlight {
			best = n
		}
	}
	return best
}

// LagTracker keeps the last size samples and reports a nearest-rank quantile.
type LagTracker struct {
	samples []time.Duration
	next    int
	count   int
}

func NewLagTracker(size int) *LagTracker {
	if size < 1 {
		size = 1
	}
	return &LagTracker{samples: make([]time.Duration, size)}
}

func (t *LagTracker) Observe(d time.Duration) {
	t.samples[t.next] = d
	t.next = (t.next + 1) % len(t.samples)
	if t.count < len(t.samples) {
		t.count++
	}
}

func (t *LagTracker) Quantile(q float64) (time.Duration, bool) {
	if t.count == 0 {
		return 0, false
	}
	buf := append([]time.Duration(nil), t.samples[:t.count]...)
	sort.Slice(buf, func(i, j int) bool { return buf[i] < buf[j] })
	q = math.Max(0, math.Min(1, q))
	idx := int(math.Ceil(q*float64(len(buf)))) - 1
	if idx < 0 {
		idx = 0
	}
	return buf[idx], true
}

bestReplica 只按在途请求数选负载最低的副本,不按延迟;延迟是准入门槛,不是排序权重。caughtUpReplica 的准入条件是 AppliedVersion >= SessionVersion,不是 Lag <= MaxLag:版本已经追上会话写入,就说明这次读能看到自己的写,延迟预算是另一条独立的门槛。

延迟跟踪与三档降级

复制延迟不能只看瞬时值,也不能只用平均值。LagTracker 保留最近 N 个样本,按最近秩取分位数;P99 迟到几百毫秒可能是正常的,但如果 P99 连续多个窗口超过预算,副本就应该被移出读流量。

三档策略写死在路由里:lag <= MaxLag 正常读副本;MaxLag < lag <= HardMaxLag 只有 ReadStaleOK 可以读,并标记 Degraded=true;lag > HardMaxLag 视为不可用,副本不参与路由。ReadYourWrites 在副本未追平时回主库,主库过载则返回 ErrOverloaded,绝不降级到陈旧副本;ReadFresh 只走主库,主库过载同样拒绝,而不是把错误藏进“读到了旧值”。

这一取舍会放大主库压力:副本延迟升高时,所有 RYW 读都会涌向主库。所以回退本身也要有预算。实践上应把回退 QPS 限制在总读流量的一定比例,主库接近容量上限时优先拒绝 ReadFresh 和 ReadStaleOK,保留 RYW 与写路径;如果回退比例持续超过阈值,正确动作是修复制或扩容,而不是继续调大主库连接池。

回退率模拟

用固定种子生成 100,000 个延迟样本:90% 落在 5–39ms,9% 落在 40–299ms,1% 落在 300–1999ms;读请求相对写入的延迟均匀分布在 1–200ms。硬上限 500ms,预算分别取 50、100、200ms。模型假设副本在 lag <= 读延迟 时已经应用了会话写入。

// file: internal/perf/readsplit/simulate.go
// tier: L1
// verify: go test ./internal/perf/readsplit/ -count=1 -v
package readsplit

import (
	"math/rand"
	"time"
)

// Result separates the read-your-writes question from stale-tolerant traffic.
// A read is in RYWPrimary when the replica would not have applied the session
// write, or when replication lag exceeds the freshness budget.
type Result struct {
	Reads         int
	RYWPrimary    int
	StaleReplica  int
	StaleDegraded int
	StaleBeyond   int
}

// Simulate walks paired lag and read-delay samples. The model is deliberately
// simple: a read arriving delay after the write sees it when lag <= delay.
func Simulate(lags, delays []time.Duration, budget, hardMax time.Duration) Result {
	n := len(lags)
	if len(delays) < n {
		n = len(delays)
	}
	res := Result{Reads: n}
	for i := 0; i < n; i++ {
		if lags[i] > delays[i] || lags[i] > budget {
			res.RYWPrimary++
		}
		switch {
		case lags[i] > hardMax:
			res.StaleBeyond++
		default:
			res.StaleReplica++
			if lags[i] > budget {
				res.StaleDegraded++
			}
		}
	}
	return res
}

// LagSamples is a deterministic mixture: 90% low lag, 9% medium, 1% long.
func LagSamples(n int, seed int64) []time.Duration {
	r := rand.New(rand.NewSource(seed))
	out := make([]time.Duration, n)
	for i := range out {
		switch p := r.Float64(); {
		case p < 0.90:
			out[i] = time.Duration(5+r.Intn(35)) * time.Millisecond
		case p < 0.99:
			out[i] = time.Duration(40+r.Intn(260)) * time.Millisecond
		default:
			out[i] = time.Duration(300+r.Intn(1700)) * time.Millisecond
		}
	}
	return out
}

// DelaySamples models 1-200ms between the session's write and its next read.
func DelaySamples(n int, seed int64) []time.Duration {
	r := rand.New(rand.NewSource(seed))
	out := make([]time.Duration, n)
	for i := range out {
		out[i] = time.Duration(1+r.Intn(200)) * time.Millisecond
	}
	return out
}

func Percent(part, total int) float64 {
	if total == 0 {
		return 0
	}
	return 100 * float64(part) / float64(total)
}

测试在 macOS darwin/arm64、Go 1.26.0 上单次运行,命令为 go test ./internal/perf/readsplit/ -count=1 -v,原始输出保存在 .local/reports/be-arch/be-arch-perf-11-3-readsplit-tests.txt。三次预算的结果如下:

预算RYW 回主库占比陈旧副本可服务降级服务超硬上限
50ms19,131 / 100,00019.13%99,1118,771889(0.89%)
100ms18,043 / 100,00018.04%99,1117,048889(0.89%)
200ms17,161 / 100,00017.16%99,1113,540889(0.89%)

预算从 50ms 放宽到 200ms,RYW 回退率只从 19.13% 降到 17.16%,因为主要成本不是预算,而是“读延迟小于复制延迟”:请求来得越快,副本越可能还没应用。降级服务的比例从 8.77% 降到 3.54%,说明预算主要影响陈旧读的降级面,而不是 RYW 的正确性回退。这些是模型数字,不是 PostgreSQL 18.4 的实测复制延迟;真实系统要用 pg_last_wal_replay_lsn 与 pg_current_wal_lsn 的差值或应用层版本采样,本节没有连接数据库。

四类测试覆盖什么

TestReadYourWritesFallsBackWhenLagging 覆盖“刚写完立即读”:会话版本 10,副本已应用到 9,路由器回主库并给出 session write not replicated;把副本版本改成 10 后,同一个请求改走副本。

TestReplicaUnavailableUsesPrimary 覆盖副本不健康:即使副本版本已追平,也不参与路由。TestPrimaryOverloadSheds 覆盖主库过载:ReadFresh 与未追平的 ReadYourWrites 都返回 ErrOverloaded,而不是悄悄读旧副本。TestStaleOKDegradesWithinHardBound 覆盖三档边界:150ms 时副本可服务且 Degraded=true,700ms 时回主库,主库也过载则返回 ErrStaleBeyondBound。

TestLagTrackerQuantile 验证最近秩分位数:样本 10/20/30ms 的 P50 是 20ms、P95 是 30ms,空窗口返回 ok=false。TestFallbackRateUnderLagDistribution 就是上表的确定性复现,同时断言预算放大不会让回退率变差。下面的测试摘录把四类场景压成顺序断言,并用 1 万样本验证预算放大不会提高回退率。

// file: internal/perf/readsplit/router_test.go
// tier: L1
// verify: go test ./internal/perf/readsplit/ -count=1 -v
package readsplit

import (
	"errors"
	"testing"
	"time"
)

func TestRouting(t *testing.T) {
	p := &Node{Name: "primary", Role: Primary, Healthy: true, Capacity: 10}
	rep := &Node{Name: "replica", Role: Replica, Healthy: true, AppliedVersion: 9, Capacity: 10}
	r := Router{Primary: p, Replicas: []*Node{rep}, MaxLag: 100 * time.Millisecond, HardMaxLag: 500 * time.Millisecond}

	d, err := r.Route(Request{Kind: ReadYourWrites, SessionVersion: 10})
	if err != nil || d.Node != p || d.Reason != "session write not replicated" {
		t.Fatalf("ryw lagging: %+v %v", d, err)
	}
	rep.AppliedVersion = 10
	if d, err = r.Route(Request{Kind: ReadYourWrites, SessionVersion: 10}); err != nil || d.Node != rep {
		t.Fatalf("ryw caught up: %+v %v", d, err)
	}
	rep.Healthy = false
	if d, err = r.Route(Request{Kind: ReadYourWrites, SessionVersion: 10}); err != nil || d.Node != p {
		t.Fatalf("replica down: %+v %v", d, err)
	}
	rep.Healthy, rep.Lag = true, 150*time.Millisecond
	if d, err = r.Route(Request{Kind: ReadStaleOK}); err != nil || !d.Degraded {
		t.Fatalf("degraded: %+v %v", d, err)
	}
	p.InFlight = p.Capacity
	if _, err = r.Route(Request{Kind: ReadFresh}); !errors.Is(err, ErrOverloaded) {
		t.Fatalf("overload: %v", err)
	}
}

func TestFallbackRate(t *testing.T) {
	lags := LagSamples(10_000, 7)
	delays := DelaySamples(10_000, 11)
	a := Simulate(lags, delays, 50*time.Millisecond, 500*time.Millisecond)
	b := Simulate(lags, delays, 200*time.Millisecond, 500*time.Millisecond)
	if b.RYWPrimary > a.RYWPrimary {
		t.Fatalf("larger budget increased fallback: %d > %d", b.RYWPrimary, a.RYWPrimary)
	}
	t.Logf("reads=%d fallback_50ms=%d fallback_200ms=%d", a.Reads, a.RYWPrimary, b.RYWPrimary)
}

哪些读可以陈旧,哪些绝不可以

可以接受有界陈旧的读:商品目录与价格展示(下单时还要再校验)、推荐位、运营看板、历史订单报表、全文搜索索引、非权威的浏览计数。它们的共同点是“陈旧只会让展示略旧,不会改变写决策”。

绝不能陈旧的读:本会话写完后的详情、库存可用量校验、支付状态与退款状态、账户余额与账本、权限与角色变更、幂等键查询、任何会被用来做写决策的读。它们的共同点是“读到的值会直接进入下一次写”,陈旧会造成超卖、重复支付或越权。

灰色地带是列表页:用户刚下单,列表可能还没出现新订单。可选做法是写完后把新对象本地合并进首页,或者给该会话一个短时间的 RYW 标记。不要用“最终一致”四个字把这类体验问题掩盖过去。

常见错误与边界

  • 用副本延迟代替版本判断。延迟是估计值,AppliedVersion >= SessionVersion 才是本会话写入可见的充分条件。
  • 主库过载时把 RYW 降级到副本。这不是性能优化,是把一致性错误伪装成可用性。
  • 只跟踪平均延迟。平均值掩盖长尾,应该看 P95/P99 和连续窗口,而不是单点。
  • 回退没有预算。副本一抖动,全部读涌向主库,故障从“读变旧”升级成“主库被打挂”。
  • 把本节模型数字当生产复制延迟。延迟分布、硬上限和预算都必须用目标数据库的监控数据重新标定。

小结

读写分离的正确性边界是“这个读是否必须看到本会话的写”。arch-lab 的路由器用 AppliedVersion 保证 RYW,用 MaxLag/HardMaxLag 把陈旧读分成正常、降级和拒绝三档,并在主库过载时明确返回 ErrOverloaded 而不是读旧副本。10 万次确定性模拟中,预算 50/100/200ms 对应的 RYW 回退率为 19.13%/18.04%/17.16%,降级服务比例为 8.77%/7.05%/3.54%,超硬上限恒为 0.89%。这些是模型结果,不是 PostgreSQL 实测;真实部署必须用数据库复制指标重新标定预算。

阅读导航:上一节:11.2 分页、批处理与连接管理 · 下一节:12.1 压测基线与瓶颈分析 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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