从电商项目课程设计,搞懂 JWT 鉴权和 Redis 缓存到底在解决什么问题
做课程设计的时候,我们小组完成了电商项目,写完了双层拦截器鉴权和 Redis 缓存,但说实话,当时只是照着敲代码,并不真正理解这两个东西为什么要这样设计。写完之后自己回头梳理了一遍原理,记录下来,也希望能帮到有同样困惑的同学。
一、先搞清楚:我们的鉴权到底是不是"纯 JWT"
很多资料一说 JWT,就是"服务器不用存储任何东西,token 里带着所有信息,验证签名就行,天然适合分布式"。但我们项目里的做法,其实是JWT + Redis 的混合方案,跟教科书里说的"无状态 JWT"不完全一样。先看核心代码:
// 登录时生成 tokenpublicstaticStringgenToken(StringuserId,Stringusername){returnJWT.create().withAudience(userId).sign(Algorithm.HMAC256(username));}// 每次请求都要走的第一层拦截器Stringtoken=request.getHeader("token");Useruser=redisTemplate.opsForValue().get(RedisConstants.USER_TOKEN_KEY+token);if(user==null){thrownewServiceException(Constants.TOKEN_ERROR,"token失效,请重新登陆");}UserHolder.saveUser(user);redisTemplate.expire(RedisConstants.USER_TOKEN_KEY+token,RedisConstants.USER_TOKEN_TTL,TimeUnit.MINUTES);关键点在这里:拦截器判断用户有没有登录,靠的不是解析 JWT 里的内容,而是拿这个 token 去 Redis 里查有没有对应的 User。也就是说,token 本质上被当成了一把"钥匙",真正的用户信息还是存在服务端(Redis)里的。这和最原始的Session 机制其实是同一个思路:
Session 机制:登录成功后,服务器在内存/Redis 里保存一份"用户会话",给浏览器一个
sessionId(通常放在 Cookie 里)。以后每次请求带着这个sessionId,服务器凭它去查会话数据。我们项目里的做法:登录成功后,服务器生成一个 JWT 字符串当
token,同时把用户信息存进 Redis,key 就是user:token:<token>。以后每次请求带着这个 token,服务器凭它去 Redis 查用户数据。
这俩本质上是一回事:状态都保存在服务端,客户端只拿一个"凭证"。区别只是这个凭证的载体——一个是随机生成的 sessionId,一个是格式化的 JWT 字符串,以及存储位置的默认实现——传统 session 常放在内存或 Servlet 容器管理,这里换成了 Redis。
那如果真的用"纯 JWT"(不查 Redis)会怎样?
真正的无状态 JWT 应该是:拦截器只做一件事——验证签名、解析出里面的 payload(比如 userId、role),不去查任何数据库/缓存,所有信息都从 token 本身解出来。
如果我们的项目改成这种做法,会有什么后果?
优点:不用查 Redis 了,理论上每个服务器节点都能独立验证 token,扩展性更好,这也是 JWT 最常被提起的卖点。
代价也很明显:
没法主动"踢人下线"。比如管理员想封禁一个用户、或者用户改了密码想让所有旧 token 失效,纯 JWT 做不到——因为 token 一旦签发,只要没到过期时间,签名验证一直能通过,服务端没有地方能"删除"它。而我们项目里因为把 token 和 User 的映射存在 Redis,只要
redisTemplate.delete()一下,这个 token 立刻失效,这是纯 JWT 做不到的。续期不自然。我们代码里每次请求都会
redisTemplate.expire(...)刷新过期时间,实现"只要一直在用就不会掉线"的效果。纯 JWT 的过期时间是签发时就写死在 token 里的,想要滑动续期得额外发一个"刷新 token"的机制,更复杂。用户信息变了不会立刻生效。比如管理员改了某用户的角色,纯 JWT 因为角色信息编码在 token 里,除非用户重新登录换新 token,否则旧 token 里的角色信息是过时的。我们的方案因为每次都是现查 Redis 里的最新 User,改了立刻生效。
所以,我们项目的选择其实是工程上很常见的一种折中:用 JWT 的形式,但保留服务端可控的能力,牺牲一点点"纯无状态"的理论优雅,换来更好的可控性。这也是我在准备面试的时候才想明白的一点——技术选型没有绝对的对错,得看你要解决的问题是什么。
二、Redis Cache-Aside:缓存和数据库不一致了怎么办
商品详情页这种"读多写少"的数据,我们用了旁路缓存(Cache-Aside)模式,核心代码:
// 读:先查缓存,没有再查数据库,查到了回填缓存publicGoodgetGoodById(Longid){StringredisKey=GOOD_TOKEN_KEY+id;GoodredisGood=valueOperations.get(redisKey);if(redisGood!=null){redisTemplate.expire(redisKey,GOOD_TOKEN_TTL,TimeUnit.MINUTES);returnredisGood;}GooddbGood=getOne(queryWrapper);// 查数据库if(dbGood!=null){valueOperations.set(redisKey,dbGood);// 回填缓存redisTemplate.expire(redisKey,GOOD_TOKEN_TTL,TimeUnit.MINUTES);}returndbGood;}// 写:更新数据库之后,直接删除缓存,而不是更新缓存publicvoidupdate(Goodgood){updateById(good);redisTemplate.delete(GOOD_TOKEN_KEY+good.getId());}这里有一个很容易被忽略、但面试官很爱问的细节:为什么写操作是"删除缓存"而不是"更新缓存"?
如果写操作直接更新缓存(set新值),表面上看好像更高效(少一次查库),但会有两个问题:
并发写的时候容易把旧数据留在缓存里。假设两个请求同时更新同一个商品:请求 A 先把数据库改成"新价格 100",请求 B 紧接着把数据库改成"新价格 200";但如果两个请求更新缓存的顺序反过来(网络延迟导致 A 的缓存写入晚于 B),缓存里最终留下的是"新价格 100",而数据库里其实是"新价格 200"——缓存和数据库不一致了,而且不会自动恢复,除非缓存过期。
如果这条数据本来就没人读过,直接写缓存是浪费。删除缓存的做法,等下次真的有人来读这条数据时才回填,天然避免了"写了缓存但没人用"的浪费。
而"删除缓存"这种做法,最坏情况下也只是让下一次读请求多查一次数据库、重新回填,缓存里绝不会留下一个"确定是错的"旧值——顶多是短暂地"没有缓存",而不是"缓存里是错的"。这就是业界常说的 **Cache-Aside 模式里"更新数据库 + 删除缓存"优于"更新数据库 + 更新缓存"**的原因。
那这样就完全没有不一致的风险了吗?
严格来说没有 100% 保证,还有一种经典的竞态条件:
请求 A 读缓存,没命中,准备去查数据库;
就在 A 查数据库、还没来得及回填缓存之前,请求 B 把这条数据更新了,并删除了缓存(此时缓存本来就是空的,删除等于没做什么);
A 才慢悠悠地把它查到的旧数据回填进缓存;
结果缓存里躺着一个旧值,一直到 TTL 过期才会被清除。
这就是为什么我们代码里给缓存加了TTL(GOOD_TOKEN_TTL,30 分钟)——TTL 存在的意义,很大程度上就是给这种"理论上小概率但无法完全避免"的不一致情况兜底:就算真的出现了脏数据,最多也只脏 30 分钟,到期自动清除、下次读取重新回填。这也是我认为这道题目面试官更想听到的答案:不是问你有没有 100% 的解决方案,而是问你知不知道这个方案的边界在哪、怎么兜底。
三、写在最后
这两个设计点(JWT + Redis 混合鉴权、Cache-Aside 缓存策略)看起来是课程设计里很小的两块代码,但拆开看,背后其实是分布式系统里两个很基础也很常被问到的话题:状态该放哪里和缓存一致性怎么兜底。写这篇总结的过程,也是我自己把"跟着敲代码"补成"知道为什么这么写"的过程,希望对同样在啃这块内容的同学有帮助。