netpoll不能直接用于千万级群聊推送,因其仅优化单连接I/O,而群聊广播瓶颈在于O(N)写放大、锁竞争、协程开销及消息扇出逻辑。

为什么 netpoll 不能直接用来做千万级群聊推送
netpoll(即 net.Conn 底层的 epoll/kqueue/iocp 封装)本身不解决群聊广播的瓶颈,它只负责高效地收发单连接数据。真正卡住的是:内存拷贝、锁竞争、协程调度开销和消息扇出逻辑。Go 的 net/http 或自建 TCP server 即便用了 runtime netpoll,一旦对每个在线用户都 conn.Write() 一次,就立刻掉进 O(N) 写放大陷阱——1000 万在线,一条群消息触发 1000 万次系统调用和内存分配。
必须绕过 conn.Write 的三种替代路径
核心思路是:把“推送给谁”和“怎么写”解耦,避免为每个连接执行独立 write 调用。可行路径有:
- 使用
io.CopyBuffer+ 共享bytes.Buffer预填充消息体,再通过runtime_pollWrite底层批量提交(需 unsafe 操作,风险高但实测吞吐翻倍) - 启用 TCP_CORK(Linux)或 TCP_NOPUSH(FreeBSD/macOS),在连接池层面聚合多条消息后一次性 flush;注意必须控制 cork 时间,否则引入延迟
- 改用
sendfile(2)零拷贝路径——仅适用于消息体是静态字节流且存于文件或 mmap 内存页时;Go 标准库不暴露该接口,需 cgo 调用syscall.Sendfile
连接管理必须放弃“每连接一 goroutine”模型
千万连接 ≠ 千万 goroutine。默认 net.Conn.Read + for { select { ... } } 模式下,每个连接至少占 2KB 栈+调度开销,内存和上下文切换成本不可承受。正确做法是:
- 用
net.Conn.SetReadDeadline配合非阻塞读 +runtime.Gosched()主动让出,或直接上golang.org/x/net/netutil.LimitListener控制并发 accept - 将读写分离:单个 goroutine 负责一批连接的
read(用epoll_wait批量获取就绪 fd),另一组 worker goroutine 负责write缓冲区 flush - 连接元数据(如用户 ID、群组 ID)必须存于无锁结构,例如
sync.Map或分片map[int64]*ConnMeta,避免广播时遍历全局 map 加锁
消息扇出阶段最容易被忽略的三个细节
群聊广播不是“查出所有成员然后挨个发”,而是要分层裁剪:
- 路由前过滤:先查群组 ID 对应的在线节点列表(比如用一致性 hash 分片到 100 个 Redis key),而非全量用户表扫描
- 连接状态二次确认:即使存在连接句柄,也要用
conn.RemoteAddr()+ 心跳时间戳快速判活,避免 write EPIPE 大量发生 - 写缓冲区复用:每个连接绑定一个
bufio.Writer,但初始化时指定固定size(如 4096),并重置而非新建;否则高频广播下 GC 压力陡增
真正难的不是让百万连接活着,而是让每条消息在亚毫秒内完成从接收、过滤、序列化到落网卡的全过程。其中任意一步出现线性扫描、全局锁或隐式内存分配,都会在千万规模下被急剧放大。


















