限流的艺术:令牌桶与滑动窗口的博弈,以及我为何在 AI 项目中选择了后者

写在前面

你好,我是 Evan。一名正在摸爬滚打的 Java 后端开发者,也是这个专栏的作者。

今天想和你聊聊一个让我"后知后觉"的话题------限流。说实话,在前两个单体项目和微服务项目中,我几乎没有主动思考过限流。那时候项目 QPS 不过百,写个接口直接上线,根本没遇到被流量打垮的情况。团队里用的限流组件是阿里 Sentinel,配置一下规则就行,底层原理?没深究过。

直到最近做 智答 Agent 项目 ,用户与 AI 对话的模块需要限制每个会话的请求频率(防止恶意刷接口),我才第一次亲手实现了一个滑动窗口计数器 。这时我才发现:原来限流算法不是"选一个组件配置"那么简单,它背后是一套关于精度、内存、突发流量的深刻权衡。

这篇文章,我想对比两种最常见的限流算法------令牌桶滑动窗口,并结合我的项目实践,帮你理清:什么时候该用谁,以及为什么 Sentinel 底层用的是滑动窗口。

一、限流的本质:一个"放行"决策问题

限流的任务很简单:判断请求是否应该被处理。但难就难在"标准"如何制定。

最常见的两种策略:

  • 基于时间窗口:比如 1 秒内最多处理 100 次请求。

  • 基于令牌桶:系统以固定速率生成令牌,请求需要取走一个令牌才能被处理。

两者都能实现限流,但它们在应对突发流量统计精度内存开销上差异巨大。

二、滑动窗口:精确到毫秒的"记账本"

2.1 固定窗口的缺陷

最简单的限流算法是固定窗口计数器:将时间切分成固定的窗口(比如 1 秒),每个窗口内计数,超过阈值就拒绝。

html 复制代码
窗口0         窗口1         窗口2
[0ms-1000ms] [1000ms-2000ms] ...

致命问题 :窗口边界处的流量突刺。

比如限制 1 秒 100 次请求。攻击者可以在第 1 秒的最后 10ms 发起 100 次,在第 2 秒的前 10ms 再发起 100 次,实际 20ms 内就承受了 200 次请求,系统可能被冲垮。

2.2 滑动窗口如何解决问题

滑动窗口将窗口进一步细分(比如 1 秒分成 10 个 100ms 的小格子)。每个格子独立计数,当前窗口的总计数 = 所有未过期的格子之和。

当时间向前滑动时,最旧的格子被丢弃,新的格子加入。

示例:限制 1 秒 100 次。格子大小 100ms,每个格子独立计数。

  • 请求在 0.95s 进入:落在格子9,计数+1。

  • 请求在 1.02s 进入:窗口滑动到 0.2s~1.2s,丢弃格子0,加入新格子,重新计算总和。

这种方式消除了窗口边界突刺,因为窗口是"连续滑动"的,不会出现两个满额窗口背靠背的情况。

2.3 滑动窗口的优缺点

我在智答 Agent 项目中的应用

我们需要限制每个会话(sessionId) 每分钟最多请求 AI 接口 30 次。如果用固定窗口,用户可以在第 59 秒和第 61 秒连续发起请求,瞬间 60 次。改用滑动窗口后,窗口大小 60 秒,格子 6 秒(10 个格子),精确控制了请求频率,彻底杜绝了边界突刺。

三、令牌桶:应对突发流量的"水库"

3.1 原理

令牌桶算法有一个"桶",以固定速率 r 往桶里放令牌(比如每秒放 10 个)。桶的容量为 b(最大突发)。每个请求必须从桶里取走 1 个令牌才能被处理。如果桶空,则拒绝。

3.2 为什么需要令牌桶?

滑动窗口严格控制了"任意时间窗口内的请求总数",但有时候我们希望允许一定的突发流量。比如:

  • 秒杀刚开始的几毫秒,瞬时流量巨大,但我们希望用户能正常下单,而不是立刻被限流。

  • API 网关对外提供接口,正常速率 1000/s,但偶尔有 2000/s 的突发(持续 1 秒),我们希望放行,而不是一刀切拒绝。

令牌桶允许这样的突发------只要桶里有令牌,请求就可以一次性取走多个令牌,形成短时高峰。然后桶慢慢恢复到稳定速率。

3.3 令牌桶的优缺点

3.4 一个经典例子

