《引言》
在进行了前面关于 Redis 基础篇及其客户端的学习之后,开始着手进行实战篇的学习。因内容很多,所以将会分为【 上 中 下 】三篇记录学习的内容与在学习的过程中解决问题的方法。Redis 实战篇的内容我写的很详细,为了能写的更好也付出了心血,希望大家多多点赞支持 ψ(*`ー´)ψ
传送门(上):当-前-页
传送门(中):Redis 实战篇 ——《黑马点评》(中)
传送门(下):NULL
目录
一、短信登录功能
1. 基于 Session 的登录功能
在进行学习前,必不可少的一步就是将其准备好的资料中的后端部分、前端 Nginx 部分及数据库的 sql 脚本进行导入。
步骤为:
① 将资料文件中的 hmdp.sql 文件导入到 MySQL 数据库中。这里我使用的是 DataGrip 软件进行 sql 文件的导入的。
② 导入后端工程,启动时需要对配置文件进行检查,看看数据库的连接配置是否正确,如果不正确启动时会报错。
③ 其中在导入前端工程时,只需将 Nginx 启动即可,但我出现了启动后无法访问的到网址的问题,在文件夹下创建了两个名为 temp 和 client_body_temp 的文件夹后即可正常启动 Nginx 访问 8080 端口显示页面。
想要实现基于 Session 的登录功能,我们需要分为三个步骤来逐步实现:
1.1. 发送短信验证码
如下图所示,可以看到在点击发送验证码之后,会向服务端发送一个请求,我们需要实现这个接口来完成发送短信验证码的功能。
按照 controller → service → serviceImpl 的顺序去创建 sendCode() 方法,最终由 controller 层调用此方法实现发送短信验证码的功能。
而在该方法内,我们需要完成发送验证码的逻辑实现,其实现步骤分为五步:
1.校验手机号
2.不符合,返回错误信息
3.符合,生成验证码
4.保存验证码到session
5.模拟发送验证码
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//2.不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到session
session.setAttribute("code", code);
//5.模拟发送验证码
log.debug("发送验证码成功,验证码:{}", code);
return Result.ok();
}
● 第一步:通过项目自带的 utils 工具包下的 RegexUtils 来对传来的手机号码 phone 进行校验,格式不对的号码会返回 true。
● 第二步:号码校验结果为不符合规范后,返回封装好的返回结果类,其内有对应不同情况的静态方法,有利于返回结果的统一性与规范性。
● 第三步:号码校验结果为符合规范后,将使用引入的 hutool 糊涂工具包中的随机数生成工具类来生成长度为 6 的验证码。
● 第四步:将生成的验证码保存到 session 中。
● 第五步:因想要发送验证码需要调用第三方平台,非常麻烦,所以此处我们使用模拟发送的方式来进行发送。注意此处需调用 Slf4j 的 log.debug() 方法,否则会因原有的 log 接口中的 debug 方法不接受带有占位符的字符串格式化,只接受单一字符串参数而报错,需要在类上加上 @Slf4j 注解。
(工具包真爽啊。。。)
最终实现功能展示效果如下:
1.2. 短信验证码登录
在上一个功能完成后,我们可以接着完成登录的功能了,可以看到登录发送的请求如下图所示(注意登录时需勾选同意协议),接下来我们就要实现该功能了。其请求如下:
注意此处使用 json 格式提交到后端,所以此处使用 @RequestBody 注解配合实体类接收。
因登录方式有验证码登录和密码登录两种,所以类中存在 password 属性。
继续之前的操作,接着在 serviceImpl 中实现我们的登录方法。
首先需要明确一点,该项目的登录与注册功能是一起的,在登录时如果检测到用户不存在则会自动为其创建一个新用户。
其功能的实现也可分为三步逐步完成:
1.校验手机号和验证码
2.根据手机号查询用户
3.存在,保存用户信息;不存在,新建用户
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
//1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//1.2.不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//2.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)){
//2.1.不一致,报错
return Result.fail("验证码错误");
}
//2.2.一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
//3.1.用户不存在,创建新用户
if (user == null){
user = createUserWithPhone(phone);
}
//3.2.保存用户信息到 Session 中
session.setAttribute("user", user);
return Result.ok();
}
private User createUserWithPhone(String phone) {
//1.初始化新用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
//2.保存用户
save(user);
return user;
}
● 第一步:校验手机号,与前面的方法相同(ctrl + c、ctrl + v);校验验证码,从 loginFrom 对象中取出验证码与从 session 中取出的 code 进行比较,不一致则报错,一致则继续之后的逻辑。
● 第二步:继续判断用户是否于数据库中存在,这里我们使用到 MyBatisplus 来简化开发,因该类继承了 ServiceImpl 类且泛型中传入了对应的 Mapper 与 User,所以其直接生成了对应类的相关方法,可以直接使用。如:query().eq("phone", phone).one(); 就是返回查询手机号码与 phone 相同的一个数据。
● 第三步:判断用户是否存在,若存在,则直接将用户保存在 session 中,故不用再将登录凭证返回,因为 session 本身具备唯一性(session ID),会自动写到 cookie 中,以后的请求都会携带 session,随时找到;若不存在,则走注册新用户的步骤,在 createUserWithPhone()方法中初始化用户,只用对 Phone 及 NickName(昵称)赋值即可,昵称使用前缀 + 随机字符串的格式进行初始化,之后使用 Mybatisplus 的 save()方法存入数据库中,最后将创建好的用户信息返回后再存入到 session 中。
此处的前缀使用的是在常量包下定义好的系统常量(看着高端...)
完成该功能后,因为我们还没有实现登录校验的功能,所以登录后会弹出登录状态。
1.3. 登录校验
在实现上一功能后,我们继续实现登录校验的功能,其请求如下图所示:
首先,我们有许多的功能模块都需要进行登录校验,我们不可能在每个功能的 controller 层中都编写相同的校验代码,所以我们就需要用到 Springmvc 中的拦截器,在所有的 controller 执行之前去进行登录校验。而数据的传递就要用到 Thread Local 保存数据,保证线程的安全。
1.定义拦截器
2.编写配置类
3.实现逻辑
● 第一步:首先我们需要定义一个拦截器类实现 HandlerInterceptor 接口并 Alt + Insert 选择实现其中的 afterCompletion 和 preHandle 方法。
在 preHandle 中实现校验登录状态的功能:
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取 session
HttpSession session = request.getSession();
//2.获取 session 中的用户信息
Object user = session.getAttribute("user");
//3.判断用户是否存在
if (user == null){
//4.不存在则拦截
response.setStatus(401);
return false;
}
//5.存在,保存信息到 ThreadLocal 后放行
UserHolder.saveUser((User)user);
return true;
}
获取传入的请求中的 session 并取出其中的用户信息(ps:此处的用户信息是在登录时写入session 中的,1.2. 实现的功能),并进行校验,不存在则在响应中设置状态码为 401 表示身份校验失败,返回 false 表示拦截该请求。存在则将用户信息保存到 ThreadLocal 中并返回 true 放行。
注意项目中将 user 强转为 User 类会报错,因资料中代码已将对应方法的参数类型更改为了UserDTO,但视频里是在后面改进时修改的,所以这里报错是正常现象,后面会更改。
其中的 UserHolder 是已经在工具包中写好的用于存储 User 类用户信息的 ThreadLocal。
之后,在请求处理完成后执行的 afterCompletion 方法中移除用户信息防止泄露。
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户信息
UserHolder.removeUser();
}
● 第二步:编写配置类,添加新创建的拦截器,并设置需要排除拦截的路径。
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
);
}
}
注意类上需加上 @Configuration 注解标识该类为配置类从而被扫描识别。
● 第三步:在对应 controller 层方法处返回获取到的用户信息。
@GetMapping("/me")
public Result me(){
//获取当前登录用户并返回
return Result.ok(UserHolder.getUser());
}
但注意此时的项目存在问题,后端返回的用户信息过于全面(甚至密码都返回... ),这样会导致用户信息的泄露,所以我们要改进,这就需要我们将之前的 User 类转为 UserDTO 类,其内只包含必要的信息字段(id、昵称、头像),更适合展示。
我们可以使用前面用过的 hutool 工具包中的 BeanUtil 中的 copyProperties 方法(老朋友了...)进行具有相似属性名的类间的转换。
将第五步中保存信息步骤中报错的强转 User 类更改为为 BeanUtil 拷贝属性即可。
//5.存在,保存信息到 ThreadLocal 后放行
UserHolder.saveUser(BeanUtil.copyProperties(user, UserDTO.class));
return true;
最终效果如下所示:
2. 集群的 session 共享问题及解决
我们知道,当你解决了一个难题时,新的问题又会接踵而至的出现。为了应对并发问题,需要部署多台 tomcat 服务器来实现负载均衡,解决高并发的问题。但是随之而来的是多台 tomcat 之间并不共享 session 的存储空间,这就导致会出现在 1 号 tomcat 上我登录成功了,可如果此时均衡到 2 号 tomcat 上时会提示我 “ 请登录... ”。
想要解决这个问题,其实很简单,只需让 session 的存储空间共享即可,而 tomcat 早期提供了 session 拷贝的功能,但因其会造成不必要的内存空间的浪费和延迟的存在而被 Pass。
如此一来,就需要能够替代 session 的产品来实现相同的功能如数据共享、内存存储(读写速度快)、key-value(结构简单)这几个功能。(Redis:这不就是我吗;快忘了原来是要学 Redis 的了...)
2.1. 分析替代后的变化
● 在写入数据时,想要将原先保存在 session 中的验证码保存在 Redis 中,因在 session 中我们将 code 直接作为 key,只是因为每一个浏览器都有独立的 session,所以即使存在相同的 code,但是相互之间不会影响。但是 Redis 的内存空间是共享的,所以相同的 key 会进行数据的覆盖,造成验证码的丢失。所以 Redis 中 key 的设计需要确保其唯一性,所以我们选择使用手机号作为 key。
● 而在取出数据时,tomcat 会自动维护 session,创建时自动生成 session ID 写入 cookie 中,可以自动从 session 中获取数据。以手机号作为 key 来读取验证码进行校验,校验通过后以Hash 结构保存在 Redis 中,相较于 String 结构可以对对象中的单个字段进行操作。
● 在保存用户信息时,我们选择使用随机的 token 作为 key 进行存储,并将其作为登录凭证,但 tomcat 并不会将 token 自动写到浏览器中,我们只能手动返回浏览器保存。
其在前端通过拦截器将 token 添加到请求头中,而后端就可以从请求头中获取数据。但因 key 最后会返回给前端,直接返回以手机号为 key 到浏览器保存,会存在数据泄露的风险。所以最终决定使用 token 这一更安全的方式作为 key。
其中我们的前端将 token 定义为 authorization 。
2.2. Redis 替代 Session 的代码实现
想要使用 Redis 替代 Session,我们需要进行三处代码逻辑的修改。首先要做的,就是要将 SpringDataRedis 的 API 注入到 controller 层中再进行代码的改进:
① 发送验证码:需要将保存验证码到 session 更改为保存到 Redis 中,其中参数内容为(key, value, 过期时间, 时间单位)。
//4.保存验证码到 redis 中
stringRedisTemplate.opsForValue().set("login:code" + phone, code, 2, TimeUnit.MINUTES);
为了提高代码的可读性和可维护性,我们需要将其中的常量使用在工具包下 RedisConstants 类中定义好的常量替代。(通俗来讲,就是为了看着更)
//4.保存验证码到 redis 中
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
② 验证码登录:将从 session 中获取验证码更改为从 Redis 中获取验证码,因其直接返回 String 类型,故其下方的校验步骤中就不需要进行 toString( ) 转换了。
//2.从 Redis 中获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
之后我们需要将原先把的保存用户信息到 session 更改为保存到 Redis 中,其 key 为使用 hutool 工具包下的 UUID 随机生成的 token(其方法参数表示是否带 “-” 符号:图1)。注意 BeanUtil 也是 hutool 工具包下的。
注意用户对象数据存储为 Hash 类型,我们使用 BeanUtil 包下的方法将对象转为 Map 类型与之对应。由因其本身不直接支持设置过期时间,所以需要在创建后手动再设置有效期,并在最后将 token 返回客户端(浏览器)。
//3.2.保存用户信息到 Redis 中
//随机生成 token 作为登录令牌
String token = UUID.randomUUID().toString(true);
//将 User 对象转为 Hash 进行存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, BeanUtil.beanToMap(userDTO));
//设置有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
//将 token 返回客户端
return Result.ok(token);

图 1
但此时仍存在问题,因该用户信息只会设置一次,所以在 30 分钟后无论用户是否还在继续访问,都会过期。所以我们需要改进,只要存在访问的行为,就不断更新有效期,在停止访问后才会在 30 分钟后过期。
而拦截器可以将客户端的请求拦截并处理,符合了我们想要不断更新有效期的目的。所以接着我们还要继续修改拦截器的代码:
首先在我们定义的拦截器类中注入 StringRedisTemplate,但因为该类为我们自定义的类,并不是被 Spring 创建的不能自动注入,所以我们需要创建一个构造方法。
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
在构造方法中我们还需要再次注入 StringRedisTemplate 并将其传入我们添加的拦截器中。
● 第一步需要获取请求头中的 token,并判断其是否为为空,其中的 authorization 是我们在前端规定好名称。
//1.获取请求头中的 token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)){
response.setStatus(401);
return false;
}
● 第二步基于 token 来获取用户信息,entries 方法已经判断是否为 null 了,所以下面只需判断 Map 内容是否为空即可。
//2.基于 token 获取用户信息
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
//3.判断用户是否存在
if (userMap.isEmpty()){ ...
● 第三步再将 map 转为对象后存入 ThreadLocal 中。最后刷新 token 的有效期放行。
//Map 再转为对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//5.存在,保存信息到 ThreadLocal
UserHolder.saveUser(userDTO);
//刷新 token 有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
//6.放行
return true;
但!!! (对,此时还是存在问题...) 启动后我们发现在登录时会报出系统错误的提示,经过查看控制台的输出发现是因为类型不匹配的问题。由于我们的 UserDTO 类中的 id 类型为 long 类型,但我们在前面的 【数据库】Redis—Java 客户端 中提到过 StringRedisTemplate 只能接受 String 类型的键值类型。
于是,我们需要解决类型不匹配的类型,这里选择最 装b (▼へ▼メ) 的一种,beanToMap 方法的拓展:CopyOptions 允许我们对其内的键值进行自定义
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
- setIgnoreNullValue:是否忽略一些空值
- setFieldValueEditor:对字段值进行修改,其中的 (fieldName, fieldValue) -> fieldValue.toString() 为 lambda 表达式,用于简化编程。fieldName 和 fieldValue 代表两个参数,箭头(->)后为相应的操作。
最后登录完成效果如下:
如果还是出现如登录后弹出登录等问题,可以再次查看一些配置是否有问题,或是一些细节的地方写错了,比如我自己就是因为在下图中的红框内忘记加上了前缀 LOGIN_USER_KEY 导致出现了错误。(还是照着视频找了半天才发现...)
但~是~~,其实最终的业务逻辑还是存在问题的...
(好奇他为啥不能一次讲清楚...)
在实现了通过访问不断刷新 token 后,我们的业务逻辑其实还存在问题,那就是我们只在需要做登录校验的请求进行了拦截,如果用户一直只访问不需要登录校验的页面,那么就不会刷新有效期,到时间后用户信息还是会被删除,仍然需要重新登录。
解决办法就是再创建一个新拦截器,与旧拦截器一起构成拦截器链,只是新拦截器不进行拦截,只在存在用户信息时进行保存并刷新 token,而旧拦截器只需取出用户信息并判断是否存在进行登录校验即可。
新拦截器代码如下所示,其实就是旧拦截器(Ctrl + c 、Ctrl + v)。只不过在需要进行拦截的地方 retun true 进行了放行。
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取请求头中的 token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)){
return true;
}
//2.基于 token 获取用户信息
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
//3.判断用户是否存在
if (userMap.isEmpty()){
return true;
}
//Map 再转为对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//5.存在,保存信息到 ThreadLocal 后放行
UserHolder.saveUser(userDTO);
//刷新 token 有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
//6.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户信息
UserHolder.removeUser();
}
}
而旧的拦截器则是只保留了 preHandle 方法,取出 ThreadLocal 中的信息判断是否存在。
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//根据 ThreadLocal 中是否存在用户信息来判断是否需要拦截
if (UserHolder.getUser() == null) {
// 没有,需要拦截
response.setStatus(401);
return false;
}
//有,放行
return true;
}
}
注意,新构造器需要在我们创建的 MvcConfig 配置类中进行添加,且因存在两个拦截器,所以需要为其设置先后顺序,而拦截器顺序 oreder 默认为 0,其数越小越先执行。若全默认为 0,则按添加的先后顺序执行。(注意新拦截器需要传入 stringRedisTemplate)
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
).order(1);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
二、商户查询缓存
1. 添加 Redis 缓存
缓存是数据交换的缓存区,是一个临时存储数据的地方,一般来说读写性能较高。我们可以将经常读写的资源添加到缓存中,提高读写的效率。而使用 Redis 来实现缓存是一个很好的方法,可以提高读写的性能。
如我们在查询商户信息时,需要反复的查看不同的商户信息,且其数据量较大,我们可以将这些需要反复查看的商户写入 Redis 中实现缓存,在下一次查看时直接读取 Redis 中的数据而不用从 MySQL 数据库中查询,大大提高了读写的效率。
1.1. 根据 id 查询商铺信息
接下来将对根据 id 查询商铺信息进行改进,使其 controller 层返回 queryById 方法,我们在此方法中实现具体的代码逻辑。
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
首先,在 Impl 类中注入 StringRedisTemplate,之后哦我们可以分三步实现通过 Redis 进行缓存的功能:
1.从 Redis 中查询是否存在缓存
2.存在,返回缓存信息
3.不存在,查询数据库,判断是否存在
public Result queryById(Long id) {
//从 Redis 中查询是否存在缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//1.存在,返回
if (StrUtil.isNotBlank(shopJson)){
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//2.不存在,查询数据库
Shop shop = getById(id);
//2.1 数据库不存在,返回错误
if (shop == null){
return Result.fail("店铺不存在!");
}
//2.2 数据库存在,写入 Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop));
//3.返回
return Result.ok(shop);
}
● 第一步:通过 stringRedisTemplate 查询 Redis 中是否存在相应的缓存数据,这里我们使用定义的常量前缀 + id 作为 key。
● 第二步:对返回的内容进行非空判断,如果不是空则将其转回 Shop 对象后返回,如果是空值代表是第一次查询,还未进行缓存,所以再从 MySQL 数据库中进行查询。
● 第三步:对数据库进行查询后,再次进行校验,如果为空则返回错误信息 "店铺不存在!",如果存在则将其转为 Json 字符串后先存入 Redis 中缓存,最后再返回。(注意此处返回的是从数据库中查询得到的对象,不是转换后的)
完成后,我们可以看到在缓存后时间由原来的 54 毫秒改进为了 11 毫秒,足足提高了 400%(虽然看着差距小,但确实有点小,只在大数据量的情况下才能看出差距)
可以看到 Redis 中的缓存信息如下图所示:
1.2. 练习题
练习题
(功能的代码实现仅代表个人思路,并不代表是最佳的实现方式)
完整版如下
@Override
public Result getTypeList() {
//查询 Redis 中是否存在数据
List<String> shopTypeJsonList = stringRedisTemplate.opsForList().range(CACHE_SHOPTYPE_KEY, 0, -1);
//1.存在,返回数据
if (shopTypeJsonList != null && !shopTypeJsonList.isEmpty()){
List<ShopType> shopTypes = shopTypeJsonList.stream()
.map(shopTypeJson -> JSONUtil.toBean(shopTypeJson, ShopType.class))
.collect(Collectors.toList());
return Result.ok(shopTypes);
}
//2.不存在,查询数据库
List<ShopType> typeList = list();
//2.1 不存在,返回错误信息
if (typeList == null || typeList.isEmpty()){
return Result.fail("店铺类型不存在!");
}
//2.2 存在,以 Json 格式写入 Redis
List<String> shopTypes = typeList.stream().map(JSONUtil::toJsonStr).collect(Collectors.toList());
stringRedisTemplate.opsForList().leftPushAll(CACHE_SHOPTYPE_KEY, shopTypes);
//3.返回
return Result.ok(typeList);
}
首先将原有的代码删除,选择返回一个新建的方法,之后到 Impl 实现类中开始编写。
@GetMapping("list")
public Result queryTypeList() {
return typeService.getTypeList();
}
接着继续按照前面的步骤进行改造即可:
1.查询 Redis 中是否存在数据
//查询 Redis 中是否存在数据
List<String> shopTypeJsonList = stringRedisTemplate.opsForList().range(CACHE_SHOPTYPE_KEY, 0, -1);
这里我用的是 List 类型进行存储,可以通过 range 方法获取一定范围内的数据,其中的参数CACHE_SHOPTYPE_KEY 是我在常量类定义好的,而 0 表示从列表的第一个元素开始,-1 表示到列表的最后一个元素,所以就是获取整个列表的所有元素。
//1.存在,返回数据
if (shopTypeJsonList != null && !shopTypeJsonList.isEmpty()){
List<ShopType> shopTypes = shopTypeJsonList.stream()
.map(shopTypeJson -> JSONUtil.toBean(shopTypeJson, ShopType.class))
.collect(Collectors.toList());
//排序
typeList.sort((o1, o2) -> o1.getSort() - o2.getSort());
return Result.ok(shopTypes);
}
存在缓存时,我们就要将其重新转换为对象列表,这里我判断的是 null 和 isEmpty,最大程度保证其不为空(其中 null 检测对象是否为有效,isEmpty 检测集合是否为空)。然后将其返回。这里在转为 List<ShopType> 类型时,我使用了集合的 stream 流和 lambda 表达式来进行操作,以简化代码的编写。
其中 stream 流的 map 方法的作用是接受一个函数作为参数,并将该函数作用于流中的每一个元素,相当于遍历其中的元素并进行操作,这里我将其中的每一个元素都用 JSONUtil 工具类由 Json 字符串转为了 ShopType 的对象 ,并在最后使用 collect 将流中的元素都收集到一个新的集合中去。
因 ShopType 具有顺序属性,所以可以使用集合的 sort 排序方法进行排序,它原本接收一个一个 Comparator 对象,其中定义排序规则。
typeList.sort(new Comparator<ShopType>() {
@Override
public int compare(ShopType o1, ShopType o2) {
return o1.getSort() - o2.getSort();
}
});
我们使用 lambda 表达式来简化代码,根据返回值的不同来确定顺序,我根据上面代码来解释一下:
- 返回负数,表示 o1 应排在 o2 之前;
- 返回 0,表示 o1 和 o2 的顺序不变;
- 返回整数,表示 o1 应排在 o2 之后;
总的来说,就是按值的大小从小到大进行排序。
2.缓存中不存在,到数据库中查询
//2.不存在,查询数据库
List<ShopType> typeList = list();
//2.1 不存在,返回错误信息
if (typeList == null || typeList.isEmpty()){
return Result.fail("店铺类型不存在!");
}
缓存不存在时,因为该类继承了 MyBatis-Plus 中的 ServiceImpl,所以可以直接调用其中写好的方法对数据库进行简单的 crud 操作,如我在此处调用的 list 方法获取 MySQL 数据库中所有的 ShopType 记录。 接着我们再对返回的数据进行判断,如果不存在则返回错误信息 "店铺类型不存在!"。
3.数据库中数据存在,存入 Redis 中,再返回
//2.2 存在,以 Json 格式写入 Redis
List<String> shopTypes = typeList.stream().map(JSONUtil::toJsonStr).collect(Collectors.toList());
stringRedisTemplate.opsForList().leftPushAll(CACHE_SHOPTYPE_KEY, shopTypes);
//排序
typeList.sort((o1, o2) -> o1.getSort() - o2.getSort());
//3.返回
return Result.ok(typeList);
还是同样的操作,使用 stream 流将其内的 ShopType 对象再转回 Json 字符串的格式,其中的符号 “::” 是 Java 中的方法引用操作符,是 lambda 表达式的一种简化方式,作用是调用 JSONUTIL 中的静态方法 toJsonStr。(没有最简,只有更简啊...)
之后存入 Redis 中,leftPushAll 方法将多个值从列表左侧加入列表中。最后返回我们从数据库中获取到的集合。(注意不是转换后的集合)
最后的效果如下
(再次重申一遍ヽ(ー_ー)ノ)
(功能的代码实现仅代表个人思路,并不代表是最佳的实现方式)
2. 缓存更新策略
使用缓存来提高读写效率,本身也是一把双刃剑,有利也有弊。其随着使用同时也会产生一些并发的问题,如保证数据的一致性等问题。在更新时如果 MySQL 数据库中的数据也发生了变化时,Redis 中的缓存如果没有更新,则会造成数据的不一致问题。
而我们有三种解决方法,分别是:
内存淘汰:因为 Redis 的数据是存储在内存中的,但内存有上限,到到达上限时,会自动触发该策略,所以不需要主动维护,但可以自己配置。当内存不足时自动淘汰部分数据以在下次查询时更新缓存。但这种方法不能被我们控制,一致性很差。
超时剔除:给缓存数据添加一个 TTL 时间,到期后自动删除缓存,方便下次查询时更新缓存。该策略的一致性由我们设置的 TTL 时间有关,越短一致性越高,但维护成本也随之增加。所以这是一种最终一致,一致性一般。
主动更新:我们主动的编写程序来在 MySQL 数据库更新的同时更新 Redis 中的缓存。但程序总会有出错的时候,所以只能说是具有好的一致性,且维护成本高。
根据不同的业务需求,我们可以选择不同的解决方法。接下来我们针对第三中主动更新来进行代码实现的分析
2.1. 主动更新策略的实现
想要实现主动更新,同样要有三种方式:
① Cache Aside:由缓存的调用者,在更新数据库的同时更新缓存。对于调用者来说较为复杂。
② Read/Write Through:将缓存与数据库整合为一个服务,由该服务来维护一致性。调用者只需调用该服务,无需关心缓存的一致性问题。简化了调用者的开发。
③ Write Behind Caching:调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库中,保证最终一致性。简化了调用者的开发。(异步:隔一段时间执行一次,将前面的所有操作进行一次批处理)但维护成本高,当缓存出现问题时数据将丢失,数据的一致性和安全性难以保证
经过对三种方法优缺点的分析,最常用的还是第一种方法。因其具有较高的自由度。但是第一种方法在实现细节的部分还存在问题:
1.删除还是更新缓存
在每次更新数据库时都需要更新缓存,这样会导致产生多次无效操作,而删除缓存就是在更新数据库时让缓存失效,这样即使多次进行更新数据库的操作,但对缓存操作的频率会更低。所以我们选择删除缓存。
2.如何保证数据库与缓存的操作同时成功
需要保证两个操作的原子性,同时成功或同时失败。在单体架构中,我们可以将对缓存和数据库的操作放在同一个事务中确保其原子性;而在分布式系统中,我们可以利用 TCC 等分布式事务方案来确保其原子性。如我在 【微服务】黑马微服务保护与分布式学习笔记 提到的 Seata。
3.先操作缓存还是数据库
先删除缓存,再更新数据库时,如果在线程执行的过程中,删除缓存时另一线程如果进行查询,会查询到数据库中的旧值并将其写入缓存中,此时再对数据库更新,结果导致缓存与数据库之间的数据不一致。
先更新数据库,再删除缓存时,如果缓存出现问题无法查询时,会查询数据库,而在写入缓存前,另一线程此时更新了数据库且将缓存进行了删除,之后在将原本线程中查询数据库得到的数据写入缓存中,这样也会导致缓存与数据库之间的数据的不一致。
综上所述,第二种先更新数据库,再删除缓存的策略更好。因为缓存的速度远远高于数据库,数据库的操作不太可能比缓存的操作还快,所以出错的可能性更低。
2.2. 改造查询商铺的缓存更新策略
超时剔除:改造的方法很简单,只需在我们在数据库中查询到数据后添加到 Redis 中时为其设置有效期,就可完成超时剔除的功能。(在实现类的根据 id 查询商铺中进行改造)
//2.2 数据库存在,写入 Redis
stringRedisTemplate.opsForValue()
.set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
主动更新:我们需要将项目中关于更新商铺信息的方法进行改造,使其在更新数据库的同时进行删除缓存的操作。
在实现类中实现该方法,注意涉及到 MySQL 和 Redis 两个数据库的操作且为单体架构,所以需要在方法上添加 @Transactional 注解保证事务的一致性。
@Transactional
public Result update(Shop shop) {
if (shop.getId() == null){
return Result.fail("店铺id不能为空!");
}
//1.更新数据库
updateById(shop);
//2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
return Result.ok();
}
首先对传入的 id 进行校验,如果为空则返回错误信息 "店铺id不能为空!",然后根据前面的分析,我们选择先更新数据库(MP中的 updateById 方法会自动获取对象内的 id 主键值),最后将 Redis 中的缓存删除,实现了主动更新策略。
效果展示
超时剔除:可以看到,缓存中的商铺信息已经有了 TTL 有效期,超时会自动删除。
主动更新:因为更新商铺信息是管理端的功能,这里只有用户端,所以接下来会使用发送请求的方式进行更新操作演示。视频中使用的是 PostMan 工具,我使用的是 ApiFox,二者并无太大差异,所以选哪一个使用都可以٩( ´︶` )( ´︶` )۶。
可以看到请求为 PUT 请求表示更新操作,路径为 http://localhost:8081/shop 注意是 8081 而不是 8080。我们将商铺名称(name)改为了 886茶餐厅。
发送后返回状态码 200 及 "success": true 信息表示修改成功。
可以看到,在刷新数据库和缓存的数据后,更新了数据库中的数据并删除了缓存。
3.缓存穿透/雪崩/击穿
尽管我们已经解决了一些问题,但总会有新问题的出现,就如标题所提到的穿透啊~雪崩啊~击穿啊~(有点熟悉啊....这不微服务保护吗!!!∑(゚Д゚ノ)ノ)等等问题。当然有问题就要解决。
3.1. 缓存穿透
在我们实现了上述功能后的前提下,如果用户请求的数据在缓存与数据库中都不存在,那么这些请求都会到达数据库。如果诸如此类的请求被并发的发送,那么会对数据库造成极大的压力。想要解决这个问题,有两个方法:
缓存空对象:在查询的数据在缓存与数据库中都不存在时,我们选则缓存一个 null 值来处理该数据的请求。这样就不会直接到达数据库了。
- 优点:实现简单,维护方便。
- 缺点:会造成额外的内存消耗(可以设置有效期解决);或在查询后再为该数据赋值,会造成短期的数据不一致(控制 TTL 的时间,一定程度上缓解)。
布隆过滤:在客户端与服务器之间加一层布隆过滤器(没有什么是加一层解决不了的...),如果数据不存在则直接拒绝请求,反之则放行。其原理是基于算法实现的,将数据库中的数据通过某种 Hash 算法计算得到的 Hash 值再转换为二进制位保存在布隆过滤器内,数据的判断就是判断对应的位置是 0 或是 1,因此只是一种概率上的统计,并不绝对,有一定的穿透风险。
- 优点:占用空间小。
- 缺点:实现较复杂(但 Redis 里已完成了实现);存在误判的可能。
代码实现
原本的业务逻辑是在缓存与数据库中都查询不到时返回错误信息,我们需要对其进行改造。
将返回错误信息修改为将空值写入 Redis 中,最后再返回错误信息。且因为这样会使缓存中存在空值,所以在取出缓存时也需要加入非空校验才行。
public Result queryById(Long id) {
//从 Redis 中查询是否存在缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//1.存在,返回
if (StrUtil.isNotBlank(shopJson)){
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//判断命中的是否是空值
if (shopJson != null){
return Result.fail("店铺不存在!");
}
//2.不存在,查询数据库
Shop shop = getById(id);
//2.1 数据库不存在,将空值写入缓存后返回错误
if (shop == null){
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
//2.2 数据库存在,写入 Redis
stringRedisTemplate.opsForValue()
.set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//3.返回
return Result.ok(shop);
}
进行修改的地方一共有两处:
//2.1 数据库不存在,将空值写入缓存后返回错误
if (shop == null){
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
第一处:在缓存与数据库中都无法查询到时先将空值写入缓存中,之后再返回错误信息。其中的有效期设置的是 2 分钟。
//判断命中的是否是空值
if (shopJson != null){
return Result.fail("店铺不存在!");
}
第二处:如下图在经过 isNotBlank 方法判断后,空值会返回 false,所以我们还要在其后加上一个判断。又因为前面的方法已经判断过有值的情况了,所以只会有两种情况——null 或是空值,所以这里只需要判断如果不是 null,只能是空值的情况,所以 != null 时返回错误信息。如果是 null 的情况则会接着去查询数据库。
效果如下图所示,在第一次查询 id 为 0 的商铺信息时,会到数据库中去查询,因为缓存与数据库中都不存在该数据,所以会创建一个空值缓存,之后再次访问时,就会直接返回缓存中的这个空值。
最后,想要解决缓存穿透,还可以增加 id 的复杂度避免被猜测出 id 的规律、做好数据的基础格式校验、加强用户权限校验、做好热点参数的限流(sentinel 微服务流量控制组件,也在【微服务】黑马微服务保护与分布式学习笔记 中提到过)等方式,避免缓存穿透。
3.2. 缓存雪崩
缓存雪崩是指在同一时间段大量的缓存 key 同时失效或 Redis 服务宕机,导致大量的请求直接到达数据库,为其带来巨大的压力。两者相比之下, Redis 宕机所带来的危害更大,所有的请求都会直接到达数据库。
解决方法:
- 给不同的 key 设置随机的 TTL 过期时间,使其不会同时失效。
- 利用 Redis 集群提高服务的可用性,一个服务宕机还有其他的服务可用。
- 给缓存业务添加降级限流策略,在服务出现问题时,直接拒绝服务。(sentinel 中的服务熔断等)
- 给业务添加多级缓存,缓存的使用场景是多种多样的,不仅可以在赢应用层添加,还可以在浏览器、反向代理 Nginx 服务器中添加等。
【该小结结束】
(对,没错,就是这么短,这一小结时短而精悍的ᕦ(・ㅂ・)ᕤ。第一种方法只需要加一个有效期即可,剩下的方法要不是太高级了,要不就是与主要内容无关,所以这里不过多赘述。)
3.3. 缓存击穿
上一节讲的是大量 Key 失效所导致的严重后果,这一节讲的是部分 Key 缓存失效所产生的严重后果。缓存击穿问题也被称为热点 Key 问题,就是一个被高并发访问且缓存重建业务较复杂的 key 突然失效了,无数的请求会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种方式:
① 互斥锁:同时操作时只允许一个线程写入缓存,其他线程只能不断地重试,直到锁被释放。
- 优点:简单粗暴。
- 缺点:需要互相等待,耗时长,性能差且存在死锁风险。
(重视一致性)
② 逻辑过期:从根本出发,因为设置了 TTL 过期时间才导致了大量 key 失效,所以我们不再设置过期时间,而是在添加缓存时将在当前时间基础上加上过期的时间所得到的时间保存,相当于永久储存,不会出现未命中的情况。
线程1 获取到互斥锁后会开启一个新线程(线程 2)去进行更新缓存操作,其本身会将旧缓存数据返回;如线程3 在更新前进行查询时缓存已过期所以会尝试获取互斥锁,不能获取到互斥锁,说明已经有线程(线程 1)正在更新,所以也会返回旧缓存数据;又如线程4 在更新完成释放锁后进行查询时缓存已更新完未过期,所以会返回获取的新缓存数据。
- 优点:线程无需等待,性能高。
- 缺点:不保证一致性,增加内存消耗且实现较复杂。
(重视可用性)
CAP定理:在分布式系统中,一致性、可用性、分区容错性这三要素最大只能实现其二,不可能三者兼顾。(鱼与熊掌,不可兼得)
3.3.1.互斥锁解决缓存击穿问题
如上所述,我们还是在查询商户信息这一业务中实现(毕竟改来改去,老朋友了...,比较熟悉),在原本业务中查询缓存未命中时尝试获取互斥锁,并进行判断。而想要实现互斥锁并添加自己的逻辑,我们可以用到之前学习 Redis 中的 String 数据类型里的 setnx 方法,给一个 key 赋值,当且仅当该 key 不存在时。所以只能被第一个操作的线程设置,释放锁就是将其删除。但为了避免突发情况,如加锁后未能及时释放,我们也会为该值设置一个有效期避免出错
(注意:本小结与视频所实现代码有所不同,但逻辑都一样,就是通过递归解决o(╥﹏╥)o)
(全部代码如下,仅代表个人的实现方式,并没有说是绝对正确的完美代码)
(_| ̄|● 如果有错误,请指出,我会及时回复并改正 _(:3」∠❀)_)
我没有进行双重校验,如果有实现的代码,可以评论交流
@Override public Result queryById(Long id) { //从 Redis 中查询是否存在缓存 String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); //1.存在,返回 if (StrUtil.isNotBlank(shopJson)){ Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } //判断命中的是否是空值 if (shopJson != null){ return Result.fail("店铺不存在!"); } //实现缓存击穿改造 //1)获取互斥锁 String lockKey = "shop:lock:" + id; Shop shop = null; try { boolean lock = tryLock(lockKey); if(!lock) { //2)获取失败,休眠后重试 Thread.sleep(50); return queryById(id); } //3)获取成功,继续查询、写入、释放锁... //2.不存在,查询数据库 shop = getById(id); //模拟重建时的延时(可删除) Thread.sleep(200); //2.1 数据库不存在,将空值写入缓存后返回错误 if (shop == null){ stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES); return Result.fail("店铺不存在!"); } //2.2 数据库存在,写入 Redis stringRedisTemplate.opsForValue() .set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { //3)释放锁 unLock(lockKey); } //3.返回 return Result.ok(shop); } //获取锁 private boolean tryLock(String key){ Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } //释放锁 private void unLock(String key){ stringRedisTemplate.delete(key); }
想要使用 Redis 实现互斥锁解决缓存击穿问题,需要分四步进行:
1.获取互斥锁
2.获取失败,休眠一段时间后再次尝试获取
3.获取成功,继续查询数据库、写入 Redis 缓存
4.释放锁后返回商铺信息
● 第一步:编写获取锁和释放锁两个方法。
//获取锁
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
其中的方法 setIfAbsent 相当于 Redis 中的 setnx 命令,写入的 value 值任意,这里为 1,并为其设置有效期为 10 秒。
同时 return BooleanUtil.isTrue(flag); 是因为返回类型为 boolean,为了防止包装类在拆包时产生空值导致空指针异常,如果是 null 则方法会返回 false。
获取锁,将 key 定义为 "shop:lock:" + id 保证每个商铺信息都有不同的锁。
String lockKey = "shop:lock:" + id;
boolean lock = tryLock(lockKey);
● 第二步:获取失败,休眠一段时间后重试,这里进行递归,只有一个线程能够拿到锁并进行接下来的查询数据库、写入缓存的操作,其余的线程只能不断地休眠后查询缓存·,直到拿到锁的线程完成操作释放锁后,其余线程可以成功查询到缓存并依情况返回对应信息。
if(!lock) {
//2)获取失败,休眠后重试
Thread.sleep(50);
return queryById(id);
}
● 第三步:获取互斥锁成功后,可以接着之前的操作,查询数据库:存在(写入缓存)、不存在(将空值写入缓存后返回错误信息)
//3)获取成功,继续查询、写入、释放锁...
//2.不存在,查询数据库
shop = getById(id);
//模拟重建时的延时
Thread.sleep(200);
//2.1 数据库不存在,将空值写入缓存后返回错误
if (shop == null){
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
//2.2 数据库存在,写入 Redis
stringRedisTemplate.opsForValue()
.set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
● 第四步:最后将从获取互斥锁到最后写入 Redis 缓存中使用 try catch 包围,在 finally 中最终释放锁,因为这个锁是基于 Redis 实现的,并不是内置的锁机制,不能自动释放。因此在任何情况下,最后都会执行释放锁。
——————————————————最终效果—————————————————
测试使用 jmter 来模拟服务器被并发访问,不了解 jmter 可以搜索相关教程使用。
对应的配置如上图所示,设置了 1000 个线程,规定运行时间为 5 秒。
运行后,可以看到只进行了一次数据库的查询,这就是那一个拿到锁的线程执行的
吞吐量也与理论值(1000 / 5)相差不多。
注意:需要将 Redis 中的缓存先删除才可以运行,且如果吞吐量对不上,可能是因为没有清除前几次的运行数据所导致的。
最后的最后,再次重申一遍
仅代表个人的实现方式,并没有说是绝对正确的完美代码
如有疑义或想要纠正错误,请大家手下留情,留言评论,我会及时回复 |ू・ω・` )
3.3.2. 逻辑过期方式解决缓存击穿问题
通过逻辑过期的方式来解决缓存击穿的问题,我们需要在业务自己进行判断是否超时。接下来,我们还是要对查询商铺信息进行改造。
(注意:本小结与视频所实现代码同样有所不同,但逻辑都一样o(╥﹏╥)o)
(全部代码如下,仅代表个人的实现方式,并没有说是绝对正确的完美代码)
(_| ̄|● 如果有错误,请指出,我会及时回复并改正 _(:3」∠❀)_)
我没有进行双重校验,如果有实现的代码,可以评论交流
@Resource private StringRedisTemplate stringRedisTemplate; private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); @Override public Result queryById(Long id) { //从 Redis 中查询是否存在缓存 String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); //1.存在,返回 if (StrUtil.isBlank(shopJson)) { return null; } //命中,判断过期时间,把 json 反序列化为对象 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())) { //未过期,返回店铺信息 if (shop == null) { return Result.fail("店铺不存在!"); } else { return Result.ok(shop); } } //过期,需要缓存重建,获取互斥锁 String lockKey = LOCK_SHOP_KEY + id; boolean isLock = tryLock(lockKey); //获取成功,开启独立线程,开启缓存重建 if (isLock) { CACHE_REBUILD_EXECUTOR.submit(() -> { try { //重建缓存 this.saveShopToRedis(id, 20L); } catch (Exception e) { throw new RuntimeException(e); } finally { //释放锁 unLock(lockKey); } }); } //返回商户信息 if (shop == null) { return Result.fail("店铺不存在!"); } else { return Result.ok(shop); } } public void saveShopToRedis(Long id, Long expireSeconds) throws InterruptedException { //1.查询店铺数据 Shop shop = getById(id); Thread.sleep(200); //2.封装逻辑过期时间 RedisData redisData = new RedisData(); redisData.setData(shop); redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); //3.写入Redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData)); } //获取锁 private boolean tryLock(String key) { Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } //释放锁 private void unLock(String key) { stringRedisTemplate.delete(key); }
将之前的代码复制粘贴一份注释掉,在这里因为逻辑发生了变化,所以我们要将之前的代码删除一部分,并且在查询不到缓存时返回 null。因为是只在查询热点数据时使用的方法,所以该数据一定是经过数据预热存在的。
public Result queryById(Long id) {
//从 Redis 中查询是否存在缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//1.存在,返回 null
if (StrUtil.isBlank(shopJson)) {
return null;
}
/*此处编写对应逻辑*/
//返回商户信息
if (shop == null) {
return Result.fail("店铺不存在!");
} else {
return Result.ok(shop);
}
}
想要实现该功能,同样也需要经过三步实现:
1.命中缓存,获取其中的信息
2.未过期,直接返回店铺信息
3.过期,需要开启新线程在其中进行缓存重建后自身再返回店铺信息
//命中,判断过期时间,把 json 反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();

RedisData
● 第一步:将缓存数据通过 JSONUtil 工具类转为对应的数据封装对象 RedisData,获取其中的 data(Shop 类) 和 expireTime 过期时间,其中 data 因为是 Object 类(不能写死为 Shop 类,防止将来存在其他类需要缓存),所以需要再进行转换,此处不过多赘述。
//未过期,返回店铺信息
if (expireTime.isAfter(LocalDateTime.now())) {
if (shop == null) {
return Result.fail("店铺不存在!");
} else {
return Result.ok(shop);
}
}
● 第二步:判断过期时间,如果在当前时间之后(After),则代表还未过期,校验后直接返回店铺信息。
//过期,需要缓存重建,获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
//获取成功,开启独立线程,开启缓存重建
if (isLock) {
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//重建缓存
this.saveShopToRedis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
//返回商户信息
return Result.ok(shop);
}
● 第三步:判断过期后,需要获取互斥锁,并在成功获取后开启独立线程,在其中进行缓存的重建,其余线程没有获取成功则继续往下校验商户信息后返回。
其中 CACHE_REBUILD_EXECUTOR 是我们定义的大小为 10 的线程池,使用 submit 方法开启新线程,在其中进行重建缓存 (saveShopToRedis)。最后使用 try catch 包围代码块,finally 中释放锁。
重建缓存方法代码如下所示:
public void saveShopToRedis(Long id, Long expireSeconds) throws InterruptedException {
//1.查询店铺数据
Shop shop = getById(id);
//模拟重建缓存延迟
Thread.sleep(200);
//2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3.写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
——————————————————最终效果—————————————————
首先使用测试将热点商铺信息数据写入缓存中,再将数据库中 1 号商铺的商铺名进行更改后。等缓存过期后再运行 jmter。
@Resource
private ShopServiceImpl shopService;
@Test
void testSaveShop() throws InterruptedException {
shopService.saveShopToRedis(1L, 10L);
}
测试同样使用 jmter 来模拟服务器被并发访问,不了解 jmter 可以搜索相关教程使用。
对应的配置如上图所示,设置了 100 个线程,规定运行时间为 1 秒。
运行后,同样可以看到只进行了一次数据库的查询,且在线程组操作时返回的数据中的店铺名进行了更改。
吞吐量也与理论值(100 / 1)相差不多。
注意:需要将 Redis 中的缓存先删除才可以运行,且如果吞吐量对不上,可能是因为没有清除前几次的运行数据所导致的。
最后的最后的最后,再次重申一遍
仅代表个人的实现方式,并没有说是绝对正确的完美代码
如有疑义或想要纠正错误,请大家手下留情,留言评论,我会及时回复 |ू・ω・` )
4. 缓存工具封装
在业务中,我们不会像之前那样复杂的代码逻辑每次需要时去手动实现,而是将其封装为工具。但在封装的过程中,也会出现一些问题。(累了,毁灭吧ヽ(ー_ー)ノ)
首先,附工具类完整代码
@Slf4j @Component public class CacheClient { @Resource private StringRedisTemplate stringRedisTemplate; private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); public void set(String key, Object value, Long time, TimeUnit unit){ stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit); } public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){ RedisData data = new RedisData(); data.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time))); data.setData(value); stringRedisTemplate.opsForValue().setIfAbsent(key, JSONUtil.toJsonStr(data)); } public <R, I> R queryWithPassThrough( String keyPrefix, I id, Class<R> type, Function<I, R> dbFallback, Long time, TimeUnit unit) { String key = keyPrefix + id; //从 Redis 中查询是否存在缓存 String shopJson = stringRedisTemplate.opsForValue().get(key); //1.存在,返回 if (StrUtil.isNotBlank(shopJson)){ return JSONUtil.toBean(shopJson, type); } //判断命中的是否是空值 if (shopJson != null){ return null; } //不存在,根据 id 查询数据库 R r = dbFallback.apply(id); if (r == null){ //缓存写入空值 stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); //返回错误信息 return null; } //存在,写入 Redis 中 this.set(key, r, time, unit); return r; } public <R,I> R queryWithLogicalExpire(String keyPrefix, I id, Class<R> type, Function<I,R> dbFallback, Long time, TimeUnit unit) { String key = keyPrefix + id; //从 Redis 中查询是否存在缓存 String shopJson = stringRedisTemplate.opsForValue().get(key); //1.存在,返回 if (StrUtil.isBlank(shopJson)) { return null; } //命中,判断过期时间,把 json 反序列化为对象 RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class); R r = JSONUtil.toBean((JSONObject) redisData.getData(), type); LocalDateTime expireTime = redisData.getExpireTime(); //未过期,返回店铺信息 if (expireTime.isAfter(LocalDateTime.now())) { return r; } //过期,需要缓存重建,获取互斥锁 String lockKey = LOCK_SHOP_KEY + id; boolean isLock = tryLock(lockKey); //获取成功,开启独立线程,开启缓存重建 if (isLock) { CACHE_REBUILD_EXECUTOR.submit(() -> { try { //查询数据库 dbFallback.apply(id); //写入 Redis this.setWithLogicalExpire(key, r, time, unit); } catch (Exception e) { throw new RuntimeException(e); } finally { //释放锁 unLock(lockKey); } }); } //返回商户信息 return r; } //获取锁 private boolean tryLock(String key) { Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } //释放锁 private void unLock(String key) { stringRedisTemplate.delete(key); } }
给工具类中有四个方法需要我们编写:
第一个方法:
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
其作用是将缓存数据写入 Redis 同时设置有效期
第二个方法:
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
RedisData data = new RedisData();
data.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
data.setData(value);
stringRedisTemplate.opsForValue().setIfAbsent(key, JSONUtil.toJsonStr(data));
}
其作用是将缓存数据以逻辑过期的方式写入 Redis 中并设置有效期,这里就与我们在前面的 RedisData 类里的 data 属性为 Object 类型相呼应上了,可以缓存任何类型的数据,不会局限于 Shop 类。
第三个方法:
public <R, I> R queryWithPassThrough(String keyPrefix, I id, Class<R> type, Function<I, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
//从 Redis 中查询是否存在缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//1.存在,返回
if (StrUtil.isNotBlank(shopJson)){
return JSONUtil.toBean(shopJson, type);
}
//判断命中的是否是空值
if (shopJson != null){
return null;
}
//不存在,根据 id 查询数据库
R r = dbFallback.apply(id);
if (r == null){
//缓存写入空值
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return null;
}
//存在,写入 Redis 中
this.set(key, r, time, unit);
return r;
}
利用泛型来确保可以接受任意类型对象,因为 id 的类型也可能不唯一,所以同样使用泛型,其中的 Function<I, R> 是函数式接口,我们可以将查询数据库的方法传入,其接收一个类型围为 I 的参数(这里指 id)并返回一个类型为 R 的结果(这里指 Shop)。其中需要在缓存中写入空值时可以使用我们前面写好的第一个方法。
将原本的解决缓存穿透问题的代码进行更改后就如上方代码所示,可以解决任意对象的缓存穿透问题,需要传入的参数依次为:key(前缀)、id、返回对象类型、查询数据库的方法、有效期、时间单位。
第四个方法:
public <R,I> R queryWithLogicalExpire(String keyPrefix, I id, Class<R> type, Function<I,R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
//从 Redis 中查询是否存在缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//1.存在,返回
if (StrUtil.isBlank(shopJson)) {
return null;
}
//命中,判断过期时间,把 json 反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
//未过期,返回店铺信息
if (expireTime.isAfter(LocalDateTime.now())) {
return r;
}
//过期,需要缓存重建,获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
//获取成功,开启独立线程,开启缓存重建
if (isLock) {
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//查询数据库
dbFallback.apply(id);
//写入 Redis
this.setWithLogicalExpire(key, r, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
//返回商户信息
return r;
}
同样的,也是通过泛型来实现可以接收任意类型,用于解决缓存击穿问题,接收的参数与上一个方法相同,但这里需要使用逻辑过期来解决缓存击穿问题,所以可以调用我们前面写好的第二个方法。
public Result queryById(Long id) {
//解决缓存穿透
/*Shop shop = cacheClient.queryWithPassThrough(
CACHE_SHOP_KEY,
id,
Shop.class,
this::getById,
CACHE_SHOP_TTL,
TimeUnit.MINUTES);*/
//解决缓存击穿
Shop shop = cacheClient.queryWithLogicalExpire(
CACHE_SHOP_KEY,
id,
Shop.class,
this::getById,
CACHE_SHOP_TTL,
TimeUnit.MINUTES);
return Result.ok(shop);
}
在实现类中注入我们写好的工具类后可以如上所示分别调用相应方法解决不同的缓存问题。
注意:缓存击穿问题需要提前准备好数据预热。
这里就不做演示了(太累了...偷个懒)
【上】完结
最后提前祝大家蛇年
大吉,万事如意
也感谢你们能够看到最后,这是我第一次内容写的这么详细、这么多
对我来说是一种挑战,对读者来说也是一个考验(毕竟文章有点长)
_| ̄|(ェ:)… 在这里谢谢大家 …(:ェ)| ̄|_
\\\希望我们一起进步///