接口限流从手写到落地:自定义注解+Redis滑动窗口——一行@RateLimit就搞定

引言

上线第二个月的某个周五晚上,运营在群里发了个爆款活动链接。十分钟后,订单服务的监控曲线齐刷刷跳水——不是宕机,是一个"查优惠券"的接口被人写脚本疯狂调用,单机 QPS 冲到 8000,把 Tomcat 的 200 个工作线程全部占满,正常的下单请求全部在排队超时

那一刻我们才意识到:限流不是"高可用锦上添花",是保命的

当时团队内部手写的限流方案长这样——每个需要限流的接口里塞一段:

// 散落在 20 多个 Controller 里的"祖传代码"
if (!rateLimiter.tryAcquire("coupon/query", 100)) {
    throw new BizException("操作太频繁,请稍后再试");
}
doQuery();

三个致命问题:

  • 代码侵入:业务逻辑里混着限流细节,改限流阈值要发版
  • 策略写死:硬编码在代码里,运营说"大促期间查询放宽到 200/s",得走发布流程
  • 单机视角:只限了单台实例,集群 8 台实例就是 8 倍配额,"100 次/秒"名存实亡

这篇文章把我们最终落地的方案完整讲一遍:自定义 @RateLimit 注解(支持 key/limit/window)→ AOP 切面拦截 → Redis Lua 脚本滑动窗口(原子性 + 精确计数)。最终效果是一行注解搞定限流:

@RateLimit(key = "'login:' + #phone", limit = 1, window = 1, timeUnit = TimeUnit.SECONDS)
@PostMapping("/login")
public Result login(@RequestParam String phone, @RequestParam String pwd) { ... }

文章最后给出三种典型接口的落地配置(登录 1 次/秒、查询 100 次/秒、下单 10 次/秒)、压测数据、以及 6 个高频踩坑问答,所有代码可直接抄走。


一、从需求出发:三种接口,三种限流策略

方案设计前先把需求摆清楚。我们梳理了核心接口,发现限流诉求完全不同:

接口类型限流策略维度桶的 Key超限行为
登录/验证码1 次/秒(防撞库、防轰炸)手机号维度login:{phone}快速失败,不提示太多信息
查询类100 次/秒(防爬虫、防刷)用户ID维度query:{userId}快速失败
下单类10 次/秒(防恶意下单,但不能误伤正常用户)用户ID维度order:{userId}快速失败 + 告警

从这张表里能提炼出限流组件必须支持的四个能力:

  1. 灵活的 Key:有的按手机号、有的按用户ID、有的要按 IP——所以 key 必须支持 SpEL 表达式动态计算
  2. 灵活的阈值和窗口:1 次/秒 和 100 次/秒 显然不能共用常量
  3. 集群级精确计数:8 台实例共享一个 100 次/秒 的配额,计数必须放 Redis
  4. 原子性:"读计数 → 判断 → 写计数"三步绝不能被并发打断,否则限流形同虚设

还有个容易被忽略的点:超限后的行为。我们统一为抛 RateLimitException,由全局异常处理器转成 HTTP 429(Too Many Requests)+ 友好文案,业务代码无感知。


二、方案选型:为什么是 注解 + AOP + Redis 滑动窗口

2.1 限流算法对比

先把算法选对。主流四种:

算法原理优点缺点适用
计数器(固定窗口)每分钟一个计数器实现最简单临界突刺:59 秒 100 次 + 61 秒 100 次 = 2 秒内 200 次精度要求低
滑动窗口把窗口切成 N 个小格,滚动统计平滑无突刺,精确实现稍复杂本文选择
漏桶恒定速率放行出口绝对平滑无法应对合理突发流量整形
令牌桶恒定速率发令牌,桶有容量允许突发,Guava RateLimiter 同款桶容量设计需斟酌单机突发场景

固定窗口的临界突刺值得单独画出来(这是面试高频题):

阈值 100 次/分钟,固定窗口:

