线上偶发延迟怎么来的:我用 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 &lt;- struct{}{}:
return true
case &lt;-ctx.Done():
return false
case &lt;-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) &lt; 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 := &amp;http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 3 * time.Second,
}
fmt.Println("listening on :8080")
_ = srv.ListenAndServe()
}
你会看到: 在压力上来时,有些请求会 429,而不是把延迟拖成“长尾”。这对线上通常更健康——因为“可解释的拒绝”比“不可预测的超时”更容易处理(客户端可以重试,网关可以降级,监控也更直观)。
5)这类中间件解决的到底是什么问题
它不是万能药,但它能稳定地解决三类常见问题:
-
goroutine 爆炸 下游慢 → goroutine 堆积 → 内存与调度压力上升 → 延迟抖动
-
连接池等待放大 比如 DB 连接池只有 50,你却允许 500 并发请求一起冲进来,剩下 450 全在等待。等待本身就会造成“偶发超时”。
-
缓存/锁争用放大 热点资源(单 key、单锁)会被大量并发争抢,导致尾延迟加剧。限制并发可以减少争用强度。
核心思想就是:不要把系统当成无限吞吐的黑洞。当你设定并发上限,系统就从“失控排队”变成“可控退让”。
6)我踩过的一个坑:只限 QPS 不限并发
很多人会做 QPS 限流,但 QPS 限流不一定能保护你:
- 请求平均 10ms 时,1000 QPS 也许没事
- 请求变成平均 300ms 时,1000 QPS 会把并发推到 300 左右
- 并发高了,资源争用上来,延迟再变得更长
所以我更倾向于把“并发限制”当底线,再视情况加上 QPS 限流。并发限制更像“系统刹车”,QPS 限流更像“车速上限”,两者解决的问题不一样。
结尾:让系统在压力下“更像系统”
我喜欢这种简单的 Gate 方案,原因不是它高级,而是它让系统在压力下更像系统:
- 忙就是忙,不用假装还能全接
- 拒绝是明确的 429,不是随机超时
- 保护的是整体稳定性,而不是某一次请求的面子
评论 0