Redis实战

  本Redis实战基于黑马程序员Redis入门到实战教程,深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目中的黑马点评项目,只写出一些关键的代码和一些编程中新颖想法以及常见问题的解决方案,方便读者重温学习思路,具体想要完成实战学习建议跟着视频学习!

短信登录

本章主要学习Redis的共享Session应用

传统的基于Session的登录流程

发送验证码:

用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号

如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户

短信验证码登录、注册:

用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息

校验登录状态:

用户在请求时候,会从cookie中携带者sessionId到后台,后台通过sessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到ThreadLocal中,并且放行

1

基于上述流程,写出对应的代码

发送验证码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号,校验是否不符合
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式错误!");
}
// 2.生成验证码
String code = RandomUtil.randomNumbers(6);
// 3.保存验证码到Session
session.setAttribute("code",code);
// 4.发送验证码,虚假发送
log.debug("验证码:"+code);
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
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.校验验证码
String code = loginForm.getCode();
Object cacheCode = session.getAttribute("code");
if(cacheCode == null || !cacheCode.toString().equals(code)){
// 4.不一致,报错
return Result.fail("验证码错误");
}
// 5.一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
// 6.判断用户是否存在
if(user == null){
// 7.用户不存在,创建新用户并保存到数据库
user = createUserWithPhone(phone);
}
// 8.用户存在,将用户所需数据存到Session
UserDTO userDTO = new UserDTO();
BeanUtils.copyProperties(user,userDTO);
session.setAttribute("user",userDTO);
return Result.ok();
}
private User createUserWithPhone(String phone) {
User user = User.builder()
.phone(phone)
.nickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10))
.build();
// 将新用户存入数据库
save(user);
return user;
}

登录拦截功能

2

手动创建一个LoginInterceptor拦截器,判断Session中是否有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
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取Session中的用户
HttpSession session = request.getSession();
// 2.判断用户是否存在
Object user = session.getAttribute("user");
if(user == null){
// 3.用户不存在,拦截并返回401状态码
response.setStatus(401);
return false;
}
// 4.用户存在,保存用户信息到ThreadLocal
UserHolder.saveUser((UserDTO) user);
// 5.放行
return true;
}

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

其中使用到了TheadLocal来存储已登录用户的信息,每个用户访问程序的时候,都会生成对应的线程,使用TheadLocal可以做到线程隔离,将信息存到自己的线程当中,每个线程操作自己的一份数据!

下面的UserHolder类就方便快捷地实现了TheadLocal中值的存取和销毁:

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

注册拦截器,使拦截器生效,创建MvcConfig类实现WebMvcConfigurer接口,需使用@Configuration注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
"/user/code",
"/user/login",
"/shop/**",
"/voucher/**",
"/shop-type",
"/upload/**",
"/blog/hot"
);
}
}

基于Redis实现共享Session登录

Session共享问题

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

分析:在集群分布模型当中,每个Tomcat中都有一份属于自己的Session,他们之间的Session是不共享的。在基于Session的登录流程中,当你第一次访问请求分到了一号Tomcat,而第二次访问请求分到了二号Tomcat,那么就可能出现了登录拦截错误的问题,因为第二台Tomcat没有第一台Tomcat中的Session。

3

基于Redis实现共享Session的值存储和访问流程

Key的使用分析:Key要有一致性,不可与其他业务的key重合,所以要加上业务的唯一前缀

发送验证码功能当中,我们可以使用手机号作为Key,验证码作为value来实现Redis的存储

登录功能当中,想要存储用户的登录信息,方便后面的业务需要,这时使用手机号并不合适,建议使用UUID生成随机token,值使用Hash存储对象,当然也可以转为Json存String类型的Value

我们不直接用手机号存储用户对象?

token不像session,Tomcat不会自动把token写在cookie中,需要我们手动返回token给前端,前端再通过代码保存到浏览器中,访问时在请求头authorization中携带token到后端。所以我们不应该使用手机号这样涉及隐私的数据传到前端保存到浏览器本地中,会有泄露的风险。

4

业务逻辑代码修改优化