窗口1 [00:00~01:00) :100 次(用满)
窗口2 [01:00~02:00) :100 次(用满)

实际观察 00:59 ~ 01:01 这 2 秒:
  → 最多通过了 200 次!是阈值的 2 倍

滑动窗口把 1 分钟切成 6 个 10 秒小格,每次检查的是"过去 60 秒的总和",从数学上消灭了突刺:

滑动窗口(60s 窗口 = 6 × 10s 格):

时刻 01:00 检查:统计 [00:00~01:00) 的总和 → 含完整窗口1
时刻 01:10 检查:统计 [00:10~01:10) 的总和 → 窗口向右滑动一格
时刻 01:20 检查:统计 [00:20~01:20) 的总和 → 继续滑动
...
任意时刻都是"最近 60 秒的精确总和",无突刺

2.2 为什么计数要放 Redis,还要用 Lua?

放 Redis:8 台实例要共享 100 次/秒 的集群配额,本地内存计数器只能管单机。Redis 单线程模型 + 高性能(内网读写 < 1ms),天然适合做集中计数器。

用 Lua:这是整个方案的正确性基石。不用 Lua 的话,Java 侧的限流逻辑是三步:

① GET count          → 拿到当前计数
② IF count >= limit  → 判断是否超限
③ INCR count         → 未超限则 +1

并发下这三步会交错。两个线程同时执行①,都拿到 count=99,都通过②,各自③,最终 count=101——超卖了 1 个配额。压测并发越高,超卖越多,限流阈值等于形同虚设。

Redis 执行 Lua 脚本时整个脚本作为单个原子命令执行,期间不会插入其他命令,从根上消灭竞态。这就是官方推荐的分布式限流姿势。

2.3 最终架构

HTTP 请求
      │
      ▼
┌─────────────────────┐
│  @RateLimit 注解      │ ← 声明限流策略(key/limit/window)
├─────────────────────┤
│  RateLimitAspect     │ ← AOP 切面:解析 SpEL key → 调 Lua
│  (AOP 切面)          │
├─────────────────────┤
│  Redis Lua 脚本      │ ← 原子执行:滑动窗口计数 + 判断 + 记录
├─────────────────────┤
│  Redis (ZSET)        │ ← 集群共享的计数存储
└─────────────────────┘
      │
      ▼ 未超限 → 放行执行业务
      ▼ 超限   → 抛 RateLimitException → 全局异常处理 → HTTP 429

三、第一步:自定义 @RateLimit 注解

3.1 注解定义

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

/**
 * 接口限流注解:基于 Redis 滑动窗口的集群级限流
 *
 * 使用示例:
 *   @RateLimit(key = "'login:' + #phone", limit = 1, window = 1)
 *   public Result login(String phone, String pwd) { ... }
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {

    /**
     * 限流 Key,支持 SpEL 表达式。
     * 常用写法:
     *   "'login:' + #phone"                    按参数(手机号)
     *   "'order:' + #userId"                   按用户ID
     *   "'ip:' + T(space.jiangyi.util.IpUtil).getClientIp()"  按 IP
     */
    String key();

    /** 窗口内允许的最大请求数 */
    int limit();

    /** 时间窗口大小,默认 1 */
    int window() default 1;

    /** 时间窗口单位,默认秒 */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /** 限流提示文案,为空则用全局默认 */
    String message() default "";

    /** 是否启用(方便配置中心动态开关,配合 APollo/Nacos 覆盖) */
    boolean enabled() default true;
}

设计说明:

  • key 支持 SpEL 是灵魂。'login:' + #phone 会把方法参数拼进 key,实现"每个手机号各自 1 次/秒";不加前缀直接写常量字符串就是全局限流
  • limit + window 分开声明而不是写死"per second",因为需求里有"100 次/10 秒"这种非整秒窗口
  • message 和 enabled 是实战补出来的字段:前者让不同接口有不同提示,后者配合配置中心做紧急降级开关

3.2 SpEL 解析:把 'login:' + #phone 变成真实 Key

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

