一个大厅单体怎么退役

拆到网关和三个服务的六个阶段

Posted by walikrence on September 8, 2026

背景脱敏:一个 Go 微服务棋牌项目。服务端有一个「大厅单体」,同时承担客户端长连接接入、登录会话、进厅首屏、进房撮合、上下行消息分发。它是所有玩法服务的必经之路,也是扩容和发版的瓶颈。本文记录从 7 月初立项到 8 月中旬删除本体的六个阶段,以及删完之后审计出来的遗漏清单。文中 MR 编号、环境名、玩法名已去除。

起点:一个进程,三种职责

7 月初摸底时,大厅单体是 98 个非测试 Go 文件、约 8,200 行。它内部其实已经有一套「网关」脚手架(连接管理、路由转发、影子对比),但生产上一个调用都没有,读循环的唯一路径还是内联分发器。更危险的是路由规则有两份:生产分发器里有 827 特判、828 冷却、游戏消息截获等隐性语义,脚手架那份一条都没有。

我们把它的职责拆成三类:

  1. 接入层:持连、握手、保活、顶号、闲置踢人;
  2. 会话域:token 续期、路由键、断线重连恢复;
  3. 业务分发:进厅首屏、进房撮合、上行转发给玩法服务、下行翻译推给客户端。

终态是接入层归网关,会话域归网关,业务分发按数据归属拆给玩家服务、房间管理和网关本地。

六个阶段

Phase A~C:先在进程内接管,再物理拆出

Phase A 把休眠脚手架激活,接管读循环主路径。规则只有一条:以生产分发器的语义为准逐条对照迁移,禁止重写。双份路由规则漂移是最先要消灭的东西。

Phase B 把进厅拆成两段(连接态与业务态分离),为跨进程做准备。

Phase C 物理拆出独立网关进程。这一步的关键设计是灰度完全由服务端控制:客户端只有一条主 WebSocket,地址来自登录回包里的 url 字段。服务端返回 /gw 就走网关,返回 /hall 就走老路。回退等于删掉登录侧的一个灰度环境变量再重启,客户端零改动。

这里踩过一个坑:客户端实际上一直无条件用本地配置覆盖登录回包的 url,所有环境都在绕过网关走硬编码的 /hall。服务端「100% 走网关」在客户端侧形同虚设,直到 7 月 15 日客户端改成「url 非空即生效」才真正接通。灰度设计的前提是对端消费你的开关,这一点要实测,不能推断。

C4:服务发现迁名暴露出「双平面」

把所有 discovery:///hall 目标一刀切迁到新名字(业务本体专用名)后,进厅首屏超时。原因是同一个服务发现名下混着两种语义:

  • 推送到用户平面:玩法服务、玩家服务按路由表里的实例号找「连接持有者」;
  • 业务服务平面:网关的 Join/Forward 要找的是业务本体。

迁名前两平面成员恰好重合,语义混用不显形;迁名让它们分离,凡按「连接持有者」寻址却指到业务本体的调用全部失效。修法是明确双平面拓扑:推送到用户平面固定用 discovery:///hall(网关冒名注册),业务平面用环境变量插值的业务本体专用名。从此 hall 这个名字的语义就是「用户连接接入点」。

故障转移也在这一阶段验过:机器人进厅保持连接,--grace-period=0 --force 强杀网关,大厅侧 10 秒防抖后清理 1 个远端孤儿会话,误杀零;普通滚动删 pod 走优雅路径,复查时 n=0 不误清。

Phase D~E:会话域下移

D 把 token/op_time/路由键续期和闲置踢人下移网关。三个键都是幂等 EXPIRE/SET,双写无害,所以没加开关,但部署顺序成为硬约束:先网关后大厅,反序会出现远端会话无人续期的窗口。E 把顶号判定和执行整体收口网关,判定放在进厅分布式锁内,SS 协议零变更,8248 踢人通知的产生方从 3 处收敛到 1 处。

7 月 31 日 8 个环境(7 测试 + 1 类生产)统一切换,大厅单体完全退出客户端接入面与会话域,只剩业务分发平面。

Phase F:下行业务平面收编

这是最难的一步。大厅在下行链路上不只是转发,还做翻译(内部消息号到客户端消息号)、竞态门禁(迟到的入座回包丢弃)、批次编排(入座批 12289→[200]→4125→[2150] 必须保序)。核心洞察是保序点必须与消息生产点同址:只搬队列不搬翻译和编排毫无意义,所以迁移单位是 handler 而不是队列。

