Contents

Spring Boot + Redis 实现分布式接口限流:Lua脚本 + 注解驱动的生产级方案

为什么需要接口限流?

在生产环境中,接口限流是保护系统稳定性的第一道防线。无论是防止恶意刷接口、控制第三方调用频率,还是保护下游服务不被压垮,限流都扮演着关键角色。

常见的限流算法有四种:固定窗口、滑动窗口、令牌桶和漏桶。其中令牌桶滑动窗口是最实用的两种——前者允许突发流量,后者精度更高。

本文将从零实现一个生产级的分布式限流方案:基于 Redis + Lua 脚本保证原子性操作,通过自定义注解实现零侵入接入,开箱即用。

一、技术选型与架构设计

为什么用 Redis + Lua?

单机限流(如 Guava RateLimiter)在多实例部署时无法共享状态。Redis 作为集中式存储天然适合分布式场景,而 Lua 脚本在 Redis 中可以原子性执行,避免了竞态条件。

核心架构:

1
请求 → Spring Interceptor → 限流注解检查 → Redis Lua脚本 → 放行/拒绝

项目依赖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<!-- pom.xml 核心依赖 -->
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.aspectj</groupId>
        <artifactId>aspectjweaver</artifactId>
    </dependency>
</dependencies>
1
2
3
4
5
6
7
# application.yml
spring:
  redis:
    host: localhost
    port: 6379
    password: 
    database: 0

二、限流算法实现

2.1 滑动窗口限流(Sliding Window)

滑动窗口将时间轴划分为细小的格子,精确统计窗口内的请求数。我们用 Redis 的 Sorted Set 实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
 * 滑动窗口限流器
 * 基于 Redis Sorted Set,score 为请求时间戳
 */
public class SlidingWindowRateLimiter {

    private final StringRedisTemplate redisTemplate;
    private final int maxRequests;    // 窗口内最大请求数
    private final long windowMillis;  // 窗口大小(毫秒)

    // Lua脚本:原子性执行滑动窗口限流逻辑
    private static final String LUA_SCRIPT =
        "local key = KEYS[1]\n" +
        "local now = tonumber(ARGV[1])\n" +
        "local window = tonumber(ARGV[2])\n" +
        "local limit = tonumber(ARGV[3])\n" +
        "\n" +
        "-- 移除窗口外的过期记录\n" +
        "redis.call('ZREMRANGEBYSCORE', key, 0, now - window)\n" +
        "\n" +
        "-- 统计当前窗口内的请求数\n" +
        "local count = redis.call('ZCARD', key)\n" +
        "\n" +
        "if count < limit then\n" +
        "    -- 未超限,添加当前请求\n" +
        "    redis.call('ZADD', key, now, now .. '-' .. math.random(100000))\n" +
        "    redis.call('PEXPIRE', key, window)\n" +
        "    return 1  -- 放行\n" +
        "else\n" +
        "    return 0  -- 拒绝\n" +
        "end";

    public SlidingWindowRateLimiter(StringRedisTemplate redisTemplate,
                                     int maxRequests, long windowMillis) {
        this.redisTemplate = redisTemplate;
        this.maxRequests = maxRequests;
        this.windowMillis = windowMillis;
    }

    /**
     * 尝试获取令牌
     * @param key 限流键(如接口路径)
     * @return true=放行, false=拒绝
     */
    public boolean tryAcquire(String key) {
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
            Collections.singletonList(key),
            String.valueOf(System.currentTimeMillis()),
            String.valueOf(windowMillis),
            String.valueOf(maxRequests)
        );
        return result != null && result == 1L;
    }
}

Lua 脚本的关键优势ZREMRANGEBYSCORE + ZCARD + ZADD 三步操作在 Redis 中是原子执行的,不会出现并发下的计数不一致问题。

2.2 令牌桶限流(Token Bucket)