/**
 * SpEL 表达式解析工具:从方法签名和入参中解析出限流 Key
 */
public class SpelKeyResolver {

    private static final ExpressionParser PARSER = new SpelExpressionParser();
    private static final ParameterNameDiscoverer NAME_DISCOVERER = new DefaultParameterNameDiscoverer();

    public static String resolve(String spel, MethodSignature signature, Object[] args) {
        // 1. 拿到参数名数组(需要 -parameters 编译参数或 Spring 的 LocalVariableTable)
        String[] paramNames = signature.getParameterNames();
        if (paramNames == null) {
            throw new IllegalStateException(
                "无法解析参数名,请检查编译是否加 -parameters(Spring Boot 3.2+ 默认开启)");
        }

        // 2. 构造求值上下文:参数名 → 参数值
        StandardEvaluationContext context = new StandardEvaluationContext();
        for (int i = 0; i < paramNames.length; i++) {
            context.setVariable(paramNames[i], args[i]);
        }

        // 3. 解析表达式,结果作为 key
        Expression expression = PARSER.parseExpression(spel);
        Object value = expression.getValue(context);
        return value == null ? "null" : value.toString();
    }
}

一个 90% 的人会踩的坑:SpEL 里 #phone 要拿到参数名,依赖编译产物保留参数名。Spring Boot 3.2+ 的 spring-boot-starter-parent 默认给 maven-compiler-plugin 加了 -parameters老项目或者自定义了 compiler 插件配置的,务必确认这一项,否则运行期 getParameterNames() 返回 null,#phone 解析直接炸。


四、第二步:AOP 切面拦截

4.1 切面实现

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * 限流切面:拦截所有 @RateLimit 方法,执行 Redis Lua 滑动窗口判断
 */
@Slf4j
@Aspect
@Component
@Order(1)   // 限流尽量最先执行:连业务参数校验都省了,尽快拒绝
@RequiredArgsConstructor
public class RateLimitAspect {

    private final StringRedisTemplate redisTemplate;

    /** Lua 脚本只需加载一次(DefaultRedisScript 内部会缓存 SHA1) */
    private static final DefaultRedisScript<Long> SLIDING_WINDOW_SCRIPT;

    static {
        SLIDING_WINDOW_SCRIPT = new DefaultRedisScript<>();
        SLIDING_WINDOW_SCRIPT.setScriptText(SlidingWindowLua.SCRIPT);
        SLIDING_WINDOW_SCRIPT.setResultType(Long.class);
    }

    @Around("@annotation(rateLimit)")
    public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
        // 全局开关(配置中心动态下发),关掉就直通
        if (!rateLimit.enabled() || !RateLimitProperties.GLOBAL_ENABLED) {
            return pjp.proceed();
        }

        // 1. 解析 SpEL key
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        String dynamicKey = SpelKeyResolver.resolve(rateLimit.key(), signature, pjp.getArgs());

        // 2. 组装完整 Redis key:前缀 + 方法信息 + 动态 key
        //    带上方法签名,避免不同接口的同名参数互相污染
        String redisKey = "rate_limit:"
                + signature.getDeclaringTypeName() + "#" + signature.getName()
                + ":" + dynamicKey;

        // 3. 计算窗口毫秒数
        long windowMillis = rateLimit.timeUnit().toMillis(rateLimit.window());

        // 4. 原子执行滑动窗口判断
        long now = System.currentTimeMillis();
        Long allowed = redisTemplate.execute(
                SLIDING_WINDOW_SCRIPT,
                Collections.singletonList(redisKey),
                String.valueOf(now),
                String.valueOf(windowMillis),
                String.valueOf(rateLimit.limit()));

        // 5. 判定:1=放行,0=超限
        if (allowed != null && allowed == 1L) {
            return pjp.proceed();
        }

