线上偶发延迟怎么来的:我用 Go 写了个限流中间件做“刹车”

线上偶发延迟怎么来的:我用 Go 写了个限流中间件做“刹车”(含代码)

我第一次在 Go 服务里遇到“偶发延迟飙升”,直觉也是怪下游:Redis、MySQL、第三方 API。后来发现下游并不慢,慢的是我们自己在并发失控时把 goroutine 堆成了“排队海”。Go 的 goroutine 很轻,但轻不代表无限;当请求的并发度超过你系统的真实承载能力,延迟会以一种很“安静”的方式变差:没有明显报错,CPU 也未必爆,但 P95/P99 会抖得厉害。

那之后我习惯在入口做一件事:明确告诉系统“最多同时处理多少个请求”。这篇文章用 Go 写一个可落地的限流/并发控制中间件(更准确地说是“并发闸门”),并说明我怎么用它把“偶发”变成可解释。


1)为什么 Go 服务也需要“并发刹车”

Go 的典型 HTTP 服务器模型是:每个请求一个 goroutine。平时很好用,但在以下场景会出问题:

  • 下游有偶发抖动(数据库慢 200ms → 800ms)
  • 你有较多 IO 等待(外部 API、磁盘、网络)
  • 入口流量突发(短时间 burst)

这时 goroutine 会快速堆积: 请求处理时间变长 → goroutine 不退出 → 并发更高 → 资源争用更严重 → 延迟继续变长。 你看到的就是“偶发超时”,但根因其实是没有并发上限


2)我的思路:用带缓冲的 channel 当信号量

Go 里最简单可控的并发限制:chan struct{} 作为信号量。

  • channel 容量 = 允许同时处理的最大请求数
  • 每个请求进来先 Acquire(往 channel 写入)
  • 处理完毕再 Release(从 channel 读出)
  • 如果拿不到名额:可以选择立即拒绝(429),或等待一会儿再决定

这比纯 QPS 限流更“贴近系统承载”:你限制的是同时在跑的请求数,对保护 CPU、连接池、下游都很有效。


3)实现:并发闸门中间件(支持超时等待)

下面是一份可以直接放进项目的实现,适用于标准库 net/http

package main

import ( "context" "fmt" "net/http" "time" )

// Gate:并发闸门(信号量) type Gate struct { sem chan struct{} }

// NewGate:maxConcurrent = 同时处理的最大请求数 func NewGate(maxConcurrent int) *Gate { if maxConcurrent <= 0 { panic("maxConcurrent must be > 0") } return &Gate{sem: make(chan struct{}, maxConcurrent)} }

// Acquire:尝试获取一个并发名额 // 如果 wait <= 0:立即返回(不等待) // 如果 wait > 0:最多等待 wait 时间 func (g *Gate) Acquire(ctx context.Context, wait time.Duration) bool { if wait <= 0 { select { case g.sem <- struct{}{}: return true default: return false } }

timer := time.NewTimer(wait)
defer timer.Stop()

select {
case g.sem <- struct{}{}:
    return true
case <-ctx.Done():
    return false
case <-timer.C:
    return false
}

}

// Release:释放名额 func (g *Gate) Release() { select { case <-g.sem: default: // 理论上不该发生:Release 次数 > Acquire 次数 } }

// Middleware:并发限制中间件 // wait = 0 代表队列不等待,拿不到就直接 429 func (g Gate) Middleware(wait time.Duration) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r http.Request) { ok := g.Acquire(r.Context(), wait) if !ok { w.Header().Set("Retry-After", "1") http.Error(w, "Too Many Requests", http.StatusTooManyRequests) return } defer g.Release()

        next.ServeHTTP(w, r)
    })
}

}

这段代码故意写得很朴素:它不做复杂的令牌补给,也不试图“聪明地排队”。它只做一件事:当系统繁忙时,明确拒绝或短暂等待,而不是无限堆 goroutine


4)配套:一个真实一点的示例服务

我们写一个模拟 handler:20% 的请求慢一点(模拟下游抖动),其他请求快一点。然后用 Gate 把并发限制在 50。

package main

import ( "fmt" "math/rand" "net/http" "time" )

func main() { rand.Seed(time.Now().UnixNano())

gate := NewGate(50)

mux := http.NewServeMux()
mux.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
    // 模拟:偶发慢请求
    if rand.Intn(10) < 2 {
        time.Sleep(250 * time.Millisecond)
    } else {
        time.Sleep(20 * time.Millisecond)
    }
    fmt.Fprintln(w, "ok")
})

// wait=80ms:允许短暂等待,超过就 429
handler := gate.Middleware(80 * time.Millisecond)(mux)

srv := &http.Server{
    Addr:              ":8080",
    Handler:           handler,
    ReadHeaderTimeout: 3 * time.Second,
}

fmt.Println("listening on :8080")
_ = srv.ListenAndServe()

}

你会看到: 在压力上来时,有些请求会 429,而不是把延迟拖成“长尾”。这对线上通常更健康——因为“可解释的拒绝”比“不可预测的超时”更容易处理(客户端可以重试,网关可以降级,监控也更直观)。


5)这类中间件解决的到底是什么问题

它不是万能药,但它能稳定地解决三类常见问题:

  1. goroutine 爆炸 下游慢 → goroutine 堆积 → 内存与调度压力上升 → 延迟抖动

  2. 连接池等待放大 比如 DB 连接池只有 50,你却允许 500 并发请求一起冲进来,剩下 450 全在等待。等待本身就会造成“偶发超时”。

  3. 缓存/锁争用放大 热点资源(单 key、单锁)会被大量并发争抢,导致尾延迟加剧。限制并发可以减少争用强度。

核心思想就是:不要把系统当成无限吞吐的黑洞。当你设定并发上限,系统就从“失控排队”变成“可控退让”。


6)我踩过的一个坑:只限 QPS 不限并发

很多人会做 QPS 限流,但 QPS 限流不一定能保护你:

  • 请求平均 10ms 时,1000 QPS 也许没事
  • 请求变成平均 300ms 时,1000 QPS 会把并发推到 300 左右
  • 并发高了,资源争用上来,延迟再变得更长

所以我更倾向于把“并发限制”当底线,再视情况加上 QPS 限流。并发限制更像“系统刹车”,QPS 限流更像“车速上限”,两者解决的问题不一样。


结尾:让系统在压力下“更像系统”

我喜欢这种简单的 Gate 方案,原因不是它高级,而是它让系统在压力下更像系统:

  • 忙就是忙,不用假装还能全接
  • 拒绝是明确的 429,不是随机超时
  • 保护的是整体稳定性,而不是某一次请求的面子

评论 0