开篇导读
通过实战篇的学习来理解各种 Redis 的使用,实战篇要学习的内容如下:
- 短信登录
这一块我们会使用 Redis 共享 session 来实现
- 商户查询缓存
通过本章节,我们会理解缓存击穿、缓存穿透、缓存雪崩等问题,从而对这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容
- 优惠卷秒杀
通过本章节,我们可以学会 Redis 的计数器功能, 结合 Lua 完成高性能的 Redis 操作,同时学会 Redis 分布式锁的原理,包括 Redis 的三种消息队列
- 附近的商户
利用 Redis 的 GeoHash 来完成对于地理坐标的操作
- UV 统计
主要是使用 Redis 的 HyperLog 来完成统计功能
- 用户签到
使用 Redis 的 BitMap 完成数据统计功能
- 好友关注
基于 Set 集合的关注、取消关注、共同关注等等功能
- 达人探店
基于 List 来完成点赞列表的操作,同时基于 SortedSet 来完成点赞的排行榜功能

一、短信登录
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 对外提供更好的服务。

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

启动项目后,在浏览器访问 http://localhost:8081/shop-type/list , 如果可以看到数据则证明运行没有问题
注意不要忘了修改 application.yaml 文件中的 MySQL、Redis 地址信息
1.4、导入前端工程
在资料中提供了一个 nginx 文件夹:

将其复制到任意目录,要确保该目录不包含中文、特殊字符和空格,例如:D:\lesson\nginx-1.18.0
1.5、运行前端项目
在 nginx 所在目录下打开一个 CMD 窗口,输入命令:
start nginx.exe
打开 Chrome 浏览器,在空白页面点击鼠标右键,选择检查,即可打开开发者工具,然后打开手机模式:

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

2、基于 Session 实现登录流程
发送验证码:
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
短信验证码登录、注册:
用户将验证码和手机号进行输入,后台从 Session 中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到 Session 中,方便后续获得当前登录信息
校验登录状态:
用户在请求时候,会从 cookie 中携带者 JsessionId 到后台,后台通过 JsessionId 从 Session 中拿到用户信息,如果没有 session 信息,则进行拦截,如果有 session 信息,则将用户信息保存到 ThreadLocal 中,并且放行

3、实现发送短信验证码和登录功能
3.1、发送短信验证码
页面流程

具体代码如下:
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、短信验证码登录和注册

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 的运行原理

当用户发起请求时,会访问我们向 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 就不一样,所以可以通过这种方式来做到线程隔离


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 的共享了
但是这种方案具有两个大问题:
- 每台服务器中都有完整的一份 session 数据,服务器压力过大
- session 拷贝数据时,可能会出现延迟
所以后来采用的方案都是基于 Redis 来完成,把 session 换成 Redis,Redis 数据本身就是共享的,就可以避免 session 共享的问题了

7、Redis 代替 session 的业务流程
7.1、设计 key 的结构
首先我们要思考一下利用 Redis 来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用 String,或者是使用哈希,如下图。如果使用 String,注意它的 value,会多占用一点空间;如果使用哈希,则它的 value 中只会存储其数据本身。如果不是特别在意内存,其实使用 String 就可以啦。
保存验证码时,可以直接用 String,因为验证码只是一个六位数字。
保存用户信息时,建议使用哈希,因为可以更方便地对每个字段进行修改,且占用内存更少

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,然后向用户发送验证码。

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

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

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

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、为什么要使用缓存
一句话:因为速度快、好用
缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
实际开发过程中,企业的数据量少则几十万、多则几千万,这么大数据量,如果没有缓存来作为 “避震器”,系统是几乎撑不住的,所以企业会大量运用到缓存技术
但是缓存也会增加代码复杂度和运营的成本:

1.2、如何使用缓存
实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与 Redis 中的缓存并发使用
浏览器缓存:主要是存在于浏览器端的缓存
应用层缓存:可以分为 Tomcat 本地缓存,比如之前提到的 map,或者是使用 Redis 作为缓存
数据库缓存:在数据库中有一片空间是 buffer pool,增改查数据都会先加载到 MySQL 的缓存中
CPU 缓存:当代计算机最大的问题是 cpu 性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了 CPU 的 L1、L2、L3 级的缓存

2、添加商户缓存
在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库那肯定慢啊,所以我们需要增加缓存
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
// 原来是直接查询数据库
// return Result.ok(shopService.getById(id));
return shopService.queryById(id);
}
缓存模型和思路
标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入 Redis。

代码思路:如果缓存有,则直接返回,如果缓存不存在,则查询数据库,然后存入 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);
}
练习:给店铺类型查询业务添加缓存
店铺类型在首页和其它多个页面都会用到,如图:

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

