读写分离先把“读到什么”说清楚
读写分离常被描述成“写主库、读从库”,真正决定正确性的是另一句话:这个读能不能接受副本尚未应用的最新写入。把读分成三类,路由才有可讨论的边界: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 回主库 | 占比 | 陈旧副本可服务 | 降级服务 | 超硬上限 |
|---|---|---|---|---|---|
| 50ms | 19,131 / 100,000 | 19.13% | 99,111 | 8,771 | 889(0.89%) |
| 100ms | 18,043 / 100,000 | 18.04% | 99,111 | 7,048 | 889(0.89%) |
| 200ms | 17,161 / 100,000 | 17.16% | 99,111 | 3,540 | 889(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 压测基线与瓶颈分析 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。