Java-Redis:实战篇(1)

实战篇

黑马点评

导入黑马点评项目

数据库

黑马点评项目框架

导入后端项目

利用idea打开项目源码hm-dianping

1.修改yaml文件下datasourcepasswordredis下的hostpassword

1
2
3
4
5
6
7
8
9
10
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/hmdp?useSSL=false&serverTimezone=UTC
username: root
password:
redis:
host: localhost
port: 6379
password:

2.修改pom.xml文件,讲lombok的版本修改为1.18.30版本。

参考链接:【已解决】java: java.lang.NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have-CSDN博客

解决:将Lombok库升级到1.18.30或更高版本。该版本已经修复了这个问题。

1
2
3
4
5
6
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<optional>true</optional>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>

3.启动项目后,在浏览器访问:http://localhost:8081/shop-type/list ,如果可以看到数据则证明运行没有问题。

导入前端项目

打开前端项目所在的nginx文件夹,将其复制到任意目录,要确保该目录不包含中文、特殊字符和空格。在nginx所在目录下打开一个CMD窗口,输入命令:start nginx.exe,或者直接点击nginx.exe运行。

打开chrome浏览器,打开开发者工具手机模式:访问: http://127.0.0.1:8080 ,即可看到页面。

短信登录

基于Session实现登录

发送短信验证码

短信验证码登录

登录验证功能

代码实现

1.UserController中实现发送验证码,登录,和/me访问自己的功能。

其中,/me只需要从当前线程ThreadLocal中获取当前用户并且返回。

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
package com.hmdp.controller;

@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

@Resource
private IUserService userService;

@Resource
private IUserInfoService userInfoService;

//发送手机验证码
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
//发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}

//登录功能:登录参数,包含手机号、验证码;或者手机号、密码
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm, session);
}

//登出功能
@PostMapping("/logout")
public Result logout(){
// TODO 实现登出功能
return Result.fail("功能未完成");
}

@GetMapping("/me")
public Result me(){
// 获取当前登录的用户并返回
return Result.ok(UserHolder.getUser());
}

@GetMapping("/info/{id}")
public Result info(@PathVariable("id") Long userId){
// 查询详情
UserInfo info = userInfoService.getById(userId);
if (info == null) {
// 没有详情,应该是第一次查看详情
return Result.ok();
}
info.setCreateTime(null);
info.setUpdateTime(null);
// 返回
return Result.ok(info);
}
}

2.IUserService

1
2
3
4
5
6
7
8
package com.hmdp.service;

public interface IUserService extends IService<User> {

Result sendCode(String phone, HttpSession session);

Result login(LoginFormDTO loginForm, HttpSession session);
}

3.UserServiceImpl

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
56
57
58
59
60
61
62
63
64
65
66
67
package com.hmdp.service.impl;

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}

//3.符合,生成验证码
String code = RandomUtil.randomString(6);

//4.保存验证码到session
session.setAttribute("code",code);

//5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);

//返回ok
return Result.ok();
}

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
//2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//2.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.equals(code)){
//3.不一致,报错
return Result.fail("验证码错误");
}

//4.一致,根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();//MyBatisPlus查询用户

//5.判断用户是否存在
if(user == null){
//6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}

//7.保存用户信息到session中
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
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);//MyBatisPlus保存用户到数据库
return user;
}
}

4.LoginInterceptor,设置拦截器进行拦截。

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
package com.hmdp.utils;

public class LoginInterceptor implements HandlerInterceptor {
@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.不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}

//5.存在,保存用户信息到ThreadLocal
UserHolder.saveUser((UserDTO) user);//保存到当前线程里

//6.放行
return true;
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//移除用户
UserHolder.removeUser();
}
}

5.MvcConfig,注册拦截器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.hmdp.config;

@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}
参考链接

1.Cookie

前端安全系列(二):如何防止CSRF攻击? - 美团技术团队

为什么token能够防止CSRF(修正版)_token防止csrf-CSDN博客

2.localstoragesessionstoragecookie的区别。

Cookie、LocalStorage和SessionStorage:一次非常详细的对比!_cookie localstorage sessionstorage-CSDN博客

3.HttpSession

HttpSession详解(简称session)-CSDN博客

Java Web之HttpSession详解-CSDN博客

集群的session共享问题

session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。

session的替代方案应该满足:

  • 数据共享
  • 内存存储
  • keyvalue结构

基于Redis实现共享session登录

Redis代替session需要考虑的问题:

  • 选择合适的数据结构
  • 选择合适的key
  • 选择合适的存储粒度
