ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot中JWT+Redis token校验的原理与实战避坑指南

SpringBoot中JWT+Redis token校验的原理与实战避坑指南 1. 这不是“加个依赖就完事”的登录校验——SpringBoot里JWTRedis的真正战场在哪你肯定见过这样的代码PostMapping(/login)里调用jwtUtil.generateToken(user)再把 token 塞进响应头前端存 localStorage每次请求带Authorization: Bearer xxx。看起来很美但上线三个月后运维半夜打电话说用户反馈“刚登完就掉线”“换台手机登录老设备还在自动续签”安全团队发邮件问“你们的 token 黑名单机制能扛住批量撞库吗”——这时候你才意识到JWT 不是银弹它和 Redis 的组合本质是一场对状态管理权、时效控制力、安全兜底能力的三重博弈。我做过 7 个 SpringBoot 项目其中 4 个因 JWT 设计缺陷被重写过登录模块。最典型的一次某政务系统上线后用户投诉“退出登录后 5 分钟内仍能访问敏感页面”。查日志发现JWT 本身没过期2 小时但 Redis 里的黑名单 key 因为没设 TTL 被撑爆内存导致redisTemplate.hasKey(blacklist:token)永远返回 false。这不是配置问题是设计思维断层——把 JWT 当成纯无状态方案却忘了业务场景里“强制下线”“密码修改即失效”这些刚需根本无法靠 JWT 自身的exp字段解决。核心关键词SpringBoot、jwt、redis、token校验、原理解析拆开看SpringBoot是容器和粘合剂它让整合变简单但也容易掩盖底层逻辑JWT是令牌载体它的签名防篡改HS256/RSA是基础但 payload 里放什么、怎么放、谁来验证决定安全水位Redis是状态仲裁者它不存储完整 token而是管“这个 token 是否已被废止”或“这个用户当前有效会话数是否超限”token校验不是if (token ! null jwtUtil.validate(token))一行代码而是包含解析、白名单/黑名单检查、刷新策略、异常熔断的完整链路原理解析必须落到字节码层面比如JwtParserBuilder.setSigningKey()传入的 SecretKey 如何参与 HMAC 计算JwsClaims对象的getHeader()和getBody()怎么映射到 Base64Url 编码的三段字符串Redis 的SET key value EX 3600 NX命令为什么比SET key value多出的NX参数能避免并发覆盖。这篇文章写给两类人一是刚写完spring-boot-starter-web能跑 Hello World 的新手想搞懂“为什么别人教程里要加 Redis我删掉它也能登录”二是已上线项目遇到 token 异常、Redis 内存暴涨、JWT 被逆向解析出敏感字段的中级开发者需要知道每个配置项背后的代价。全文不讲 Maven 依赖怎么贴不教 Redis Desktop Manager 怎么连只聚焦一个动作当你在SecurityConfig里写下.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)时背后发生了什么以及你漏掉了哪些致命细节。2. 为什么必须用 RedisJWT 的“无状态”神话与现实撕裂点2.1 JWT 的设计初衷用签名换去中心化而非消灭状态JWTJSON Web Token标准 RFC 7519 明确写道“A JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties.” —— 它的核心价值是紧凑性compact和URL 安全性URL-safe不是“绝对无状态”。很多人误以为 JWT 天然适合分布式是因为它把用户身份信息claims加密打包进 token 本身服务端无需查数据库就能验证合法性。这没错但前提是你接受 token 在有效期内永远有效且无法单点废止。我们来解构一个典型 JWTeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c第一段eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9是 headerBase64Url 解码后为{alg:HS256,typ:JWT}第二段eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ是 payload解码得{sub:1234567890,name:John Doe,iat:1516239022}第三段SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c是 signature由HMACSHA256(base64UrlEncode(header) . base64UrlEncode(payload), secret)生成。关键点来了signature 只保证前两段不被篡改不保证 payload 里的exp字段不被客户端恶意延长。如果前端拿到 token 后用工具修改 payload 中的exp:17356896002025 年再重新计算 signature只要 secret 泄露或使用弱密钥攻击者就能伪造合法 token。这就是为什么jwt如何防止数据被串改成为高频搜索词——答案不是 JWT 本身而是 secret 管理、算法选择HS256 vs RS256、以及服务端对exp和nbfnot before的严格校验。2.2 Redis 的不可替代性填补 JWT 的三大能力缺口当业务需求超出 JWT 原生能力时Redis 就成了必选项。我画了张对比表列出现实中最常踩坑的三个场景场景JWT 原生能力Redis 补位方式实际后果无 Redis强制退出登录无法实现。token 在exp前始终有效存储 token 的 SHA256 哈希值校验前先查EXISTS blacklist:hash用户点击“退出”后token 仍可继续使用至过期存在越权风险密码修改即时生效无法实现。旧 token 仍有效密码变更时将用户 ID 作为 key时间戳作为 value 写入user_last_password_change:123校验时比对lastPasswordChangeTime token.iat用户改密后旧设备仍可用原 token 登录违背最小权限原则单设备登录限制无法实现。同一账号多端登录产生多个有效 token维护user_sessions:123Set 类型每次登录SADD新 token登出SREM校验时SCARD判断数量用户 A 在手机登录用户 B 在电脑登录两者互不影响但企业级应用要求“后登录踢前登录”提示别用 Redis 存完整 tokentoken 可能含敏感信息如邮箱且长度不定易造成内存碎片。标准做法是存DigestUtils.md5DigestAsHex(token.getBytes())的哈希值长度固定 32 字符内存占用可控。更隐蔽的问题是Redis 数据类型选型。很多教程直接redisTemplate.opsForValue().set(token:token, valid, 30, TimeUnit.MINUTES)这看似合理但当你要支持“踢出指定设备”时就得遍历所有 key 查找匹配O(n) 复杂度直接拖垮性能。正确姿势是用Hash 结构存user_tokens:123field 为设备标识如android_abc123value 为 token 哈希用Sorted Set存user_token_scores:123score 为登录时间戳member 为 token 哈希方便按时间淘汰最老会话用String存blacklist:hash配合EX设置 TTL避免手动清理。2.3 SpringBoot 版本陷阱为什么springboot版本太高会导致 JWT 故障SpringBoot 2.6 默认禁用了循环引用spring.main.allow-circular-referencesfalse而很多 JWT 工具类如JwtTokenUtil依赖RedisTemplate和UserDetailsService的双向注入。如果你的JwtAuthenticationFilter构造器里同时注入了jwtUtil和redisTemplate而jwtUtil又依赖redisTemplate就会触发BeanCurrentlyInCreationException。解决方案不是降版本而是重构依赖链将RedisTemplate注入改为ObjectProviderRedisTemplate延迟获取或用Lazy注解修饰jwtUtil的注入点更彻底的做法把 token 校验逻辑从 Filter 移到OncePerRequestFilter的doFilterInternal方法里用ApplicationContext.getBean(RedisTemplate.class)动态获取虽不优雅但能破局。另一个坑是springboot框架升级到 3.x 后javax.*包全面替换为jakarta.*如果你用的jjwt-api版本低于 0.11.5io.jsonwebtoken.security.SignatureException会因包路径变化而捕获不到导致 token 校验失败时抛出NoClassDefFoundError而非预期的AuthenticationException。这是springboot面试题里常考的兼容性考点——版本升级不是改 pom.xml 就完事要逐行检查异常处理链路。3. 核心细节拆解从 JWT 解析到 Redis 校验的每一步都藏着雷区3.1 JWT 解析环节你以为的“验证通过”可能只是签名没爆JWT 校验绝不是Jwts.parser().setSigningKey(secret).parseClaimsJws(token)一行搞定。我见过最危险的写法是try { JwsClaims claimsJws Jwts.parser().setSigningKey(secret).parseClaimsJws(token); return claimsJws.getBody(); } catch (Exception e) { return null; // ❌ 错误不同异常需区别处理 }这里埋了三个雷雷1异常吞没。ExpiredJwtException过期、UnsupportedJwtException算法不支持、MalformedJwtException格式错误、SignatureException签名无效都继承自JwtException但业务逻辑完全不同过期应返回 401签名无效要告警格式错误可能是前端传参错误。统一return null会让前端无法区分原因调试成本翻倍。雷2密钥硬编码。setSigningKey(mySecretKey123)直接写死生产环境必须从application.yml读取且要用Value(${jwt.secret:defaultSecret})提供默认值防启动失败。雷3算法未限定。setSigningKey()默认接受所有算法如果攻击者构造alg:none的 tokenRFC 7519 允许签名段为空服务端可能跳过校验。必须显式指定Jwts.parserBuilder().setSigningKey(key).requireAlgorithm(HS256)。正确写法应分层处理public Claims parseToken(String token) { try { // 1. 预校验长度、格式 if (!token.startsWith(Bearer )) { throw new IllegalArgumentException(Token must start with Bearer ); } String realToken token.substring(7); // 2. 解析并验证签名、过期、发行者等 return Jwts.parserBuilder() .setSigningKey(getSignInKey()) // 从配置读取 .requireIssuer(my-app) // 强制 issuer .requireAudience(web-client) // 强制 audience .build() .parseClaimsJws(realToken) .getBody(); } catch (ExpiredJwtException e) { log.warn(JWT expired: {}, e.getMessage()); throw new TokenExpiredException(Token expired); } catch (SignatureException e) { log.error(Invalid JWT signature, e); throw new InvalidTokenException(Invalid signature); } catch (MalformedJwtException e) { log.warn(Invalid JWT token: {}, e.getMessage()); throw new InvalidTokenException(Malformed token); } catch (UnsupportedJwtException e) { log.error(Unsupported JWT token, e); throw new InvalidTokenException(Unsupported algorithm); } catch (IllegalArgumentException e) { log.warn(JWT claims string is empty, e); throw new InvalidTokenException(Empty token); } }3.2 Redis 校验环节不是“查一下有没有”而是“查什么、怎么查、查完干啥”Redis 校验不是简单的redisTemplate.hasKey(blacklist:tokenHash)。我们以“强制退出”为例完整链路如下登出接口PostMapping(/logout) public Result logout(RequestHeader(Authorization) String authHeader) { String token authHeader.substring(7); String tokenHash DigestUtils.md5DigestAsHex(token.getBytes()); // 关键设置过期时间 token 剩余有效期避免长期占用内存 long expireSeconds jwtUtil.getRemainingValidSeconds(token); redisTemplate.opsForValue() .set(blacklist: tokenHash, invalid, expireSeconds, TimeUnit.SECONDS); return Result.success(); }校验过滤器public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims jwtUtil.parseToken(token); String tokenHash DigestUtils.md5DigestAsHex(token.getBytes()); // 1. 检查黑名单Redis Boolean isBlacklisted redisTemplate.hasKey(blacklist: tokenHash); if (Boolean.TRUE.equals(isBlacklisted)) { throw new InvalidTokenException(Token is blacklisted); } // 2. 检查用户密码更新时间Redis String lastChangeKey user_last_password_change: claims.getSubject(); String lastChangeStr (String) redisTemplate.opsForValue().get(lastChangeKey); if (lastChangeStr ! null) { long lastChangeTime Long.parseLong(lastChangeStr); long tokenIssueTime claims.getIssuedAt().getTime() / 1000; if (lastChangeTime tokenIssueTime) { throw new InvalidTokenException(Password changed, token invalidated); } } // 3. 构建 Authentication 并放入 SecurityContext UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( claims.getSubject(), null, getAuthorities(claims)); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (TokenExpiredException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write(Token expired); return; } catch (InvalidTokenException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write(e.getMessage()); return; } } chain.doFilter(request, response); }注意redisTemplate.hasKey()在 Redis Cluster 模式下有性能问题因为它是 O(1) 但需跨节点查询。高并发场景建议改用redisTemplate.execute()执行 Lua 脚本原子性完成“查业务逻辑”避免竞态条件。3.3 Token 续签Refresh Token的落地难点不是多发一个 token 就行jwt实现token续签是高频需求但多数实现有致命缺陷。常见错误方案❌ 方案1登录时发两个 tokenaccess_token 30 分钟refresh_token 7 天前端用 access_token快过期时用 refresh_token 换新 access_token。问题refresh_token 若泄露攻击者可无限续签且无法主动废止。❌ 方案2每次请求都刷新 access_token 有效期。问题频繁写 Redis 增加压力且 token 有效期不断延长违背“短时效”安全原则。正确方案是滑动窗口 Redis 记录access_token 有效期设为 30 分钟但每次成功校验后检查last_refresh_time:userId的值如果距上次刷新超过 15 分钟则生成新 token 并更新 Redisrefresh_token 存储在 HttpOnly Cookie 中防 XSS且绑定设备指纹User-Agent IP 哈希每次使用后立即失效DEL refresh_token:fingerprint。代码关键点// 校验通过后执行续签 String userId claims.getSubject(); String lastRefreshKey last_refresh_time: userId; Long lastRefresh (Long) redisTemplate.opsForValue().get(lastRefreshKey); long now System.currentTimeMillis() / 1000; if (lastRefresh null || now - lastRefresh 900) { // 15分钟 String newToken jwtUtil.generateToken(userId); // 更新最后刷新时间 redisTemplate.opsForValue().set(lastRefreshKey, now, 7, TimeUnit.DAYS); // 响应头返回新 token前端需监听并更新 response.setHeader(X-Access-Token, newToken); }4. 实操全流程从零搭建可落地的 JWTRedis 校验体系4.1 环境准备与依赖配置避开redis下载官网和redis windows 下载的坑SpringBoot 项目整合 Redis首要问题是客户端选型。redis相关热词里redis desktop manager和another redis desktop manager说明可视化工具需求旺盛但生产环境必须用 Jedis 或 Lettuce。我推荐 LettuceSpringBoot 2.0 默认因为支持异步非阻塞 I/O连接复用率高内置 Redis Cluster、Sentinel 支持与 Spring Cache 抽象层无缝集成。pom.xml关键依赖!-- SpringBoot Web -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Spring Security -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency !-- JWT -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency !-- Redis -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- 连接池Lettuce 默认用 Netty无需额外池 -- dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependencyapplication.yml配置要点spring: redis: host: 127.0.0.1 port: 6379 password: your_password # 生产环境必须设密码 database: 0 lettuce: pool: max-active: 20 max-idle: 10 min-idle: 0 max-wait: 1000ms # 关键禁用 Redis 默认序列化器避免乱码 cache: redis: time-to-live: 3600000 # 1小时全局 TTL # JWT 配置 jwt: secret: your-256-bit-secret-key-here # 至少 32 字符用 openssl rand -hex 32 生成 expiration: 1800 # 30分钟单位秒 refresh-expiration: 604800 # 7天refresh token 有效期提示redis安装配置时Windows 下用redis-server.exe redis.windows.conf启动Linux 下用systemctl start redis。但开发环境强烈建议用 Dockerdocker run -d --name redis -p 6379:6379 -e REDIS_PASSWORDyourpass redis:7-alpine避免本地环境差异。4.2 核心组件编码手把手写出可复用的 JwtUtil 和 RedisServiceJwtUtil.java精简版含关键注释Component Slf4j public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expiration}) private long expiration; // 1. 生成 tokensubject 为用户IDclaims 可扩展角色等 public String generateToken(String subject) { return Jwts.builder() .setSubject(subject) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expiration * 1000)) .signWith(getSignInKey(), SignatureAlgorithm.HS256) .compact(); } // 2. 解析 token见 3.1 节的完整异常处理 public Claims parseToken(String token) { // ... 上文已详述此处省略 } // 3. 获取剩余有效期秒 public long getRemainingValidSeconds(String token) { try { Claims claims parseToken(token); return (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000; } catch (Exception e) { return 0; } } // 4. 获取密钥避免重复创建 private Key getSignInKey() { byte[] keyBytes Decoders.BASE64.decode(secret); return Keys.hmacShaKeyFor(keyBytes); } }RedisService.java封装常用操作Service Slf4j public class RedisService { Autowired private RedisTemplateString, Object redisTemplate; // 1. 存入黑名单带 TTL public void setBlacklist(String tokenHash, long expireSeconds) { redisTemplate.opsForValue() .set(blacklist: tokenHash, invalid, expireSeconds, TimeUnit.SECONDS); } // 2. 检查是否在黑名单 public boolean isBlacklisted(String tokenHash) { return Boolean.TRUE.equals( redisTemplate.hasKey(blacklist: tokenHash)); } // 3. 设置用户密码最后修改时间 public void setUserLastPasswordChange(String userId, long timestamp) { redisTemplate.opsForValue() .set(user_last_password_change: userId, String.valueOf(timestamp)); } // 4. 获取用户密码最后修改时间 public Long getUserLastPasswordChange(String userId) { String value (String) redisTemplate.opsForValue() .get(user_last_password_change: userId); return value null ? 0L : Long.parseLong(value); } // 5. 批量删除用户所有会话登出所有设备 public void logoutAllDevices(String userId) { // 删除黑名单相关 key如果有 redisTemplate.delete(blacklist:*); // 删除密码变更 key redisTemplate.delete(user_last_password_change: userId); // 删除会话统计 key redisTemplate.delete(user_sessions: userId); } }JwtAuthenticationFilter.java安全过滤器核心Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtUtil jwtUtil; Autowired private RedisService redisService; Autowired private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { filterChain.doFilter(request, response); return; } String token authHeader.substring(7); String username null; try { username jwtUtil.parseToken(token).getSubject(); } catch (Exception e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write(Invalid token: e.getMessage()); return; } // 校验 Redis 状态 String tokenHash DigestUtils.md5DigestAsHex(token.getBytes()); if (redisService.isBlacklisted(tokenHash)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write(Token is blacklisted); return; } // 加载用户详情 UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); // 续签逻辑见 3.3 节 handleTokenRefresh(username, token, response); filterChain.doFilter(request, response); } private void handleTokenRefresh(String userId, String token, HttpServletResponse response) { // 实现滑动窗口续签此处省略具体代码 } }4.3 Security 配置让 Spring Security 真正理解你的 JWTSecurityConfig.java是整个链路的枢纽必须精准控制过滤器顺序Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) // JWT 无需 CSRF但需确保前端禁用 Cookie .sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 强制无状态 .authorizeHttpRequests(authz - authz .requestMatchers(/api/auth/**).permitAll() // 登录注册放行 .requestMatchers(/api/public/**).permitAll() .anyRequest().authenticated() // 其他请求需认证 ) // 关键插入自定义 JWT 过滤器在 UsernamePasswordAuthenticationFilter 之前 .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean public JwtAuthenticationFilter jwtAuthenticationFilter() { return new JwtAuthenticationFilter(); } // 密码编码器即使不用密码登录也需提供 Bean public PasswordEncoder passwordEncoder() { return NoOpPasswordEncoder.getInstance(); // 开发用生产用 BCryptPasswordEncoder } }注意SessionCreationPolicy.STATELESS意味着 Spring Security 不会创建 HttpSession所有状态靠 JWT 和 Redis 维护。如果项目混用 Session如文件上传需单独配置http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)但会增加复杂度。5. 常见问题与排查技巧实录那些让你加班到凌晨的真问题5.1 Redis 内存暴涨不是数据太多而是 TTL 没设对现象上线一周后Redis 内存从 100MB 涨到 2GBINFO memory显示used_memory_human:2.10G但KEYS blacklist:* | wc -l只有 5000 条。根因redisTemplate.opsForValue().set(blacklist:hash, invalid)没设过期时间key 永久存在。排查命令# 查看最大内存和当前使用 redis-cli INFO memory | grep -E (used_memory_human|maxmemory_human) # 找出最占内存的 key 类型 redis-cli --bigkeys # 查看 blacklist 相关 key 的 TTL redis-cli KEYS blacklist:* | head -10 | xargs -I {} redis-cli TTL {}解决方案所有set操作必须带TimeUnit参数对黑名单 keyTTL 设为token 剩余有效期 5 分钟防时钟漂移用redisTemplate.expire()单独设置 TTL 时确保 key 存在否则返回 false。5.2 JWT 解析失败io.jsonwebtoken.security.SignatureException的真实来源现象测试环境一切正常生产环境大量SignatureException日志显示JWT signature does not match locally computed signature。排查步骤检查jwt.secret配置生产环境是否用了\n换行符YAML 中secret: abc\n123会被解析为abc 换行 123而 Java 字符串中\n是字符不是换行检查密钥长度HS256 要求密钥至少 256 位32 字节mySecret只有 8 字节强度不足检查算法一致性前端用 RS256 生成后端用 HS256 验证。修复方法密钥用 Base64 编码存储secret: bXlTZWNyZXQxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAJava 中Decoders.BASE64.decode(secret)统一算法推荐 HS256简单或 RS256更安全需私钥签名、公钥验证。5.3 多实例部署下的 Redis 连接问题redis主从配置失效现象K8s 部署 3 个 Pod用户 A 在 Pod1 登录Pod2 校验 token 时提示Token is blacklisted失败但实际未登出。根因Redis 主从同步有延迟SET命令在主节点执行后从节点可能未及时同步导致读从节点的校验失败。解决方案读写分离所有写操作登出、改密走主节点读操作校验黑名单也走主节点Lettuce 配置spring: redis: lettuce: cluster: read-from: MASTER # 强制读主或更优方案用 Redis ClusterSLOT分配保证 key 路由到同一节点。5.4 SpringBoot 与 Redis 的序列化陷阱redis序列化导致乱码现象Redis Desktop Manager 里看到user_last_password_change:123的 value 是aced0005737200116a6176612e6c616e672e4c6f6e67...无法阅读。原因RedisTemplate默认用JdkSerializationRedisSerializer序列化成二进制。解决自定义RedisTemplateBean用StringRedisTemplate或配置 JSON 序列化器Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // key 用 String 序列化器 template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); // value 用 Jackson 序列化器支持对象 ObjectMapper om new ObjectMapper(); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); Jackson2JsonRedisSerializer serializer new Jackson2JsonRedisSerializer(Object.class, om); template.setValueSerializer(serializer); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; }5.5 面试高频题实战redis面试题与springboot面试题的交叉点QJWT 存 Redis 还是存数据库A都不存。Redis 存的是元数据黑名单哈希、密码变更时间不是 token 本身。数据库存用户凭证JWT 是无状态令牌二者职责分离。Q如何实现“同账号多地登录后登录踢前登录”A登录时用user_sessions:123Hash 存储{device1: tokenHash1, device2: tokenHash2}新登录时HGETALL user_sessions:123获取所有旧 token 哈希加入黑名单HSET user_sessions:123 newDevice newTokenHash校验时HGET user_sessions:123 currentDevice确认 token 有效性。QRedis 内存满了怎么办A优先查redis-cli --bigkeys找大 key检查是否有 key 漏设 TTL用MEMORY USAGE key查单个 key 内存配置maxmemory-policy volatile-lruLRU 淘汰带 TTL 的 key。6. 最后分享一个血泪教训JWT 的
返回列表