学后端到现在也算是开始写第一个可以称之为项目的东西了,虽然都在讲 xx 点评、xx 外卖之类的项目没含金量,我也觉得简历里面写这种东西没什么竞争力,但花一两个星期写完我不认为不会有任何收获,看的也就是 redis - 黑马点评的那个视频
这个项目的前端页面已经提供了,我只需要完成后端接口即可。以各个功能模块的代码作为段落进行解析,记录一下写这个项目遇到的各种问题和学到的、弄明白的各种东西,所以不会贴完整的代码,完整代码会放到 Github 上,等写完了再传,欢迎品鉴 shi 山(
技术栈大概是 springboot、mysql、redis、mybatisplus 这些
# 准备工作
下载 redis 并导入到 wsl 里面,wsl 就作为我的 redis 服务器了,挂着。视频提供了 sql 文件,创建一个数据库然后导入一下,相关命令为:
1 | SOURCE hmdp.sql; |
配置文件里配置一下 redis 和 mysql:
1 | spring: |
还有前端环境一些杂七杂八的,挺简单的没学到什么东西,懒得写了。
# 短信验证码登录
# 需求分析和实现
第一个功能模块是做手机号 - 短信验证码登录的功能

前端这里,当用户输入手机号并点击 “发送验证码” 的时候需要向后端请求验证码,验证码发送之后点击 “登录”,又需要发送到后端进行验证
第一个请求是发送验证码,那么后端这里接收到的就是手机号,首先应该校验的就是手机号是否是正确的手机号,可以用正则来一个 mismatch 匹配:
1 | public static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$"; |
若手机号没问题,那么就可以随机生成一个验证码并发送给用户
第二个请求就是登录,后端需要校验用户的手机号和验证码是否匹配。若匹配成功,则又要判定用户是否存在,如果存在可以直接登录,否则应该先自动注册一个账号
那么这里牵扯到了一个矛盾。HTTP 协议本身是无状态的,服务器无法仅通过一次请求判断当前请求是否来自同一用户。为了在多次请求之间保存验证码等临时状态信息,可以使用 Session 机制
**Session 是由 Web 容器按照 Servlet 规范提供的一种会话管理机制,而 tomcat 是其中的一个实现方式。服务器会为每个客户端创建一个会话对象,并通过 Cookie(如 JSESSIONID)在客户端与服务器之间建立关联。** 在发送验证码时,可以将手机号与验证码以键值对的形式存入 Session;在校验验证码时,再从 Session 中取出对应的值进行比对,从而完成验证
这么看来似乎可以解决现在的问题,但随着技术的发展,session 机制渐渐暴露出来很多缺点。现代的后端服务器大多数是 “集群式”,有很多的服务器来共同处理客户端发送的请求,客户端每次发送的请求可能都不是同一个服务器在处理,那么,因为 session 机制是服务器层面的技术,多服务器之间无法完成数据共享,虽然有一些解决办法,但都不太好用
那么就需要找一个好点的解决方法,这个方法就是 Redis,一种键值对型的内存级数据库,可以提供集中式、高性能、可共享的状态存储。简单来说,它跟 session 不在一个状态层面,独立于 web 容器存在,因此各个服务器都可以访问同一个 redis,这样就解决了数据共享的问题。
在第一个请求中,生成验证码之后需要把它存到 redis 数据库里面,Java 提供了 api,相关代码如下:
1 | stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES); |
1 | String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone); |
解决了这个问题,再回到刚刚的登录需求
前面提到过 HTTP 请求是无状态的,那么登录完成之后客户端要知道自己已经登录了,仍然可以用 redis 来存储登录状态。具体实现方案为:
使用 redis 的 string-hash 结构来存储,key 段存储一串 token(可以用随机生成的 UUID),value 段存储用户信息,然后把 token 返回给前端,以后客户端每次请求都携带这个 token,服务端再进行校验即可。
但是,原来的用户类 User 保存了太多的信息,如果直接把它转成 Map 然后存到 redis 里面,再返回给前端的话,HTTP 请求体里面会直接暴露用户的敏感信息,那么则需要一个 “中间类”,只做必要的校验,舍弃掉敏感信息即可,同时应该注意设置有效期,防止用户数量过多把 redis 存储空间占满
到此为止短信验证码登录就完成了
# 拦截器
前面提到,服务端需要校验从客户端发来的 token 以鉴别用户,那么我们不可能在每个 service 方法里面都写个校验,应该把它总结到一个地方,也就是 spring MVC 提供的拦截器(注意区分它与过滤器)
一次 HTTP 请求的执行顺序如下:
1 | 客户端请求 |
首先创建一个类 LoginInterceptor ,它需要继承接口 HandlerInterceptor ,然后根据业务需求,需要实现里面的方法 preHandle ,在这里面做登录校验即可,首先获取 token
1 | String token = request.getHeader("Authorization"); |
然后用这个 token 去数据库里面找用户,如果 token 或用户不存在直接返回 401 即可,如果存在则先把它转成 UserDTO 对象,然后把它存到.....
存到哪?我要在后面的 controller、service 继续使用这个用户,可是我应该把它存在什么地方?
第一个想法是,我的 redis 里面都有这个用户了,直接照着 token 拿不就完了。但是,这里的 token 是前端传来的,要从 HTTP 请求体里面拿,也就是说:
Controller 传一次
Service 再传
每层都要带 token
那么参数会爆炸,而且后续的业务逻辑里面还要写查询,会导致重复的 IO,性能很差且污染业务逻辑
把它塞到 HTTP 请求体里面,然后让后面的 controller 自己 get 一下?也一样会污染参数和业务逻辑。
这里可以用 ThreadLocal,它的作用很简单:同一个线程里,随便在哪,都能拿到这份数据;换一个线程,互相看不见,真实的数据在堆内存中,它只存引用
核心原理是,一次 HTTP 请求就认为是一个线程,那么需要的机制也就是在一次请求内,全局可用,但又不会串请求,这就是 ThreadLocal 干的
1 | // 将Hash数据转为UserDTO对象 |
这里如果不写 saveUser 的操作,当请求走出拦截器代码部分时,userDTO 对象就会被销毁。写了之后则会提升它的作用域,使它一直存在于堆内存中,后续可以用 ThreadLocal 获取,不用传任何参数,本质是把这个 userDTO 变成了逻辑上的全局变量,物理上的线程私有变量
唯一需要注意的是,一定要在 controller 结束后 UserHolder.removeUser() 删掉这次的对象,否则线程池复用可能把上一个请求的数据 “泄露” 到下一个请求,造成严重的安全问题。
到这里为止,短信验证码登录和校验功能都已经完成了,但这里有一个细节性的东西还是可以修改一下。
之前写的拦截器代码里面做了登录状态(即 token)的刷新,但这个拦截器并不是拦截一切路径,它只拦截一些做登录校验的路径。可是如果用户一直在访问比如首页、商户详情页之类不需要登录的页面,一定时间之后他的登录状态会被取消,显然不太合理
改进方法就是再加一个拦截器,把这两个拦截器的功能隔开,一个做 token 刷新,一个负责拦截即可,代码也不用动太多,稍微改改就行
# 商户查询缓存
# 需求分析
由于 redis 的读写速度极快,因此可以用作客户端 - 服务端 - 数据库之间的缓存区域,具体模型如下:

