本篇博客总结了我在学习redis中做的实战项目,实现了短信登录和添加缓存,并对缓存使用时出现的各种问题做了总结,其中业务部分代码和注释都是我手敲的,前端代码和图片来自黑马程序员
虽然这个项目已经烂大街,但是对于学习项目开发和redis来说仍是一份不错的教材,感谢黑马程序员
课程链接:https://www.bilibili.com/video/BV1cr4y1671t?p=1&vd_source=40ac0553f204ea9791dc385431e71f1c

项目结构:
项目结构

短信登录功能

基于session实现发送手机验证码

  • 实现流程:
    实现流程

  • 代码实现:

    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
    /**
    * Controller包下
    * 发送手机验证码
    */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
    // 发送短信验证码并保存验证码
    return userService.sendCode(phone, session);
    }

    /**
    * Service包下的发送手机验证码接口
    *
    * @param phone 验证码
    * @param session 将结果保存到session
    * @return 返回成功或失败
    */
    Result sendCode(String phone, HttpSession session);

    /**
    * 业务代码 service.impl包下
    * @param phone 验证码
    * @param session 将结果保存到session
    * @return 返回成功或失败
    */
    @Override
    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);
    //6.返回ok
    return Result.ok();
    }

实现短信登录和注册

  • 实现流程:
    实现流程
  • 代码实现:
    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
    /**
    * Controller包下
    * 登录功能
    * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
    */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
    // 实现登录功能
    return userService.login(loginForm, session);
    }

    /**
    * Service包下的登录接口
    * @param loginForm 登陆参数
    * @param session 保存结果
    * @return 返回成功或失败
    */
    Result login(LoginFormDTO loginForm, HttpSession session);

    /**
    * 业务代码 service.impl包下
    * @param loginForm 登陆参数
    * @param session 保存结果
    * @return
    */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
    //1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
    return Result.fail("手机号格式错误!");
    }
    //2.校验验证码
    Object cacheCode = session.getAttribute("code");
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.toString().equals(code)) {
    //3.不一致,直接报错
    return Result.fail("验证码错误!");
    }
    //4.一致,根据手机号查询用户(Mybatis-plus)
    User user = query().eq("phone", phone).one();
    //5.判断用户是否存在
    if (user == null) {
    //6.不存在,创建新用户并保存
    user = createUserWithPhone(phone);
    }
    //7.保存用户信息到session
    session.setAttribute("user", user);
    return Result.ok();
    }

    /**
    * 更具手机号创建用户
    * @param phone 手机号
    * @return 用户信息
    */
    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;
    }

实现登录校验拦截器

  • 实现流程
    实现流程

  • 代码实现:

    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
    /**
    * 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.不存在,拦截
    response.setStatus(401);
    return false;
    }
    //5.存在,保存到ThreadLocal
    UserHolder.saveUser((User) user);
    //6.放行
    return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    //移除用户,避免内存泄漏
    UserHolder.removeUser();
    }
    }

    /**
    * Config包下
    * 拦截器生效
    */
    @Configuration
    public class MyConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
    "/user/code",
    "/user/login",
    "/blog/log",
    "shop/**",
    "shop-type/**",
    "/upload/**",
    "voucher/**"
    );
    }
    }

    /**
    * Controller包下
    * 获取当前登录状态并返回
    * @return 成功或失败
    */
    @GetMapping("/me")
    public Result me() {
    // 获取当前登录的用户并返回
    UserDTO user = UserHolder.getUser();
    return Result.ok(user);
    }

隐藏用户敏感信息

由于之前的业务逻辑是将获取到的User对象一股脑丢进session中,一方面存入多余的信息会增加服务器负担,另一方面也会有泄露风险,如图所示:
前端接收参数
所以从一开始就不应该存入用户的完整信息,定义UserDTO类

1
2
3
4
5
6
7
8
9
/**
* DTO包下
*/
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}

代码中凡是涉及User对象的都应该转为UserDTO

  • 优化后效果:
    效果
    这样传到前端的数据就不在携带敏感信息,因为数据量的减少从而降低了内存的压力。

关于ThreadLocal