假设速率 r=10/s,桶容量 b=20。系统空闲一段时间后桶满。此时突然涌入 20 个请求,它们都能被放行(突发)。然后接下来的 1 秒内,桶只能以 10/s 的速率补充令牌,所以后续请求最多只能再处理 10 个。

这就是"允许突发,但不会持续超额"。

四、一张表看懂:滑动窗口 vs 令牌桶

实际选择

  • 如果你的需求是"绝对不能让用户在一段时间内超过 N 次",用滑动窗口(如防刷、限流验证码)。

  • 如果你的需求是"保护系统不被长时间高流量压垮,但允许短时峰刺",用令牌桶(如网关限流、数据库连接池限流)。

五、从实践到原理:Sentinel 为什么选择滑动窗口?

阿里 Sentinel 是目前 Java 生态最流行的限流组件。它的底层实现就是滑动窗口,而不是令牌桶。为什么?

  1. 精确控制:Sentinel 需要支持多种限流规则(QPS、线程数、关联资源等),滑动窗口能提供精确的统计值。

  2. 无需预测:令牌桶需要预估"未来令牌数",而 Sentinel 更看重"过去实际请求数"。

  3. 易于降级:滑动窗口可以直接根据实时统计触发熔断,更贴近"响应式"设计。

但 Sentinel 也提供了匀速排队模式(类似令牌桶),用于处理突发。所以它不是完全抛弃令牌桶思想,而是根据场景选择。

我的项目经验

在智答 Agent 中,AI 对话模块单次调用耗时长(2~5 秒),且涉及计费。不允许任何突发,必须严格限制频率。所以我选了滑动窗口,直接基于 Redis 的 ZSET 实现(按时间戳存储请求记录,清理过期条目)。如果用令牌桶,突发放行可能导致 AI 服务短时过载,不可接受。

六、你在项目中何时该考虑限流?

即使你的项目 QPS 很低,也应该提前考虑限流,原因有三:

  1. 防止恶意攻击:公开接口容易被刷(短信验证码、搜索接口)。

  2. 保护下游依赖:你的服务可能调用数据库、消息队列或第三方 API,它们都有承载上限。

  3. 公平性:多租户场景下,限制某个用户的调用次数,防止资源被占满。

最小实践

在 Spring Boot 项目中,你可以用 @RateLimiter 注解 + AOP 快速实现一个简单的滑动窗口(基于 Guava Cache 或 Redis)。先让限流存在,再慢慢优化。

java 复制代码
@RateLimiter(limit = 100, window = 60)  // 60秒内最多100次
public Result askAI(String question) { ... }

**思考:**在你的项目中,有一个对外提供的 API 接口,正常 QPS 大约 500。但每隔几分钟会有一次 1 秒内 2000 QPS 的突发流量(来自合法用户的行为聚集)。如果使用滑动窗口限制 1000 QPS,会导致部分合法请求被误拦;如果使用令牌桶并设置桶容量 2000,又可能导致持续高流量压垮系统。你有什么优化的思路?欢迎在评论区分享你的方案。

相关推荐
xieliyu.2 小时前
Java算法精讲:双指针(三)
java·开发语言·算法
love530love2 小时前
LiveTalking 数字人项目 Windows 部署完全指南(EPGF 架构)
人工智能·windows·python·架构·livetalking·epgf
星辰徐哥2 小时前
Spring Boot 微服务架构设计与实现
spring boot·后端·微服务
星辰徐哥2 小时前
Spring Boot 数据导入导出与报表生成
spring boot·后端·ui
明夜之约2 小时前
Spring Boot 自动装配源码
java·spring boot·后端
Leaton Lee2 小时前
Spring Boot分层架构详解:从Controller到Service再到Mapper的完整流程
java·spring boot·后端·架构
Micro麦可乐2 小时前
Spring Boot 实战:从零设计一个短链系统(含完整代码与数据库设计)
数据库·spring boot·后端·哈希算法·雪花算法·短链系统
Jinkxs2 小时前
Resilience4j- 与 Spring Boot 快速集成:自动配置与基础注解使用
java·spring boot·后端
毕设源码_郑学姐2 小时前
计算机毕业设计springboot网络相册设计与实现 基于Spring Boot框架的在线相册管理系统开发与应用 Spring Boot驱动的网络影集设计与实践
spring boot·后端·课程设计
辣机小司2 小时前
【踩坑记录:Spring Boot 配置文件读取值不一致?警惕 YAML 的“八进制陷阱”与 SnakeYAML 版本之谜】
java·spring boot·后端·yaml·踩坑记录