实施走了七步:观测画像 → 影子模式(网关旁路观察,建状态镜像,只计数不生效)→ 对账 → 告警 → 通道加干跑 → 引擎 → 测试环境放量 → 类生产放量。全程零回滚。影子阶段产出两个判据:网关能否纯靠转发流建立正确状态机;12289 到达时绑定命中率。命中率不够就说明门禁需要补上行观察,这一点在真正切流之前就知道了。

淘汰专项:业务分发拆给三个服务

统一切换后大厅只剩上行业务分发,最后用 21 个 MR 拆掉:进房 8513 迁房间管理(门票垫付、write-ahead、回滚一起走);进厅首屏组包迁玩家服务,由网关直接取并下发;在线集合写入方迁网关;8199 等不可达消息退役。大厅的客户端 handler 表从十余个号降到 1 个。

8 月 10 日删除本体:153 个文件,净减 9,706 行。

收口判据:旧路径调用方计数归零

整个过程反复用的判据只有一个:不是「新东西建好了」,而是「旧路径调用方计数归零」

  • 下行收编:gateway_downlink_relay_total{msg_id} 逐消息号计数,归零才算收编完成;
  • 首屏迁移:大厅 business/inbound 处理量归零;
  • 8199 退役:删完用「非定义引用数」逐轮找孤儿,连带清掉整条入队路径与 CI 白名单里的豁免条目;
  • 开关验收:类生产打开首屏开关后两边日志都是 0 条(Debug 级被吞),靠 gateway_first_screen_total{result="delivered"} 合计 6 次确认生效。开关类特性必须带一个日志级别无关的计数指标。

删完之后审计出来的遗漏

编译期零报错、启动期零报错,遗漏只有黑盒冒烟能暴露,而冒烟连红 4 天没人看(allow_failure 加无通知出口)。

路由黑洞。删除转发层当天,传统玩法桌内上行(2xxx 号段等)历史上落在默认路由由大厅转发,网关路由表没配这些号段,全部被静默丢弃。玩家取消托管连发 24 次全丢,重连查状态被丢导致回桌黑屏,局内购买成功但零反馈被误判失败反复扣款。收口冒烟只覆盖了两类玩法的号段,「冒烟全绿」只证明被冒烟的号段活着。修法是按服务端注册号全集对账路由表,并加单测 TestRouteTableCoversLegacyGameC2S 逐个对账。

断链。在线时长巡检定时器随大厅消失;断线不再退匹配队列(LeaveMatchByUser 零断线调用方);取消匹配 711 在无会话时不再回 774,客户端永远卡在取消中。这三处的共同形态是「接口在、客户端在、调用方没了」。

代码搬了配置没搬。巡检逻辑迁到网关很对,但网关没配 mq 模块,事件在 ErrTransportNotInitialized 后经 sync.Once 只打一条 Warn 就静默丢弃,巡检日志照常打「完成」。

失真的注释。网关里含大厅名字的注释 209 行,多数是正当的历史溯源,但有 6 处在描述当前行为却已失真:「回退大厅,保证不丢」实际走丢弃;「断开事件仍须通知大厅」分支体只剩一行 log;keepAliveHall 函数与文件都删了,只剩注释在指。还有一个测开关的单测,做的是 os.Setenvos.Getenv 自己验自己,开关早随灰度结束摘除,它永远绿。

教训

  • 删服务必须先做「副作用调用点清单」:被删代码里所有事件发送、外部调用、定时器、就地回执,逐项写明接管方或「确认死代码」。这次 4 处有冒烟覆盖纯属侥幸,审计又挖出 4 处无覆盖的。
  • 灰度设计的前提是对端真的消费你的开关:客户端无条件覆盖 url 让服务端灰度空转了一周。
  • 改服务发现名前先按调用语义分类:谁在找「连接持有者」、谁在找「业务本体」,dump 一次注册表确认该名下有几条注册链。
  • 迁移的收口判据是旧路径计数归零,不是新路径建好。每项迁移配一条端到端断言,SKIP 不为 0 时逐条确认是预期跳过还是覆盖丢失。
  • 承载者删除时,指向它的注释同批订正。最危险的是带行为承诺的那类(「保证不丢」「幂等跳过」),它们会让后来者跳过验证直接采信。