学后端到现在也算是开始写第一个可以称之为项目的东西了,虽然都在讲 xx 点评、xx 外卖之类的项目没含金量,我也觉得简历里面写这种东西没什么竞争力,但花一两个星期写完我不认为不会有任何收获,看的也就是 redis - 黑马点评的那个视频

这个项目的前端页面已经提供了,我只需要完成后端接口即可。以各个功能模块的代码作为段落进行解析,记录一下写这个项目遇到的各种问题和学到的、弄明白的各种东西,所以不会贴完整的代码,完整代码会放到 Github 上,等写完了再传,欢迎品鉴 shi 山(

技术栈大概是 springboot、mysql、redis、mybatisplus 这些

# 准备工作

下载 redis 并导入到 wsl 里面,wsl 就作为我的 redis 服务器了,挂着。视频提供了 sql 文件,创建一个数据库然后导入一下,相关命令为:

1
SOURCE hmdp.sql;

配置文件里配置一下 redis 和 mysql:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/hmdp?useSSL=false&serverTimezone=UTC
username: xx
password: xx
redis:
host: 127.0.0.1
port: 6379
lettuce:
pool:
max-active: 10
max-idle: 10
min-idle: 1
time-between-eviction-runs: 10s

还有前端环境一些杂七杂八的,挺简单的没学到什么东西,懒得写了。

# 短信验证码登录

# 需求分析和实现

第一个功能模块是做手机号 - 短信验证码登录的功能

image-20260108152319856

前端这里,当用户输入手机号并点击 “发送验证码” 的时候需要向后端请求验证码,验证码发送之后点击 “登录”,又需要发送到后端进行验证

第一个请求是发送验证码,那么后端这里接收到的就是手机号,首先应该校验的就是手机号是否是正确的手机号,可以用正则来一个 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
2
3
4
5
6
7
8
9
10
11
12
13
客户端请求

Filter(编码 / 跨域等)

DispatcherServlet

Interceptor.preHandle()

Controller

Interceptor.postHandle()

Interceptor.afterCompletion()

首先创建一个类 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
2
3
// 将Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
UserHolder.saveUser(userDTO);

这里如果不写 saveUser 的操作,当请求走出拦截器代码部分时,userDTO 对象就会被销毁。写了之后则会提升它的作用域,使它一直存在于堆内存中,后续可以用 ThreadLocal 获取,不用传任何参数,本质是把这个 userDTO 变成了逻辑上的全局变量,物理上的线程私有变量

唯一需要注意的是,一定要在 controller 结束后 UserHolder.removeUser() 删掉这次的对象,否则线程池复用可能把上一个请求的数据 “泄露” 到下一个请求,造成严重的安全问题。


到这里为止,短信验证码登录和校验功能都已经完成了,但这里有一个细节性的东西还是可以修改一下。

之前写的拦截器代码里面做了登录状态(即 token)的刷新,但这个拦截器并不是拦截一切路径,它只拦截一些做登录校验的路径。可是如果用户一直在访问比如首页、商户详情页之类不需要登录的页面,一定时间之后他的登录状态会被取消,显然不太合理

改进方法就是再加一个拦截器,把这两个拦截器的功能隔开,一个做 token 刷新,一个负责拦截即可,代码也不用动太多,稍微改改就行

# 商户查询缓存

# 需求分析

由于 redis 的读写速度极快,因此可以用作客户端 - 服务端 - 数据库之间的缓存区域,具体模型如下:

image-20260109141417298

简单写段代码,然后在前端刷新页面,可以发现第一次查询和第二次查询的请求时间明显降低,从秒级到了毫秒级

这里踩了个坑

MyBatis-Plus 的 list() 不会返回 null,只会返回:

  • 空 List
  • 或非空 List

也就是说不能用 list == null 来判空,得用 list.isEmpty() ,用 null 来判断会导致代码变为 “死代码”,永远不会进这个逻辑

# 缓存更新策略

如果数据库的数据更新了但缓存的数据还是老数据,就会导致客户端查询到的数据与数据库的数据出现差异,因此需要及时更新缓存中的数据,常见的缓存更新思路包括先删除缓存再更新数据库、先更新数据库再删除缓存等方式。在并发读写场景下,由于数据库操作与缓存操作之间不存在原子性,可能会出现短暂的数据不一致问题,导致客户端拿到的数据有误

image-20260109162211859

image-20260109161458731

image-20260109162044800

大致思路如上,缓存一致性问题主要来源于并发时序问题(读写竞争)和分布式环境下的操作非原子性,像现在写的单体系统不用在意太多,先了解一下即可。尤其注意一下第三张图的并发问题,相对于缓存操作来说,数据库操作速度极慢。因此先更新数据库再删除缓存,虽然仍存在并发读写导致短暂不一致的可能,但在实际应用中出现概率较低,且可通过缓存过期时间进行最终一致性兜底,故在项目中常用第二种方案

# 缓存穿透

缓存穿透指的是客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会生效,但每次的请求都会发送到数据库。如果有人利用这个漏洞向服务器发送大量无意义的请求数据,很有可能会导致服务器过载以至于崩溃,因此有必要解决这种问题

解决方法有两种:

  1. 缓存空对象

    image-20260109175744723

    • 具体实现方法为:如果用户发送的请求在缓存和数据库中都不存在,那么也会缓存一个 null 数据到缓存中

    • 优点是实现简单,维护方便,可操作性比较大

    • 缺点是会导致额外的内存消耗及可能会导致短期的数据不一致

  2. 布隆过滤

    这个东西暂时不用深入了解原理,大致知道它是用哈希算法进行的,不用真正存储数据库所有数据的一种方法即可

    image-20260109180034742

    • 优点是占用内存较少,不用存储多余的 key
    • 缺点是实现复杂且有误判可能(若被布隆过滤器识别为存在不一定是真的存在,这样的话仍可能出现穿透现象)

上面两个方法是较为常见的被动防御缓存穿透,但实际上还有一些主动防御的方法,比如对请求进行校验,不符合要求的直接拦截等方法。这里先不深入了

知道了缓存穿透这个现象,那就应该修改一下前面的缓存代码,采用的是第一种方法,相关代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
    // 防御缓存穿透相关代码,留作备份
// public Shop queryWithPassThrough(Long id){
// String key = CACHE_SHOP_KEY + id;
// // 从redis查询缓存
// String shopJson = stringRedisTemplate.opsForValue().get(key);
//
// // 注意这里的isNotBlank仅仅判断查询到的shopJson有没有值,null、空值等不包括在内,因此下面还要做一次校验
// if (StrUtil.isNotBlank(shopJson)){
// return JSONUtil.toBean(shopJson, Shop.class);
// }
//
// // 如果查询到的不是null,那么就只能是我们设置的空值
// if (shopJson != null) {
// return null;
// }
//
// Shop shop = getById(id);
// // 如果数据库里面都不存在,则需要先缓存一下空值,然后返回fail
// if (shop == null){
// stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL + RandomUtil.randomLong(1,6),TimeUnit.MINUTES);
// return null;
// }
// // 若存在则先保存到redis,然后返回给用户
// stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL + RandomUtil.randomLong(1,6), TimeUnit.MINUTES);
//
// return shop;
// }

# 缓存雪崩

缓存雪崩指的是同一时段大量的缓存 key 同时失效或者 Redis 服务宕机,导致大量请求直接打入数据库带来巨大访问压力

解决方法有下面这些:

  • 给不同 Key 的 TTL 添加几分钟的随机值
  • 利用 Redis 集群提高服务的可用性(微服务集群方面)
  • 给缓存业务添加降级限流
  • 给业务添加多级缓存(浏览器端、JVM 端等)

代码方面采用的是简单的第一种

# 缓存击穿

也称为热点 key 问题,指的是一个被高并发访问且缓存业务重建较为复杂的 key 突然失效了,大量访问请求会瞬间冲向数据库以过载。

缓存业务重建较为复杂指的是某些数据不仅仅是查一个数据库而得,可能需要多表查询甚至进行大量计算才可以得到,缓存重建的时间比较久的一种情况。此时它又是一个热点 key,有大量并发线程来访问,那么就会出现这种情况:
image-20260109185036688

在重建缓存过程中,大量线程都会未命中缓存,导致大量线程同时查询数据库并进行缓存重建,这一超高爆发对数据库会带来极大冲击

解决方法有两个:互斥锁和逻辑过期

image-20260109214318273

要解决的问题只有一个:防止多个线程一块去查询数据库导致数据库访问量过多而崩溃。只能让一个线程去查数据库


这边我的代码里面两种方法都写了,留作一个备份

# 互斥锁

这里没有用 Java 提供的 lock 机制,而是通过 redis 的 setnx 关键字来实现简单自定义互斥锁,具体操作看代码即可,大致思路为

image-20260110161526883

一开始写的原始代码长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public Shop queryWithMutex(Long id){
String key = CACHE_SHOP_KEY + id;
// 从redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);

// 注意这里的isNotBlank仅仅判断查询到的shopJson有没有值,null、空值等不包括在内,因此下面还要做一次校验
if (StrUtil.isNotBlank(shopJson)){
return JSONUtil.toBean(shopJson, Shop.class);
}

// 如果查询到的不是null,那么就只能是我们设置的空值
if (shopJson != null) {
return null;
}

// 到这为止就是“未命中”,需要尝试获取互斥锁
String keyLock = LOCK_SHOP_KEY + id;
Shop shop = null;
try {

// 如果锁获取失败,那么需要休眠一段时间再来获取,用递归实现
if (!tryLock(keyLock)) {
Thread.sleep(50);
return queryWithMutex(id);
}

shop = getById(id);
// 如果数据库里面都不存在,则需要先缓存一下空值,然后返回fail
if (shop == null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL + RandomUtil.randomLong(1,6),TimeUnit.MINUTES);
return null;
}
// 若存在则先保存到redis,然后返回给用户
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL + RandomUtil.randomLong(1,6), TimeUnit.MINUTES);

} catch (Exception e) {
throw new RuntimeException(e);
}finally {
unlock(keyLock);
}
return shop;
}

