Bootstrap

Redis实践篇

初始化项目:

短信登录

![](https://img-blog.csdnimg.cn/img_convert/f01340d4825d8e40cb49dfdbd390481a.png)

基于Session实现登录

发送短信验证码
![](https://img-blog.csdnimg.cn/img_convert/7d43f4d27e3b03ff579659b9f67d0669.png)
    /**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
   
        // TODO 发送短信验证码并保存验证码
        return userService.sendCode(phone, session);
    }
    /**
     * 发送验证码
     *
     * @param phone
     * @param session
     * @return
     */
    Result sendCode(String phone, HttpSession session);
    /**
     * 发送验证码
     *
     * @param phone
     * @param 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);

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

        return Result.ok();
    }
短信验证码登录、注册
![](https://img-blog.csdnimg.cn/img_convert/3adf515050df24e46b6f6ae5c14387fd.png)
    /**
     * 登录功能
     *
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
   
        // TODO 实现登录功能
        return userService.login(loginForm, session);
    }
    /**
     * 登录
     * @param loginForm
     * @param session
     * @return
     */
    Result login(LoginFormDTO loginForm, HttpSession session);
    /**
     * 登录
     *
     * @param loginForm
     * @param session
     * @return
     */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
   
        // 1.校验手机号
        if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
   
            // 如果不符合,返回错误信息
            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.一致,根据手机号查询用户
        User user = query().eq("phone", loginForm.getPhone()).one();

        // 5.用户是否存在
        if (user == null) {
   
            // 5.1 不存在,注册新用户
            user = createWithPhone(loginForm.getPhone());
        }
        // 5.2 存在,直接登录
        session.setAttribute("user", user);// 将用户信息存入session
        return Result.ok();
    }
校验登录状态

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();
    }
}
import com.hmdp.entity.User;

public class UserHolder {
   
    private static final ThreadLocal<User> tl = new ThreadLocal<>();

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

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

    public static void removeUser(){
   
        tl.remove();
    }
}
@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"
        );
    }
}
隐藏用户敏感信息
```java @Data public class UserDTO { private Long id; private String nickName; private String icon; } ```
@GetMapping("/me")
public Result me() {
   
    // TODO 获取当前登录的用户并返回
    UserDTO user = UserHolder.getUser();
    return Result.ok(user);
}
/**
 * 登录
 *
 * @param loginForm
 * @param session
 * @return
 */
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
   
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
   
        // 如果不符合,返回错误信息
        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.一致,根据手机号查询用户
    User user = query().eq("phone", loginForm.getPhone()).one();

    // 5.用户是否存在
    if (user == null) {
   
        // 5.1 不存在,注册新用户
        user = createWithPhone(loginForm.getPhone());
    }
    // 5.2 存在,直接登录
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    session.setAttribute("user", userDTO);
    return Result.ok();
}
@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((UserDTO) user);

    // 6.放行
    return true;
}
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();
    }
}

集群的session共享问题

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

session的替代方案应该满足:

  • 数据共享
  • 内存存储
  • key、value结构

基于Redis实现共享session登录

![](https://img-blog.csdnimg.cn/img_convert/33be3cd9f8f9b651682d9242cbc70f9e.png)
/**
     * 发送验证码
     *
     * @param phone
     * @param 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.保存验证码到redis // set key value ex 120
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);

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

    return Result.ok();
}

保存登录的用户信息,可以使用String结构,以JSON字符串来保存,比较直观:

KEY VALUE
heima:user:1 { name:“Jack”, age:21}
heima:user:2 { name:“Rose”, age:18}

Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且内存占用更少:

KEY VALUE
field value
heima:user:1 name Jack
age 21
heima:user:2 name Rose
age 18
/**
     * 登录
     *
     * @param loginForm
     * @param session
     * @return
     */
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
   
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
   
        // 如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }

    // 2.从redis获取校验验证码
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + loginForm.getPhone());

    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
   
        // 3.不一致,报错
        return Result.fail("验证码错误!");
    }

    // 4.一致,根据手机号查询用户
    User user = query().eq("phone", loginForm.getPhone()).one();

    // 5.用户是否存在
    if (user == null) {
   
        // 5.1 不存在,注册新用户
        user = createWithPhone(loginForm.getPhone());
    }
    // 5.2 存在,直接登录,保存用户到redis
    // 5.2.1 随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString(true);
    // 5.2.2 将User对象转为Hash存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    //        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                                                     CopyOptions.create()
                                                     .setIgnoreNullValue(true)
                                                     .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    // 5.2.3 将token和user存储到redis
    stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, userMap);

    // 5.2.4 设置token有效期
    stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);

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

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

  • ****选择合适的数据结构
  • ****选择合适的key
  • 选择合适的存储粒度

