结算之后,为什么还是旧战功
《群岛纪元》的一次跨服攻城结束后,玩家在结算页看到自己获得 1200 战功,点回联盟名册,战功还是 800。刷新两次后数字才变。运营收到反馈时第一反应是“结算写失败了”,查日志却发现主库写入成功,只是名册读的是从库,而复制延迟在那个时段接近 900 毫秒。
同类问题在不同产品里反复出现:
- 《裂隙战场》赛后立即刷新段位,看到的是上一场结算前的分数。
- 《泡泡工坊》购买道具后马上打开背包,道具还没出现。
- 《群岛纪元》封禁一个账号后,其他玩家仍能在排行榜上看到他的成绩。
这些都属于“写主库、读从库”带来的可见性窗口。读写分离能降低主库压力,但把“写完立刻读到”的假设打破了。这一节讨论哪些读必须接受这个代价,哪些不能,以及从库、主库、复制链路分别出问题时先牺牲什么。
主从延迟窗口有多大
复制延迟不是一个固定值。正常时段可能是几十毫秒,跨区域复制、批量结算或从库回放跟不上时,会涨到几百毫秒甚至几秒。对玩家来说,写完到下一次读之间通常只有几百毫秒,正好落在延迟窗口里。
| 场景 | 典型延迟(示例值) | 玩家感知 |
|---|---|---|
| 同区域异步复制 | 20–80 毫秒 | 多数情况下无感 |
| 跨区域异步复制 | 100–500 毫秒 | 切屏后可能看到旧值 |
| 批量结算期间 | 500 毫秒–2 秒 | 明显旧值 |
| 从库回放中断 | 无上限 | 持续旧值,直到修复 |
延迟的度量也要小心。监控上的“复制延迟”可能是从库自己上报的秒数,也可能由主库的写入位点与从库回放位点估算。两种口径在高峰期会差很多,判定读主时应该用“这个从库已经应用到哪个版本”,而不是一个笼统的秒数。
读己之写:三种手段与代价
“读己之写”要求同一个会话写完某个 key 后,后续读至少能看到自己写入的版本。实现手段有三种:
| 手段 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 版本令牌 | 写操作返回版本号,读时带上;从库版本不够就等待或读主 | 精确,只影响相关读 | 需要传递令牌,读路径多一次比较 |
| 会话粘滞 | 写后一段时间内该会话的读都走主库 | 实现简单,不依赖版本 | 主库压力上升,故障切换时粘滞状态丢失 |
| 强制读主 | 某类读永远走主库 | 语义最清晰 | 成本最高,主库容易成为瓶颈 |
北辰工作室的选择是:资产、结算、支付、封禁这类读强制读主;普通资料、榜单、地图读从库;写后读用版本令牌,令牌过期后按延迟预算决定等待还是读主。会话粘滞只作为令牌丢失时的兜底,不作为主方案。
版本令牌的关键是版本号必须来自主库的单调序列,不能由客户端生成。写操作在同一个事务里分配版本并返回,读请求把版本带到路由层,路由层比较从库已应用的版本。
哪些读必须读主
| 读类型 | 例子 | 是否读主 | 理由 |
|---|---|---|---|
| 资产 | 金币、道具、背包 | 是 | 旧值会导致重复消费或显示错误 |
| 结算 | 战功、赛季积分、奖励 | 是 | 结算结果必须立即可见 |
| 支付 | 订单状态、发货结果 | 是 | 涉及钱,不能有可见性窗口 |
| 封禁 | 封号、禁言、风控状态 | 是 | 旧值会放行已被处罚的账号 |
| 关系链 | 好友、黑名单 | 令牌约束 | 强一致,但可按版本判定 |
| 玩家资料 | 等级、头像、签名 | 否 | 短时间旧值可接受 |
| 榜单 | 排名、积分 | 否 | 可旧,展示层标注即可 |
| 大地图 | 城池归属、资源点 | 否 | 秒级旧值可接受 |
这张表不是永久不变的。活动期间“榜单”可能变成结算依据,那时它就应该临时升级为读主;反过来,资产读如果已经通过令牌严格约束,也不一定每次都要读主。判定依据是“旧值会不会改变玩家决策或造成资损”,不是“这个字段重不重要”。
延迟预算:等多久,还是直接读主
令牌版本高于从库版本时,路由层有三个选择:等待从库追上、直接读主、用本地快照兜底。等待需要预算,否则复制中断时请求会一直挂着。
预算通常从写操作完成时刻算起。比如写后 500 毫秒内允许等待,超过就转读主;如果主库已经过载,则退到本地快照或拒绝。这个预算要和业务 SLA 对齐:结算页可以等 200 毫秒,大厅列表不值得等。
write v11 ──> token(v11, t0)
│
read at t0+100ms ───┼── replica applied v10 -> wait 最多 500ms
│
read at t0+700ms ───┴── 等待预算用完 -> 读主(主库有余量)
-> 本地快照(主库过载)
-> 拒绝(无快照)
降级顺序:先牺牲哪个读
读写分离的降级不是一个开关,而是三条链路分别出问题时的优先级:
- 从库挂掉:先保住有本地快照的容忍读,再把必须可见的读转到主库;没有快照又没有主库余量时拒绝,而不是把请求压到主库。
- 主库压力过高:敏感读仍然读主,因为不能牺牲正确性;容忍读留在从库或本地,不再因为令牌过期而转主库。
- 复制中断:带令牌且版本落后的读转主库;容忍读继续读从库,但必须标记为旧值;敏感读本来就在主库,不受影响。
这个顺序的核心是:正确性优先于可用性,可用性优先于新鲜度。不能为了“都能读”把敏感读放到从库,也不能为了“都新鲜”把主库压垮。
代码:令牌、延迟预算与读主判定
下面的 internal/rwvisibility/router.go 把读分类、令牌比较、等待预算和降级顺序放在一个包里。Observe 更新各分片从库的水位,Decide 返回目标、原因、等待时长和是否需要标注旧值。
// file: internal/rwvisibility/router.go
// tier: L1
// verify: go test ./internal/rwvisibility/
package rwvisibility
import (
"sync"
"time"
)
// Class 描述一次读的语义类别。Sensitive 的读永远不能落到从库。
type Class int
const (
Profile Class = iota
Leaderboard
Wallet
Settlement
Payment
Ban
)
func (c Class) Sensitive() bool {
switch c {
case Wallet, Settlement, Payment, Ban:
return true
default:
return false
}
}
// Token 是写后返回给会话的可见性令牌,Version 来自主库的单调版本号。
type Token struct {
Session string
Key string
Version uint64
IssuedAt time.Time
}
// Target 是路由结论。
type Target int
const (
TargetReplica Target = iota
TargetPrimary
TargetWait
TargetLocal
TargetReject
)
// Reason 是机器可读的判定原因,用于埋点和降级统计。
type Reason int
const (
ReasonOK Reason = iota
ReasonEmptyKey
ReasonSensitive
ReasonReplicaDown
ReasonReadYourWrite
ReasonLagBudget
ReasonWaitBudget
ReasonPrimarySaturated
ReasonNoRoute
)
// Decision 是一次读路由的结果。Stale 为真表示调用方必须标注数据可能过期。
type Decision struct {
Target Target
Reason Reason
Wait time.Duration
Stale bool
}
// Policy 是延迟预算:复制落后多少就不再信任从库,读己之写最多等多久。
type Policy struct {
LagBudget time.Duration
WaitBudget time.Duration
}
func (p Policy) normalized() Policy {
if p.LagBudget <= 0 {
p.LagBudget = 2 * time.Second
}
if p.WaitBudget <= 0 {
p.WaitBudget = 500 * time.Millisecond
}
return p
}
// ReplicaState 是某个分片从库的观测状态。
type ReplicaState struct {
Applied uint64
Lag time.Duration
Healthy bool
SeenAt time.Time
}
// Request 是一次读请求。Shard 用于定位从库状态,Token 仅在写后读时携带。
type Request struct {
Key string
Shard string
Class Class
Token *Token
PrimaryUp bool
PrimarySaturated bool
LocalSnapshot bool
Now time.Time
}
// Router 保存各分片的从库水位,并按策略给出读路由。
type Router struct {
mu sync.RWMutex
policy Policy
replicas map[string]ReplicaState
}
func New(policy Policy) *Router {
return &Router{policy: policy.normalized(), replicas: map[string]ReplicaState{}}
}
// Observe 更新从库水位。Healthy 为假时 Applied 与 Lag 只保留供排查。
func (r *Router) Observe(shard string, applied uint64, lag time.Duration, healthy bool, at time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
r.replicas[shard] = ReplicaState{Applied: applied, Lag: lag, Healthy: healthy, SeenAt: at}
}
func (r *Router) Replica(shard string) (ReplicaState, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
state, ok := r.replicas[shard]
return state, ok
}
func (r *Router) Decide(req Request) Decision {
if req.Key == "" {
return Decision{Target: TargetReject, Reason: ReasonEmptyKey}
}
now := req.Now
if now.IsZero() {
now = time.Now()
}
r.mu.RLock()
state, known := r.replicas[req.Shard]
policy := r.policy
r.mu.RUnlock()
if req.Class.Sensitive() {
if req.PrimaryUp {
return Decision{Target: TargetPrimary, Reason: ReasonSensitive}
}
return Decision{Target: TargetReject, Reason: ReasonNoRoute}
}
if !known || !state.Healthy {
return degraded(req, ReasonReplicaDown)
}
if req.Token != nil && req.Token.Version > state.Applied {
left := policy.WaitBudget - now.Sub(req.Token.IssuedAt)
if left > 0 {
return Decision{Target: TargetWait, Reason: ReasonReadYourWrite, Wait: left}
}
if req.PrimaryUp && !req.PrimarySaturated {
return Decision{Target: TargetPrimary, Reason: ReasonReadYourWrite}
}
if req.LocalSnapshot {
return Decision{Target: TargetLocal, Reason: ReasonWaitBudget, Stale: true}
}
if req.PrimaryUp {
return Decision{Target: TargetReject, Reason: ReasonPrimarySaturated}
}
return Decision{Target: TargetReject, Reason: ReasonNoRoute}
}
if state.Lag > policy.LagBudget {
return Decision{Target: TargetReplica, Reason: ReasonLagBudget, Stale: true}
}
return Decision{Target: TargetReplica, Reason: ReasonOK}
}
// degraded 处理从库不可用:先保住有本地快照的容忍读,再考虑主库余量。
func degraded(req Request, reason Reason) Decision {
if req.LocalSnapshot {
return Decision{Target: TargetLocal, Reason: reason, Stale: true}
}
if req.PrimaryUp && !req.PrimarySaturated {
return Decision{Target: TargetPrimary, Reason: reason}
}
if req.PrimaryUp {
return Decision{Target: TargetReject, Reason: ReasonPrimarySaturated}
}
return Decision{Target: TargetReject, Reason: ReasonNoRoute}
}
几个关键点:
Class.Sensitive()覆盖资产、结算、支付、封禁,这类读在主库不可用时直接拒绝,不回退从库。- 令牌版本高于从库已应用版本时,先按
WaitBudget计算剩余等待时间;预算用完才考虑读主。 - 主库过载时,令牌读不再转主库,而是优先用本地快照;没有快照则拒绝并给出
PrimarySaturated。 - 从库不可用时,有本地快照的容忍读走本地,其余才考虑主库余量。
- 复制延迟超过
LagBudget时仍可读从库,但Stale为真,调用方必须标注。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 令牌丢失 | 客户端重连后不知道写到哪个版本 | 读主或按会话粘滞窗口处理 |
| 时钟不一致 | 等待预算算错 | 令牌时间用服务端单调时钟,不用客户端时间 |
| 延迟指标过期 | 路由以为从库很新 | 给 Observe 加时效判断,过期状态按不可用处理 |
| 主库切换 | 新主库版本可能落后 | 切换期间敏感读拒绝,容忍读标注旧值 |
| 等待堆积 | 复制中断时大量请求挂起 | 等待预算硬上限,超时转主或拒绝 |
代码里的 ReplicaState 带了 SeenAt,但 Decide 没有检查它是否过期。生产实现应该加一条规则:如果 SeenAt 距当前时间超过阈值,就把这个从库当作不可用,而不是继续相信旧的 Applied 和 Lag。
三款产品的落点
| 产品 | 强制读主 | 令牌读 | 可旧读 |
|---|---|---|---|
| 《裂隙战场》 | 资产、结算、封禁 | 赛后段位、赛季积分 | 排行榜、玩家资料 |
| 《群岛纪元》 | 资产、跨服结算、支付 | 攻城战功、联盟贡献 | 大地图、跨服榜单 |
| 《泡泡工坊》 | 购买、背包、封禁 | 活动进度 | 大厅配置、好友动态 |
三款产品的共同点是:钱和处罚不能旧,展示性数据可以旧。差别在于《群岛纪元》的跨区域复制延迟更大,令牌等待预算要按区域配置;《泡泡工坊》的读量小,强制读主的成本更容易接受。
常见错误与边界
- 把所有读都切到从库,写完立刻读的场景必然出问题。
- 令牌版本由客户端或缓存生成,无法和主库版本比较。
- 等待预算没有上限,复制中断时请求全部挂起。
- 主库过载时仍把令牌读转主库,加速主库崩溃。
- 从库延迟指标不检查时效,用几分钟前的状态做路由。
- 敏感读在主库不可用时回退从库,用旧资产值继续服务。
小结
读写分离的代价是可见性窗口,不是“偶尔读到旧值”这么简单:资产、结算、支付、封禁的旧值会直接造成资损或错误放行。读己之写用主库分配的版本令牌来约束,令牌落后时在等待预算内等从库,超预算再按主库余量决定读主、读本地快照还是拒绝。降级顺序必须是正确性优先于可用性、可用性优先于新鲜度:先牺牲容忍读,再考虑主库,敏感读永远不将就。下一章转向异步消息,讨论队列选型和投递语义。
阅读导航:上一节:5.2 热 key、穿透与击穿 · 下一节:6.1 队列选型与投递语义 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。