线上问题排查:连接抖动、消息延迟与内存增长
本章目标:按「先定范围、分层量化、再动手」的顺序处理 im-go 线上最高频的三类问题——连接抖动(flapping)、消息延迟、内存增长;每个现象都给出判定依据与可复制的命令,而不是靠猜。
1. 统一排查路径
- 定范围:单台网关还是全集群?只影响新连接还是存量连接?几点开始?与最近的发布/配置变更对齐时间线。
- 分层量化:客户端 → 负载均衡/Nginx → 网关 → 逻辑层 → Redis/MySQL,每一层至少要有一个数字(在线数、队列长度、RPC P99、Redis 延迟)。
- 先留证再动手:
ss、lsof、pprof、日志片段先落到/tmp/diag-$(date +%s)/,重启会销毁现场。 - 一次只改一个变量,改完用同一组命令复测,否则无法归因。
网关至少要暴露这四个内部数字,排障时才有对照物:在线连接数(im_online_connections)、待确认消息数(im_ack_pending)、投递失败原因分布(im_deliver_failed_total)、协程数(go_goroutines)——都来自第 12 章的指标清单。
2. 连接抖动:先看心跳与代理超时
ss -s # 全局 socket 统计:总数、TIME_WAIT
ss -tn state established '( sport = :8080 )' | wc -l # 内核里的实际建连数
lsof -p $(pgrep -f bin/gateway) | wc -l # 进程 fd 数,与在线连接数×2 对照
netstat -s | grep -iE 'listen|overflow|retrans|timeout' # 队列溢出与重传
sudo grep '"event":"conn_close"' /var/log/im-go/gateway.log | grep -o '"reason":"[^"]*"' | sort | uniq -c | sort -rn | head
redis-cli INFO clients # connected_clients / blocked_clients
sudo tail -200 /var/log/nginx/error.log | grep -i 'upstream timed out'
判读依据:
| 观测 | 结论 |
|---|---|
| 在线数曲线呈锯齿、重连率升高 | 代理空闲超时小于心跳周期:Nginx proxy_read_timeout 默认 60 秒,心跳 30 秒要留足余量(第 14 章) |
| 每 30 秒或 60 秒成批掉线 | 定时器效应:负载均衡会话超时、心跳参数或客户端后台节流 |
listen queue overflow 计数上涨 | 全连接队列满,调 somaxconn 与 listen backlog(第 13 章) |
| TIME_WAIT 数量暴涨 | 服务端主动关闭过多:健康检查用短连接、或大量连接被踢后未复用 |
| 只有某个运营商/地区掉线 | 中间设备或 NAT 表项超时,缩短心跳周期并启用 TCP keepalive |
日志出现 too many open files | LimitNOFILE 没调,fd 打到上限后新连接被拒 |
比曲线更可靠的是内核与进程内部数字的差值:ss 数出的 ESTABLISHED 远大于进程内在线数,说明有幽灵连接(半开连接未被心跳回收);两者接近但都远大于客户端实际在线数,说明客户端一直在重连而旧连接没被清理。同时看第 12 章的 im_conn_total{result} 建连/断连速率,两者同步抬升就是抖动。再配合日志里的关闭码——1006 是异常关闭(网络断开、代理掐断),1000/1001 是正常或主动下线,比例突变的方向就指向代理层还是客户端。
3. 消息延迟:把投递链路拆成六段
一次单聊投递要经过六个可度量的阶段:第 12 章的 im_msg_deliver_latency_seconds{stage="push"} 只给出端到端数字,要定位就得给每段补一个 stage 标签(decode、auth、route、rpc、db、push、other),之后一次采样就能指认大头:
| 阶段 | 典型瓶颈 | 主要命令 |
|---|---|---|
| 网关收包与解码 | 大帧、JSON 编解码、单帧超限 | go tool pprof CPU profile |
| 会话与鉴权校验 | Redis 往返、JWT 校验成本 | redis-cli --latency |
| 路由查询 | 慢查询、大 key、命中率下降 | redis-cli SLOWLOG GET 10 |
| gRPC 调用逻辑层 | 连接池排队、序列化、下游超时 | pprof block profile、gRPC 指标 |
| 落库 | 慢 SQL、行锁、连接池耗尽 | SHOW FULL PROCESSLIST、慢查询日志 |
| 投递到对端并写回 | 慢客户端导致发送队列积压 | 网关队列长度、写超时计数 |
go tool pprof -top 'http://127.0.0.1:6060/debug/pprof/profile?seconds=30'
go tool pprof -top 'http://127.0.0.1:6060/debug/pprof/block' # 谁在等锁、等通道
redis-cli --latency -h 127.0.0.1 # 排除 Redis 网络往返
redis-cli SLOWLOG GET 10
mysql -N -e "SHOW FULL PROCESSLIST" | awk '{print $6}' | sort | uniq -c | sort -rn | head
grep -o 'cost_ms=[0-9]*' /var/log/im-go/logic.log | sort -t= -k2 -n | tail -20 # 上行处理耗时 Top20
三句判读经验:P50 正常而 P99 抬升多为排队(锁竞争、连接池、发送队列),去看 block profile;P50 与 P99 一起抬升才是资源饱和(CPU、GC、Redis 单点),先扩容再优化;只有特定用户或特定群慢通常是群扩散(fan-out)写放大或历史消息过长,与机器无关。各段之和与端到端耗时的差值(stage="other")如果超过 10%,说明还有没埋到的阶段,先补埋点而不是继续猜。
4. 内存增长:连接 map、goroutine 与发送队列
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=1' | head -40 # 协程数与栈聚合
go tool pprof -inuse_space http://127.0.0.1:6060/debug/pprof/heap # 存活对象
go tool pprof -alloc_space http://127.0.0.1:6060/debug/pprof/heap # 分配热点
grep -E 'VmRSS|Threads' /proc/$(pgrep -f bin/gateway)/status # 进程真实占用
ss -s # 与连接数对照
GODEBUG=gctrace=1 /opt/im-go/current/gateway -conf /opt/im-go/configs/config.yaml # 临时实例,换端口跑
| 根因 | 表现 | 处理 |
|---|---|---|
| 连接 map 未删 | conns map[connID]*Conn 只关连接不删键;同设备重连覆盖了 key,旧键成孤儿 | 所有清理收口到唯一一个 remove(),defer 调用,并周期比对 map 长度与在线数 |
| goroutine 泄漏 | 读循环退出后写循环仍阻塞在 channel,或 ticker 未 Stop、心跳 goroutine 没有退出信号 | 用 close(done) 统一广播退出;用 goroutine?debug=1 看栈是否集中在同一条 chan receive |
| 发送队列积压 | 慢客户端写不出去,send chan []byte 越堆越长,内存随慢客户端数量上涨 | 有界队列 + SetWriteDeadline,超时直接踢连接(第 02 章) |
| 本地缓存无界 | 路由缓存、群成员缓存只写不淘汰 | 一律加 TTL 与容量上限(LRU),禁止无界 map |
| Redis 端增长 | route:* 键没设过期时间,幽灵路由堆积 | 登录写入必须带 EXPIRE;redis-cli INFO memory 与 --bigkeys 定期看 |
判定顺序固定:先看 VmRSS 是单调上涨还是「涨到某值后回落」(后者是正常 GC 行为,前者才是泄漏);再用 heap 的 inuse_space 定位是 map、*Conn 还是 []byte 在占;goroutine 数单调上涨不回落,一定是泄漏,与内存曲线同向基本可以定案。重启前先抓 goroutine 与 heap——重启之后现场就没了。
5. 留证包:一次故障归档一次
D=/tmp/diag-$(date +%s) && mkdir -p $D
ss -s > $D/ss.txt
lsof -p $(pgrep -f bin/gateway) > $D/lsof.txt
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2' -o $D/goroutine.txt
curl -s 'http://127.0.0.1:6060/debug/pprof/heap' -o $D/heap.pb.gz # 默认即 pprof 格式
redis-cli INFO all > $D/redis-info.txt
redis-cli SLOWLOG GET 50 > $D/redis-slowlog.txt
tail -5000 /var/log/im-go/gateway.log > $D/gateway.log
三条纪律:抓取顺序先内核后进程(内核计数变化最快,越早抓越准);每个文件都记录抓取时刻,便于和监控曲线对齐;归档进故障单并与当天的发布、配置变更放在一起——同类问题第二次出现时,两份留证包一对比就能看出差异。
常见坑
| 坑 | 现象 | 做法 |
|---|---|---|
| 一上来就重启 | 现场丢失,一周后再犯 | 先 ss/lsof/pprof 留证再重启 |
| 只看进程内在线数 | 与内核连接数偏差大,问题被掩盖 | 两边同时看,差值即幽灵连接 |
| 把 504/断连归因于「网络抖动」 | 其实是自己配的超时小于心跳 | 对照心跳周期与代理超时(第 14 章) |
| 调大超时当修复 | 断连变成「更晚的断连」 | 先定位是心跳、代理还是负载均衡 |
| 内存涨了就加大机器 | 泄漏仍在,只是晚几天爆 | 用 heap 找到持有者,改代码 |
| 生产长期开着 mutex profile | 自身开销拖慢服务 | 只在排查窗口开启,采完设回 0 |
| pprof 端口对公网开放 | 内部结构甚至内存快照泄露 | 只监听 127.0.0.1:6060,与监控端口(第 12 章的 9101)分开 |
| 忽略变更时间线 | 找不到触发点,反复试错 | 与发布、配置、流量变更对齐时间 |
小结:三类问题共用一条路径——先定范围、再分层量化、留证后再动手。连接抖动查心跳与代理超时的关系(ss -s、netstat -s、在线数与内核连接数的差值),消息延迟把投递链路拆成六段逐段埋点(pprof block、Redis SLOWLOG、MySQL 慢查询),内存增长优先怀疑连接 map、goroutine 与发送队列三处泄漏(goroutine?debug=1、heap inuse_space、VmRSS 曲线),而重启永远是最后一步动作。