qinghuan
← 黑马点评每日学习总结 / 全部记录

DAY 01 / 黑马点评每日学习总结

短信登录与商户查询缓存

跟着自己的代码提交,弄懂登录后怎样记住用户、两个拦截器各做什么,以及商户数据怎样从缓存里读出来。

本篇目录 · 快速跳转

这一篇,复习到什么程度?

Day01 是本系列的第 1 篇复习记录。内容以 ck9847/hmdp 的提交差异和合并后的源码为依据;Day 表示整理顺序,不代表这些代码在同一天完成。

  • 不看代码,说明验证码、登录凭证 token、用户信息 UserDTO 和线程里的临时存储 ThreadLocal 各有什么用。
  • 按请求顺序说清两个拦截器的分工,以及请求结束后谁清理用户信息。
  • 分清“缓存空结果”“只让一个请求更新同一份缓存”“后台更新时允许返回旧值”三种办法,并找到代码实际用了哪种。
  • 遇到更新后查不到商户时,能顺着缓存删除、提前加载数据和缓存缺失的处理一步步检查。

本篇完成了 Git 历史与源码核对,未运行该 Java 项目的 MySQL、Redis、短信或并发压测。下文的运行现象均为按源码推导的预期;练习用于后续自行验证。

我的 Git 提交记录

按实现演进对照。提交关系和时间口径见下一节;点击短 SHA 可查看真实差异。

  1. 18bec89 ↗

    第一次提交

    已经实现 Session 验证码、Session 保存 UserDTO 和登录拦截器。它是本次对比的起点,不是空项目。

  2. 5827bcc ↗

    基于redis实现短信登录业务代码开发

    相对首次提交修改 4 个文件:验证码与用户态迁入 Redis,新增 RefreshTokenInterceptor,登录拦截器只做登录判断。

  3. 630ac68 ↗

    商户查询缓存业务代码开发

    此提交与 5827bcc 的父提交都是 18bec89。它包含 Redis 登录和商户缓存代码;与 5827bcc 比较,另外 9 个文件涉及商户详情、类型列表、CacheClient 和预热测试。

  4. 5fc11dc ↗

    Merge remote-tracking branch 'origin/master'

    合并两个分支。最终目录树与 630ac68 相同;本篇所有当前代码链接固定到此版本。

01先看提交记录:这次改了哪些代码

讲解时可以按“先用 Session 保存登录信息,再改用 Redis,最后加入商户缓存”来理解。不过,真实的 Git 提交并不是这样一条直线:5827bcc 和 630ac68 从同一个父提交分出,最后由 5fc11dc 合并。学习顺序与代码提交关系要分开看。

上方日期按提交者时间显示,时区统一为 Asia/Tokyo。Git 还记录了作者时间,两者可能不同:630ac68 的作者时间为 2026-09-13 00:35:39 +09:00,提交者时间为 2026-09-20 21:12:25 +09:00;5827bcc 的两种时间都是 2026-09-13 00:35:39 +09:00。

第一次提交就已经把 User 中需要的字段复制到 UserDTO,再存入 Session;改用 Redis 后仍然这样做。DTO 可以理解成专门用于传递少量用户信息的对象。复制字段会得到另一个对象,强制类型转换只是要求 Java 把原对象按某种类型看待,不能用后者代替前者。

  1. 18bec89 · Session 基础
  2. ↳ 5827bcc · Redis 登录
  3. ↳ 630ac68 · Redis 登录 + 缓存
  4. 5fc11dc · 合并后复习基线

02短信登录:核对验证码,再发放登录凭证

获取验证码的入口是 POST /user/code?phone=手机号,登录入口是 POST /user/login。sendCode 先检查手机号格式,再生成 6 位随机数字,存到 Redis 的 login:code:手机号 下,2 分钟后自动过期。目前“发送短信”只是打印 debug 日志,还没有接入短信服务商。

login 从 Redis 取出验证码。没有取到,或与输入不一致,就返回“验证码错误”。通过检查后,按手机号查用户;还没有账号就创建一个昵称以 user_ 开头的用户。接着生成去掉横线的 UUID,作为登录凭证 token,并把对应的 UserDTO 存成 Redis Hash,设置 30 分钟有效期。Hash 可以理解成一张按字段名取值的小表。

