当前位置: 首页 > news >正文

Redis 实战篇 ——《黑马点评》(上)

《引言》

        在进行了前面关于 Redis 基础篇及其客户端的学习之后,开始着手进行实战篇的学习。因内容很多,所以将会分为 上 中 下 】三篇记录学习的内容与在学习的过程中解决问题的方法Redis 实战篇的内容我写的很详细,为了能写的更好也付出了心血,希望大家多多点赞支持 ψ(*`ー´)ψ

a96419ae67e640cc93808ee6bd04b3eb.gif


目录

一、短信登录功能

1. 基于 Session 的登录功能

1.1. 发送短信验证码

1.2. 短信验证码登录

1.3. 登录校验

2. 集群的 session 共享问题及解决

2.1. 分析替代后的变化

2.2. Redis 替代 Session 的代码实现

二、商户查询缓存

1. 添加 Redis 缓存

1.1. 根据 id 查询商铺信息

1.2. 练习题 

2. 缓存更新策略

2.1. 主动更新策略的实现

2.2. 改造查询商铺的缓存更新策略

3.缓存穿透/雪崩/击穿

3.1. 缓存穿透

3.2. 缓存雪崩

3.3. 缓存击穿

3.3.1.互斥锁解决缓存击穿问题

3.3.2. 逻辑过期方式解决缓存击穿问题

4. 缓存工具封装


一、短信登录功能

1. 基于 Session 的登录功能

        在进行学习前,必不可少的一步就是将其准备好的资料中的后端部分、前端 Nginx 部分及数据库sql 脚本进行导入。

步骤为:

        将资料文件中的 hmdp.sql 文件导入到 MySQL 数据库中。这里我使用的是 DataGrip 软件进行 sql 文件的导入的。

        导入后端工程,启动时需要对配置文件进行检查,看看数据库的连接配置是否正确,如果不正确启动时会报错。

         其中在导入前端工程时,只需将 Nginx 启动即可,但我出现了启动后无法访问的到网址的问题,在文件夹下创建了两个名为 temp client_body_temp 的文件夹后即可正常启动 Nginx 访问 8080 端口显示页面。


        想要实现基于 Session 的登录功能,我们需要分为三个步骤来逐步实现:

1.1. 发送短信验证码

        如下图所示,可以看到在点击发送验证码之后,会向服务端发送一个请求,我们需要实现这个接口来完成发送短信验证码的功能。

c81ab25e99d845c5b6e2f0c7e55964b6.png

        按照 controller → service → serviceImpl 的顺序去创建 sendCode() 方法,最终由 controller 层调用此方法实现发送短信验证码的功能。

        而在该方法内,我们需要完成发送验证码的逻辑实现,其实现步骤分为五步

1.校验手机号
2.不符合,返回错误信息
3.符合,生成验证码
4.保存验证码到session
5.模拟发送验证码

    public Result sendCode(String phone, HttpSession session) {//1.校验手机号if (RegexUtils.isPhoneInvalid(phone)) {//2.不符合,返回错误信息return Result.fail("手机号格式错误");}//3.符合,生成验证码String code = RandomUtil.randomNumbers(6);//4.保存验证码到sessionsession.setAttribute("code", code);//5.模拟发送验证码log.debug("发送验证码成功,验证码:{}", code);return Result.ok();}

● 第一步:通过项目自带的 utils 工具包下的 RegexUtils 来对传来的手机号码 phone 进行校验,格式不对的号码会返回 true

4d8470c304c244538ec2e31af3a90abc.png

● 第二步:号码校验结果为不符合规范后,返回封装好的返回结果类,其内有对应不同情况的静态方法,有利于返回结果的统一性规范性

f9fff50a06d146a395281a752f02a0e3.png

● 第三步:号码校验结果为符合规范后,将使用引入的 hutool 糊涂工具包中的随机数生成工具类来生成长度为 6 的验证码。

● 第四步:将生成的验证码保存到 session 中。

● 第五步:因想要发送验证码需要调用第三方平台,非常麻烦,所以此处我们使用模拟发送的方式来进行发送。注意此处需调用 Slf4j log.debug() 方法,否则会因原有的 log 接口中的 debug 方法不接受带有占位符的字符串格式化,只接受单一字符串参数而报错,需要在类上加上 @Slf4j 注解。

工具包真爽啊。。。

        最终实现功能展示效果如下:

94a59ae1b6c84bb8b0bb650a91506305.png


1.2. 短信验证码登录

        在上一个功能完成后,我们可以接着完成登录的功能了,可以看到登录发送的请求如下图所示(注意登录时需勾选同意协议),接下来我们就要实现该功能了。其请求如下:9edf465583fc4168a51c14132432dde9.png

        注意此处使用 json 格式提交到后端,所以此处使用 @RequestBody 注解配合实体类接收。

b8a1fe4440554065bffb1b6ee42b19e2.png

        因登录方式有验证码登录密码登录两种,所以类中存在 password 属性。

2c14bfabf641482bbab444565d58a3a3.png

        继续之前的操作,接着在 serviceImpl 中实现我们的登录方法。

        首先需要明确一点,该项目的登录与注册功能是一起的,在登录时如果检测到用户不存在则会自动为其创建一个新用户

55f45f6617b44541b5a440860a8e57b9.png

        其功能的实现也可分为三步逐步完成:

1.校验手机号和验证码
2.根据手机号查询用户
3.存在,保存用户信息;不存在,新建用户

    public Result login(LoginFormDTO loginForm, HttpSession session) {String phone = loginForm.getPhone();//1.校验手机号if (RegexUtils.isPhoneInvalid(phone)) {//1.2.不符合,返回错误信息return Result.fail("手机号格式错误");}//2.校验验证码Object cacheCode = session.getAttribute("code");String code = loginForm.getCode();if (cacheCode == null || !cacheCode.toString().equals(code)){//2.1.不一致,报错return Result.fail("验证码错误");}//2.2.一致,根据手机号查询用户User user = query().eq("phone", phone).one();//3.1.用户不存在,创建新用户if (user == null){user = createUserWithPhone(phone);}//3.2.保存用户信息到 Session 中session.setAttribute("user", user);return Result.ok();}private User createUserWithPhone(String phone) {//1.初始化新用户User user = new User();user.setPhone(phone);user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));//2.保存用户save(user);return user;}

● 第一步:校验手机号,与前面的方法相同ctrl + c、ctrl + v);校验验证码,从 loginFrom 对象中取出验证码与从 session 中取出的 code 进行比较,不一致则报错,一致则继续之后的逻辑

● 第二步:继续判断用户是否于数据库中存在,这里我们使用到 MyBatisplus 来简化开发,因该类继承了 ServiceImpl 类且泛型中传入了对应的 Mapper User,所以其直接生成了对应类的相关方法,可以直接使用。如:query().eq("phone", phone).one(); 就是返回查询手机号码phone 相同的一个数据。

21902ceba8254c7ebe58d058d4545849.png

● 第三步:判断用户是否存在,若存在,则直接将用户保存在 session 中,故不用再将登录凭证返回,因为 session 本身具备唯一性session ID),会自动写到 cookie 中,以后的请求都会携带 session,随时找到;若不存在,则走注册新用户的步骤,在 createUserWithPhone()方法中初始化用户,只用对 PhoneNickName(昵称)赋值即可,昵称使用前缀 + 随机字符串的格式进行初始化,之后使用 Mybatisplus save()方法存入数据库中,最后将创建好的用户信息返回后再存入到 session 中。

        此处的前缀使用的是在常量包下定义好的系统常量(看着高端...

baa29e7c4d5a41c8ba3ac114fc6fa2ff.png

        完成该功能后,因为我们还没有实现登录校验的功能,所以登录后会弹出登录状态。


1.3. 登录校验

        在实现上一功能后,我们继续实现登录校验的功能,其请求如下图所示:

465dc8e055464d578dff6cffeaada2ec.png

        首先,我们有许多的功能模块都需要进行登录校验,我们不可能在每个功能的 controller 层中都编写相同的校验代码,所以我们就需要用到 Springmvc 中的拦截器,在所有的 controller 执行之前去进行登录校验。而数据的传递就要用到 Thread Local 保存数据,保证线程的安全。

1.定义拦截器
2.编写配置类
3.实现逻辑

       ● 第一步:首先我们需要定义一个拦截器类实现 HandlerInterceptor 接口Alt + Insert 选择实现其中的 afterCompletion preHandle 方法。

1e05fb805b8a40cb84e4d16fa1b083f3.png

        在 preHandle 中实现校验登录状态的功能:

    @Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获取 sessionHttpSession 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);return true;}

        获取传入的请求中的 session 并取出其中的用户信息(ps:此处的用户信息是在登录时写入session 中的,1.2. 实现的功能),并进行校验不存在则在响应中设置状态码为 401 表示身份校验失败,返回 false 表示拦截该请求。存在则将用户信息保存到 ThreadLocal 中并返回 true 放行。

        注意项目中将 user 强转为 User 类会报错,因资料中代码已将对应方法的参数类型更改为了UserDTO,但视频里是在后面改进时修改的,所以这里报错是正常现象,后面会更改

        其中的 UserHolder 是已经在工具包中写好的用于存储 User 类用户信息的 ThreadLocal。

        之后,在请求处理完成后执行的 afterCompletion 方法中移除用户信息防止泄露。

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

        ● 第二步:编写配置类添加新创建的拦截器,并设置需要排除拦截的路径。

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

        注意类上需加上 @Configuration 注解标识该类为配置类从而被扫描识别。

        ● 第三步:在对应 controller 层方法处返回获取到的用户信息。

    @GetMapping("/me")public Result me(){//获取当前登录用户并返回return Result.ok(UserHolder.getUser());}

        但注意此时的项目存在问题,后端返回的用户信息过于全面(甚至密码都返回... ),这样会导致用户信息的泄露,所以我们要改进,这就需要我们将之前的 User 类转为 UserDTO 类,其内只包含必要的信息字段(id、昵称、头像),更适合展示。

1aa17f222a5143d888fcbf71bf65b72e.png

e6d32ced530b429fb0d3cf647a8af597.png

        我们可以使用前面用过的 hutool 工具包中的 BeanUtil 中的 copyProperties 方法(老朋友了...)进行具有相似属性名的类间的转换。

        将第五步中保存信息步骤中报错的强转 User 类更改为为 BeanUtil 拷贝属性即可。

//5.存在,保存信息到 ThreadLocal 后放行
UserHolder.saveUser(BeanUtil.copyProperties(user, UserDTO.class));
return true;

        最终效果如下所示:

2414c3b0918c41039951d85e128d7102.png


2. 集群的 session 共享问题及解决

        我们知道,当你解决了一个难题时,新的问题又会接踵而至的出现c0864320d38845eab72356fae60217b1.png为了应对并发问题,需要部署多台 tomcat 服务器来实现负载均衡,解决高并发的问题。但是随之而来的是多台 tomcat 之间并不共享 session 的存储空间,这就导致会出现在 1 号 tomcat 上我登录成功了,可如果此时均衡到 2 号 tomcat 上时会提示我 “ 请登录... ”。

        想要解决这个问题,其实很简单,只需让 session 的存储空间共享即可,而 tomcat 早期提供了 session 拷贝的功能,但因其会造成不必要的内存空间的浪费延迟的存在而被 Pass

cb376694e52649a39c4a2b1838c79023.png

        如此一来,就需要能够替代 session 的产品来实现相同的功能如数据共享、内存存储(读写速度快)、key-value(结构简单)这几个功能。(Redis:这不就是我吗5c1013386e4e4967a3ab28c9b35eb5fd.png;快忘了原来是要学 Redis 的了...


2.1. 分析替代后的变化

        ● 在写入数据时,想要将原先保存在 session 中的验证码保存在 Redis 中,因在 session 中我们将 code 直接作为 key,只是因为每一个浏览器都有独立的 session,所以即使存在相同的 code,但是相互之间不会影响。但是 Redis 的内存空间是共享的,所以相同的 key 会进行数据的覆盖,造成验证码的丢失。所以 Redis key 的设计需要确保其唯一性,所以我们选择使用手机号作为 key

        ● 而在取出数据时tomcat 会自动维护 session,创建时自动生成 session ID 写入 cookie 中,可以自动从 session 中获取数据。以手机号作为 key 来读取验证码进行校验,校验通过后以Hash 结构保存在 Redis 中,相较于 String 结构可以对对象中的单个字段进行操作。

        ● 在保存用户信息时,我们选择使用随机的 token 作为 key 进行存储,并将其作为登录凭证,但 tomcat 并不会将 token 自动写到浏览器中,我们只能手动返回浏览器保存。

        其在前端通过拦截器将 token 添加到请求头中,而后端就可以从请求头中获取数据。但因 key 最后会返回给前端,直接返回以手机号key 到浏览器保存,会存在数据泄露的风险。所以最终决定使用 token 这一更安全的方式作为 key

        其中我们的前端将 token 定义为 authorization

0174b21097a84d45a314ea10b1588b98.png


2.2. Redis 替代 Session 的代码实现

        想要使用 Redis 替代 Session,我们需要进行三处代码逻辑的修改。首先要做的,就是要将 SpringDataRedis API 注入到 controller 层中再进行代码的改进

① 发送验证码:需要将保存验证码到 session 更改为保存到 Redis 中,其中参数内容为(key, value, 过期时间, 时间单位)。

//4.保存验证码到 redis 中
stringRedisTemplate.opsForValue().set("login:code" + phone, code, 2, TimeUnit.MINUTES);

       为了提高代码的可读性可维护性,我们需要将其中的常量使用在工具包下 RedisConstants 类中定义好的常量替代。(通俗来讲,就是为了看着更721d56b3284043d887c78502f0eed2e1.png

//4.保存验证码到 redis 中
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);

fce8a6023cd74be08f5882e50836ca1d.png

② 验证码登录:将从 session 中获取验证码更改为从 Redis 中获取验证码,因其直接返回 String 类型,故其下方的校验步骤中就不需要进行 toString( ) 转换了。

//2.从 Redis 中获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);

        之后我们需要将原先把的保存用户信息session 更改为保存到 Redis 中,其 key 为使用 hutool 工具包下的 UUID 随机生成的 token其方法参数表示是否带 “-” 符号:图1)。注意 BeanUtil 也是 hutool 工具包下的。

        注意用户对象数据存储为 Hash 类型,我们使用 BeanUtil 包下的方法将对象转为 Map 类型与之对应。由因其本身不直接支持设置过期时间,所以需要在创建后手动再设置有效期,并在最后将 token 返回客户端(浏览器)。

//3.2.保存用户信息到 Redis 中
//随机生成 token 作为登录令牌
String token = UUID.randomUUID().toString(true);
//将 User 对象转为 Hash 进行存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, BeanUtil.beanToMap(userDTO));
//设置有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
//将 token 返回客户端
return Result.ok(token);
8db906c3dc344bf7993d71a36ca066ac.png

 图 1

        但此时仍存在问题,因该用户信息只会设置一次,所以在 30 分钟后无论用户是否还在继续访问,都会过期。所以我们需要改进,只要存在访问的行为,就不断更新有效期,在停止访问后才会在 30 分钟后过期。

        而拦截器可以将客户端的请求拦截并处理,符合了我们想要不断更新有效期的目的。所以接着我们还要继续修改拦截器的代码:

        首先在我们定义的拦截器类中注入 StringRedisTemplate,但因为该类为我们自定义的类,并不是被 Spring 创建的不能自动注入,所以我们需要创建一个构造方法

private StringRedisTemplate stringRedisTemplate;public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;
}

        在构造方法中我们还需要再次注入 StringRedisTemplate  并将其传入我们添加的拦截器中。

11442d6a77bb48cab29908550e0ea072.png

       ​● 第一步需要获取请求头中的 token,并判断其是否为为空,其中的 authorization 是我们在前端规定好名称。

//1.获取请求头中的 token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)){response.setStatus(401);return false;
}

        第二步基于 token 来获取用户信息,entries 方法已经判断是否为 null 了,所以下面只需判断 Map 内容是否为空即可。

//2.基于 token 获取用户信息
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
//3.判断用户是否存在
if (userMap.isEmpty()){ ...

        ● 第三步再将 map 转为对象后存入 ThreadLocal 中。最后刷新 token 有效期放行。

//Map 再转为对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//5.存在,保存信息到 ThreadLocal 
UserHolder.saveUser(userDTO);
//刷新 token 有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
//6.放行
return true;

        但!!! 对,此时还是存在问题...) 启动后我们发现在登录时会报出系统错误的提示,经过查看控制台的输出发现是因为类型不匹配的问题。由于我们的 UserDTO 类中的 id 类型为 long 类型,但我们在前面的 【数据库】Redis—Java 客户端 中提到过 StringRedisTemplate 只能接受 String 类型的键值类型。

        于是,我们需要解决类型不匹配的类型,这里选择最 装b (▼へ▼メ) 的一种,beanToMap 方法的拓展CopyOptions 允许我们对其内的键值进行自定义

698bcf077f0042a1b7ff99b23107960f.png

Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
  • setIgnoreNullValue:是否忽略一些空值
  • setFieldValueEditor:对字段值进行修改,其中的 (fieldName, fieldValue) -> fieldValue.toString() lambda 表达式,用于简化编程。fieldName 和 fieldValue 代表两个参数,箭头(->)后为相应的操作。

最后登录完成效果如下:

1b38e0b6aad0467b80e72d1be9dc1526.png

        如果还是出现如登录后弹出登录等问题,可以再次查看一些配置是否有问题,或是一些细节的地方写错了,比如我自己就是因为在下图中的红框内忘记加上了前缀 LOGIN_USER_KEY 导致出现了错误。(还是照着视频找了半天才发现...

862aa11ea3e845f7b17e56a8719ce868.png

但~是~~其实最终的业务逻辑还是存在问题的... 

好奇他为啥不能一次讲清楚...

        在实现了通过访问不断刷新 token 后,我们的业务逻辑其实还存在问题,那就是我们只在需要做登录校验的请求进行了拦截,如果用户一直只访问不需要登录校验的页面,那么就不会刷新有效期,到时间后用户信息还是会被删除,仍然需要重新登录

        解决办法就是再创建一个新拦截器,与旧拦截器一起构成拦截器链,只是新拦截器不进行拦截,只在存在用户信息时进行保存并刷新 token,而旧拦截器只需取出用户信息并判断是否存在进行登录校验即可。

        新拦截器代码如下所示,其实就是旧拦截器Ctrl + c 、Ctrl + v)。只不过在需要进行拦截的地方 retun true 进行了放行。

public class RefreshTokenInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获取请求头中的 tokenString token = request.getHeader("authorization");if (StrUtil.isBlank(token)){return true;}//2.基于 token 获取用户信息Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);//3.判断用户是否存在if (userMap.isEmpty()){return true;}//Map 再转为对象UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);//5.存在,保存信息到 ThreadLocal 后放行UserHolder.saveUser(userDTO);//刷新 token 有效期stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);//6.放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户信息UserHolder.removeUser();}
}

        而旧的拦截器则是只保留了 preHandle 方法,取出 ThreadLocal 中的信息判断是否存在。

public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//根据 ThreadLocal 中是否存在用户信息来判断是否需要拦截if (UserHolder.getUser() == null) {// 没有,需要拦截response.setStatus(401);return false;}//有,放行return true;}
}

        注意,新构造器需要在我们创建的 MvcConfig 配置类中进行添加,且因存在两个拦截器,所以需要为其设置先后顺序,而拦截器顺序 oreder 默认为 0,其数越越先执行。若全默认为 0,则按添加的先后顺序执行。(注意新拦截器需要传入 stringRedisTemplate)

public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/user/code","/user/login","/blog/hot","/shop/**","/shop-type/**","/upload/**","/voucher/**").order(1);registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);

二、商户查询缓存

1. 添加 Redis 缓存

        缓存是数据交换的缓存区,是一个临时存储数据的地方,一般来说读写性能较高。我们可以将经常读写的资源添加到缓存中,提高读写的效率。而使用 Redis 来实现缓存是一个很好的方法,可以提高读写的性能。

        如我们在查询商户信息时,需要反复的查看不同的商户信息,且其数据量,我们可以将这些需要反复查看的商户写入 Redis 中实现缓存,在下一次查看时直接读取 Redis 中的数据而不用从 MySQL 数据库中查询,大大提高了读写的效率。


1.1. 根据 id 查询商铺信息

        接下来将对根据 id 查询商铺信息进行改进,使其 controller 返回 queryById 方法,我们在此方法中实现具体的代码逻辑。

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

        首先,在 Impl 类中注入 StringRedisTemplate,之后哦我们可以分三步实现通过 Redis 进行缓存的功能:

1.从 Redis 中查询是否存在缓存
2.存在,返回缓存信息
3.不存在,查询数据库,判断是否存在

public Result queryById(Long id) {//从 Redis 中查询是否存在缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);//1.存在,返回if (StrUtil.isNotBlank(shopJson)){Shop shop = JSONUtil.toBean(shopJson, Shop.class);return Result.ok(shop);}//2.不存在,查询数据库Shop shop = getById(id);//2.1 数据库不存在,返回错误if (shop == null){return Result.fail("店铺不存在!");}//2.2 数据库存在,写入 RedisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop));//3.返回return Result.ok(shop);
}

        ● 第一步:通过 stringRedisTemplate 查询 Redis 中是否存在相应的缓存数据,这里我们使用定义的常量前缀 + id 作为 key

        ● 第二步:对返回的内容进行非空判断如果不是空则将其转回 Shop 对象后返回,如果是空值代表是第一次查询,还未进行缓存,所以再从 MySQL 数据库中进行查询。

        ● 第三步:数据库进行查询后,再次进行校验如果为空则返回错误信息 "店铺不存在!"如果存在则将其转为 Json 字符串后先存入 Redis 中缓存,最后再返回。(注意此处返回的是从数据库中查询得到的对象,不是转换后的

        完成后,我们可以看到在缓存后时间由原来的 54 毫秒改进为了 11 毫秒,足足提高了 400%然看着差距小,但确实有点小,只在大数据量的情况下才能看出差距

de50955a8ea947159284c0a371a9fb4d.png

        可以看到 Redis 中的缓存信息如下图所示:

6b3a35bad9cd4133a2ff9be0743b8f54.png


1.2. 练习题 

练习题

c0eea37de9df4169b1fa6341ea0d0ee2.png

(功能的代码实现仅代表个人思路,并不代表是最佳的实现方式)

完整版如下

@Override
public Result getTypeList() {//查询 Redis 中是否存在数据List<String> shopTypeJsonList = stringRedisTemplate.opsForList().range(CACHE_SHOPTYPE_KEY, 0, -1);//1.存在,返回数据if (shopTypeJsonList != null && !shopTypeJsonList.isEmpty()){List<ShopType> shopTypes = shopTypeJsonList.stream().map(shopTypeJson -> JSONUtil.toBean(shopTypeJson, ShopType.class)).collect(Collectors.toList());return Result.ok(shopTypes);}//2.不存在,查询数据库List<ShopType> typeList = list();//2.1 不存在,返回错误信息if (typeList == null || typeList.isEmpty()){return Result.fail("店铺类型不存在!");}//2.2 存在,以 Json 格式写入 RedisList<String> shopTypes = typeList.stream().map(JSONUtil::toJsonStr).collect(Collectors.toList());stringRedisTemplate.opsForList().leftPushAll(CACHE_SHOPTYPE_KEY, shopTypes);//3.返回return Result.ok(typeList);
}

        首先将原有的代码删除,选择返回一个新建的方法,之后到 Impl 实现类中开始编写。

@GetMapping("list")
public Result queryTypeList() {return typeService.getTypeList();
}

        接着继续按照前面的步骤进行改造即可:

1.查询 Redis 中是否存在数据

//查询 Redis 中是否存在数据
List<String> shopTypeJsonList = stringRedisTemplate.opsForList().range(CACHE_SHOPTYPE_KEY, 0, -1);

        这里我用的是 List 类型进行存储,可以通过 range 方法获取一定范围内的数据,其中的参数CACHE_SHOPTYPE_KEY 是我在常量类定义好的,而 0 表示从列表的第一个元素开始,-1 表示到列表的最后一个元素,所以就是获取整个列表的所有元素

8f26707f56a740c39e85fbbc66541fdf.png

//1.存在,返回数据
if (shopTypeJsonList != null && !shopTypeJsonList.isEmpty()){List<ShopType> shopTypes = shopTypeJsonList.stream().map(shopTypeJson -> JSONUtil.toBean(shopTypeJson, ShopType.class)).collect(Collectors.toList());//排序typeList.sort((o1, o2) -> o1.getSort() - o2.getSort());return Result.ok(shopTypes);
}

        存在缓存时,我们就要将其重新转换为对象列表,这里我判断的是 null isEmpty,最大程度保证其不为空(其中 null 检测对象是否为有效,isEmpty 检测集合是否为空)。然后将其返回。这里在转为 List<ShopType> 类型时,我使用了集合的 stream 流lambda 表达式来进行操作,以简化代码的编写。

        其中 stream 流的 map 方法的作用是接受一个函数作为参数,并将该函数作用于流中的每一个元素,相当于遍历其中的元素并进行操作,这里我将其中的每一个元素都用 JSONUtil 工具类Json 字符串转为了 ShopType 的对象 ,并在最后使用 collect 将流中的元素都收集到一个新的集合中去。

67e53caa1dcd4a658b0fb821dff0519a.png

        因 ShopType 具有顺序属性,所以可以使用集合的 sort 排序方法进行排序,它原本接收一个一个 Comparator 对象,其中定义排序规则

typeList.sort(new Comparator<ShopType>() {@Overridepublic int compare(ShopType o1, ShopType o2) {return o1.getSort() - o2.getSort();}
});

        我们使用 lambda 表达式来简化代码,根据返回值的不同来确定顺序,我根据上面代码来解释一下:

  • 返回负数,表示 o1 应排在 o2 之前;
  • 返回 0,表示 o1 和 o2 的顺序不变;
  • 返回整数,表示 o1 应排在 o2 之后;

        总的来说,就是按值的大小从小到大进行排序。

6f76335fb945403a8088947c41475298.png

2.缓存中不存在,到数据库中查询

//2.不存在,查询数据库
List<ShopType> typeList = list();
//2.1 不存在,返回错误信息
if (typeList == null || typeList.isEmpty()){return Result.fail("店铺类型不存在!");
}

        缓存不存在时,因为该类继承了 MyBatis-Plus 中的 ServiceImpl,所以可以直接调用其中写好的方法对数据库进行简单crud 操作,如我在此处调用的 list 方法获取 MySQL 数据库中所有的 ShopType 记录。 接着我们再对返回的数据进行判断,如果不存在则返回错误信息 "店铺类型不存在!"。

3.数据库中数据存在,存入 Redis 中,再返回

//2.2 存在,以 Json 格式写入 Redis
List<String> shopTypes = typeList.stream().map(JSONUtil::toJsonStr).collect(Collectors.toList());
stringRedisTemplate.opsForList().leftPushAll(CACHE_SHOPTYPE_KEY, shopTypes);
//排序
typeList.sort((o1, o2) -> o1.getSort() - o2.getSort());
//3.返回
return Result.ok(typeList);

        还是同样的操作,使用 stream 流将其内的 ShopType 对象再转回 Json 字符串的格式,其中的符号 “::” Java 中的方法引用操作符,是 lambda 表达式的一种简化方式,作用是调用 JSONUTIL 中的静态方法 toJsonStr。(没有最简,只有更简啊...

        之后存入 Redis 中,leftPushAll 方法将多个值从列表左侧加入列表中。最后返回我们从数据库中获取到的集合。(注意不是转换后的集合


最后的效果如下

59118b4ca8614b56a437cad556d6d803.png

d16a5ba9d4274a868dfdce7d8720bc71.png


(再次重申一遍ヽ(ー_ー)ノ)

(功能的代码实现仅代表个人思路,并不代表是最佳的实现方式)


2. 缓存更新策略

        使用缓存来提高读写效率本身也是一把双刃剑,有利也有弊。其随着使用同时也会产生一些并发的问题,如保证数据的一致性等问题。在更新时如果 MySQL 数据库中的数据也发生了变化时,Redis 中的缓存如果没有更新,则会造成数据的不一致问题。

        而我们有三种解决方法,分别是:

内存淘汰:因为 Redis 的数据是存储在内存中的,但内存有上限,到到达上限时,会自动触发该策略,所以不需要主动维护,但可以自己配置。当内存不足时自动淘汰部分数据以在下次查询时更新缓存但这种方法不能被我们控制,一致性很差

超时剔除:缓存数据添加一个 TTL 时间,到期后自动删除缓存,方便下次查询时更新缓存。该策略的一致性由我们设置的 TTL 时间有关,越短一致性越高,但维护成本也随之增加。所以这是一种最终一致,一致性一般

主动更新:我们主动的编写程序来在 MySQL 数据库更新的同时更新 Redis 中的缓存。但程序总会有出错的时候,所以只能说是具有好的一致性,且维护成本高

        根据不同业务需求,我们可以选择不同的解决方法。接下来我们针对第三中主动更新来进行代码实现的分析


2.1. 主动更新策略的实现

        想要实现主动更新,同样要有三种方式:

① Cache Aside:由缓存的调用者,在更新数据库的同时更新缓存。对于调用者来说较为复杂。

888bbb3a9c0f49628cbbaaba0aa55f4a.png

② Read/Write Through:将缓存与数据库整合为一个服务,由该服务来维护一致性。调用者只需调用该服务,无需关心缓存的一致性问题。简化了调用者的开发

80c0faf038c3499683a02c51d8b1a0fa.png

③ Write Behind Caching:调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库中,保证最终一致性。简化了调用者的开发。(异步:隔一段时间执行一次,将前面的所有操作进行一次批处理但维护成本高,当缓存出现问题时数据将丢失,数据的一致性和安全性难以保证

b3956666d1714691bf66b7398484b8d3.png

        经过对三种方法优缺点的分析,最常用的还是第一种方法。因其具有较高的自由度。但是第一种方法在实现细节的部分还存在问题

1.删除还是更新缓存

        在每次更新数据库时都需要更新缓存,这样会导致产生多次无效操作,而删除缓存就是在更新数据库时让缓存失效,这样即使多次进行更新数据库的操作,但对缓存操作的频率会更低。所以我们选择删除缓存

2.如何保证数据库与缓存的操作同时成功

        需要保证两个操作的原子性,同时成功或同时失败。在单体架构中,我们可以将对缓存数据库的操作放在同一个事务中确保其原子性;而在分布式系统中,我们可以利用 TCC 等分布式事务方案来确保其原子性。如我在 【微服务】黑马微服务保护与分布式学习笔记 提到的 Seata

3.先操作缓存还是数据库

        先删除缓存,再更新数据库时,如果在线程执行的过程中,删除缓存另一线程如果进行查询,会查询到数据库中的旧值并将其写入缓存中,此时再对数据库更新,结果导致缓存与数据库之间的数据不一致

b92a32d6c8784d459e6311ac27b8c6ad.png

        先更新数据库,再删除缓存时,如果缓存出现问题无法查询时,会查询数据库,而在写入缓存前,另一线程此时更新了数据库且将缓存进行了删除,之后在将原本线程中查询数据库得到的数据写入缓存中,这样也会导致缓存与数据库之间的数据的不一致。         

f861c42f49b84500bc79938536f5ef0b.png

        综上所述,第二种先更新数据库,再删除缓的策略更好。因为缓存的速度远远高于数据库,数据库的操作不太可能比缓存的操作还,所以出错的可能性更低


2.2. 改造查询商铺的缓存更新策略

        超时剔除:改造的方法很简单,只需在我们在数据库中查询到数据后添加到 Redis 中时为其设置有效期,就可完成超时剔除的功能。(在实现类的根据 id 查询商铺中进行改造

//2.2 数据库存在,写入 Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

        主动更新:我们需要将项目中关于更新商铺信息的方法进行改造,使其在更新数据库的同时进行删除缓存的操作。

c470b7505e774ebaa2a07378a38a3091.png

        在实现类中实现该方法,注意涉及到 MySQL Redis 两个数据库的操作且为单体架构,所以需要在方法上添加 @Transactional 注解保证事务的一致性

@Transactional
public Result update(Shop shop) {if (shop.getId() == null){return Result.fail("店铺id不能为空!");}//1.更新数据库updateById(shop);//2.删除缓存stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());return Result.ok();
}

        首先对传入的 id 进行校验,如果为空则返回错误信息 "店铺id不能为空!",然后根据前面的分析,我们选择先更新数据库MP中的 updateById 方法会自动获取对象内的 id 主键值),最后将 Redis 中的缓存删除,实现了主动更新策略。

效果展示

        超时剔除:可以看到,缓存中的商铺信息已经有了 TTL 有效期,超时会自动删除。

7f550e7bd7784a49a3763150cbba25da.png

        主动更新:因为更新商铺信息是管理端的功能,这里只有用户端,所以接下来会使用发送请求的方式进行更新操作演示。视频中使用的是 PostMan 工具,我使用的是 ApiFox,二者并无太大差异,所以选哪一个使用都可以٩( ´︶` )( ´︶` )۶

        可以看到请求为 PUT 请求表示更新操作,路径为 http://localhost:8081/shop 注意是 8081 而不是 8080。我们将商铺名称name)改为了 886茶餐厅

f2a5b77e95914df1a3987f888baa39ac.png

        发送后返回状态码 200 "success": true 信息表示修改成功

dc7a73daea1c4153aab163244e079adb.png

        可以看到,在刷新数据库和缓存的数据后,更新了数据库中的数据并删除了缓存

481734765ec9454f8c9e7eb82148e33b.png

c28bd626faa248cdb4defe2d8f89449b.png


3.缓存穿透/雪崩/击穿

         尽管我们已经解决了一些问题,总会有新问题的出现,就如标题所提到的穿透啊~雪崩啊~击穿啊~有点熟悉啊....这不微服务保护吗!!!∑(゚Д゚ノ)ノ)等等问题。当然有问题就要解决。


3.1. 缓存穿透

        在我们实现了上述功能后的前提下,如果用户请求的数据在缓存数据库都不存在,那么这些请求都会到达数据库。如果诸如此类的请求被并发的发送,那么会对数据库造成极大的压力。想要解决这个问题,有两个方法:

缓存空对象:在查询的数据在缓存与数据库中都不存在时,我们选则缓存一个 null 值来处理该数据的请求。这样就不会直接到达数据库了。

  • 优点:实现简单,维护方便。
  • 缺点:会造成额外的内存消耗(可以设置有效期解决);或在查询后再为该数据赋值,会造成短期的数据不一致(控制 TTL 的时间,一定程度上缓解)。

布隆过滤:客户端服务器之间加一层布隆过滤器没有什么是加一层解决不了的...),如果数据不存在则直接拒绝请求,反之则放行。其原理是基于算法实现的,将数据库中的数据通过某种 Hash 算法计算得到的 Hash 值再转换为二进制位保存在布隆过滤器内,数据的判断就是判断对应的位置是 0 或是 1,因此只是一种概率上的统计,并不绝对,有一定的穿透风险

  • 优点:占用空间小。
  • 缺点:实现较复杂(但 Redis 里已完成了实现);存在误判的可能。

代码实现

        原本的业务逻辑是在缓存与数据库中都查询不到返回错误信息,我们需要对其进行改造

421f5057367b4bf8957bb4059c57d29a.png

        将返回错误信息修改为将空值写入 Redis 中,最后再返回错误信息。且因为这样会使缓存中存在空值,所以在取出缓存时也需要加入非空校验才行。

public Result queryById(Long id) {//从 Redis 中查询是否存在缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);//1.存在,返回if (StrUtil.isNotBlank(shopJson)){Shop shop = JSONUtil.toBean(shopJson, Shop.class);return Result.ok(shop);}//判断命中的是否是空值if (shopJson != null){return Result.fail("店铺不存在!");}//2.不存在,查询数据库Shop shop = getById(id);//2.1 数据库不存在,将空值写入缓存后返回错误if (shop == null){stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return Result.fail("店铺不存在!");}//2.2 数据库存在,写入 RedisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);//3.返回return Result.ok(shop);
}

        进行修改的地方一共有两处:

    //2.1 数据库不存在,将空值写入缓存后返回错误if (shop == null){stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return Result.fail("店铺不存在!");}

        第一处:在缓存与数据库中都无法查询到时先将空值写入缓存中,之后再返回错误信息。其中的有效期设置的是 2 分钟

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

        第二处:如下图在经过 isNotBlank 方法判断后,空值会返回 false,所以我们还要在其后加上一个判断。又因为前面的方法已经判断过有值的情况了,所以只会有两种情况——null 或是空值,所以这里只需要判断如果不是 null,只能是空值的情况,所以 != null 时返回错误信息。如果是 null 的情况则会接着去查询数据库

b619eecf28b449a181c47cf9d01e08b5.png

        效果如下图所示,在第一次查询 id 0 的商铺信息时,会到数据库中去查询,因为缓存与数据库中都不存在该数据,所以会创建一个空值缓存,之后再次访问时,就会直接返回缓存中的这个空值

431bc895bd454f2498673644a6af7771.png

        最后,想要解决缓存穿透,还可以增加 id 的复杂度避免被猜测出 id 的规律、做好数据的基础格式校验、加强用户权限校验、做好热点参数的限流(sentinel 微服务流量控制组件,也在【微服务】黑马微服务保护与分布式学习笔记 中提到过)等方式,避免缓存穿透。


3.2. 缓存雪崩

        缓存雪崩是指在同一时间段大量的缓存 key 同时失效Redis 服务宕机,导致大量的请求直接到达数据库,为其带来巨大的压力。两者相比之下, Redis 宕机所带来的危害更大,所有的请求都会直接到达数据库

解决方法:

  • 给不同的 key 设置随机的 TTL 过期时间,使其不会同时失效
  • 利用 Redis 集群提高服务的可用性,一个服务宕机还有其他的服务可用。
  • 给缓存业务添加降级限流策略,在服务出现问题时,直接拒绝服务。(sentinel 中的服务熔断等
  • 给业务添加多级缓存,缓存的使用场景是多种多样的,不仅可以在赢应用层添加,还可以在浏览器反向代理 Nginx 服务器中添加等。

【该小结结束】

对,没错,就是这么短,这一小结时短而精悍的ᕦ(・ㅂ・)ᕤ。第一种方法只需要加一个有效期即可,剩下的方法要不是太高级了,要不就是与主要内容无关,所以这里不过多赘述。


3.3. 缓存击穿

        上一节讲的是大量 Key 失效所导致的严重后果,这一节讲的是部分 Key 缓存失效所产生的严重后果。缓存击穿问题也被称为热点 Key 问题,就是一个被高并发访问且缓存重建业务较复杂的 key 突然失效了,无数的请求会在瞬间给数据库带来巨大的冲击

常见的解决方案有两种方式:

① 互斥锁:同时操作时只允许一个线程写入缓存,其他线程只能不断地重试,直到锁被释放

ec54ab44d7a84491864c11309a1857ab.png

  • 优点:简单粗暴。
  • 缺点:需要互相等待,耗时长,性能差且存在死锁风险。

重视一致性

② 逻辑过期:从根本出发,因为设置了 TTL 过期时间才导致了大量 key 失效,所以我们不再设置过期时间,而是在添加缓存时将在当前时间基础上加上过期的时间所得到的时间保存,相当于永久储存,不会出现未命中的情况。

        线程1 获取到互斥锁后会开启一个新线程线程 2)去进行更新缓存操作,其本身会将旧缓存数据返回;如线程3 在更新前进行查询时缓存已过期所以会尝试获取互斥锁,不能获取到互斥锁,说明已经有线程(线程 1)正在更新,所以也会返回旧缓存数据;又如线程4 在更新完成释放锁后进行查询时缓存已更新未过期,所以会返回获取的新缓存数据

f0095729156a4844ae8c46e724690130.png

  • 优点:线程无需等待,性能
  • 缺点:不保证一致性,增加内存消耗且实现较复杂。

重视可用性

CAP定理:在分布式系统中,一致性可用性、分区容错性这三要素最大只能实现其二,不可能三者兼顾。(鱼与熊掌,不可兼得


3.3.1.互斥锁解决缓存击穿问题

        如上所述,我们还是在查询商户信息这一业务中实现(毕竟改来改去,老朋友了...,比较熟悉),在原本业务中查询缓存未命中时尝试获取互斥锁,并进行判断。而想要实现互斥锁并添加自己的逻辑,我们可以用到之前学习 Redis 中的 String 数据类型里的 setnx 方法,给一个 key 赋值,当且仅当该 key 不存在时。所以只能被第一个操作的线程设置,释放锁就是将其删除。但为了避免突发情况,如加锁后未能及时释放,我们也会为该值设置一个有效期避免出错

注意:本小结与视频所实现代码有所不同,但逻辑都一样,就是通过递归解决o(╥﹏╥)o

全部代码如下,仅代表个人的实现方式,并没有说是绝对正确的完美代码

_| ̄|● 如果有错误,请指出,我会及时回复并改正 _(:3」∠❀)_

我没有进行双重校验,如果有实现的代码,可以评论交流

@Override
public Result queryById(Long id) {//从 Redis 中查询是否存在缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);//1.存在,返回if (StrUtil.isNotBlank(shopJson)){Shop shop = JSONUtil.toBean(shopJson, Shop.class);return Result.ok(shop);}//判断命中的是否是空值if (shopJson != null){return Result.fail("店铺不存在!");}//实现缓存击穿改造//1)获取互斥锁String lockKey = "shop:lock:" + id;Shop shop = null;try {boolean lock = tryLock(lockKey);if(!lock) {//2)获取失败,休眠后重试Thread.sleep(50);return queryById(id);}//3)获取成功,继续查询、写入、释放锁...//2.不存在,查询数据库shop = getById(id);//模拟重建时的延时(可删除)Thread.sleep(200);//2.1 数据库不存在,将空值写入缓存后返回错误if (shop == null){stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return Result.fail("店铺不存在!");}//2.2 数据库存在,写入 RedisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);} catch (InterruptedException e) {throw new RuntimeException(e);} finally {//3)释放锁unLock(lockKey);}//3.返回return Result.ok(shop);
}//获取锁
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);
}

        想要使用 Redis 实现互斥锁解决缓存击穿问题,需要分四步进行:

1.获取互斥锁
2.获取失败,休眠一段时间后再次尝试获取
3.获取成功,继续查询数据库、写入 Redis 缓存
4.释放锁后返回商铺信息

 

        ● 第一步:编写获取锁释放锁两个方法。

//获取锁
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);
}

        其中的方法 setIfAbsent 相当于 Redis 中的 setnx 命令,写入的 value 任意,这里为 1,并为其设置有效期 10 秒。 

        同时 return BooleanUtil.isTrue(flag); 是因为返回类型boolean,为了防止包装类拆包时产生空值导致空指针异常,如果是 null 则方法会返回 false

        获取锁,将 key 定义为 "shop:lock:" + id 保证每个商铺信息都有不同的锁

String lockKey = "shop:lock:" + id;
boolean lock = tryLock(lockKey);

        ● 第二步:获取失败休眠一段时间后重试,这里进行递归,只有一个线程能够拿到并进行接下来的查询数据库写入缓存的操作,其余的线程只能不断地休眠后查询缓存·,直到拿到锁的线程完成操作释放锁后,其余线程可以成功查询到缓存并依情况返回对应信息。

if(!lock) {//2)获取失败,休眠后重试Thread.sleep(50);return queryById(id);
}

        ● 第三步:获取互斥锁成功后,可以接着之前的操作查询数据库:存在写入缓存)、不存在将空值写入缓存后返回错误信息

//3)获取成功,继续查询、写入、释放锁...//2.不存在,查询数据库
shop = getById(id);
//模拟重建时的延时
Thread.sleep(200);
//2.1 数据库不存在,将空值写入缓存后返回错误
if (shop == null){stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return Result.fail("店铺不存在!");
}
//2.2 数据库存在,写入 Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

        ● 第四步:最后将从获取互斥锁到最后写入 Redis 缓存中使用 try catch 包围,在 finally 中最终释放锁,因为这个是基于 Redis 实现的,并不是内置的锁机制,不能自动释放。因此在任何情况下,最后都会执行释放锁

——————————————————最终效果—————————————————

        测试使用 jmter 来模拟服务器被并发访问,不了解 jmter 可以搜索相关教程使用。

21521d0827674093a347675bd40987c5.png

640458120cf94a0fb11463ccf9d15417.png

        对应的配置如上图所示,设置了 1000 个线程,规定运行时间为 5 秒。

6a864336e3104fa6a1a97a7e4dc73f78.png

        运行后,可以看到只进行了一次数据库的查询,这就是那一个拿到锁的线程执行的

dd2d3972d843466d906a632fcd3a9391.png

        吞吐量也与理论值(1000 / 5)相差不多。

c8948af8f4b0471bb9a7192784909689.png

        注意:需要将 Redis 中的缓存先删除才可以运行,且如果吞吐量对不上,可能是因为没有清除前几次的运行数据所导致的。

485d121210ee4d40a2eee987e42daa51.png

最后的最后,再次重申一遍

仅代表个人的实现方式,并没有说是绝对正确的完美代码

如有疑义或想要纠正错误,请大家手下留情,留言评论,我会及时回复 |ू・ω・` )