令牌桶允许突发流量——桶内有令牌时可以快速通过,令牌按固定速率补充。适合 API 网关等需要弹性限流的场景:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/**
 * 令牌桶限流器
 * 基于 Redis Hash 存储桶状态
 */
public class TokenBucketRateLimiter {

    private final StringRedisTemplate redisTemplate;

    // Lua脚本:原子性执行令牌桶逻辑
    private static final String LUA_SCRIPT =
        "local key = KEYS[1]\n" +
        "local capacity = tonumber(ARGV[1])     -- 桶容量\n" +
        "local rate = tonumber(ARGV[2])          -- 每秒补充速率\n" +
        "local now = tonumber(ARGV[3])           -- 当前时间(ms)\n" +
        "local requested = tonumber(ARGV[4])     -- 请求令牌数\n" +
        "\n" +
        "local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')\n" +
        "local tokens = tonumber(bucket[1]) or capacity\n" +
        "local last_refill = tonumber(bucket[2]) or now\n" +
        "\n" +
        "-- 计算应该补充的令牌数\n" +
        "local elapsed = (now - last_refill) / 1000.0\n" +
        "local refill = math.floor(elapsed * rate)\n" +
        "\n" +
        "if refill > 0 then\n" +
        "    tokens = math.min(capacity, tokens + refill)\n" +
        "    last_refill = now\n" +
        "end\n" +
        "\n" +
        "if tokens >= requested then\n" +
        "    tokens = tokens - requested\n" +
        "    redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)\n" +
        "    redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)\n" +
        "    return 1  -- 放行\n" +
        "else\n" +
        "    redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)\n" +
        "    redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)\n" +
        "    return 0  -- 拒绝\n" +
        "end";

    public TokenBucketRateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 尝试获取令牌
     * @param key      限流键
     * @param capacity 桶容量
     * @param rate     每秒补充速率
     * @return true=放行
     */
    public boolean tryAcquire(String key, int capacity, int rate) {
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
            Collections.singletonList(key),
            String.valueOf(capacity),
            String.valueOf(rate),
            String.valueOf(System.currentTimeMillis()),
            String.valueOf(1)
        );
        return result != null && result == 1L;
    }
}

三、自定义注解实现零侵入接入

硬编码调用限流器不够优雅。我们设计两个注解,让限流配置像 Spring Security 一样声明式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
 * 接口限流注解
 * 标注在 Controller 方法上即可生效
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {

    /** 限流键,默认使用方法全限定名 */
    String key() default "";

    /** 限流算法:sliding_window 或 token_bucket */
    String algorithm() default "sliding_window";

    /** 滑动窗口:最大请求数 */
    int maxRequests() default 100;

    /** 滑动窗口:窗口大小(秒) */
    int windowSeconds() default 1;

    /** 令牌桶:桶容量 */
    int bucketCapacity() default 50;

    /** 令牌桶:每秒补充速率 */
    int refillRate() default 10;

    /** 限流后的提示信息 */
    String message() default "请求过于频繁,请稍后再试";
}

四、AOP 切面处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
 * 限流切面 - 自动拦截带 @RateLimit 注解的方法
 */
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RateLimitAspect {

    @Resource
    private SlidingWindowRateLimiter slidingWindowLimiter;

    @Resource
    private TokenBucketRateLimiter tokenBucketLimiter;

    @Around("@annotation(rateLimit)")
    public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
        // 生成限流键:接口全限定名 + 可选自定义key
        String key = buildKey(joinPoint, rateLimit);

        boolean allowed;
        String algorithm = rateLimit.algorithm();

        if ("token_bucket".equals(algorithm)) {
            allowed = tokenBucketLimiter.tryAcquire(
                key, rateLimit.bucketCapacity(), rateLimit.refillRate());
        } else {
            allowed = slidingWindowLimiter.tryAcquire(key);
        }

        if (!allowed) {
            throw new RateLimitException(rateLimit.message());
        }

        return joinPoint.proceed();
    }

    private String buildKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) {
        String baseKey = joinPoint.getSignature().getDeclaringTypeName()
                        + "." + joinPoint.getSignature().getName();
        String customKey = rateLimit.key();
        if (!customKey.isEmpty()) {
            baseKey += ":" + customKey;
        }
        return "rate_limit:" + baseKey;
    }
}

全局异常处理,返回友好的 HTTP 429 响应:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RateLimitException.class)
    @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
    public Map<String, Object> handleRateLimit(RateLimitException ex) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("code", 429);
        body.put("message", ex.getMessage());
        body.put("timestamp", System.currentTimeMillis());
        return body;
    }
}

五、使用示例

接入只需一行注解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@RestController
@RequestMapping("/api/v1")
public class OrderController {

    /**
     * 滑动窗口限流:1秒内最多100次请求
     */
    @GetMapping("/orders")
    @RateLimit(maxRequests = 100, windowSeconds = 1)
    public Result<List<Order>> listOrders() {
        return Result.success(orderService.findAll());
    }

    /**
     * 令牌桶限流:桶容量50,每秒补充10个令牌
     */
    @PostMapping("/orders")
    @RateLimit(
        algorithm = "token_bucket",
        bucketCapacity = 50,
        refillRate = 10,
        message = "下单太频繁,请3秒后再试"
    )
    public Result<Order> createOrder(@RequestBody OrderDTO dto) {
        return Result.success(orderService.create(dto));
    }

    /**
     * 针对特定用户的限流(用户维度)
     */
    @GetMapping("/orders/my")
    @RateLimit(key = "#{user.id}", maxRequests = 30, windowSeconds = 1)
    public Result<List<Order>> myOrders(@CurrentUser User user) {
        return Result.success(orderService.findByUserId(user.getId()));
    }
}

六、压力测试验证

wrk 做简单压测,验证限流效果:

1
2
3
4
5
6
7
# 安装 wrk
sudo apt install wrk

# 200个并发,持续10秒,测试滑动窗口限流接口
wrk -t4 -c200 -d10s --latency http://localhost:8080/api/v1/orders

# 预期结果:约100 QPS 被放行,其余返回 429

也可以用 JMeter 写更精细的测试脚本,观察不同并发级别下的拒绝率变化。

七、进阶优化

7.1 限流键加 IP 前缀

实现基于客户端 IP 的限流,防止单个用户占用全局配额:

1
2
3
4
5
6
private String buildKey(HttpServletRequest request, ProceedingJoinPoint joinPoint, RateLimit rateLimit) {
    String clientIp = getClientIp(request);
    String baseKey = joinPoint.getSignature().getDeclaringTypeName()
                    + "." + joinPoint.getSignature().getName();
    return "rate_limit:" + clientIp + ":" + baseKey;
}

7.2 多级限流组合

接口级 + 用户级 + 全局级三层限流,逐级收紧:

1
2
3
4
@RateLimit(key = "global", maxRequests = 10000, windowSeconds = 1)  // 全局
@RateLimit(key = "#{user.id}", maxRequests = 100, windowSeconds = 1) // 用户
@GetMapping("/api/products")
public Result<List<Product>> listProducts() { ... }

7.3 限流指标监控

通过 Redis 的 INFO statsMONITOR 命令观察限流 key 的读写频率,接入 Prometheus + Grafana 做可视化监控。

总结

本文实现了一个生产级的分布式限流方案,核心要点:

特性 实现方案
分布式一致性 Redis + Lua 脚本原子操作
滑动窗口 Sorted Set + 时间戳
令牌桶 Hash 存储桶状态
零侵入 自定义注解 + AOP 切面
多维度 支持接口级/用户级/全局级

完整代码已整理为可直接复用的 Spring Boot Starter,克隆项目后添加依赖即可使用。在生产环境中,建议根据实际业务场景选择合适的算法——令牌桶适合需要弹性突发的 API 网关,滑动窗口适合精确控制的业务接口。