压测工具的限速器不能用 time.Ticker
现象:设了 5000 QPS,只跑出 88
第一版限速器用 time.Ticker,逻辑上看不出问题:每隔 1s/qps 放行一个请求。
实测却完全不对:
| 目标 QPS | 实测 |
|---|---|
| 200 | 62 |
| 1000 | 64 |
| 5000 | 88 |
吞吐被死死卡在 60~90,与目标值无关。原因在 Windows 的系统定时器分辨率:约 15.6ms。
time.Ticker 的最小有效间隔被它限制,1s/5000 = 0.2ms 的要求根本表达不出来。
改法:绝对时间表
不去「每隔多久放行一次」,而是直接算第 n 个请求的理想发出时刻:
type Limiter struct {
interval time.Duration
start time.Time
seq int64
}
func (l *Limiter) Wait() time.Time {
if l == nil { return time.Now() }
n := atomic.AddInt64(&l.seq, 1) - 1
at := l.start.Add(time.Duration(n) * l.interval)
if wait := time.Until(at); wait > 0 {
time.Sleep(wait)
}
return at
}
调度只依赖 time.Now(),与定时器精度无关。
落后于时间表时会追赶补发(at 已经过去了就立刻返回),长期平均速率依然是准的。
改成这个之后实测 200 / 1000 / 5000 三档分别跑出 188 / 985 / 4982。
更重要的一个修正:coordinated omission
限速器返回的那个 at 不只是让线程睡一会儿,它被当成延迟的起算点:
// 限速:等到本请求的许可时刻,startAt 为其「理想发出时刻」,
// 落后于时间表所积累的排队时间会由 doRequest 计入延迟。
startAt := lim.Wait()
rec := doRequest(ctx, template, client, cfg.Body, startAt)
func doRequest(ctx context.Context, tmpl *http.Request, client *http.Client,
body string, startAt time.Time) Record {
req := tmpl.Clone(ctx)
if body != "" {
req.Body = io.NopCloser(strings.NewReader(body))
}
resp, err := client.Do(req)
if err != nil {
return Record{Latency: time.Since(startAt), ErrMsg: truncate(err.Error(), 120)}
}
defer resp.Body.Close()
n, _ := io.Copy(io.Discard, resp.Body)
return Record{Latency: time.Since(startAt), Status: resp.StatusCode, Bytes: n}
}
如果延迟从「实际发出」开始算,被限速器推迟的那段排队时间就凭空消失了 —— 服务端明明已经扛不住、请求在客户端排了 200ms 的队,报告里却显示 P99 很漂亮。 这就是 coordinated omission(协调遗漏),HdrHistogram 的作者 Gil Tene 反复讲的那件事, wrk2 的 fixed-rate 模式也是同一个思路。
配套地,报告里会输出速率达成率:目标 5000 实际 4982 表示服务端跟得上; 如果实际远低于目标,说明瓶颈在被测服务而不是压测器 —— 这个数不分开展示, 再漂亮的分位数也没有意义。
两个容易漏的细节
- Body 每次要新建。
template的 Body 是共享的*strings.Reader, 多个 goroutine 同时读它会直接乱掉,所以每次请求NopCloser(strings.NewReader(body))。 - 被 cancel 的在途请求不算失败:
// 停止信号到来后被中断的在途请求不计入统计(那不是服务端的错)
if rec.ErrMsg != "" && ctx.Err() != nil {
return
}
否则每次 Ctrl+C 都会在报告里留下一串 connect timeout,误导后面的分析。
已知边界
它仍然是**闭环(closed-loop)**模型:worker 必须等上一次响应读完才发下一次。
真开环要按时间表预生成请求 + 独立结果队列,内存与复杂度都要上一个台阶。
另外每请求一次 Request.Clone + 新建 Body reader 带来不小 GC 压力,
极限吞吐不如 wrk 那种 epoll + 复用缓冲的写法 —— 换来的是零依赖、可读、可改。