简单写段代码,然后在前端刷新页面,可以发现第一次查询和第二次查询的请求时间明显降低,从秒级到了毫秒级
这里踩了个坑
MyBatis-Plus 的
list()不会返回 null,只会返回:
- 空 List
- 或非空 List
也就是说不能用 list == null 来判空,得用 list.isEmpty() ,用 null 来判断会导致代码变为 “死代码”,永远不会进这个逻辑
# 缓存更新策略
如果数据库的数据更新了但缓存的数据还是老数据,就会导致客户端查询到的数据与数据库的数据出现差异,因此需要及时更新缓存中的数据,常见的缓存更新思路包括先删除缓存再更新数据库、先更新数据库再删除缓存等方式。在并发读写场景下,由于数据库操作与缓存操作之间不存在原子性,可能会出现短暂的数据不一致问题,导致客户端拿到的数据有误



大致思路如上,缓存一致性问题主要来源于并发时序问题(读写竞争)和分布式环境下的操作非原子性,像现在写的单体系统不用在意太多,先了解一下即可。尤其注意一下第三张图的并发问题,相对于缓存操作来说,数据库操作速度极慢。因此先更新数据库再删除缓存,虽然仍存在并发读写导致短暂不一致的可能,但在实际应用中出现概率较低,且可通过缓存过期时间进行最终一致性兜底,故在项目中常用第二种方案
# 缓存穿透
缓存穿透指的是客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会生效,但每次的请求都会发送到数据库。如果有人利用这个漏洞向服务器发送大量无意义的请求数据,很有可能会导致服务器过载以至于崩溃,因此有必要解决这种问题
解决方法有两种:
缓存空对象
![image-20260109175744723]()
具体实现方法为:如果用户发送的请求在缓存和数据库中都不存在,那么也会缓存一个 null 数据到缓存中
优点是实现简单,维护方便,可操作性比较大
缺点是会导致额外的内存消耗及可能会导致短期的数据不一致
布隆过滤
这个东西暂时不用深入了解原理,大致知道它是用哈希算法进行的,不用真正存储数据库所有数据的一种方法即可
![image-20260109180034742]()
- 优点是占用内存较少,不用存储多余的 key
- 缺点是实现复杂且有误判可能(若被布隆过滤器识别为存在不一定是真的存在,这样的话仍可能出现穿透现象)
上面两个方法是较为常见的被动防御缓存穿透,但实际上还有一些主动防御的方法,比如对请求进行校验,不符合要求的直接拦截等方法。这里先不深入了
知道了缓存穿透这个现象,那就应该修改一下前面的缓存代码,采用的是第一种方法,相关代码为:
1 | // 防御缓存穿透相关代码,留作备份 |
# 缓存雪崩
缓存雪崩指的是同一时段大量的缓存 key 同时失效或者 Redis 服务宕机,导致大量请求直接打入数据库带来巨大访问压力
解决方法有下面这些:
- 给不同 Key 的 TTL 添加几分钟的随机值
- 利用 Redis 集群提高服务的可用性(微服务集群方面)
- 给缓存业务添加降级限流
- 给业务添加多级缓存(浏览器端、JVM 端等)
代码方面采用的是简单的第一种
# 缓存击穿
也称为热点 key 问题,指的是一个被高并发访问且缓存业务重建较为复杂的 key 突然失效了,大量访问请求会瞬间冲向数据库以过载。
缓存业务重建较为复杂指的是某些数据不仅仅是查一个数据库而得,可能需要多表查询甚至进行大量计算才可以得到,缓存重建的时间比较久的一种情况。此时它又是一个热点 key,有大量并发线程来访问,那么就会出现这种情况:
在重建缓存过程中,大量线程都会未命中缓存,导致大量线程同时查询数据库并进行缓存重建,这一超高爆发对数据库会带来极大冲击
解决方法有两个:互斥锁和逻辑过期