3.3.2. 逻辑过期方式解决缓存击穿问题

        通过逻辑过期的方式来解决缓存击穿的问题,我们需要在业务自己进行判断是否超时。接下来,我们还是要对查询商铺信息进行改造。

(注意:本小结与视频所实现代码同样有所不同,但逻辑都一样o(╥﹏╥)o)

(全部代码如下,仅代表个人的实现方式,并没有说是绝对正确的完美代码)

(_| ̄|● 如果有错误,请指出,我会及时回复并改正 _(:3」∠❀)_)

我没有进行双重校验,如果有实现的代码,可以评论交流

@Resource
private StringRedisTemplate stringRedisTemplate;private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);@Override
public Result queryById(Long id) {//从 Redis 中查询是否存在缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);//1.存在,返回if (StrUtil.isBlank(shopJson)) {return null;}//命中,判断过期时间,把 json 反序列化为对象RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);LocalDateTime expireTime = redisData.getExpireTime();if (expireTime.isAfter(LocalDateTime.now())) {//未过期,返回店铺信息if (shop == null) {return Result.fail("店铺不存在!");} else {return Result.ok(shop);}}//过期,需要缓存重建,获取互斥锁String lockKey = LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);//获取成功,开启独立线程,开启缓存重建if (isLock) {CACHE_REBUILD_EXECUTOR.submit(() -> {try {//重建缓存this.saveShopToRedis(id, 20L);} catch (Exception e) {throw new RuntimeException(e);} finally {//释放锁unLock(lockKey);}});}//返回商户信息if (shop == null) {return Result.fail("店铺不存在!");} else {return Result.ok(shop);}
}public void saveShopToRedis(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.写入RedisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}//获取锁
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);
}

        将之前的代码复制粘贴一份注释掉,在这里因为逻辑发生了变化,所以我们要将之前的代码删除一部分,并且在查询不到缓存时返回 null。因为是只在查询热点数据时使用的方法,所以该数据一定是经过数据预热存在的。

