在高并发分布式系统中,限流是保障系统稳定性的最后一道防线。当电商秒杀、大促活动、API 网关等场景遭遇流量洪峰时,若没有合理的限流机制,系统会因 CPU、内存、数据库连接等资源耗尽而发生雪崩式崩溃。传统的单机限流方案在微服务架构下已显乏力,而基于 Redis+Lua 的分布式限流方案凭借其高性能、原子性和全局一致性,成为业界主流选择。本文将从原理出发,深入解析令牌桶算法的核心逻辑,并结合生产级代码实现,带你从零搭建一个支持多维度组合的分布式限流系统。
一、为什么必须用分布式限流?
1.1 单机限流的致命缺陷
传统的单机限流工具(如 Google Guava 的 RateLimiter)在单体应用时代表现尚可,但在微服务架构下存在无法克服的局限性:
- 全局状态不一致:每个服务实例独立维护自己的限流计数器,无法跨节点共享状态。例如,若设置全局 QPS 为 1000,部署 10 个实例,理论上每个实例应限 100QPS,但实际中负载均衡可能导致某个实例被集中访问,提前触发限流,而其他实例仍处于空闲状态,整体系统的实际承载能力远低于预期。
- 扩容困难:当业务增长需要新增服务实例时,必须手动重新计算每个实例的限流阈值,运维成本极高,且容易出现配置错误。
- 无法应对分布式攻击:针对 IP、用户维度的恶意刷接口行为,单机限流无法识别跨节点的请求,导致攻击者可以通过分散请求到不同实例来绕过限流。
1.2 分布式限流的核心优势
分布式限流通过中心化存储(Redis)统一管理所有服务实例的限流状态,从根本上解决了单机限流的问题:
- 全局一致性:所有服务实例共享同一份限流数据,无论部署多少个实例,都能严格遵守全局限流阈值。
- 灵活扩展:支持动态调整限流策略,无需重启服务,可通过配置中心实时修改阈值、时间窗口等参数。
- 多维度精细化控制:可以按接口、IP、用户 ID、设备号等任意维度进行限流,甚至支持多维度组合(如同时限制某个用户在某个 IP 上的请求频率)。
- 高可用:Redis 本身支持主从复制、哨兵模式和集群部署,可保证限流服务的高可用性。
二、技术选型与核心算法详解
2.1 技术栈选型:AOP + Redisson + Lua
本方案采用 Spring AOP + Redisson + Lua 的技术组合,各组件的职责如下:
- Spring AOP:通过切面拦截带自定义注解的方法,实现无侵入式限流,无需修改业务代码。
- Redisson:Java 生态中最成熟的 Redis 客户端,原生支持 Redis 集群模式、Lua 脚本执行和分布式锁,比 Jedis 更适合复杂的分布式场景。
- Lua 脚本:将限流逻辑封装在 Lua 脚本中,由 Redis 原子执行,避免多线程并发下的竞态条件,保证限流逻辑的正确性。
2.2 限流算法对比
常见的限流算法有四种:固定窗口、滑动窗口、漏桶和令牌桶。每种算法都有其适用场景,我们需要根据业务需求选择最合适的算法。
(1)固定窗口算法
原理:将时间划分为固定大小的窗口(如 1 分钟),每个窗口内维护一个计数器,请求到达时计数器加 1,当计数器达到阈值时,拒绝后续请求,窗口结束后计数器清零。
优点:实现简单,性能高。
缺点:存在严重的 "临界问题"。例如,设置 1 分钟限 100QPS,在第 59 秒发送 100 个请求,第 1 分 01 秒再发送 100 个请求,实际上在 2 秒内处理了 200 个请求,远超限流阈值。
适用场景:对限流精度要求不高的简单场景。
代码实现:
java
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
* 固定窗口计数器限流算法
* 优点:实现简单、性能极高
* 缺点:存在"临界问题"(窗口交界处可能出现2倍阈值的突发流量)
*/
public class FixedWindowRateLimiter {
// 时间窗口大小(毫秒)
private final long windowSizeMs;
// 窗口内允许的最大请求数
private final int maxRequests;
// 当前窗口的请求计数器(原子类保证线程安全)
private final AtomicInteger counter = new AtomicInteger(0);
// 窗口开始时间(原子类保证线程安全)
private final AtomicLong windowStartTime = new AtomicLong(System.currentTimeMillis());
public FixedWindowRateLimiter(long windowSizeMs, int maxRequests) {
this.windowSizeMs = windowSizeMs;
this.maxRequests = maxRequests;
}
/**
* 尝试获取令牌
* @return true-获取成功(允许请求),false-获取失败(限流)
*/
public boolean tryAcquire() {
long currentTime = System.currentTimeMillis();
// 1. 判断是否进入新窗口
if (currentTime - windowStartTime.get() > windowSizeMs) {
// 重置窗口:CAS操作保证只有一个线程能重置成功
if (windowStartTime.compareAndSet(windowStartTime.get(), currentTime)) {
counter.set(0);
}
}
// 2. 原子性增加计数并判断是否超过阈值
return counter.incrementAndGet() <= maxRequests;
}
// 测试用例
public static void main(String[] args) throws InterruptedException {
// 1秒内最多允许5个请求
FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(1000, 5);
// 模拟10个并发请求
for (int i = 0; i < 10; i++) {
final int requestId = i;
new Thread(() -> {
if (limiter.tryAcquire()) {
System.out.println("请求" + requestId + ":成功");
} else {
System.out.println("请求" + requestId + ":被限流");
}
}).start();
}
// 等待1秒后,窗口重置,再发5个请求
Thread.sleep(1000);
System.out.println("===== 窗口重置 =====");
for (int i = 10; i < 15; i++) {
final int requestId = i;
new Thread(() -> {
if (limiter.tryAcquire()) {
System.out.println("请求" + requestId + ":成功");
} else {
System.out.println("请求" + requestId + ":被限流");
}
}).start();
}
}
}
(2)滑动窗口算法
原理:将固定窗口进一步划分为多个小的时间片(如将 1 分钟划分为 6 个 10 秒的时间片),每个时间片维护独立的计数器。当请求到达时,计算当前时间所在的窗口(包含最近 6 个时间片),将所有时间片的计数器相加,若超过阈值则拒绝请求。窗口会随着时间滑动,不断淘汰过期的时间片。
优点:解决了固定窗口的临界问题,限流精度更高。
缺点:实现复杂,需要维护多个时间片的计数器,性能稍差。
适用场景:对限流精度要求较高的场景。
代码实现:
java
import java.util.concurrent.atomic.AtomicIntegerArray;
/**
* 滑动窗口计数器限流算法
* 优点:解决了固定窗口的"临界问题",限流精度更高
* 缺点:实现稍复杂,需要维护多个时间片的计数器
*/
public class SlidingWindowRateLimiter {
// 总窗口大小(毫秒)
private final long windowSizeMs;
// 每个小时间片的大小(毫秒)
private final long sliceSizeMs;
// 时间片数量
private final int sliceCount;
// 每个时间片的请求计数器(原子数组保证线程安全)
private final AtomicIntegerArray counters;
// 上一次请求的时间戳
private volatile long lastRequestTime;
// 上一次请求所在的时间片索引
private volatile int lastSliceIndex;
public SlidingWindowRateLimiter(long windowSizeMs, int sliceCount, int maxRequests) {
this.windowSizeMs = windowSizeMs;
this.sliceCount = sliceCount;
this.sliceSizeMs = windowSizeMs / sliceCount;
this.counters = new AtomicIntegerArray(sliceCount);
this.lastRequestTime = System.currentTimeMillis();
this.lastSliceIndex = getCurrentSliceIndex();
}
/**
* 获取当前时间对应的时间片索引
*/
private int getCurrentSliceIndex() {
return (int) ((System.currentTimeMillis() / sliceSizeMs) % sliceCount);
}
/**
* 尝试获取令牌
* @return true-获取成功,false-被限流
*/
public synchronized boolean tryAcquire() {
long currentTime = System.currentTimeMillis();
int currentSliceIndex = getCurrentSliceIndex();
// 1. 清除所有过期的时间片(滑动窗口)
long timePassed = currentTime - lastRequestTime;
if (timePassed > windowSizeMs) {
// 超过整个窗口大小,全部清零
for (int i = 0; i < sliceCount; i++) {
counters.set(i, 0);
}
} else {
// 清除从上次请求到现在之间过期的时间片
int slicesToClear = (int) (timePassed / sliceSizeMs);
for (int i = 1; i <= slicesToClear; i++) {
int index = (lastSliceIndex + i) % sliceCount;
counters.set(index, 0);
}
}
// 2. 计算当前窗口内的总请求数
int totalRequests = 0;
for (int i = 0; i < sliceCount; i++) {
totalRequests += counters.get(i);
}
// 3. 判断是否超过阈值
if (totalRequests >= maxRequests) {
return false;
}
// 4. 当前时间片计数加1
counters.incrementAndGet(currentSliceIndex);
lastRequestTime = currentTime;
lastSliceIndex = currentSliceIndex;
return true;
}
// 测试用例
public static void main(String[] args) throws InterruptedException {
// 1秒窗口,划分为10个100毫秒的时间片,最多允许5个请求
SlidingWindowRateLimiter limiter = new SlidingWindowRateLimiter(1000, 10, 5);
// 模拟10个请求,间隔100毫秒
for (int i = 0; i < 10; i++) {
final int requestId = i;
new Thread(() -> {
if (limiter.tryAcquire()) {
System.out.println("请求" + requestId + ":成功");
} else {
System.out.println("请求" + requestId + ":被限流");
}
}).start();
Thread.sleep(100);
}
}
}
(3)漏桶算法
原理:将请求比作水,漏桶比作队列,水以恒定的速度从漏桶流出。当水流入速度超过流出速度时,多余的水会溢出(拒绝请求)。
优点:可以强制限制请求的处理速度,实现削峰填谷,使系统输出流量保持平稳。
缺点:不允许突发流量,即使系统资源空闲,也只能以固定速度处理请求,无法充分利用系统资源。
适用场景:需要严格控制请求处理速度的场景,如消息队列消费。
代码实现:
java
/**
* 漏桶限流算法
* 优点:可以强制限制请求的处理速度,输出流量非常平稳
* 缺点:不允许突发流量,即使系统资源空闲也只能以固定速度处理
*/
public class LeakyBucketRateLimiter {
// 桶的容量(最大排队请求数)
private final int capacity;
// 漏水速度(每秒处理的请求数)
private final double leakRate;
// 当前桶中的水量(排队的请求数)
private double currentWater;
// 上次漏水的时间戳
private long lastLeakTime;
public LeakyBucketRateLimiter(int capacity, double leakRate) {
this.capacity = capacity;
this.leakRate = leakRate;
this.currentWater = 0;
this.lastLeakTime = System.currentTimeMillis();
}
/**
* 尝试获取令牌
* @return true-获取成功(请求进入桶中等待处理),false-被限流(桶满)
*/
public synchronized boolean tryAcquire() {
long currentTime = System.currentTimeMillis();
// 1. 计算从上次漏水到现在应该漏出的水量
double leakedWater = (currentTime - lastLeakTime) / 1000.0 * leakRate;
// 2. 更新当前水量(不能小于0)
currentWater = Math.max(0, currentWater - leakedWater);
lastLeakTime = currentTime;
// 3. 判断桶是否已满
if (currentWater < capacity) {
currentWater += 1;
return true;
}
return false;
}
// 测试用例
public static void main(String[] args) throws InterruptedException {
// 桶容量10,每秒漏水5个(即每秒处理5个请求)
LeakyBucketRateLimiter limiter = new LeakyBucketRateLimiter(10, 5);
// 模拟15个突发请求
for (int i = 0; i < 15; i++) {
final int requestId = i;
new Thread(() -> {
if (limiter.tryAcquire()) {
System.out.println("请求" + requestId + ":进入桶中");
} else {
System.out.println("请求" + requestId + ":被限流(桶满)");
}
}).start();
}
// 等待2秒,观察漏水效果
Thread.sleep(2000);
System.out.println("===== 2秒后 =====");
// 再发5个请求
for (int i = 15; i < 20; i++) {
final int requestId = i;
new Thread(() -> {
if (limiter.tryAcquire()) {
System.out.println("请求" + requestId + ":进入桶中");
} else {
System.out.println("请求" + requestId + ":被限流(桶满)");
}
}).start();
}
}
}
(4)令牌桶算法(本方案选择)
原理:系统以恒定的速度向令牌桶中放入令牌,当请求到达时,需要从桶中获取一个令牌才能被处理。如果桶中没有令牌,则拒绝请求。令牌桶的容量是固定的,当令牌放满时,多余的令牌会被丢弃。
核心优势:
- 允许突发流量:令牌桶可以积累令牌,当系统空闲时,令牌会逐渐填满桶,此时如果有突发流量到来,可以一次性获取多个令牌进行处理,充分利用系统资源。
- 平滑限流:令牌以恒定速度放入桶中,避免了固定窗口的临界问题,使请求处理速度更加平滑。
- 易于实现多维度限流:每个限流维度(如接口、IP、用户)可以维护独立的令牌桶,互不干扰。
令牌桶算法的数学模型:
- 设令牌桶容量为
max_tokens(即最大突发请求数) - 令牌生成速率为
rate(即每秒生成的令牌数,等于限流 QPS) - 当前令牌数为
current_tokens - 当请求到达时,若
current_tokens >= 1,则current_tokens -= 1,请求被处理;否则拒绝请求。 - 每隔
1/rate秒,current_tokens += 1,但不超过max_tokens。
代码实现:
java
/**
* 令牌桶限流算法(工业界标准)
* 优点:允许突发流量,同时能平滑限流,兼顾性能和灵活性
* 缺点:实现稍复杂
*/
public class TokenBucketRateLimiter {
// 令牌桶容量(最大突发请求数)
private final int capacity;
// 令牌生成速度(每秒生成的令牌数,即限流QPS)
private final double tokenRate;
// 当前令牌数
private double currentTokens;
// 上次生成令牌的时间戳
private long lastTokenTime;
public TokenBucketRateLimiter(int capacity, double tokenRate) {
this.capacity = capacity;
this.tokenRate = tokenRate;
// 初始时桶是满的
this.currentTokens = capacity;
this.lastTokenTime = System.currentTimeMillis();
}
/**
* 尝试获取令牌
* @return true-获取成功(允许请求),false-获取失败(限流)
*/
public synchronized boolean tryAcquire() {
return tryAcquire(1);
}
/**
* 尝试获取指定数量的令牌
* @param permits 需要获取的令牌数
* @return true-获取成功,false-获取失败
*/
public synchronized boolean tryAcquire(int permits) {
if (permits <= 0 || permits > capacity) {
return false;
}
long currentTime = System.currentTimeMillis();
// 1. 计算从上次生成令牌到现在应该生成的令牌数
double generatedTokens = (currentTime - lastTokenTime) / 1000.0 * tokenRate;
// 2. 更新当前令牌数(不能超过桶的容量)
currentTokens = Math.min(capacity, currentTokens + generatedTokens);
lastTokenTime = currentTime;
// 3. 判断是否有足够的令牌
if (currentTokens >= permits) {
currentTokens -= permits;
return true;
}
return false;
}
// 测试用例
public static void main(String[] args) throws InterruptedException {
// 令牌桶容量10,每秒生成5个令牌(即限流QPS=5,最大突发10个请求)
TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(10, 5);
// 模拟15个突发请求
System.out.println("===== 突发15个请求 =====");
for (int i = 0; i < 15; i++) {
final int requestId = i;
new Thread(() -> {
if (limiter.tryAcquire()) {
System.out.println("请求" + requestId + ":成功");
} else {
System.out.println("请求" + requestId + ":被限流");
}
}).start();
}
// 等待1秒,令牌桶会补充5个令牌
Thread.sleep(1000);
System.out.println("===== 1秒后 =====");
// 再发10个请求
for (int i = 15; i < 25; i++) {
final int requestId = i;
new Thread(() -> {
if (limiter.tryAcquire()) {
System.out.println("请求" + requestId + ":成功");
} else {
System.out.println("请求" + requestId + ":被限流");
}
}).start();
}
}
}
三、生产级分布式限流系统实现
3.1 自定义限流注解:灵活配置多维度策略
首先定义一个 @RateLimit 注解,用于标记需要限流的方法,并配置限流参数。注解支持多维度组合限流、可配置的时间窗口和降级方法,满足不同业务场景的需求。
java
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 限流维度枚举
*/
enum Dimension {
GLOBAL, // 全局限流(所有请求共享一个令牌桶)
IP, // IP维度限流(每个IP一个令牌桶)
USER // 用户维度限流(每个用户一个令牌桶)
}
/**
* 限流维度(支持组合,如同时限制全局和用户)
*/
Dimension[] dimensions() default {
Dimension.GLOBAL
};
/**
* 时间窗口内允许的最大请求数(即令牌桶容量)
*/
double count();
/**
* 时间窗口大小
*/
long interval() default 1;
/**
* 时间单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 降级方法名(限流时调用该方法返回结果)
*/
String fallback() default "";
}
- 支持多维度组合,例如
@RateLimit(dimensions = {Dimension.GLOBAL, Dimension.USER}, count = 1000, interval = 1)表示同时限制全局 QPS 为 1000,且每个用户的 QPS 不超过 1000。 - 可配置降级方法,限流时优雅返回自定义结果,而非直接抛出异常,提升用户体验。
- 时间单位灵活,支持秒、分钟、小时等多种时间窗口。
3.2 AOP 切面:无侵入式拦截与逻辑处理
通过 Spring AOP 拦截所有标记了 @RateLimit 注解的方法,在方法执行前执行限流逻辑。切面负责生成限流 Key、调用 Lua 脚本执行限流判断、处理限流结果和降级逻辑。
java
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.redisson.api.RScript;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.StringCodec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@Aspect
@Component
public class RateLimitAspect {
@Autowired
private RedissonClient redissonClient;
private String luaScriptSha;
// Lua脚本内容(见下文)
private static final String LUA_SCRIPT = "..."
@PostConstruct
public void init() {
// 预加载 Lua 脚本到 Redis,获取 SHA 1值,减少网络传输开销
this.luaScriptSha =
redissonClient.getScript(StringCodec.INSTANCE).scriptLoad(LUA_SCRIPT);
}
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
// 1. 计算时间窗口(转换为毫秒)
long intervalMs = rateLimit.timeUnit().toMillis(rateLimit.interval());
// 2. 获取目标类和方法名
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
// 3. 生成限流Key(使用Hash Tag适配Redis集群)
List<String> keys = generateKeys(className, methodName, rateLimit.dimensions());
// 4. 构造Lua脚本参数
List<Object> args = new ArrayList<>();
args.add(rateLimit.count()); // 最大令牌数
args.add(intervalMs); // 时间窗口(毫秒)
args.add(System.currentTimeMillis()); // 当前时间戳
args.add(1); // 每次请求消耗的令牌数
args.add(intervalMs * 2 / 1000); // Key过期时间(窗口的2倍)
// 5. 执行Lua脚本
RScript script = redissonClient.getScript(StringCodec.INSTANCE);
Long result = script.evalSha(RScript.Mode.READ_WRITE, luaScriptSha,
RScript.ReturnType.VALUE, keys, args.toArray());
// 6. 处理限流结果
if (result == null || result == 0) {
// 触发限流,执行降级方法
return handleFallback(joinPoint, rateLimit);
}
// 7. 执行业务方法
return joinPoint.proceed();
}
/**
* 生成限流Key,使用Hash Tag确保同一方法的所有Key落在同一个Redis Slot
*/
private List<String> generateKeys(String className, String methodName, RateLimit.Dimension[] dimensions) {
List<String> keys = new ArrayList<>();
// Hash Tag:用{}包裹类名和方法名,确保所有Key落在同一个Slot
String hashTag = "{" + className + ":" + methodName + "}";
String keyPrefix = "ratelimit:" + hashTag;
for (RateLimit.Dimension dimension : dimensions) {
switch (dimension) {
case GLOBAL:
keys.add(keyPrefix + ":global");
break;
case IP:
String ip = getClientIp();
keys.add(keyPrefix + ":ip:" + ip);
break;
case USER:
// 从请求上下文获取当前用户ID(需根据实际项目调整)
String userId = getCurrentUserId();
keys.add(keyPrefix + ":user:" + userId);
break;
}
}
return keys;
}
/**
* 执行降级方法
*/
private Object handleFallback(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
String fallbackName = rateLimit.fallback();
if (fallbackName.isEmpty()) {
// 未配置降级方法,抛出默认异常
throw new RuntimeException("请求过于频繁,请稍后再试");
}
// 查找降级方法(优先匹配同参数列表,其次匹配无参方法)
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Class<?> targetClass = joinPoint.getTarget().getClass();
Class<?>[] parameterTypes = signature.getParameterTypes();
Method fallbackMethod;
try {
fallbackMethod = targetClass.getDeclaredMethod(fallbackName, parameterTypes);
} catch (NoSuchMethodException e) {
fallbackMethod = targetClass.getDeclaredMethod(fallbackName);
}
fallbackMethod.setAccessible(true);
// 调用降级方法
return fallbackMethod.invoke(joinPoint.getTarget(), joinPoint.getArgs());
}
// 辅助方法:获取客户端IP
private String getClientIp() {
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
// 辅助方法:获取当前用户ID(需根据实际项目实现)
private String getCurrentUserId() {
// 示例:从Token中解析用户ID
return "123456";
}
}
- Redis Cluster 兼容性 :使用 Hash Tag(
{className:methodName})将同一方法的所有限流 Key 映射到同一个 Redis Slot。在 Redis Cluster 模式下,Lua 脚本只能操作同一个 Slot 内的 Key,否则会报错。Hash Tag 通过将 Key 中{}内的部分作为分片依据,确保相关 Key 落在同一个节点。 - Lua 脚本预加载 :在
@PostConstruct方法中将 Lua 脚本加载到 Redis 并获取 SHA1 值,后续通过 **evalSha**调用脚本,避免每次传输完整的脚本内容,大幅减少网络开销。 - 智能降级处理:支持两种降级方法签名 ------ 与原方法同参数列表的方法和无参方法,提升了降级逻辑的灵活性。
3.3 Lua 脚本:原子性限流的核心
Lua 脚本是整个限流系统的灵魂,它将所有限流逻辑封装在一个脚本中,由 Redis 原子执行,确保在高并发下不会出现竞态条件。本方案采用两阶段提交的设计思想:先检查所有维度的令牌是否充足,全部通过后再统一扣减令牌,避免部分扣减导致的数据不一致。
Lua
-- 令牌桶限流Lua脚本
-- KEYS: 限流Key列表(每个维度一个Key)
-- ARGV: [1]max_tokens(最大令牌数), [2]interval_ms(时间窗口毫秒), [3]now_ms(当前时间戳), [4]permits(每次请求消耗的令牌数), [5]expire_time(Key过期时间秒)
local max_tokens = tonumber(ARGV[1])
local interval_ms = tonumber(ARGV[2])
local now_ms = tonumber(ARGV[3])
local permits = tonumber(ARGV[4])
local expire_time = tonumber(ARGV[5])
-- 第一阶段:预检查所有维度的令牌是否充足
for i, key in ipairs(KEYS) do
local value_key = key .. ":value" -- 存储当前令牌数的Key
local permits_key = key .. ":permits" -- 存储请求记录的ZSet Key
-- 初始化令牌桶(如果不存在)
if redis.call("exists", value_key) == 0 then
redis.call("set", value_key, max_tokens)
end
-- 回收过期令牌:删除interval_ms之前的请求记录,并将对应的令牌放回桶中
local expired_values = redis.call("zrangebyscore", permits_key, 0, now_ms - interval_ms)
if #expired_values > 0 then
local expired_count = 0
for _, v in ipairs(expired_values) do
-- 解析请求记录中的令牌数(格式:request_id:permits)
local _, p = string.match(v, "(.*):(.*)")
expired_count = expired_count + tonumber(p)
end
-- 删除过期记录
redis.call("zremrangebyscore", permits_key, 0, now_ms - interval_ms)
-- 回收令牌
local curr_v = tonumber(redis.call("get", value_key))
redis.call("set", value_key, math.min(max_tokens, curr_v + expired_count))
end
-- 检查当前令牌是否足够
local current_val = tonumber(redis.call("get", value_key))
if current_val < permits then
return 0 -- 任一维度令牌不足,直接返回失败
end
end
-- 第二阶段:所有维度检查通过,统一扣减令牌
for i, key in ipairs(KEYS) do
local value_key = key .. ":value"
local permits_key = key .. ":permits"
-- 生成唯一请求ID(时间戳+随机数)
local request_id = now_ms .. ":" .. math.random(1000000)
-- 记录本次请求到ZSet(分数为时间戳,值为request_id:permits)
redis.call("zadd", permits_key, now_ms, request_id .. ":" .. permits)
-- 扣减令牌
local current_v = tonumber(redis.call("get", value_key))
redis.call("set", value_key, current_v - permits)
-- 设置Key过期时间,防止内存泄漏
redis.call("expire", value_key, expire_time)
redis.call("expire", permits_key, expire_time)
end
return 1 -- 限流通过
- 两阶段提交:先检查所有维度的令牌,全部通过后再扣减,避免出现 "部分维度扣减成功,部分失败" 的不一致情况。
- ZSET 记录请求历史 :使用有序集合(ZSET)存储每次请求的时间戳和消耗的令牌数,便于精确回收过期令牌。ZSET 的分数为请求时间戳,值为
request_id:permits格式的字符串。 - 自动内存管理 :通过 zremrangebyscore
定期清理过期的请求记录,并设置 Key 的过期时间为时间窗口的 2 倍,确保过期令牌能被正常回收,同时避免长期占用 Redis 内存。 - 原子性保证:整个脚本在 Redis 中作为一个原子操作执行,即使有多个请求同时到达,也不会出现竞态条件。
四、性能优化与生产环境注意事项
4.1 性能优化最佳实践
- 减少网络往返 :
- 预加载 Lua 脚本:使用
scriptLoad+evalSha替代eval,每次调用仅传输 40 字节的 SHA1 值,而非完整的脚本内容。 - 批量操作:在 Lua 脚本内部完成所有 Redis 命令,避免多次网络往返。
- 预加载 Lua 脚本:使用
- 高效数据结构 :
- 使用 String 存储当前令牌数,O (1) 复杂度的读取和更新。
- 使用 ZSET 存储请求记录,支持按时间戳范围查询和删除,效率远高于 List 或 Hash。
- Redis 配置优化 :
- 关闭不必要的持久化:限流数据属于临时数据,丢失后不会影响业务,可关闭 RDB 和 AOF 持久化,提高 Redis 性能。
- 合理设置连接池:根据服务实例数量和并发量调整 Redisson 的连接池大小,避免连接瓶颈。
- 本地缓存兜底:当 Redis 出现故障时,使用本地 Guava RateLimiter 作为兜底方案,保证服务的可用性。
4.2 生产环境注意事项
- 限流阈值设置 :
- 通过压测确定系统的最大承载能力,限流阈值应设置为最大承载能力的 80% 左右,预留一定的缓冲空间。
- 不同接口的限流阈值应根据业务重要性和访问频率单独设置,核心接口可适当提高阈值。
- 监控与告警 :
- 监控限流触发次数、Redis 的 QPS、内存使用率等指标,及时发现异常流量。
- 当限流触发次数超过阈值时,发送告警通知,便于运维人员及时处理。
- 降级策略设计 :
- 降级逻辑应尽可能简单,避免降级方法本身成为性能瓶颈。
- 对于非核心接口,可直接返回默认值或缓存数据;对于核心接口,可采用排队等待或降级到备用服务的方式。
- 防刷机制 :
- 对频繁触发限流的 IP 或用户进行临时拉黑,防止恶意攻击。
- 结合验证码、滑块验证等手段,进一步提高系统的安全性。
五、总结与扩展
本文详细介绍了基于 Redis + Lua 的分布式限流方案,从原理到实现,深入解析了令牌桶算法的核心逻辑和生产级代码的设计要点。该方案具有高性能、原子性、全局一致性和多维度支持等优点,能够有效应对高并发场景下的流量冲击。
未来,我们可以在此基础上进行进一步扩展:
- 支持动态调整限流策略:通过配置中心(如 Nacos、Apollo)实时修改限流阈值和时间窗口,无需重启服务。
- 支持更复杂的限流算法:如基于令牌桶的动态限流,根据系统负载自动调整限流阈值。
- 支持分布式限流集群:当单 Redis 实例性能不足时,可采用 Redis Cluster 或分片模式,将限流数据分散到多个节点。
分布式限流是高并发系统中不可或缺的组件,只有深入理解其原理并结合实际业务场景进行优化,才能构建出稳定可靠的系统。