存验证码使用String-String即可,存储对象建议使用String-Hash,可对对象的每个字段单独存储,可以针对单个字段做CRUD,并且占用内存更少(如果是转为Json格式使用String-String存储的话,格式会占用更多内存)。

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
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.从Redis中取出验证码和前端传来的验证码校验
String code = loginForm.getCode();
String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
if(cacheCode == null || !cacheCode.equals(code)){
// 4.不一致,报错
return Result.fail("验证码错误");
}
// 5.一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
// 6.判断用户是否存在
if(user == null){
// 7.用户不存在,创建新用户并保存到数据库
user = createUserWithPhone(phone);
}
// 8.用户存在,将用户所有数据存到Redis
// 8.1 随机生成token
String token = UUID.randomUUID().toString();
// 8.2 将用户数据转为HashMap进行存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().
setIgnoreNullValue(true).
setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
String key = RedisConstants.LOGIN_USER_KEY + token;
// 8.3存入Redis并设置过期时间
stringRedisTemplate.opsForHash().putAll(key,userMap);
stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 9.返回token
return Result.ok(token);
}

对下面代码进行深入分析:

1
2
3
4
5
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().
setIgnoreNullValue(true).
setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));

该函数将userDTO对象转换为一个Map<String, Object>对象。在转换过程中,使用了BeanUtil.beanToMap方法,并传入了三个参数:userDTO对象本身、一个空的HashMap对象和一个CopyOptions对象。

  • userDTO对象是要转换的对象。
  • 空的HashMap对象用于存储转换后的键值对。
  • CopyOptions对象用于设置转换过程中的选项。在这里,设置了两个选项:
    • setIgnoreNullValue(true)表示忽略userDTO对象中值为null的属性,不会将其转换为Map中的键值对。
    • setFieldValueEditor用于设置一个字段值编辑器,该编辑器将每个属性的值转换为字符串类型。在这里,使用了一个lambda表达式(fieldName, fieldValue) -> fieldValue.toString(),表示将每个属性的值调用toString()方法后作为键值对的值(因为使用的是StringRedisTemplate,value的值必须为String,否则报错)

简而言之就是将userDTO对象转为HashMap,但是转换后value中存在非String类型的值,所以构建了一个空的HashMap对象,用于修改后的拷贝。

拦截器代码的优化代码

从前端的请求头中获取token,根据有效token来查询Redis得到用户数据存入TheadLocal中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.请求头中的token
String token = request.getHeader("authorization");
if(StrUtil.isBlank(token)){
response.setStatus(401);
return false;
}
// 2.判断用户是否在Redis中
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
if(userMap.isEmpty()){
// 3.用户不存在,拦截并返回401状态码
response.setStatus(401);
return false;
}
// 3.将查到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 4.用户存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
// 5.刷新token有效期
stringRedisTemplate.expire(key,RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return true;
}

使用BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false) 来将一个Map转换回对应的对象

拦截器的进一步优化

原拦截器不是拦截一切路径,所以会导致访问不需拦截的路径时,不会刷新token的时间,会出现用户一直访问的是不拦截的网页时,虽然一直在浏览网页,但是还要重新登录的情况。

得新加一个全路径拦截器。全路径拦截器只判断Redis中是否存在用户,存在即写入TheadLocal并刷新时间,不存在则放行到下一个拦截器再一并拦截。

Refresh拦截器:

  • 获取token
  • 查询Redis中是否有该用户
  • 无则放行到下一个Login拦截器一并拦截
  • 有则存入TheadLocal,并刷新token有效期,放行到下一个Login拦截器

Login拦截器:

  • 查询TheadLocal中是否有用户
  • 无则拦截,有则放行

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
/**
* @Author JunWei Li
* @Date 2024-07-17 15:37
*/
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.判断用户是否在Redis中
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
if(userMap.isEmpty()){
return true;
}
// 3.将查到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 4.用户存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
// 5.刷新token有效期
stringRedisTemplate.expire(key,RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return true;
}

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

LoginInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.判断ThreadLocal中是否有用户
UserDTO userDTO = UserHolder.getUser();
if(userDTO == null){
// 无则拦截
response.setStatus(401);
return false;
}
// 有用户放行
return true;
}

MvcConfig中注册新的拦截器,通过order()来对拦截器进行优先级排序

1
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);

至此,短信登录登陆功能全部设计完成!!!

商品查询缓存