public Result queryById(Long id) {//从 Redis 中查询是否存在缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);//1.存在,返回 nullif (StrUtil.isBlank(shopJson)) {return null;}/*此处编写对应逻辑*///返回商户信息if (shop == null) {return Result.fail("店铺不存在!");} else {return Result.ok(shop);}
}

        想要实现该功能,同样也需要经过三步实现:

1.命中缓存,获取其中的信息
2.未过期,直接返回店铺信息
3.过期,需要开启新线程在其中进行缓存重建后自身再返回店铺信息

 

//命中,判断过期时间,把 json 反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
ca4963a2dd684796bb9f4361b2518f57.png

RedisData

        ● 第一步:将缓存数据通过 JSONUtil 工具类转为对应的数据封装对象 RedisData,获取其中的 dataShop 类) 和 expireTime 过期时间,其中 data 因为是 Object 类(不能写死为 Shop 类,防止将来存在其他类需要缓存),所以需要再进行转换,此处不过多赘述

//未过期,返回店铺信息
if (expireTime.isAfter(LocalDateTime.now())) {if (shop == null) {return Result.fail("店铺不存在!");} else {return Result.ok(shop);}
}

        ● 第二步:判断过期时间,如果在当前时间之后(After),则代表还未过期,校验后直接返回店铺信息。