        // 6. 超限:记录日志(带 key 便于排查是谁触发的)并抛业务异常
        log.warn("[限流触发] key={}, limit={}/{}ms, uri={}",
                redisKey, rateLimit.limit(), windowMillis,
                RequestContext.currentUri());
        String msg = rateLimit.message().isEmpty()
                ? "请求过于频繁,请稍后再试" : rateLimit.message();
        throw new RateLimitException(msg);
    }
}

4.2 全局异常处理:把限流转成标准 429

@RestControllerAdvice
public class GlobalExceptionHandler {

    /** 限流异常 → HTTP 429,带 Retry-After 提示客户端 */
    @ExceptionHandler(RateLimitException.class)
    public ResponseEntity<Result<Void>> handleRateLimit(RateLimitException e) {
        return ResponseEntity
                .status(429)
                .header("Retry-After", "1")
                .body(Result.fail(429, e.getMessage()));
    }
}

为什么坚持返回 429 而不是 200 + 错误码?429 是 HTTP 标准语义,网关、监控、客户端 SDK 都认识它:网关能统计限流命中率、前端能针对性弹"稍后再试"、爬虫脚本也能正确触发退避。返回 200 + code 只会让监控曲线看起来"一切正常"。

4.3 一个必须处理的故障场景:Redis 挂了怎么办?

限流组件自身不能成为新的单点故障——Redis 不可用时,策略必须是"放行"而不是"拒绝"(除非业务要求强限流)。切面里加一层兜底:

Long allowed;
try {
    allowed = redisTemplate.execute(SLIDING_WINDOW_SCRIPT,
            Collections.singletonList(redisKey), args...);
} catch (Exception e) {
    // Redis 不可用:降级放行 + 打点告警(Prometheus 指标 rate_limit_fallback_total)
    log.error("[限流降级] Redis 异常,本次放行。key={}", redisKey, e);
    Metrics.counter("rate_limit_fallback_total").increment();
    return pjp.proceed();
}

取舍说明:降级放行意味着 Redis 故障期间限流失效,但比"Redis 挂 → 全站接口 429"好得多。可用性优先于防护强度,除非你的接口涉及资金安全(那种场景 Redis 必须高可用部署 + 本地限流兜底)。


五、第三步:Redis Lua 滑动窗口(核心)

5.1 数据结构选择:ZSET

滑动窗口要回答的问题是:"过去 window 毫秒内,这个 key 发生了多少次请求?"

用 ZSET(有序集合)存储,每个请求一条记录:

  • member:唯一标识(时间戳 + 随机数,防止同一毫秒多条记录互相覆盖)
  • score:请求时间戳(毫秒)

那么"过去 60 秒的请求数"就是一次范围统计:

ZCOUNT key [now-60000, +inf]     ← 或者先清理再 ZCARD,见下文

为什么不用 Hash/String 计数器?因为滑动窗口需要按时间删除过期记录,ZSET 的 ZREMRANGEBYSCORE 一条命令搞定;普通计数器只能做固定窗口。

5.2 Lua 脚本全文

-- SlidingWindowLua.SCRIPT
-- 滑动窗口限流:KEYS[1]=限流key
--   ARGV[1]=当前时间戳(ms)  ARGV[2]=窗口大小(ms)  ARGV[3]=最大请求数
-- 返回:1=放行  0=超限

local key      = KEYS[1]
local now      = tonumber(ARGV[1])
local window   = tonumber(ARGV[2])
local limit    = tonumber(ARGV[3])

-- ① 清理窗口外的过期记录(滑动窗口左移)
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- ② 统计窗口内的请求数
local current = redis.call('ZCARD', key)

-- ③ 超限判断
if current >= limit then
    return 0
end

-- ④ 记录本次请求
--    member 用 now + 随机数保证唯一,防止同一毫秒内多条记录覆盖
local member = now .. '-' .. math.random(1000000, 9999999)
redis.call('ZADD', key, now, member)

-- ⑤ 给整个 key 设置过期时间 = 窗口大小
--    防止冷 key 的 ZSET 永久驻留内存(清理逻辑由 Redis 自动兜底)
redis.call('PEXPIRE', key, window)