有几个点需要注意,也就是我代码的修改处


在 try-catch-finally 里面有这样一句: return queryWithMutex(id) ,执行顺序是先 queryWithMutex (id)-> 保存返回值 ->finally-> 返回返回值

那么中间有这样一段逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
public Shop queryWithMutex(Long id) {
boolean isLock = tryLock();

try {
if (!tryLock(keyLock)) {
Thread.sleep(50);
return queryWithMutex(id);
}
} finally {
unlock(keyLock);
}
}

模拟一下:
一个线程来了尝试获取锁,如果没有拿到则进入 if 语句进行递归 -> 第二次递归假设拿到了锁

那么在第二次递归中,由于它拿到了锁,那么就不会进 if 语句,继续下面的查询数据库 -> 添加缓存 -> 走到 finally 里面释放锁 -> 返回结果到上一层递归,然后由上一层递归再返回出去。

似乎没问题?但实际上当上一层(也就是第一层)递归在返回之前,它也要进一次 finally 并进行 unlock 释放锁,如果说在释放锁之前有另一个线程刚好拿到锁,由于我的代码中锁的 key 是一致的 String keyLock = LOCK_SHOP_KEY + id ,那么此时就会把另一个线程的锁释放掉


改进了之后出现的第二个问题是,我加的锁是有 TTL 的,那么就不可避免地会出现锁过期的现象,可以预料到这样的场景:

A 线程还没有执行完,他的锁就过期了。此时有一个线程 B 拿到了锁,如果刚好 A 线程进了 finally 段,此时它的 isLock 校验是可以过的,那么就把线程 B 的锁释放掉了。

解决问题也很简单,既然可能会导致误删,那么我给每个线程拿到的锁都做一个特定标识,在释放锁的时候都去校验一下这个标识不就好了?标识可以对 value 进行操作,因为前面的代码里面这个值都是固定的 “1”,正好可以用来做唯一性标识

思路没问题,改一下代码即可,释放锁那里的代码一开始可能会想着改成这样:

1
2
3
4
String valueInRedis = stringRedisTemplate.opsForValue().get(key);
if (lockValue.equals(valueInRedis)) {
stringRedisTemplate.delete(key);
}

既然我要根据 value 来判断,那我直接取出来比一下就行了

在以往的代码中,这种思路没什么问题,因为写的都是单线程。可是在多线程模式下这个思路会出现很大的安全隐患

中间可能发生并发:

  • 线程 A get 得到 value "v1" ,然后经过了校验
  • 线程 A 还没 delete
  • 线程 B 抢到了锁,把 key 设置成 "v2"
  • 线程 A 执行 delete删掉了 B 的锁

那么这里就应该引入一个新的概念,用 Redis 支持的 内嵌 Lua 脚本,可以把多条命令包装成单条原子操作

