《游戏服务端编程实践》1.4.2 分布式与微服务

从拆分触发条件、游戏后端典型服务边界、同步调用与异步消息的取舍出发,说明分布式拆分新增的超时、重试、重复与乱序等失败模式,讨论服务契约、版本管理与模块化单体,并给出一个带健康检查、实例摘除、过期清扫、加权选择和重试排除的内存服务注册表实现与测试。

一、先问为什么拆,而不是怎么拆

微服务不是“更先进”,它是一组用运维复杂度换组织效率的取舍。拆分的合理触发条件通常只有四个:团队规模大到单仓库协作成本过高、发布节奏互相阻塞、故障域需要隔离、某个模块需要独立扩缩容。四个条件都不成立时,拆分只会把函数调用变成网络调用,把编译期错误变成运行时超时。

游戏后端尤其容易误判。登录、网关、世界、战斗、社交、支付看起来都是“服务”,但它们的实时性、状态量和一致性要求差异极大。把战斗拆成独立服务,如果没有想清楚帧同步和状态归属,最后会得到一个“每次技能判定都要跨网络”的系统。

拆分触发条件可观测信号不拆的替代方案
团队协作冲突合并冲突频繁、发布互相等待模块化单体 + 代码所有权
发布节奏不同某模块一周多更,其余按月内部接口 + 独立构建产物
故障域隔离单模块故障拖垮全进程进程内隔离、舱壁、限流
独立扩缩容某模块 CPU/内存明显偏高拆分部署或独立 worker

二、按业务能力拆,而不是按技术分层

常见的错误拆法是“controller 一层、service 一层、dao 一层”,每层一个服务。这种拆法只复制了代码结构,没有复制业务边界,结果是每次改一个功能都要跨三四个服务发版。

正确的拆分单元是业务能力:登录负责身份与票据,网关负责连接与转发,世界负责地图与实体,战斗负责一局内的结算,社交负责关系链,支付负责订单与回调。每个单元应该拥有自己的数据,并通过明确契约暴露能力。

候选单元是否适合独立关键约束
登录适合无状态、可水平扩展
网关适合长连接、会话保持、容量按连接数算
世界通常按地图或分片拆状态重、迁移成本高
战斗视玩法而定帧同步要求高时不宜跨进程
社交适合读多写少、可异步
支付适合强一致、幂等、审计要求高

有些模块必须同进程:高频读写的角色状态、同一帧内的战斗判定、依赖本地缓存的排行榜计算。把它们拆出去,网络往返就会吃掉帧预算。

三、同步调用与异步消息的边界

同步调用适合“调用方必须立刻知道结果”的场景,例如登录校验、支付下单、进入房间前的准入检查。异步消息适合“可以稍后完成”的场景,例如发奖、写日志、更新排行榜、通知好友。

方式优点新增失败模式适用
同步 RPC语义直观、结果确定超时、重试放大、级联故障强依赖、短链路
异步消息解耦、削峰、可重放重复、乱序、积压、丢失最终一致、可补偿
事件流可回放、可审计分区倾斜、消费滞后状态同步、数据分析

拆分之后,原来不会出现的失败模式会一起出现:调用超时但对方其实成功、重试导致重复扣费、消息乱序导致状态回退、消费者积压导致延迟飙升。每一种都要有对应的幂等键、去重表、顺序键和积压告警,而不是假设网络可靠。

四、服务契约与版本

服务契约至少包含四部分:接口名与语义、请求与响应结构、错误码、超时与重试约定。契约变更要向后兼容:新增字段可以,删除字段和改变语义不行;必须变更时,用版本号或双写过渡。

契约不只是 proto 文件。它还包括“这个接口是否幂等”“调用方应该重试几次”“超时时间是多少”“失败后是否要补偿”。这些信息如果只存在于某个人的记忆里,拆分后的系统就无法独立演进。

五、先做模块化单体

