背景脱敏:一个 Go 微服务架构的牌类游戏后端,正式服四个 8 核节点,目标是 10 万同时在线。从 8 月中旬到月底跑了二十多轮爬坡压测,撮合拐点从约 13k 推到峰值 152,983 在线。本文一半讲瓶颈是怎么一层层剥开的,另一半讲中途下错的结论:每一个都不报错、看起来有理有据,只有实测能证伪。
瓶颈是一层剥开一层的
容量工程没有「找到那个瓶颈」这回事。修掉一层,下一层立刻顶上来,每千人全栈成本从 0.29 核降到 0.22 核就是这么一刀刀省出来的。按时间顺序:
| # | 层 | 识别信号 | 治法 | 效果 |
|---|---|---|---|---|
| 1 | 用户基础表无主键 | 点查 WHERE user_id=? 也卡 Sending data,SHOW INDEX 返回空 |
补主键 + 门禁「注册进 ORM 的实体必须有键」 | 3000 新号 48s 满员,行锁峰值 103→2 |
| 2 | 撮合 tick 全池栅栏 | 单个慢池拖慢所有池的撮合节奏 | 每池独立协程 + inflight 守卫 | 30k 满载撮合 P99 29.4s→1.98s |
| 3 | MySQL prepare/execute/close 三连 | global status 差分 Com_stmt_prepare ≥ Com_stmt_execute |
DSN 加 interpolateParams=true |
主库 QPS ↓77%、B 库 ↓90% |
| 4 | 消费吞吐被「回源延迟×并发」钉死 | 236/s ≈ 128 并发 ÷ 519ms | workers 16→64 | 30k 满员 lag 自愈到 29 |
| 5 | 回填 99% 耗时是锁排队 | 分段直方图:flush 段占 99%,MySQL 读仅 12ms | 锁外 EXISTS 短路 | 回源 P99 773ms→16.7ms |
| 6 | 房间管理 goroutine 风暴 | 每玩家 5~7 个裸协程,context.Background() 无超时 |
RPC 3s/5s 超时兜底 | 清场收尾 50 分钟→分钟级 |
| 7 | 登录整形与建号池 | 号池耗尽后建号 160/s 成为在线顶 | 预建号池 + 登录整形门控 | 池路径 2000/s |
| 8 | 域事件落库 WAL | 发布 788/s > 消费能力,lag 真积压 87 万 | group-commit:20ms/500 行一次 InsertMulti | 积压斜率降约 2/3,首破 102k |
| 9 | WAL 表 flush 与 insert 锁互等 | 满载 Error 1205 + lag 浸泡累积 |
flush 事务 RC + SKIP LOCKED | lag 从 27.7 万累积变实时追平 |
| 10 | 席位闸门 | 每服 3000 桌硬顶,4 服≈4.8 万席位 | 游戏服 ×2、×3 | 129k |
| 11 | Redis 单线程 | 主线程 0.935 逼满,26 万 ops/s | io-threads=4 + 热数据零申领省 6/7 路 IO | 12.3 万 ops/s 时主线程 0.411 |
最后还是卡在 Redis 单线程与节点 CPU:150k 时 kafka lag 从千级发散到 24 万,四节点 CPU 0.93~0.97。结论是稳态可持续约 130k,再往上加节点是先决条件。
另有两个不是「瓶颈」而是「缺陷」的层顺手被揪出:机器人 uid 池被 3.6 万桌需求加上轮残租抽干(AllocUid 失败 55 万次/5min,匹配 P99 恶化到 236s,扩池 3 倍后归零);扩副本后号段池容量没跟着扩,6 个 Pod 抢 4 个号,抢不到的回退静态 ID 造成双主。
三次「口径不可比」
真正想写的是误判。二十多轮压测里下错的结论比修掉的瓶颈还多,归成四类。
一、口径不可比
观测窗口不等长。 做「建号是否有独立影响」的对照实验,池内组跑了 32 分钟满达成 8000/8000,越池组跑了 5 分钟到 2242/3000。汇报成「池内满达成、越池只到 75%」。整理成文时回读曲线,越池组最后 60 秒还在净增 296 人,全程 60 个采样点只有 3 个下降点,是脚本采完 60 个点就 kubectl delete job 把它掐了。汇报表格列了并发、账号数、在线峰值,唯独没列观测时长。没进表的维度就等于没被比较。
资源指标口径不同。 报「某节点内存涨到 89%」,实测稳定 76~78%。kubectl top 是 allocatable 口径,Prometheus 是 MemTotal 口径,两个数根本不该放在一起比。
滚更窗口叠加值。 一次 129,702 的在线纪录,一半靠新旧 ReplicaSet 共存期 16 个 Pod 持桌的巧合;稳态复测 12 Pod 只有 110k。判读必须剔除滚更窗口。
第三条还有个反转:110k 那轮一度被定性为「房间管理派桌吞吐是服务端瓶颈」。铁证是施压端 26 批只有 22 个 Pod Running,22×5000=110,000 与在线分毫不差,服务端全指标健康。在线钉死在「批数×每批人数」的整数上,先查施压 Pod 存活数,再谈服务端瓶颈。 Pending 的根因是 CPU request 超卖,实际节点 CPU 只用了 13~58%。
二、冷启动当稳态
「日志系统 30 小时写满」,实测 0.36 GB/天,差 200 倍。外推用的是冷启动 4 小时的数据,那段含 WAL 建立、索引初始化、回填。反向判据:曲线有涨有跌说明已进平衡态,单调上升才是真积累。
还有一条更隐蔽:performance_schema 的 digest 排行显示登录路径 8 类 SQL 累计 4.7 亿次调用,据此几乎要立项做「登录路径批量化」大改造。两次快照相减(30 分钟窗口)之后,总调用增量只有 569 次,登录路径增量为零。那 4.7 亿次是七八轮压测打出来的历史累计。任何来自 performance_schema / SHOW GLOBAL STATUS 的排行都是累计量,用来排优先级之前必须做增量、确认窗口不跨压测。
三、瞬时采样点
同一天三次基于单点采样得出结论,三次都被推翻:
| 当时的结论 | 依据 | 被什么推翻 |
|---|---|---|
| 撮合 P99 在单调劣化 | 三个瞬时点 0.93→1.32→2.79s | 分档聚合:800 人档峰值 3.33s 反而高于 1600 人档的 1.59s |
| 拐点找到了,三个服务 76~81% 逼近上限 | 爬升期一次 kubectl top |
稳态回落到 23% / 17% / 一位数 |
| 发压机成了瓶颈,1468m 超线性增长 | 切档时一次 kubectl top |
几分钟后回落到 661m,是新增 250 桌的建连尖峰 |
形态完全一样:系统处于瞬态(冷启动 / 切档 / 建连风暴)时采了一个点,当成了稳态水位。压测器本身就输出「只计保持窗口内增量」的档位统计,三次误判全部发生在档位统计还没出、先看了眼瞬时指标的间隙。
单调性是廉价而有力的证伪工具:8000 人档 97.0% 优于 7000 人档 91.9%,非单调立刻排除「7000 是容量墙」,真撞墙不会人更多反而更好。
四、把后果当成因
追了一整天的「正式服匹配回归」不存在。压测器背靠背跑,上一轮的对局在 AI 托管下要继续打约 20 分钟,窗口内起下一轮,同一批账号被判定断线重连、resume 回上一局,等一个早已走远的局面直到超窗。一天里连续四次拿到和故障强相关的信号当根因,全错。
最贵的一次:摘掉全部本地改动重跑、现象依旧,据此排除了自己的嫌疑。但那次对照同样跑在残局窗口里,阴性结果不构成任何证据。先证明环境干净,再做对照。
同一家族还有两条:分位数在样本稀疏时约等于最慢一人(80k 满载撮合成功率仅 0.04/s,P99 29s 是每 10 分钟约 20 个样本里最慢的那个);kafka exporter 在 rebalance 时报出的 24 万 lag,与发布速率 0.1~1.2/s 物理矛盾,还拍出过负值。分位数与 lag 类指标,先看样本率和发布率再下结论。
代理指标在失败态下反向
用 rate(login_requests_total{status="success"}) 代理建号速率,读数峰值 74.6/s,而数据库实际只新建 926 个账号,差一个数量级。原因是 5s 重试在塌陷时空转刷成功计数。代理指标只在健康态成立,一旦进入失败态,代理与权威计数朝相反方向走,此时只信权威计数。 这次是顺手留了数据库交叉校验才发现,属侥幸。
教训
- 先埋点再动刀。三刀全部由「差分/分段数据→定位→最小修复→复验」闭环;反例是差点按「批量 SELECT IN」开工,而 MySQL 读只占 12ms。
- 对照实验的各臂参数由脚本统一下发,汇总表必须有「观测时长」与「是否收敛」两列。终止条件是系统性质(达标或收敛),固定轮数只能作超时上限,超时退出的轮只能报下界。
- 档位统计出来之前,瞬时指标只用于「发现值得看」,不用于「下结论」。
- 报某个数之前先问:它和我要对比的数是同一口径吗?采样窗口跨过冷启动了吗?
- 异步化有守恒律。订阅异步化只是转移排队位置,下游处理能力不足时背压必然回传;必须配「worker × 单事件成本 ≥ 峰值事件率」的吞吐对账。
- 合并率/频率类参数必须过满载验证。group-commit 窗口从 20ms 改成 2ms「空闲即冲」,单测全绿,满载直接锁雪崩。