02-Redis企业实战一

开篇导读

通过实战篇的学习来理解各种 Redis 的使用,实战篇要学习的内容如下:

  • 短信登录

这一块我们会使用 Redis 共享 session 来实现

  • 商户查询缓存

通过本章节,我们会理解缓存击穿、缓存穿透、缓存雪崩等问题,从而对这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容

  • 优惠卷秒杀

通过本章节,我们可以学会 Redis 的计数器功能, 结合 Lua 完成高性能的 Redis 操作,同时学会 Redis 分布式锁的原理,包括 Redis 的三种消息队列

  • 附近的商户

利用 Redis 的 GeoHash 来完成对于地理坐标的操作

  • UV 统计

主要是使用 Redis 的 HyperLog 来完成统计功能

  • 用户签到

使用 Redis 的 BitMap 完成数据统计功能

  • 好友关注

基于 Set 集合的关注、取消关注、共同关注等等功能

  • 达人探店

基于 List 来完成点赞列表的操作,同时基于 SortedSet 来完成点赞的排行榜功能

image1

一、短信登录

1、导入黑马点评项目

1.1、导入 SQL

首先,导入 SQL 文件 hmdp.sql

其中的表有:

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

注意:MySQL 的版本采用 5.7 及以上版本

1.2、有关当前模型

手机或者 app 端发起请求,请求 Nginx 服务器,Nginx 基于七层模型走的是 HTTP 协议,可以实现基于 Lua 直接绕开 Tomcat 访问 Redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游 Tomcat 服务器,打散流量,我们都知道一台 4 核 8G 的 Tomcat,在优化和处理简单业务的加持下,大不了就处理 1000 左右的并发, 经过 Nginx 的负载均衡分流后,利用集群支撑起整个项目,同时 Nginx 在部署了前端项目后,更是可以做到动静分离,进一步降低 Tomcat 服务的压力,这些功能都得靠 Nginx 起作用,所以 Nginx 是整个项目中重要的一环。

在 Tomcat 支撑起并发流量后,我们如果让 Tomcat 直接去访问 MySQL,根据经验 MySQL 企业级服务器只要上点并发,一般是 16 或 32 核心 CPU、32 或 64G 内存,像企业级 MySQL 加上固态硬盘能够支撑的并发,大概就是 4000 起 ~ 7000 左右,上万并发,瞬间就会让 MySQL 服务器的 CPU、硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用 MySQL 集群,同时为了进一步降低 MySQL 的压力,增加访问的性能,我们也会加入 Redis,同时使用 Redis 集群使得 Redis 对外提供更好的服务。

image2

1.3、导入后端项目

将 hm-dianping 项目源码复制到你的 IDEA 工作空间,然后利用 IDEA 打开即可:

image3

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

注意不要忘了修改 application.yaml 文件中的 MySQL、Redis 地址信息

1.4、导入前端工程

在资料中提供了一个 nginx 文件夹:

image4

将其复制到任意目录,要确保该目录不包含中文、特殊字符和空格,例如:D:\lesson\nginx-1.18.0

1.5、运行前端项目

在 nginx 所在目录下打开一个 CMD 窗口,输入命令:

start nginx.exe

打开 Chrome 浏览器,在空白页面点击鼠标右键,选择检查,即可打开开发者工具,然后打开手机模式:

image5

然后访问 http://127.0.0.1:8080 ,即可看到页面:

image6

2、基于 Session 实现登录流程

发送验证码

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

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

短信验证码登录、注册

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

校验登录状态

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

image7

3、实现发送短信验证码和登录功能

3.1、发送短信验证码

页面流程

image8

具体代码如下

UserController

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

UserServiceImpl

@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);

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

3.2、短信验证码登录和注册

image9

LoginFormDTO:

@Data
public class LoginFormDTO {
    private String phone;
    private String code;
    private String password;
}

UserController:

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

UserServiceImpl:

@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.一致,根据手机号查询用户
    User user = query().eq("phone", phone).one();

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

    // 7.保存用户信息到session中
    session.setAttribute("user",user);

    return Result.ok();
}

private User createUserWithPhone(String phone) {
    // 1.创建用户
    User user = new User();
    user.setPhone(phone);
    user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    // 2.保存用户
    save(user);
    return user;
}