-TheadLocal数据结构:
结构图

  • Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap
  • ThreadLocalMap有自己的独立实现,可以简单地将它的key视作ThreadLocalvalue为代码中放入的值(实际上key并不是ThreadLocal本身,而是它的一个弱引用)。
  • 每个线程在往ThreadLocal里放值的时候,都会往自己的ThreadLocalMap里存,读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离
  • ThreadLocalMap有点类似HashMap的结构,只是HashMap是由数组+链表/红黑树实现的,而ThreadLocalMap中并没有链表结构。

由于ThreadLocal中的key是弱引用,所以在gc时会被回收,而value是强引用则不会,如果不做任何措施,value就永远无法被gc,这时就有可能出现内存泄露问题,这就是我们为什么在拦截器的afterCompletion()方法中手动removeUser()的原因

代码中的UserHolder类如下:

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

使用redis代替session

session共享问题

当将来数据量变大,我们肯定需要配置多台Tomcat来进行负载均衡,而此时session的弊端就显现了出来,由于每个Tomcat都会有自己的sessionId,所以当另一台Tomcat想要获取数据时就拿不到,从而降低用户体验,如图所示:
项目架构
想要解决这个问题,就必须满足三个条件:

  • 数据共享:解决数据丢失问题
  • 内存存储:保证读写效率以应对高并发需求
  • key-value结构:存储的数据应该间接且方便维护
    综上,Redis闪亮登场!终于到了我学习这个项目的主题!

整体业务流程:
业务流程
业务流程

基于redis实现短信登录

代码实现

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
    //注入API
@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.randomNumbers(6);
//4.不再存入session,而是保存验证码到redis,设置有效时间2分钟
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
//5.发送验证码
log.debug("发送验证码成功:{}", code);
//6.返回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)) {
//不一致,直接报错
return Result.fail("验证码错误!");
}
//4.一致,根据手机号查询用户(Mybatis-plus)
User user = query().eq("phone", phone).one();
//5.判断用户是否存在
if (user == null) {
//6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
/**
* 7.保存用户到redis
* 使用hash存储,支持单个字段查询,减少内存消耗
* 不再使用手机号,而是使用token做为key,避免传入前端的数据携带敏感信息
*/
//7.1生成token,作为登陆令牌
String token = UUID.randomUUID().toString();
//7.2将User对象转为hash存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//将所有字段值转为字符串
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().
setIgnoreNullValue(true).
setFieldValueEditor((filedName, fieldValue) -> fieldValue.toString()));
//7.3存储,给token设置有效期,避免内存占用过多,参考session为30分钟
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
//7.4设置token有效期,但session会自动刷新有效期,而redis只要过了有效期就自动踢出用户
//在登录拦截器设置更新token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
//8.返回token
return Result.ok(token);
}

/**
* utils包下
* 更改拦截器,设置token有效期
* 注意:不能使用注解注入,因为此类对象是我们手动创建的,而不是spring创建的
* 所以使用构造函数注入,但config包下的MvcConfig是由spring构建的,注入后放入构造函数即可
*/
public class LoginInterceptor implements HandlerInterceptor {

private StringRedisTemplate stringRedisTemplate;

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);
return false;
}
//2.改为基于token从redis中获取用户
String key = RedisConstants.LOGIN_USER_KEY;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
//3.判断用户是否存在
if (userMap.isEmpty()) {
//4.不存在,拦截
response.setStatus(401);
return false;
}
//5.将查询到的哈希数据转为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 afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户,避免内存泄漏
UserHolder.removeUser();
}
}

登录拦截器优化

我们在拦截器中写的更新token操作并不能完全达到目的,因为目前的登录拦截器只拦截需要登录的路径,反之,当用户浏览不用登录的页面如首页、商铺详情页时就不会触发拦截器,从而导致用户明明一直活跃却还是被踢出,为了解决这个问题,我们使用拦截器链:
业务流程

  • 第一个拦截器拦截所有页面,只放行,确保用户所有操作都会触发拦截器
  • 第二个拦截器做实际的拦截操作

代码如下:
在utils包下创建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
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
/**
* 拦截所有请求,只放行
*/
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获取redis用户信息
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
//3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
//5.将查询到的hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6.用户存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
//7.刷新token有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
//8.放行
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
}

/**
* 判断要不要拦截
*/
public class LoginInterceptor implements HandlerInterceptor {

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

/**
* Config包下
* 配置拦截器并设置优先级
*/
@Configuration
public class MyConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
"/user/code",
"/user/login",
"/blog/log",
"shop/**",
"shop-type/**",
"/upload/**",
"voucher/**"
).order(1);
//token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).
addPathPatterns("/**").order(0);
}
}

添加缓存

缓存就是数据交换的缓冲区,时存储数据的临时地方,用于提升读写效率。
优点:

  • 降低后端负载:请求进来直接查缓存,不用再查数据库
  • 提高读写效率,降低响应时间:使用redis缓存直接在内存层面读写,省去磁盘IO的时间

缺点:

  • 数据的一致性:缓存和数据库中的数据可能不一致
  • 代码维护:为了解决一致性问题,需要增加业务代码
  • 运维:为了解决如缓存雪崩等问题需要搭建集群从而提高运维成本

缓存工作模型如下:
业务流程

  1. 客户端发送请求到缓存,如果命中则直接返回
  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
    /**
    * Controller包下
    * 根据id查询商铺信息
    * @param id 商铺id
    * @return 商铺详情数据
    */
    @GetMapping("/{id}")
    public Result queryShopById(@PathVariable("id") Long id) {
    return shopService.queryById(id);
    }

    //service.impl包下
    Result queryById(Long id);

    /**
    * 通过id查询商铺信息
    * @param id 商铺id
    * @return 商铺信息
    */
    @Override
    public Result queryById(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(id);
    //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);
    }

缓存更新策略

由于缓存和数据库中的数据存在一致性问题,用户可能会查到旧数据,所以应该选择合适的缓存更新策略,这里选择主动更新策略,如图所示:
缓存更新策略
业务场景:

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

主动更新策略有以下三种方式:
主动更新策略
这里选择第一种,因为第一种虽然需要我们自己编码,但可控性更高,而第二种虽然简化了一些,但对维护成本有较高要求,而第三种在异步存储之前还是有可能生一致性问题。

注意事项

删除缓存还是更新缓存?

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

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

  • 单体系统:由于缓存和数据库在一个项目当中,所以将缓存与数据库放在一个事务中
  • 分布式系统:利用TCC等分布式事务方案

先操作缓存还是先操作数据库?
仅对异常情况来说,如果先删缓存再擦做数据库,如图所示:
先缓存再数据库
在数据规模较大的前提下这种情况是很有可能发生的,因为redis是基于内存的,它远比需要进行磁盘IO的数据库快。
而先操作数据库,再删缓存如下:
先数据库再缓存
由图可见,在内存查询中途插上一次磁盘IO,相对于前者来说发生的概率是较低的,所以我们选择先操作数据库再删缓存。

业务中添加策略

给查询商铺的缓存添加超时剔除和主动更新策略:
修改ShopController中的业务逻辑,满足以下条件:

  • 根据id查询店铺时,如果缓存未命中,则查询数据库,写入缓存,设置超时时间
  • 根据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
//将queryById方法中的第六步改为以下代码
//6.存在,把数据写入redis,添加过期时间
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

/**
* Controller包下
* 更新商铺信息
* @param shop 商铺数据
* @return
*/
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
shopService.update(shop);
return Result.ok();
}

//service.impl包下
Result update(Shop shop);

/**
* 先操作数据库再删缓存
* @param shop 店铺信息
* @return
*/
@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();
}

缓存穿透

概念

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

解决办法

缓存空对象

事先把空值存入数据库,当发送一个缓存和数据库都不存在的请求时,直接把数据库中的空值返回,可以解决请求变化不频繁的情况,如图所示:
缓存无效key

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

  • 缺点:

  1. 额外的内存消耗:当黑客恶意攻击时,每次构建不同的请求key,会导致redis中缓存大量的无效key
    解决方案:可以对无效key设置一个较短的TTL起到一定的保护作用
  2. 可能造成短期的不一致:如果用户请求了一个刚好不存在的id,那么redis中会缓存null值,此时我们给商铺插入一条数据,那么用户再查也依旧是null,只有当TTL过期才会更新
    解决方案:尽量将TTL的时间设置的短一些,或者在数据库插入数据时主动覆盖redis中的null值

布隆过滤

在客户端和redis之间再加一层过滤器,其他逻辑不变,如图所示:
布隆过滤
布隆过滤器可能会误判,如果它说某个元素不存在,那么它一定不存在,但它说存在,那么这个元素不一定存在。
要解释这种情况,需要理解布隆过滤器的工作原理:

  1. 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
  2. 根据得到的哈希值,在位数组中把对应下标的值置为 1。

判断元素是否存在的操作:

  1. 对给定元素再次进行相同的哈希计算;
  2. 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。

当不同的字符串哈希出来的位置相同,就会出现误判,发生的概率取决于数组大小和哈希函数的优劣

  • 优点:
    内存占用小,没有多余key:不用缓存多余的空数据
  • 缺点:
  1. 实现复杂:可以使用redis自带的BitMap简化开发
  2. 存在误判可能

项目实现

使用缓存空对象的方式解决查询商铺页面的缓存穿透问题。

  • 业务流程:
    业务流程
    两个修改:
  1. 从redis中查询缓存时,判断是否为空
  2. 商铺不存在时,将空值写入redis
  • 代码实现:
    修改queryById()中的第2步和第5步:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    //2.判断是否存在
    //只有当shopJson有真实数据才返回true
    if (StrUtil.isNotBlank(shopJson)) {
    //3.存在,直接返回
    Shop shop = JSONUtil.toBean(shopJson, Shop.class);
    return Result.ok(shop);
    }

    //5.不存在,返回错误,并且将空值写入redis
    if (shop == null) {
    stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
    return Result.fail("店铺不存在!");
    }

总结

缓存穿透产生的原因是什么?

  • 用户请求的数据在缓存和数据库中都不存在,不断发起这样的请求会给数据库带来巨大压力

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

  • 缓存null值
  • 布隆过滤

主动方案:

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

缓存雪崩

概念

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

解决方法

  1. 给不同的key的TTL添加随机值:
    使批量缓存的数据分布在一个时间段内失效,而不是同时失效,缓解数据库压力。
  2. 利用redis集群提高服务的可用性:
    哨兵机制可以实现服务的监控,如果主宕机,哨兵可以自动从从机选出来一个替代原来的主,而且主从还可以实现一种数据同步,如果主宕机,从上面还会有数据,避免数据丢失。
  3. 给缓存业务添加降级和限流策略:
    当redis出现故障时,及时做服务降级,比如快速失败、拒绝服务,而不是继续压入数据库,牺牲部分服务,从而保护数据库的健康。
  4. 给业务添加多级缓存:
    在多个层面建立缓存,比如在Nginx和JVM中都建立缓存,如果redis崩了,还有其他缓存可以弥补

缓存击穿

概念

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

解决方法

互斥锁

当很多线程同时查询缓存且未命中,只有一个线程能获取锁,在把数据重新写入redis之前,其他线程全部等待,如图所示:
业务流程
因为有大量的线程在等待释放锁,因此这种方案的效率比较低。

逻辑过期

之前产生问题的原因是我们设置了TTL,导致缓存突然失效,那现在不设置TTL了,我们需要在存储数据时设置expire(过期时间,并不是TTL,而是在当前时间基础上再加上过期时间),理论上它是永不过期的,一般在双11等活动前预热完成。
它的业务流程如图所示:
业务流程
为了避免线程等待时间过长,缓存重构交给独立线程去做,线程1和线程三只要发现逻辑过期,返回旧数据即可,直到线程4进来,自然可以获取更新后的数据。

方案对比

业务流程
根据业务需求,在保证可用性和一致性之间做出抉择。

项目实现

基于互斥锁解决缓存击穿

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

  • 业务流程
    业务流程
    获取锁:setnx key value
    释放锁:del key

  • 互斥锁代码实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    /**
    * 利用互斥锁解决缓存击穿
    * @param id 店铺id
    * @return 店铺信息
    */
    public Shop queryWithMutex(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(id);
    //2.判断是否存在
    //只有当shopJson有真实数据才返回true
    if (StrUtil.isNotBlank(shopJson)) {
    //3.存在,直接返回
    return JSONUtil.toBean(shopJson, Shop.class);
    }
    //判断命中的是否为空值
    if (shopJson == null) {
    //返回错误信息
    return null;
    }
    //4.实现缓存重建
    //4.1获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    try {
    boolean isLock = tryLock(lockKey);
    //4.2是否获取成功
    if (!isLock) {
    //4.3失败,休眠并重试
    Thread.sleep(50);
    return queryWithMutex(id);
    }
    //4.4成功,根据id查询数据库
    Shop shop = getById(id);
    //模拟重建的演示
    Thread.sleep(200);
    //5.不存在,返回错误,并且将空值写入redis
    if (shop == null) {
    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);
    //8.返回
    return shop;
    } catch (InterruptedException e) {
    throw new RuntimeException(e);
    } finally {
    //7.释放互斥锁
    unlock(lockKey);
    }
    }
  • 将缓存穿透,获取锁,释放锁封装:

    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
    /**
    * 利用互斥锁解决缓存击穿
    * @param id 店铺id
    * @return 店铺信息
    */
    public Shop queryWithMutex(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(id);
    //2.判断是否存在
    //只有当shopJson有真实数据才返回true
    if (StrUtil.isNotBlank(shopJson)) {
    //3.存在,直接返回
    return JSONUtil.toBean(shopJson, Shop.class);
    }
    //判断命中的是否为空值
    if (shopJson == null) {
    //返回错误信息
    return null;
    }
    //4.实现缓存重建
    //4.1获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    try {
    boolean isLock = tryLock(lockKey);
    //4.2是否获取成功
    if (!isLock) {
    //4.3失败,休眠并重试
    Thread.sleep(50);
    return queryWithMutex(id);
    }
    //4.4成功,根据id查询数据库
    Shop shop = getById(id);
    //模拟重建的演示
    Thread.sleep(200);
    //5.不存在,返回错误,并且将空值写入redis
    if (shop == null) {
    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);
    //8.返回
    return shop;
    } catch (InterruptedException e) {
    throw new RuntimeException(e);
    } finally {
    //7.释放互斥锁
    unlock(lockKey);
    }
    }

    /**
    * 尝试获取锁
    * @param key 锁
    * @return 是否获取成功
    */
    private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    //不要直接返回,可能出现空指针
    return BooleanUtil.isTrue(flag);
    }

    /**
    * 释放锁
    * @param key 锁
    */
    private void unlock(String key) {
    stringRedisTemplate.delete(key);
    }
  • 调用主方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    /**
    * 通过id查询商铺信息
    * @param id 商铺id
    * @return 商铺信息
    */
    @Override
    public Result queryById(Long id) {
    //缓存穿透
    //Shop shop = queryWithPassThrough(id);

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

基于逻辑过期解决缓存击穿

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

  • 业务流程
    业务流程
  • 代码实现:
  1. 在utils包下创建:

    1
    2
    3
    4
    5
    @Data
    public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
    }
  2. 在实现类封装方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /**
    * 封装店铺信息逻辑过期时间
    * 没有添加TTL,key可以视为永久有效
    * @param id 店铺id
    * @param expireSeconds 逻辑过期时间
    */
    public void saveShop2Redis(Long id, long expireSeconds){
    //1.查询店铺数据
    Shop shop = getById(id);
    //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));
    }
  3. 逻辑过期主要业务代码:

    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
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
    * 利用逻辑过期解决缓存击穿
    * @param id
    * @return
    */
    public Shop queryWithLogicalExpire(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(id);
    //2.判断是否存在
    if (StrUtil.isBlank(shopJson)) {
    //3.未命中,直接返回
    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) {
    //6.3成功,开启独立线程实现缓存重建
    CACHE_REBUILD_EXECUTOR.submit(() -> {
    try {
    //重建
    this.saveShop2Redis(id, CACHE_SHOP_TTL);
    } catch (Exception e) {
    throw new RuntimeException(e);
    } finally {
    //释放锁
    unlock(lockKey);
    }
    });
    }
    //7.返回过期的商铺信息
    return shop;
    }