导入项目

项目介绍

  • 此次项目采用前后端分离开发模式
  • 手机或者app发起请求,请求Nginx服务器,Nginx基于七层模型走HTTP协议,实现基于Lua绕开Tomcat访问Redis,也可以作为静态资源服务器,轻松抗下上万并发请求。

导入SQL

黑马以及提供好了需要导入的SQL文件,以下是SQL文件中提供的表

说明
tb_user用户表
tb_user_info用户详情表
tb_shop商户信息表
tb_shop_type商户类型表
tb_blog用户日记表(达人探店日记)
tb_follow用户关注表
tb_voucher优惠券表
tb_voucher_order优惠券的订单表

导入后端项目

黑马已经提供好了后端项目源码压缩包,我们将其解压之后,放到自己的工作目录里

使用IDEA打开项目,修改application.yml文件,将数据库连接信息修改为自己的连接信息

启动项目,访问http://localhost:8081/shop-type/list,如果可以看到JSON数据,说明项目启动成功

导入前端项目

前端项目黑马也以及提供了源码压缩包,我们解压之后,在Nginx所在目录打开cmd窗口直接输入命令即可启动程序

1
start nginx

访问https://localhost:8080/,如果能够访问到登录页面,说明项目启动成功。
访问失败

访问失败的原因可能有很多,记录一下我遇到的localhost拒绝访问的原因:

  1. Nginx文件夹所处目录含有中文或空格等字符,导致Nginx启动失败
  2. Nginx端口被占用,导致Nginx启动失败

如何查看端口被哪个进程占用?

1
2
# 这里的port替换成需要查找的端口号
netstat -ano | findstr port

查看对应端口的应用是什么
1
tasklist | findstr pid

如何杀死进程?

1
2
# 这里的pid替换成需要杀死的进程号
taskkill /f /pid xxx

在命令提示符窗口中输入命令

1
tasklist /fi "imagename eq nginx.exe"

启动成功:

启动失败:

短信登录

基于Session实现登录流程

  1. 发送验证码
    用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
    如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
  2. 短信验证码登录、注册
    用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
  3. 校验登录状态
    用户在请求的时候,会从cookie中携带JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并放行

模拟发送短信验证码功能

  • 输入手机号,点击发送验证码按钮,查看发送的请求

    请求网址: http://localhost:8080/api/user/code?phone=15832165478
    请求方法: POST

  • 调用UserController中的code方法,携带参数是phone

    1
    2
    3
    4
    5
    6
    7
    8
    /**
    * 发送手机验证码
    */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
    // TODO 发送短信验证码并保存验证码
    return userService.sendCode(phone,session);
    }

    此处黑马没有使用真的短信服务发送验证码,只是随机生成了一个验证码来作校验使用,后期自己想实现此功能可以用邮箱验证(免费),也可以用阿里云短信服务(有一定费用)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    @Slf4j
    @Service
    public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    @Override
    public Result sendCode(String phone, HttpSession session) {
    //1.校验手机号(一般使用正则表达式,验证手机号是否符合正常规范,如是否为11位,是否是1开头)
    if (RegexUtils.isPhoneInvalid(phone)) {
    //2.如果不符合,返回错误信息
    return Result.fail("手机号格式错误!");
    }
    //3.符合则生成验证码(6是指定验证码为6位)
    String code = RandomUtil.randomNumbers(6);
    //4.保存验证码导session中
    session.setAttribute("code", code);
    //5.发送验证码
    //这里采用记录日志来简单模拟(记得加上Slf4j注解)
    log.debug("发送短信验证码成功,验证码:{}", code);
    //返回OK
    return Result.ok();
    }
    }

根据验证码登录

login为实现登录的函数,LoginFormDTO为实体类

1
2
3
4
5
6
@PostMapping("/login")
//前端提交的是Json数据,所以需要用@RequestBody注解,来为实体类赋值
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// TODO 实现登录功能
return Result.fail("功能未完成");
}
1
2
3
4
5
6
7
8
@Data
public class LoginFormDTO {
//手机号码登录
private String phone;
private String code;
//密码登录(暂不使用)
private String password;
}

登录方法中不需要返回登录的凭证,因为是将用户的信息保存在session中,访问Tomcat时,sessionId早已自动写在Cookie中,每次请求都会写到Cookie,能直接在Cookie中找到用户信息

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
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.校验验证码
//从session中取出生成的随机验证码
Object cacheCode = session.getAttribute("code");
//取出用户输入的验证码
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.toString().equals(code)){
//4.不一致,报错
return Result.fail("验证码错误");
}
//4.一致,根据手机号查询用户(MybatisPlus提供的快捷单表增删改查)
User user = query().eq("phone", phone).one();

//5.判断用户是否存在
if(user == null){
//不存在,则创建(暂时还未实现,提前先把创建的方法写上,
//将创建的值返回保存在user,确保下面保存用户信息时user一定有值
user = createUserWithPhone(phone);
}
//7.保存用户信息到session中
session.setAttribute("user",user);

return Result.ok();
}
1
2
3
4
5
6
7
8
9
private User createUserWithPhone(String phone) {
//1.创建用户
User user = new User();
user.setPhone(phone);
user.setNickName("user_" + RandomUtil.randomString(10));
//2.保存用户
save(user);
return user;
}

实现登录拦截功能

  • 登录拦截功能,就是用户如果没有登录,访问其他页面的时候,会自动跳转到登录页面。
  • 首先需要我们先创建一个拦截器,并实现HandlerInterceptor接口,重写前置拦截器和完成处理方法。
    • 前置拦截器:在控制器方法执行之前执行,如果返回true,则继续执行控制器方法,如果返回false,则取消执行控制器方法。
    • 完成处理方法:在控制器方法执行完成之后执行,无论控制器方法是否执行成功,都会执行完成处理方法。

