接口限流详解:算法、分层与 Spring Boot 实战
限流和高并发、Redis 经常连着问:「你们怎么防刷?」「秒杀接口怎么保护?」我按面试口述顺序写——先讲为什么限、四种算法区别、放哪一层,再落到 Spring Boot / Redis / Sentinel / 网关的具体写法。
题目
- 为什么要做接口限流?和熔断、降级有什么区别?
- 常见限流算法有哪些?各适合什么场景?
- 限流放在哪一层?按什么维度限(IP、用户、接口)?
- Spring Boot 项目里怎么实现? Redis 分布式限流要注意什么?
参考答案(口述要点)
- 目的:保护下游(DB、核心服务)不被突发流量打垮,防刷、防恶意、保证大多数用户可用
- 算法:固定窗口简单但有边界突刺;滑动窗口更平滑;令牌桶允许突发;漏桶输出恒定
- 分层:Nginx / 网关挡粗流量,应用内细粒度(按用户、按接口),可叠加
- 实现:单体用 Guava 或 Redis 计数;微服务用 Gateway + Sentinel;被限流返回 429 + 友好提示
- 区别:限流是主动拒请求;熔断是依赖挂了暂时切断;降级是返回兜底数据
为什么要限流
| 场景 | 不限流会怎样 |
|---|---|
| 登录 / 短信验证码 | 被撞库、短信费爆掉 |
| 秒杀下单 | DB 连接池打满,全站变慢 |
| 开放查询 API | 被爬虫刷,Redis/DB 热点 |
| 大促零点 | 合法流量峰值也足以压垮单机 |
限流本质是 用配额换稳定性:宁可让少部分请求快速失败(429),也不要让所有请求排队超时。
和周边概念别混:
| 概念 | 触发条件 | 行为 |
|---|---|---|
| 限流 | 请求速率超阈值 | 直接拒绝或排队 |
| 熔断 | 下游错误率/慢调用超阈值 | 一段时间内不再调用下游 |
| 降级 | 熔断或手动开关 | 返回默认值、缓存、简化逻辑 |
| 削峰 | 写流量突发 | MQ 异步,限流常配合使用 |
四种限流算法(面试必讲)
1. 固定窗口计数器
在固定时间窗口内计数,超过 limit 就拒绝。
时间轴: |---- 60s 窗口 ----|---- 下一窗口 ----|
计数: 98 → 100 → 拒- 优点:实现最简单,Redis
INCR + EXPIRE即可 - 缺点:窗口边界突刺——第 59 秒来了 100 次、第 61 秒又来 100 次,瞬间 200 次
- 适用:粗粒度、容忍边界误差的接口(如「每分钟 100 次」)
2. 滑动窗口
把大窗口切成多个小格,或 Redis ZSET 按时间戳记每次请求,统计最近 N 秒总数。
- 优点:比固定窗口平滑,边界问题小很多
- 缺点:实现稍复杂,ZSET 要定期清理
- 适用:对公平性要求高的 API 限流
3. 令牌桶(Token Bucket)
以固定速率往桶里放令牌,请求消耗令牌;桶满可攒令牌,允许合理突发。
- 优点:能应对短突发(桶里有存货)
- 缺点:桶大小和速率要调参
- 适用:网关、Guava
RateLimiter、Sentinel 匀速排队模式 - 代表:Guava、Sentinel QPS 限流
4. 漏桶(Leaky Bucket)
请求像水一样进桶,以恒定速率漏出处理;桶满则溢出不处理。
- 优点:输出速率严格平滑
- 缺点:突发流量会被丢弃或排队,不能「借」未来配额
- 适用:下游处理能力固定、要严格匀速的场景(消息消费、写库)
对比表
| 算法 | 突发流量 | 实现难度 | 典型组件 |
|---|---|---|---|
| 固定窗口 | 边界突刺 | ★ | Redis INCR |
| 滑动窗口 | 较平滑 | ★★ | Redis ZSET |
| 令牌桶 | 允许突发 | ★★ | Guava、Sentinel |
| 漏桶 | 输出恒定 | ★★ | Nginx、消息队列 |
面试一句话:「我们登录接口用 Redis 滑动窗口按 IP 限;核心下单用 Sentinel 令牌桶。」
限流放哪一层、按什么维度
| 层级 | 手段 | 适合挡什么 |
|---|---|---|
| CDN / WAF | 地域、Bot 规则 | 静态资源、明显恶意 |
| Nginx | limit_req_zone | 全站 IP 粗限 |
| API 网关 | Spring Cloud Gateway、Kong | 路由级、租户级 |
| 应用内 | Sentinel、AOP + Redis | 按用户、按接口细限 |
| 依赖方 | Feign + Sentinel | 保护下游微服务 |
常见维度
| 维度 | key 示例 | 场景 |
|---|---|---|
| 全局限流 | rate:global:createOrder | 保护核心写接口总 QPS |
| IP | rate:ip:192.168.1.1 | 防刷、开放 API |
| 用户 | rate:user:10086 | 秒杀「每人 1 秒 1 次」 |
| 接口 | rate:api:/login | 单接口配额 |
| 组合 | rate:user:10086:/order | 最精细 |
多层可以叠加:网关挡 10000 QPS,应用内再挡单用户 5 QPS。
被限流怎么响应
HTTP/1.1 429 Too Many Requests
Retry-After: 3
Content-Type: application/json
{"code": 429, "message": "操作过于频繁,请稍后再试"}前端收到 429 可做倒计时、按钮置灰,别让用户疯狂点。
方案一:Redis 固定窗口(单体最常用)
适合 Spring Boot 单体:自定义注解 + AOP + INCR。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
/** 资源名,会拼进 Redis key */
String key();
/** 窗口秒数 */
int period() default 60;
/** 窗口内最大次数 */
int limit() default 100;
/** 限流维度:IP / USER / GLOBAL */
LimitType type() default LimitType.IP;
}
public enum LimitType { IP, USER, GLOBAL }@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {
private final StringRedisTemplate redis;
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
String redisKey = buildKey(rateLimit);
Long count = redis.opsForValue().increment(redisKey);
if (count != null && count == 1) {
redis.expire(redisKey, Duration.ofSeconds(rateLimit.period()));
}
if (count != null && count > rateLimit.limit()) {
throw new BizException(429, "访问过于频繁,请稍后再试");
}
return pjp.proceed();
}
private String buildKey(RateLimit rl) {
String suffix = switch (rl.type()) {
case IP -> RequestUtils.getClientIp();
case USER -> String.valueOf(SecurityUtils.getUserId());
case GLOBAL -> "all";
};
return "rate:" + rl.key() + ":" + suffix;
}
}使用
@RateLimit(key = "sms:send", period = 60, limit = 1, type = LimitType.IP)
@PostMapping("/sms/send")
public void sendSms(@RequestBody SmsRequest req) {
smsService.send(req.getPhone());
}注意:INCR 和 EXPIRE 非原子,极端情况下 key 可能永不过期。生产用 Lua 一次做完:
-- KEYS[1]=key ARGV[1]=limit ARGV[2]=period秒
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if count > tonumber(ARGV[1]) then
return 0
end
return 1方案二:Redis 滑动窗口(ZSET)
按时间戳记录每次请求,统计最近 windowSeconds 内的次数。
@Service
@RequiredArgsConstructor
public class SlidingWindowRateLimiter {
private final StringRedisTemplate redis;
private static final String SCRIPT = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
local count = redis.call('ZCARD', key)
if count >= limit then
return 0
end
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, window * 1000)
return 1
""";
public boolean tryAcquire(String key, int limit, int windowSeconds) {
long now = System.currentTimeMillis();
String member = now + ":" + UUID.randomUUID();
Long ok = redis.execute(
RedisScript.of(SCRIPT, Long.class),
List.of(key),
String.valueOf(now),
String.valueOf(windowSeconds),
String.valueOf(limit),
member
);
return Long.valueOf(1).equals(ok);
}
}@PostMapping("/login")
public TokenVO login(@RequestBody LoginRequest req, HttpServletRequest request) {
String key = "rate:sliding:login:" + RequestUtils.getClientIp(request);
if (!slidingWindowRateLimiter.tryAcquire(key, 10, 60)) {
throw new BizException(429, "登录尝试过多,请 1 分钟后再试");
}
return authService.login(req);
}滑动窗口比固定窗口更适合 登录防暴力、短信验证码 这类安全场景。
方案三:Guava RateLimiter(单机令牌桶)
不需要 Redis,进程内限流;多实例部署时每个节点各算各的,总配额 ≈ limit × 实例数。
@Component
public class LocalRateLimiterRegistry {
private final ConcurrentHashMap<String, RateLimiter> limiters = new ConcurrentHashMap<>();
public boolean tryAcquire(String resource, double permitsPerSecond) {
RateLimiter limiter = limiters.computeIfAbsent(
resource,
k -> RateLimiter.create(permitsPerSecond)
);
return limiter.tryAcquire();
}
}@GetMapping("/report/export")
public void export(HttpServletResponse response) {
if (!localRateLimiter.tryAcquire("export", 2.0)) { // 每秒 2 个
throw new BizException(429, "导出繁忙,请稍后");
}
reportService.export(response);
}适合:导出、报表、CPU 密集接口——只在单机上保护资源,不要求全局精确配额。
方案四:Sentinel(微服务推荐)
Spring Cloud Alibaba 项目里,限流 + 熔断 + 降级一套搞定。
依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>注解方式
@RestController
@RequestMapping("/api/order")
public class OrderController {
@PostMapping
@SentinelResource(
value = "createOrder",
blockHandler = "createOrderBlock"
)
public OrderVO create(@RequestBody CreateOrderRequest req) {
return orderService.create(req);
}
public OrderVO createOrderBlock(CreateOrderRequest req, BlockException ex) {
throw new BizException(429, "下单人数过多,请稍后再试");
}
}规则可在 Nacos / Sentinel Dashboard 配:QPS 阈值、流控效果(直接拒绝 / 匀速排队)、热点参数(按商品 id 限)。
| 流控效果 | 对应算法感 | 行为 |
|---|---|---|
| 直接拒绝 | 计数/令牌不足 | 立刻抛 BlockException |
| 匀速排队 | 漏桶 | 请求排队,超 maxQueueingTime 则拒 |
| 预热冷启动 | 令牌桶预热 | 系统刚启动逐步升配额 |
面试说:「网关挡总流量,Sentinel 在 createOrder 资源上按 QPS 限,热点商品再配 paramFlow。」
方案五:Spring Cloud Gateway 网关限流
在流量入口用 Redis 做全局限流,应用不用改代码。
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/order/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 100 # 每秒补充令牌
redis-rate-limiter.burstCapacity: 200 # 桶容量
redis-rate-limiter.requestedTokens: 1
key-resolver: "#{@userKeyResolver}"@Bean
public KeyResolver userKeyResolver() {
// 按 IP;也可从 header 取 userId
return exchange -> Mono.just(
exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()
);
}replenishRate + burstCapacity 就是令牌桶语义,适合 保护整个 order 服务。
方案六:Nginx 入口限流
单体前后端分离、前面挂 Nginx 时,最便宜的挡法:
# http 块
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;
server {
location /api/ {
limit_req zone=api_limit burst=40 nodelay;
proxy_pass http://127.0.0.1:8080;
}
}rate=20r/s:漏桶/leaky 语义,平均每秒 20 个burst=40:允许短时突发 40 个排队- 适合挡 爬虫、全站 IP 粗限,细粒度仍要进应用层
分布式限流要注意什么
| 问题 | 说明 |
|---|---|
| 时钟漂移 | 多机时间差影响滑动窗口,用 Redis 统一时间或容忍小误差 |
| Redis 单点 | 限流 key 丢了规则失效,Redis 要高可用 |
| 热点 key | 全局限流一个 key 打满单个 Redis slot,可本地预限 + Redis 精限 |
| 误伤 | 公司 NAT 出口同一 IP 很多用户,纯 IP 限流要配合用户维度 |
| 配额与扩容 | 机器从 2 台扩到 4 台,Guava 本机限流总量会翻倍,要改用 Redis/Sentinel |
| 异步场景 | 限流通过只代表「准入」,下游 MQ 堆积还要靠消费者限流 |
项目里怎么选(口述素材)
| 项目形态 | 我会怎么做 |
|---|---|
| 单体 Spring Boot | 短信/登录:@RateLimit + Redis 滑动窗口;报表:Guava 本机 |
| Spring Cloud | Gateway 总限 + Sentinel 接口级 + 热点参数 |
| 秒杀 | 网关按用户 1s1次 + Redis 库存预扣 + MQ 异步下单,限流在入口就挡住大部分 |
| 开放 API | IP + AppKey 双维度,超限 429,配合 API 密钥配额 |
和缓存题联动:限流计数本身也是 Redis 的典型用法,key 设计 rate:{业务}:{维度}:{id},TTL 跟窗口走。
延伸 / 易错点
- 限流不是万能的:DB 慢、没索引,限流只会让用户多看到 429,根因还要治理 SQL
- 登录限流别只认 IP:移动网络 IP 变;可 IP + 账号组合,多次失败后加验证码
- Warmup:冷启动时直接满 QPS 可能把刚启动的 JVM 打挂,Sentinel 预热或逐步放量
- 测试:压测时单独验证限流阈值是否生效,别等到大促才发现 key 写错
- 监控:记录
blocked次数,限流触发率突然升高可能是攻击或营销活动没预告
写在最后
限流面试抓住 算法 → 层级 → 维度 → 一种你写过的实现 四条线就够。单体项目答 Redis 固定/滑动窗口 + 注解 AOP;微服务补 Gateway 和 Sentinel。别和高并发题里的熔断、降级搅在一起——限流是「人太多不让进」,熔断是「里面着火了暂时别进去」。