4、实现登录拦截功能

Tomcat 的运行原理

image10

当用户发起请求时,会访问我们向 Tomcat 注册的端口。任何程序想要运行,都需要有一个线程对当前端口号进行监听。Tomcat 也不例外,当监听线程知道用户想要和 Tomcat 连接连接时,会由监听线程创建 socket 连接。socket 都是成对出现的,用户通过 socket 互相传递数据。当 Tomcat 端的 socket 接受到数据后,此时监听线程会从 Tomcat 的线程池中取出一个线程执行用户请求。在我们的服务部署到 Tomcat 后,线程会找到用户想要访问的工程,然后用这个线程转发到工程中的 controller、service、dao 中,并且访问对应的 DB,在用户执行完请求后,再统一返回,再找到 Tomcat 端的 socket,再将数据写回到用户端的 socket,完成请求和响应

通过以上讲解,我们可以得知:每个用户其实对应都是去找 Tomcat 线程池中的一个线程来完成工作的, 使用完成后再进行回收,既然每个请求都是独立的,所以在每个用户去访问我们的工程时,我们可以使用 ThreadLocal 来做到线程隔离,每个线程操作自己的一份数据

关于 Threadlocal

如果看过 ThreadLocal 的源码,你会发现在 ThreadLocal 中,无论是其 put 方法 还是 get 方法, 都是先获得当前用户的线程,然后从线程中取出线程的成员变量 map,只要线程不一样,map 就不一样,所以可以通过这种方式来做到线程隔离

image11

image12

UserHolder:

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

拦截器代码

public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取session
        HttpSession session = request.getSession();

        // 2.获取session中的用户
        Object user = session.getAttribute("user");

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

        // 5.存在,保存用户信息到Threadlocal
        UserHolder.saveUser((User) user);

        // 6.放行
        return true;
    }

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

让拦截器生效

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

5、隐藏用户敏感信息

我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个 UserDTO 对象,这个 UserDTO 对象中没有敏感信息。我们在返回前,将有用户敏感信息的 User 对象转化成没有敏感信息的 UserDTO 对象,就能够避免这个尴尬的问题了。

在登录方法处修改

// 7.保存用户信息到session中
session.setAttribute("user", BeanUtils.copyProperties(user, UserDTO.class));

在拦截器处修改

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

在 UserHolder 处将 user 对象换成 UserDTO

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

6、集群的 session 共享问题

核心思路分析

每个 Tomcat 中都有一份属于自己的 Session,假设用户第一次访问第一台 Tomcat,并且把自己的信息存放到第一台服务器的 session 中。但是第二次这个用户访问到了第二台 Tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的 session,所以此时整个登录拦截功能就会出现问题。我们如何解决这个问题呢?早期的方案是 session 拷贝,就是说虽然每个 Tomcat 上都有不同的 session,但是每当任意一台服务器的 session 修改时,都会同步给其他的 Tomcat 服务器的 session,这样的话就可以实现 session 的共享了

但是这种方案具有两个大问题:

  1. 每台服务器中都有完整的一份 session 数据,服务器压力过大
  2. session 拷贝数据时,可能会出现延迟

所以后来采用的方案都是基于 Redis 来完成,把 session 换成 Redis,Redis 数据本身就是共享的,就可以避免 session 共享的问题了

image13

7、Redis 代替 session 的业务流程

7.1、设计 key 的结构

首先我们要思考一下利用 Redis 来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用 String,或者是使用哈希,如下图。如果使用 String,注意它的 value,会多占用一点空间;如果使用哈希,则它的 value 中只会存储其数据本身。如果不是特别在意内存,其实使用 String 就可以啦。

保存验证码时,可以直接用 String,因为验证码只是一个六位数字。

保存用户信息时,建议使用哈希,因为可以更方便地对每个字段进行修改,且占用内存更少

image14

7.2、设计 key 的具体细节

保存短信验证码时,我们可以使用 String 结构,就是一个简单的 key – value 键值对的方式。但是关于 key 的处理,session 是每个用户都有自己的 session,但是 Redis 的 key 是共享的,咱们就不能使用 code 了。

在设计这个 key 的时候,需要满足两点:

  • key 要具有唯一性
  • key 要方便携带

