直接用 io.copy 无法限速,因其内部高速读写且不感知速率;必须包装 io.writer,拦截 write 方法,结合 rate.limiter.waitn 实现精准字节级限速。

为什么直接用 io.Copy 无法限速
因为 io.Copy 内部是尽可能快地读写,不感知时间或字节速率。即使你在外层加 time.Sleep,也拦不住它内部缓冲区一次性吞掉大量数据,导致突发流量远超目标带宽。
真正可控的入口只有一个:把写操作“包一层”,让每次 Write 都按需阻塞,等够了时间再放行。这要求你替换掉原始的 io.Writer,换成一个带令牌桶逻辑的包装器。
- 不要试图在
io.Copy后做 sleep —— 数据早已发出去 - 不要用固定间隔 sleep 模拟限速 —— 网络延迟和系统调度会让实际速率抖动极大
- 必须拦截并重写
Write方法,且对小包(如 HTTP header)也要生效
用 golang.org/x/time/rate 实现写限速包装器
rate.Limiter 是 Go 官方维护的令牌桶实现,适合做写限速。关键点在于:每次 Write 前先 WaitN,确保有足够令牌才写入;否则阻塞到令牌生成。
注意:不能用 AllowN + 重试循环,那会退化成忙等;WaitN 才是阻塞式、低开销的正确用法。
type RateWriter struct {
w io.Writer
limiter *rate.Limiter
}
<p>func (rw *RateWriter) Write(p []byte) (int, error) {
n := len(p)
if n == 0 {
return 0, nil
}
// 等待获取 n 个令牌
if err := rw.limiter.WaitN(context.Background(), n); err != nil {
return 0, err
}
return rw.w.Write(p)
}</p>
- 初始化
limiter时,rate.Limit单位是 “每秒令牌数”,即目标带宽(如rate.Limit(1024 * 1024)表示 1MB/s) -
burst设为略大于平均单次写大小(如 64KB),避免小包被过度延迟 - 务必传
context.Background(),不要用带 cancel 的 context —— 限速不该被业务逻辑意外中断
HTTP 服务中给响应体限速要绕过 http.ResponseWriter 封装
HTTP handler 里拿到的 http.ResponseWriter 不暴露底层 Write 方法,也不能直接替换。必须用 http.Hijacker 或中间件方式包裹 ResponseWriter。
推荐做法:在 handler 开头就用 ResponseWriter 构造自定义 wrapper,然后把原 Write 替换为限速版。注意要同时重写 WriteHeader 和 Header 方法,否则状态码和 header 会失效。
- 不要尝试用
bufio.Writer包一层再限速 —— 缓冲会破坏速率控制精度 - 如果用了 gzip middleware,限速 wrapper 必须放在压缩之后(即作用于最终写出的字节流)
- 测试时用
curl -v观察Content-Length和传输耗时,比看日志更可靠
限速值设太小会导致连接卡住或超时
比如设限速为 1KB/s,但客户端 TCP 窗口已满,或者服务端 write buffer 满了,Write 调用本身就会阻塞 —— 此时 rate.Limiter.WaitN 还没开始等,整个 goroutine 就卡死了。
这不是限速逻辑的问题,而是网络栈和 OS buffer 的交互结果。必须配合合理的超时和错误处理。
- 给
Write操作加整体超时:用context.WithTimeout包裹WaitN调用 - 检查底层
io.Writer是否支持SetWriteDeadline,并在每次写前设置(尤其对net.Conn) - 避免在高并发下用极低限速(如
限速不是越细越好,最小安全单位建议不低于 8KB/s;低于这个值,协议开销和调度抖动会吃掉大部分精度。