前端页面携带token

前端login.html页面。发送登录请求时保存用户信息到session

前端common.js文件。每次发送请求时讲token作为放入请求头。

代码实现

1.UserServiceImpl,使用Redis存储登录信息。

将验证码保存到Redis中,key值为login:code+手机号码

随机生成token,将新用户保存到Rediskey值为login:token+token值。并设置有效期。需要将token值返回给前端,前端会将token保存到sessionStorage,在以后每次的请求中都会携带请求头authorization,值为token的值回到后端。后续后端拦截器每次要验证请求头authorization的值是否为token的值。

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package com.hmdp.service.impl;

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}

//3.符合,生成验证码
String code = RandomUtil.randomString(6);

//4.保存验证码到redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);

//5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);

//返回ok
return Result.ok();
}

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
//2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//3.从redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.equals(code)){
//3.不一致,报错
return Result.fail("验证码错误");
}

//4.一致,根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();//MyBatisPlus查询用户

//5.判断用户是否存在
if(user == null){
//6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}

//7.保存用户信息到redis中
//7.1 随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
//7.1 将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.toBean(user, UserDTO.class);
Map<String,Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().
setIgnoreNullValue(true).
setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
//7.3 存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
//7.4 设置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

//8. 返回token
return Result.ok(token);
}

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);//MyBatisPlus保存用户到数据库
return user;
}
}

2.LoginInterceptor。拦截器判断token是否存在,通过tokenRedis中获取用户信息,并将保存用户信息到ThreadLocal。刷新Redis中的token有效期。

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
package com.hmdp.utils;

@Slf4j
public class LoginInterceptor implements HandlerInterceptor {

private StringRedisTemplate stringRedisTemplate;
// 这里不能使用@Resource注解来注入,只能使用构造函数来注入。
// 因为LoginInterceptor类的对象是手动new出来的,不是通过Spring创建的,无法做依赖注入

public LoginInterceptor(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)) {
//不存在,拦截,返回401状态码
response.setStatus(401);
log.info("info");
return false;
}

//2.基于token获取redis中的用户
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);

//3.判断用户是否存在
if(userMap.isEmpty()) {
//4.不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}

//5. 将查询到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

//6.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);//保存到当前线程里

//7. 刷新token有效期
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

//8.放行
return true;
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//移除用户
UserHolder.removeUser();
}
}

3.MvcConfig,要注入StringRedisTemplate

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
package com.hmdp.config;

@Configuration
public class MvcConfig implements WebMvcConfigurer {

// 该类加了@Configuration注解,由Spring来构建,可以做依赖注入
@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
// 注意这里每个链接必须得前面加/
// 比如:如果是user/code还是会被拦截,/user/code才不会被拦截
}
}
登录拦截器的优化

登录拦截器优化前:

登录拦截器优化后:

优化方法:使用RefreshTokenInterceptor拦截一切路径,在这里刷新token有效期。

1.RefreshTokenInterceptor

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
package com.hmdp.utils;

@Slf4j
public class RefreshTokenInterceptor implements HandlerInterceptor {

private StringRedisTemplate stringRedisTemplate;
// 这里不能使用@Resource注解来注入,只能使用构造函数来注入。
// 因为LoginInterceptor类的对象是手动new出来的,不是通过Spring创建的,无法做依赖注入

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获取redis中的用户
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);

//3.判断用户是否存在
if(userMap.isEmpty()) {//为空放行到下一个拦截器
return false;
}

//5. 将查询到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

//6.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);//保存到当前线程里

//7. 刷新token有效期
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

//8.放行
return true;
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//移除用户
UserHolder.removeUser();
}
}

2.LoginInterceptor拦截需要进行登录验证的路径,判断是否存在用户。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.hmdp.utils;

@Slf4j
public class LoginInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.判断是否需要拦截(ThreadLocal中是否有用户
if(UserHolder.getUser() == null){
//没有,需要拦截,设置状态码
response.setStatus(401);
//拦截
return false;
}
//有用户,则放行
return true;
}
}

3.MvcConfig,注册两个拦截器。

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
package com.hmdp.config;

@Configuration
public class MvcConfig implements WebMvcConfigurer {

// 该类加了@Configuration注解,由Spring来构建,可以做依赖注入
@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public void addInterceptors(InterceptorRegistry registry) {
//登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
// 注意这里每个链接必须得前面加/
// 比如:如果是user/code还是会被拦截,/user/code才不会被拦截

//token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**").order(0);
//默认拦截所有请求
}
}