可以考虑使用手机号作为 key,每个用户的手机号是不同的,不会导致每个用户存入 Redis 的验证码被覆盖重写。用户点击登录后,后台校验验证码时,以手机号为 key 也方便从 Redis 中读取到验证码。

但校验登录状态时,如果我们采用 phone 手机号这个数据来存储当然是可以的,但是把这样的敏感数据存储到 Redis 中并且从页面中带过来毕竟不太合适,所以我们可以在后台生成一个随机串 token,然后让前端带来这个 token 就能完成我们的整体逻辑了

7.3、整体访问流程

当发送短信验证码时,后台生成验证码后,会以手机号为 key 存储验证码到 Redis,然后向用户发送验证码。

image15

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

image16

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

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

8、基于 Redis 实现短信登录

修改后的 UserServiceImpl 代码:

@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(RedisConstants.LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
        // 不一致,报错
        return Result.fail("验证码错误");
    }

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

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

    // 7.保存用户信息到redis中
    // 7.1.随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString(true);
    // 7.2.将User对象转为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()));
    // 7.3.存储
    String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 7.4.设置token有效期
    stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

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

修改后的 LoginInterceptor 代码:

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 + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);

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

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

        // 6.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);

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

        // 8.放行
        return true;
    }

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

9、解决状态登录刷新问题

9.1、初始方案思路总结

初始方案中,确实可以使用对应路径的拦截,同时刷新登录 token 令牌的存活时间,但是这个拦截器只是拦截需要被拦截的路径。假设当前用户处于登录状态,然后访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案是存在问题的。具体点说,用户登录后的行为所对应的请求没有被拦截,比如一直在浏览店铺列表等,过了有效期后用户的登录信息仍会失效。

image17

9.2、优化方案

既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了 ThreadLocal 的数据,所以此时第二个拦截器只需要判断拦截器中的 user 对象是否存在即可,完成整体刷新功能。

image18

9.3、代码

RefreshTokenInterceptor:

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

        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }

        // 4.将查询到的hash数据转为UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        // 5.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);

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

        // 7.放行
        return true;
    }

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

LoginInterceptor:

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

MvcConfig:

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/user/code",
                        "/user/login",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "upload/**",
                        "/voucher/**"
                ).order(1);

        // 刷新token拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .addPathPatterns("/**").order(0);
    }
}

二、商户查询缓存

1、什么是缓存?

前言:什么是缓存?

缓存就像自行车、越野车的避震器。举个例子:越野车、山地自行车,都拥有 “避震器”,防止车体加速后因惯性,在酷似 “U” 字母的地形上飞跃,硬着陆导致的损害,像个弹簧一样。

同样,实际开发中系统也需要 “避震器”,防止过高的数据访问猛冲系统,导致其操作线程无法及时处理信息而瘫痪。这在实际开发中对企业来讲,对产品口碑、用户评价都是致命的,所以企业非常重视缓存技术。

缓存(Cache),就是数据交换的缓冲区,是存贮数据的临时地方,一般读写性能较高。俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码。例如:

// 本地用于高并发
Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>();

// 用于redis等缓存
static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build();

// 本地缓存
Static final Map<K,V> map =  new HashMap();

由于其被 static 修饰,所以随着类的加载而被加载到内存之中,作为本地缓存。由于其又被 final 修饰,所以其引用和对象之间的关系是固定的,不能改变,因此不用担心赋值(=)导致缓存失效。

1.1、为什么要使用缓存

一句话:因为速度快、好用

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

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

但是缓存也会增加代码复杂度和运营的成本:

image19

1.2、如何使用缓存

实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与 Redis 中的缓存并发使用

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

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

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

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

image20

2、添加商户缓存

在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库那肯定慢啊,所以我们需要增加缓存

@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
    // 原来是直接查询数据库
    // return Result.ok(shopService.getById(id));
    return shopService.queryById(id);
}

缓存模型和思路

标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入 Redis。

image21

代码思路:如果缓存有,则直接返回,如果缓存不存在,则查询数据库,然后存入 Redis。代码如下:

@Override
public Result queryById(Long id) {
    String key = RedisConstants.CACHE_SHOP_KEY + id;

    // 1.从Redis查询商户缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);

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

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

    // 5.不存在,返回错误
    if (shop == null) {
        return Result.fail("店铺不存在!");
    }

    // 6.存在,写入Redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));

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

练习:给店铺类型查询业务添加缓存

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

image22

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

image23

修改后的代码:

ShopTypeController:

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

    return typeService.queryTypeList();
}