return 1

五步逐条解释:

  1. ZREMRANGEBYSCORE(0, now-window):把 score 小于"窗口起点"的记录全删掉——这就是"窗口滑动"的实现,删完剩下的全是最近 window 内的
  2. ZCARD:数一下还剩几条,就是当前窗口内的请求数
  3. 达到 limit 直接返回 0,不发新的记录
  4. ZADD 记录本次请求。member 必须唯一:ZSET 的 member 是唯一索引,两个请求如果同一毫秒且 member 相同,后者会覆盖前者,计数就少了now + 随机数 实测碰撞概率可忽略
  5. PEXPIRE(window) 是内存兜底:即使这个 key 之后再也没人访问,ZSET 也会在窗口结束后自动过期,不会泄漏

5.3 正确性验证:为什么它是原子的

Redis 执行 Lua 时整个脚本独占执行,上面 5 步中间不可能插入其他客户端的命令。等价于把"清理+统计+判断+记录+过期"做成了一个不可分割的操作——第 2.2 节的超卖问题被彻底消灭。

压测验证(100 并发打一个 limit=100/window=10s 的接口):

方案10 秒内实际放行数预期
Java 三步(GET/判断/INCR)137100
Redis Lua 滑动窗口100100

Lua 版分毫不差,非 Lua 版超卖 37%——这就是"原子性"三个字的分量。

5.4 性能与内存开销

单次限流的 Redis 开销:

指标数值(实测,内网)
Lua 脚本执行耗时P99 < 0.3ms
接口增加的 RTP99 < 0.5ms
单 key 内存(limit=100/10s 窗口满载)< 4KB

两个生产级优化(流量大了再上,别过早优化):

  1. 脚本缓存:Spring 的 DefaultRedisScript 首次执行后自动用 EVALSHA(脚本 SHA1 缓存在 Redis),后续不再传输脚本本体
  2. 高频接口前置本地限流:单机 QPS 上万时,先用 Guava RateLimiter 按"集群配额 / 实例数 × 1.2"做本地粗筛,挡掉 80% 流量后再查 Redis 精判,Redis 压力降一个数量级

六、三种接口的落地配置

回到第一章的需求表,最终代码长这样:

6.1 登录:每手机号 1 次/秒

@PostMapping("/login")
@RateLimit(key = "'login:' + #phone", limit = 1, window = 1,
           message = "操作过于频繁,请 1 秒后再试")
public Result<Token> login(@RequestParam String phone,
                           @RequestParam String password) {
    return authService.login(phone, password);
}

防撞库场景一般还会叠加"每小时最多 5 次失败"的规则,同一套注解声明两次不行(Java 注解默认不可重复),加 @Repeatable 支持即可:

// 注解定义上加
@Repeatable(RateLimits.class)
public @interface RateLimit { ... }

// 使用:两层限流——1 次/秒(防轰炸)+ 5 次失败/小时(防撞库,由切面区分成功失败计数)
@RateLimit(key = "'login:' + #phone", limit = 1, window = 1)
@RateLimit(key = "'login-fail:' + #phone", limit = 5, window = 3600,
           message = "尝试次数过多,账户已临时锁定")
@PostMapping("/login")
public Result<Token> login(...) { ... }

6.2 查询:每用户 100 次/秒(集群共享)

@GetMapping("/coupon/list")
@RateLimit(key = "'query:' + T(space.jiangyi.util.AuthUtil).currentUserId()",
           limit = 100, window = 1)
public Result<List<Coupon>> listCoupons() {
    return couponService.listAvailable();
}

key 用当前登录用户 ID:已登录用户按人限流;未登录的查询接口换成取 IP 即可。100 次/秒是集群配额(Redis 集中计数),8 台实例一起分。

6.3 下单:每用户 10 次/秒 + 告警

@PostMapping("/order")
@RateLimit(key = "'order:' + #request.userId",
           limit = 10, window = 1,
           message = "下单太频繁啦,休息一下再试试")