注意: 此处黑马视频中与提供的资料略有不同,视频中全部为User类,此处为UserDTO类,下一节说明为什么换为UserDTO类。

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 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(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
//5.存在,保存用户信息到ThreadLocal
UserHolder.saveUser((UserDTO)user);
//6.放行
return true;
}

//销毁对应的登录信息
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户
UserHolder.removeUser();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

public static void saveUser(UserDTO user){
tl.set(user);
}

public static UserDTO getUser(){
return tl.get();
}

public static void removeUser(){
tl.remove();
}
}

  • 创建完拦截器之后,需要将拦截器添加到SpringMVC的拦截器链中,再从Config目录下创建MvcConfig类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    //不要忘记添加@Configuration注解,注册为配置类
    @Configuration
    public class MvcConfig implements WebMvcConfigurer {
    //添加拦截注册器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    //设置拦截器,除了以下界面其余都拦截
    registry.addInterceptor(new LoginInterceptor())
    .excludePathPatterns(
    "/login",
    "/blog/hot",
    "/shop/**",
    "/voucher/**",
    "/shop-type/**",
    "/upload/**",
    "/user/code",
    "/user/login"
    );
    }
    }
  • 再修改一下UserController中的me方法
    1
    2
    3
    4
    5
    6
    7
    @GetMapping("/me")
    public Result me(){
    // 获取当前登录的用户并返回
    //直接获取之前保存在Threadlocal里的User信息
    UserDTO user = UserHolder.getUser();
    return Result.ok(user);
    }

隐藏用户敏感信息

如何在之前使用的全是User类,而非UserDTO类,此时查看浏览器返回的信息中,用户包括密码等所有信息都会被返回,这是非常危险的,因此通过创建一个UserDTO类,并只返回需要的信息,从而隐藏用户敏感信息

1
2
3
4
5
6
7
8
9
10
11
12
{
"success":true,
"data":{
"id":1010,
"phone":"1586385296@qq.com",
"password":"",
"nickName":"user_i1b3ir09",
"icon":"",
"createTime":"2022-10-22T14:20:33",
"updateTime":"2022-10-22T14:20:33"
}
}
1
2
3
4
5
public class UserDTO{
private Long id;
private String nickName;
private String icon;
}

如果前面将黑马提供的资料中的UserDTO类改为了User,则此处还需再改回来,如果没有更改,则只需要改变login方法中第七步保存用户信息的代码即可。

1
2
3
4
5
6
7
8
9
//5.判断用户是否存在
if(user == null){
//6.不存在,则创建(暂时还未实现,提前先把创建的方法写上)
//将创建的值返回保存在user,确保下面保存用户信息时user一定有值
user = createUserWithPhone(phone);
}
//7.保存用户信息到session中
- session.setAttribute("user", user);
+ session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));

session共享问题

  • 每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?

    • 早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
  • 但是这种方案具有两个大问题

    • 每台服务器中都有完整的一份session数据,服务器压力过大
    • session拷贝数据时,可能会出现延迟
  • 所以我们后面都是基于Redis来完成,我们把session换成Redis,Redis数据本身就是共享的,就可以避免session共享的问题了。

Redis替代session的业务流程

设计key结构

  • 首先我们来思考一下该用什么数据结构来存储数据
  • 由于存入的数据比较简单,我们可以使用String或者Hash
    • 如果使用String,以JSON字符串来保存数据,会额外占用部分空间
    • 如果使用Hash,他会将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且内存占用更少
  • 如果不是特别在意内存且数据量不大,可以直接使用String
  • 如果考虑到优化等问题,可以选择Hash,此处选择Hash

设计key的具体细节

  • 我们这里就采用的是简单的K-V键值对方式
  • 对于key的处理,不能像session一样用code来当做key
  • 因为Redis的key是共享的,code可能会重复,也不建议使用手机号,因为phone这种敏感字段不适合存储到Redis中
  • 在设计key的时候,我们需要满足两点
    1. key要有唯一性
    2. key要方便携带
  • 所以我们在后台随机生成一个token,然后让前端带着这个token就能完成我们的业务逻辑了

整体访问流程

  • 当注册完成后,用户去登录,然后校验用户提交的手机号/邮箱和验证码是否一致
    • 如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到Redis,并生成一个token作为Redis的key
  • 当我们校验用户是否登录时,会携带着token进行访问,从Redis中获取token对应的value,判断是否存在这个数据
    • 如果不存在,则拦截
    • 如果存在,则将其用户信息(userDto)保存到threadLocal中,并放行

基于Redis实现短信登录

Redis的相关配置在最开始已经配置好了,直接编写代码注入StringRedisTemplate即可