Lua 是一种轻量级脚本语言:

  • 语法简单、像 Python / JavaScript 一样动态
  • 内存占用小,执行效率高
  • 适合嵌入到其他程序里当 “脚本引擎” 使用

没学过这东西,代码里的 Lua 是 ai 写的,这里贴一下各个段落的作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
/*
KEYS[1] → Redis 脚本的第一个 key,在 Java 里传的是锁 key
ARGV[1] → Redis 脚本的第一个参数,在 Java 里传的是锁 value(UUID token)
redis.call('get', KEYS[1]) → 拿到当前锁的 value
判断 get(key) 是否等于我们自己的 token(保证安全解锁)
相等 → 删除锁 (del) 并返回 1
不相等 → 不删除,返回 0
*/

1
2
3
4
5
stringRedisTemplate.execute(
UNLOCK_SCRIPT, // Lua 脚本
Collections.singletonList(key),// KEYS
value // ARGV
);

参数含义
UNLOCK_SCRIPT要执行的 Lua 脚本封装对象,包含脚本内容和返回类型
Collections.singletonList(key)Lua 脚本的 KEYS 数组,在 Lua 里通过 KEYS[1] 获取
valueLua 脚本的 ARGV 数组,在 Lua 里通过 ARGV[1] 获取

跑了个测试类,只有第一个线程去查了数据库,其它的 49 个都走的缓存,目的应该是达到了

毕竟本地的数据库访问速度挺快的,也懒得再去模拟其他情况了。没看出来啥问题,就到这里吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Test
public void testConcurrentQuery() throws InterruptedException {
Long id = 1L;

stringRedisTemplate.delete(CACHE_SHOP_KEY + id); // 模拟缓存未命中

// 模拟50个并发请求
int threadCount = 50;
CountDownLatch latch = new CountDownLatch(threadCount);


// 循环启动50个线程,第一个线程拿到锁去查询数据库,然后存到缓存里面,其它的线程阻塞等待第一个线程拿到数据或者超时返回null
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
Shop shop = (Shop) shopService.queryById(id).getData();
System.out.println(Thread.currentThread().getName() + ": " + shop);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
latch.countDown();
}
}).start();
}

latch.await();
}

# 逻辑过期

image-20260117112839749

这里默认一定命中,若未命中则认为不是热点 key,不需要处理

对于我的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 创建10个线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