这一章主要是企业的缓存使用技巧,涉及缓存穿透、缓存雪崩、缓存击穿等问题的解决

什么是缓存

有高性能的地方就有缓存

缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码

5

数据一致性成本:读取数据优先从缓存读取数据,所以当数据库中的数据被修改后,缓存必须删除,否则缓存中的数据和数据库中的数据会产生不一致的效果。

为什么需要缓存

缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力

实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为”避震器”,系统是几乎撑不住的,所以企业会大量运用到缓存技术。

多级缓存

实际开发中,会构筑多级缓存来使系统运行速度进一步提升

浏览器缓存:主要是存在于浏览器端的缓存

应用层缓存:可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存

数据库缓存:在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中

CPU缓存:当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存

添加店铺信息缓存到Redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public Result queryById(Long id) {
// 1.从Redis中查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 2.有缓存,直接返回
if(StrUtil.isNotBlank(shopJson)){
// 转为Bean返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 3.没有缓存,转向查数据库
Shop shop = getById(id);
// 4.数据库中没有,返回错误
if(shop ==null){
return Result.fail("店铺不存在");
}
// 5.有则返回,并存入Redis
// 将对象转为Json
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}

使用hutool提供的JSONUtil的toJsonStr()和toBean()方法,使得Json和Bean对象互相转换

刷新前端页面,可以在Navicat中才看到对应的商店Cache数据

6

添加店铺类型缓存到Redis

对店铺类型进行缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public List<ShopType> queryTypeList() {
// 1.从Redis中查询缓存
String shopTypeJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_TYPE_KEY);
// 2.有缓存,直接返回
if(StrUtil.isNotBlank(shopTypeJson)){
// 转为List集合
return JSONUtil.toList(shopTypeJson, ShopType.class);
}
// 3.没有缓存,转向查数据库
List<ShopType> shopTypeList = query().orderByAsc("sort").list();
// 4.返回数据,并存入Redis
// 将对象转为Json
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_TYPE_KEY, JSONUtil.toJsonStr(shopTypeList));
return shopTypeList;
}

7

缓存更新策略

缓存更新:Redis数据存储在内存中,不可能无限制存储数据,那么就要缓存更新

  • 内存淘汰:redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)

  • 超时剔除:当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存

  • 主动更新:我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题
    8

我们主要学习使用主动更新,一致性非常好,但是维护成本比较高,需要我们人为编码进行更新

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

由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是:用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等

有如下几种方案解决:

9

我们主要是用第一种方式(Cache Aside Pattern 人工编码方式),也就是主动更新,后两种方式更为复杂,涉及知识更多

这里有两个问题:

  • 删除缓存还是更新缓存?
    • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
  • 如何保证缓存与数据库的操作的同时成功或失败?
    • 单体系统,将缓存与数据库操作放在一个事务
    • 分布式系统,利用TCC等分布式事务方案
  • 先操作缓存还是先操作数据库?
    • 先删除缓存,再操作数据库
    • 先操作数据库,再删除缓存
  • 对于第一个问题:我们采取删除缓存的方式。当用户在不断修改数据库的时候,如果采用更新缓存的方式,那么会产生很多次无效的更新缓存,只有最后一次有效。那么最好的方法就是删除缓存的方式,因为他只在下个人访问的时候再重新从数据库中读出数据写入缓存。

  • 对于第二个问题已给出解决方案

  • 对于第三个问题:我们应当是先操作数据库,再删除缓存。原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入缓存的就是旧的数据,新的数据被旧数据覆盖了,造成了缓存和数据库不一致现象。如黑马画的下图所示:

10

代码实现商铺和缓存的读写一致

  • 给Cache加TTL,假定为30min
1
2
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop));
stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_KEY + id,RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
  • 修改店铺数据时,先更新数据库,再删除缓存,注意使用@Transactional注解