1
2
@Autowired
private StringRedisTemplate stringRedisTemplate;

  • 修改SendCode方法
  • 1
    2
    3
    4
    5
    6
    7
    8
            //3.符合则生成验证码(6是指定验证码为6位)
    String code = RandomUtil.randomNumbers(6);
    - //4.保存验证码到session中
    - session.setAttribute("code", code);
    + //4.保存验证码到Redis,
    + //直接将手机号存入,可能别的功能也会直接存入手机号,造成冲突,故最好加上前缀
    + //给验证码数据设置有效期,防止Redis长期存储失效的数据,这些不会变化的数据最好设为常量
    + stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Override
    public Result sendCode(String phone, HttpSession session) {
    //1.校验手机号(一般使用正则表达式,验证手机号是否符合正常规范,如是否为11位,是否是1开头)
    if (RegexUtils.isPhoneInvalid(phone)) {
    //2.如果不符合,返回错误信息
    return Result.fail("手机号格式错误!");
    }
    //3.符合则生成验证码(6是指定验证码为6位)
    String code = RandomUtil.randomNumbers(6);
    //4.保存验证码到Redis,
    //直接将手机号存入,可能别的功能也会直接存入手机号,造成冲突,故最好加上前缀
    //给验证码数据设置有效期,防止Redis长期存储失效的数据
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);

    //5.发送验证码
    //可以使用第三方平台如阿里云,容联运等实现,用也可以尝试使用qq邮箱实现,这里采用记录日志来简单模拟(记得加上Slf4j注解)
    log.debug("发送短信验证码成功,验证码:{}", code);
    //返回OK
    return Result.ok();
    }

    key使用用login:code:email的形式,并设置有效期2分钟,我们可以定义一个常量类来替换这里的login:code:和2,让代码显得更专业一点

    1
    2
    3
    4
    public class RedisConstants {
    public static final String LOGIN_CODE_KEY = "login:code:";
    public static final Long LOGIN_CODE_TTL = 2L;
    }

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
    @Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号(每次独立的请求都应当校验一下)
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.校验验证码
- //从session中取出生成的随机验证码
- Object cacheCode = session.getAttribute("code");
+ String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
//取出用户输入的验证码
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.toString().equals(code)){
//4.不一致,报错
return Result.fail("验证码错误");
}
//4.一致,根据手机号查询用户(MybatisPlus提供的快捷单表增删改查)
User user = query().eq("phone", phone).one();

//5.判断用户是否存在
if(user == null){
//6.不存在,则创建(暂时还未实现,提前先把创建的方法写上)
//将创建的值返回保存在user,确保下面保存用户信息时user一定有值
user = createUserWithPhone(phone);
}
- //7.保存用户信息到session中
- session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));

+ //7.保存用户信息到redis中
+ //7.1 随机生成token,作为登录令牌
+ //UUID java和Hutools都有,此处使用的是Hutools的
+ String token = UUID.randomUUID().toString(true);
+ //7.2 将User对象转为Hash存储
+ UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
+ Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
+ //7.3 存储
+ //使用put(String key,Object hashKey,Object value)的话,每个字段都要执行一次,需要与服务器进行多次交互
+ stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token, userMap);
+ //7.4 设置token有效期
+ stringRedisTemplate.expire(LOGIN_USER_KEY,30, TimeUnit.MINUTES);
+ //8.返回token
- return Result.ok();
+ return Result.ok(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
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号(每次独立的请求都应当校验一下)
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.校验验证码

String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
//取出用户输入的验证码
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.toString().equals(code)){
//4.不一致,报错
return Result.fail("验证码错误");
}
//4.一致,根据手机号查询用户(MybatisPlus提供的快捷单表增删改查)
User user = query().eq("phone", phone).one();

//5.判断用户是否存在
if(user == null){
//6.不存在,则创建(暂时还未实现,提前先把创建的方法写上)
//将创建的值返回保存在user,确保下面保存用户信息时user一定有值
user = createUserWithPhone(phone);
}
//7.保存用户信息到redis中
//7.1 随机生成token,作为登录令牌
//UUID java和Hutools都有,此处使用的是Hutools的
String token = UUID.randomUUID().toString(true);
//7.2 将User对象转为Hash存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
//7.3 存储
//使用put(String key,Object hashKey,Object value)的话,每个字段都要执行一次,需要与服务器进行多次交互
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token, userMap);
//7.4 设置token有效期
stringRedisTemplate.expire(LOGIN_USER_KEY,30, TimeUnit.MINUTES);
//8.返回token
return Result.ok(token);
}

解决登录状态刷新问题

初始方案:

  • 查看请求,发现我们存的token在请求头中。

    authorization: 6867061d-a8d0-4e60-b92f-97f7d698a1ca

  • 通过拦截器拦截到请求,来证明用户正在操作,每次拦截时刷新用户登录状态,如果用户在30分钟没有任何操作,则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
        @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");
    //1.获取请求头上的token,authorization为课程中展示的前端代码
    + String token = request.getHeader("authorization");
    + if(StrUtil.isBlank(token)){
    + //如果token为空,则直接拦截并返回状态码
    + response.setStatus(401);
    + return false;
    + }
    + //2.根据token获取redis中的用户
    + //使用get返回的仅仅是某一个key的某一字段,这里使用entries
    + Map<Object, Object> usermap = stringRedisTemplate.opsForHash()
    + .entries(LOGIN_USER_KEY + token);


    //3.判断用户是否存在
    - if(user==null)
    + if(usermap.isEmpty()){
    //4.不存在,拦截,返回401状态码
    response.setStatus(401);
    return false;
    }
    + //5.将查询的Hash数据转为UserDTO对象
    + UserDTO userDTO = BeanUtil.fillBeanWithMap(usermap, new UserDTO(), false);

    - //5.存在,保存用户信息到ThreadLocal
    - UserHolder.saveUser((UserDTO)user);
    + //6.存在,保存用户信息到ThreadLocal
    + UserHolder.saveUser(userDTO);
    + //7.刷新token有效期
    + stringRedisTemplate.expire(LOGIN_USER_KEY+token,30, TimeUnit.MINUTES);
    + //8.放行
    - //6.放行
    return true;
    }
    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
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

    //1.获取请求头上的token,authorization为课程中展示的前端代码
    String token = request.getHeader("authorization");
    if(StrUtil.isBlank(token)){
    //如果token为空,则直接拦截并返回状态码
    response.setStatus(401);
    return false;
    }
    //2.根据token获取redis中的用户
    //使用get返回的仅仅是某一个key的某一字段,这里使用entries
    Map<Object, Object> usermap = stringRedisTemplate.opsForHash()
    .entries(LOGIN_USER_KEY + token);


    //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(LOGIN_USER_KEY+token,30, TimeUnit.MINUTES);
    //8.放行

    return true;
    }