//过期,需要缓存重建,获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
//获取成功,开启独立线程,开启缓存重建
if (isLock) {CACHE_REBUILD_EXECUTOR.submit(() -> {try {//重建缓存this.saveShopToRedis(id, 20L);} catch (Exception e) {throw new RuntimeException(e);} finally {//释放锁unLock(lockKey);}});
}
//返回商户信息
return Result.ok(shop);
}

        ● 第三步:判断过期后,需要获取互斥锁,并在成功获取后开启独立线程,在其中进行缓存的重建,其余线程没有获取成功则继续往下校验商户信息返回

        其中 CACHE_REBUILD_EXECUTOR 是我们定义的大小为 10 的线程池,使用 submit 方法开启新线程,在其中进行重建缓存 (saveShopToRedis)。最后使用 try catch 包围代码块,finally 中释放锁。

重建缓存方法代码如下所示:

public void saveShopToRedis(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.写入RedisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

 ——————————————————最终效果—————————————————

         首先使用测试热点商铺信息数据写入缓存中,再将数据库 1 号商铺的商铺名进行更改后。等缓存过期后再运行 jmter

@Resource
private ShopServiceImpl shopService;@Test
void testSaveShop() throws InterruptedException {shopService.saveShopToRedis(1L, 10L);
}

        测试同样使用 jmter 来模拟服务器被并发访问,不了解 jmter 可以搜索相关教程使用。

cb4679a4488b418c9e62b6808c3e90be.png

0a177530f5bd4356b928bc3a9c05d070.png

        对应的配置如上图所示,设置了 100 个线程,规定运行时间为 1 秒。

c791de8fcc9a43ccb8da96aba1d0a278.png

e57d7e2760a24f73a82ca3dd84a6b3f8.png

        运行后,同样可以看到只进行了一次数据库的查询,且在线程组操作时返回的数据中的店铺名进行了更改。

898e9a7cf6e14cec81a3d9d58c17bae6.png

        吞吐量也与理论值(100 / 1)相差不多。

        注意:需要将 Redis 中的缓存先删除才可以运行,且如果吞吐量对不上,可能是因为没有清除前几次的运行数据所导致的。

最后的最后的最后,再次重申一遍

仅代表个人的实现方式,并没有说是绝对正确的完美代码

如有疑义或想要纠正错误,请大家手下留情,留言评论,我会及时回复 |ू・ω・` )


4. 缓存工具封装

        在业务中,我们不会像之前那样复杂的代码逻辑每次需要时去手动实现,而是将其封装为工具。但在封装的过程中,也会出现一些问题。(累了,毁灭吧ヽ(ー_ー)ノ

首先,附工具类完整代码

@Slf4j
@Component
public class CacheClient {@Resourceprivate 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 data = new RedisData();data.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));data.setData(value);stringRedisTemplate.opsForValue().setIfAbsent(key, JSONUtil.toJsonStr(data));}public <R, I> R queryWithPassThrough(String keyPrefix, I id, Class<R> type, Function<I, R> dbFallback, Long time, TimeUnit unit) {String key = keyPrefix + id;//从 Redis 中查询是否存在缓存String shopJson = stringRedisTemplate.opsForValue().get(key);//1.存在,返回if (StrUtil.isNotBlank(shopJson)){return JSONUtil.toBean(shopJson, type);}//判断命中的是否是空值if (shopJson != null){return null;}//不存在,根据 id 查询数据库R r = dbFallback.apply(id);if (r == null){//缓存写入空值stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);//返回错误信息return null;}//存在,写入 Redis 中this.set(key, r, time, unit);return r;}public <R,I> R queryWithLogicalExpire(String keyPrefix, I id, Class<R> type, Function<I,R> dbFallback, Long time, TimeUnit unit) {String key = keyPrefix + id;//从 Redis 中查询是否存在缓存String shopJson = stringRedisTemplate.opsForValue().get(key);//1.存在,返回if (StrUtil.isBlank(shopJson)) {return null;}//命中,判断过期时间,把 json 反序列化为对象RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);LocalDateTime expireTime = redisData.getExpireTime();//未过期,返回店铺信息if (expireTime.isAfter(LocalDateTime.now())) {return r;}//过期,需要缓存重建,获取互斥锁String lockKey = LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);//获取成功,开启独立线程,开启缓存重建if (isLock) {CACHE_REBUILD_EXECUTOR.submit(() -> {try {//查询数据库dbFallback.apply(id);//写入 Redisthis.setWithLogicalExpire(key, r, time, unit);} catch (Exception e) {throw new RuntimeException(e);} finally {//释放锁unLock(lockKey);}});}//返回商户信息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);}
}

        给工具类中有四个方法需要我们编写:

第一个方法:

public void set(String key, Object value, Long time, TimeUnit unit){stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}

        其作用是将缓存数据写入 Redis 同时设置有效期

第二个方法:

public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){RedisData data = new RedisData();data.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));data.setData(value);stringRedisTemplate.opsForValue().setIfAbsent(key, JSONUtil.toJsonStr(data));
}

        其作用是将缓存数据以逻辑过期的方式写入 Redis 中并设置有效期,这里就与我们在前面的 RedisData 类里的 data 属性为 Object 类型相呼应上了,可以缓存任何类型的数据,不会局限于 Shop 类。

第三个方法:

public <R, I> R queryWithPassThrough(String keyPrefix, I id, Class<R> type, Function<I, R> dbFallback, Long time, TimeUnit unit) {String key = keyPrefix + id;//从 Redis 中查询是否存在缓存String shopJson = stringRedisTemplate.opsForValue().get(key);//1.存在,返回if (StrUtil.isNotBlank(shopJson)){return JSONUtil.toBean(shopJson, type);}//判断命中的是否是空值if (shopJson != null){return null;}//不存在,根据 id 查询数据库R r = dbFallback.apply(id);if (r == null){//缓存写入空值stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);//返回错误信息return null;}//存在,写入 Redis 中this.set(key, r, time, unit);return r;
}

        利用泛型来确保可以接受任意类型对象,因为 id 的类型也可能不唯一,所以同样使用泛型,其中的 Function<I, R> 是函数式接口,我们可以将查询数据库的方法传入,其接收一个类型围为 I 的参数(这里指 id)并返回一个类型为 R 的结果(这里指 Shop)。其中需要在缓存中写入空值时可以使用我们前面写好的第一个方法

        将原本的解决缓存穿透问题的代码进行更改后就如上方代码所示,可以解决任意对象的缓存穿透问题,需要传入的参数依次为:key(前缀)、id、返回对象类型、查询数据库的方法、有效期、时间单位

第四个方法:

public <R,I> R queryWithLogicalExpire(String keyPrefix, I id, Class<R> type, Function<I,R> dbFallback, Long time, TimeUnit unit) {String key = keyPrefix + id;//从 Redis 中查询是否存在缓存String shopJson = stringRedisTemplate.opsForValue().get(key);//1.存在,返回if (StrUtil.isBlank(shopJson)) {return null;}//命中,判断过期时间,把 json 反序列化为对象RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);LocalDateTime expireTime = redisData.getExpireTime();//未过期,返回店铺信息if (expireTime.isAfter(LocalDateTime.now())) {return r;}//过期,需要缓存重建,获取互斥锁String lockKey = LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);//获取成功,开启独立线程,开启缓存重建if (isLock) {CACHE_REBUILD_EXECUTOR.submit(() -> {try {//查询数据库dbFallback.apply(id);//写入 Redisthis.setWithLogicalExpire(key, r, time, unit);} catch (Exception e) {throw new RuntimeException(e);} finally {//释放锁unLock(lockKey);}});}//返回商户信息return r;
}

        同样的,也是通过泛型来实现可以接收任意类型,用于解决缓存击穿问题,接收的参数与上一个方法相同,但这里需要使用逻辑过期来解决缓存击穿问题,所以可以调用我们前面写好的第二个方法

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.queryWithLogicalExpire(CACHE_SHOP_KEY,id,Shop.class,this::getById,CACHE_SHOP_TTL,TimeUnit.MINUTES);return Result.ok(shop);
}

        在实现类注入我们写好的工具类后可以如上所示分别调用相应方法解决不同的缓存问题。

注意:缓存击穿问题需要提前准备好数据预热

这里就不做演示了太累了...偷个懒


【上】完结

最后提前祝大家蛇年cf357312bb644ee6bcb4520b59fe085b.png大吉,万事如意

也感谢你们能够看到最后,这是我第一次内容写的这么详细、这么多

对我来说是一种挑战,对读者来说也是一个考验(毕竟文章有点长

_| ̄|(ェ:)… 在这里谢谢大家 …(:ェ)| ̄|_

\\\希望我们一起进步///

dd23c66392a14cb0a84727d2619be2f7.gif

相关文章:

Redis 实战篇 ——《黑马点评》(上)

《引言》 在进行了前面关于 Redis 基础篇及其客户端的学习之后&#xff0c;开始着手进行实战篇的学习。因内容很多&#xff0c;所以将会分为【 上 中 下 】三篇记录学习的内容与在学习的过程中解决问题的方法。Redis 实战篇的内容我写的很详细&#xff0c;为了能写的更好也付出…...

Redis的生态系统和社区支持

Redis的生态系统和社区支持 1. Redis 生态系统 1.1 Redis核心 Redis 是一个高性能的内存存储系统,支持丰富的数据结构(如字符串、列表、集合、哈希和有序集合)。它的核心提供了: 高性能数据存储:单线程模型支持每秒数百万级别的操作。多种数据结构:适用于多样化场景,如…...

基于C语言从0开始手撸MQTT协议代码连接标准的MQTT服务器,完成数据上传和命令下发响应(华为云IOT服务器)

文章目录 一、前言二、搭建开发环境三、网络编程基础概念科普3.1 什么是网络编程3.2 TCP 和 UDP协议介绍3.3 TCP通信的实现过程 四、Windows下的网络编程相关API介绍4.1 常用的函数介绍4.2 函数参数介绍4.3 编写代码体验网络编程 五、访问华为云IOT服务器创建一个产品和设备5.2…...

什么是 GPT?Transformer 工作原理的动画展示

大家读完觉得有意义记得关注和点赞&#xff01;&#xff01;&#xff01; 目录 1 图解 “Generative Pre-trained Transformer”&#xff08;GPT&#xff09; 1.1 Generative&#xff1a;生成式 1.1.1 可视化 1.1.2 生成式 vs. 判别式&#xff08;译注&#xff09; 1.2 Pr…...

IDEA 编辑器自动识别 Dockerfile 类型高亮和语法提示

在 IDEA 中&#xff0c;如果项目里面的只有一个 Dockerfile文件时&#xff0c;那么此时使用打开这个文件都是 ok 的&#xff0c;支持语法高亮和关键词提示。 如果我们有多个 Dockerfile 时&#xff0c; 比如 A_Dockerfile , B_Dockerfile , C_Dockerfile, 这个时候你会发现 IDE…...

AI知识库与用户行为分析:优化用户体验的深度洞察

在当今数字化时代&#xff0c;用户体验&#xff08;UX&#xff09;已成为衡量产品成功与否的关键指标之一。AI知识库作为智能客服系统的重要组成部分&#xff0c;不仅为用户提供快速、准确的信息检索服务&#xff0c;还通过用户行为分析&#xff0c;为产品优化提供了深度洞察。…...

什么是Redis哨兵机制?

大家好&#xff0c;我是锋哥。今天分享关于【什么是Redis哨兵机制&#xff1f;】面试题。希望对大家有帮助&#xff1b; 什么是Redis哨兵机制&#xff1f; 1000道 互联网大厂Java工程师 精选面试题-Java资源分享网 Redis 哨兵&#xff08;Sentinel&#xff09;机制是 Redis 提…...

JavaScript中如何创建对象

在JavaScript中&#xff0c;创建对象有多种方法。以下是几种常见的方式&#xff1a; 1. 对象字面量 这是最直接和常用的创建对象的方法。使用花括号 {} 包围一组键值对来定义一个对象。 let person {name: "John",age: 30,greet: function() {console.log("…...

2025:OpenAI的“七十二变”?

朋友们&#xff0c;准备好迎接AI的狂欢了吗&#xff1f;&#x1f680; 是不是跟我一样&#xff0c;每天醒来的第一件事就是看看AI领域又有什么新动向&#xff1f; 尤其是那个名字如雷贯耳的 OpenAI&#xff0c;简直就是AI界的弄潮儿&#xff0c;一举一动都牵动着我们这些“AI发…...

Mysql(MGR)和ProxySQL搭建部署-Kubernetes版本

一、Mysql(MGR) 1.1 statefulSet.yaml apiVersion: apps/v1 kind: StatefulSet metadata:labels:app: mysqlname: mysqlnamespace: yihuazt spec:replicas: 3serviceName: mysql-headlessselector:matchLabels:app: mysqltemplate:metadata:labels:app: mysqlspec:affinity:p…...

uni-app 多平台分享实现指南

uni-app 多平台分享实现指南 在移动应用开发中&#xff0c;分享功能是一个非常常见的需求&#xff0c;尤其是在社交媒体、营销活动等场景中。使用 uni-app 进行多平台开发时&#xff0c;可以通过一套代码实现跨平台的分享功能&#xff0c;涵盖微信小程序、H5、App 等多个平台。…...

Windows系统下载、部署Node.js与npm环境的方法

本文介绍在Windows电脑中&#xff0c;下载、安装并配置Node.js环境与npm包管理工具的方法。 Node.js是一个基于Chrome V8引擎的JavaScript运行时环境&#xff0c;其允许开发者使用JavaScript编写命令行工具和服务器端脚本。而npm&#xff08;Node Package Manager&#xff09;则…...

Typora 最新版本下载安装教程(附详细图文)

文章简介 在当今快节奏的信息化时代&#xff0c;简洁高效的写作工具成为了每位内容创作者的必需品。而Typora&#xff0c;这款备受推崇的 Markdown 编辑器&#xff0c;正是为此而生。它采用无缝设计&#xff0c;去除了模式切换、预览窗口等干扰&#xff0c;带来真正的实时预览…...

将一个变量声明为全局变量比如:flag1=false;然后通过jQuery使用js一个方法,将它设置为不可修改

方法 1&#xff1a;使用 Object.defineProperty 通过 Object.defineProperty 将全局变量设置为只读属性。 // 声明全局变量 var flag1 false;// 使用 Object.defineProperty 将其设置为不可修改 Object.defineProperty(window, flag1, {configurable: false, // 不允许删除属…...

找不到qt5core.dll无法运用软件的解决办法

在运行某些软件或游戏时&#xff0c;部分用户会遇到电脑显示由于找不到qt5core.dll&#xff0c;无法继续执行代码的问题&#xff0c;下面就给大家分享几种简单的解决方法&#xff0c;轻松恢复软件正常运行。 导致qt5core.dll缺失的原因 qt5core.dll是 Qt 应用程序框架的一部分…...

集线器,交换机,路由器,mac地址和ip地址知识记录总结

一篇很不错的视频简介 基本功能 从使用方面来说&#xff0c;都是为了网络传输的标识&#xff0c;和机器确定访问对象 集线器、交换机和路由器 常听到路由器和集线器&#xff0c;下面是区别&#xff1a; 集线器 集线器&#xff1a;一个简单的物理扩展接口数量的物理硬件。…...

Javascript算法——回溯算法(组合问题)

相关资料来自《代码随想录》&#xff0c;版权归原作者所有&#xff0c;只是学习记录 回溯 回溯模板 void backtracking(参数) {if (终止条件) {存放结果;return;}for (选择&#xff1a;本层集合中元素&#xff08;树中节点孩子的数量就是集合的大小&#xff09;) {处理节点…...

【人工智能机器学习基础篇】——深入详解无监督学习之聚类,理解K-Means、层次聚类、数据分组和分类

深入详解无监督学习之聚类&#xff1a;如K-Means、层次聚类&#xff0c;理解数据分组和分类 无监督学习是机器学习中的一个重要分支&#xff0c;旨在从未标注的数据中发现潜在的结构和模式。聚类&#xff08;Clustering&#xff09;作为无监督学习的核心任务之一&#xff0c;广…...

从0到机器视觉工程师(二):封装调用静态库和动态库

目录 静态库 编写静态库 使用静态库 方案一 方案二 动态库 编写动态库 使用动态库 方案一 方案二 方案三 总结 静态库 静态库是在编译时将库的代码合并到最终可执行程序中的库。静态库的优势是在编译时将所有代码包含在程序中&#xff0c;可以使程序独立运行&…...

Mybatis的set标签,动态SQL

set标签常用于update语句中&#xff0c;搭配if标签使用 set标签的作用 1、会动态加上前置set关键字 2、可以删除无关的逗号 示例代码&#xff1a; <update id"update">update employee<set><if test"name ! null">name #{name},<…...

机器学习-感知机-神经网络-激活函数-正反向传播-梯度消失-dropout

文章目录 感知机工作流程 神经网络区别各种各样的神经网络 激活函数激活函数类型Sigmoid 函数ReLU函数Leaky ReLU 函数Tanh 函数 正向传播反向传播梯度消失(gradient vanish)如何解决 Dropout使用 PyTorch实战神经网络算法(手写MNIST数字识别)viewsoftmax和log-softmaxcross-en…...

HTML5 时间选择器详解

HTML5 的时间选择器&#xff08;Time Picker&#xff09;允许用户通过图形界面选择时间。它通过设置 <input> 元素的 type 属性为 time 来实现。以下是关于 HTML5 时间选择器的详细讲解。 HTML5 时间选择器详解 1. 基本用法 要创建一个时间选择器&#xff0c;只需使用…...

SSM-Spring-AOP

目录 1 AOP实现步骤&#xff08;以前打印当前系统的时间为例&#xff09; 2 AOP工作流程 3 AOP核心概念 4 AOP配置管理 4-1 AOP切入点表达式 4-1-1 语法格式 4-1-2 通配符 4-2 AOP通知类型 五种通知类型 AOP通知获取数据 获取参数 获取返回值 获取异常 总结 5 …...

小红书笔记详情API分析及读取深度探讨

一、引言 随着社交电商的蓬勃发展&#xff0c;小红书凭借其独特的社区氛围和强大的内容生产能力&#xff0c;吸引了大量用户和开发者。对于开发者而言&#xff0c;小红书提供的API接口是获取其丰富内容的重要途径。本文将对小红书笔记详情API进行深入分析&#xff0c;并详细阐…...

【Yarn】通过JMX采集yarn相关指标的Flink任务核心逻辑

通过JMX采集yarn相关指标的Flink任务核心逻辑 文章目录 通过JMX采集yarn相关指标的Flink任务核心逻辑通过jmx接口查询Yarn队列指标请求JMX配置项核心处理流程输出到kafka格式通过jmx接口查询ResourceManager核心指标请求JMX读取配置yaml配置文件核心处理逻辑输出Kafka格式彩蛋 …...

【网络安全】PostMessage:分析JS实现XSS

前言 PostMessage是一个用于在网页间安全地发送消息的浏览器 API。它允许不同的窗口&#xff08;例如&#xff0c;来自同一域名下的不同页面或者不同域名下的跨域页面&#xff09;进行通信&#xff0c;而无需通过服务器。通常情况下&#xff0c;它用于实现跨文档消息传递&…...

基于springboot的码头船只货柜管理系统 P10078

项目说明 本号所发布的项目均由我部署运行验证&#xff0c;可保证项目系统正常运行&#xff0c;以及提供完整源码。 如需要远程部署/定制/讲解系统&#xff0c;可以联系我。定制项目未经同意不会上传&#xff01; 项目源码获取方式放在文章末尾处 注&#xff1a;项目仅供学…...

SpringMVC(二)原理

目录 一、配置Maven&#xff08;为了提升速度&#xff09; 二、流程&&原理 SpringMVC中心控制器 完整流程&#xff1a; 一、配置Maven&#xff08;为了提升速度&#xff09; 在SpringMVC&#xff08;一&#xff09;配置-CSDN博客的配置中&#xff0c;导入Maven会非…...

计算机网络:网络层知识点及习题(一)

网课资源&#xff1a; 湖科大教书匠 1、概述 网络层实现主机到主机的传输&#xff0c;主要有分组转发和路由选择两大功能 路由选择处理机得出路由表&#xff0c;路由表再生成转发表&#xff0c;从而实现分组从不同的端口转发 网络层向上层提供的两种服务&#xff1a;面向连接…...

题解:A. Noldbach Problem

问题描述 Nick 对素数非常感兴趣。他阅读了有关 Goldbach Problem 的内容&#xff0c;了解到每个大于 2 的偶数都可以表示为两个素数的和。于是他决定创造一个新问题&#xff0c;称为 Noldbach Problem。 Noldbach 问题的定义如下&#xff1a; 如果一个素数 $p$ 满足&#x…...

ESP32S3 + IDF 5.2.2 扫描WiFi

ESP32S3 IDF 5.2.2 扫描WiFi 目录 1 资料 2 通过Wi-Fi库扫描附近的网络 2.1 通过idf命令创建工程 2.2 编写测试用例 2.3 优化测试用例 3 小结 1 资料 在ESP平台基于IDF开发WiFi相关功能&#xff0c;主要就是基于IDF的Wi-Fi库进行二次开发。可供参考的官方资料&#xff…...

鸿蒙开发汇总

写在前面 汇总贴&#xff0c;整理在开发过程中遇到的有趣的、不太好解决的问题&#xff0c;记录一下思考的过程及自己的解决方案。 只做为技术分享&#xff0c;转载请标明出处。 ArkTs-this指向问题 ArkTs-Text组件长度计算不对的问题...

PDF阅读和编辑工具——xodo

本文给大家推荐一款好用的PDF阅读和编辑工具——xodo,一款免费的跨平台PDF阅读、编辑、批注工具。 注意xodo PDF Reader是免费的&#xff0c;xodo PDF Studio是收费的&#xff0c;但是xodo PDF Studio功能多很多。...

QT-------------自定义插件和库

以下是一个使用 Qt 实现图表交互操作的示例&#xff0c;涵盖了自定义图表视图类、不同类型的柱状图和饼图等内容。 实现思路 自定义图表视图类&#xff1a;创建一个从 QChartView 派生的自定义类&#xff0c;用于处理图表的交互操作。主窗口设计初始化&#xff1a;在主窗口中…...

《云原生安全攻防》-- K8s安全配置:CIS安全基准与kube-bench工具

在本节课程中&#xff0c;我们来了解一下K8s集群的安全配置&#xff0c;通过对CIS安全基准和kube-bench工具的介绍&#xff0c;可以快速发现K8s集群中不符合最佳实践的配置项&#xff0c;及时进行修复&#xff0c;从而来提高集群的安全性。 在这个课程中&#xff0c;我们将学习…...

PCA降维算法详细推导

关于一个小小的PCA的推导 文章目录 关于一个小小的PCA的推导1 谱分解 (spectral decomposition)2 奇异矩阵(singular matrix)3 酉相似(unitary similarity)4 酉矩阵5 共轭变换6 酉等价7 矩阵的迹的计算以及PCA算法推导8 幂等矩阵(idempotent matrix)9 Von Neumanns 迹不等式 [w…...

C++ 基础思维导图(一)

目录 1、C基础 IO流 namespace 引用、const inline、函数参数 重载 2、类和对象 类举例 3、 内存管理 new/delete 对象内存分布 内存泄漏 4、继承 继承权限 继承中的构造与析构 菱形继承 1、C基础 IO流 #include <iostream> #include <iomanip> //…...

Excel文件恢复教程:快速找回丢失数据!

Excel文件恢复位置在哪里&#xff1f; Excel是微软开发的电子表格软件&#xff0c;它为处理数据和组织工作提供了便捷。虽然数据丢失的问题在数字时代已经司空见惯&#xff0c;但对于某些用户来说&#xff0c;恢复未保存/删除/丢失的Excel文件可能会很困难&#xff0c;更不用说…...

人脑处理信息的速度与效率:超越计算机的直观判断能力

人脑处理信息的速度与效率&#xff1a;超越计算机的直观判断能力 关键词&#xff1a; #人脑信息处理 Human Brain Information Processing #并行处理 Parallel Processing #视觉信息分析 Visual Information Analysis #决策速度 Decision Speed #计算机与人脑比较 Computer v…...

Spring Boot 中的 classpath详解

Spring Boot 中的 classpath 详解 在开发 Spring Boot 应用时&#xff0c;理解 classpath 的概念对于配置、资源管理以及构建项目非常重要。特别是当我们使用 Maven 打包工具时&#xff0c;项目的资源文件在不同的阶段会被放置到不同的目录。本文将深入探讨 Spring Boot 中的 …...

标准库以及HAL库——按键控制LED灯代码

按键控制LED本质还是控制GPIO,和点亮一个LED灯没什么区别 点亮一个LED灯&#xff1a;是直接控制输出引脚&#xff0c;GPIO初始化推挽输出即可 按键控制LED&#xff1a;是按键输入信号从而控制输出引脚&#xff0c;GPIO初始化推挽输出一个引脚以外还得加一个GPIO上拉输入 但是…...

Spring Cloud (四、服务熔断降级-HyStrix)

spring cloud 概述 分布式系统面临的问题 复杂分布式体系结构中的应用程序有数十个依赖关系&#xff0c;每个依赖关系在某些时候将不可避免地失败。 服务雪崩 多个微服务之间调用的时候&#xff0c;假设微服务A调用微服务B和微服务C&#xff0c;微服务B和微服务C又调用其它的…...

【C语言】如何插入并播放音频文件

在 C 语言中处理音频文件可以是一个有趣且挑战性的任务&#xff0c;尤其是在嵌入式开发或多媒体程序开发中。尽管 C 语言本身并不直接支持音频处理&#xff0c;但可以通过集成第三方库来处理音频文件的解码和播放。本篇博客将介绍如何在 C 语言中插入并播放音频文件&#xff0c…...

图书项目:整合SSM

步骤&#xff1a; pom文件&#xff1a;导包&#xff0c;写入静态资源导出配置&#xff0c;连接数据库 建包&#xff1a;controller dao/mapper pojo service 配置文件&#xff1a;mybatis-config.xml applicationContext.xml&#xff08;Spring的配置文件&#xff09; datab…...

C#OPC(下)

安装 OPC UA SDK 通过 NuGet 包管理器&#xff0c;在 Visual Studio 中右键单击项目名称&#xff0c;选择 “管理 NuGet 程序包”&#xff0c;在搜索框中输入 “OPCFoundation.NetStandard.Opc.Ua”&#xff0c;找到对应的 OPC UA SDK 包后点击 “安装”&#xff0c;将其集成到…...

STLG_01_05_程序设计C语言 - 数据类型概念解析

一、典型例题 下面这些示例&#xff0c;将能够更熟练地运用C语言中的数据类型&#xff0c;加深对数据存储和处理的理解&#xff1a; 示例&#xff1a;确定变量a、b、c和d的数据类型&#xff0c;并说明它们的存储大小和取值范围 short int a -1000; unsigned int b 50000; f…...

使用工厂+策略模式实现去除繁琐的if else

使用工厂策略模式实现去除繁琐的if else 在中间有一个mapstruct的bug&#xff0c;即在修改实体类中的类型时&#xff0c;或者修改属性名字&#xff0c;mapstruct都无法进行转换&#xff0c;会报错&#xff0c;此时需要maven cleanmaven compile即可 前言 在这次的开发中&#…...

Apache MINA 反序列化漏洞CVE-2024-52046

漏洞描述&#xff1a; Apache MINA 是一个功能强大、灵活且高性能的网络应用框架。它通过抽象网络层的复杂性&#xff0c;提供了事件驱动架构和灵活的 Filter 链机制&#xff0c;使得开发者可以更容易地开发各种类型的网络应用。 Apache MINA 框架的 ObjectSerializationDeco…...

SpringSpringBoot常用注解总结

Spring&SpringBoot常用注解总结 1.SpringBootApplication 这个注解是 Spring Boot 项目的基石&#xff0c;创建 SpringBoot 项目之后会默认在主类加上。 SpringBootApplication public class SpringSecurityJwtGuideApplication {public static void main(java.lang.Str…...

设计模式 创建型 原型模式(Prototype Pattern)与 常见技术框架应用 解析

原型模式&#xff08;Prototype Pattern&#xff09;是一种创建型设计模式&#xff0c;其核心思想在于通过复制现有的对象&#xff08;原型&#xff09;来创建新的对象&#xff0c;而非通过传统的构造函数或类实例化方式。这种方式在需要快速创建大量相似对象时尤为高效&#x…...