UserDTO 只保存 id、nickName、icon 三个字段。这里用的 StringRedisTemplate 读写字符串,所以 beanToMap 会忽略 null 字段,并把其他字段值转成字符串。保存的是这三个必要字段,不是数据库 User 对象的全部信息。

最后要用 return Result.ok(token) 把登录凭证交给客户端。之后发请求时,客户端把它放在 Authorization 请求头中,服务器据此找回用户。当前代码直接把头部值接到 Redis key 后面,所以应发送原样 token,不能自己加 Bearer 前缀。token 本身只是一个随机编号,用户资料还保存在服务器的 Redis 中。

  1. 校验手机号 / 验证码
  2. 按手机号查库或注册
  3. User → UserDTO → Hash
  4. 返回 token
  5. 后续请求携带 Authorization
UserServiceImpl.login · 当前源码节选
        // 7.将用户信息转换为UserDTO并保存到redis中
        // 7.1 随机生成token,作为登录令牌
        String token = UUID.randomUUID().toString(true);
        // 7.2 将User对象转为HashMap存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((filedName,filedValue)->filedValue.toString()));
        // 7.3 存储数据在redis中
        stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,userMap);
        // 7.4 设置token有效期
        stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL,TimeUnit.MINUTES);
        // 8.返回token给客户端
        return Result.ok(token);
    }
对照此版本的源码与行号 ↗

03Redis 里存了什么,过期时间怎样设置

Redis 里放着几种用途不同的数据:验证码用来检查这次登录,token 对应用户登录信息,商户缓存用来减少查数据库,lock:shop 用来协调谁更新缓存。它们各有自己的有效期和失效后的处理方式,不能一概记成“都保存 30 分钟”。

TTL 表示 Redis key 剩余的有效期,不是保证精确执行删除的倒计时。表里的 20 秒来自 queryById 的调用参数,提前加载数据的 testSaveShop 则用了 10 秒。这两个时间都写在 JSON 里,表示“何时该更新”;setWithLogicalExpire 没有设置让 Redis 到期清理 key 的 TTL。

Redis 里存了什么,过期时间怎样设置 · 对照表
Key / 用途Redis 结构与内容源码中的时间
login:code:{phone}String:6 位验证码2 分钟物理 TTL
login:token:{token}Hash:UserDTO 的非空字段,值转字符串30 分钟物理 TTL;有效请求续期
cache:shop:{id} · 普通缓存String:Shop JSON;不存在时写空字符串有效数据 30 分钟;空值 2 分钟
cache:shop:{id} · 当前逻辑过期String:{ data, expireTime }重建后逻辑有效 20 秒;无物理 TTL
cache:shop-type:listString:按 sort 排序的 ShopType 数组 JSON30 分钟物理 TTL
lock:shop:{id}String:固定值 "1",SET NX 获取锁10 秒物理 TTL

04两个拦截器:读取用户信息,再判断能否访问

先恢复用户,再判断这个接口能不能访问

这两个拦截器各管一件事,顺序也有明确分工。

