Redis-Rate-Limit-In-Prectice
昨晚十一点多我在公司楼下吹风,手机叮一下,小李又把活动接口打到 5xx 了…我抬头一看月亮,哎算了,干脆把“基于用户 IP 的接口限流”这一整套,从最容易落地的固定窗口,到更稳的滑动窗口,再到一些现场经验,一口气整齐活儿发你们。Spring Boot + Redis,纯 Java 代码,能直接抄(不是抄作业那种…你懂的)上去跑。
为什么要在服务里做 IP 限流
你要是单机,内存里计数就够了;但线上一上多实例、再挂个 Nginx、再来一点点突发流量,光靠应用内存就扛不住。Redis 当公共计数器最顺手:自增、过期都现成,集群下也通用。IP 这个维度也好解释——够粗能挡住“一个客户端太猛”的情况,又不至于误杀全体。
项目最小改造:固定窗口版(1 分钟不超过 N 次)
逻辑超直白:按 “IP+接口” 做 key,进来就 INCR,第一次见到就顺便设个过期。到阈值就拦。
Redis 与基础配置
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> t = new RedisTemplate<>();
t.setConnectionFactory(factory);
t.setKeySerializer(new StringRedisSerializer());
t.setValueSerializer(new StringRedisSerializer());
return t;
}
}
固定窗口计数器
@Component
publicclass IpFixedWindowLimiter {
privatefinal RedisTemplate<String, Object> redis;
// 可以丢到配置里:1 分钟 10 次
privatestaticfinalint LIMIT = 10;
privatestaticfinalint EXPIRE_SEC = 60;
public IpFixedWindowLimiter(RedisTemplate<String, Object> redis) {
this.redis = redis;
}
public boolean tryAccess(String ip, String api) {
String key = "lim:fixed:" + ip + ":" + api;
Long c = redis.opsForValue().increment(key);
if (c != null && c == 1) {
redis.expire(key, EXPIRE_SEC, TimeUnit.SECONDS);
}
return c != null && c <= LIMIT;
}
}
Web 拦截器接起来
@Component
publicclass IpLimitInterceptor implements HandlerInterceptor {
privatefinal IpFixedWindowLimiter limiter;
public IpLimitInterceptor(IpFixedWindowLimiter limiter) {
this.limiter = limiter;
}
private String clientIp(HttpServletRequest req) {
String xff = req.getHeader("X-Forwarded-For");
if (xff != null && !xff.isEmpty()) return xff.split(",")[0].trim();
String rip = req.getHeader("X-Real-IP");
return (rip != null && !rip.isEmpty()) ? rip : req.getRemoteAddr();
}
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) returntrue;
String ip = clientIp(req);
String uri = req.getRequestURI();
if (!limiter.tryAccess(ip, uri)) {
resp.setStatus(429);
resp.setContentType("application/json;charset=UTF-8");
resp.getWriter().write("{\"code\":429,\"msg\":\"请求太频繁,请稍后再试\"}");
returnfalse;
}
returntrue;
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final IpLimitInterceptor interceptor;
public WebConfig(IpLimitInterceptor interceptor) { this.interceptor = interceptor; }
@Override public void addInterceptors(InterceptorRegistry r) { r.addInterceptor(interceptor).addPathPatterns("/**"); }
}
这套能救火,但有个“踩点”小毛病:窗口重置那一秒,前后两边加起来能被刷到 2×LIMIT。小李就老踩这点儿,心累。
滑动窗口版(ZSET + Lua,按“最近 N 秒”算)
换个思路:记录每次请求的时间戳;每次来就把窗口外的旧记录清掉,看窗口内条数是否超过阈值。要么全部操作放 Lua 保证原子,要么就别玩了。
Lua 脚本(原子清理 + 计数 + 过期)
resources/scripts/ip_rate_limit_sliding.lua
-- KEYS[1] = key (lim:win:ip:api)
-- ARGV[1] = nowMillis, ARGV[2] = windowMillis, ARGV[3] = limit
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 cnt = redis.call('ZCARD', key)
if cnt < limit then
-- 为避免同毫秒覆盖,member 用 now+随机数
local member = tostring(now) .. '-' .. tostring(math.random(100000,999999))
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, window)
return 1
else
return 0
end
装载脚本与服务
@Configuration
public class RateLimitScriptConfig {
@Bean
public DefaultRedisScript<Long> slidingWindowScript() {
DefaultRedisScript<Long> s = new DefaultRedisScript<>();
s.setLocation(new ClassPathResource("scripts/ip_rate_limit_sliding.lua"));
s.setResultType(Long.class);
return s;
}
}
@Component
public class IpSlidingWindowLimiter {
privatefinal RedisTemplate<String, Object> redis;
privatefinal DefaultRedisScript<Long> script;
@Value("${limit.windowMillis:60000}")
privatelong windowMillis;
@Value("${limit.count:10}")
privatelong limit;
public IpSlidingWindowLimiter(RedisTemplate<String, Object> redis, DefaultRedisScript<Long> script) {
this.redis = redis; this.script = script;
}
public boolean tryAccess(String ip, String api) {
String key = "lim:win:" + ip + ":" + api;
long now = System.currentTimeMillis();
Long ok = redis.execute(script, Collections.singletonList(key),
String.valueOf(now), String.valueOf(windowMillis), String.valueOf(limit));
return ok != null && ok == 1L;
}
}
把拦截器切到滑动窗口
@Component
publicclass SlidingIpLimitInterceptor implements HandlerInterceptor {
privatefinal IpSlidingWindowLimiter limiter;
public SlidingIpLimitInterceptor(IpSlidingWindowLimiter limiter) { this.limiter = limiter; }
private String clientIp(HttpServletRequest req) {
String xff = req.getHeader("X-Forwarded-For");
if (xff != null && !xff.isEmpty()) return xff.split(",")[0].trim();
String rip = req.getHeader("X-Real-IP");
return (rip != null && !rip.isEmpty()) ? rip : req.getRemoteAddr();
}
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) returntrue;
if (!limiter.tryAccess(clientIp(req), req.getRequestURI())) {
resp.setStatus(429);
resp.setContentType("application/json;charset=UTF-8");
resp.getWriter().write("{\"code\":429,\"msg\":\"慢点,喝口水再点\"}");
returnfalse;
}
returntrue;
}
}
@Configuration
public class WebConfigWin implements WebMvcConfigurer {
private final SlidingIpLimitInterceptor interceptor;
public WebConfigWin(SlidingIpLimitInterceptor interceptor) { this.interceptor = interceptor; }
@Override public void addInterceptors(InterceptorRegistry r) { r.addInterceptor(interceptor).addPathPatterns("/**"); }
}
我先把外卖拿一下…好,回来继续。
- 反代之后的真实 IP:生产几乎都有 Nginx/网关,记得用
X-Forwarded-For/X-Real-IP拿第一段,否则拦的是网关内网 IP,等于没拦。 - 维度设计别省:
lim:{env}:{app}:{api}:{ip},以后查问题、做灰度都轻松。想“按 IP 全局”,就把{api}拿掉;想“按接口全局”,就把{ip}拿掉。 - 时钟同步要命:滑动窗口依赖时间戳,应用节点最好 NTP 同步;不要用“客户端时间”。
- 高并发同毫秒:上面 Lua 已经把 member 加了随机尾巴,避免同毫秒覆盖。再猛就拼
now-nano-rand。 - 返回体统一:429 别只给一句话,前端一般会根据 code 做节流提示,顺便可以把“剩余等待秒数”也给出来,体验会好很多。
- 黑白名单:活动类接口必备。白名单(运营/测试机)先放过,黑名单直接 403,不占用限流桶。
- Redis 选型:
- 单点就别 上来就搞
pipeline,一般 QPS 也压不到; - 是真高并发再考虑脚本预热和连接池参数;
- 多活就加上
replica-read谨慎点,限流写一定走主。
- 单点就别 上来就搞
想更“匀速”一点?令牌桶就图稳(可选)
有些接口你不想“次数卡死”,而是希望“每秒最多 X 个”,就上令牌桶。核心是周期性往桶里放 token,请求来取不到 token 就 429。
@Component
public class TokenBucketLimiter {
privatefinal RedisTemplate<String, Object> redis;
public TokenBucketLimiter(RedisTemplate<String, Object> redis) { this.redis = redis; }
public boolean tryAcquire(String ip, String api, int ratePerSec, int burst) {
String keyTokens = "lim:tb:t:" + ip + ":" + api;
String keyTs = "lim:tb:ts:" + ip + ":" + api;
long now = System.currentTimeMillis();
// 简化版:用 Lua 更合适,这里演示思路
Long lastTs = (Long) redis.opsForValue().get(keyTs);
if (lastTs == null) lastTs = now;
Long tokens = (Long) redis.opsForValue().get(keyTokens);
if (tokens == null) tokens = (long) burst;
long deltaMs = Math.max(0, now - lastTs);
long add = (ratePerSec * deltaMs) / 1000;
long newTokens = Math.min(burst, tokens + add);
if (newTokens > 0) {
redis.opsForValue().set(keyTokens, newTokens - 1);
redis.opsForValue().set(keyTs, now);
returntrue;
} else {
redis.opsForValue().set(keyTokens, newTokens);
redis.opsForValue().set(keyTs, now);
returnfalse;
}
}
}
生产版也建议用 Lua 原子化更新,避免多个命令时竞态。
我昨天在工位上用 JMeter 随手压了下,模拟一个 IP 对同一路径打流,固定窗口会在“窗口边界”附近出现两段短暂的放量,滑动窗口就平滑得多。两种都能救火,滑动窗口更“公平”,令牌桶更“顺手”。 最小代价就上“固定窗口”,两三个类就行;活动/秒杀这类敏感接口,上“滑动窗口(ZSET+Lua)”;要控制“速率感受”,加“令牌桶”。三个方案不是互斥,很多时候一个接口一个策略,配个注解就能切换。
我先去看下小李有没有再把测试服打挂…有问题你直接甩接口路径给我,我顺便帮你把 key 维度改造一下,顺手再把 429 的返回体调个样式。