public Result<OrderVO> createOrder(@RequestBody @Valid OrderRequest request) {
    return orderService.create(request);
}

下单接口额外建议:在切面里对 P0/P1 级接口的超限事件打 Prometheus 指标(rate_limit_rejected_total{uri=..., key=...}), Grafana 配一个"限流拒绝率"面板——拒绝率突增往往就是攻击或活动流量的前兆,比业务报错早 10 分钟发现。


七、完整流程走查

一次请求进来,全链路是这样的(以登录接口为例):

1. POST /login?phone=13800001111&pwd=xxx
2. AOP 切面命中 @RateLimit(key="'login:'+#phone", limit=1, window=1s)
3. SpEL 解析:'login:' + '13800001111' → 'login:13800001111'
4. 拼装 Redis key:
   rate_limit:space.jiangyi.controller.AuthController#login:login:13800001111
5. EVALSHA 执行 Lua:
   ① ZREMRANGEBYSCORE 清理 1 秒前的记录
   ② ZCARD = 0(窗口内没有请求)
   ③ 0 < 1 → 未超限
   ④ ZADD 记录本次请求
   ⑤ PEXPIRE 1s
   → 返回 1
6. 放行 → 执行 login() 业务逻辑
7. 同一手机号 300ms 后再发一次:
   ② ZCARD = 1 → 1 >= 1 → 返回 0
8. 切面抛 RateLimitException → 全局处理器 → HTTP 429 "操作过于频繁,请 1 秒后再试"

测试用例三连(写进集成测试,防止后人改坏):

@SpringBootTest
class RateLimitIntegrationTest {

    @Autowired TestRestTemplate rest;

    @Test
    void 登录接口_同手机号_1秒内第二次应429() {
        var first  = rest.postForEntity("/login?phone=13800001111&pwd=x", null, String.class);
        var second = rest.postForEntity("/login?phone=13800001111&pwd=x", null, String.class);
        assertEquals(200, first.getStatusCode().value());
        assertEquals(429, second.getStatusCode().value());
    }

    @Test
    void 不同手机号_互不影响() {
        rest.postForEntity("/login?phone=13800001111&pwd=x", null, String.class);
        var other = rest.postForEntity("/login?phone=13900002222&pwd=x", null, String.class);
        assertEquals(200, other.getStatusCode().value());
    }

    @Test
    void 窗口滑过后_恢复可用() throws Exception {
        rest.postForEntity("/login?phone=13800001111&pwd=x", null, String.class);
        Thread.sleep(1100);   // 越过 1s 窗口
        var again = rest.postForEntity("/login?phone=13800001111&pwd=x", null, String.class);
        assertEquals(200, again.getStatusCode().value());
    }
}

八、常见问题

8.1 为什么用 ZSET 不用 Redis-Cell(CELL 模块)?

Redis-Cell 的 CL.THROTTLE 是现成的令牌桶模块,开箱即用。但三个现实问题:需要自编译模块(云 Redis 大多不支持)、算法是令牌桶(我们要滑动窗口的精确计数语义)、无法自定义 key 结构。ZSET 方案零依赖、算法可控,够用且好排查。

8.2 大量用户会把 Redis 打爆吗?内存呢?

算笔账:1 万活跃用户 × 每 key 满载 4KB ≈ 40MB,完全无压力。QPS 方面,一次限流 = 1 次脚本执行(内网 < 1ms),Redis 单机 10 万+ QPS,限流本身占用不到 10%。真到了单机 Redis 瓶颈,上 Redis Cluster 按 key 分片即可(key 天然带 hashTag 能力的话注意把不同用户的 key 分散开,别用 {login} 这种固定 hashtag 把流量全打到同一个 slot)。

8.3 时钟漂移会影响精度吗?