在拆成独立进程之前,先在单体内把边界做清楚:模块之间只通过显式接口调用、每个模块拥有自己的数据表、禁止跨模块直接读表、用事件或接口解耦副作用。这样做的成本远低于直接上微服务,而且未来拆分时只需要把接口调用替换成 RPC。

模块化单体还能帮你验证边界是否合理。如果两个模块在单体内仍然频繁互相调用、共享事务,那么拆成服务后只会把耦合变成分布式耦合,问题更糟。

六、失败场景:拆分后共享数据库

某团队把角色服务和背包服务拆成两个进程,但为了“省事”仍然让两个服务直连同一套库表。上线后出现两个问题:背包服务在事务里更新了道具,角色服务却用旧快照覆盖了角色资产;两边都以为自己拥有数据,没有一方对一致性负责。

这类问题的根因不是数据库,而是数据所有权没有随服务边界一起拆。正确做法是每个服务只写自己的表,通过接口或事件暴露变更;跨服务的一致性用 sagas、补偿或对账解决,而不是靠共享事务。

七、可运行实现:带健康检查的服务注册表

下面的注册表提供注册、心跳、健康上报、过期清扫、按权重选择和重试排除。Pick 使用平滑加权轮询,exclude 用于重试时跳过已经失败的实例。

// file: ch01/services/registry.go
// tier: L1
// verify: go test ./ch01/services/
package services

import (
	"context"
	"errors"
	"sort"
	"sync"
	"time"
)

var (
	// ErrInvalidInstance 表示注册信息缺少服务名、实例 ID 或地址。
	ErrInvalidInstance = errors.New("services: invalid instance")
	// ErrNotFound 表示服务或实例不存在。
	ErrNotFound = errors.New("services: instance not found")
	// ErrNoHealthy 表示没有可用的健康实例。
	ErrNoHealthy = errors.New("services: no healthy instance")
)

// Instance 是服务注册表中的一个实例。
type Instance struct {
	ID       string
	Service  string
	Addr     string
	Weight   int
	Healthy  bool
	LastSeen time.Time

	current int
}

// Registry 是一个进程内服务注册表。它适合单机实验和单元测试,
// 生产环境应把同样的语义放到 etcd、Consul 或 Nacos 中。
type Registry struct {
	mu       sync.RWMutex
	services map[string]map[string]*Instance
	now      func() time.Time
}

// NewRegistry 创建一个使用系统时钟的注册表。
func NewRegistry() *Registry {
	return NewRegistryWithClock(time.Now)
}

// NewRegistryWithClock 允许注入逻辑时钟,便于测试心跳过期。
func NewRegistryWithClock(now func() time.Time) *Registry {
	return &Registry{
		services: make(map[string]map[string]*Instance),
		now:      now,
	}
}

// Register 注册或覆盖一个实例。重复注册同一 ID 视为幂等更新,
// 并把健康状态重置为 true。
func (r *Registry) Register(instance Instance) error {
	if instance.ID == "" || instance.Service == "" || instance.Addr == "" {
		return ErrInvalidInstance
	}
	if instance.Weight <= 0 {
		instance.Weight = 1
	}
	if instance.LastSeen.IsZero() {
		instance.LastSeen = r.now()
	}
	instance.Healthy = true
	instance.current = 0

	r.mu.Lock()
	defer r.mu.Unlock()
	byID := r.services[instance.Service]
	if byID == nil {
		byID = make(map[string]*Instance)
		r.services[instance.Service] = byID
	}
	copied := instance
	byID[instance.ID] = &copied
	return nil
}

// Unregister 摘除实例,返回实例是否存在。
func (r *Registry) Unregister(service, id string) bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	byID := r.services[service]
	if byID == nil {
		return false
	}
	if _, ok := byID[id]; !ok {
		return false
	}
	delete(byID, id)
	if len(byID) == 0 {
		delete(r.services, service)
	}
	return true
}

// Heartbeat 刷新实例的存活时间并恢复健康状态。
func (r *Registry) Heartbeat(service, id string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	instance, ok := r.lookup(service, id)
	if !ok {
		return ErrNotFound
	}
	instance.Healthy = true
	instance.LastSeen = r.now()
	return nil
}