IShopTypeService:

public interface IShopTypeService extends IService<ShopType> {
    Result queryTypeList();
}

ShopTypeServiceImpl:

  • 使用 List 来存取数据
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryTypeList() {
        // 1.从Redis中查询数据
        // 使用list取,[0, -1]代表全部
        // debug发现取出的10条数据全在一起,也就是说List里面只有一个元素,内容是所有的数据
        List<String> shopTypeList = stringRedisTemplate.opsForList().range(RedisConstants.SHOP_TYPE_KEY, 0, -1);

        // 2.判断是否存在
        if (CollectionUtil.isNotEmpty(shopTypeList)) {
            // 3.存在,返回数据
            // shopTypeList.get(0) 其实是获取了整个List集合里的元素
            List<ShopType> types = JSONUtil.toList(shopTypeList.get(0), ShopType.class);
            return Result.ok(types);
        }

        // 4.不存在,查询数据库
        List<ShopType> typeList = query().orderByAsc("sort").list();
        // 5.不存在,返回错误
        if (CollectionUtil.isEmpty(typeList)) {
            return Result.fail("商户列表信息不存在!");
        }

        // 6.存在,写入Redis中
        // list存
        String jsonStr = JSONUtil.toJsonStr(typeList);
        stringRedisTemplate.opsForList().leftPushAll(RedisConstants.SHOP_TYPE_KEY, jsonStr);

        // 7.返回
        return Result.ok(typeList);
    }
}
  • 使用 String 来存取数据
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryTypeList() {
        // 1.从Redis中查询数据
        // 使用string取
        String shopType = stringRedisTemplate.opsForValue().get(RedisConstants.SHOP_TYPE_KEY);

        // 2.判断是否存在
        if (!StrUtil.isEmpty(shopType)) {
            // 3.存在,返回数据
            List<ShopType> typeList = JSONUtil.toList(shopType, ShopType.class);
            return Result.ok(typeList);
        }

        // 4.不存在,查询数据库
        List<ShopType> typeList = query().orderByAsc("sort").list();
        // 5.不存在,返回错误
        if (CollectionUtil.isEmpty(typeList)) {
            return Result.fail("商户列表信息不存在!");
        }

        // 6.存在,写入Redis中
        // string存
        stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_TYPE_KEY, JSONUtil.toJsonStr(typeList));

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

存入 Redis 的数据:

[
    {"id":1,"name":"美食","icon":"/types/ms.png","sort":1,"createTime":1640175467000,"updateTime":1640229871000},
    {"id":2,"name":"KTV","icon":"/types/KTV.png","sort":2,"createTime":1640175507000,"updateTime":1640229871000},
    {"id":3,"name":"丽人·美发","icon":"/types/lrmf.png","sort":3,"createTime":1640175528000,"updateTime":1640229871000},
    {"id":10,"name":"美睫·美甲","icon":"/types/mjmj.png","sort":4,"createTime":1640175706000,"updateTime":1640229871000},
    {"id":5,"name":"按摩·足疗","icon":"/types/amzl.png","sort":5,"createTime":1640175567000,"updateTime":1640229871000},
    {"id":6,"name":"美容SPA","icon":"/types/spa.png","sort":6,"createTime":1640175575000,"updateTime":1640229871000},
    {"id":7,"name":"亲子游乐","icon":"/types/qzyl.png","sort":7,"createTime":1640175593000,"updateTime":1640229871000},
    {"id":8,"name":"酒吧","icon":"/types/jiuba.png","sort":8,"createTime":1640175602000,"updateTime":1640229871000},
    {"id":9,"name":"轰趴馆","icon":"/types/hpg.png","sort":9,"createTime":1640175608000,"updateTime":1640229871000},
    {"id":4,"name":"健身运动","icon":"/types/jsyd.png","sort":10,"createTime":1640175544000,"updateTime":1640229871000}
]

3、缓存更新策略

3.1、理论基础

缓存更新是 Redis 为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向 Redis 插入太多数据,此时就可能会导致缓存中的数据过多,所以 Redis 会对部分数据进行更新,或者把它叫为淘汰更合适。

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

怎么解决呢?为解决这个问题需要考虑缓存的更新策略,有如下几种方案:

  • 内存淘汰:Redis 自动进行,当 Redis 内存达到咱们设定的 max-memery 的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
  • 超时剔除:当我们给 Redis 设置了过期时间 TTL 之后,Redis 会将超时的数据进行删除,方便咱们继续使用缓存
  • 主动更新:我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题

image24

主动更新策略

主动更新的业务实现目前在企业中有三种模式:

  • Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案

  • Read / Write Through Pattern:由系统本身完成,数据库与缓存的问题交由系统本身去处理

  • Write Behind Caching Pattern:调用者只操作缓存,其他线程去异步处理数据库,实现最终一致

image25

方案一对于调用者可能有些复杂,但是可以去人为控制它

方案二要开发起来成本很高,维护这样一个服务也是比较复杂的,市面上也不太好找到现成的这样一个服务

方案三与方案二类似,效率比较高,但要维护这样一个异步更新的任务是比较复杂的,需要实时监控缓存中数据的变更。其次难以保证一致性,如果缓存已经执行了很多次更新,但还没触发数据库的更新,这段时间内缓存和数据库一直处于不一致的状态。而且如果此时缓存出现了宕机,数据就丢失了。所以其一致性和可靠性存在一定问题

综上所述考虑使用方案一,但是方案一需要开发者自己编码,这里有几个问题

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

1、删除缓存还是更新缓存?

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

如果采用第一个方案,假设我们每次操作数据库后都更新缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来

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

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

3、先操作缓存还是先操作数据库?

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

假设初始化时缓存和数据库的数据都是 10

先看第一种方案的正常情况:

image26

再来看第一种方案的异常情况。在两个线程并发来访问时,假设线程 1 先来,它先把缓存删了,此时线程 2 过来,它查询缓存数据并不存在,所以它查询数据库然后写入缓存,当它写入缓存后,线程 1 再执行更新动作。此时实际上缓存中写入的是旧数据,这种情况发生概率还是挺大的,因为更新数据库相比于更新缓存需要更长的时间

image27

方案二的正常情况:

image28

来看方案二的异常情况(假设此时缓存正好失效了)。但是这种情况的发生概率并不高,因为写入缓存是微秒级的时间,相比之下更新数据库慢得多,所以很难在线程 1 的两个任务间隔时间内完成线程 2 的更新数据库和删缓存操作。

image29

综上所述,我们应当是先操作数据库,再删除缓存。

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

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

3.2、代码实现

核心思路如下:

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

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

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

1、修改 ShopServiceImpl 的 queryById 方法,设置 Redis 缓存时添加过期时间

@Override
public Result queryById(Long id) {
    String key = RedisConstants.SHOP_INFO_KEY + id;

    // 1.从Redis查询商户缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);

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

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

    // 5.不存在,返回错误
    if (shop == null) {
        return Result.fail("店铺不存在!");
    }

    // 6.存在,写入Redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.SHOP_INFO_TTL, TimeUnit.MINUTES);

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

2、修改 ShopController 的 updateShop 方法,ShopServiceImpl 中添加 update(Shop shop) 方法

代码分析:通过之前的淘汰,我们确定了采用删除策略,来解决双写问题,当我们修改了数据之后,然后把缓存中的数据进行删除,查询时发现缓存中没有数据,则会从 MySQL 中加载最新的数据,从而避免数据库和缓存不一致的问题

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

4、缓存穿透问题

4.1、解决思路

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

常见的解决方案有两种:

  • 缓存空对象
    • 优点:实现简单,维护方便
    • 缺点:
    • 额外的内存消耗
    • 可能造成短期的不一致
  • 布隆过滤
    • 优点:内存占用较少,没有多余 key
    • 缺点:
    • 实现复杂
    • 存在误判可能

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

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

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

image30

4.2、编码解决商品查询的缓存穿透问题

核心思路如下:

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

现在的逻辑应该改成:如果这个数据不存在,我们不会返回 404,而是会把这个数据写入到 Redis 中,并且将 value 设置为空字符串。当再次发起查询时,如果缓存命中,还要先判断这个 value 是否是空字符串,如果是空字符串,则是之前写入的数据,证明是缓存穿透数据,如果不是则直接返回数据。

image31

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

    if (shop == null) {
        return Result.fail("店铺不存在");
    }

    // 7.返回
    return Result.ok(shop);
}
// 缓存穿透的解决方案
private Shop queryWithPassThrough(Long id) {
    String key = RedisConstants.SHOP_INFO_KEY + id;

    // 1.从Redis查询商户缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);

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

    // 判断命中的是否是空值
    if ("".equals(shopJson)) {
        // 返回错误信息
        return null;
    }

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

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

    // 6.存在,写入Redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.SHOP_INFO_TTL, TimeUnit.MINUTES);

    // 7.返回
    return shop;
}

小总结

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

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

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

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

5、缓存雪崩问题及解决思路

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

解决方案:

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

image32

6、缓存击穿问题

6.1、问题介绍及解决思路

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

逻辑分析:假设线程 1 在查询缓存之后,本来应该去查询数据库然后把这个数据重新加载到缓存的,此时只要线程 1 走完这个逻辑,其他线程就都能从缓存中加载这些数据了。但是假设在线程 1 没有走完的时候,后续的线程 2、线程 3、线程 4 同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大

image33

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

解决方案一、使用锁来解决:

因为锁能实现互斥性。假设线程过来,只能一个一个的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用 tryLock 方法 + double check 来解决这样的问题。

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

image34

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

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

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

这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

image35

两种方案进行对比

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

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

image36

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

核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询

如果获取到了锁的线程,再去进行查询,查询后将数据写入 Redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿

image37

操作锁的代码

核心思路就是利用 Redis 的 setnx 方法来表示获取锁,该方法含义是 Redis 中如果没有这个 key 则插入成功,返回 1,在 stringRedisTemplate 中返回 true,如果有这个 key 则插入失败,则返回 0,在 stringRedisTemplate 返回 false。我们可以通过 true 或者是 false 来表示是否有线程成功插入 key,成功插入 key 的线程我们认为它就是获取到锁的线程。

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

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

操作代码

@Override
public Result queryById(Long id) {
    // 互斥锁解决缓存击穿
    Shop shop = queryWithMutex(id);

    if (shop == null) {
        return Result.fail("店铺不存在");
    }

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

// 利用互斥锁实现的缓存击穿的解决方案
private Shop queryWithMutex(Long id) {
    String key = RedisConstants.SHOP_INFO_KEY + id;
    Shop shop;

    // 1.从Redis查询商户缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);

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

    // 判断命中的是否是空值
    if ("".equals(shopJson)) {
        // 返回错误信息
        return null;
    }

    // 4.实现缓存重建
    // 4.1.获取互斥锁
    String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
    try {
        boolean success = tryLock(lockKey);
        // 4.2.判断是否获取成功
        if (!success) {
            // 4.3.失败,则休眠并重试
            Thread.sleep(50);
            return queryWithMutex(id);
        }
        // 4.4.成功,再次检测redis缓存是否存在,做DoubleCheck
        shopJson = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(shopJson)) {
            // 存在,无需重建缓存,直接返回
            shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }
        // 不存在,根据id查询数据库
        shop = getById(id);
        // 模拟查询数据库的延迟
        Thread.sleep(200);

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

        // 6.存在,写入Redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.SHOP_INFO_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        // 7.释放互斥锁
        unlock(lockKey);
    }

    // 8.返回
    return shop;
}

6.3、利用逻辑过期解决缓存击穿问题

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

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

image38

如何封装数据:因为现在 Redis 中存储的数据的 value 需要带上过期时间,此时要么你去修改原来的实体类,要么你专门重新封装一个类

步骤一

新建一个实体类,我们采用第二个方案,这个方案,对原来代码没有侵入性。

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

步骤二

ShopServiceImpl 新增 saveShop2Redis 方法,利用单元测试进行缓存预热

public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
    // 1.查询店铺数据
    Shop shop = getById(id);
    // 模拟缓存重建的延迟
    Thread.sleep(20);
    // 2.封装逻辑过期时间
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    // 3.写入Redis
    stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_INFO_KEY + id, JSONUtil.toJsonStr(redisData));
}

在测试类中