1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if(id == null){
return Result.fail("店铺id不可为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
return Result.ok();
}

缓存穿透

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

当碰到大量恶意查询缓存和数据库都没有的数据时,所有请求打到数据库,容易搞垮数据库

常见的解决方案有两种:

  • 缓存空对象

    • 优点:实现简单,维护方便
    • 缺点:
      • 额外的内存消耗
      • 可能造成短期的不一致
  • 布隆过滤

    • 优点:内存占用较少,没有多余key

    • 缺点:

      • 实现复杂

      • 存在误判可能

缓存空对象思路分析:当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了

布隆过滤:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,

假设布隆过滤器判断这个数据不存在,则直接返回

这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突

11

编码解决缓存穿透问题

在原先逻辑中,没有数据则返回错误信息,而不采取措施防御,会存在缓存穿透问题

现在我们要做的是,在缓存和数据库都没有该数据的情况下,给Redis写入一个缓存空对象,值为null,使得下一次同样的请求会命中null,直接返回空对象,而不让其到达数据库

12

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 2.有缓存,直接返回
if(StrUtil.isNotBlank(shopJson)){
// 转为Bean返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// ******多加一步 判断Cache是否为""******
if("".equals(shopJson)){
return Result.fail("店铺数据不存在!");
}
// 3.没有缓存,转向查数据库
Shop shop = getById(id);
// 4.数据库中没有,返回错误
if(shop == null){
// ******多加一步 写入缓存空对象******
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,"",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}

由于StrUtil.isNotBlank在值为null时返回的是false,所以要单独加判断null值。同时写入缓存空对象,当查询一个不存在的店铺id的时候,会在Redis中存储空值,在设定的时间内不再经过关系型数据库读写,不给其产生压力

13

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

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

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

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

缓存雪崩

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

解决方案:

  • 给不同的Key的TTL添加随机值

  • 利用Redis集群提高服务的可用性

  • 给缓存业务添加降级限流策略

  • 给业务添加多级缓存

14

由于缓存雪崩涉及了微服务的内容和集群分布等内容,在这里了解就好,目前已有知识暂时无法实现,还得再去多补充微服务知识

推荐学习2024最新SpringCloud微服务开发与实战,java黑马商城项目微服务实战开发(涵盖MybatisPlus、Docker、MQ、ES、Redis高级等)_哔哩哔哩_bilibili

缓存击穿

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

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

逻辑分析:

假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了

但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库同时的去执行数据库代码对数据库访问压力过大

高并发 && 缓存重建业务较复杂(时间长)

15

两种解决方法及其对比

解决方法一、使用互斥锁解决

因为锁能实现互斥性,使得只有拿到锁的那个线程可以访问数据库,避免所有线程都查询数据库,但是也影响了查询性能,查询的性能从并行变成了串行。

假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。

16

解决方案二、逻辑过期方案

方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。

我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。

这种方案巧妙在于,异步的构建缓存

缺点在于在构建完缓存之前,返回的都是脏数据

17

两种方法的对比

互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响

逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦

18

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

19

我们来手动实现上锁和解锁函数,主要利用的是Redis的setnx方法来表示获取锁,他只在值不存在的时候才能修改并返回1(被Spring封装成了Boolean值true),这样如果返回true则证明该线程得到了锁,false则证明有线程已经拿到了锁,其他线程只能等待

1
2
3
4
5
6
7
8
9
10
public boolean tryLock(String key){
// 如果不存在锁,则返回值为true,可获得锁
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}

public void unlock(String key){
// 释放锁
stringRedisTemplate.delete(key);
}

修改queryById方法,将互斥锁解决缓存击穿封装成queryWithMutex函数

1
2
3
4
5
6
7
8
9
@Override
public Result queryById(Long id) {
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if(shop == null){
return Result.fail("店铺不存在");
}
return Result.ok(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
public Shop queryWithMutex(Long id){
String lockKey = "lock:shop:" + id;
// 1.从Redis中查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 2.有缓存,直接返回
if(StrUtil.isNotBlank(shopJson)){
// 转为Bean返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 缓存穿透:查看是否为空对象
if("".equals(shopJson)){
return null;
}
// 3.互斥锁解决缓存击穿
// 3.1.实现缓存重建,获取互斥锁
// 3.2.判断是否获取锁成功
Shop shop = null;
try {
if(!tryLock(lockKey)){
// 3.3.失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 3.4.成功,没有缓存,转向查数据库
shop = getById(id);
// 5.数据库中没有,返回错误
if(shop == null){
// 缓存穿透:写入缓存空对象
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,"",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6.将对象转为Json存入Redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7.释放互斥锁
unlock(lockKey);
}
// 8.返回
return shop;
}

在拿到锁且没有缓存需要查询数据库时加入缓存重建延迟,使用Apache Jmeter模拟高并发的情况,加入缓存重建延迟,模拟缓存重建业务较复杂情况

1
2
3
// 3.4.成功,没有缓存,转向查数据库
shop = getById(id);
Thread.sleep(200);//模拟缓存重建业务较复杂情况

20

可以看到1000个http请求,但是只有一条查询数据库的记录,说明互斥锁生效确实解决了高并发下同时访问数据库的问题

21

利用逻辑过期解决缓存击穿

当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。

  • 我们假设缓存中一定会有数据,对热点数据已进行缓存预热,所以在查询Redis时,没命中直接返回空数据
  • 一旦命中后,由第一个检查到该逻辑过期时间已经过期的线程开启一个新的线程拿锁重构数据,在该线程重构数据完成之前,即其他无法获得锁的线程,只能返回旧数据(脏数据),重构数据完成后即可全部返回新数据

22

定义一个封装类,包含一个逻辑过期时间,并且包含想要操作的Bean对象:

1
2
3
4
5
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}

不添加TTL,封装一个逻辑过期时间:

1
2
3
4
5
6
7
8
9
10
11
public void saveShop2Redis(Long id, Long expireSeconds){
// 1.查询店铺信息
Shop shop = getById(id);
//Thread.sleep(200);模拟缓存重建业务较复杂情况所用时间
// 2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3.写入Redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(redisData));
}

生成线程池:

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

实现逻辑过期的主要代码:

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
public Shop queryWithLogicalExpire( Long id ) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, 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 = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
CACHE_REBUILD_EXECUTOR.submit( ()->{

try{
//重建缓存
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}

手动修改数据库的值,把店铺id为1的名称改为10086茶餐厅,当前缓存还为100茶餐厅

使用Jmeter测试,可以看到,在200ms的延迟中,有一些数据还为旧数据,当延迟结束后重构数据后,才缓存入了新数据,获取到的自然也就是新数据

23

封装Redis工具类

  • 这部分涉及的知识点太多,涉及到泛型,函数的封装等等,还未理解,需要慢慢消化,并且到了公司之后,都会有现成公司的工具类可供使用,理解即可

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

  • 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
  • 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓

存击穿问题

  • 方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
  • 方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

将逻辑进行封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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
@Slf4j
@Component
public class CacheClient {

private final StringRedisTemplate stringRedisTemplate;

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

public CacheClient(StringRedisTemplate stringRedisTemplate) {
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);
return r;
}

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.存在,直接返回
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){
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}

public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}

// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.获取锁成功,根据id查询数据库
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);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放锁
unlock(lockKey);
}
// 8.返回
return r;
}

private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}

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

在ShopServiceImpl 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Resource
private CacheClient cacheClient;

@Override
public Result queryById(Long id) {
// 解决缓存穿透
Shop shop = cacheClient
.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

// 互斥锁解决缓存击穿
// Shop shop = cacheClient
// .queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

// 逻辑过期解决缓存击穿
// Shop shop = cacheClient
// .queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);

if (shop == null) {
return Result.fail("店铺不存在!");
}
// 7.返回
return Result.ok(shop);
}

总结

在这篇文章中,我们详细探讨了如何利用Redis实现短信登录功能和商品缓存机制。我们通过Redis的快速读写性能,实现了短信验证码的生成、存储和验证,并通过EXPIRE命令有效管理验证码的有效期,确保验证码过期后自动失效,提高了系统的安全性。

在商品缓存方面,Redis被用来将常用的商品信息缓存到内存中,减少数据库的读操作,从而提升系统性能。我们深入探讨了缓存穿透缓存击穿缓存雪崩等常见问题及其解决方案。缓存穿透通过检查和过滤无效请求避免对数据库的冲击;缓存击穿通过互斥锁逻辑过期策略防止大量并发请求击穿缓存;缓存雪崩则通过设置不同的缓存过期时间和预热策略来避免大量缓存同时失效导致的系统崩溃。

通过这些实战应用,我们不仅掌握了Redis的基本使用方法,还学会了如何将其应用于实际项目中,为进一步深入学习和使用Redis奠定了坚实的基础。