// Report 记录一次健康检查结果。probe 返回错误时实例被标记为不健康,
// 但不会被立即删除,便于观察和恢复。
func (r *Registry) Report(service, id string, probeErr error) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	instance, ok := r.lookup(service, id)
	if !ok {
		return ErrNotFound
	}
	instance.Healthy = probeErr == nil
	instance.LastSeen = r.now()
	return nil
}

// Check 执行一次带超时的健康探测并把结果写入注册表。
func (r *Registry) Check(ctx context.Context, service, id string, timeout time.Duration, probe func(context.Context) error) error {
	if probe == nil {
		return errors.New("services: nil probe")
	}
	checkCtx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()
	probeErr := probe(checkCtx)
	if err := r.Report(service, id, probeErr); err != nil {
		return err
	}
	return probeErr
}

// Sweep 把超过 ttl 没有心跳的实例标记为不健康,返回本次状态翻转数量。
func (r *Registry) Sweep(ttl time.Duration) int {
	r.mu.Lock()
	defer r.mu.Unlock()
	now := r.now()
	changed := 0
	for _, byID := range r.services {
		for _, instance := range byID {
			if instance.Healthy && now.Sub(instance.LastSeen) > ttl {
				instance.Healthy = false
				changed++
			}
		}
	}
	return changed
}

// Healthy 返回某服务当前健康的实例,按 ID 排序。
func (r *Registry) Healthy(service string) []Instance {
	r.mu.RLock()
	defer r.mu.RUnlock()
	byID := r.services[service]
	out := make([]Instance, 0, len(byID))
	for _, instance := range byID {
		if instance.Healthy {
			out = append(out, *instance)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
	return out
}

// Pick 用平滑加权轮询选择一个健康实例。exclude 用于重试:
// 调用方把已经失败的实例 ID 放进去,下一次自然会选到别的实例。
func (r *Registry) Pick(service string, exclude map[string]struct{}) (Instance, error) {
	r.mu.Lock()
	defer r.mu.Unlock()

	byID := r.services[service]
	var selected *Instance
	total := 0
	for _, instance := range byID {
		if !instance.Healthy {
			continue
		}
		if _, skip := exclude[instance.ID]; skip {
			continue
		}
		instance.current += instance.Weight
		total += instance.Weight
		if selected == nil || instance.current > selected.current ||
			(instance.current == selected.current && instance.ID < selected.ID) {
			selected = instance
		}
	}
	if selected == nil {
		return Instance{}, ErrNoHealthy
	}
	selected.current -= total
	return *selected, nil
}

func (r *Registry) lookup(service, id string) (*Instance, bool) {
	byID := r.services[service]
	if byID == nil {
		return nil, false
	}
	instance, ok := byID[id]
	return instance, ok
}

测试覆盖注册校验与幂等更新、健康检查失败后的摘除、心跳恢复与过期清扫、显式摘除、重试排除以及加权轮询比例。运行方式:

cd .local/checks/gs-practice-lab
gofmt -l ch01/services
go vet ./ch01/services/
go test ./ch01/services/

小结

拆分的第一步不是选框架,而是证明拆分能解决一个具体问题。按业务能力划分边界,让每个服务拥有数据;同步调用用于强依赖,异步消息用于可延迟的副作用;契约要写清超时、重试和幂等;在拆分之前,先把单体模块化。

常见错误与边界

  • 按技术分层拆服务,导致一个功能改动跨多个服务发版。
  • 为了“微服务化”把高频战斗判定拆出进程,帧预算被网络往返吃光。
  • 拆分后继续共享数据库,数据所有权不清,冲突无人负责。
  • 用分布式事务硬保证强一致,忽略可用性和补偿设计。
  • 同步调用没有超时和重试上限,单点故障被放大成雪崩。
  • 异步消息没有幂等键和顺序键,重复与乱序直接污染状态。
  • 注册表只注册不摘除,故障实例长期留在可选列表里。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist