限速逻辑必须放在读环节,通过包装io.reader并在read前用rate.limiter.waitn按字节数申请配额实现;写端限速不可靠,io.limitreader不支持速率控制,粗暴sleep浪费goroutine且无法响应取消。

限速逻辑该放在读还是写环节
直接在 io.Copy 外套一层限速器不行——它底层用的是无缓冲的循环读写,速率控制点必须介入数据流动路径。实际有效的方式是包装 io.Reader,让每次 Read 调用都主动等待,而不是靠写端“慢下来”。否则写入目标(比如网络 socket)可能因缓冲区满而阻塞,导致整体延迟不可控,甚至触发超时。
- 读端限速:稳定、可预测,适用于本地文件复制或上传前预处理
- 写端限速:容易受下游响应影响,仅适合已知写入吞吐稳定的场景(如写入本地 SSD 文件)
- 不要用
time.Sleep在 goroutine 里粗暴延时——会浪费 goroutine,且无法响应取消信号
用 io.LimitReader 无法实现真正的限速
io.LimitReader 是按总字节数限制,不是按速率(bytes/sec),它不带时间维度。你给它一个 1MB 的限制,它就只读 1MB,跟“每秒最多 512KB”完全无关。真要限速,得自己实现一个带令牌桶或漏桶语义的 io.Reader,核心是每次 Read 前计算应等待多久。
- 推荐用
golang.org/x/time/rate的rate.Limiter,它支持平滑限速和突发流量控制 - 注意
rate.NewLimiter的第一个参数是limit(每秒事件数),第二个是burst(最大突发量),单位是 float64,传512 * 1024表示 512KB/s - 别把
Limiter.WaitN放在Read返回后调用——那等于限速的是“上一次读”,会导致速率抖动
如何封装一个限速的 io.Reader
关键是在 Read 方法开头,根据本次要读的字节数,向 rate.Limiter 申请对应额度,并等待。这样每次读都严格对齐设定速率。
type RateLimitedReader struct {
r io.Reader
limiter *rate.Limiter
}
<p>func (r *RateLimitedReader) Read(p []byte) (int, error) {
n := len(p)
if err := r.limiter.WaitN(context.Background(), n); err != nil {
return 0, err
}
return r.r.Read(p)
}</p>
- 必须用
WaitN而不是ReserveN,后者需要手动调用Delay,容易漏掉错误分支 - 如果上游
Reader本身返回n (比如 EOF 或短读),你仍按 <code>len(p)申请了配额——这会导致后续读取被过度压制;更稳妥的做法是先Read,再按实际读到的字节数补等(但会略微放大瞬时速率) - 生产环境建议传入带超时的
context.Context到WaitN,避免因限速器卡死导致整个复制挂起
完整复制函数怎么组合读写和限速
限速 Reader 写好后,直接丢给 io.Copy 即可,不用改写逻辑。但要注意:如果目标 Writer 是网络连接或慢设备,io.Copy 内部 buffer 默认 32KB,可能造成“读端已按速放出,写端却积压”,表现为内存占用缓慢上升。这时需显式控制 buffer 大小。
- 用
io.CopyBuffer(dst, src, make([]byte, 8*1024))把 buffer 缩到 8KB,降低内存压力 - 若需支持 cancel,用
io.CopyN+ 循环方式替代io.Copy,并在每次迭代检查ctx.Done() - 别在限速 Reader 里做任何额外解码或校验——限速层只负责节奏,其他逻辑应剥离到独立中间件
限速真正难的不是实现 WaitN,而是当源 Reader 返回短读、或目标 Writer 写入缓慢时,如何让速率统计和等待逻辑不漂移。多数人忽略的是:速率是长期统计值,单次 Read 的等待误差会在后续补偿,但 buffer 大小和上下文超时必须提前想清楚。