商户查询缓存

缓存

缓存就是数据交换的缓冲区(称作Cache),是存贮数据的临时地方,一般读写性能较高。

缓存的作用:降低后端负载,提高读写效率,降低响应时间。

缓存的成本:数据一致性成本、代码维护成本、运维成本。

添加Redis缓存

给根据id查询商铺添加缓存

1.ShopController

1
2
3
4
5
6
7
8
9
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}

2.IShopService

1
Result queryById(Long id);

3.ShopServiceImpl

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
package com.hmdp.service.impl;

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

@Resource
private StringRedisTemplate stringRedisTemplate;

public ShopServiceImpl(StringRedisTemplate stringRedisTemplate) {
}

@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if(shop == null){
return Result.fail("店铺不存在");
}
//6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
//7.返回
return Result.ok(shop);
}
}
给店铺类型查询业务添加缓存

需求:修改ShopTypeController中的queryTypeList方法,添加查询缓存。

参考链接:

【黑马点评】给店铺类型查询业务添加缓存【业务实现】_黑马点评商品类型-CSDN博客

【黑马点评】实战篇-作业-店铺类型缓存-List实现 - chendsome - 博客园

1.ShopTypeController

1
2
3
4
5
6
@GetMapping("list")
public Result queryTypeList() {
/* List<ShopType> typeList = typeService
.query().orderByAsc("sort").list();*/
return typeService.queryTypeList();
}

2.IShopTypeService

1
Result queryTypeList();

3.ShopTypeServiceImpl

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
package com.hmdp.service.impl;

@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result queryTypeList() {
//1.从redis中查询店铺类型缓存
String shopType = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOPTYPE_KEY);
//2.判断是否为空
if (StrUtil.isNotBlank(shopType)) {
//3.存在,直接返回
List<ShopType> shopTypes = JSONUtil.toList(shopType, ShopType.class);
return Result.ok(shopTypes);
}
//4.不存在,从数据库中查询写入redis
List<ShopType> shopTypes = query().orderByAsc("sort").list();
//5.不存在,返回错误
if (shopTypes == null) {
return Result.fail("分类不存在");
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOPTYPE_KEY,JSONUtil.toJsonStr(shopTypes));
//7.返回
return Result.ok(shopTypes);
}
}

缓存更新策略

内存淘汰 超时剔除 主动更新
说明 不用自己维护,利用Redis的内存淘汰机制,当内存不足时自动淘汰部分数据。下次查询时更新缓存。 给缓存数据添加TTL时间,到期后自动删除缓存。下次查询时更新缓存。 编写业务逻辑,在修改数据库的同时,更新缓存。
一致性 一般
维护成本

业务场景:

  • 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存。
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存。
主动更新策略
1. Cache Aside Pattern

由缓存的调用者,在更新数据库的同时更新缓存。

操作缓存和数据库时有三个问题需要考虑:

删除缓存还是更新缓存:选择删除缓存

  • 更新缓存:每次更新数据库都更新缓存,无效写操作较多。
  • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存

如何保证缓存与数据库的操作的同时成功或失败?

  • 单体系统,将缓存与数据库操作放在一个事务。
  • 分布式系统,利用TCC等分布式事务方案。

先操作缓存还是先操作数据库:先写数据库,然后再删除缓存

  • 先删除缓存,再操作数据库。
  • 先操作数据库,再删除缓存。

案例:

1.一开始缓存和数据库均为10,正常操作的流程如下:线程1删除缓存后更新数据库为20,此时线程2查询缓存,未命中之后查询数据库,得到的值为20,将该值写入缓存。在这种情况下两次得到的值都是正确的。

2.一开始缓存和数据库均为10,线程1执行先删除缓存再更新数据库的操作。当线程1删除缓存后,更新数据库之前,此时线程2查询缓存,未命中并查询数据库,得到的值为20,并将20写入缓存。在线程2操作完毕之后,线程1更新数据库为20,此时缓存和数据库的值不一样,就造成了数据不一致的情况。

这种情况发生的可能性会比较高,因为更新数据库的操作会比较长,此时线程2更容易进来执行。

3.开始缓存和数据库均为10,正常操作的流程如下:线程2更新数据库为20,然后再将缓存删除,此时线程1查询缓存,未命中之后查询数据库,得到的值为20,将该值写入缓存。在这种情况下两次得到的值都是正确的。

