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

做课程设计的时候,我们小组完成了电商项目,写完了双层拦截器鉴权和 Redis 缓存,但说实话,当时只是照着敲代码,并不真正理解这两个东西为什么要这样设计。写完之后自己回头梳理了一遍原理,记录下来,也希望能帮到有同样困惑的同学。

一、先搞清楚:我们的鉴权到底是不是"纯 JWT"

很多资料一说 JWT,就是"服务器不用存储任何东西,token 里带着所有信息,验证签名就行,天然适合分布式"。但我们项目里的做法,其实是 JWT + Redis 的混合方案,跟教科书里说的"无状态 JWT"不完全一样。先看核心代码:


// 登录时生成 token

public static String genToken(String userId, String username){

    return JWT.create()

            .withAudience(userId)

            .sign(Algorithm.HMAC256(username));

}


// 每次请求都要走的第一层拦截器

String token = request.getHeader("token");

User user = redisTemplate.opsForValue().get(RedisConstants.USER_TOKEN_KEY + token);

if(user == null){

    throw new ServiceException(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)模式,核心代码:


// 读:先查缓存,没有再查数据库,查到了回填缓存

public Good getGoodById(Long id) {

    String redisKey = GOOD_TOKEN_KEY + id;

    Good redisGood = valueOperations.get(redisKey);

    if (redisGood != null) {

        redisTemplate.expire(redisKey, GOOD_TOKEN_TTL, TimeUnit.MINUTES);

        return redisGood;

    }

    Good dbGood = getOne(queryWrapper); // 查数据库

    if (dbGood != null) {

        valueOperations.set(redisKey, dbGood); // 回填缓存

        redisTemplate.expire(redisKey, GOOD_TOKEN_TTL, TimeUnit.MINUTES);

    }

    return dbGood;

}



// 写:更新数据库之后,直接删除缓存,而不是更新缓存

public void update(Good good) {

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

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