限流与鉴权深度解析
面试官:你知道令牌桶和漏桶算法的区别吗?
你:漏桶算法以固定速率处理请求,能平滑流量但无法处理突发;令牌桶以固定速率生成令牌,允许在桶未满时处理突发流量。简单说,漏桶强制限速,令牌桶允许短时突发。
面试官:那 Sentinel 用的是哪种算法?为什么选它?
这个问题考察对主流框架实现的理解。能说清 Sentinel 的「滑动窗口 + 令牌桶」混合策略的候选人,才具备生产实战经验。
链式追问一:限流算法核心原理
Section titled “链式追问一:限流算法核心原理”Q1:四种限流算法的原理和对比?必考
Section titled “Q1:四种限流算法的原理和对比?”算法概览:
┌─────────────────────────────────────────────────────────────┐│ 限流算法对比 │├─────────────────────────────────────────────────────────────┤│ ││ 1. 固定窗口计数器 2. 滑动窗口计数器 ││ ┌───┬───┐ ┌─┬─┬─┬─┬─┬─┐ ││ │ 0 │ 1 │ │1│2│3│4│5│6│ ││ └───┴───┘ └─┴─┴─┴─┴─┴─┘ ││ 简单但临界突破 精确但内存占用高 ││ ││ 3. 漏桶算法 4. 令牌桶算法 ││ │ ┌───┐ ││ ▼ ┌──│ ● │──┐ ││ ┌───┐ 固定速率 │ └───┘ │ ││ │桶 │ ─────────→ │ 令牌池 │ ││ └───┘ 流出 └─────────┘ ││ 强制限速 允许突发 ││ │└─────────────────────────────────────────────────────────────┘1. 固定窗口计数器
场景:每分钟限制 100 次请求
时间线: 00:00 ─────────── 01:00 ─────────── 02:00 │ 窗口1 │ 窗口2 │ │ 100次上限 │ 计数器归0 │
问题演示(临界突破): 00:59 发送 100 次请求 ← 窗口1 满了 01:01 发送 100 次请求 ← 窗口2 重新开始
结果:00:59~01:01 的 2 秒内,实际发送了 200 次请求! 这是固定窗口的致命缺陷:临界时刻可能 2x 突破限制。2. 滑动窗口计数器
场景:每分钟限制 100 次(拆分为 60 个 1 秒小格)
当前时间:01:30有效窗口:00:30 ~ 01:30(最近 60 秒)
┌───┬───┬───┬───┬───┬─────┬───┬───┐│00:│00:│00:│...│01:│01: │01:│01:││30 │31 │32 │ │28 │29 │30 │31 │└───┴───┴───┴───┴───┴─────┴───┴───┘ ↑ ↑ 最老的小格 最新小格
当前请求计数 = 所有 60 个小格的总和每秒滑动一格,自然过渡,无临界问题!Redis 实现(ZSet):
/** * 滑动窗口限流器 * 使用 Redis ZSet 实现,key 为请求时间戳 */public class SlidingWindowRateLimiter {
@Autowired private StringRedisTemplate redisTemplate;
/** * 检查是否允许请求 * @param key 限流 key(如 "rate_limit:user:123") * @param limit 限制次数 * @param windowSeconds 窗口大小(秒) */ public boolean allowRequest(String key, int limit, int windowSeconds) { long now = System.currentTimeMillis(); long windowStart = now - windowSeconds * 1000;
// 1. 删除窗口外的旧数据 redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart);
// 2. 统计当前窗口内的请求数 Long count = redisTemplate.opsForZSet().count(key, windowStart, now); if (count == null) count = 0L;
// 3. 判断是否超过限制 if (count >= limit) { return false; // 拒绝请求 }
// 4. 记录本次请求 String requestId = UUID.randomUUID().toString(); redisTemplate.opsForZSet().add(key, requestId, now);
// 5. 设置过期时间(窗口大小 + 1 秒缓冲) redisTemplate.expire(key, windowSeconds + 1, TimeUnit.SECONDS);
return true; // 允许请求 }}3. 漏桶算法(Leaky Bucket)
原理:请求像水一样流入桶中,桶以固定速率漏水(处理请求)
请求流量(突发) │ ▼┌─────────────────┐│ 漏桶(队列) │ ← 固定容量(如 100 个请求)│ ┌───┬───┬───┐ ││ │ r1│ r2│ r3│ ││ └───┴───┴───┘ │└─────────────────┘ │ ▼ 固定速率流出(如每秒 10 个) ┌──────┐ │ 处理 │ └──────┘
特性: • 桶满则拒绝新请求(保护系统) • 流出速率固定,无论输入多大 • 无法处理突发流量(即使下游有空闲,也不能加速)
适用场景: • 保护数据库等脆弱系统 • 需要严格限制处理速率 • 流量削峰填谷4. 令牌桶算法(Token Bucket)
原理:系统以固定速率向桶中放令牌,请求消耗令牌
固定速率放令牌(如每秒 100 个) │ ▼┌─────────────────┐│ 令牌桶 │ ← 最大容量(如 200 个)│ ● ● ● ● ● │ 允许令牌积累│ ● ● ● ● ● ││ ● ● ● ● │└─────────────────┘ │ │ 请求到达,取走令牌 ▼┌─────────────────┐│ 处理请求 │ 有令牌 → 处理└─────────────────┘ 无令牌 → 拒绝/等待
特性: • 平时令牌积累,突发时可快速消耗 • 突发量 = 桶容量(如 200 个) • 平均速率 = 放令牌速率(如每秒 100 个)
适用场景: • API 限流(允许短时突发) • 用户操作限制 • 网络流量控制完整对比表格:
| 算法 | 突发处理 | 流量平滑 | 精确度 | 实现复杂度 | 内存占用 | 适用场景 |
|---|---|---|---|---|---|---|
| 固定窗口 | 临界 2x 突破 | 差 | 低 | 极简 | O(1) | 简单限流,要求不高 |
| 滑动窗口 | 准确限制 | 好 | 高 | 中等 | O(N) | 需要精确控制的场景 |
| 漏桶 | ❌ 不允许 | 最好 | 中 | 中等 | O(桶容量) | 保护下游,削峰填谷 |
| 令牌桶 | ✅ 允许(到桶容量) | 较好 | 中 | 中等 | O(1) | API 限流,允许突发 |
Q2:Sentinel 用的是什么限流算法?为什么?高频
Section titled “Q2:Sentinel 用的是什么限流算法?为什么?”Sentinel 的混合策略:
Sentinel 限流策略:
1. QPS 限流 └── 滑动时间窗口算法 • 统计每秒请求数 • 1 秒拆分为 2 个 500ms 的样本窗口 • 循环复用,减少内存占用
2. 并发线程数限流 └── 计数器算法 • 统计当前正在处理的线程数 • 超过阈值拒绝新请求
3. 预热(Warm Up)模式 └── 令牌桶算法(Guava RateLimiter) • 冷启动时,令牌生成速率从 阈值/3 逐渐提升到 阈值 • 防止冷系统突然被大流量打垮
4. 匀速排队 └── 漏桶算法 • 请求排队,固定速率处理 • 适合消息队列等场景滑动窗口实现细节:
/** * Sentinel 滑动窗口核心实现 * 使用数组循环复用,避免频繁创建对象 */public class SlidingWindow {
// 窗口大小:1000ms private final int windowLengthInMs = 500; // 样本窗口数量:2 个 private final int sampleCount = 2; // 循环数组存储每个样本窗口的计数 private final AtomicLong[] array = new AtomicLong[sampleCount];
public boolean canPass(int threshold) { long currentTimeMs = System.currentTimeMillis();
// 计算当前样本窗口索引 int idx = (int) ((currentTimeMs / windowLengthInMs) % sampleCount);
// 获取当前样本窗口的开始时间 long windowStart = currentTimeMs - (currentTimeMs % windowLengthInMs);
// 重置过期的样本窗口 AtomicLong old = array[idx]; if (old == null) { array[idx] = new AtomicLong(1); return true; }
// 如果样本窗口的时间不对,重置 if (old.get() >> 32 != windowStart) { array[idx] = new AtomicLong((windowStart << 32) + 1); return true; }
// 统计当前窗口的总请求数 long sum = 0; for (AtomicLong counter : array) { if (counter != null) { sum += counter.get() & 0xFFFFFFFFL; } }
// 判断是否超过阈值 if (sum >= threshold) { return false; // 拒绝 }
// 计数 +1 old.incrementAndGet(); return true; }}Guava RateLimiter(令牌桶):
/** * Guava RateLimiter 使用示例 * 支持预热模式 */public class RateLimiterExample {
public static void main(String[] args) { // 1. 普通模式:每秒 100 个令牌 RateLimiter rateLimiter = RateLimiter.create(100.0);
// 2. 预热模式:每秒 100 个,预热时间 10 秒 // 冷启动时从 33.3/s 逐渐提升到 100/s RateLimiter warmupLimiter = RateLimiter.create(100.0, 10, TimeUnit.SECONDS);
// 3. 阻塞等待获取令牌 rateLimiter.acquire(); // 阻塞直到获取到令牌 doProcess();
// 4. 非阻塞尝试获取 if (rateLimiter.tryAcquire()) { doProcess(); } else { // 快速失败 throw new RateLimitException("请求太频繁"); }
// 5. 尝试获取,最多等待 100ms if (rateLimiter.tryAcquire(100, TimeUnit.MILLISECONDS)) { doProcess(); } else { throw new RateLimitException("等待超时"); } }}为什么 Sentinel 选择混合策略:
| 场景 | 算法选择 | 原因 |
|---|---|---|
| API QPS 限流 | 滑动窗口 | 精确统计,无临界问题 |
| 并发线程数 | 计数器 | 简单高效,实时统计 |
| 冷启动保护 | 令牌桶(预热) | 渐进提升容量,避免系统过载 |
| 消息队列 | 漏桶(匀速) | 固定速率消费,避免消息堆积 |
Q3:分布式限流如何实现?高频
Section titled “Q3:分布式限流如何实现?”单机限流 vs 分布式限流:
单机限流: Gateway 1: 100 QPS Gateway 2: 100 QPS Gateway 3: 100 QPS 总计:300 QPS(如果配置 100 QPS/台)
问题: • 无法精确控制全局 QPS • 请求分布不均时,某台可能超限
分布式限流: Gateway 1 ─┐ Gateway 2 ─┼─→ Redis(全局计数器)→ 全局 100 QPS Gateway 3 ─┘Redis + Lua 实现令牌桶:
/** * Redis 分布式令牌桶限流器 * 使用 Lua 脚本保证原子性 */public class RedisTokenBucketRateLimiter {
@Autowired private StringRedisTemplate redisTemplate;
// Lua 脚本:原子性地获取令牌 private static final String SCRIPT = "local key = KEYS[1] " + "local permits = tonumber(ARGV[1]) " + // 请求令牌数 "local maxPermits = tonumber(ARGV[2]) " + // 桶容量 "local rate = tonumber(ARGV[3]) " + // 令牌生成速率(个/秒) "local now = tonumber(ARGV[4]) " + // 当前时间(毫秒)
// 获取当前令牌数和上次更新时间 "local info = redis.call('hmget', key, 'tokens', 'lastRefillTime') " + "local tokens = tonumber(info[1]) or maxPermits " + "local lastRefillTime = tonumber(info[2]) or now " +
// 计算补充的令牌数 "local interval = (now - lastRefillTime) / 1000 " + "local addedTokens = math.floor(interval * rate) " +
// 更新令牌数(不超过桶容量) "tokens = math.min(maxPermits, tokens + addedTokens) " +
// 判断是否有足够令牌 "if tokens >= permits then " + " tokens = tokens - permits " + " redis.call('hmset', key, 'tokens', tokens, 'lastRefillTime', now) " + " redis.call('expire', key, 3600) " + " return 1 " + // 成功获取 "else " + " return 0 " + // 令牌不足 "end";
/** * 尝试获取令牌 */ public boolean tryAcquire(String key, int permits, int maxPermits, int rate) { long now = System.currentTimeMillis(); Long result = redisTemplate.execute( new DefaultRedisScript<>(SCRIPT, Long.class), Collections.singletonList(key), String.valueOf(permits), String.valueOf(maxPermits), String.valueOf(rate), String.valueOf(now) ); return result != null && result == 1; }}性能优化:本地缓存 + 异步同步:
纯 Redis 方案的问题: • 每次请求都访问 Redis,延迟高(~1ms) • Redis 成为性能瓶颈
优化方案(本地缓存 80% 流量): Gateway 本地令牌桶(80 个/s)+ Redis 全局限流(20 个/s)
┌─────────────────────────────────────┐ │ Gateway 本地令牌桶 │ │ • 处理 80% 请求(快速响应) │ │ • 定期从 Redis 同步配额 │ └─────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ Redis 全局限流 │ │ • 处理剩余 20% 请求 │ │ • 精确控制全局总量 │ └─────────────────────────────────────┘
性能提升:延迟从 ~1ms 降到 ~0.1ms链式追问二:JWT vs Session 鉴权
Section titled “链式追问二:JWT vs Session 鉴权”Q4:JWT 和 Session 的区别?各自适用什么场景?必考
Section titled “Q4:JWT 和 Session 的区别?各自适用什么场景?”架构对比:
Session 方案(服务端状态):
客户端 服务端 │ │ │ 1. 登录请求 │ ├────────────────────────►│ │ │ 创建 Session(Redis) │ │ session_id = "abc123" │ 2. 返回 Session ID │ │◄────────────────────────┤ Set-Cookie: JSESSIONID=abc123 │ │ │ 3. 后续请求(带 Cookie) │ ├────────────────────────►│ │ Cookie: JSESSIONID=... │ 查询 Session(Redis) │ │ 验证用户身份 │ │ │ 4. 返回数据 │ │◄────────────────────────┤
JWT 方案(客户端状态):
客户端 服务端 │ │ │ 1. 登录请求 │ ├────────────────────────►│ │ │ 生成 JWT(私钥签名) │ │ token = "xxx.yyy.zzz" │ 2. 返回 JWT │ │◄────────────────────────┤ { "token": "xxx.yyy.zzz" } │ │ │ 本地存储 JWT │ │ │ │ 3. 后续请求(带 Token) │ ├────────────────────────►│ │ Authorization: Bearer..│ 验证签名(无需查询) │ │ 解析用户信息 │ 4. 返回数据 │ │◄────────────────────────┤详细对比:
| 维度 | Session(服务端状态) | JWT(客户端状态) |
|---|---|---|
| 存储位置 | 服务端(内存/Redis) | 客户端(Cookie/LocalStorage) |
| 服务端存储 | 需要存储 Session 数据 | 无需存储(无状态) |
| 分布式 | 需要共享 Session(Redis) | 天然支持 |
| 扩展性 | 受 Redis 容量限制 | 无限制 |
| 注销 | 立即生效(删除 Session) | 有延迟(需要黑名单) |
| 安全性 | Session ID 泄露可立即失效 | Token 泄露前无法失效 |
| Token 大小 | Session ID(~50B) | JWT(~500B+) |
| 跨域 | 需要配置 Cookie | 天然支持(Header) |
| 移动端 | Cookie 支持差 | 天然支持 |
| 性能 | 需查询 Redis | 无需查询(验证签名) |
JWT 结构详解:
JWT = Header.Payload.Signature
示例:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
┌─────────────────────────────────────────────────────────┐│ Header(Base64 编码) │├─────────────────────────────────────────────────────────┤│ { ││ "alg": "HS256", // 签名算法 ││ "typ": "JWT" // Token 类型 ││ } │└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐│ Payload(Base64 编码) │├─────────────────────────────────────────────────────────┤│ { ││ "sub": "user_123", // 用户 ID(标准声明) ││ "name": "John Doe", // 用户名 ││ "iat": 1516239022, // 签发时间(标准声明) ││ "exp": 1516242622, // 过期时间(标准声明) ││ "roles": ["USER", "ADMIN"], // 自定义声明 ││ "permissions": ["read", "write"] ││ } │└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐│ Signature(签名) │├─────────────────────────────────────────────────────────┤│ HMACSHA256( ││ base64(header) + "." + base64(payload), ││ secret // 服务端密钥,只有服务端知道 ││ ) ││ ││ 作用:防止篡改。篡改 Payload 后,Signature 验证失败。 │└─────────────────────────────────────────────────────────┘Q5:JWT 如何实现注销(踢出登录)?高频
Section titled “Q5:JWT 如何实现注销(踢出登录)?”JWT 的天然缺陷:无状态意味着签发后无法撤销(只要未过期就有效)。
方案对比:
| 方案 | 延迟 | 性能 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 短过期 + Refresh Token | 最多 15 分钟 | 高 | 中 | 推荐方案 |
| Token 黑名单 | 即时 | 中(Redis 查询) | 中 | 需要立即注销 |
| 双 Token 验证 | 即时 | 低(两次验证) | 高 | 高安全场景 |
方案一:短过期 + Refresh Token(推荐)
┌─────────────────────────────────────────────────────────┐│ 双 Token 机制 │├─────────────────────────────────────────────────────────┤│ ││ Access Token: ││ • 过期时间短(5~15 分钟) ││ • 存储在客户端(LocalStorage) ││ • 用于访问业务接口 ││ ││ Refresh Token: ││ • 过期时间长(7 天) ││ • 存储在 Redis(可撤销) ││ • 用于刷新 Access Token ││ │└─────────────────────────────────────────────────────────┘
流程:
1. 登录成功 └── 返回 Access Token + Refresh Token
2. 业务请求 └── 携带 Access Token(Header: Authorization: Bearer ...)
3. Access Token 过期 └── 返回 401 Unauthorized └── 客户端用 Refresh Token 换新 Access Token
4. 注销(踢出登录) └── 删除 Redis 中的 Refresh Token └── 用户无法刷新 Access Token └── 最多等 15 分钟,Access Token 自然过期代码实现:
/** * JWT 双 Token 认证服务 */@Servicepublic class JwtAuthService {
@Autowired private StringRedisTemplate redisTemplate;
@Value("${jwt.access-token-expiration:900}") // 15 分钟 private long accessTokenExpiration;
@Value("${jwt.refresh-token-expiration:604800}") // 7 天 private long refreshTokenExpiration;
/** * 登录:生成双 Token */ public LoginResponse login(String username, String password) { // 1. 验证用户名密码 User user = authenticate(username, password);
// 2. 生成 Access Token String accessToken = Jwts.builder() .setSubject(user.getId()) .claim("roles", user.getRoles()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpiration * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();
// 3. 生成 Refresh Token(随机字符串) String refreshToken = UUID.randomUUID().toString().replace("-", "");
// 4. Refresh Token 存入 Redis redisTemplate.opsForValue().set( "refresh_token:" + refreshToken, user.getId(), refreshTokenExpiration, TimeUnit.SECONDS );
return new LoginResponse(accessToken, refreshToken, accessTokenExpiration); }
/** * 刷新 Access Token */ public LoginResponse refreshToken(String refreshToken) { // 1. 验证 Refresh Token 是否有效 String userId = redisTemplate.opsForValue().get("refresh_token:" + refreshToken); if (userId == null) { throw new UnauthorizedException("Refresh Token 无效或已过期"); }
// 2. 查询用户信息 User user = userRepository.findById(userId);
// 3. 生成新的 Access Token String newAccessToken = Jwts.builder() .setSubject(user.getId()) .claim("roles", user.getRoles()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpiration * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();
// 4. 可选:刷新 Refresh Token(延长有效期) redisTemplate.expire("refresh_token:" + refreshToken, refreshTokenExpiration, TimeUnit.SECONDS);
return new LoginResponse(newAccessToken, refreshToken, accessTokenExpiration); }
/** * 注销(踢出登录) */ public void logout(String refreshToken) { // 删除 Redis 中的 Refresh Token redisTemplate.delete("refresh_token:" + refreshToken); // 用户无法刷新 Access Token // 最多等 15 分钟,Access Token 自然过期 }}方案二:Token 黑名单
/** * JWT 黑名单方案 */@Servicepublic class JwtBlacklistService {
@Autowired private StringRedisTemplate redisTemplate;
/** * 注销时将 Token 加入黑名单 */ public void logout(String token) { // 解析 Token 获取过期时间 Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody();
Date expiration = claims.getExpiration();
// 计算 TTL(剩余有效时间) long ttl = (expiration.getTime() - System.currentTimeMillis()) / 1000;
if (ttl > 0) { // 将 Token 加入黑名单,TTL = 剩余有效期 redisTemplate.opsForValue().set( "blacklist:" + token, "1", ttl, TimeUnit.SECONDS ); } }
/** * 验证 Token 时检查黑名单 */ public boolean validateToken(String token) { // 1. 检查黑名单 if (Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:" + token))) { return false; // 已注销 }
// 2. 验证签名和过期时间 try { Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); return true; } catch (JwtException e) { return false; } }}性能对比:
无黑名单: 验证 JWT → 解析签名 → 通过(~0.1ms)
有黑名单: 验证 JWT → 检查 Redis 黑名单 → 解析签名 → 通过(~1ms)
影响: • 每次 JWT 验证都需要查询 Redis • 增加 Redis 压力 • 延迟增加 ~1msQ6:网关层如何统一实现 JWT 鉴权?高频
Section titled “Q6:网关层如何统一实现 JWT 鉴权?”完整实现:
/** * 网关层 JWT 认证过滤器 * 统一鉴权,透传用户信息到下游服务 */@Componentpublic class JwtAuthGlobalFilter implements GlobalFilter, Ordered {
@Autowired private JwtUtil jwtUtil;
@Autowired private StringRedisTemplate redisTemplate;
// 白名单路径(无需认证) private static final List<String> WHITE_LIST = Arrays.asList( "/api/auth/login", "/api/auth/register", "/api/auth/refresh", "/api/public/**", "/health", "/actuator/**" );
@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getPath().value();
// 1. 白名单路径直接放行 if (isWhiteListed(path)) { return chain.filter(exchange); }
// 2. 提取 Token String token = extractToken(request); if (token == null) { return unauthorized(exchange, "缺少认证 Token"); }
try { // 3. 验证 Token Claims claims = jwtUtil.validateToken(token);
// 4. 可选:检查黑名单 if (isInBlacklist(token)) { return unauthorized(exchange, "Token 已失效"); }
// 5. 可选:检查用户状态(是否被封禁) String userId = claims.getSubject(); if (isUserDisabled(userId)) { return unauthorized(exchange, "用户已被禁用"); }
// 6. 透传用户信息到下游服务(关键!) ServerHttpRequest newRequest = request.mutate() .header("X-User-Id", userId) .header("X-User-Name", claims.get("name", String.class)) .header("X-User-Roles", String.join(",", claims.get("roles", List.class))) .header("X-Token", token) // 下游可能需要 Token 调用其他服务 .build();
// 7. 继续过滤器链 return chain.filter(exchange.mutate().request(newRequest).build());
} catch (ExpiredJwtException e) { return unauthorized(exchange, "Token 已过期"); } catch (JwtException e) { return unauthorized(exchange, "Token 无效"); } }
/** * 提取 Token */ private String extractToken(ServerHttpRequest request) { String bearerToken = request.getHeaders().getFirst("Authorization"); if (bearerToken != null && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); }
// 支持从 Cookie 提取 HttpCookie cookie = request.getCookies().getFirst("access_token"); if (cookie != null) { return cookie.getValue(); }
return null; }
/** * 检查白名单 */ private boolean isWhiteListed(String path) { for (String pattern : WHITE_LIST) { if (pattern.endsWith("/**")) { String prefix = pattern.substring(0, pattern.length() - 3); if (path.startsWith(prefix)) { return true; } } else if (path.equals(pattern)) { return true; } } return false; }
/** * 检查黑名单 */ private boolean isInBlacklist(String token) { return Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:" + token)); }
/** * 检查用户是否被禁用 */ private boolean isUserDisabled(String userId) { String status = redisTemplate.opsForValue().get("user:status:" + userId); return "DISABLED".equals(status); }
/** * 返回 401 错误 */ private Mono<Void> unauthorized(ServerWebExchange exchange, String message) { ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = new HashMap<>(); body.put("code", 401); body.put("message", message); body.put("timestamp", System.currentTimeMillis());
DataBuffer buffer = response.bufferFactory() .wrap(JSON.toJSONString(body).getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer)); }
@Override public int getOrder() { return -100; // 高优先级,尽早拦截 }}下游服务使用用户信息:
/** * 下游服务控制器 * 直接从 Header 获取用户信息,无需再解析 JWT */@RestControllerpublic class OrderController {
@PostMapping("/api/orders") public Order createOrder(@RequestBody OrderRequest request, @RequestHeader("X-User-Id") String userId, @RequestHeader("X-User-Roles") String roles) { // 网关已经完成认证,直接使用用户信息 return orderService.createOrder(userId, request); }
@GetMapping("/api/orders") public List<Order> getMyOrders(@RequestHeader("X-User-Id") String userId) { // 直接查询当前用户的订单 return orderService.findByUserId(userId); }}架构优势:
传统方案(每个服务都验证 JWT): Gateway → 用户服务(验证 JWT) → 订单服务(验证 JWT) → 支付服务(验证 JWT)
问题: • JWT 解析开销重复 • 每个服务都要处理认证逻辑
网关统一认证方案: Gateway(验证 JWT,透传用户信息)→ 用户服务(信任 Header) → 订单服务(信任 Header) → 支付服务(信任 Header)
优势: • JWT 解析只执行一次 • 下游服务无需关心认证 • 统一的安全策略
注意: • 内网服务间调用,信任网关透传的 Header • 外网直接访问服务,需要额外保护链式追问三:限流与鉴权结合
Section titled “链式追问三:限流与鉴权结合”Q7:如何实现基于用户级别的精细化限流?高频
Section titled “Q7:如何实现基于用户级别的精细化限流?”多维度限流策略:
限流维度:
1. IP 级别(防 DDoS) └── 单个 IP 每秒最多 100 次请求
2. 用户级别(防刷量) └── 单个用户每分钟最多 60 次请求
3. 接口级别(保护核心接口) └── 查询接口:100 QPS └── 下单接口:10 QPS
4. 租户级别(SaaS 场景) └── 基础版:1000 QPS └── 专业版:5000 QPS └── 企业版:不限Gateway 实现:
/** * 多维度限流过滤器 */@Componentpublic class MultiDimensionRateLimitFilter implements GlobalFilter, Ordered {
@Autowired private StringRedisTemplate redisTemplate;
@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getPath().value();
// 1. IP 限流(防 DDoS) String ip = getClientIp(request); if (!checkRateLimit("ip:" + ip, 100, 1)) { return tooManyRequests(exchange, "IP 请求过于频繁"); }
// 2. 用户限流(需要先认证) String userId = request.getHeaders().getFirst("X-User-Id"); if (userId != null) { // 获取用户等级(不同等级不同配额) UserLevel level = getUserLevel(userId); int userLimit = level.getRateLimit(); // VIP: 100/min, 普通用户: 60/min
if (!checkRateLimit("user:" + userId, userLimit, 60)) { return tooManyRequests(exchange, "用户请求过于频繁"); } }
// 3. 接口限流 String apiLimit = getApiLimit(path); // 查询接口:100,下单接口:10 if (!checkRateLimit("api:" + path, apiLimit, 1)) { return tooManyRequests(exchange, "接口繁忙"); }
// 4. 租户限流(SaaS) String tenantId = request.getHeaders().getFirst("X-Tenant-Id"); if (tenantId != null) { int tenantLimit = getTenantLimit(tenantId); if (!checkRateLimit("tenant:" + tenantId, tenantLimit, 1)) { return tooManyRequests(exchange, "租户配额已用尽"); } }
return chain.filter(exchange); }
/** * 滑动窗口限流检查 */ private boolean checkRateLimit(String key, int limit, int windowSeconds) { long now = System.currentTimeMillis(); long windowStart = now - windowSeconds * 1000;
// 使用 Redis ZSet 实现滑动窗口 String redisKey = "rate_limit:" + key;
// 1. 删除过期数据 redisTemplate.opsForZSet().removeRangeByScore(redisKey, 0, windowStart);
// 2. 统计当前窗口请求数 Long count = redisTemplate.opsForZSet().count(redisKey, windowStart, now); if (count == null) count = 0L;
// 3. 判断是否超限 if (count >= limit) { return false; }
// 4. 记录本次请求 redisTemplate.opsForZSet().add(redisKey, UUID.randomUUID().toString(), now); redisTemplate.expire(redisKey, windowSeconds + 1, TimeUnit.SECONDS);
return true; }
@Override public int getOrder() { return -90; // 在认证之后 }}限流配置管理:
# 限流规则配置(Nacos)rate-limit: # IP 限流 ip: default: 100 # 默认 100 次/秒 blacklist: # 黑名单(直接拒绝) - 192.168.1.100
# 用户限流 user: normal: 60 # 普通用户 60 次/分钟 vip: 200 # VIP 用户 200 次/分钟 svip: 1000 # SVIP 用户 1000 次/分钟
# 接口限流 api: /api/orders/create: 10 # 下单接口 10 QPS /api/orders/query: 100 # 查询接口 100 QPS /api/payments/create: 5 # 支付接口 5 QPS
# 租户限流 tenant: basic: 1000 # 基础版 1000 QPS pro: 5000 # 专业版 5000 QPS enterprise: -1 # 企业版不限实战案例:限流系统设计
Section titled “实战案例:限流系统设计”案例:秒杀场景下的限流策略
Section titled “案例:秒杀场景下的限流策略”场景:秒杀活动,100 件商品,10 万用户参与。
问题:
- 瞬时流量达到 10 万 QPS
- 直接打垮数据库
- 库存超卖
解决方案:
多层限流架构:
┌─────────────────────────────────────────────────────────┐│ CDN 限流(第一层) ││ • 静态资源缓存 ││ • 请求频率限制 │└─────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────┐│ Nginx 限流(第二层) ││ • IP 限流:单 IP 10 次/秒 ││ • 全局限流:总 QPS 限制在 5000 │└─────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────┐│ Gateway 限流(第三层) ││ • 用户限流:单用户 1 次/秒 ││ • 接口限流:秒杀接口 1000 QPS │└─────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────┐│ Redis 预扣库存(第四层) ││ • Lua 脚本原子性扣减 ││ • 库存不足直接返回 │└─────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────┐│ MQ 异步下单(第五层) ││ • 请求写入消息队列 ││ • 数据库异步消费 │└─────────────────────────────────────────────────────────┘核心代码:
/** * 秒杀接口限流 */@RestControllerpublic class SeckillController {
@Autowired private StringRedisTemplate redisTemplate;
/** * 秒杀接口 */ @PostMapping("/api/seckill/{productId}") public Result seckill(@PathVariable String productId, @RequestHeader("X-User-Id") String userId) {
// 1. 用户限流(1 秒只能抢 1 次) String userKey = "seckill:user:" + userId + ":" + productId; Boolean success = redisTemplate.opsForValue().setIfAbsent( userKey, "1", 1, TimeUnit.SECONDS ); if (!success) { return Result.fail("操作过于频繁"); }
// 2. 预扣库存(Lua 脚本保证原子性) String script = "if redis.call('get', KEYS[1]) <= '0' then " + " return 0 " + "end " + "return redis.call('decr', KEYS[1])";
Long stock = redisTemplate.execute( new DefaultRedisScript<>(script, Long.class), Collections.singletonList("seckill:stock:" + productId) );
if (stock == null || stock < 0) { return Result.fail("商品已售罄"); }
// 3. 发送消息到 MQ SeckillOrder order = new SeckillOrder(userId, productId); rabbitTemplate.convertAndSend("seckill.order", order);
return Result.success("抢购成功,订单处理中"); }}