先执行 order(0) 的 RefreshTokenInterceptor。 它配置匹配 /**,即经过这条 Spring MVC 拦截链的所有路径。

  • 没有 token,或按 token 找不到 Redis Hash:继续放行。
  • 有有效 token:把 Hash 字段还原为 UserDTO,临时存到 UserHolder 的 ThreadLocal 中;随后把 Redis 中的有效期重新延长到 30 分钟。

再执行 order(1) 的 LoginInterceptor。 它通过 UserHolder.getUser() 检查当前请求是否已有登录用户。需要登录却没有用户时,设置 HTTP 401 并返回 false;有用户才交给 Controller。/user/me 也从 UserHolder 中读取用户。

HTTP 401 是响应状态码,Result.fail 是业务返回内容。排查接口时,两者要分别检查,不能只看其中一个。

为什么公开页面也经过刷新拦截器

用户可能一直在浏览 /shop/**,没有访问必须登录的接口。如果只有受保护接口才负责续期,正在浏览的用户也可能过期。

现在的分工是:未登录用户照常看商户,已登录用户浏览时顺手续期。 刷新拦截器没有把“没找到用户”直接当成拒绝访问;是否必须登录,交给后面的登录拦截器按路由处理。

临时保存了用户,也要看清清理边界

ThreadLocal 只让当前处理请求的线程临时保存用户信息,不能代替共享会话。

正常同步请求结束时,RefreshTokenInterceptor.afterCompletion 调用 removeUser 清理,防止这个线程下次处理请求时还带着旧用户。

但有两个边界要记住:

  • 如果 preHandle 已保存用户,却在给 Redis 续期时抛出异常,没能返回 true,这个清理回调就不会执行。
  • 另开的异步任务不能假定会自动拿到这份用户信息。

“正常结束会清理”不等于所有异常路径都会清理。读这段代码时,要把保存用户、续期和完成回调放在一起看。

  1. 请求头 Authorization
  2. order(0) · 查 Redis / 恢复用户 / 续期
  3. order(1) · 按路由要求登录
  4. Controller / Service
  5. afterCompletion · removeUser
两个拦截器:读取用户信息,再判断能否访问 · 对照表
请求情况刷新拦截器登录拦截器 / 结果
无 token,请求 GET /shop/1放行,不写 UserHolder该路由被排除;继续查商户
无效 token,请求 GET /user/meHash 为空,放行UserHolder 为空;HTTP 401
有效 token,请求 GET /shop/1恢复 DTO,刷新 30 分钟 TTL该路由被排除;继续查商户
有效 token,请求 GET /user/me恢复 DTO,刷新 TTL身份存在;返回当前 DTO
MvcConfig · 当前拦截器配置
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/voucher/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);
        // token刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}
对照此版本的源码与行号 ↗

05查商户:先读缓存,查不到时怎样处理

先看缓存,没有再查数据库

查询商户时,ShopController.queryShopById 把任务交给 shopService.queryById。最基础的缓存思路是:先看 Redis,有数据就返回;没有再查数据库,把查到的结果写回 Redis,供下次使用。

这叫旁路缓存(Cache Aside)。商户的原始业务数据仍由数据库保存。

不存在的商户,也可以暂时记下来

如果有人反复查询一个根本不存在的商户,每次都访问数据库就很浪费。这类问题叫缓存穿透。

空结果缓存会把“已经查过,确实不存在”记成空字符串 "",保存 2 分钟。下次读到这个标记,就直接返回不存在,不必再查一次数据库。

null 和空字符串,代表两件不同的事

Redis GET 的结果这里的含义接下来怎么做
正常商户数据缓存里已有商户内容还原数据并返回
空字符串 ""已缓存“查不到”的结果返回不存在,不再查数据库
null没有这个 key再去数据库查询

因此,代码按这个顺序判断:

  1. 用 isNotBlank 判断是否有正常商户数据。
  2. 再用 json != null 判断是否命中了已缓存的空结果。
  3. 两种情况都没命中,才查数据库。

不能只用一次 isBlank 就把流程决定下来。 那样会把“根本没有缓存”和“已经缓存了不存在”混在一起。

两分钟的空结果也有代价

空结果缓存能减少对同一个无效编号的重复查询,但如果请求不断更换新编号,数据库仍可能忙起来。

另外,在这 2 分钟里,如果原本不存在的商户刚被创建,旧空结果可能暂时把它遮住。新增数据时,需要考虑删除旧缓存,或明确接受等待它过期。

本节配套代码是保留的可选策略 queryWithPassThrough。空结果缓存不是挡住所有穿透请求的保证;2 分钟也同时意味着这段时间内可能读到旧的“不存在”结果。

CacheClient.queryWithPassThrough · 保留的可选策略
    public <R,ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String json=stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        // 判断命中的是否是空值
        if(json != null){
            // 返回错误信息
            return null;
        }
        // 4.不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "",CACHE_NULL_TTL,TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6.存在,写入redis
        this.set(key, r, time, unit);
        // 7.返回
        return r;
    }
对照此版本的源码与行号 ↗

06穿透、击穿、雪崩:为什么数据库会突然忙起来

一个很热门的商户缓存刚好过期,许多请求一起查数据库,这叫缓存击穿。互斥重建的目标,是让同一个商户的缓存由一个请求查库更新,其他请求等待;逻辑过期则允许请求使用旧数据,同时尝试在后台更新。前一种可能让用户等待,后一种要接受旧信息;能否及时更新,还要看后台任务是否成功。

缓存雪崩的范围更大:很多 key 一起过期,或 Redis 本身不可用,导致大量请求转向数据库。把过期时间分散开、限制请求量、临时降低服务能力,都是常见应对思路;但本次代码还没有完整实现这些办法,不能写成项目已经具备的功能。

ShopServiceImpl 文件里保留了三种查询方法,但 queryById 中前两种调用已经注释掉。要判断项目实际走哪条路,应从这个入口往下读。一个方法存在于文件里,不代表当前请求真的会调用它。

穿透、击穿、雪崩:为什么数据库会突然忙起来 · 对照表
策略缓存未命中过期 / 并发行为当前状态
空值缓存查库;不存在写空字符串热点到期仍可能并发回源CacheClient 已封装;入口注释
互斥锁重建拿锁后查库失败睡眠 50ms 后递归重试ShopServiceImpl 保留;入口注释
逻辑过期直接返回 null过期返回旧值,拿锁后异步重建queryById 当前启用
ShopServiceImpl.queryById · 当前真正执行的入口
    @Override
    public Result queryById(Long id) {
        // 缓存穿透
        // Shop shop = cacheClient.queryWithPassThrough(
        // CACHE_SHOP_KEY,id,Shop.class,this::getById,CACHE_SHOP_TTL,TimeUnit.MINUTES);

        // 互斥锁解决缓存击穿
        // Shop shop = queryWithMutex(id);

        // 逻辑过期解决缓存击穿
        Shop shop =cacheClient.queryWithLogicalExpire(
                CACHE_SHOP_KEY,id,Shop.class,this::getById,20L,TimeUnit.SECONDS);

        if (shop == null) {
            return Result.fail("店铺不存在!");
        }
        // 7.返回
        return Result.ok(shop);
    }
对照此版本的源码与行号 ↗

07逻辑过期:什么时候返回旧数据,什么时候更新

RedisData 包含两部分:data 里是商户信息,expireTime 是“何时该更新”。读取 JSON 后先还原 RedisData,再把 data 还原为 Shop。如果还没到时间,直接返回;如果已到时间,尝试获取这个商户自己的 lock:shop:{id}。拿到锁的请求提交后台更新任务。在这些步骤没有抛出异常的情况下,请求返回刚读到的旧 Shop,不等待后台查询完成。

后台工作由一个固定 10 线程的线程池处理:执行 dbFallback.apply(id) 查数据库,再调用 setWithLogicalExpire 写回新内容。请求不用等这次查询完成,但刷新成功前可能一直读到旧值;如果任务反复失败,代码没有规定旧值最多保留多久。这里的“过期”只是告诉代码该刷新,不会因为 JSON 里的时间到了就让 Redis 自动删除 key。

当前实现要求先把数据放进缓存,这一步叫预热。因为一旦缓存为空,方法直接返回 null,不会自己查数据库。仓库里的 testSaveShop 只给 id=1 写入逻辑缓存,也没有检查结果的断言。它只能说明有这样一段辅助代码,不能证明所有商户都已加载,也不能证明并发测试通过。

还有一个容易忽略的问题:普通 Shop JSON 和包了一层 RedisData 的 JSON,都使用 cache:shop:{id} 这个 key。换策略时,需要在测试环境按新格式重新生成数据,或者使用不同版本的 key。旧格式不能直接交给新格式的读取代码。

逻辑过期:什么时候返回旧数据,什么时候更新 · 对照表
读取状态源码分支复习结论
Redis 无 key / 空白直接 return null数据库有商户也可能返回“店铺不存在”
逻辑时间未到期直接 return r正常缓存命中
逻辑时间已到,拿到锁提交任务,当前返回 r当前读取旧值;后台查库刷新
逻辑时间已到,没拿到锁不提交任务,返回 r允许陈旧读取,避免一起重建
CacheClient.queryWithLogicalExpire · 当前源码
    public <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type, Function<ID,R> dbFallback,Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String json=stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(json)) {
            // 3.存在,直接返回
            return null;
        }
        // 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject)redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 5.1 未过期,直接返回店铺信息
            return r;
        }
        // 5.2 已过期,需要缓存重建
        // 6.缓存重建
        // 6.1 获取互斥锁
        String localKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(localKey);
        // 6.2 判断是否获取锁成功
        if (isLock) {
            // 6.3 成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R r1=dbFallback.apply(id);
                    // 写入redis
                    this.setWithLogicalExpire(key, r1, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unlock(localKey);
                }
            });
        }
        // 6.4 返回过期的商铺信息
        return r;
    }
对照此版本的源码与行号 ↗

08改完商户后,为什么反而查不到

更新商户的方法用 @Transactional 管理数据库事务,先执行 updateById(shop),再删除 cache:shop:{id},最后返回成功。如果读取时“缓存没有就查数据库”,删除旧缓存是常见做法。但当前读取方法偏偏是“缓存没有就返回 null”,两边没有配合上。

按这段代码推导:如果更新成功删掉缓存,之后没有预热、其他请求写回,也没有已经在运行的后台任务写回,那么下一次查询就会提示“店铺不存在!”,即使数据库里仍然有这家店。这个提示不能证明数据库数据被删了。

可以考虑让逻辑过期在第一次找不到缓存时,按受控方式查数据库并填入;也可以在数据库事务提交后可靠地触发重建。对普通商户,还可以选择“没有缓存就查库,并记住空结果”的方式。无论选哪种,都要一起检查新增、更新、删除、首次查询和缓存丢失。这里记录的是建议,还没有修改业务仓库。

@Transactional 不会自动把 MySQL 更新和 Redis 删除绑成“要么一起成功,要么一起撤销”的事务。当前方法先删缓存,Spring 代理通常要等方法正常返回后,才尝试提交数据库事务。

如果改回“缓存没有就查库”,并发查询可能在提交前读到旧数据库内容,再把旧值写进缓存;当前逻辑过期下,已启动的后台重建任务也可能造成旧值回写。因此还要验证提交后删除、失败重试等做法,不能直接宣称数据始终一致。

  1. 更新数据库(事务内)
  2. 删除商户 key
  3. 方法返回 / 事务提交
  4. 下一次查询:缓存缺失
  5. 当前逻辑过期分支返回 null
ShopServiceImpl.update · 与查询入口连起来读
    @Transactional
    public Result update(Shop shop) {
        Long id = shop.getId();
        if (id == null) {
            return Result.fail("店铺id不能为空");
        }
        // 1.更新数据库
        updateById(shop);
        // 2.删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY+shop.getId());
        return Result.ok();
    }
}
对照此版本的源码与行号 ↗

09商户类型列表:按顺序读取和保存缓存

GET /shop-type/list 调用 queryTypeList。先读 cache:shop-type:list;有缓存就把 JSON 数组还原成 List<ShopType>。没有则按 sort 从小到大查数据库,把整个结果转成 JSON 保存 30 分钟。这是商户类型列表自己的查询流程,与商户详情分开。

这个 key 在 Redis 中的类型是 String,只是字符串内容长得像 JSON 数组,并不是 Redis List。数据库没有任何类型时,代码写入的是 "[]"。它不是空白字符串,下次仍能读到并还原成空列表。

顺序应在查数据库时用排序条件说清楚,缓存照着保存。当前代码没有在修改类型后主动删除旧缓存。如果以后加入类型管理,要一起补上这一步;多个请求同时第一次查询时,也仍有可能重复查数据库。

ShopTypeServiceImpl.queryTypeList · 当前源码
    @Override
    public List<ShopType> queryTypeList() {
        // 1.从Redis查询店铺类型缓存
        String typeListJson = stringRedisTemplate.opsForValue()
                .get(RedisConstants.CACHE_SHOP_TYPE_KEY);
        // 2.缓存命中,反序列化后直接返回
        if (StrUtil.isNotBlank(typeListJson)) {
            return JSONUtil.toList(typeListJson, ShopType.class);
        }

        // 3.缓存未命中,查询数据库并按sort字段排序
        List<ShopType> typeList = query().orderByAsc("sort").list();
        // 4.将查询结果写入Redis,避免下一次再次查询数据库
        stringRedisTemplate.opsForValue().set(
                RedisConstants.CACHE_SHOP_TYPE_KEY,
                JSONUtil.toJsonStr(typeList),
                RedisConstants.CACHE_SHOP_TYPE_TTL,
                TimeUnit.MINUTES
        );
        return typeList;
    }
}
对照此版本的源码与行号 ↗

10CacheClient:把重复的缓存操作放进工具类

CacheClient 把重复的缓存步骤放到一起。方法里的 R 表示返回哪种对象,ID 表示编号的类型,Class<R> type 告诉 JSON 工具要还原成哪种对象。Function<ID,R> dbFallback 则是一段“用编号查数据库”的方法。传入 this::getById,就把查商户的办法交给它,因此工具不必直接依赖 ShopMapper。

几个方法可以按用途记:set 保存普通 JSON,并设置 Redis 有效期;setWithLogicalExpire 在 JSON 里记下该刷新的时间;queryWithPassThrough 会缓存查不到的结果;queryWithLogicalExpire 在读到已过逻辑时间的缓存时,允许返回旧值,并尝试后台更新。

后一个方法遇到缓存缺失仍直接返回 null。调用方在 queryById 中选择要用哪种。

复用前还要检查几个问题:锁名称固定用了商户前缀;代码没有展示应用退出时怎样关闭线程池;后台异常存进 Future,却没有明确读取和处理。固定 10 个线程只限制同时执行的任务数,排队数量没有业务上限。而且先拿锁再排队,等待期间 10 秒锁也可能到期。用于别的业务前,要检查锁是否重名、怎样发现更新失败、怎样限制排队,以及查不到数据时如何处理。

方法签名与调用 · 从当前源码整理
public <R, ID> R queryWithLogicalExpire(
    String keyPrefix, ID id, Class<R> type,
    Function<ID, R> dbFallback, Long time, TimeUnit unit
)

// R = Shop,ID = Long,回源函数 = this::getById
cacheClient.queryWithLogicalExpire(
    CACHE_SHOP_KEY, id, Shop.class, this::getById,
    20L, TimeUnit.SECONDS
);
对照此版本的源码与行号 ↗

11这些代码还存在哪些问题,怎样排查

下面区分已实现的学习机制和仍需验证 / 改进的部分,避免把课堂版实现写成生产级结论。

这些代码还存在哪些问题,怎样排查 · 对照表
观察到的代码可能的问题 / 如何继续验证
验证码只打印日志;登录成功未删除验证码不是实发短信;验证码可在有效期内再次使用。练习补充原子校验与消费、发送频控和失败次数限制。
Hash 写入与 expire 是两条命令两步之间若失败可能留下无 TTL 登录态。后续评估原子写入与设置过期时间。
POST /user/logout 返回“功能未完成”尚未撤销 Redis token,不能把登出算成已完成。
登录排除规则包含 /shop/** 与 /upload/**按路径放行不区分 HTTP 方法,商户新增 / 修改也落在排除范围;后续区分公开读接口和受保护写接口。
tryLock 固定值 "1",unlock 直接 DEL锁过期后可能被别人重获,旧持有者仍可能删掉新锁;需要唯一持有者标识与原子比较删除。
queryWithMutex 递归重试,finally 无条件 unlock未获锁的调用层也会执行清理;还缺少拿锁后再次查缓存,可能重复回源。该方法当前未启用。
逻辑过期拿锁后未复查缓存已有请求刚完成重建时仍可能再次重建;线程池提交失败也需处理锁与可观测性。
updateById 返回值未检查传入不存在的 ID 可能仍得到成功响应,应验证受影响行数 / 业务存在性。
Redis 连接异常没有降级分支不能由“加了缓存”推断服务在缓存故障时仍可用;需独立设计和验证故障策略。

12用四句话串起 Day01

登录:验证码用来检查手机号,token 是之后查找 Redis 登录信息的凭证。登录成功必须把 token 返回给客户端。

拦截:先尝试找回用户并续期,再检查当前接口是否要求登录;请求结束时清理线程里临时保存的用户信息。

缓存:空结果可以记住“查过但不存在”;热门数据过期后,可以协调一个请求更新同一份缓存,也可以允许返回旧值并尝试后台更新。当前商户详情用了后一种,类型列表则是没有缓存就查数据库。

一起看读和写:当前逻辑过期查询找不到缓存就返回 null,更新方法却会删除缓存。复习时要把两段代码接起来看,才能发现更新之后的查询问题。

把理解变成可检验的输出

分层练习与思维拓展

共 12 道题,不附作业答案。先完成 4 道基础必做与 3 道综合应用,再挑战登出实现和 4 类思维拓展。登录状态恢复、缓存分支与读写衔接是本次重点。

动手题在自己的隔离测试环境完成。每题记录“独立完成 / 查阅后完成 / 尚未完成”,保留环境、输入、观察结果与疑问;不要把读过代码当作运行验证。

  1. 01 / 基础必做

    画出完整登录链路

    从 POST /user/code 到 GET /user/me,标出 Controller、Service、Redis、两个拦截器和 UserHolder;解释 Session 方案改成 Redis 后哪些职责搬家了。

    提交内容
    一张时序图 + 四类数据的存储位置表。
    完成检查
    • 标明验证码与 token 的 key、类型、TTL。
    • 包含 token 返回、请求头携带、用户恢复、请求后清理。
  2. 02 / 基础必做

    验证 token 与续期

    在自己的隔离测试环境登录,查看返回 data;用裸 token 请求 /user/me,再分别用无 token、过期 token 和 Bearer 前缀试验。随后用有效 token 访问 /shop/1,观察登录 key 的 TTL。

    提交内容
    脱敏请求 / 响应及访问前后的 TTL 记录。
    完成检查
    • 区分 HTTP 401 与业务 Result.fail。
    • 不要贴真实手机号、验证码或完整 token。
    • 明确公开接口访问是否触发续期;记录实际结果而非抄预期。
  3. 03 / 基础必做

    三种空状态推演

    仅针对 queryWithPassThrough,分别输入 Redis GET 的 null、空字符串、合法 Shop JSON;再模拟数据库无记录。逐行写出是否查库、返回值和回填内容。

    提交内容
    四行分支表。
    完成检查
    • 能解释 isNotBlank 与 json != null 的先后顺序。
    • 写清空值 2 分钟与正常数据 30 分钟。
  4. 04 / 基础必做

    验证商户类型缓存

    在测试环境只清理 cache:shop-type:list,连续调用两次 /shop-type/list,观察 SQL 日志、Redis TYPE / GET / TTL 和响应顺序。

    提交内容
    两次请求的证据对照表。
    完成检查
    • 第一次回源、第二次命中要有日志或计数证据。
    • 说明 JSON 数组为什么仍是 String 类型。
    • 检查 sort 顺序和空列表情况。
  5. 05 / 综合应用

    复现冷启动缺口

    保持 queryById 当前策略不变,选一个数据库存在的测试商户,分别测试无缓存、已预热未逻辑过期、已逻辑过期。不要连接生产 Redis。

    提交内容
    三种状态的 key 内容、响应与回源记录。
    完成检查
    • 区分 Redis 物理 TTL 与 expireTime。
    • 无缓存时明确数据库存在性。
    • 标明预期与实际是否一致。
  6. 06 / 综合应用

    复现更新后的读取

    确保没有其他重建任务,预热一个测试商户,调用 PUT /shop 更新它,再读该商户。根据 update 和 queryWithLogicalExpire 找到行为原因,并提出一个最小改进方案。

    提交内容
    执行时序 + 数据库 / Redis / HTTP 证据 + 改进设计。
    完成检查
    • 覆盖事务提交、删 key 和缺失分支。
    • 方案同时考虑首次访问、更新和重建失败。
    • 未实际实施的改进明确标注为设计。
  7. 07 / 综合应用

    重建并发的证据

    在隔离环境将一个已预热热点设为逻辑过期,用固定请求数与并发度访问;记录数据库回源次数、返回旧值的情况、重建耗时和异常。再解释为什么这些结果不足以证明锁在所有情况下正确。

    提交内容
    实验参数、日志和结果表。
    完成检查
    • 只比较相同数据和环境。
    • 区分请求耗时与后台重建耗时。
    • 观察锁超过 10 秒的情形,不能只看 HTTP 成功率。
  8. 08 / 进阶挑战

    补全登出闭环

    在独立练习分支完成 /user/logout:从同一请求头取得 token,撤销对应 Redis 会话;设计重复登出、无效 token 与登录接口的交互规则。

    提交内容
    实现说明与接口验证记录。
    完成检查
    • 退出后原 token 不能再访问 /user/me。
    • 只影响目标会话,不删除其他用户登录态。
    • 说明拦截器可能先于 Controller 拒绝请求的情况。
  9. 09 / 思维拓展 · 反例构造

    十秒锁真的安全么

    构造 A 持锁超过 10 秒、B 在过期后重新拿锁、A 随后 unlock 的时间线。再对递归互斥方法的未获锁层分析 finally。

    提交内容
    两张最小反例时序图。
    完成检查
    • 指出被误删的是谁的锁。
    • 解释为什么只延长 TTL 不能证明互斥正确。
  10. 10 / 思维拓展 · 条件变化

    商户被删除之后

    如果后台重建时数据库返回 null,下一次逻辑过期读取会怎样?设计 data=null、实体删除、重新创建时的缓存协议。

    提交内容
    状态转移表与需要新增的验证用例。
    完成检查
    • 分别检查解析、过期判断、返回值。
    • 明确空值与正常对象的格式及过期策略。
  11. 11 / 思维拓展 · 跨域迁移

    从商户搬到商品

    把 CacheClient 复用到商品详情,商品 id 与商户 id 都为 1。检查数据 key、锁 key、实体类型、TTL、更新流程。

    提交内容
    复用调用示例与接口调整草案。
    完成检查
    • 发现锁前缀硬编码可能造成无关实体互相竞争。
    • 明确哪些参数属于业务策略,哪些属于通用机制。
  12. 12 / 思维拓展 · 开放探究

    哪里可以接受旧值

    比较商户名称、营业时间、价格和可售库存:哪些可接受短暂陈旧,哪些不能只依赖这种逻辑过期缓存?为各自提出可验证的一致性要求。

    提交内容
    四类数据的策略选择与取舍表。
    完成检查
    • 每项写明允许陈旧的时间或业务条件。
    • 不把“响应更快”当作唯一判断标准。

合上代码,先回答这六问

先自己复述,再展开核对。这里是正文自测提示,练习题仍需独立完成。

验证码和 token 为什么不是同一个东西?

验证码用于一次登录校验,按手机号查找且短期失效;token 是后续请求携带的随机会话标识,对应 Redis 中的 UserDTO。当前代码尚未实现验证码成功后消费。

公开接口为什么也经过刷新拦截器?

公开只表示允许匿名访问。带有效 token 的浏览请求仍应恢复身份并续期;是否必须登录由后面的登录拦截器和路由规则决定。

缓存返回 null 与空字符串有什么差别?

在空值缓存策略中,null 是 key 缺失,需要回源;空字符串是已缓存的不存在结果,应直接返回空。当前逻辑过期方法则把两者都作为空白直接返回。

逻辑过期 20 秒后 Redis 会删除 key 吗?

不会。expireTime 是 JSON 内的业务字段,当前写入未设置 Redis TTL。应用判断过期后触发重建,并可继续返回旧对象。

为什么更新后可能提示店铺不存在?

更新删除了 key,而当前逻辑过期读取在缺失时不查数据库。在没有预热或其他重建回填的前提下,查询会得到 null。

@Transactional 能保证 MySQL 与 Redis 强一致吗?

不能。这里主要控制数据库事务;Redis 删除不自动参与同一个原子事务,还存在提交前失效与并发读写的时序窗口。

源码索引 · 固定到本次提交

以下链接固定到 5fc11dc,之后仓库继续更新时,仍能回到这篇总结对应的版本。

配合 Java 系统课程补齐前置知识 ↗