会有一点,但可控。脚本里 now 由 Java 侧传入(System.currentTimeMillis()),多台实例时钟差 50ms 以内,对秒级窗口的影响 < 5%。要求严格就上 NTP;注意不要用 redis.call('TIME') 后再自己换算——用服务器时间反而是官方推荐做法之一,两种都可以,保持一致即可。

8.4 ZADD 的 member 随机数会不会碰撞?

now(毫秒) + 6 位随机数,同一毫秒内碰撞概率 = 1/900 万。就算碰撞,后果也只是两条记录合并为一条、计数少 1,窗口滑动后自愈,不影响正确性。强需求可用 now .. '-' .. redis.call('INCR', key..':seq') 序列号方案,但一般不必。

8.5 和 Guava RateLimiter / Sentinel 怎么选?

方案集群级注解声明依赖适用
本文:@RateLimit + Redis仅 Redis接口维度的集群精确限流
Guava RateLimiter❌ 单机零依赖单机兜底、方法级熔断限速
Sentinel✅(需 Token Server)引入全家桶流控+熔断+降级一体化

不冲突的组合拳:Sentinel/网关做南北向大流量防护,本文方案做业务维度的精细限流(按手机号/用户ID),Guava 做每台实例的本地兜底

8.6 限流阈值怎么定?拍脑袋吗?

三步法:

  1. 压测定容量:单接口压出 P99 < 200ms 时的最大安全 QPS,比如 300
  2. 留安全余量:阈值 = 安全 QPS × 70%,即 210,防止限流形同虚设或误伤
  3. 灰度观察:先放宽 2 倍上线,看 Grafana 的"限流拒绝率 + 接口 P99"两个面板,逐步收紧到目标值

阈值变化走配置中心(Nacos/Apollo 监听 @RateLimitProperties),改阈值不发版,这是注解方案相对硬编码的最大红利。


九、总结

落地三步速查卡

┌────────────┬────────────────────────────────────────────────┐
│ 第一步      │ @RateLimit 注解                                 │
│            │ key(SpEL) / limit / window / message / enabled │
├────────────┼────────────────────────────────────────────────┤
│ 第二步      │ AOP 切面                                        │
│            │ SpEL 解析 key → EVALSHA 执行 Lua                │
│            │ 超限抛 RateLimitException → HTTP 429            │
│            │ Redis 挂了降级放行 + 打点告警                    │
├────────────┼────────────────────────────────────────────────┤
│ 第三步      │ Redis Lua 滑动窗口(ZSET 实现)                  │
│            │ ZREMRANGEBYSCORE 清过期 → ZCARD 计数            │
│            │ → 判断 → ZADD 记录 → PEXPIRE 兜底               │
│            │ 全脚本原子执行,消灭并发超卖                     │
└────────────┴────────────────────────────────────────────────┘

关键数据

  • 滑动窗口彻底消灭固定窗口的临界突刺(2 秒 200 次的 bug)
  • Lua 原子性实测:100 并发下放行数 = 100 精确无超卖,非原子方案超卖 37%
  • 限流增加的接口 RT:P99 < 0.5ms
  • 单 key 内存 < 4KB,1 万活跃用户 ≈ 40MB
  • 阈值调整走配置中心,不发版

一句话

注解声明策略(谁限、限多少、多久),AOP 负责拦截,Redis Lua 负责集群级的原子计数——三层各司其职,业务代码里就剩一行 @RateLimit。

给团队的建议

场景建议
只有几个接口要限直接抄本文方案,半天落地
接口超过 50 个要限同方案 + 把阈值全部挪到配置中心
已在用 SentinelSentinel 管大面防护,本文方案管用户级精细限流
涉及资金安全Redis 高可用必须做 + 本地 Guava 兜底双层限流

互动话题:你们的接口限流是怎么做的?有没有被"集群配额 × 实例数"坑过?限流阈值是压测出来的还是拍的?欢迎留言讨论!


参考资料


标题:接口限流从手写到落地:自定义注解+Redis滑动窗口——一行@RateLimit就搞定
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/08/30/1787967293901.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消