4.一开始缓存和数据库均为10,线程2执行先更新数据库再删除缓存的操作。首先线程1查询缓存,未命中并查询数据库值为10。此时线程2更新数据库为20,并删除缓存。此时切换到线程1执行写入缓存的操作,因为之前线程1查询缓存结果为10,此时写入缓存的值为10。此时缓存和数据库的值不一样,就造成了数据不一致的情况。

这种情况发生的可能性非常低,因为写入缓存的操作非常快,而更新数据库的操作时间较长,此时几乎不会切换到线程2执行更新数据库操作之后再切换到进程1更新缓存,而是直接在线程1更新缓存。

综上分析,选择先操作数据库,再删除缓存的方式进行更新。

2. Read/Write Through Pattern

缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。

3. Write Behind Caching Pattern

调用者只操作缓存,由其它线程异步的将缓存数据持久化到数据库,保证最终一致。

缓存更新策略的最佳实践方案
  • 低一致性需求:使用Redis自带的内存淘汰机制。
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案。

读操作:

  • 缓存命中则直接返回。
  • 缓存未命中则查询数据库,并写入缓存,设定超时时间。

写操作:

  • 先写数据库,然后再删除缓存
  • 要确保数据库与缓存操作的原子性
给查询商铺的缓存添加超时剔除和主动更新的策略

修改ShopController中的业务逻辑,满足下面的需求:

  • 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间。

ShopServiceImplqueryById设置超时时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if(shop == null){
return Result.fail("店铺不存在");
}
//6.存在,写入Redis(设置超时时间)
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7.返回
return Result.ok(shop);
}

测试方法:查看Redis下过期时间是否更新。

  • 根据id修改店铺时,先修改数据库,再删除缓存。

1.ShopController

1
2
3
4
5
6
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
//shopService.updateById(shop);
return shopService.update(shop);
}

2.IShopService

1
Result update(Shop shop);

3.ShopServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
@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 + id);
return Result.ok();
}
}

测试方法

通过Postman更新店铺:http://localhost:8080/api/shop,使用PUT方法,使用raw填写JSON修改数据:

1
2
3
4
5
6
7
8
9
10
11
12
{
"area": "大关",
"openHours": "10:00-22:00",
"sold": 4215,
"address": "金华路锦昌文华苑29号",
"comments": 3035,
"avgPrice": 80,
"score": 37,
"name": "101茶餐厅",
"typeId": 1,
"id": 1
}

修改之后再次查看数据,会发现缓存已经被删除。此时重新查询数据库,并写入缓存。以后再访问则直接访问缓存。

缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库

常见的解决方案有两种:

  1. 缓存空对象

    • 优点:实现简单,维护方便

    • 缺点:

      • 额外的内存消耗
      • 可能造成短期的不一致
  2. 布隆过滤

    • 优点:内存占用较少,没有多余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
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 判断命中的是否是空值
if(shopJson != null){
//返回一个错误信息
return Result.fail("店铺信息不存在");
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if(shop == null){
// 将空值写入Redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
//6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7.返回
return Result.ok(shop);
}

测试方法:

访问不存在的店铺id为0,链接:http://localhost:8080/api/shop/0

第一次访问时,查询了数据库,并将空值存入缓存。后续访问均不查询数据库,直接访问缓存,直到缓存过期再重新访问数据库。

总结

缓存穿透产生的原因:用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力。

缓存穿透的解决方案有哪些?

  • 缓存null
  • 布隆过滤
  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

  • 给不同的KeyTTL添加随机值(针对key同时失效。在批量导入key的时候,给TTL后面加入随机数,比如设置有效期为30分钟,可以随机加上1-5分钟,使得TTL在30-35之间波动,可以使key失效的时间在一个时间段内,而不是一起失效。)
  • 利用Redis集群提高服务的可用性(针对Redis服务宕机,比如Redis哨兵机制)
  • 给缓存业务添加降级限流策略(提前做好容错处理,当发现Redis故障时,及时做服务降级,快速失败拒绝服务。而不是把请求压到数据库,这样子做牺牲部分服务,保护数据库。)
  • 给业务添加多级缓存(浏览器、nginxredisjvm、数据库多个层面进行缓存。)

缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期
解决方案 优点 缺点
互斥锁 没有额外的内存消耗保证一致性实现简单 线程需要等待,性能受影响可能有死锁风险
逻辑过期 线程无需等待,性能较好 不保证一致性有额外内存消耗实现复杂

互斥锁

代码

案例:基于互斥锁方式解决缓存击穿问题

需求:修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题。

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
package com.hmdp.service.impl;

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

@Resource
private StringRedisTemplate stringRedisTemplate;

public ShopServiceImpl(StringRedisTemplate stringRedisTemplate) {
}

@Override
public Result queryById(Long id) {
//缓存穿透
//Shop shop = queryWithPassThrough(id);

//互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if(shop == null){
return Result.fail("店铺不存在!");
}

return Result.ok(shop);
}