// 逻辑过期解决缓存穿透
public Shop queryWithLogicalExpire(Long id){
String key = CACHE_SHOP_KEY + id;
// 从redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);

// 这里默认一定命中,若未命中则认为不是热点key,不需要处理
if (StrUtil.isBlank(shopJson)){
return null;
}

// 若命中则需要判断缓存是否过期,先把查询到的redis字符串反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson,RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(),Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();

// 判断逻辑是否过期,若未过期则直接返回即可,如果过期,则需要利用互斥锁,让一个线程去重建缓存,其它的线程先直接返回过期信息即可
if (expireTime.isAfter(LocalDateTime.now())){
return shop;
}

String keyLock = LOCK_SHOP_KEY + id;
String valueLock = UUID.randomUUID().toString();
boolean isLock = tryLock(keyLock,valueLock);

// 拿到锁了之后新创建一个新的线程,让它去重建缓存,自己先直接返回旧数据
if (isLock){
// 拿到锁之后仍然要做一次double check
shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
redisData = JSONUtil.toBean(shopJson,RedisData.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())){
shop = JSONUtil.toBean((JSONObject) redisData.getData(),Shop.class);
unlock(keyLock,valueLock);
return shop;
}
}

CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id,20L);
} catch (Exception e){
// 异步线程抛异常不会回到主线程,因此其抛异常没有什么意义,应该做一下日志
log.error("缓存重建失败,shopId={}", id, e);
}finally{
unlock(keyLock,valueLock);
}
});
}

return shop;
}

有一些点提一下,做个笔记。

这里的线程池是懒创建的,当第一次出现 submit ,发生的事情是:

  1. 线程池发现:当前线程数 < 10
  2. 创建一个新线程
  3. 用这个线程执行你的任务

第二次 submit ,如果第一个线程还在忙,那就再创建一个新线程

直到最多创建 10 个线程时,再多任务 → 排队

也就是说它们不会一开始就创建 10 个。

submit 那里用的 lambda 表达式,原语法应该为

1
2
3
4
5
6
new Runnable() {
@Override
public void run() {
this.saveShop2Redis(id, 20L);
}
}


写到这里突然发现一件事,就是前面的几个问题都有多种解决方案,我想都留存下来作为学习用,但是都写在项目文件里面然后用注释来保存感觉也太丑了。。。而且真实项目肯定也不会这么干。同时,Service 层不应该做这么多东西,把很多方法都写在里面显得很臃肿

联想到之前学的一些配置类,应该可以把自己写的各种方法也作为一种 “策略”,然后提供接口,再用类似配置类的形式规定接口使用哪个实现,这样既方便后续更改,也不会把项目代码搞得太丑,还保证了 Service 层的功能性

方案有了,那么就来实现(大量借助 ai。。。。 code 水平真的有待提升)

首先创建了一个 ShopCacheStrategy 接口,用于 Service 层的调用,然后创建两个类作为实现类,分别实现对应的 query 方法。

同时我新建了一个工具类包,用于存放一些工具类,我把取与释放锁的相应代码写到了里面,保证一下 Service 的简洁。

一开始我没有动太多,就是直接把代码挪到不同地方而已。但这样做会导致实现类里面仍然需要调用 getById 方法,也就是说我还需要让策略实现类继承那个 mp 父类,这显然有点 “越权” 嫌疑。

那么,如果我的这些类里面不能出现 getById ,那应该怎么查询数据库?其实这里面根本就不应该查询数据库,因为它只是一个解决问题的实现类,不应该让它与数据库产生交互

但是我重建缓存的时候又得进行 double check,不查数据库怎么看?

可以用 Supplier<T>

Supplier<T> 是 Java 8 的一个 函数式接口,定义在 java.util.function 包里:

1
2
3
4
@FunctionalInterface
public interface Supplier<T> {
T get();
}

它很简单:就是一个没有参数,但会返回一个值的 “工厂 / 提供者”。

  • 调用 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
2
3
shop:
cache:
strategy: mutexStrategy