高峰期消息延迟2秒到100ms,我是怎么改的

0 阅读4分钟

接手服务端团队后,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: 120, 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是即时的,订阅断开后消息不缓存,直接丢了。

修复

两个策略:

  1. 加快重连:sleep从1秒改成100ms,减少丢失窗口
  2. 重要消息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系统问题,关键步骤:

  1. 量化问题 — 加监控日志,不要靠猜
  2. 画架构图 — 搞清楚消息流转的全链路
  3. 看边界情况 — 多端登录、断线重连、服务重启
  4. 找隐藏bug — 有些只在特定场景触发

这次最大的教训:多端登录场景一定要专门测试。很多bug只在多端同时在线时才出现,日常测试容易漏掉。