Go Web 服务中的请求限流:基于令牌桶与滑动窗口的实现方案

本文深入讲解 Go Web 服务中两种主流请求限流算法:令牌桶与滑动窗口。分析它们的实现原理、适用场景和工程常见坑,并给出基于 Go 的实现示例与选型建议,帮助你在高并发服务中做出务实决策。

限流到底在保护什么

限流这个名字听起来很直接,就是限制单位时间内能进入的请求数。但真正要限的是什么,值得先想清楚。

Go Web 服务中的请求限流:基于令牌桶与滑动窗口的实现方案

有的场景是保护数据库,比如某个查询特别重,超过一定并发就会把连接池打满;有的场景是保护下游服务,比如调用第三方 API 有配额;还有的是保证用户之间的公平性,防止某个客户端把资源都占走。

如果只是为了“不加限制怕被打爆”,那限流阈值很难定。更务实的做法是,先测量下游能承受的并发和 QPS,再反推入口限流值。限流只是一个闸门,闸门调多高,取决于后面那条路到底能走多少车。

令牌桶:允许突发,但平均速率可控

令牌桶算法的核心有两个参数:桶的容量 b 和令牌补充速率 r。系统按 r 的速度往桶里放令牌,桶最多放 b 个。请求进来时先取令牌,有令牌就放行,没有就拒绝或者等待。

这种设计带来的好处很实际:长期看,每秒处理的请求数不会超过 r,但短时间内容许出现一个 b 大小的突发流量。对很多 Web 业务来说,突发恰恰是需要被容忍的。比如缓存刚过期,热点 key 回源,或者客户端做了一次批量操作,瞬时流量高一点,但平均值并不高。令牌桶可以吸收这些毛刺。

Go 生态里最常用的令牌桶实现是 golang.org/x/time/rate

limiter := rate.NewLimiter(rate.Limit(100), 200)
if limiter.Allow() {
    // 处理请求
} else {
    http.Error(w, "too many requests", http.StatusTooManyRequests)
}

这里每秒补充 100 个令牌,桶容量是 200。也就是说,即使瞬时有 200 个请求进来,也能全部通过;但如果请求持续不断,长期下来每秒最多处理 100 个。

需要注意的是,Allow() 是非阻塞的,拿不到令牌就立刻返回 false。如果希望等待,应该用 Wait() 或 WaitN()。在 Web 服务里,通常不想请求无限等待,所以 Allow() 反而更常见。

自己实现令牌桶也不复杂,每次请求时计算当前时间和上次补充时间之间能补充多少令牌。但并发安全、系统时钟回拨这些边界条件很容易写错。在没有特殊需求之前,直接用 rate 包就是性价比最高的选择。

滑动窗口:更精确的配额控制

滑动窗口要解决的是固定窗口的临界突发问题。

假设限制是每分钟 100 次,固定窗口从 9:00:00 到 9:01:00 记了 100 次,然后 9:01:00 重置,又可以在 9:01:01 前再放 100 次。实际上在 9:00:59 到 9:01:01 这两秒里,放进了 200 次请求。对外承诺“每分钟最多 100 次”的业务,这显然会被用户拿出监控数据来打脸。

滑动窗口的思路是,让窗口的起点跟着当前时间平滑移动,而不是固定在整点。

type Window struct {
    mu   sync.Mutex
    size time.Duration
    max  int
    logs []time.Time
}

func (w *Window) Allow(now time.Time) bool {
    w.mu.Lock()
    defer w.mu.Unlock()

    start := now.Add(-w.size)
    idx := sort.Search(len(w.logs), func(i int) bool {
        return !w.logs[i].Before(start)
    })
    if idx > 0 {
        w.logs = w.logs[idx:]
    }

    if len(w.logs) >= w.max {
        return false
    }
    w.logs = append(w.logs, now)
    return true
}

这段代码记录了每一个请求的时间戳。每次请求进来,先清理掉已经滑出窗口的时间戳,然后看剩余数量是否达到上限。实现很直观,但有一个明显问题:内存占用和请求数成正比。如果每秒几千请求,窗口内就要存几万甚至几十万个时间点,GC 压力会变大。

生产环境更常用的是分片式滑动窗口:把窗口切分成多个小桶,比如一分钟切成 60 个一秒的桶。请求到达时,更新当前秒对应的桶计数,同时把窗口最前面那个一秒桶的计数减掉。这样内存固定为桶的个数,性能也更稳定。

