一、先问为什么拆,而不是怎么拆
微服务不是“更先进”,它是一组用运维复杂度换组织效率的取舍。拆分的合理触发条件通常只有四个:团队规模大到单仓库协作成本过高、发布节奏互相阻塞、故障域需要隔离、某个模块需要独立扩缩容。四个条件都不成立时,拆分只会把函数调用变成网络调用,把编译期错误变成运行时超时。
游戏后端尤其容易误判。登录、网关、世界、战斗、社交、支付看起来都是“服务”,但它们的实时性、状态量和一致性要求差异极大。把战斗拆成独立服务,如果没有想清楚帧同步和状态归属,最后会得到一个“每次技能判定都要跨网络”的系统。
| 拆分触发条件 | 可观测信号 | 不拆的替代方案 |
|---|---|---|
| 团队协作冲突 | 合并冲突频繁、发布互相等待 | 模块化单体 + 代码所有权 |
| 发布节奏不同 | 某模块一周多更,其余按月 | 内部接口 + 独立构建产物 |
| 故障域隔离 | 单模块故障拖垮全进程 | 进程内隔离、舱壁、限流 |
| 独立扩缩容 | 某模块 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/
小结
拆分的第一步不是选框架,而是证明拆分能解决一个具体问题。按业务能力划分边界,让每个服务拥有数据;同步调用用于强依赖,异步消息用于可延迟的副作用;契约要写清超时、重试和幂等;在拆分之前,先把单体模块化。
常见错误与边界
- 按技术分层拆服务,导致一个功能改动跨多个服务发版。
- 为了“微服务化”把高频战斗判定拆出进程,帧预算被网络往返吃光。
- 拆分后继续共享数据库,数据所有权不清,冲突无人负责。
- 用分布式事务硬保证强一致,忽略可用性和补偿设计。
- 同步调用没有超时和重试上限,单点故障被放大成雪崩。
- 异步消息没有幂等键和顺序键,重复与乱序直接污染状态。
- 注册表只注册不摘除,故障实例长期留在可选列表里。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。