@SpringBootTest
public class HmDianPingApplicationTests {
    @Resource
    private ShopServiceImpl shopService;

    @Test
    void testSaveShop() throws InterruptedException {
        shopService.saveShop2Redis(1L, 10L);
    }
}

步骤三:正式代码

ShopServiceImpl

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

@Override
public Result queryById(Long id) {
    // 逻辑过期解决缓存击穿
    Shop shop = queryWithLogicalExpire(id);

    if (shop == null) {
        return Result.fail("店铺不存在");
    }

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

// 利用逻辑过期实现的缓存击穿的解决方案
public Shop queryWithLogicalExpire(Long id) {
    String key = RedisConstants.SHOP_INFO_KEY + id;

    // 1.从Redis查询商户缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);

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

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

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

    // 6.已过期,需要缓存重建
    // 6.1.获取互斥锁
    String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
    boolean success = tryLock(lockKey);
    // 6.2.判断是否获取锁成功
    if (success) {
        // 6.3.成功,再次检测redis缓存是否过期,做DoubleCheck
        shopJson = stringRedisTemplate.opsForValue().get(key);
        redisData = JSONUtil.toBean(shopJson, RedisData.class);
        shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        expireTime = redisData.getExpireTime();
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 未过期,无需重建缓存,直接返回
            return shop;
        }
        // 过期,开启独立线程,实现缓存重建
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                this.saveShop2Redis(id, 20L);
            } catch (Exception e) {
                throw new RuntimeException(e);
            } finally {
                // 释放锁
                unlock(lockKey);
            }
        });
    }
    // 6.4.返回过期的商铺信息
    return shop;
}

7、封装 Redis 工具类

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

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

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

  • 方法 4:根据指定的 key 查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题将逻辑进行封装

将逻辑封装到工具类 CacheClient:

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

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

    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
            this.set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }

        // 6.存在,写入Redis
        this.set(key, r, time, unit);

        // 7.返回
        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.缓存未命中,直接返回null
            return null;
        }

        // 4.命中,需要先把json序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();
        R r = JSONUtil.toBean(data, type);
        LocalDateTime expireTime = redisData.getExpireTime();

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

        // 6.已过期,需要缓存重建
        // 6.1.获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean success = tryLock(lockKey);
        // 6.2.判断是否获取锁成功
        if (success) {
            // 6.3.成功,再次检测redis缓存是否过期,做DoubleCheck
            json = stringRedisTemplate.opsForValue().get(key);
            redisData = JSONUtil.toBean(json, RedisData.class);
            r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
            expireTime = redisData.getExpireTime();
            if (expireTime.isAfter(LocalDateTime.now())) {
                // 未过期,无需重建缓存,直接返回
                return r;
            }
            // 过期,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R newR = dbFallBack.apply(id);
                    // 写入Redis
                    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 json = stringRedisTemplate.opsForValue().get(key);

        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }

        // 判断命中的是否是空值
        if (json != null) {
            // 返回错误信息
            return null;
        }

        // 4.实现缓存重建
        // 4.1.获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        R r;
        try {
            boolean success = tryLock(lockKey);
            // 4.2.判断是否获取成功
            if (!success) {
                // 4.3.失败,则休眠并重试
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallBack, time, unit);
            }
            // 4.4.成功,再次检测redis缓存是否存在,做DoubleCheck
            json = stringRedisTemplate.opsForValue().get(key);
            if (StrUtil.isNotBlank(json)) {
                // 存在,无需重建缓存,直接返回
                return JSONUtil.toBean(json, type);
            }
            // 不存在,根据id查询数据库
            r = dbFallBack.apply(id);
            // 模拟查询数据库的延迟
            // Thread.sleep(200);

            // 5.不存在,返回错误
            if (r == null) {
                // 将空值写入redis
                this.set(key, "", RedisConstants.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", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

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

在 ShopServiceImpl 中

@Resource
private CacheClient cacheClient;

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

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

    // 逻辑过期解决缓存击穿
    /*Shop shop = cacheClient.queryWithLogicalExpire(RedisConstants.SHOP_INFO_KEY, id, 
                Shop.class, this::getById, RedisConstants.SHOP_INFO_TTL, TimeUnit.MINUTES);*/

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

    // 返回
    return Result.ok(shop);
}
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