Skip to content
第 196 / 250 章中间件⏱ 12 分钟阅读

第 196 章:Redis 分布式 Session 与限流

学习目标

  • 实现分布式 Session 共享
  • 掌握 4 种限流算法
  • 用 Redis + Lua 实现限流
  • Spring Security Session 集成

一、为什么需要分布式 Session

多机部署时,默认 Session 存在单机内存,负载均衡到不同机器会出现登录态丢失

二、Spring Session + Redis

2.1 引入

xml
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2.2 启用

java
@SpringBootApplication
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class App { }

2.3 配置

yaml
spring:
  session:
    store-type: redis
    timeout: 3600s
    redis:
      namespace: myapp:session
      flush-mode: on_save
  data:
    redis:
      host: localhost
      port: 6379

2.4 使用(无侵入)

所有 HttpSession 自动存到 Redis:

java
@GetMapping("/user/info")
public User getUserInfo(HttpSession session) {
    Long userId = (Long) session.getAttribute("userId");
    return userService.findById(userId);
}

Session 数据结构:

Key:    myapp:session:abc-uuid
Value:  hash结构(attributes)
TTL:    3600s 自动续期

三、Token 替代 Session

移动端 / 跨域场景,常用 JWT 或 Redis Token:

java
@Service
public class TokenService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public String create(Long userId) {
        String token = UUID.randomUUID().toString().replace("-", "");
        String key = "token:" + token;

        // 存 userId,7 天过期
        redisTemplate.opsForValue().set(key, userId, 7, TimeUnit.DAYS);
        return token;
    }

    public Long getUserId(String token) {
        String key = "token:" + token;
        Long userId = (Long) redisTemplate.opsForValue().get(key);
        if (userId == null) {
            throw new UnauthorizedException("登录已过期");
        }
        // 续期
        redisTemplate.expire(key, 7, TimeUnit.DAYS);
        return userId;
    }

    public void destroy(String token) {
        redisTemplate.delete("token:" + token);
    }
}

四、限流算法

4.1 固定窗口(最简单)

lua
-- 固定窗口
local current = redis.call('INCR', KEYS[1])
if current == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current <= tonumber(ARGV[2])

缺点:临界突发(窗口切换时可能 2 倍流量)

4.2 滑动窗口

lua
-- 滑动窗口
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 current = redis.call('ZCARD', key)
if current >= limit then
  return 0
end
-- 记录
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('EXPIRE', key, math.ceil(window / 1000))
return 1

4.3 令牌桶(Token Bucket)

以固定速率往桶里放令牌,请求拿走令牌才能通过。

lua
-- 令牌桶
local key = KEYS[1]
local rate = tonumber(ARGV[1])     -- 每秒生成 N 个
local capacity = tonumber(ARGV[2]) -- 桶容量
local now = tonumber(ARGV[3])      -- 当前时间 ms
local requested = tonumber(ARGV[4]) -- 需要的令牌数

local data = redis.call('HMGET', key, 'tokens', 'last')
local tokens = tonumber(data[1]) or capacity
local last = tonumber(data[2]) or now

-- 计算这段时间新增的令牌
local delta = math.max(0, now - last)
local newTokens = math.min(capacity, tokens + delta * rate / 1000)

if newTokens >= requested then
  newTokens = newTokens - requested
  redis.call('HMSET', key, 'tokens', newTokens, 'last', now)
  redis.call('PEXPIRE', key, 60000)
  return 1
end
return 0

4.4 漏桶(Leaky Bucket)

请求进入桶,桶以固定速率流出,溢出则拒绝。

特点:强制匀速,常用于流量整形。

五、Redis 实战限流

5.1 Lua 脚本

lua
-- rate_limit.lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local period = tonumber(ARGV[2])  -- 秒

local current = redis.call('GET', key)
if current == false then
  redis.call('SET', key, 1, 'EX', period)
  return 1
end

current = tonumber(current)
if current >= limit then
  return 0
end

redis.call('INCR', key)
return 1

5.2 Spring 注解

java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    int limit() default 60;
    int period() default 60;  // 秒
    String key() default "";
}

5.3 AOP 实现

java
@Aspect
@Component
public class RateLimitAspect {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Around("@annotation(rateLimit)")
    public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
        String key = rateLimit.key().isEmpty()
            ? "rate:" + pjp.getSignature().toShortString()
            : "rate:" + rateLimit.key();

        Long count = redisTemplate.execute(rateLimitScript,
            Collections.singletonList(key),
            String.valueOf(rateLimit.limit()),
            String.valueOf(rateLimit.period()));

        if (count == null || count == 0) {
            throw new RateLimitException("访问过于频繁");
        }

        return pjp.proceed();
    }
}

5.4 使用

java
@RateLimit(limit = 60, period = 60)  // 每分钟 60 次
@GetMapping("/api/data")
public Result data() {
    return Result.ok(...);
}

@RateLimit(limit = 5, period = 1, key = "login:ip:" + ip)  // 登录防爆破
@PostMapping("/login")
public Result login(@RequestBody LoginDto dto) {
    return Result.ok(...);
}

六、Gateway 限流

yaml
spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/api/user/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10     # 每秒令牌数
                redis-rate-limiter.burstCapacity: 20     # 桶容量
                key-resolver: "#{@ipKeyResolver}"        # 按 IP
java
@Bean
public KeyResolver ipKeyResolver() {
    return exchange -> Mono.just(
        exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()
    );
}

七、滑动窗口实战

java
@Service
public class SlidingWindowLimiter {

    private static final String LUA = """
        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 count = redis.call('ZCARD', key)
        if count >= limit then
          return 0
        end
        redis.call('ZADD', key, now, now .. ':' .. ARGV[4])
        redis.call('PEXPIRE', key, window + 1000)
        return 1
        """;

    @Autowired
    private StringRedisTemplate redisTemplate;

    public boolean tryAcquire(String key, int limit, long windowMs) {
        Long now = System.currentTimeMillis();
        String nonce = UUID.randomUUID().toString();

        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(LUA, Long.class),
            Collections.singletonList("limit:" + key),
            String.valueOf(now),
            String.valueOf(windowMs),
            String.valueOf(limit),
            nonce
        );

        return result != null && result == 1;
    }
}

八、本章小结

限流算法精度适用
固定窗口简单限流
滑动窗口一般场景
令牌桶突发流量
漏桶流量整形
Session 方案特点
默认 Session单机内存
Spring Session + Redis无侵入、自动同步
Token + Redis跨域、移动端

动手练习

  1. 用 Spring Session + Redis 实现 Session 共享
  2. 实现 Lua 版固定窗口限流
  3. 用 ZSet 实现滑动窗口限流
  4. 用令牌桶保护登录接口(防爆破)

推荐阅读


下一章:第 197 章:Redis 应用场景与最佳实践

本站基于 VitePress 构建 · 由 Codebook 团队维护