public Shop queryWithMutex(Long id){
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的是否是空值
if(shopJson != null){
//返回一个错误信息
return null;
}
String lockKey = null;
Shop shop = null;

try {
//4.实现缓存重建
//4.1 获取互斥锁
lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
//4.2 判断是否获取成功
if(!isLock){
//4.3 失败,则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}

//获取锁成功,再次检测redis缓存是否存在,做DoubleCheck,如果存在则无需重建缓存。
//1.从redis查询商铺缓存
shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//不存在,再根据id查询数据库

//4.4成功,根据id查询数据库
shop = getById(id);

//模拟重建的延时,延时越高,并发出现的线程越多
Thread.sleep(200);

//5.不存在,返回错误
if(shop == null){
// 将空值写入Redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
//6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//7.释放互斥锁
unlock(lockKey);
}

//8.返回
return shop;
}

public Shop queryWithPassThrough(Long id){
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的是否是空值
if(shopJson != null){
//返回一个错误信息
return null;
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if(shop == null){
// 将空值写入Redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7.返回
return shop;
}

private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//锁的有效时长比正常业务执行的时间长10-20倍就好,这里设置为10s,避免异常情况。
return BooleanUtil.isTrue(flag);//flag可能出现null值
}

private void unlock(String key) {
stringRedisTemplate.delete(key);
}

@Override
@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 + id);
return Result.ok();
}
}
测试

1.使用Jmeter进行测试,定义线程组,设置1000个线程,执行时间为5s。

2.填写HTTP请求,然后点击运行。

3.运行结束后在查看结果树中查看运行的结果。

4.查看后台程序,可以发现访问数据库语句只执行了一条,说明互斥锁程序没有问题。

逻辑过期

代码

案例:基于逻辑过期方式解决缓存击穿问题

需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题。

注意:因为会在测试时,提前准备好数据,所以上述如果访问缓存不命中的话,直接返回空,然后在test方法中,调用ShopServiceImplsaveShop2Redis方法提前在缓存中准备好数据。

1
2
3
4
5
6
7
8
9
10
11
12
package com.hmdp;

@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private ShopServiceImpl shopService;

@Test
void testSaveShop() throws InterruptedException {//测试提前准备好预热数据
shopService.saveShop2Redis(1L, 10L);
}
}

ShopServiceImpl

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
package com.hmdp.service.impl;

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

@Resource
private StringRedisTemplate stringRedisTemplate;

public ShopServiceImpl(StringRedisTemplate stringRedisTemplate) {
}

@Override
public Result queryById(Long id) {
//缓存穿透
//Shop shop = queryWithPassThrough(id);

//互斥锁解决缓存击穿
/* Shop shop = queryWithMutex(id);
if(shop == null){
return Result.fail("店铺不存在!");
}*/

//逻辑过期解决缓存击穿
Shop shop = queryWithLogicalExpire(id);
if(shop == null){
return Result.fail("店铺不存在!");
}

return Result.ok(shop);
}

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

public Shop queryWithLogicalExpire(Long id){
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isBlank(shopJson)){
//3.不存在,直接返回null
return null;
}

//4.命中,需要先把json发序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();

//5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
//5.1 未过期,直接返回店铺信息
return shop;
}

//5.2 已过期,需要缓存重建
//6.缓存重建
//6.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);

//6.2 判断是否获取锁成功
if(isLock){
//获取锁成功应该再次检测redis缓存是否过期,做DoubleCheck。如果存在则无需重建缓存。
//判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
//5.1 未过期,直接返回店铺信息
return shop;
}
//6.3 成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//重建缓存
this.saveShop2Redis(id, 20L);//测试的时候用20s(期待缓存过期做缓存重建查看是否触发线程安全问题),实际过期时间设置为30min。
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放锁
unlock(lockKey);
}
});
}

//6.4返回过期的商铺信息

return shop;
}