当然,分片法不是绝对精确。它把窗口边界处理缩小到 1 秒的粒度,也就是说,实际限制可能比设定值多出“一个分片的量”。如果业务能接受这种误差,分片法几乎是必选。

令牌桶与滑动窗口的选型对比

两种算法的差异用一个表格可以看得很清楚。

维度 令牌桶 滑动窗口
突发流量 允许桶容量以内的突发 严格限制窗口内总量,不支持突发
实现复杂度 低,官方库可直接用 中等,需要自己维护时间戳或分片
内存占用 O(1),基本可忽略 时间戳法 O(N),分片法 O(分片数)
准确性 平均速率有保证,瞬时可能超出 窗口内请求数更精确
典型场景 API 网关、保护下游、允许短时峰值 用户配额、调用次数统计、严格防刷

选型时先问自己一个问题:业务是否能接受短时间内的流量尖峰。如果能,令牌桶让系统有弹性;如果不能,滑动窗口更合适。

再多说一句准确性的理解。令牌桶并不是不准确,它保证的是“平均速率”;滑动窗口保证的是“窗口内总量”。对于内部流量保护,平均速率通常够了;对于对外承诺 SLA,或者按次数计费,窗口总量才是用户真正关心的。

工程里最常见的三个坑

算法本身的细节并不难,真正让团队头疼的是把它们放进真实环境。

第一个坑是多实例限流失效。很多 Go 服务是水平部署的,每个实例各自维护限流器。比如 3 个实例,每个限制 100 QPS,看起来总量是 300,但实际流量不一定均匀分布。负载均衡算法、实例重启、长连接复用都可能导致单个实例承接了远超平均的请求。本地限流在这种架构下只能作为兜底,真正要全局精确,还是得走 Redis + Lua 做分布式限流。

第二个坑是限流阈值没有基于下游容量。你在入口限到 200 QPS,但数据库连接池只有 50 个连接,背后的 SQL 平均耗时 100ms,连接池早就被打满了。限流应该放在整个调用链路的入口没错,但阈值必须参考下游能承受的极限,最好是压测出来的 70% 到 80%。

第三个坑是限流后的反馈没有闭环。客户端拿到 HTTP 429 后,如果不做退避,立刻重试,限流就没有起到保护作用,反而增加了系统负担。服务端应该在 429 响应里带上 Retry-After 头,客户端也要有对应的退避策略。

把这三个问题汇总一下:

  • 本地限流在多副本下不等于全局限流,需要分布式方案。
  • 限流阈值要跟着下游容量走,不能拍脑袋。
  • 429 只是开始,需要配合客户端的退避和重试策略。

落地时的一些务实建议

如果你从零开始给 Go Web 服务接入限流,下面几个建议可以考虑。

先说做法。

  1. 优先使用 golang.org/x/time/rate,不要第一反应就自己造轮子。官方库的令牌桶已经够稳定,也支持批量消耗。
  2. 如果选滑动窗口,先用分片法,不要直接记录所有时间戳。分片法已经能覆盖绝大多数场景,代码量也更可控。
  3. 多实例且需要全局统一限额时,用 Redis + Lua 实现原子计数。Redis 不可用时的降级策略也要提前想好,比如退化成每实例本地限流。
  4. 把限流指标打进监控系统。被拒绝次数、等待时长、触发限流的路由分布,这些数据比算法本身更能帮你判断阈值是否需要调整。
  5. 保留线上动态调整限流阈值的通道。很多时候初始阈值来自压测,但压测不能完全模拟真实流量,上线后需要有手段快速调整。

再补一个场景。如果你是做开放平台,给第三方开发者提供 API,通常需要精确控制每个 App 的每分钟调用次数。这时候滑动窗口更合适,因为用户会拿“我明明只调了 30 次”来和你核对。固定窗口的临界突发会在用户视角里变成“突然被限流”,排查起来非常麻烦。反过来,如果你是在保护内部订单服务,允许一小波流量进入队列让系统吞吐更有弹性,令牌桶就是更自然的选择。

最后说几句

令牌桶和滑动窗口并不是互斥的,它们解决的是不同层面的问题。令牌桶是一个流量整形器,给系统留出突发空间;滑动窗口是一个配额记账本,把每一笔都记清楚。理解了背后的取舍,你在 Go Web 服务里再遇到限流需求时,就能根据业务形态做判断,而不是想都不想套用一个库。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/962/

(0)
上一篇 24分钟前
下一篇 5分钟前

相关推荐