Java Web 开发中基于 Spring Boot + Redis + JWT 的统一登录鉴权与接口限流实战
在做 Java Web 项目时,登录鉴权和接口限流几乎是绕不过去的两件事。前者解决“你是谁、你能干什么”,后者解决“你不能无限打接口把服务打挂”。
很多项目一开始只有一个简单的登录接口,后面业务一多,就会慢慢暴露出这些问题:
- 用户登录后如何保持会话,又不依赖服务器内存?
- 多服务部署后,登录状态如何统一?
- JWT 虽然无状态,但怎么做主动下线、踢人、续期?
- 登录接口、短信接口、核心业务接口怎么限流?
- 限流是按 IP、按用户,还是按接口维度?
这篇文章我就带你做一个能落地、可运行的方案:
用 Spring Boot + Redis + JWT 实现统一登录鉴权,并补上一套常用的接口限流能力。
这不是“只讲概念”的文章,我会从背景、原理、代码、排错到最佳实践完整走一遍。你可以直接把代码骨架抄到项目里,再按自己业务扩展。
一、背景与问题
先说结论:
- JWT 适合做登录态载体,便于分布式部署;
- Redis 适合做登录态缓存、黑名单、限流计数;
- Spring Boot 拦截器/过滤器 适合统一做鉴权与限流切面。
但如果只是“JWT + 解析 token”,很快会遇到几个实际问题:
1. 纯 JWT 无法很好处理“主动失效”
JWT 自带过期时间,但如果你想实现:
- 用户修改密码后,旧 token 失效
- 管理员踢掉某个账号
- 单端登录,新登录顶掉旧登录
只靠 JWT 本身就不够了。
这时通常会把 token 或 session 信息放入 Redis 做一层服务端校验。
2. 登录态和权限控制不应散落在 Controller
如果每个接口都手写:
String token = request.getHeader("Authorization");
项目一大,维护成本会非常高。更好的方式是:
- 登录态解析统一放在过滤器/拦截器
- 用户上下文统一注入
- 需要登录、需要角色权限的接口通过注解控制
3. 限流不能只靠网关,应用层也需要兜底
很多团队已经有 Nginx、Gateway 或云产品限流,但业务里依然经常需要:
- 登录接口:按 IP 限流,防暴力破解
- 短信验证码接口:按手机号 + IP 双重限流
- 用户接口:按用户 ID 限流
- 核心接口:按接口路径做细粒度限流
这类规则离业务很近,放在 Spring Boot 应用层做会更灵活。
二、前置知识与环境准备
本文示例环境:
- JDK 17
- Spring Boot 3.2.x
- Spring Web
- Spring Data Redis
- jjwt 0.12.x
- Redis 7.x
- Maven
Maven 依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
application.yml
server:
port: 8080
spring:
data:
redis:
host: 127.0.0.1
port: 6379
timeout: 3000ms
security:
jwt:
secret: "ReplaceWithYourOwnSecretKeyAtLeast32BytesLong!"
expire-seconds: 7200
issuer: "demo-auth"
rate-limit:
enabled: true
三、核心原理
这一套方案的核心思路可以概括成一句话:
JWT 负责身份声明,Redis 负责状态控制,过滤器负责统一拦截,Lua/原子计数负责限流。
四、整体架构图
flowchart LR
A[客户端] --> B[Spring Boot API]
B --> C[JWT过滤器]
C --> D{Token合法?}
D -- 否 --> E[返回401]
D -- 是 --> F[Redis校验会话]
F --> G{会话存在?}
G -- 否 --> E
G -- 是 --> H[注入用户上下文]
H --> I[限流拦截器]
I --> J{是否超限?}
J -- 是 --> K[返回429]
J -- 否 --> L[Controller业务处理]
这个架构里有两个关键链路:
-
登录链路
- 用户输入账号密码
- 服务端校验通过
- 生成 JWT
- Redis 记录当前 token/session
- 返回 token 给客户端
-
请求链路
- 客户端带 token 发请求
- 服务端验签、验过期
- 再去 Redis 校验会话是否仍有效
- 校验通过后放行
- 同时对接口做限流
五、鉴权流程时序图
sequenceDiagram
participant U as 用户
participant A as Spring Boot应用
participant R as Redis
U->>A: POST /auth/login 用户名密码
A->>A: 校验账号密码
A->>A: 生成JWT(token, userId, jti)
A->>R: SET login:token:{token} userInfo EX 7200
A-->>U: 返回token
U->>A: GET /user/profile + Authorization: Bearer token
A->>A: 解析JWT、校验签名/过期
A->>R: GET login:token:{token}
R-->>A: userInfo
A->>A: 注入登录用户上下文
A-->>U: 返回业务数据
六、登录与会话设计
1. 为什么不是“只用 JWT”
很多教程会说 JWT 是无状态的,服务端不用存 session。
这句话没错,但在真实项目里,完全无状态往往不够用。
所以我们采用折中方案:
- JWT 中保存必要身份信息:
userId、username、jti - Redis 中保存会话状态:
token -> userInfo - 请求时必须同时满足:
- JWT 合法
- Redis 中存在该 token 对应会话
这样就具备了:
- 可主动失效
- 可单端登录
- 可延长会话
- 可做黑名单
2. Redis Key 设计建议
login:token:{token} -> 用户会话信息(JSON), TTL=7200
login:user:{userId} -> 当前有效token, TTL=7200
blacklist:token:{token} -> 1, TTL=剩余过期时间
rate_limit:{key} -> count, TTL=窗口秒数
如果你想实现“单端登录”,登录时可以:
- 先查
login:user:{userId}是否存在旧 token - 如果存在,删除旧 token 会话,或加入黑名单
- 再写入新的 token
七、实战代码(可运行)
下面给出一个精简但完整的版本。为了突出主线,用户校验先用内存模拟。
1. 启动类
package com.example.authdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AuthDemoApplication {
public static void main(String[] args) {
SpringApplication.run(AuthDemoApplication.class, args);
}
}
2. 用户信息对象
package com.example.authdemo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser {
private Long userId;
private String username;
private String role;
}
3. JWT 工具类
package com.example.authdemo.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.UUID;
@Component
public class JwtUtil {
private final SecretKey secretKey;
private final long expireSeconds;
private final String issuer;
public JwtUtil(
@Value("${security.jwt.secret}") String secret,
@Value("${security.jwt.expire-seconds}") long expireSeconds,
@Value("${security.jwt.issuer}") String issuer) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expireSeconds = expireSeconds;
this.issuer = issuer;
}
public String generateToken(Long userId, String username, String role) {
Date now = new Date();
Date expireAt = new Date(now.getTime() + expireSeconds * 1000);
String jti = UUID.randomUUID().toString();
return Jwts.builder()
.issuer(issuer)
.subject(String.valueOf(userId))
.claim("username", username)
.claim("role", role)
.id(jti)
.issuedAt(now)
.expiration(expireAt)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public Claims parse(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
public long getExpireSeconds() {
return expireSeconds;
}
}
4. Redis 配置
package com.example.authdemo.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
RedisSerializer<String> stringSerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setValueSerializer(jsonSerializer);
template.setHashKeySerializer(stringSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
5. 登录服务
package com.example.authdemo.service;
import com.example.authdemo.model.LoginUser;
import com.example.authdemo.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
public class AuthService {
private final JwtUtil jwtUtil;
private final RedisTemplate<String, Object> redisTemplate;
public Map<String, Object> login(String username, String password) {
// 演示用:模拟用户校验
if (!"admin".equals(username) || !"123456".equals(password)) {
throw new RuntimeException("用户名或密码错误");
}
Long userId = 1L;
String role = "ADMIN";
String newToken = jwtUtil.generateToken(userId, username, role);
long expireSeconds = jwtUtil.getExpireSeconds();
String userTokenKey = "login:user:" + userId;
Object oldToken = redisTemplate.opsForValue().get(userTokenKey);
// 单端登录:删除旧token会话
if (oldToken != null) {
redisTemplate.delete("login:token:" + oldToken);
}
LoginUser loginUser = new LoginUser(userId, username, role);
redisTemplate.opsForValue().set("login:token:" + newToken, loginUser, expireSeconds, TimeUnit.SECONDS);
redisTemplate.opsForValue().set(userTokenKey, newToken, expireSeconds, TimeUnit.SECONDS);
return Map.of(
"token", newToken,
"expireSeconds", expireSeconds,
"user", loginUser
);
}
public void logout(String token) {
Object session = redisTemplate.opsForValue().get("login:token:" + token);
if (session instanceof LoginUser loginUser) {
redisTemplate.delete("login:user:" + loginUser.getUserId());
}
redisTemplate.delete("login:token:" + token);
}
public LoginUser getLoginUser(String token) {
Object obj = redisTemplate.opsForValue().get("login:token:" + token);
if (obj == null) {
return null;
}
return (LoginUser) obj;
}
public void refreshToken(String token) {
Object obj = redisTemplate.opsForValue().get("login:token:" + token);
if (obj == null) {
return;
}
LoginUser loginUser = (LoginUser) obj;
long expireSeconds = jwtUtil.getExpireSeconds();
redisTemplate.opsForValue().set("login:token:" + token, loginUser, expireSeconds, TimeUnit.SECONDS);
redisTemplate.opsForValue().set("login:user:" + loginUser.getUserId(), token, expireSeconds, TimeUnit.SECONDS);
}
}
6. 用户上下文
package com.example.authdemo.context;
import com.example.authdemo.model.LoginUser;
public class UserContext {
private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>();
public static void set(LoginUser loginUser) {
HOLDER.set(loginUser);
}
public static LoginUser get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}
7. JWT 鉴权过滤器
package com.example.authdemo.filter;
import com.example.authdemo.context.UserContext;
import com.example.authdemo.model.LoginUser;
import com.example.authdemo.service.AuthService;
import com.example.authdemo.util.JwtUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final AuthService authService;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
return uri.startsWith("/auth/login");
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"未登录或Token缺失\"}");
return;
}
String token = authHeader.substring(7);
Claims claims = jwtUtil.parse(token);
LoginUser loginUser = authService.getLoginUser(token);
if (loginUser == null) {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"登录已失效\"}");
return;
}
// 可选:滑动续期
authService.refreshToken(token);
UserContext.set(loginUser);
filterChain.doFilter(request, response);
} catch (Exception e) {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"Token非法或已过期\"}");
} finally {
UserContext.clear();
}
}
}
8. 限流注解
package com.example.authdemo.ratelimit;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
int seconds();
int maxCount();
LimitType type() default LimitType.IP;
String key() default "";
}
package com.example.authdemo.ratelimit;
public enum LimitType {
IP,
USER,
URI
}
9. 限流拦截器
这里用 Redis 原子自增实现一个固定窗口限流。不是最复杂的方案,但够稳定,也好理解。
package com.example.authdemo.ratelimit;
import com.example.authdemo.context.UserContext;
import com.example.authdemo.model.LoginUser;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {
private final StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
if (rateLimit == null) {
return true;
}
String limitKey = buildKey(request, rateLimit);
Long count = stringRedisTemplate.opsForValue().increment(limitKey);
if (count != null && count == 1) {
stringRedisTemplate.expire(limitKey, rateLimit.seconds(), TimeUnit.SECONDS);
}
if (count != null && count > rateLimit.maxCount()) {
response.setStatus(429);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":429,\"message\":\"请求过于频繁,请稍后再试\"}");
return false;
}
return true;
}
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
return switch (rateLimit.type()) {
case IP -> "rate_limit:ip:" + getClientIp(request) + ":" + request.getRequestURI();
case USER -> {
LoginUser user = UserContext.get();
String userId = user == null ? "anonymous" : String.valueOf(user.getUserId());
yield "rate_limit:user:" + userId + ":" + request.getRequestURI();
}
case URI -> "rate_limit:uri:" + request.getRequestURI();
};
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip != null && !ip.isBlank()) {
return ip.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
10. Web 配置注册拦截器
package com.example.authdemo.config;
import com.example.authdemo.ratelimit.RateLimitInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final RateLimitInterceptor rateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor).addPathPatterns("/**");
}
}
11. 控制器
package com.example.authdemo.controller;
import com.example.authdemo.context.UserContext;
import com.example.authdemo.model.LoginUser;
import com.example.authdemo.ratelimit.LimitType;
import com.example.authdemo.ratelimit.RateLimit;
import com.example.authdemo.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/auth/login")
@RateLimit(seconds = 60, maxCount = 5, type = LimitType.IP)
public Map<String, Object> login(@RequestBody LoginRequest request) {
return authService.login(request.getUsername(), request.getPassword());
}
@PostMapping("/auth/logout")
public Map<String, Object> logout(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
String token = authHeader.substring(7);
authService.logout(token);
return Map.of("message", "退出成功");
}
@GetMapping("/user/profile")
@RateLimit(seconds = 10, maxCount = 20, type = LimitType.USER)
public Map<String, Object> profile() {
LoginUser user = UserContext.get();
return Map.of(
"userId", user.getUserId(),
"username", user.getUsername(),
"role", user.getRole()
);
}
@GetMapping("/public/ping")
@RateLimit(seconds = 5, maxCount = 3, type = LimitType.URI)
public Map<String, Object> ping() {
return Map.of("message", "pong");
}
@Data
public static class LoginRequest {
private String username;
private String password;
}
}
八、接口调用示例
1. 登录
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
返回示例:
{
"token": "eyJhbGciOiJIUzI1NiJ9....",
"expireSeconds": 7200,
"user": {
"userId": 1,
"username": "admin",
"role": "ADMIN"
}
}
2. 访问受保护接口
curl http://localhost:8080/user/profile \
-H "Authorization: Bearer 你的token"
3. 退出登录
curl -X POST http://localhost:8080/auth/logout \
-H "Authorization: Bearer 你的token"
九、限流状态变化图
stateDiagram-v2
[*] --> 正常请求
正常请求 --> 计数加一
计数加一 --> 未超限: count <= max
计数加一 --> 已超限: count > max
未超限 --> 放行业务
已超限 --> 返回429
放行业务 --> [*]
返回429 --> [*]
十、逐步验证清单
如果你是边学边做,我建议按这个顺序验证,不容易乱。
第一步:只验证登录是否正常
- Redis 启动
- 调
/auth/login - 拿到 token
- 看 Redis 是否写入:
login:token:{token}login:user:1
第二步:验证鉴权过滤器
- 不带 token 请求
/user/profile,应返回 401 - 带正确 token,应返回用户信息
- Redis 中手动删掉
login:token:{token},再请求应返回 401
第三步:验证单端登录
- 连续登录两次,拿到两个不同 token
- 用第一个 token 调
/user/profile,应失效 - 用第二个 token 调用,应成功
第四步:验证限流
- 连续调用
/public/ping - 超过阈值后应返回 429
十一、常见坑与排查
这部分很重要。我自己做这类功能时,真正花时间的往往不是“写代码”,而是“为什么明明写了却不生效”。
1. Redis 里取出来的对象类型不对
现象
明明存的是 LoginUser,取出来后强转报错。
原因
Redis 序列化方式不一致,或者你一个地方用 RedisTemplate<String, Object>,另一个地方又用字符串模板写入。
排查建议
- 确认 key/value serializer 是否统一
- 开发期尽量固定 JSON 序列化
- 若跨服务共享 Redis,建议显式定义 DTO 结构,不要依赖默认序列化细节
2. JWT secret 太短导致启动或签名失败
现象
启动时报签名 key 相关异常,或者生成 token 时报错。
原因
HS256 对密钥长度有要求,太短不安全也可能不被接受。
排查建议
- secret 至少 32 字节
- 不要把测试环境的短 secret 带到生产
3. 过滤器执行了,但接口还是拿不到用户信息
现象
UserContext.get() 返回 null。
原因
常见有两个:
- 请求根本没经过该过滤器
ThreadLocal被错误清理或异步线程中取不到
排查建议
- 确认过滤器已被 Spring 托管
- 确认请求路径没有被
shouldNotFilter排除 - 如果用了异步任务、线程池,
ThreadLocal不会自动传递
这一点我踩过坑:主线程里有登录用户,异步线程里直接 null。因为
ThreadLocal是线程级别的,不是请求全局共享。
4. 限流在本地正常,上线后全乱了
现象
有些用户明明没怎么请求,却频繁被限;或者 IP 限流失效。
原因
大概率是 真实客户端 IP 没取对。
线上一般有 Nginx 或网关转发,request.getRemoteAddr() 看到的可能只是代理层地址。
排查建议
- 优先读取
X-Forwarded-For - 确保反向代理正确透传头部
- 不要盲信客户端自己传来的 IP,生产中要配合可信代理链
5. 滑动续期导致 Redis 压力变大
现象
每次请求都刷新 token TTL,Redis QPS 上升明显。
原因
“每请求必续期”在高并发下会产生大量写操作。
优化建议
改成“临近过期才续期”,比如剩余 TTL 小于 20 分钟时再刷新一次,而不是每次都刷。
十二、安全/性能最佳实践
这一部分决定了你的方案能不能从“能跑”走到“能上线”。
1. Token 不要存太多敏感信息
JWT 虽然签名后防篡改,但默认不是加密的。
所以不要在 token 里放:
- 明文手机号
- 身份证号
- 密码摘要
- 复杂权限全量数据
推荐只放最小必要字段:
- userId
- username(可选)
- role / scope(简化版)
- jti
2. 登录密码必须加密存储
本文为了演示用了固定账号密码,但生产里必须:
- 使用
BCrypt/Argon2 - 不要自己手写 MD5
- 登录失败次数过多要触发额外防护
3. 限流维度要按场景拆开
不要指望“一套限流参数打天下”。
常见建议
- 登录接口:按 IP 限流
- 验证码接口:按手机号 + IP 双限流
- 用户中心接口:按用户 ID 限流
- 公共查询接口:按 URI 或 appKey 限流
如果业务更复杂,可以扩展 LimitType:
PHONE,
API_KEY,
TENANT
4. 固定窗口够用,但别神化它
本文使用的是固定窗口限流,优点是:
- 简单
- 好实现
- 大多数后台接口够用
但它也有边界问题:
- 窗口切换瞬间可能出现突刺
- 无法像令牌桶那样更平滑
如果你面对的是高并发、强实时场景,可以升级到:
- 滑动窗口
- 漏桶
- 令牌桶
- Redis Lua 脚本原子限流
5. 对鉴权失败和限流失败做统一响应
别让系统返回一堆风格不一致的信息。建议统一成:
- 401:未认证、token 无效、会话过期
- 403:已登录但无权限
- 429:请求过于频繁
这样前端和调用方更容易做兼容处理。
6. Redis Key 要有前缀和 TTL
这是很容易被忽略的点。
一个规范的 Redis key 会让你排障效率提升很多。
例如:
login:token:xxx
login:user:1
rate_limit:ip:127.0.0.1:/auth/login
并且:
- 登录态必须设置 TTL
- 限流计数必须设置 TTL
- 黑名单也要根据 token 剩余时间设置 TTL
否则 Redis 会慢慢堆积无用 key。
7. 生产环境建议配合网关做“双层防护”
应用层限流很好用,但不应该成为唯一屏障。我的建议是:
- 网关层:做粗粒度限流、IP 黑白名单、统一审计
- 应用层:做细粒度业务限流、登录态校验、用户级别限制
这样既能抗流量,也能贴近业务。
十三、可以继续扩展的方向
如果你打算把这套方案继续做强,可以考虑这些增强点:
1. 权限注解
比如实现:
@RequireRole("ADMIN")
在拦截器或 AOP 中读取用户角色,做角色判断。
2. 黑名单机制
对于已签发但需要立刻失效的 token,可以将其加入:
blacklist:token:{token}
请求时额外校验是否在黑名单中。
3. 刷新 token 双 token 机制
常见做法:
- access_token:短期有效
- refresh_token:长期有效
这样既安全,又能提升用户体验。
4. Lua 脚本限流
把 INCR + EXPIRE 合并成 Lua,一次原子执行,更稳妥。
十四、总结
这套 Spring Boot + Redis + JWT 的统一登录鉴权与接口限流方案,核心价值在于:
- JWT 解决分布式下的身份传递
- Redis 解决会话控制、主动失效、限流计数
- 过滤器/拦截器 解决统一接入
- 注解式限流 让业务接口更容易维护
如果你现在要落地,我建议按这个优先级推进:
- 先把 JWT + Redis 会话校验 做起来
- 再加 登录接口和核心接口限流
- 最后补 单端登录、滑动续期、黑名单、角色权限
边界上也要清楚:
- 中小型后台系统:本文方案已经很实用
- 高并发开放平台:建议再升级为 Lua 限流、双 token、权限中心、网关联动
- 强安全场景:要进一步补充设备指纹、风控、异常行为审计
如果你把本文代码先跑通,再按自己的业务对象、权限模型和限流维度替换,基本就能形成一套靠谱的生产骨架。
说直白点:先别追求“最完整架构”,先把“登录能控、接口不炸”做扎实。