解决状态登录刷新问题

登录拦截器的优化
![](https://img-blog.csdnimg.cn/img_convert/644b7cb725abd99372c18f19e98ea7a9.png)

将拦截器分为登录拦截器和token刷新的拦截器并配置到MvcConfig中

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;
    }
}
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)) {
   
            // 不存在,拦截,返回401状态码
            response.setStatus(401);
            return false;
        }
        // 2.基于token获取redis中的用户
        String key  = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);

        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
   
            // 4.不存在,拦截
            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, LOGIN_USER_TTL, TimeUnit.MINUTES);

        // 8.放行
        return true;
    }

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

    @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); // order值越大,执行的优先级反而越低
        // token刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

商户查询缓存

什么是缓存

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

添加Redis缓存

给查询的商铺信息添加缓存

/**
 * 根据id查询商铺信息
 * @param id 商铺id
 * @return 商铺详情数据
 */
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
   
    return shopService.queryById(id);
}
/**
 * 根据id查询店铺
 * @param id
 * @return
 */
Result queryById(Long id);
@Override
public Result queryById(Long id) {
   
    // 1.从redis查询商品缓存
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);

    // 2.判断是否存在
    if (StringUtil.isNotBlank(shopJson)) {
   
        // 3.存在,直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }

    // 4.不存在,根据id查询数据库
    Shop shop = getById(id);

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

    // 6.存在,将商品数据存入redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

    // 7.返回商品信息
    return Result.ok(shop);
}

案例:给商铺类型查询业务添加缓存

店铺类型在首页和其它多个页面都会用到,如图:

@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
   
    @Resource
    private IShopTypeService typeService;

    @GetMapping("list")
    public Result queryTypeList() {
   
//        List<ShopType> typeList = typeService
//                .query().orderByAsc("sort").list();
//        return Result.ok(typeList);
        return typeService.getTypeList();
    }
}
public interface IShopTypeService extends IService<ShopType> {
   

    /**
     * 获取店铺类型列表
     * @return
     */
    Result getTypeList();
}
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
   

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 获取店铺类型列表
     *
     * @return
     */
    @Override
    public Result getTypeList() {
   
        // 1.从redis中查询店铺类型
        List<String> shopTypes = stringRedisTemplate.opsForList().range("shop_type", 0, -1);

        // 2.判断是否命中缓存
        if (!shopTypes.isEmpty()) {
   
            // 3.命中则转为ShopType类型直接返回
            List<ShopType> tmp = shopTypes.stream()
                    .map(type -> JSONUtil.toBean(type, ShopType.class))
                    .collect(Collectors.toList());
            return Result.ok(tmp);
        }

        // 4.未命中则查询数据库,判断商铺类型是否存在
        List<ShopType> tmp = query().orderByAsc("sort").list();
        if (tmp.isEmpty()) {
   
            return Result.fail("店铺类型不存在");
        }

        // 5.不存在,返回错误信息
        shopTypes = tmp.stream()
                .map(type -> JSONUtil.toJsonStr(type))
                .collect(Collectors.toList());

        // 6.存在则将商铺数据写入redis
        stringRedisTemplate.opsForList().leftPushAll("cache_shop_type", shopTypes);

        // 6.根据查询结果返回
        return Result.ok(tmp);
    }
}

缓存更新策略

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

业务场景:

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

