← 返回笔记列表

压测工具的限速器不能用 time.Ticker

发布于 来自 Kite

  • 工程笔记
  • Y-HTTP-Bench
  • Go
  • 性能测试

现象:设了 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 表示服务端跟得上; 如果实际远低于目标,说明瓶颈在被测服务而不是压测器 —— 这个数不分开展示, 再漂亮的分位数也没有意义。

两个容易漏的细节

  1. Body 每次要新建。template 的 Body 是共享的 *strings.Reader, 多个 goroutine 同时读它会直接乱掉,所以每次请求 NopCloser(strings.NewReader(body))。
  2. 被 cancel 的在途请求不算失败:
// 停止信号到来后被中断的在途请求不计入统计(那不是服务端的错)
if rec.ErrMsg != "" && ctx.Err() != nil {
    return
}

否则每次 Ctrl+C 都会在报告里留下一串 connect timeout,误导后面的分析。

已知边界

它仍然是**闭环(closed-loop)**模型:worker 必须等上一次响应读完才发下一次。 真开环要按时间表预生成请求 + 独立结果队列,内存与复杂度都要上一个台阶。 另外每请求一次 Request.Clone + 新建 Body reader 带来不小 GC 压力, 极限吞吐不如 wrk 那种 epoll + 复用缓冲的写法 —— 换来的是零依赖、可读、可改。