慢客户端与背压:写队列、丢弃策略与限流

一个房间里只要有 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. 参数取值建议

参数建议值说明
sendQueueSize64~256先算内存:队列长 × 平均消息大小 × 连接数
maxBatchSize16~64 KB合并上限,超过就分多次写,避免单次占用过久
writeWait5~10 s写超时;超时即断开,不重试
入站速率20 条/秒,突发 40按连接;用户维度另设更宽的总额度
SetReadLimit64 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 超时且不重试,被丢弃的消息靠落库与序号补拉恢复;同时用令牌桶管住上行速率,并把队列长度、写超时、丢弃次数三组指标暴露出来,慢客户端才不会变成整个服务的慢性病。

笔记加载中…