《游戏服务端架构·规模化、全球化与成本》5.3 读写分离的可见性兜底

从《群岛纪元》攻城结算后立刻刷新仍看到旧战功的现象切入,拆解主从延迟窗口与读己之写的冲突,比较令牌、会话粘滞与强制读主三种手段的代价,给出资产、结算、支付、封禁等读的主库判定,以及从库故障、主库压力和复制中断时的降级顺序与可运行 Go 实现。

结算之后,为什么还是旧战功

《群岛纪元》的一次跨服攻城结束后,玩家在结算页看到自己获得 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 ───┴── 等待预算用完 -> 读主(主库有余量)
                                      -> 本地快照(主库过载)
                                      -> 拒绝(无快照)

降级顺序:先牺牲哪个读

读写分离的降级不是一个开关,而是三条链路分别出问题时的优先级:

  1. 从库挂掉:先保住有本地快照的容忍读,再把必须可见的读转到主库;没有快照又没有主库余量时拒绝,而不是把请求压到主库。
  2. 主库压力过高:敏感读仍然读主,因为不能牺牲正确性;容忍读留在从库或本地,不再因为令牌过期而转主库。
  3. 复制中断:带令牌且版本落后的读转主库;容忍读继续读从库,但必须标记为旧值;敏感读本来就在主库,不受影响。

这个顺序的核心是:正确性优先于可用性,可用性优先于新鲜度。不能为了“都能读”把敏感读放到从库,也不能为了“都新鲜”把主库压垮。

代码:令牌、延迟预算与读主判定

下面的 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 队列选型与投递语义 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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