修改后的代码:
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 会将超时的数据进行删除,方便咱们继续使用缓存
- 主动更新:我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题

主动更新策略
主动更新的业务实现目前在企业中有三种模式:
- Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案
-
Read / Write Through Pattern:由系统本身完成,数据库与缓存的问题交由系统本身去处理
-
Write Behind Caching Pattern:调用者只操作缓存,其他线程去异步处理数据库,实现最终一致

方案一对于调用者可能有些复杂,但是可以去人为控制它
方案二要开发起来成本很高,维护这样一个服务也是比较复杂的,市面上也不太好找到现成的这样一个服务
方案三与方案二类似,效率比较高,但要维护这样一个异步更新的任务是比较复杂的,需要实时监控缓存中数据的变更。其次难以保证一致性,如果缓存已经执行了很多次更新,但还没触发数据库的更新,这段时间内缓存和数据库一直处于不一致的状态。而且如果此时缓存出现了宕机,数据就丢失了。所以其一致性和可靠性存在一定问题
综上所述考虑使用方案一,但是方案一需要开发者自己编码,这里有几个问题
操作缓存和数据库时有三个问题需要考虑:
1、删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
如果采用第一个方案,假设我们每次操作数据库后都更新缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来
2、如何保证缓存与数据库的操作的同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用 TCC 等分布式事务方案
3、先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存
假设初始化时缓存和数据库的数据都是 10
先看第一种方案的正常情况:

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

方案二的正常情况:

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

综上所述,我们应当是先操作数据库,再删除缓存。
总结缓存更新策略的最佳实践方案:
- 低一致性需求:使用 Redis 自带的内存淘汰机制
- 高一致性需求:主动更新,并以超时剔除作为兜底方案
- 读操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库,并写入缓存,设定超时时间
- 写操作:
- 先写数据库,然后再删除缓存
- 要确保数据库与缓存操作的原子性
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 中。假设布隆过滤器判断这个数据不存在,则直接返回
这种方式优点在于节约内存空间,但可能存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突

4.2、编码解决商品查询的缓存穿透问题
核心思路如下:
在原来的逻辑中,我们如果发现这个数据在 MySQL 中不存在,直接就返回 404 了,这样是会存在缓存穿透问题的
现在的逻辑应该改成:如果这个数据不存在,我们不会返回 404,而是会把这个数据写入到 Redis 中,并且将 value 设置为空字符串。当再次发起查询时,如果缓存命中,还要先判断这个 value 是否是空字符串,如果是空字符串,则是之前写入的数据,证明是缓存穿透数据,如果不是则直接返回数据。

@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 集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存

6、缓存击穿问题
6.1、问题介绍及解决思路
缓存击穿问题也叫热点 Key 问题,就是一个被高并发访问并且缓存重建业务较复杂的 key 突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
逻辑分析:假设线程 1 在查询缓存之后,本来应该去查询数据库然后把这个数据重新加载到缓存的,此时只要线程 1 走完这个逻辑,其他线程就都能从缓存中加载这些数据了。但是假设在线程 1 没有走完的时候,后续的线程 2、线程 3、线程 4 同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大

常见的解决方案有两种:
- 互斥锁
- 逻辑过期
解决方案一、使用锁来解决:
因为锁能实现互斥性。假设线程过来,只能一个一个的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用 tryLock 方法 + double check 来解决这样的问题。
假设现在线程 1 过来访问,它查询缓存没有命中,但是此时它获得到了锁的资源,那么线程 1 就会独自去执行逻辑。假设现在线程 2 过来,线程 2 在执行过程中,并没有得到锁,那么线程 2 就可以进行到休眠,直到线程 1 把锁释放后,线程 2 获得锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。

解决方案二、逻辑过期方案
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对 key 设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题。但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 Redis 的 value 中,注意:这个过期时间并不会直接作用于 Redis,而是我们后续通过逻辑去处理。假设线程 1 去查询缓存,然后从 value 中判断出来当前的数据已经过期了,此时线程 1 去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程会开启一个新线程去进行以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程 1 直接先返回过期数据。假设现在线程 3 过来访问,由于线程 2 持有着锁,所以线程 3 无法获得锁,线程 3 也直接返回过期数据,只有等到新开的线程 2 把重建数据构建完后,其他线程才能返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

两种方案进行对比
互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
逻辑过期方案:线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦

6.2、利用互斥锁解决缓存击穿问题
核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
如果获取到了锁的线程,再去进行查询,查询后将数据写入 Redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿

操作锁的代码:
核心思路就是利用 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 中的数据,如果过期则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。

如何封装数据:因为现在 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);
}