慢客户端与背压:写队列、丢弃策略与限流
一个房间里只要有 1% 的客户端下行差——弱网、手机切后台、地铁进隧道——广播循环就会卡在某个连接的 Write 上:内核发送缓冲区(SO_SNDBUF)填满后,net.Conn.Write 会一直阻塞到接收窗口重新打开。一个慢客户端拖住整个房间的广播,是长连接服务最典型的故障。反过来,为了不阻塞而无界堆积待发消息,内存占用等于「队列长度 × 消息大小 × 连接数」——10 万连接各堆 256 条 1 KB 消息就是 25 GB。背压(backpressure)就是在这两者之间做取舍:阻塞自己、丢掉消息,还是断开连接。
1. 写为什么会挡住
gorilla/websocket 的消息写会一直落到 net.Conn.Write:数据先拷贝进内核发送缓冲区,缓冲区满则阻塞。必须记住两点:
WriteMessage返回nil只代表数据进了内核缓冲区,不代表对端收到,所以写成功不能当作送达确认(送达要靠应用层 ack)。- 每条连接只有一个写协程,队列就是业务侧的缓冲区。队列无界,等于把内核背压转成了堆内存压力。
2. 三种处置策略
| 策略 | 行为 | 代价 | 适用 |
|---|---|---|---|
| 阻塞等待 | 广播协程等这个连接 | 延迟叠加,一个连接拖垮一片 | 强一致、低并发、内部系统 |
| 丢弃消息 | 有界队列满则丢 | 消息不可达 | 可容忍丢失(在线状态、输入中提示) |
| 断开连接 | 判定不可写即回收 | 用户看到一次重连 | 默认推荐:可恢复的聊天消息靠 seq 补拉 |
聊天消息能被「丢」的前提是它已经落库并带会话序号,客户端重连后按 seq 补拉——也就是第 7 章那套机制。没有补拉能力的场景,丢弃就是永久数据丢失。另外控制帧不被业务队列影响:WriteControl 允许与业务写并发调用,所以即使队列堵死,服务端依然能把 Ping 或踢下线帧发出去。
3. 有界队列 + 非阻塞投递
// 每连接待发上限:需按「队列长 × 平均消息大小 × 连接数」预算内存
const sendQueueSize = 128
type Client struct {
conn *websocket.Conn
send chan []byte // 有界;生产侧永远不做阻塞发送
done chan struct{}
logger *slog.Logger
mu sync.Mutex
dropped uint64 // 队列满导致的断开次数,作为连接质量指标上报
}
// enqueue 非阻塞投递:投不进去说明该客户端已经跟不上,直接回收连接。
func (c *Client) enqueue(msg []byte) bool {
select {
case c.send <- msg:
return true
case <-c.done:
return false
default:
c.mu.Lock()
c.dropped++
c.mu.Unlock()
c.logger.Warn("发送队列已满,回收慢连接", "容量", cap(c.send))
c.close() // 幂等:内部用 sync.Once,只会真正关闭一次
return false
}
}
写协程按「只写队列里的消息、写必带超时」的原则工作:
func (c *Client) writePump() {
for {
select {
case msg, ok := <-c.send:
if !ok {
return
}
if err := c.conn.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
c.close()
return
}
// 若协议层约定「批帧 = 多条信封顺序拼接」,可在这里合并多条消息,
// 用一次系统调用发出;但绝不能把裸 payload 直接写到底层连接上。
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
c.close()
return
}
case <-c.done:
return
}
}
}
写超时(writeWait)到点即断开,不要重试同一条消息:那条卡住的消息会造成队头阻塞(head-of-line blocking),把后面所有消息一起拖住,而客户端重连后补拉能拿到完整数据。
4. 入站限流,与背压配套
背压解决下行,限流解决上行。两者都要有,因为 SetReadLimit 限制的是单帧字节数,不是发送速率:
// allow 单连接入站限流(`rate.NewLimiter(rate.Limit(20), 40)`);返回 false 表示丢弃本帧。
func (c *Client) allow() bool {
if c.limiter.Allow() {
return true
}
c.sendError(codeRateLimited, "发送过快")
if c.bumpViolation() > maxViolations {
c.closeWith(codeRateLimited) // 4008,客户端应延时重连
}
return false
}
- 单连接令牌桶拦突发,用户维度用共享存储(Redis)计数拦多设备刷屏。
- 补拉流量单独给额度,否则重连瞬间的批量补拉会被自己的限流误杀。
- 入口再加一层全局限流器快速失败,防止一个热点群把整个进程拖满。
5. 参数取值建议
| 参数 | 建议值 | 说明 |
|---|---|---|
| sendQueueSize | 64~256 | 先算内存:队列长 × 平均消息大小 × 连接数 |
| maxBatchSize | 16~64 KB | 合并上限,超过就分多次写,避免单次占用过久 |
| writeWait | 5~10 s | 写超时;超时即断开,不重试 |
| 入站速率 | 20 条/秒,突发 40 | 按连接;用户维度另设更宽的总额度 |
| SetReadLimit | 64 KB | 与协议约定一致,防超大帧 |
| 违规容忍次数 | 5 次 | 超过即关闭并回 4008 |
| 队列长度告警 | P99 > 70% 容量 | 平均值会掩盖问题,必须看 P99 |
6. 常见坑与排查
- 无界队列:
chan []byte不带容量或自己append到切片,队列会跟着消息一起涨,直到 OOM。 default分支静默丢弃:没有日志也没有打点,线上只能看到「用户说没收到消息」。丢弃、断开、写超时必须各有一个计数器。- 关闭 channel 引发 panic:
close(c.send)只能由 Hub 单点注销时执行且生产者唯一;更稳的做法是关done广播退出,让send交给 GC。 - 持锁投递:在 Hub 的读锁里遍历并投递是可以的,但投递本身必须非阻塞;一旦在锁内调用阻塞写,整张连接表都会被一个慢客户端卡住。
- 把
SetReadLimit当限流:它只是帧大小限制,防不住每秒一万条小消息;帧大小与发送速率两条限制都要有。 - 只看平均队列长度:P99 才是慢连接的信号;平均值永远好看。
排查三件套:go tool pprof -inuse_space 看堆里 chan 占了多少(队列内存会以 chan send 相关对象体现);goroutine profile 里大量堆在 WriteMessage 的协程说明慢连接成群出现;ss -tnmi 'sport = :8080' 看 Send-Q 与 retrans,持续高 Send-Q 就是典型慢客户端,配合 rtt 能区分是链路差还是对端读得慢。
小结:背压的核心是「有界 + 非阻塞 + 可恢复」——每连接一个有界发送队列,投递用 select/default 非阻塞,队列满即回收连接,写操作统一带 SetWriteDeadline 超时且不重试,被丢弃的消息靠落库与序号补拉恢复;同时用令牌桶管住上行速率,并把队列长度、写超时、丢弃次数三组指标暴露出来,慢客户端才不会变成整个服务的慢性病。