跳到主要内容

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 的返回体调个样式。