当前位置: 首页 > news >正文

从电商项目课程设计,搞懂 JWT 鉴权和 Redis 缓存到底在解决什么问题

从电商项目课程设计,搞懂 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 本身解出来。

如果我们的项目改成这种做法,会有什么后果?

  1. 优点:不用查 Redis 了,理论上每个服务器节点都能独立验证 token,扩展性更好,这也是 JWT 最常被提起的卖点。

  2. 代价也很明显

    • 没法主动"踢人下线"。比如管理员想封禁一个用户、或者用户改了密码想让所有旧 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新值),表面上看好像更高效(少一次查库),但会有两个问题:

  1. 并发写的时候容易把旧数据留在缓存里。假设两个请求同时更新同一个商品:请求 A 先把数据库改成"新价格 100",请求 B 紧接着把数据库改成"新价格 200";但如果两个请求更新缓存的顺序反过来(网络延迟导致 A 的缓存写入晚于 B),缓存里最终留下的是"新价格 100",而数据库里其实是"新价格 200"——缓存和数据库不一致了,而且不会自动恢复,除非缓存过期。

  2. 如果这条数据本来就没人读过,直接写缓存是浪费。删除缓存的做法,等下次真的有人来读这条数据时才回填,天然避免了"写了缓存但没人用"的浪费。

而"删除缓存"这种做法,最坏情况下也只是让下一次读请求多查一次数据库、重新回填,缓存里绝不会留下一个"确定是错的"旧值——顶多是短暂地"没有缓存",而不是"缓存里是错的"。这就是业界常说的 **Cache-Aside 模式里"更新数据库 + 删除缓存"优于"更新数据库 + 更新缓存"**的原因。

那这样就完全没有不一致的风险了吗?

严格来说没有 100% 保证,还有一种经典的竞态条件:

  1. 请求 A 读缓存,没命中,准备去查数据库;

  2. 就在 A 查数据库、还没来得及回填缓存之前,请求 B 把这条数据更新了,并删除了缓存(此时缓存本来就是空的,删除等于没做什么);

  3. A 才慢悠悠地把它查到的旧数据回填进缓存;

  4. 结果缓存里躺着一个旧值,一直到 TTL 过期才会被清除。

这就是为什么我们代码里给缓存加了TTL(GOOD_TOKEN_TTL,30 分钟)——TTL 存在的意义,很大程度上就是给这种"理论上小概率但无法完全避免"的不一致情况兜底:就算真的出现了脏数据,最多也只脏 30 分钟,到期自动清除、下次读取重新回填。这也是我认为这道题目面试官更想听到的答案:不是问你有没有 100% 的解决方案,而是问你知不知道这个方案的边界在哪、怎么兜底。

三、写在最后

这两个设计点(JWT + Redis 混合鉴权、Cache-Aside 缓存策略)看起来是课程设计里很小的两块代码,但拆开看,背后其实是分布式系统里两个很基础也很常被问到的话题:状态该放哪里缓存一致性怎么兜底。写这篇总结的过程,也是我自己把"跟着敲代码"补成"知道为什么这么写"的过程,希望对同样在啃这块内容的同学有帮助。

http://www.cnnetsun.cn/news/3148892.html

相关文章:

  • 面试官问:“模型一本正经胡说时,logprobs 抓得到吗?“
  • 你往 AI 里装的那些 skill,打开看过一眼吗?
  • 【图像分类】实战ResNet——从零构建到CIFAR-10分类(Pytorch)
  • Agent记忆系统设计与实现
  • 别把知识图谱做成高级文档库——定制化做企业级知识图谱
  • 【面板数据模型实战】从理论到Stata/R/Python实现与选择
  • 【机器人】基于缓冲的不确定性感知沃罗诺伊单元多机器人碰撞规避附Matlab代码
  • Rmarkdown动态文档创作与数据科学报告实战指南
  • 【HarmonyOS NEXT】error: failed to install bundle. code:9568322...
  • 多接地配电系统的基于PMU的系统状态估计附Matlab代码
  • Linux /etc/fstab 配置详解:5个关键参数避免重启后挂载回退只读
  • 普推黑体(PUTUI)1.202,更适合商标及标题文字!
  • 用C语言的<wchar.h>宽字节库实现好玩的逐字输出效果(模拟打字)
  • 鸿蒙新特性——Badge 徽章组件详解
  • Linux 用户管理知识与应用实践(二:用户相关命令与示例)
  • 高速 ADC 与 FPGA LVDS 接口设计:5 项 PCB 布线规则与 IDELAY 时序校准实战
  • 远控横评:向日葵、ToDesk、UU 远程,远程玩游戏差距有多大
  • Transformers自动化训练全流程优化实战
  • 助睿实验7-3:可视化探索
  • 基于51单片机的教室智能照明灯控制系统光控人数检测定做定制电子13(设计源文件+万字报告+讲解)(支持资料、图片参考_相关定制)_文章底部可以扫码
  • kotlin-basic-blog
  • 89个公共Tracker如何让BT下载告别“孤岛困境“?
  • 剧云推出分镜大师:让剧本更快变成可拍摄的镜头方案
  • Deceive:终极游戏隐身指南 - 如何在英雄联盟、VALORANT和符文大地传说中保持隐身状态
  • 《鸿蒙原生应用从0-1构建:项目工程结构与核心配置全景解析》
  • ExplorerPatcher深度解析:重塑Windows界面体验的高效工具
  • Node.js 插件沙箱:开放扩展之前先限制能力
  • Go 泛型的运行时性能:单态化、接口装箱与编译器优化的基准分析
  • OBS美颜文章_终极指南
  • 别再手写Bug了!用Python+LangGraph实现AI自修复代码的完整指南