先删除缓存,再操作数据库
![](https://img-blog.csdnimg.cn/img_convert/7d400d78892e188ab5016e5c62233eaf.png)![](https://img-blog.csdnimg.cn/img_convert/29efec585a078c5a3a8cd4f241ca65de.png)
先操作数据库,再删除缓存
![](https://img-blog.csdnimg.cn/img_convert/69cc29a116d8057b7d97f70af8956ad9.png)![](https://img-blog.csdnimg.cn/img_convert/42fcb2c963c2d260fa56ac1f22f3d5a4.png)
@Override
public Result queryById(Long id) {
   
    // 1.从redis查询商品缓存
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);

    // 2.判断是否存在
    if (StringUtil.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(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        // 返回错误信息
        return Result.fail("店铺不存在");
    }
    // 6.存在,将商品数据存入redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

    // 7.返回商品信息
    return Result.ok(shop);
}
/**
 * 更新商铺信息
 * @param shop 商铺数据
 * @return 无
 */
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
   
    // 写入数据库
    return shopService.update(shop);
}
/**
 * 更新店铺
 *
 * @param shop
 * @return
 */
Result update(Shop shop);
@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();
}
{
   
  "area": "大关",
  "openHours": "10:00-22:00",
  "sold": 4215,
  "address": "金华路锦昌文华苑29号",
  "comments": 3035,
  "avgPrice": 80,
  "score": 37,
  "name": "鸡你太美茶餐厅",
  "typeId": 1,
  "id": 1
}

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

1.低一致性需求:使用Redis****自带的内存淘汰机制

2.****高一致性需求:主动更新,并以超时剔除作为兜底方案

  • ****读操作:
    • ****缓存命中则直接返回
    • ****缓存未命中则查询数据库,并写入缓存,设定超时时间
  • ****写操作:
    • ****先写数据库,然后再删除缓存
    • 要确保数据库与缓存操作的原子性

缓存穿透

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

常见的解决方案有两种:

  • 缓存空对象

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

    • 优点:内存占用较少,没有多余key
    • 缺点:
      • 实现复杂
      • 存在误判可能

@Override
public Result queryById(Long id) {
   
    // 1.从redis查询商品缓存
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);

    // 2.判断是否存在
    if (StringUtil.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(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        // 返回错误信息
        return Result.fail("店铺不存在");
    }
    // 6.存在,将商品数据存入redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

    // 7.返回商品信息
    return Result.ok(shop);
}

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

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

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

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

详情请见SpringCloud

缓存雪崩

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

解决方案:

  • 给不同的Key的TTL添加随机值
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

具体实现复习SpringCloud的课程

缓存击穿(热点key)

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

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

KEY VALUE
heima:user:1 { name:“Jack”, age:21, expire:152141223}

解决方案 优点 缺点
互斥锁 •没有额外的内存消耗
•保证一致性
•实现简单
•线程需要等待,性能受影响
•可能有死锁风险
逻辑过期 •线程无需等待,性能较好 •不保证一致性
•有额外内存消耗
•实现复杂
基于互斥锁方式解决缓存击穿问题
需求:修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题

核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是,进行查询之后。

- 如果没有从缓存中查询到数据,则进行互斥锁的获取,获取互斥锁之后,判断是否获取到了锁,如果没获取到,则休眠一段时间,过一会儿再去尝试,直到获取到锁为止,才能进行查询
- 如果获取到了锁的线程,则进行查询,将查询到的数据写入Redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行数据库的逻辑,防止缓存击穿

操作锁的代码

核心思路就是利用redis的setnx方法来表示获取锁,如果redis没有这个key,则插入成功,返回1,如果已经存在这个key,则插入失败,返回0。在StringRedisTemplate中返回true/false,我们可以根据返回值来判断是否有线程成功获取到了锁

以下代码均在ShopServiceImpl中修改

// 获取锁
private boolean tryLock(String key) {
   
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unlock(String key) {
   
    stringRedisTemplate.delete(key);
}
// 缓存穿透
public Shop queryWithPassThrough(Long id) {
   
    // 1.从redis查询商品缓存
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);

    // 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(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        // 返回错误信息
        return null;
    }
    // 6.存在,将商品数据存入redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    
    // 7.返回商品信息
    return shop;
}
// 互斥锁解决缓存击穿
private Shop queryWithMutex(Long id) {
   

    String key = CACHE_SHOP_KEY + id;

    // 1.从redis查询商品缓存
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);

    // 2.判断是否存在
    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;
    Shop shop = null;
    // 4.2 判断否获取成功
    try {
   
        if (!tryLock(lockKey)) {
   
            // 4.3 失败,则休眠重试
            Thread.sleep(50);
            return queryWithMutex(id);
        }

        // 获取锁成功后应该再次检测redis缓存是否存在,做DoubleCheck,如果存在则无需重建缓存
        shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        if (StrUtil.isNotBlank(shopJson)) {
   
            JSONUtil.toBean(shopJson, Shop.class);
        }
        
        // 4.4 成功,根据id查询数据库
        shop = getById(id);

        // 5.不存在,返回错误信息
        if (shop == null) {
   
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6.存在,将商品数据存入redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
   
        throw new RuntimeException(e);
    } finally {
   
        // 7.释放互斥锁
        unlock(lockKey);
    }

    // 8.返回商品信息
    return shop;
}
@Override
public Result queryById(Long id) {
   
     // 1.缓存穿透
     // Shop shop = queryWithPassThrough(id);

    // 2.互斥锁解决缓存击穿
    Shop shop = queryWithMutex(id);

    // 返回商品信息
    return Result.ok(shop);
}
基于逻辑过期方式解决缓存击穿问题
需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题

