Java案例如何实现限流?——高并发场景下的“刹车”艺术
目录导读
- 为什么需要限流?——高并发下的“救生圈”
- Java限流核心算法实战
- 1 计数器算法:最简单的“门禁”
- 2 滑动窗口算法:更精准的“时间切片”
- 3 令牌桶算法:流量平滑的“水库”
- 4 漏桶算法:恒定速率的“漏斗”
- Java案例:基于Spring Boot + Redis实现分布式限流
- 常见问题问答(Q&A)
- 限流落地最佳实践与性能优化
为什么需要限流?——高并发下的“救生圈”
场景回放:双十一零点,每秒数百万请求涌入,数据库连接池瞬间被打爆,接口响应时间从10ms飙升到10秒,最终导致雪崩……这就是没有限流的后果。

限流的核心目标:
- 保护系统自身:防止CPU、内存、数据库连接等资源耗尽
- 保障公平性:避免少数恶意用户占满所有资源
- 实现降级:超过阈值后,直接返回“服务繁忙”或排队
关键问题:在Java应用中,如何用代码实现一个可靠又高效的限流器?
Java限流核心算法实战
1 计数器算法:最简单的“门禁”
原理:固定时间窗口内(如1秒),统计请求次数,超过阈值则拒绝。
Java实现(基于AtomicInteger):
public class CounterLimiter {
private final AtomicInteger counter = new AtomicInteger(0);
private final int maxRequests; // 每秒最大请求数
private final long windowSize = 1000L; // 1秒窗口
private long windowStart = System.currentTimeMillis();
public CounterLimiter(int maxRequests) {
this.maxRequests = maxRequests;
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - windowStart > windowSize) {
// 重置窗口
counter.set(0);
windowStart = now;
}
if (counter.incrementAndGet() <= maxRequests) {
return true;
}
return false;
}
}
缺点:存在“临界突变”问题,比如在1秒的最后一刻涌进100个请求,下一秒开始又100个,实际瞬时QPS可能高达200。
2 滑动窗口算法:更精准的“时间切片”
改进:将窗口划分为更小的时间片(如10个100ms的切片),通过“滑动”统计最近N个切片的总请求数。
Redis + Lua实现(企业级常用):
-- 滑动窗口限流脚本
local key = KEYS[1] -- 限流key
local windowSize = tonumber(ARGV[1]) -- 窗口大小(ms)
local maxRequests = tonumber(ARGV[2]) -- 最大请求数
local currentTime = tonumber(ARGV[3]) -- 当前时间戳(ms)
-- 移除窗口外过期的记录
redis.call('ZREMRANGEBYSCORE', key, 0, currentTime - windowSize)
-- 统计当前窗口中请求数
local count = redis.call('ZCARD', key)
if count < maxRequests then
-- 添加当前请求记录
redis.call('ZADD', key, currentTime, currentTime .. '_' .. math.random())
redis.call('EXPIRE', key, windowSize/1000 + 1)
return 1 -- 允许通过
else
return 0 -- 限流
end
优势:有效解决临界突变问题,但Lua脚本增加了Redis开销。
3 令牌桶算法:流量平滑的“水库”
原理:以恒定速率往桶里放令牌,请求必须获取令牌才能通过,可以应对突发流量(桶内可缓存令牌)。
Java实现(Guava的RateLimiter原理复现):
public class TokenBucketLimiter {
private final int capacity; // 桶容量
private final double refillRate; // 每秒放入令牌数
private double tokens; // 当前令牌数
private long lastRefillTime;
public TokenBucketLimiter(int capacity, double refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.tokens = capacity;
this.lastRefillTime = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
refill();
if (tokens >= 1) {
tokens -= 1;
return true;
}
return false;
}
private void refill() {
long now = System.currentTimeMillis();
double tokensToAdd = (now - lastRefillTime) / 1000.0 * refillRate;
tokens = Math.min(capacity, tokens + tokensToAdd);
lastRefillTime = now;
}
}
工程注解:Google Guava的RateLimiter就是经典令牌桶实现,直接引入依赖即可使用。
4 漏桶算法:恒定速率的“漏斗”
原理:请求进入一个固定容量的桶,桶底部以恒定速率漏出请求,超出桶容量的请求被丢弃。
与令牌桶关键区别:漏桶强制平滑输出,令牌桶允许突发输入。
Java案例:基于Spring Boot + Redis实现分布式限流
场景:微服务架构下,多个实例共享一个限流器,必须使用Redis作为集中计数器。
实现步骤:
- 添加依赖(pom.xml)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
- 自定义注解:
@RateLimit
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key(); // 限流key(支持SpEL)
int maxRequests(); // 最大请求数
long windowSize(); // 窗口大小(秒)
String fallbackMsg() default "服务繁忙,请稍后再试";
}
- AOP切面实现(使用Lua脚本保证原子性)
@Aspect
@Component
public class RateLimitAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
String key = parseKey(rateLimit.key(), joinPoint);
long windowSize = rateLimit.windowSize() * 1000L;
int maxRequests = rateLimit.maxRequests();
long currentTime = System.currentTimeMillis();
// 执行Lua脚本(使用前面滑动窗口的Lua)
String luaScript = "local key = KEYS[1]; local window = tonumber(ARGV[1]); " +
"local max = tonumber(ARGV[2]); local now = tonumber(ARGV[3]); " +
"redis.call('ZREMRANGEBYSCORE', key, 0, now - window); " +
"local count = redis.call('ZCARD', key); " +
"if count < max then " +
" redis.call('ZADD', key, now, now .. '_' .. math.random()); " +
" redis.call('EXPIRE', key, window/1000 + 1); return 1; " +
"else return 0; end";
List<String> keys = Collections.singletonList(key);
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
keys, windowSize, maxRequests, currentTime
);
if (result == null || result == 0) {
throw new RateLimitException(rateLimit.fallbackMsg());
}
return joinPoint.proceed();
}
}
- 使用示例
@RestController
public class DemoController {
@RateLimit(key = "user:api:${userId}", maxRequests = 10, windowSize = 1)
@GetMapping("/api/query")
public String query(String userId) {
return "OK";
}
}
常见问题问答(Q&A)
Q1:限流到底应该限制到多少?
A:通过压力测试获得系统最大QPS,然后设置阈值为80%左右,例如系统支撑500 QPS,限流阈值设为400,同时结合业务重要性分优先级,核心接口限流值更高。
Q2:如果限流生效时,返回什么给客户端?
A:建议统一HTTP状态码429(Too Many Requests),配合JSON错误信息。{"code":429,"message":"请求过于频繁,请稍后重试"}。
Q3:本地限流和Redis限流怎么选?
A:单机应用(QPS<10000)用Guava RateLimiter或本地令牌桶,性能高无网络开销,分布式多实例必须用Redis,但要考虑Redis单点故障,建议使用Redis Cluster或透传限流状态到网关层。
Q4:滑动窗口的切片大小如何选择?
A:一般窗口1秒,切片100ms-200ms,越小越精确,但Redis ZSet操作开销越大,业务允许一定误差时,也可以使用更高效的计数器算法。
限流落地最佳实践与性能优化
- 分层限流:网关层(Nginx/Spring Cloud Gateway)做粗粒度限流,应用层做细粒度业务限流,数据库层用连接池限流。
- 热点参数限流:针对用户ID、商品ID等热点key,使用Sentinel的“热点参数限流”功能,比通用限流更灵活。
- 动态配置改造:限流阈值不要硬编码,接入Apollo/Nacos配置中心,实现运行时动态调整。
- 监控与告警:配合Prometheus + Grafana统计“被限流的请求数”,当限流比例超过10%时触发告警。
- 避免Redis成为新瓶颈:限流调用量极大时,建议使用本地缓存+异步同步策略,或者采用Sentinel等成熟组件。