在这个方案中,用户在访问需要拦截的路径时,会触发拦截器的执行,同时刷新登录token令牌的存活时间,但是如果当前用户只访问了一些不需要拦截的路径,那么这个拦截器就不会生效,此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的。

商户查询缓存

什么是缓存

缓存是数据交换的缓冲区,用来临时存储数据,一般读写性能较高

  • 缓存就像自行车、越野车的避震器,不但能防止车体在移动过程中因震动而摇晃,从而提高车辆性能,还能避免车体在地面上飞跃硬着陆导致损坏
  • 在实际开发中,系统也需要“避震器”来防止过高的访问量导致数据库压力过大,导致操作线程无法及时处理信息而瘫痪。

哪里用到了缓存

  • 实际开发中,会构筑多级缓存来对系统运行速度进一步提升,例如:本地缓存与Redis中的缓存并发使用
  • 浏览器缓存:主要是存在于浏览器端的缓存
  • 应用层缓存:可以分为toncat本地缓存,例如之前提到的map或者是使用Redis作为缓存
  • 数据库缓存:在数据库中有一片空间是buffer pool,增改查数据都会先加载到mysql的缓存中
  • CPU缓存:当代计算机最大的问题就是CPU性能提升了,但是内存读写速度没有跟上,所以为了适应当下的情况,增加了CPU的L1,L2,L3级的缓存

