Go实现千万级WebSocket消息推送服务,先别急着堆机器"

admin 2026-10-18 08:43:30 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

cover_image

Go 实现千万级 WebSocket 消息推送服务,先别急着堆机器

原创

go
go

Go语言教程

2026年7月28日 13:29
陕西

在小说阅读器读本章

去阅读

线上连接数刚涨起来,最先报警的不是 CPU,而是推送队列。

active_conn=186420
push_queue=64
queue_full=12783
write_timeout=936
goroutines=392817

这种日志我第一眼不会去怀疑 WebSocket 协议,更不会先调大机器。queue_full 连着涨,说明消息生产速度已经超过客户端消费速度。机器再加两倍,慢客户端还是慢,内存照样被拖死。

千万级 WebSocket 服务,难点从来不是把连接升级成功,而是连接建立以后,怎么找到它、怎么写数据、写不进去怎么办。

这里说的“千万级”,通常指整个集群承载千万长连接,不是让一台 Go 服务硬扛一千万。单机能放多少连接,取决于内存、文件描述符、网卡、消息频率和平均包大小。只报一个连接数,基本没参考价值。

我一般把链路拆成三段:

业务服务 -> 消息队列 -> WebSocket 网关 -> 客户端

业务服务只负责产生消息,不直接查 WebSocket 连接。消息按照 user_id 分区,送到对应的网关节点。网关收到消息后,只查本机连接并推送。

连接表也别直接扔进一个巨大的 sync.Map 就不管了。连接频繁上线、下线、重连时,全局竞争和 GC 压力很容易冒出来。我更习惯做分片。

type Session struct {
 UserID string
 Conn   Socket
 SendQ  chan []byte
 Done   chan struct{}
}

type sessionBucket struct {
 mu    sync.RWMutex
 items map[string]*Session
}

type SessionTable struct {
 buckets [256]sessionBucket
}

func (t *SessionTable) bucket(userID string) *sessionBucket {
 var h uint32 = 2166136261
 for i := 0; i < len(userID); i++ {
  h ^= uint32(userID[i])
  h *= 16777619
 }
 return &t.buckets[h&255]
}

func (t *SessionTable) Get(userID string) (*Session, bool) {
 b := t.bucket(userID)
 b.mu.RLock()
 s, ok := b.items[userID]
 b.mu.RUnlock()
 return s, ok
}

256 个分片不是什么标准答案,只是避免所有连接挤同一把锁。真正上线前,还得看锁等待、连接分布和 CPU 核数,别看到一个数字就原样复制。

每条连接只能有一个写协程,这个约束最好从代码结构上卡死。业务协程不能拿到连接后直接 Write,否则多个 goroutine 并发写同一条连接,消息顺序、帧边界和关闭流程都会变得很难看。

func (s *Session) writeLoop() {
 heartbeat := time.NewTicker(25 * time.Second)
 defer heartbeat.Stop()
 defer s.Conn.Close()

 for {
  select {
  case payload := <-s.SendQ:
   _ = s.Conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
   if err := s.Conn.WriteBinary(payload); err != nil {
    return
   }

  case <-heartbeat.C:
   _ = s.Conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
   if err := s.Conn.WritePing(); err != nil {
    return
   }

  case <-s.Done:
   return
  }
 }
}

这里最关键的不是心跳,而是 SendQ 必须有上限。

很多 WebSocket 服务写着写着就 OOM,原因并不复杂:客户端网络变慢,发送队列持续堆积。一条连接积压几百条消息看着不多,乘上几十万连接,内存很快就没了。

推送入口要明确处理背压,不能假装消息永远写得进去。

var ErrClientBusy = errors.New(\"client send queue is full\")

func push(s *Session, body []byte) error {
 msg := bytes.Clone(body)

 select {
 case s.SendQ <- msg:
  return nil
 default:
  return ErrClientBusy
 }
}

队列满了以后怎么处理,要看业务。

聊天消息可能需要进入离线存储,客户端重连后补拉;行情、在线人数这类状态消息,旧数据通常可以直接覆盖;系统通知可以重试,但必须带消息 ID,避免客户端重复展示。

最差的做法是无限等待。一个慢客户端把消费协程卡住,后面的正常用户也跟着排队。

网关层还要解决用户路由。用户连接建立后,将 user_id -> gateway_id 写入 Redis 或专门的路由服务,并设置租约。业务消息进入 MQ 时按用户分区,消费节点发现用户不在本机,就不要继续跨节点乱转。

路由关系不能只靠客户端断开时删除。进程被强杀、机器掉电时,清理逻辑根本没机会执行。网关要定期续租,过期记录让存储层自己淘汰。

真到线上排查,我会先盯这几项:

ws_active_connections
ws_push_queue_full_total
ws_write_timeout_total
ws_message_lag_seconds
process_open_fds
go_goroutines
go_memstats_heap_inuse_bytes

连接数正常,message_lag 却不断增加,问题多半在 MQ 消费或网关写出;write_timeout 集中出现在部分运营商网络,要查客户端链路;goroutine 数量只涨不降,则要看读写协程有没有在断线后退出。

千万级 WebSocket 不是某个 Go 参数调大就能解决的。连接要分片,消息要分区,发送要串行,队列要有界,慢客户端必须舍得处理。

这几个地方没卡住,机器越多,事故规模通常也越大。


Go实现千万级WebSocket消息推送服务,先别急着堆机器" 网络安全文章

Go实现千万级WebSocket消息推送服务,先别急着堆机器"

本文探讨了Go实现千万级WebSocket消息推送服务的架构设计,指出核心难点在于连接管理、消息推送和背压处理而非单纯增加机器。关键方案包括:将链路拆分为业务服务、消息队列和网关三层;使用分片减少锁竞争;采用单写协程保证消息顺序;设置有限发送队列防止OOM;通过用户ID分区路由并处理慢客户端。建议监控连接数、队列满、写超时等指标以定位问题。
Goslices.Move新提案:一次搬动,胜过两次删除插入" 网络安全文章

Goslices.Move新提案:一次搬动,胜过两次删除插入"

Go社区提出slices.Move新提案,旨在优化切片内元素移动操作。传统方法使用Delete和Insert组合需两次内存搬动,而Move通过一次平移中间区域元素实现,减少内存复制,提升性能。提案仍处于Open状态,但开发者可在项目内部实现类似moveAt函数。该函数原地调整顺序,不改变切片长度,适用于长切片频繁重排场景如优先队列和排行榜缓存。
AlterSend:几个GB的大文件,别再跟网盘死磕了" 网络安全文章

AlterSend:几个GB的大文件,别再跟网盘死磕了"

AlterSend是一款开源点对点文件传输工具,支持多平台,通过二维码或连接码配对设备,实现端到端加密传输。它避免网盘上传下载的繁琐流程,无文件大小限制,但传输时需预留两倍磁盘空间。适合熟人之间或跨设备临时传输大文件,网络速度取决于双方带宽。注意磁盘空间要求,避免传输中断。
评论:0   参与:  0