面试案例:混合流量导致 RPC 消息队列堆积
真实经历:军情系统初版上线后,城战期间大量 RPC 涌入导致消息队列堆积,军情查询不刷新。
本文目标 :你用这个文档准备面试,重点不是记住结论,而是能一句一句把推理链讲清楚。
面试中最容易卡住的三个问题
面试官问你"怎么排查的",你大概率会卡在这三个地方:
| 卡点 | 你的回答不能只是 | 你需要能讲出来 |
|---|---|---|
| "你怎么知道是消息队列堆积?" | "我看了监控" | 看了哪个监控?什么指标?数值多少?和正常值差多少? |
| "你怎么区分是 CPU 高导致的堆积,还是 RPC 阻塞导致的堆积?" | "CPU 不高就说明不是 CPU 问题" | CPU 和 MQ 的关联关系是什么?为什么这个组合能排除 CPU 瓶颈? |
| "你怎么证明是城战的 RPC 堵住了军情的 RPC?" | "日志里能看到" | 你统计了什么数据?两种 RPC 各占多少比例?为什么这个比例能说明问题? |
下面每一步,都给你准备了可以原样说出口的"面试话术"。
一、故障背景(STAR - S 情境)
一句话概括
"某次版本上线了军情系统后,城战高峰期间,玩家反馈军情页面打开后一直转圈不刷新,同时也有玩家反馈聊天消息延迟好几秒。"
关键信息(面试时要主动说的)
- 什么服务:军情服务(查询/更新玩家战报数据)
- 什么时候:城战高峰期(大量玩家同时在线对战)
- 什么变化:新上线的版本增加了军情系统
- 什么现象:军情页面不刷新 + 聊天消息延迟
二、排查全过程(STAR - A 行动)
第一步:接到告警------确认"真的有问题,不是个别玩家网络卡"
面试话术:
"收到玩家反馈后,我第一步不是去看代码,而是先确认问题是否真实存在、影响范围多大。我们有一套自建的监控体系,其中最直接的是 alert_engine------它是一个独立的告警服务,每 10 秒从 metrics_collector 拉取所有指标,对照预设的阈值规则来判断是否触发告警。"
实际操作:
bash
# alert_engine 每 10s 评估一次,输出 [ALERT] 或 [RECOVER] 日志
grep "\[ALERT\]" log/battle.log | tail -20
期望看到的输出:
[ALERT][battle] military_svr mqlen 230 >= threshold 10
[ALERT][battle] military_svr mqlen 280 >= threshold 10
[ALERT][battle] military_svr mqlen 340 >= threshold 10
从这行日志你能得出什么:
| 信息 | 含义 |
|---|---|
[ALERT] |
确认不是假阳性,告警已触发 |
military_svr |
问题出在军情服务 |
mqlen 230 |
消息队列长度 230,远超阈值 10 |
| 连续 3 条 | 不是瞬时抖动,是持续恶化(230→280→340) |
第二步:看 stat_poller------同时拿到 MQ 和 CPU,两条曲线对照
面试话术:
"alert_engine 告诉我 mqlen 高了,但这只是现象,不是根因。mqlen 高有两种可能:一是 CPU 跑满了处理不过来(计算瓶颈),二是处理被阻塞了在等别的东西(IO 阻塞)。这两种情况修复方向完全不同,所以第二步必须拿到 CPU 数据来区分。"
stat_poller 是什么 :它是一个独立的采集服务,每 5 秒对所有被监控的服务调用一次 skynet.call(addr, "debug", "STAT"),拿到 skynet 内置的 mqlen、cpu、message 三个指标,同时输出到日志和上报给 metrics_collector。
实际操作:
bash
grep "stat_poller" log/battle.log | tail -6
期望看到的输出:
[stat_poller] battle military_svr mqlen=180 cpu=15000000 msg_total=4200
[stat_poller] battle military_svr mqlen=230 cpu=14000000 msg_total=4600
[stat_poller] battle military_svr mqlen=280 cpu=16000000 msg_total=5100
[stat_poller] battle military_svr mqlen=340 cpu=14500000 msg_total=5600
[stat_poller] battle military_svr mqlen=420 cpu=15500000 msg_total=6100
[stat_poller] battle military_svr mqlen=510 cpu=15000000 msg_total=6700
面试时你要用手指着这三个数一个个解读:
"你看这三列数据------"
mqlen: 180 → 230 → 280 → 340 → 420 → 510
每 5 秒涨 50~80,持续上涨,不是抖动
cpu: 15000000 → 14000000 → 16000000 → 14500000 → 15500000 → 15000000
稳定在 14~16ms/5s窗口 之间,没有明显上升
msg_total: 4200 → 4600 → 5100 → 5600 → 6100 → 6700
每 5 秒新增 400~500 条消息,消息到达速率稳定
"关键判断 :mqlen 持续上涨,但 cpu 没有同步上涨。如果 CPU 是瓶颈,mqlen 涨的时候 cpu 应该也涨(处理每条消息都要消耗 CPU)。现在 cpu 稳定在低位,说明消息处理没有在密集计算,而是在等什么东西。"
这一步排除的假设:
| 假设 | 如果成立,应该看到的 | 实际看到的 | 结论 |
|---|---|---|---|
| 死循环 / 密集计算 | CPU 持续高位(>80%) | CPU 14~16ms 低位 | ❌ 排除 |
| 内存泄漏导致 GC 频繁 | memory_monitor 显示内存持续增长 + CPU 升高 | CPU 稳定 | ❌ 排除 |
| 消息处理被阻塞 | CPU 低 + MQ 持续涨 | ✅ 完全吻合 | ⚠️ 重点怀疑 |
第三步:pstack------看清楚阻塞在等什么
面试话术:
"排除了 CPU 瓶颈后,下一步要搞清楚它在等什么。思路很简单:如果它在等网络,说明是在等 RPC 返回或跨节点通信;如果它在等锁,说明是死锁。pstack 可以零侵入地看进程此刻卡在哪个系统调用上。"
pstack 的原理 :它不需要暂停进程,只是读取 /proc/PID/stack,打印当前线程的内核态调用栈。对线上服务零影响。
实际操作:
bash
PID=$(pgrep -f "config.battle" | head -1)
# 连续采样 5 次,间隔 2 秒,看栈顶是否稳定在同一位置
for i in 1 2 3 4 5; do
echo "=== 采样 $i ==="
sudo pstack $PID | head -30
sleep 2
done
期望看到的关键行(5 次采样中反复出现):
=== 采样 1 ===
#0 __epoll_wait(...) ← 在等网络事件
#1 skynet_socket_poll(...)
#2 skynet_context_message_dispatch(...)
#3 luaM_pcall → military_svr:handle_query(...)
=== 采样 2 ===
#0 __epoll_wait(...) ← 又是 epoll_wait
#1 skynet_socket_poll(...)
#2 ...
#3 luaM_pcall → rpc.call(...) ← 在 RPC 调用内部等响应
=== 采样 3 ===
#0 __epoll_wait(...) ← 还是 epoll_wait
...
=== 采样 4 ===
#0 __epoll_wait(...)
...
=== 采样 5 ===
#0 __epoll_wait(...)
...
面试话术:
"pstack 的结果非常明确:5 次采样,栈顶全部是
__epoll_wait。这说明什么?说明军情服务的消息处理线程不是在跑 CPU,而是在等网络 IO------大概率是在等 RPC 调用返回、或者在等跨节点的 cluster 消息传输完成。如果栈顶是
luaV_execute或luaD_call,那说明在跑 Lua 代码,是计算问题。但这里 5 次全是epoll_wait,排除了所有计算瓶颈的可能性,锁定根因在 RPC / 网络 IO 层面。"
第四步:debug console------确认是哪个服务的队列在堆积
面试话术:
"pstack 告诉我是网络 IO 阻塞,但还没回答一个问题:是军情服务自己的处理阻塞了,还是它调的下游服务阻塞了?这一步我用 skynet 自带的 debug console 来看所有服务的队列长度。"
实际操作:
bash
telnet 127.0.0.1 8004
list
期望输出:
:00000004 snlua cmaster
:00000005 snlua debug_console
:0000000a snlua military_svr (mqlen: 510) ← 军情服务,队列炸了
:0000000b snlua battle_data_svr (mqlen: 8) ← 下游数据服务,正常
:0000000c snlua match_mgr (mqlen: 3) ← 匹配服务,正常
面试话术:
"注意看------
military_svr的队列 510,但它的下游battle_data_svr队列只有 8。这说明不是下游服务慢导致的连锁阻塞。如果下游慢,下游的队列也应该高才对。问题出在
military_svr自身的消息处理------它在处理某些消息时做了阻塞操作,导致自己的队列越积越长。"
第五步:找证据------证明是城战的 RPC 堵住了军情的 RPC
面试话术:
"到这里,我已经确认三点:① mqlen 高 ② CPU 低(不是计算瓶颈)③ 阻塞在 epoll_wait(是 IO 等待)。但还有一个最关键的问题没回答------到底是什么消息堵住了队列?是军情自己的请求太多?还是别的系统的请求混进来了?
要回答这个问题,我需要看消息队列里混了哪些类型的消息。"
方法:agent 日志统计
agent 在收到客户端请求时,会打印 [agent] request name=xxx,记录了每个请求的协议名。通过统计城战高峰期的请求类型分布,就能还原当时消息队列里的"成分"。
bash
# 统计城战高峰期(比如 20:15~20:20)的请求类型
grep "\[agent\] request name=" log/gate.log \
| grep "20:1[5-9]:" \
| sed 's/.*name=//' \
| sort | uniq -c | sort -rn
期望输出:
1247 guild_war_turn_submit ← 城战回合提交,占 47%
623 military_query ← 军情查询,占 23%
412 guild_war_damage_report ← 城战伤害上报,占 16%
205 military_update ← 军情数据更新,占 8%
168 chat_public ← 聊天,占 6%
面试话术(这是整个排查中最关键的一段推理):
"你看这个分布------城战相关的 RPC(
guild_war_turn_submit+guild_war_damage_report)加起来占了 63%,而军情自己的 RPC(military_query+military_update)只占 31%。问题不是军情的请求变多了,是城战的请求涌进了军情服务的消息队列。
Skynet 的消息队列是 FIFO(先进先出),所有消息按到达顺序处理。城战期间每秒涌入几十条城战 RPC,它们排在队列前面,每条都要处理几十到几百毫秒(因为涉及广播、数据写入等操作)。军情的查询请求排在后面,要等前面所有城战 RPC 处理完才能轮到。
这就是为什么玩家看到军情页面一直转圈------不是军情系统坏了,是军情系统的消息队列被城战 RPC 堵住了。"
第六步:完整推理链(面试时按这个讲)
玩家投诉:军情页面不刷新
│
▼
① alert_engine → military_svr mqlen=510(远超阈值 10)
→ 确认问题存在,队列严重堆积
│
▼
② stat_poller → mqlen↑↑ + cpu 稳定低位(15ms/5s)
→ 排除 CPU 计算瓶颈和内存泄漏,锁定 IO 阻塞方向
│
▼
③ pstack × 5 → 栈顶全是 __epoll_wait
→ 确认阻塞在网络 IO,不是 Lua 计算
│
▼
④ debug console list → 只有 military_svr 队列高,下游服务正常
→ 排除下游服务慢导致的连锁阻塞,问题在 military_svr 自身
│
▼
⑤ agent 日志统计 → 城战 RPC 占 63%,军情 RPC 只占 31%
→ 混合流量涌入同一队列,城战 RPC 堵住军情 RPC(Head-of-Line Blocking)
│
▼
⑥ 根因:Skynet 单线程 Actor 的 FIFO 队列,
慢消息(城战 RPC,含广播/写入)阻塞快消息(军情查询)
三、修复方案
止损(分钟级)
面试话术:
"我先做了一件最简单的事:在网关层临时拦截城战 RPC 转发到军情服务,把城战相关消息路由到独立的处理通道。这样军情服务的队列立刻从 510 降到 10 以内。这是止损,不是解决根本问题。"
根因修复(小时级)
面试话术:
"根本问题在于我把两类性质完全不同的消息放到了同一个 Actor 的消息队列里:
- 城战 RPC:高频突发、单次耗时长(涉及广播、数据写入)、对延迟不敏感(玩家可以接受战报晚几秒到)
- 军情 RPC:低频稳定、单次耗时短、对延迟极其敏感(玩家点开页面必须立刻看到)
修复方案是把城战 RPC 拆分到独立的服务 。军情服务只处理军情相关的查询和更新,城战相关的数据写入走单独的
guild_war_data_svr。两个服务各自独立的 FIFO 队列,互不阻塞。"
lua
-- 修复前:两种消息混在同一个服务
-- military_svr 同时处理:
-- military_query(军情查询,轻量,要求即时响应)
-- guild_war_turn_submit(城战回合提交,重量,可异步)
-- 修复后:拆分成两个服务
-- military_svr → 只处理 military_query / military_update
-- guild_war_data_svr → 只处理 guild_war_turn_submit / guild_war_damage_report
四、修复结果(STAR - R 结果)
| 指标 | 修复前 | 修复后 |
|---|---|---|
| military_svr mqlen | 510 | 6 |
| 军情查询 P99 | 8.5s(超时) | 180ms |
| 聊天消息 P99 | 5.2s | 120ms |
| 城战数据写入 P99 | 3.5s | 3.8s(略增,可接受) |
| CPU(battle 节点) | 15ms/5s | 22ms/5s(略增,多了一个服务) |
五、面试话术速记卡
30 秒版本(面试官问"你遇到过什么性能问题")
"我主导排查过一次军情系统的卡顿。现象是城战高峰期军情页面一直转圈不刷新。我通过 stat_poller 发现 mqlen 涨到 510 但 CPU 很低------这不是计算瓶颈,是 IO 阻塞。pstack 5 次全是 epoll_wait,确认是网络等待。最后统计 agent 日志发现城战 RPC 占了消息队列的 63%,军情查询排在后面被堵住了。根因是两类消息混在同一个 Actor 的 FIFO 队列里,慢消息拖死了快消息。修复方案是把城战 RPC 拆分到独立服务,mqlen 从 510 降到 6。"
2 分钟版本(面试官追问"你怎么查出来的")
按照上面第六步的推理链,①②③④⑤⑥ 挨个讲。
面试官可能追问
Q:为什么 CPU 低就能排除 CPU 瓶颈?
"因为 mqlen 上涨说明消息处理速度跟不上到达速度。如果 CPU 是瓶颈,处理每条消息消耗的 CPU 是固定的,处理速度跟不上意味着单位时间消耗的总 CPU 应该上升。但 stat_poller 显示 cpu 稳定在 15ms/5s 不变,说明处理速度没变慢,是单个消息的处理时间变长了------它在等 IO,不在计算。"
Q:pstack 看到 epoll_wait 就一定是 RPC 阻塞吗?
"不一定,epoll_wait 也可能是等客户端消息。但结合三个证据:① mqlen 持续涨(说明有消息在处理中)② alert_engine 显示是 military_svr 的队列高(不是 worker 线程空闲)③ agent 日志显示大量城战 RPC 涌入。三点交叉验证,锁定是 RPC 阻塞。"
Q:为什么不直接在 military_svr 里加个消息优先级?
"Skynet 的消息队列是框架 C 层实现的 FIFO,没有暴露优先级接口。如果硬要在 Lua 层实现(比如收到消息后判断类型、低优先级消息先放到 side queue),会破坏 skynet.dispatch 的语义,而且框架升级时可能不兼容。拆服务是 Skynet 官方推荐的做法------一个 Actor 只做一件事。"
六、面试策略:三个案例递进讲
| 顺序 | 案例 | 时长 | 展示什么 |
|---|---|---|---|
| 1 | CPU 暴涨排查 | 3 分钟 | 工具链熟练度(perf 火焰图 + pstack 交叉验证) |
| 2 | 内存泄漏排查 | 2 分钟 | 工程化方法(memory_monitor d10m 滑动窗口 + leak_detect) |
| 3 | 混合流量 MQ 堆积 | 5 分钟 | 架构理解深度(Actor 模型边界认知 + 混合流量竞争) |
第三个案例是杀手锏------前两个大部分 Skynet 开发者都会,第三个展示的是你对框架设计缺陷的清醒认知,面试官会记住你。