为什么要用缓存

  • 言简意赅:`速度快,好用
  • 缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
  • 缓存可以减少数据库的访问次数,从而提高系统的性能
  • 缓存可以减少数据库的压力,从而提高系统的稳定性
  • 实际开发中,企业的数据量,少则几十万,多则几千万,这么大的数据量,如果没有缓存来作为避震器系统是几乎撑不住的,所以企业会大量运用缓存技术
  • 但是缓存也会增加代码复杂度和运营成本

  • 缓存的作用

    1. 降低后端负载
    2. 提高读写效率,降低响应时间
  • 缓存的成本

    1. 数据一致性成本
    2. 代码维护成本
    3. 运维成本(一般采用服务器集群,需要多加机器,机器就是钱)

添加商户缓存

启动项目后,随便访问一个商户,查看浏览器发送的请求

请求网址: http://localhost:8080/api/shop/10
请求方法: GET

  • 此时浏览器发送的是Restful风格的请求,请求路径中包含了商户id
  • 再找到ShopController层调用的方法,发现是调用的ShopService的getById方法,直接在数据库中查询
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /**
    * 根据id查询商铺信息
    * @param id 商铺id
    * @return 商铺详情数据
    */
    @GetMapping("/{id}")
    public Result queryShopById(@PathVariable("id") Long id) {
    return Result.ok(shopService.getById(id));
    }
  • 我们可以通过在客户端与数据库之间加上一个Redis缓存,先从Redis中查询,如果没有查到,再去MySQL中查询,同时查询完毕之后,将查询到的数据也存入Redis,这样当下一个用户来进行查询的时候,就可以直接从Redis中获取到数据

代码实现

代码思路:

  1. 从Redis中查询商户缓存
  2. 如果缓存存在,直接返回
  3. 如果缓存不存在,从MySQL中查询
  4. 如果MySQL中也不存在,返回错误
  5. 如果MySQL中存在,将查询到的数据存入Redis缓存
  6. 返回数据

Controller层只负责调用方法,将业务逻辑写到Service,然后去ServiceImpl层实现

1
2
3
4
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}

1
2
3
public interface IShopService extends IService<Shop> {
Result queryById(Long 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
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private StringRedisTemplate stringRedisTemplate;

@Override
public Result queryById(Long id) {
// 1. 从Redis中查询商户缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id.toString());
// 2. 判断是否存在
if(StrUtil.isNotBlank(shopJson)){
// 3. 如果缓存存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shopJson);
}
// 4. 如果缓存不存在,从MySQL中查询
Shop shop = baseMapper.selectById(id);
// 5. 如果MySQL中也不存在,返回错误
if(shop == null){
return Result.fail("店铺不存在");
}
// 6. 如果MySQL中存在,将查询到的数据存入Redis缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id.toString(),JSONUtil.toJsonStr(shop));
// 7. 返回数据
return Result.ok(shop);
}
}

课后练习

学习商户数据缓存后,尝试做一下商户类型数据缓存
在首页可以观察到发送的请求为http://localhost:8080/api/shop-type/list


故仿照商户缓存的思路,编写商户类型缓存,注意此处的商户类型=数据不仅仅是一个商户,而是多个商户的类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
@Resource
private IShopTypeService typeService;

@GetMapping("list")
public Result queryTypeList() {
// List<ShopType> typeList = typeService
// .query().orderByAsc("sort").list();
List<ShopType> typeList = typeService.queryTypeList();
return Result.ok(typeList);
}
}
1
2
3
public interface IShopTypeService extends IService<ShopType> {
List<ShopType> queryTypeList();
}
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
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

@Resource
private StringRedisTemplate stringRedisTemplate;


@Override
public List<ShopType> queryTypeList() {
String key = "login:type";
// SetOperations<String, String> setOps = redisTemplate.opsForSet();
// 1. 从redis查询商铺缓存
// String shopTypeJson = setOps.members(key).toString();
String shopTypeJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isNotBlank(shopTypeJson)) {
// 3. 存在,转化为List返回
// return objectMapper.readValue(shopTypeJson, new TypeReference<List<ShopType>>(){});
return JSONUtil.toList(shopTypeJson, ShopType.class);
}
// 4. 不存在,查询数据库
List<ShopType> typeList = this.query().orderByAsc("sort").list();
// 5. 不存在,返回空列表
if (typeList.isEmpty()) {
return new ArrayList<>();
}
// 6. 存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(typeList));
// 7. 返回
return typeList;
}
}

缓存更新策略

  • 缓存更新是Redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们想Redis插入太多数据,此时就可能会导致缓存中数据过多,所以Redis会对部分数据进行更新,或者把它成为淘汰更合适
  • 内存淘汰:Redis自动进行,当Redis内存大道我们设定的max-memery时,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
  • 超时剔除:当我们给Redis设置了过期时间TTL之后,Redis会将超时的数据进行删除,方便我们继续使用缓存
  • 主动更新:我们可以手动调用方法把缓存删除掉,通常用于解决缓存和数据库不一致问题
内存淘汰超时剔除主动更新
说明不用自己维护, 利用Redis的内存淘汰机制, 当内存不足时自动淘汰部分数据。 下次查询时更新缓存。给缓存数据添加TTL时间, 到期后自动删除缓存。 下次查询时更新缓存。编写业务逻辑, 在修改数据库的同时, 更新缓存。
一致性一般
维护成本
  • 业务场景
    • 低一致性需求:使用内存淘汰机制,例如店铺类型的查询缓存(因为这个很长一段时间都不需要更新)
    • 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情查询的缓存

数据库和缓存不一致解决方案

  • 由于我们的缓存数据源来自数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是
    • 用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等
  • 那么如何解决这个问题呢?有如下三种方式
    1. Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库之后再去更新缓存,也称之为双写方案
    2. Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。但是维护这样一个服务很复杂,市面上也不容易找到这样的一个现成的服务,开发成本高
    3. Write Behind Caching Pattern:调用者只操作缓存,其他线程去异步处理数据库,最终实现一致性。但是维护这样的一个异步的任务很复杂,需要实时监控缓存中的数据更新,其他线程去异步更新数据库也可能不太及时,而且缓存服务器如果宕机,那么缓存的数据也就丢失了

数据库和缓存不一致采用什么方案

  • 综上所述,在企业的实际应用中,还是方案一最可靠,但是方案一的调用者该如何处理呢?
  • 如果采用方案一,假设我们每次操作完数据库之后,都去更新一下缓存,但是如果中间并没有人查询数据,那么这个更新动作只有最后一次是有效的,中间的更新动作意义不大,所以我们可以把缓存直接删除,等到有人再次查询时,再将缓存中的数据加载出来
  • 对比删除缓存与更新缓存
    • 更新缓存:每次更新数据库都需要更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,再次查询时更新缓存
  • 如何保证缓存与数据库的操作同时成功/同时失败
    • 单体系统:将缓存与数据库操作放在同一个事务
    • 分布式系统:利用TCC等分布式事务方案
  • 先操作缓存还是先操作数据库?我们来仔细分析一下这两种方式的线程安全问题
    • 先删除缓存,再操作数据库
      删除缓存的操作很快,但是更新数据库的操作相对较慢,如果此时有一个线程2刚好进来查询缓存,由于我们刚刚才删除缓存,所以线程2需要查询数据库,并写入缓存,但是我们更新数据库的操作还未完成,所以线程2查询到的数据是脏数据,出现线程安全问题
    • 先操作数据库,再删除缓存
      线程1在查询缓存的时候 ,缓存TTL刚好失效,需要查询数据库并写入缓存,这个操作耗时相对较短(相比较于上图来说),但是就在这么短的时间内,线程2进来了,更新数据库,删除缓存,但是线程1虽然查询完了数据(更新前的旧数据),但是还没来得及写入缓存,所以线程2的更新数据库与删除缓存,并没有影响到线程1的查询旧数据,写入缓存,造成线程安全问题
  • 虽然这二者都存在线程安全问题,但是相对来说,后者出现线程安全问题的概率相对较低,所以我们最终采用后者先操作数据库,再删除缓存的方案

缓存更新策略的最佳实践方案:

  1. 低一致性需求:使用Redis自带的内存淘汰机制
  2. 高一致性需求:主动更新,并以超时剔除作为兜底方案
    • 读操作:
      • 缓存命中则直接返回
      • 缓存未命中则查询数据库并写入缓存,设定超时时间
    • 写操作:
      • 先写数据库,然后删除缓存
      • 确保数据库与缓存操作的原子性

实现缓存与数据库双写一致

  • 修改ShopController中的业务逻辑,满足以下要求

    1. 根据id查询店铺时,如果缓存未命中,则查询数据库,并将数据库结果写入缓存,并设置TTL
    2. 根据id修改店铺时,先修改数据库,再删除缓存
  • 修改ShopService的queryById方法,写入缓存时设置一下TTL

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) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//否则去数据库中查
Shop shop = getById(id);
//查不到返回一个错误信息或者返回空都可以,根据自己的需求来
if (shop == null){
return Result.fail("店铺不存在!!");
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
//并存入redis,设置TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
//最终把查询到的商户信息返回给前端
return Result.ok(shop);
}
  • 修改ShopController中的update方法
  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /**
    * 更新商铺信息
    * @param shop 商铺数据
    * @return
    */
    @PutMapping
    public Result updateShop(@RequestBody Shop shop) {
    // 写入数据库
    shopService.updateById(shop);
    return Result.ok();
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /**
    * 更新商铺信息
    * @param shop 商铺数据
    * @return
    */
    @PutMapping
    public Result updateShop(@RequestBody Shop shop) {
    // 写入数据库
    return shopService.update(shop);
    }
    1
    2
    3
    4
    public interface IShopService extends IService<Shop> {
    Result queryById(Long id);
    Result update(Shop shop);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Override
    //如果id为空发生异常,应该为异常回滚事务
    @Transactional
    public Result update(Shop shop) {
    Long id = shop.getId();
    if(id == null){
    return Result.fail("店铺ID不能为空");
    }
    // 1.更新数据库
    updateById(shop);
    // 2.删除缓存
    stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId().toString());
    return null;
    }

缓存穿透

  • 缓存穿透:缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远都不会生效(只有数据库查到了,才会让redis缓存,但现在的问题是查不到),会频繁的去访问数据库。

解决方案

  • 常见的结局方案有两种
    1. 缓存空对象
      • 优点:实现简单,维护方便
      • 缺点:额外的内存消耗,可能造成短期的不一致
    2. 布隆过滤
      • 优点:内存占用小,没有多余的key
      • 缺点:实现复杂,可能存在误判
  • 缓存空对象思路分析:当我们客户端访问不存在的数据时,会先请求redis,但是此时redis中也没有数据,就会直接访问数据库,但是数据库里也没有数据,那么这个数据就穿透了缓存,直击数据库。但是数据库能承载的并发不如redis这么高,所以如果大量的请求同时都来访问这个不存在的数据,那么这些请求就会访问到数据库,简单的解决方案就是哪怕这个数据在数据库里不存在,我们也把这个这个数据存在redis中去(这就是为啥说会有额外的内存消耗),这样下次用户过来访问这个不存在的数据时,redis缓存中也能找到这个数据,不用去查数据库。可能造成的短期不一致是指在空对象的存活期间,我们更新了数据库,把这个空对象变成了正常的可以访问的数据,但由于空对象的TTL还没过,所以当用户来查询的时候,查询到的还是空对象,等TTL过了之后,才能访问到正确的数据,不过这种情况很少见罢了
  • 布隆过滤思路分析:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,根据哈希思想去判断当前这个要查询的数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库里一定会存在这个数据,从数据库中查询到数据之后,再将其放到redis中。如果布隆过滤器判断这个数据不存在,则直接返回。这种思想的优点在于节约内存空间,但存在误判,误判的原因在于:布隆过滤器使用的是哈希思想,只要是哈希思想,都可能存在哈希冲突

代码实现

原来的逻辑中,我们如果发现这个数据在MySQL中不存在,就直接返回一个错误信息了,但是这样存在缓存穿透问题

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) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//否则去数据库中查
Shop shop = getById(id);
//查不到返回一个错误信息或者返回空都可以,根据自己的需求来
if (shop == null){
return Result.fail("店铺不存在!!");
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
//并存入redis,设置TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
//最终把查询到的商户信息返回给前端
return Result.ok(shop);
}

现在的逻辑是:如果这个数据不存在,将这个数据写入到Redis中,并且将value设置为空字符串,然后设置一个较短的TTL,返回错误信息。当再次发起查询时,先去Redis中判断value是否为空字符串,如果是空字符串,则说明是刚刚我们存的不存在的数据,直接返回错误信息

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
@Override
public Result queryById(Long id) {
// 1. 从Redis中查询商户缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id.toString());
// 2. 判断是否存在,isNotBlank在空字符串,转义字符和null时都为false
if(StrUtil.isNotBlank(shopJson)){
// 3. 如果缓存存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shopJson);
}

//判断命中的是否是空值
if(shopJson!=null){
return Result.fail("店铺信息不存在");
}


// 4. 如果缓存不存在,从MySQL中查询
Shop shop = baseMapper.selectById(id);
// 5. 如果MySQL中也不存在,返回错误
if(shop == null){
//将空值写入到Redis中
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id.toString(), "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
// 6. 如果MySQL中存在,将查询到的数据存入Redis缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id.toString(),JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. 返回数据
return Result.ok(shop);
}

小结:

  • 缓存穿透产生的原因是什么?
    • 用户请求的数据在缓存中和在数据库中都不存在,不断发起这样的请求,会给数据库带来巨大压力
      • 缓存穿透的解决方案有哪些?
      1. 缓存null值
      2. 布隆过滤
      3. 增强id复杂度,避免被猜测id规律(可以采用雪花算法)
      4. 做好数据的基础格式校验
      5. 加强用户权限校验
      6. 做好热点参数的限流

缓存雪崩

  • 缓存雪崩是指在同一时间段,大量缓存的key同时失效,或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力
  • 解决方案
    • 给不同的Key的TTL添加随机值,让其在不同时间段分批失效
    • 利用Redis集群提高服务的可用性(使用一个或者多个哨兵(Sentinel)实例组成的系统,对redis节点进行监控,在主节点出现故障的情况下,能将从节点中的一个升级为主节点,进行故障转义,保证系统的可用性。)
    • 给缓存业务添加降级限流策略
    • 给业务添加多级缓存(浏览器访问静态资源时,优先读取浏览器本地缓存;访问非静态资源(ajax查询数据)时,访问服务端;请求到达Nginx后,优先读取Nginx本地缓存;如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat);如果Redis查询未命中,则查询Tomcat;请求进入Tomcat后,优先查询JVM进程缓存;如果JVM进程缓存未命中,则查询数据库)

缓存击穿

  • 缓存击穿是指在同一时间,某个key非常热点,同时大量请求同时到达,而这个key正好失效,导致大量请求到达数据库,带来巨大压力
  • 举个不太恰当的例子:一件秒杀中的商品的key突然失效了,大家都在疯狂抢购,那么这个瞬间就会有无数的请求访问去直接抵达数据库,从而造成缓存击穿
  • 解决方案
    • 给热点数据添加互斥锁(互斥锁可以用Redis的setnx命令实现),当多个线程同时查询同一个数据时,只有一个线程能查询到数据,其他线程都阻塞等待,等第一个线程查询到数据后,将数据写入到Redis中,其他线程再从Redis中查询数据
    • 给热点数据添加逻辑过期时间(逻辑过期时间是指在数据过期时,不立即删除数据,而是给数据添加一个过期时间,当过期时间到了之后,再去查询数据,如果数据存在,则返回数据,如果数据不存在,则返回空值)
  • 逻辑分析:假设线程1在查询缓存之后未命中,本来应该去查询数据库,重建缓存数据,完成这些之后,其他线程也就能从缓存中加载这些数据了。但是在线程1还未执行完毕时,又进来了线程2、3、4同时来访问当前方法,那么这些线程都不能从缓存中查询到数据,那么他们就会在同一时刻访问数据库,执行SQL语句查询,对数据库访问压力过大
    img
  • 解决方案一:互斥锁
  • 利用锁的互斥性,假设线程过来,只能一个人一个人的访问数据库,从而避免对数据库频繁访问产生过大压力,但这也会影响查询的性能,将查询的性能从并行变成了串行,我们可以采用tryLock方法+double check来解决这个问题
  • 线程1在操作的时候,拿着锁把房门锁上了,那么线程2、3、4就不能都进来操作数据库,只有1操作完了,把房门打开了,此时缓存数据也重建好了,线程2、3、4直接从redis中就可以查询到数据。
  • 解决方案二:逻辑过期方案
  • 方案分析:我们之所以会出现缓存击穿问题,主要原因是在于我们对key设置了TTL,如果我们不设置TTL,那么就不会有缓存击穿问题,但是不设置TTL,数据又会一直占用我们的内存,所以我们可以采用逻辑过期方案
  • 我们之前是TTL设置在redis的value中,注意:这个过期时间并不会直接作用于Redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断当前数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的进程他会开启一个新线程去进行之前的重建缓存数据的逻辑,直到新开的线程完成逻辑之后,才会释放锁,而线程1直接进行返回,假设现在线程3过来访问,由于线程2拿着锁,所以线程3无法获得锁,线程3也直接返回数据(但只能返回旧数据,牺牲了数据一致性,换取性能上的提高),只有等待线程2重建缓存数据之后,其他线程才能返回正确的数据
  • 这种方案巧妙在于,异步构建缓存数据,缺点是在重建完缓存数据之前,返回的都是脏数据,但是能提升性能

对比互斥锁与逻辑删除

  • 互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,只是加了一把锁而已,也没有其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁的情况,就可能死锁,所以只能串行执行,性能会受到影响
  • 逻辑过期方案:线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构缓存数据,但是在重构数据完成之前,其他线程只能返回脏数据,且实现起来比较麻烦
解决方案优点缺点
互斥锁没有额外的内存消耗 保证一致性 实现简单线程需要等待,性能受影响 可能有死锁风险
逻辑过期线程无需等待,性能较好不保证一致性 有额外内存消耗 实现复杂

利用互斥锁解决缓存击穿问题

  • 核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是,进行查询之后,如果没有从缓存中查询到数据,则进行互斥锁的获取,获取互斥锁之后,判断是否获取到了锁,如果没获取到,则休眠一段时间,过一会儿再去尝试,知道获取到锁为止,才能进行查询
  • 如果获取到了锁的线程,则进行查询,将查询到的数据写入Redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行数据库的逻辑,防止缓存击穿
  • 操作锁的代码
  • 核心思路就是利用redis的setnx方法来表示获取锁,因为Redis是单线程的,如果redis没有这个key,则插入成功,返回1,如果已经存在这个key,则插入失败,返回0。在StringRedisTemplate中返回true/false,我们可以根据返回值来判断是否有线程成功获取到了锁
1
2
3
4
5
6
//    尝试获取锁
private boolean trylock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);

return BooleanUtil.isTrue(flag);
}
1
2
3
4
//    尝试删除锁
private void unlock(String key){
stringRedisTemplate.delete(key);
}

解决缓存击穿和解决缓存穿透代码很相近,所以对之前的querybyId方法进行整合

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
    //缓存穿透逻辑
public Shop queryWithPassThrough(Long id){
String key = CACHE_SHOP_KEY + id.toString();
// 1. 从Redis中查询商户缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在,isNotBlank在空字符串,转义字符和null时都为false
if(StrUtil.isNotBlank(shopJson)){
// 3. 如果缓存存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}

//判断命中的是否是空值
if(shopJson!=null){
return null;
}

// 4. 如果缓存不存在,从MySQL中查询
Shop shop = baseMapper.selectById(id);
// 5. 如果MySQL中也不存在,返回错误
if(shop == null){
//将空值写入到Redis中
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id.toString(), "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. 如果MySQL中存在,将查询到的数据存入Redis缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id.toString(),JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. 返回数据
return shop;
}

在编写我们的互斥锁代码,与缓存穿透类似,稍加修改即可
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
    public Shop queryWithMutex(Long id){
String key = CACHE_SHOP_KEY + id.toString();
// 1. 从Redis中查询商户缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在,isNotBlank在空字符串,转义字符和null时都为false
if(StrUtil.isNotBlank(shopJson)){
// 3. 如果缓存存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}

//判断命中的是否是空值
if(shopJson!=null){
return null;
}

// 4. 实现缓存重建
// 4.1 获取互斥锁
String lockKey="lock:shop:"+id.toString();
Shop shop = null;
try {
boolean isLock = trylock(lockKey);
// 4.2 判断互斥锁是否获取成功
if(!isLock){
// 4.3 失败,则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//
// 4.4 成功,则查询数据库并写入缓存
shop = getById(id);
Thread.sleep(500);
// 5. 如果MySQL中也不存在,返回错误
if(shop == null){
//将空值写入到Redis中
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id.toString(), "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. 如果MySQL中存在,将查询到的数据存入Redis缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id.toString(),JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally{
// 7. 释放互斥锁
unlock(lockKey);
}
// 8. 返回数据
return shop;
}

最终修改queryById方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public Result queryById(Long id) {

// 缓存穿透
// Shop shop = queryWithPassThrough(id);

// 缓存击穿
Shop shop = queryWithMutex(id);
if(shop == null){
return Result.fail("商铺不存在");
}
return Result.ok(shop);
}

  • 使用Jmeter进行测试
    • 我们先来模拟一下缓存击穿的情景,缓存击穿是指在某时刻,一个热点数据的TTL到期了,此时用户不能从Redis中获取热点商品数据,然后就都得去数据库里查询,造成数据库压力过大。
    • 那么我们首先将Redis中的热点商品数据删除,模拟TTL到期,然后用Jmeter进行压力测试,开100个线程来访问这个没有缓存的热点数据
  • 配置Jmeter相关属性

每次启动Jmeter前别忘了清空Redis缓存哦。

缓存总结

认识缓存

  • 缓存是一种具备高效读写能力的数据暂存区域
  • 缓存的作用
    • 降低后端负载
    • 提高服务读写响应速度
  • 缓存的成本
    • 开发成本
    • 运维成本
    • 一致性问题

缓存更新策略

  • 缓存更新策略大致分为三种

    1. 内存淘汰 ———— Redis自带的内存淘汰机制
    2. 过期淘汰 ———— 利用expire命令给数据设置过期时间
    3. 主动更新 ———— 主动完成数据库与缓存的同步更新
  • 可根据业务的一致性需求来选择不同的策略

    • 低一致性需求 ———— 内存淘汰或过期淘汰
    • 高一致性需求 ———— 主动更新为主,过期淘汰兜底
  • 主动更新的方案

    • Cache Aside ———— 缓存调用者更新数据库的同时完成对缓存的更新
      • 一致性良好
      • 实现难度一般
    • Read/Write Through ———— 缓存与数据库集成为一个服务,服务保证两者的一致性,对外暴露API接口。调用者调用API,无需知道自己操作的是数据库还是缓存,不关心一致性
      • 一致性优秀
      • 实现复杂
      • 性能一般
    • Write Back ———— 缓存调用者的CRUD都针对缓存完成。独立线程异步的将缓存数据写到数据库,实现最终数据的一致
      • 一致性差
      • 性能好
      • 实现复杂
  • Cache Aside的模式选择

    • 更新缓存还是删除缓存?
      • 更新缓存会产生许多无效更新,并且在较大的线程会有安全问题
      • 删除缓存本质是延迟更新,没有无效更新,安全问题相对较低
    • 先操作数据库还是缓存?
      • 先更新数据,再删除缓存 ———— 在满足原子性的情况下,安全问题的概率较低
      • 先删除换粗,再更新数据 ———— 安全问题概率较高
    • 如何确保数据库与缓存操作原子性
      • 单体系统 ———— 事务机制
      • 分布式系统 ———— 分布式事务机制
  • 最佳实践

    • 查询数据时

      1. 先查询缓存
      2. 如果缓存命中,直接返回
      3. 缓存未命中,查询数据库
      4. 将数据库数据写入缓存
      5. 返回结果
    • 更新数据时

      1. 先更新数据库
      2. 删除缓存
      • 必须确保两者的原子性

缓存穿透

  • 产生原因
    • 用户请求的数据在缓存和数据库都不存在,这样也不会写入缓存,这些请求会全部打到数据库
  • 解决方案
    • 缓存空对象
      • 思路:不存在的数据也在Redis建立缓存,把值设为空,并设置较短的过期时间
      • 优点:实现简单,维护方便
      • 缺点:会额外消耗内存,且可能有短期数据不一致问题
    • 布隆过滤器
      • 思路:利用布隆过滤算法,在请求进入Redis之前先判断是否存在,如果不存在则直接拒绝请求
      • 优点:内存占用少
      • 缺点:实现复杂,且存在误判的可能
    • 其他
      • 做好数据格式校验
      • 加强用户权限校验
      • 做好热点参数限流

缓存雪崩

  • 产生原因
    • 同一时段大量key失效Redis服务宕机,导致大量请求直接到达数据库
  • 解决方案
    • 不同的key设置不同的失效时间
    • 利用Redis集群提高服务的可用性
    • 添加多级缓存
    • 给缓存业务添加限流策略

缓存击穿

  • 产生原因
    • 热点Key在一段时间内被高并发访问,而缓存重建耗时较长
    • 热点key突然过期,因为重建耗时长,大量数据直接到达数据库
  • 解决方案
    • 互斥锁
      • 思路:给缓存重建过程加锁,一个线程在缓存重建时,其他线程等待
      • 优点:实现简单且一致性好
      • 缺点:性能下降且有死锁风险
    • 逻辑过期
      • 思路:
        • 热点key缓存永不过期,设置一个逻辑过期时间,查询数据时判断逻辑过期时间,如果过期则重建缓存
        • 重建缓存也通过互斥锁来避免并发重建
        • 重建缓存利用独立线程异步执行
        • 其他线程无需等待,查询则返回旧的数据
      • 优点:性能好,不会阻塞线程
      • 缺点:一致性差,且有额外内存消耗