思路分析:当用户开始查询redis时,判断是否命中

  • 如果没有命中则直接返回空数据,不查询数据库
  • 如果命中,则将value取出,判断value中的过期时间是否满足
    • 如果没有过期,则直接返回redis中的数据
    • 如果过期,则在开启独立线程后,直接返回之前的数据,独立线程去重构数据,重构完成后再释放互斥锁
  • 封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么新建一个类包含原有的数据和过期时间

步骤一

这里我们选择新建一个实体类,包含原有数据(用万能的Object)和过期时间,这样对原有的代码没有侵入性

@Data
public class RedisData {
   
    private LocalDateTime expireTime;
    private Object data;
}

步骤二

在ShopServiceImpl中新增方法,进行单元测试,看看能否写入数据

// 把商品数据缓存到redis
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
   
    // 1.查询店铺数据
    Shop shop = getById(id);
    Thread.sleep(200);

    // 2.封装逻辑过期
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));

    // 3.写入redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

编写测试方法

@Resource
private StringRedisTemplate stringRedisTemplate;

@Test
public void test() throws InterruptedException {
   
    shopService.saveShop2Redis(1L, 1000L);
}

运行测试方法,去Redis图形化页面看到存入的value,确实包含了data和expireTime

{
   
  "data": {
   
    "area": "大关",
    "openHours": "10:00-22:00",
    "sold": 4215,
    "images": "https://qcloud.dpfile.com/pc/jiclIsCKmOI2arxKN1Uf0Hx3PucIJH8q0QSz-Z8llzcN56-_QiKuOvyio1OOxsRtFoXqu0G3iT2T27qat3WhLVEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vfCF2ubeXzk49OsGrXt_KYDCngOyCwZK-s3fqawWswzk.jpg,https://qcloud.dpfile.com/pc/IOf6VX3qaBgFXFVgp75w-KKJmWZjFc8GXDU8g9bQC6YGCpAmG00QbfT4vCCBj7njuzFvxlbkWx5uwqY2qcjixFEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vmIU_8ZGOT1OjpJmLxG6urQ.jpg",
    "address": "金华路锦昌文华苑29号",
    "comments": 3035,
    "avgPrice": 80,
    "updateTime": 1666502007000,
    "score": 37,
    "createTime": 1640167839000,
    "name": "476茶餐厅",
    "x": 120.149192,
    "y": 30.316078,
    "typeId": 1,
    "id": 1
  },
  "expireTime": 1666519036559
}

步骤三:正式代码
正式代码我们就直接照着流程图写就好了

// 逻辑过期解决缓存击穿
private Shop queryWithLogicalExpire(Long id) {
   
    String key = CACHE_SHOP_KEY + id;

    // 1.从redis查询商品缓存
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);

    // 2.判断是否存在
    if (StrUtil.isBlank(shopJson)) {
   
        // 3.不存在,直接返回
        return null;
    }

    // 4.命中,需要先把json反序列化为对象
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    JSONObject data = (JSONObject) redisData.getData();
    Shop shop = JSONUtil.toBean(data, 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 成功,开启独立线程,实现缓存重建
        // 6.3.1 检测redis缓存是否过期,如果未过期则无需重建缓存
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
   
            return shop;
        }

        // 6.3.2 开启独立线程
        CACHE_REBUILD_POOL.submit(() -> {
   
            // 6.3.2.1 重建缓存
            try {
   
                this.saveShop2Redis(id, 20L);
            } catch (Exception e) {
   
                throw new RuntimeException(e);
            } finally {
   
                
;