要解决的问题只有一个:防止多个线程一块去查询数据库导致数据库访问量过多而崩溃。只能让一个线程去查数据库
这边我的代码里面两种方法都写了,留作一个备份
# 互斥锁
这里没有用 Java 提供的 lock 机制,而是通过 redis 的 setnx 关键字来实现简单自定义互斥锁,具体操作看代码即可,大致思路为

一开始写的原始代码长这样:
1 | public Shop queryWithMutex(Long id){ |
有几个点需要注意,也就是我代码的修改处
在 try-catch-finally 里面有这样一句: return queryWithMutex(id) ,执行顺序是先 queryWithMutex (id)-> 保存返回值 ->finally-> 返回返回值。
那么中间有这样一段逻辑:
1 | public Shop queryWithMutex(Long id) { |
模拟一下:
一个线程来了尝试获取锁,如果没有拿到则进入 if 语句进行递归 -> 第二次递归假设拿到了锁
那么在第二次递归中,由于它拿到了锁,那么就不会进 if 语句,继续下面的查询数据库 -> 添加缓存 -> 走到 finally 里面释放锁 -> 返回结果到上一层递归,然后由上一层递归再返回出去。
似乎没问题?但实际上当上一层(也就是第一层)递归在返回之前,它也要进一次 finally 并进行 unlock 释放锁,如果说在释放锁之前有另一个线程刚好拿到锁,由于我的代码中锁的 key 是一致的 String keyLock = LOCK_SHOP_KEY + id ,那么此时就会把另一个线程的锁释放掉
改进了之后出现的第二个问题是,我加的锁是有 TTL 的,那么就不可避免地会出现锁过期的现象,可以预料到这样的场景:
A 线程还没有执行完,他的锁就过期了。此时有一个线程 B 拿到了锁,如果刚好 A 线程进了 finally 段,此时它的 isLock 校验是可以过的,那么就把线程 B 的锁释放掉了。
解决问题也很简单,既然可能会导致误删,那么我给每个线程拿到的锁都做一个特定标识,在释放锁的时候都去校验一下这个标识不就好了?标识可以对 value 进行操作,因为前面的代码里面这个值都是固定的 “1”,正好可以用来做唯一性标识
思路没问题,改一下代码即可,释放锁那里的代码一开始可能会想着改成这样:
1 | String valueInRedis = stringRedisTemplate.opsForValue().get(key); |
既然我要根据 value 来判断,那我直接取出来比一下就行了
在以往的代码中,这种思路没什么问题,因为写的都是单线程。可是在多线程模式下这个思路会出现很大的安全隐患
中间可能发生并发:
- 线程 A
get得到 value"v1",然后经过了校验 - 线程 A 还没
delete - 线程 B 抢到了锁,把 key 设置成
"v2" - 线程 A 执行
delete→ 删掉了 B 的锁
那么这里就应该引入一个新的概念,用 Redis 支持的 内嵌 Lua 脚本,可以把多条命令包装成单条原子操作
Lua 是一种轻量级脚本语言:
- 语法简单、像 Python / JavaScript 一样动态
- 内存占用小,执行效率高
- 适合嵌入到其他程序里当 “脚本引擎” 使用
没学过这东西,代码里的 Lua 是 ai 写的,这里贴一下各个段落的作用:
1 | if redis.call('get', KEYS[1]) == ARGV[1] then |
1 | stringRedisTemplate.execute( |
| 参数 | 含义 |
|---|---|
UNLOCK_SCRIPT | 要执行的 Lua 脚本封装对象,包含脚本内容和返回类型 |
Collections.singletonList(key) | Lua 脚本的 KEYS 数组,在 Lua 里通过 KEYS[1] 获取 |
value | Lua 脚本的 ARGV 数组,在 Lua 里通过 ARGV[1] 获取 |
跑了个测试类,只有第一个线程去查了数据库,其它的 49 个都走的缓存,目的应该是达到了
毕竟本地的数据库访问速度挺快的,也懒得再去模拟其他情况了。没看出来啥问题,就到这里吧
1 | @Test |
# 逻辑过期

这里默认一定命中,若未命中则认为不是热点 key,不需要处理
对于我的代码:
1 | // 创建10个线程池 |
有一些点提一下,做个笔记。
这里的线程池是懒创建的,当第一次出现 submit ,发生的事情是:
- 线程池发现:当前线程数 < 10
- 创建一个新线程
- 用这个线程执行你的任务
第二次 submit ,如果第一个线程还在忙,那就再创建一个新线程
直到最多创建 10 个线程时,再多任务 → 排队
也就是说它们不会一开始就创建 10 个。
submit 那里用的 lambda 表达式,原语法应该为
1 | new Runnable() { |
写到这里突然发现一件事,就是前面的几个问题都有多种解决方案,我想都留存下来作为学习用,但是都写在项目文件里面然后用注释来保存感觉也太丑了。。。而且真实项目肯定也不会这么干。同时,Service 层不应该做这么多东西,把很多方法都写在里面显得很臃肿
联想到之前学的一些配置类,应该可以把自己写的各种方法也作为一种 “策略”,然后提供接口,再用类似配置类的形式规定接口使用哪个实现,这样既方便后续更改,也不会把项目代码搞得太丑,还保证了 Service 层的功能性
方案有了,那么就来实现(大量借助 ai。。。。 code 水平真的有待提升)
首先创建了一个 ShopCacheStrategy 接口,用于 Service 层的调用,然后创建两个类作为实现类,分别实现对应的 query 方法。
同时我新建了一个工具类包,用于存放一些工具类,我把取与释放锁的相应代码写到了里面,保证一下 Service 的简洁。
一开始我没有动太多,就是直接把代码挪到不同地方而已。但这样做会导致实现类里面仍然需要调用 getById 方法,也就是说我还需要让策略实现类继承那个 mp 父类,这显然有点 “越权” 嫌疑。
那么,如果我的这些类里面不能出现 getById ,那应该怎么查询数据库?其实这里面根本就不应该查询数据库,因为它只是一个解决问题的实现类,不应该让它与数据库产生交互
但是我重建缓存的时候又得进行 double check,不查数据库怎么看?
可以用 Supplier<T>
Supplier<T> 是 Java 8 的一个 函数式接口,定义在 java.util.function 包里:
1 | @FunctionalInterface |
它很简单:就是一个没有参数,但会返回一个值的 “工厂 / 提供者”。
- 调用
get()方法,返回一个T类型的对象。 - 它没有参数,只有返回值。
也就是说,我可以用它的 get 方法获取一个类型为 T 的对象。那么这个对象怎么来?从数据库里面查。在哪查?不能在实现类里面查,应该在 Service 里面查
所以我在 Service 层里面写的是:
1 | Shop shop = cacheStrategy.queryById(id, () -> getById(id)); |
第二个参数为 lambda 表达式, () 表示没有参数, getById(id) 表示 supplier 的返回值。
当调用 dbFallback.get() 时,代码才会走这个 getById 来获得 shop 对象并返回,而这一过程是在调用处的类进行的,而不是在实现类处进行的
方案切换的话,因为方案比较少,所以我简单写了个 Config 类,用原生的 Spring 方案结合 yaml 配置文件来更改配置,这样更换实现类的时候只用改一下配置文件即可
1 | shop: |