public Shop queryWithMutex(Long id){
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的是否是空值
if(shopJson != null){
//返回一个错误信息
return null;
}
String lockKey = null;
Shop shop = null;

try {
//4.实现缓存重建
//4.1 获取互斥锁
lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
//4.2 判断是否获取成功
if(!isLock){
//4.3 失败,则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}

//获取锁成功,再次检测redis缓存是否存在,做DoubleCheck,如果存在则无需重建缓存。
//1.从redis查询商铺缓存
shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//不存在,再根据id查询数据库

//4.4成功,根据id查询数据库
shop = getById(id);

//模拟重建的延时,延时越高,并发出现的线程越多
Thread.sleep(200);

//5.不存在,返回错误
if(shop == null){
// 将空值写入Redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
//6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//7.释放互斥锁
unlock(lockKey);
}

//8.返回
return shop;
}

public Shop queryWithPassThrough(Long id){
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的是否是空值
if(shopJson != null){
//返回一个错误信息
return null;
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if(shop == null){
// 将空值写入Redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7.返回
return shop;
}

private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//锁的有效时长比正常业务执行的时间长10-20倍就好,这里设置为10s,避免异常情况。
return BooleanUtil.isTrue(flag);//flag可能出现null值
}

private void unlock(String key) {
stringRedisTemplate.delete(key);
}


public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {//提前将高并发数据存入Redis中
//1.查询店铺数据
Shop shop = getById(id);

//模拟延迟,休眠200ms
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));
}


@Override
@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 + id);
return Result.ok();
}
}
测试

1.使用Jmeter进行测试,定义线程组,设置200个线程,执行时间为1s。

2.填写HTTP请求(和上述互斥锁的一样),然后点击运行。

3.运行结束后在查看结果树中查看运行的结果。

4.查看后台程序,可以发现访问数据库语句只执行了一条,说明互斥锁程序没有问题。

执行流程分析:提前预存好的缓存很快过期(有效期10s),运行Jmeter测试后,当Java程序命中缓存判断缓存失效后,会先返回当前结果,然后再访问数据库更新缓存。所以一开始的Jmeter线程读取到的是过期数据,等过了一段时间完成缓存更新后,Jmeter线程读取到的即为最新数据。

缓存工具封装

基于StringRedisTemplate封装一个缓存工具类,满足下列需求:

  • 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间。
  • 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题。
  • 方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题。
  • 方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题。

1.缓存工具类:CacheClient

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
package com.hmdp.utils;

@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;

public CacheClient(StringRedisTemplate stringRedisTemplate) {//也可以用@Resource注解注入
this.stringRedisTemplate = stringRedisTemplate;
}

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 redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));

//写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}

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;
}


private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

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.不存在,直接返回null
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 lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);

//6.2 判断是否获取锁成功
if(isLock){
//获取锁成功应该再次检测redis缓存是否过期,做DoubleCheck。如果存在则无需重建缓存。
//判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
//5.1 未过期,直接返回店铺信息
return r;
}
//6.3 成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//重建缓存
R r1 = dbFallback.apply(id);
//写入Redis
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放锁
unlock(lockKey);
}
});
}

//6.4返回过期的信息
return r;
}

private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//锁的有效时长比正常业务执行的时间长10-20倍就好,这里设置为10s,避免异常情况。
return BooleanUtil.isTrue(flag);//flag可能出现null值
}

private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}

2.使用缓存工具类。

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
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

@Resource
private StringRedisTemplate stringRedisTemplate;

@Resource
private CacheClient cacheClient;

public ShopServiceImpl(StringRedisTemplate stringRedisTemplate) {
}

@Override
public Result queryById(Long id) {
//缓存穿透
//Shop shop = queryWithPassThrough(id);

//使用cacheClient传入函数有两种写法,都对
/* Shop shop = cacheClient.queryWithPassThrough(
CACHE_SHOP_KEY, id, Shop.class, id2 -> getById(id2), CACHE_SHOP_TTL, TimeUnit.MINUTES);*/
/* Shop shop = cacheClient.queryWithPassThrough(
CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);*/


//互斥锁解决缓存击穿
/* Shop shop = queryWithMutex(id);
if(shop == null){
return Result.fail("店铺不存在!");
}*/

//逻辑过期解决缓存击穿
//Shop shop = queryWithLogicalExpire(id);
Shop shop = cacheClient.queryWithLogicalExpire(
CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);
if(shop == null){
return Result.fail("店铺不存在!");
}

return Result.ok(shop);
}
}

Java-Redis:实战篇(1)
http://surourou8.github.io/2024/12/19/Java-Redis:实战篇(1)/
作者
Su Rourou
发布于
2024年12月19日
许可协议