接手服务端团队后,IM系统的问题逐渐暴露:高峰期延迟严重,有人投诉丢消息。
这篇文章记录排查和修复过程。
问题表现
- 高峰时段消息延迟,有时候几秒才收到
- 服务发布重启期间偶发丢消息
- 多端登录时,PC端有时候收不到私聊消息
前两个问题比较明显,第三个最隐蔽。
一、服务重启丢消息
根因
原来发布流程:收到终止信号 → 立即退出进程。消息队列里还没处理的消息直接丢了。
修复
加优雅关闭:
shutdownHook := func() {
log.Info("shutting down...")
consumer.Pause() // 停止接收新消息
processWaitGroup.Wait() // 等待处理中的消息完成
producer.Close()
consumer.Close()
}
registerShutdown(shutdownHook)
发布日志变成:
[INFO] received SIGTERM, starting graceful shutdown
[INFO] pending messages: 12 → 0, elapsed: 340ms
[INFO] shutdown complete
这个最简单,真正麻烦的是后面。
二、推送延迟:批量操作改广播
量化问题
加监控日志:
func PushMessage(msg *Message) {
start := time.Now()
node := getNode(msg.UserId) // Redis查询
sendToNode(node, msg) // Redis PUBLISH
}
抓了一天日志:
[TRACE] get_node: 2ms, send_to_node: 1ms // 单次不慢
[TRACE] batch_push 100 users: 2847ms // 批量很慢
瓶颈分析
原来实现:
func BatchPush(userIds []int64, msg *Message) {
for _, uid := range userIds {
node := getNode(uid) // 每次查Redis
sendToNode(node, msg) // 每次PUBLISH
}
}
100人房间推送1条消息:
- 100次
getNode()→ 100次Redis GET - 100次
sendToNode()→ 100次Redis PUBLISH
修复
把循环从业务层挪到长连接服务内部:
【修复前】
业务服务 → 消息API → 消息路由 → [循环100次] → 长连接服务
【修复后】
业务服务 → 消息API → 消息路由 → [1次广播] → 长连接服务 → 遍历内存连接池
长连接服务收到广播后:
case TypeBroadcastRoom:
room := rooms.Get(msg.RoomId)
for connId := range room.Members() {
conn := connections.Get(connId)
conn.SendChan <- msg // 内存操作,不打Redis
}
效果:
- 100人房间推10条消息:1000次Redis操作 → 10次
- 延迟:2-5秒 → <100ms
三、Redis订阅断线丢消息
广播改完后,还是偶发丢消息。排查发现是Redis订阅的问题。
问题
长连接服务订阅Redis消息:
func watchChannel(ch string) {
reconnect:
conn, _ := redis.Dial("tcp", addr)
psc := redis.PubSubConn{Conn: conn}
psc.Subscribe(ch)
for {
switch n := psc.Receive().(type) {
case redis.Message:
dispatch(n.Data)
case error:
time.Sleep(100 * time.Millisecond) // 断线后快速重连
goto reconnect
}
}
}
问题:Redis PubSub是即时的,订阅断开后消息不缓存,直接丢了。
修复
两个策略:
- 加快重连:sleep从1秒改成100ms,减少丢失窗口
- 重要消息ACK:私聊消息需要客户端确认,超时走离线消息
四、多端登录私聊丢消息(最隐蔽的bug)
这个问题用户反馈少,但一旦发生就是严重bug。
定位过程
看代码发现逻辑:
func onAck(ack *Ack) {
if ack.Kind == PRIVATE {
rdb.Del("CACHE:" + ack.MsgId) // 直接删缓存
rdb.LRem(pendingKey(ack.Uid), ack.MsgId) // 删除离线列表
}
}
问题在哪?
用户可能在APP、PC、Web三端同时登录。私聊消息会推送到所有端。如果APP端先确认,直接删缓存,PC端还没拉取的话,消息就丢了。
修复
删缓存前检查所有平台的离线消息:
func onAck(ack *Ack) {
if ack.Kind == PRIVATE {
// 检查其他平台是否还有这条消息
done := false
for _, pl := range []string{"app", "pc", "web"} {
if pl == ack.Platform {
continue
}
if rdb.Exists(offlineCache(ack.Uid, pl), ack.MsgId) {
done = true
break
}
}
// 所有平台都确认了才删缓存
if !done {
rdb.Del("CACHE:" + ack.MsgId)
}
rdb.LRem(currentPending(ack.Uid), ack.MsgId)
}
}
这个bug很隐蔽:
- 只有多端同时在线才触发
- 只是私聊受影响
- 用户可能以为是网络问题,不会反馈
五、消息消费慢:NSQ并发优化
排查过程中还发现回写队列积压,消息处理跟不上。
问题
原来消费者配置:
consumer := &NsqConsumer{
Topic: "msg_callback",
Channel: "data",
}
默认单goroutine消费,高峰期积压严重。
修复
加并发参数:
consumer := &NsqConsumer{
Topic: "msg_callback",
Channel: "data",
MaxInFlight: 100, // 最多100条未确认
Concurrency: 10, // 10个goroutine并行消费
}
效果:消费速度 ~100/s → ~1000/s
效果
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 服务重启丢消息 | 偶发 | 基本消除 |
| 100人房间推送延迟 | 2-5秒 | <100ms |
| 多端私聊丢消息 | 偶发 | 基本消除 |
| NSQ消费积压 | 高峰期明显 | 稳定 |
总结
排查IM系统问题,关键步骤:
- 量化问题 — 加监控日志,不要靠猜
- 画架构图 — 搞清楚消息流转的全链路
- 看边界情况 — 多端登录、断线重连、服务重启
- 找隐藏bug — 有些只在特定场景触发
这次最大的教训:多端登录场景一定要专门测试。很多bug只在多端同时在线时才出现,日常测试容易漏掉。