🐢

黑马点评上

8205 字
41 分钟
黑马点评上

项目总览#

image.webp
image.webp

短信登录#

发送短信#

image.webp
image.webp

Controller#

  • 请求 URLhttp://localhost<8080>/api/user/code?phone=13292387557
  • 请求方法 POST 无返回值
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private IUserService userService;
@Resource
private IUserInfoService userInfoService;
//发送手机验证码
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
return userService.sendCode(phone,session);
//返回保存session
}

Service#

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public Result sendCode(String phone, HttpSession session) {
//校验手机号的有效性借助
if(RegexUtils.isPhoneInvalid(phone)){
//如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//符合生成6位数验证吗
String code = RandomUtil.randomNumbers(6);
//将验证码存入session
session.setAttribute("code",code);
//发送验证码
log.debug("发送验证码成功,验证码为:{}",code);
return Result.ok();
}
}

生成验证码,使用hutoool工具提供的RandomUtil,随机生成六位验证码并保存到session,这一步是为了方便后续把session里面存储的生成的验证码和后来用户输入的验证码作比较,只有正确才可以登录。

登录注册#

Controller#

  • 请求 URLhttp://localhost<8080>/api/user/login
  • 请求方法POST
  • 负载(请求参数)为{“phone”: “13292387557”,“code”: “222222”}json格式
@Data
public class LoginFormDTO {
private String phone;
private String code;
private String password;
}
//登录功能
// 登录参数,包含手机号、验证码;或者手机号、密码
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
return userService.login(loginForm,session);
}

Service#

用户将验证码和手机号进行输入,后台从session中拿到当前验证码,和用户输入的验证码进行校验

//登录注册功能
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone=loginForm.getPhone();
//校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//校验验证码
//已经存到session中的验证码
Object cacheCode =session.getAttribute("code");
//请求验证码
String code =loginForm.getCode();
if(cacheCode == null|| !cacheCode.toString().equals(code)){
//不一致返回
return Result.fail("验证码错误");
}
  • 如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,

  • 用户不存在,则为用户创建账号信息,保存到 数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息

// 一致的话根据手机号查询用户select* from tb_user where phone =?
User user= query().eq("phone",phone).one();
//用户是否存在
if(user==null){
// 不存在向数据库插入新用户并保存
user=createUserWithPhone(phone);
}
// 保存用户信息到sessin
session.setAttribute("user",
//补充属性BeanUtil.copyProperties
BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
//创建用户对象插入数据
private User createUserWithPhone(String phone){
User user=new User();
user.setPhone(phone); user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
//保存用户
save(user);
return user;
}

登录校验#

拦截器#

拦截器实现HandlerInterceptor接口,相当于SpringAOP类似preHandle

  • 用户在请求时从cookie中携带着sessionId到后台
  • 后台通过sessionId从session中拿到用户信息,如果没有session信息,则进行拦截,
  • 如果有session信息,则将用户信息保存到threadLocal中,并且放行
//拦截器登录校验
public class LoginInterceptor implements HandlerInterceptor {
//alter+insert实现controller方法前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//从requst获取session
HttpSession session = request.getSession();
//根据session获取用户
Object user = session.getAttribute("user");
//判断用户是否存在
if(user == null){
//不存在,拦截返回401状态码
response.setStatus(401);
return false;
}
//存在,把用户信息存入threadlocal
//session利用beanutil属性拷贝去除敏感信息,转成UserDTO类
UserHolder.saveUser((UserDTO)user);
//放行
return true;
}
//方法后执行
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
UserHolder.removeUser();
}
}
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(;
}
}
  • 通过实现WebMvcConfigurer重写addInterceptor方法(InterceptorRegister类 registry)
  • registry.addInterceptor(new 类名).excludePathPatterns指定请求路径
public class Mvcconfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}

Controller#

//登录校验记录用户信息
@GetMapping("/me")
public Result me(){
//获取当前用户信息
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}

Tomcat#

image.webp
image.webp

核心原理

  • 线程池复用:Tomcat 用固定大小的线程池处理请求,避免了为每个请求创建新线程的开销,提升了并发能力。

  • 线程隔离:每个请求都由一个独立的工作线程处理,因此可以使用 ThreadLocal 来存储线程级别的上下文(如用户信息、请求 ID),实现线程安全的数据隔离。

  • IO 与业务分离:监听线程只负责建立连接和接收数据,不处理业务逻辑,保证了 Tomcat 能高效地接收大量并发连接。

Session共享#

image.webp
image.webp

  • 每个tomcat中都有一份属于自己的session

  • 第一次访问第一台tomcat的session

  • 第二次这访问第二台tomcat,在第二台服务器上,没有第一台器存放的session,登录拦截功能就会出现问题session拷贝每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,

  • 每台服务器中都有完整的一份session 数据,服务器压力过大。

  • session拷贝数据时,可能会出现延迟

  • redis数据本身就是共享

Redis实现#

image.webp
image.webp

  • Redis是个公共空间,key设计要求唯一性、安全性、易携带性
  • 唯一就是避免value之间相互覆盖,安全性则是key的命名不要泄露用户重要信息,以phone作为key
  • 易携带性是其他接口可以轻松携带这个key访问Redis中存储的值,

image.webp
image.webp

验证码#

stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY +phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES); //TTL设置过期时间

public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {//发送验证码
//引入redis配置类
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone) {
//校验手机号的有效性借助
if(RegexUtils.isPhoneInvalid(phone)){
//如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//符合生成6位数验证吗
String code = RandomUtil.randomNumbers(6);
//转换成存入redis数据共享 以手机号作为key 工具类定义过期时间
stringRedisTemplate.opsForValue()
.set(RedisConstants.LOGIN_CODE_KEY +phone, code,
LOGIN_CODE_TTL, TimeUnit.MINUTES); //TTL设置过期时间
//发送验证码
log.debug("发送验证码成功,验证码为:{}",code);
return Result.ok();
}

登录注册#

校验验证码

  • 校验用户提交的手机号和验证码,是否一致,如果一致,

  • 根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis

保存用户信息

  • BeanUtil.copyProperties(user, UserDTO.class);将数据库表user对象转换为UserDTO对象

  • BeanUtil.beanToMap(userDTO, new HashMap<>(),
    CopyOptions.create()
    .setIgnoreNullValue(true)
    .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()))

    将User对象转成HashMap存储

  • stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);

  • 生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问并设置token过期时间

//登录注册功能
@Override
public Result login(LoginFormDTO loginForm) {
String phone=loginForm.getPhone();
//校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//校验验证码
//获取Redis中的key
String cacheCode=stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
String code =loginForm.getCode();//请求验证码
if(cacheCode == null|| !cacheCode.equals(code)){
//不一致返回
return Result.fail("验证码错误");
}
// 一致的话根据手机号查询用户select* from tb_user where phone =?
User user= query().eq("phone",phone).one();
//用户是否存在
if(user==null){
// 不存在向数据库插入新用户并保存
user=createUserWithPhone(phone);
}
// 保存用户信息到Redis
//1.生成随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
//2.将user对象转换为UserDTO对象
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//将User对象转成HashMap存储
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
/4./存储到redis
String tokenKey=LOGIN_USER_KEY+token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
//5.设置token有效期 stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
return Result.ok(token);
}
}

拦截器#

引入Redis此为自定义工具类不受Spring监管不能用@Resourse利用构造方法

从redis中取出token对应的value用户信息,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行

  • stringRedisTemplate.opsForHash().entries获取 用户对象用hashMap承接并判断是否为空
  • BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);Hash转换为UserDTO对象
  • 刷新token时stringRedisTemplate.expire
//拦截器登录校验
public class LoginInterceptor implements HandlerInterceptor {
//引入Redis
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(StringRedisTemplate stringRedisTemplate){
this.stringRedisTemplate=stringRedisTemplate;
}
//alter+insert实现controller方法前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取请求头的token
String token =request.getHeader("authorization");
//判断用户是否存在
if(token == null){
//不存在,拦截返回401状态码
response.setStatus(401);
return false;
}
// 基于Redis获取用户(获取全部key和value)
Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
.entries(RedisConstants.LOGIN_USER_KEY + token);
//判断用户是否存在
if(userMap.isEmpty()){
//不存在拦截401状态码
response.setStatus(401);
return false;
}
//将查到的Hash数据转成USERDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//存在,把用户信息存入threadlocal
//session利用beanutil属性拷贝去除敏感信息,转成UserDTO类
UserHolder.saveUser(userDTO);
//刷新token时间
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//放行
return true;
}

拦截状态刷新#

拦截器实现HandlerInterceptor接口,相当于SpringAOP类似preHandle

  • 通过实现WebMvcConfigurer重写addInterceptors方法(InterceptorRegister registry)
  • registry.addInterceptor(new 类名).excludePathPatterns指定请求路径

image.webp
image.webp

  • 使用对应路径的拦截,同时刷新登录token令牌的存活时间,只是拦截需要被拦截的路径
  • 假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效

image.webp
image.webp

//拦截器登录校验
public class RefreshTokenInterceptor implements HandlerInterceptor {
//引入Redis因为此为自定义工具类不受Spring监管不能用@Resourse利用构造方法
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate){
this.stringRedisTemplate=stringRedisTemplate;
}
//alter+insert实现controller方法前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取请求头的token
String token =request.getHeader("authorization");
//判断用户是否存在
if(token == null){
return true;
}
// 基于Redis获取用户(获取全部key和value)
Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
.entries(RedisConstants.LOGIN_USER_KEY + token);
//判断用户是否存在
if(userMap.isEmpty()){
return true;
}
//将查到的Hash数据转成USERDTO对象‘
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//存在,把用户信息存入threadlocal
//session利用beanutil属性拷贝去除敏感信息,转成UserDTO类
UserHolder.saveUser(userDTO);
//刷新token时间
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//放行
return true;
}
//方法后执行
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
UserHolder.removeUser();
}
}
@Configuration
public class Mvcconfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//token刷新拦截器(第一个拦截器)利用oder字段指定
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
}
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;
}
}

本地缓存#

初识#

  • CPU从磁盘或硬盘读写数据要经过寄存器读写性能差
  • 缓存是数据交换的缓冲区。俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码,本地缓存的三种实现方式
例1:static final Map<K,V> map = new HashMap(); // 单线程数据结构
例2:static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); // 多线程数据结构
例3:static final Cache<K,V> USER_CACHE = Caffeine.newBuilder().build(); // 专业本地缓存

HashMap#

static final Map<K,V> map = new HashMap<>(); (最原始的本地缓存)

这是最基础的Java集合实现,利用HashMap的键值对特性来暂存数据。

本质:一个单纯的非线程安全的数据结构。

优势:

  • 极其轻量、速度最快:没有加锁机制,单线程环境下读写性能极高。
  • 零学习成本:Java原生API,随手即写。

劣势:

  • 非线程安全(致命弱点):在高并发(多线程)环境下同时读写,会导致数据丢失、报错(ConcurrentModificationException),甚至在JDK 1.7及以前版本会引发死循环导致CPU 100%。
  • 无缓存管理机制:没有过期时间(TTL)、没有淘汰策略(如LRU)。放进去的数据如果不手动 remove(),会一直占用内存,极易导致 OOM(内存溢出)。

适用场景

  • 单线程环境下的数据暂存。
  • 只读缓存:在系统启动时一次性加载字典数据,后续运行过程中绝对不会再被修改。

ConcurrentHashMap#

static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); (并发安全的本地缓存)

为了解决 HashMap 并发报错的问题,引入了 ConcurrentHashMap。这也是很多初级项目中常用的缓存方案。

本质:Java并发包(JUC)提供的高性能、线程安全的哈希表。

优势:

  • 高并发安全:JDK 1.8 之后采用了 CAS + synchronized 锁粒度细化到头节点(分段锁思想的升级版),在保证线程安全的同时,支持高并发读写。
  • 无外部依赖:依然是JDK自带类,不需要引入第三方库。

劣势:

  • 依然缺乏缓存管理能力:它本质上还是个“容器”而不是“缓存框架”。它不知道哪个Key过期了,也不知道在内存满了时该删除谁。

  • OOM风险依然存在:作为全局静态变量,如果不写定时任务去清理,数据只增不减,最终撑爆JVM内存。

适用场景:

  • 高并发场景下,需要频繁读写的共享状态/映射表(例如保存当前在线用户的WebSocket Session)。
  • 数据量完全可控,且通常伴随着业务逻辑会有明确的 put 和 remove 操作的场景。

Caffeine#

  • static final Cache<K,V> USER_CACHE = Caffeine.newBuilder().build(); (现代高性能本地缓存之王)

  • 这是目前 Java 生态中最优秀的本地缓存框架。它的 API 设计很大程度上借鉴了 Google Guava Cache,但在底层算法和性能上进行了全面的颠覆和升级。

  • 本质:基于 W-TinyLFU 算法构建的极高性能、线程安全的现代内存缓存框架。

  • 核心优势:

  • 极致的读写性能:在多线程并发读写测试中,Caffeine 的性能通常远超 Guava Cache,甚至在某些场景下优于基础的 ConcurrentHashMap(因为它使用了 RingBuffer 环形缓冲区和异步批量处理机制来降低锁竞争)。

  • 极高的缓存命中率(杀手锏):传统缓存多用 LRU(最近最少使用)淘汰算法,容易被偶发的突发流量把真正的热点数据“挤出去”。Caffeine 采用的是独创的 W-TinyLFU 算法,它兼顾了访问频率(LFU)和访问时间(LRU),极其聪明地保留真正的“热点数据”。

image.webp
image.webp

强大的缓存生命周期管理:

容量驱逐:maximumSize(10000)(按条数)或按权重驱逐。

时间驱逐:expireAfterWrite(写入后过期)、expireAfterAccess(访问后过期),甚至支持自定义灵活的过期策略。

防缓存击穿机制:通过 build(CacheLoader) 结合自动刷新(refreshAfterWrite),可以保证在高并发下,如果一个热点 Key 过期了,只有一个线程会去查数据库,其他线程要么等待,要么返回老值(配合异步刷新),完美解决并发击穿问题。

劣势:

  • 引入第三方依赖:需要引入 com.github.ben-manes.caffeine。
  • 依然存在单机局限性:数据保存在当前 JVM 堆内存(或直接内存)中,集群部署时,各个服务器之间的缓存数据是不共享、不一致的。

适用场景:

  • 一切需要单机本地缓存的场景的完美选择(例如权限缓存、字典表缓存、本地短时热点数据)。
  • 构建多级缓存架构(L1本地缓存 + L2分布式缓存)的 L1 层首选(即:Caffeine + Redis 的黄金组合)。
维度HashMapConcurrentHashMapCaffeineRedis
核心定位单线程数据结构多线程并发数据结构高性能 JVM 本地缓存分布式缓存中间件
线程安全否,全局共享时危险是,JDK 8+ 主要基于 CAS + synchronized 控制并发是,内部做了并发优化是,命令执行具备原子性
淘汰算法无,可能 OOM无,可能 OOM支持,常用 W-TinyLFU,高命中率支持多种策略,如 LRU、LFU、随机、TTL 等
过期时间 TTL无无原生支持,策略丰富原生支持
防缓存击穿无无支持 LoadingCache、AsyncLoadingCache、refreshAfterWrite 等机制需业务代码实现,如互斥锁、逻辑过期、请求合并
读写速度极快,适合单线程临时数据快,适合多线程并发访问极快,本地内存级别,延迟很低较快,但受网络 IO、序列化、服务端负载影响
分布式支持否否否,数据只在当前 JVM 内是,跨 JVM、跨机器共享
适用场景方法内部临时 Map进程内共享并发状态单机热点数据缓存、多级缓存 L1集群共享缓存、多级缓存 L2
实战推荐度不推荐做全局缓存可用,但需手动管理生命周期很高,适合本地缓存很高,微服务常用

image.webp
image.webp

  • Map和ConcurrentHashMap只是容器,因为它们不像Caffeine和Redis一样具备自我管理的生命周期(容量上限,过期剔除、智能淘汰)。
  • 如果系统是单体架构或者数据对一致性要求不高且需要极低的响应延迟,直接上 Caffeine
  • 如果你的系统是分布式集群,或者需要多台机器状态同步,上 Redis。
  • 在最极致的高并发大厂实战中,往往是Caffeine 挡第一波,Redis 挡第二波,最后兜底查 MySQL。

多级缓存#

image.webp
image.webp

  • 多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻Tomcat压力,提升服务性能:

  • 浏览器访问静态资源时,优先读取浏览器本地缓存

  • 访问非静态资源(ajax查询数据)时,访问服务端

  • 请求到达Nginx后,优先读取Nginx本地缓存

  • 如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat)

  • 如果Redis查询未命中,则查询Tomcat

  • 请求进入Tomcat后,优先查询JVM进程缓存

  • 如果JVM进程缓存未命中,则查询数据库

在多级缓存架构中,Nginx内部需要编写本地缓存查询、Redis查询、Tomcat查询的业务逻辑,因此这样的nginx服务不再是一个反向代理服务器,而是一个编写业务的Web服务器了(Nginx编程会用到OpenResty框架结合Lua语言 )。因此这样的业务Nginx服务也需要搭建集群来提高并发,再有专门的nginx服务来做反向代理,另外,我们的Tomcat服务将来也会部署为集群模式

商品缓存#

Service#

image.webp
image.webp

  • 查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回
  • 如果缓存数据不存在,再查询数据库,然后将数据存入redis
  • Shop shop =JSONUtil.toBean(shopJson,Shop.class);Redis的String格式转换为对象
  • stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));将 Bean 等对象转换为 String类型,内容格式为JSon
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
// // 根据id查询商铺信息
@Override
public Result queryById(Long id) {
//商品的key
String key =CACHE_SHOP_KEY+id;
//先从Redis中查询缓存
String shopJson= stringRedisTemplate.opsForValue().get(key);
//判断缓存是否存在
if(StrUtil.isNotBlank(shopJson)){
// 存在
Shop shop =JSONUtil.toBean(shopJson,Shop.class);
return Result.ok(shop) ;
}
//不存在查询数据库
Shop shop= getById(id);
if (shop==null){
return Result.fail("店铺不存在");
}
//存在写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
}

一致性#

  • 缓存更新是redis为了节约内存而设计

image.webp
image.webp

image.webp
image.webp

采用方案一,调用者操作缓存和数据库时有三个问题需要提前考虑

1**.删除还是更新缓存**

  • 更新缓存:每次更新数据库都更新缓存,但却不进行查询,不会使用缓存

  • 删除缓存:执行一次删除缓存操作,只有在调用者查询时,即真正要用到缓存了再做数据同步更新缓存,访问时再查一次数据库

选择删除缓存

  • 因为更新缓存会产生大量的无效写操作(可能更新了多次才被读一次)
  • 延迟加载(删除后等下一次查询再写入)性能更好

原子性#

如何保证原子性

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

将操作数据库和操作缓存放在同一个事务中。

先操作数据库再操作缓存

  • 数据库比Redis查询较慢,有线程速度差异
  • 先删除缓存,查询未命中,查Mysql写入旧值到Redis,更新数据库
  • 数据库是新值,缓存却是旧值

image.webp
image.webp

先删除缓存再更新数据库

image.webp
image.webp

设置过期时间

//存在写入redis
//设置key失效时间ttl
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);

原子性事务

@Transactional` 默认主要管理数据库事务,Redis 删除操作通常不会随着数据库事务一起回滚,因此数据库和 Redis 并不处在同一个真正的原子事务里

//更新店铺信息
//原子性同时删除数据库和缓存
@Transactional
@Override
public Result update(Shop shop) {
Long id = shop.getId();
if(id==null){
return Result.fail("店铺id不能为空");
}
//更新数据库
updateById(shop);
// 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY+id);
return Result.ok();
}

SpringCache#

SpringCache实现缓存双写

查询数据

  • @Cacheable 表示:先查缓存,缓存没有数据时才执行方法
  • cacheNames作为前缀,使用方法参数 id 作为缓存 Key
  • 自动完成查询缓存、查询数据库、写入缓存

更新数据

  • @CacheEvict 表示删除缓存,先更新数据库,再删除缓存
  • 避免数据库已经更新,但 Redis 仍然保存旧数据的问题
@Cacheable(cacheNames = CACHE_SHOP_KEY,key = "#id")
public Shop queryById(Serializable id) {
Shop shop = this.getById(id);
return shop;
}
@CacheEvict(cacheNames = CACHE_SHOP_KEY,key = "#shop.id")
public void updateShop(Shop shop) {
this.updateById(shop);
}

TTL、序列化方式等统一在 RedisCacheManager 中配置

@Configuration
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// 创建ObjectMapper用于处理序列化和反序列化
ObjectMapper objectMapper = new ObjectMapper();
// 注册JavaTimeModule以支持Java 8日期时间类型
objectMapper.registerModule(new JavaTimeModule());
// 禁用将日期序列化为时间戳格式,保持可读性
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// 启用默认的多态类型信息处理,以便在反序列化时保留类型信息
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
// 创建通用的Jackson2 JSON Redis序列化器
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper);
// 定义默认的缓存配置,设置缓存条目过期时间为10分钟,并使用定义好的序列化器 。
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer));
// 创建缓存配置映射,允许为不同的缓存名称定义不同的配置
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
// 为店铺缓存设置单独的过期时间(1小时)
cacheConfigurations.put(CACHE_SHOP_KEY, defaultConfig.entryTtl(Duration.ofDays(1)));
// 构建RedisCacheManager实例,使用默认配置和自定义的缓存配置初始化
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}

三大缓存#

缓存穿透#

  • 客户端请求的数据在缓存和数据库中都不存在
  • 导致每次请求都直接打到数据库,恶意攻击时容易压垮数据库。

解决方案:

  • 缓存空对象(项目采用):即使数据库查不到,也把空值(如空字符串)存入Redis,并设置较短的过期时间。优点是简单,缺点是消耗内存且可能引起短期不一致。

  • 非null情况:Redis的Value值一种为Json,一种为空字符串,null情况查询数据库,

  • 均不存在写空值进Redis设置过期时间,数据库存在正常数据写进Redis

  • 布隆过滤器:利用哈希算法加位图,在请求进入Redis前先判断数据是否存在。优点是省内存,缺点是实现复杂且存在误判率。

image.webp
image.webp

@Cacheable(cacheNames = CACHE_SHOP_KEY,key = "#id")
public Shop queryById(Serializable id) {
Shop shop = this.getById(id);
if (shop == null){ //缓存穿透()
//缓存穿透
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY +id,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
return shop;
}

缓存雪崩#

定义:同一时间段内,大量缓存的Key同时失效,或者Redis服务宕机,导致瞬间大量请求直达数据库。

解决方案:

  • 给不同的Key设置过期时间时,加上一个随机值,打散过期时间。

  • 利用Redis集群保证高可用。

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

  • 给业务添加多级缓存

缓存击穿#

  • 一个被高并发访问且缓存重建逻辑较复杂的热点Key突然失效(过期),瞬间无数请求发现缓存没命中
  • 查询数据库写入redis时间过长,另一个线程也未命中并查询数据库
  • 很多线程同时去查数据库并尝试重建缓存,压垮数据库。

image.webp
image.webp

互斥锁#

使用互斥锁,确保同一时间只有一个线程重建缓存

  • trylock方法利用redis的setnx命令,若key不存在,才进行插入操作返回bolean值

  • False拿到锁的线程去查库重建缓存;True拿不到的线程休眠重试。

  • 优缺点:优点是保证了数据的一致性,实现较简单;缺点是互相等待导致性能下降,且有死锁风险。

互斥锁的影响

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

image.webp
image.webp

image.webp
image.webp

private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2、判断存在缓存
//存在
if (StrUtil.isNotBlank(shopJson)) {
// 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的值是否是空值
if (shopJson != null) {
//返回一个错误信息
return null;
}
// 4.实现缓存重构
//4.1 获取互斥锁限制一个线程查询数据库(仅用于查询一次数据库)
String lockKey = "lock:shop:" + id;
//数据库查询
Shop shop = null;
//其他线程正在查询数据库并重建缓存
try {
boolean isLock = tryLock(lockKey);
// 4.2 判断否获取锁成功
if(!isLock){
//4.3 失败,则休眠重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4 成功,根据id查询数据库
shop = getById(id);
// 5.不存在,返回错误
if(shop == null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//返回错误信息
return null;
}
//6.写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES);
}catch (Exception e){
throw new RuntimeException(e);
}
finally {
//7.释放互斥锁
unlock(lockKey);
}
return shop;
}

逻辑过期#

  • 原理:不给Key设置实际的TTL,而是在Value中存入一个“逻辑过期时间”字段
  • 发现过期时,当前线程直接返回旧数据(脏数据),同时开启一个独立新线程去获取互斥锁并异步查库重建缓存。
  • 优缺点:优点是线程不阻塞,并发性能极高;缺点是牺牲了短期的一致性(重构完成前返回的都是脏数据),且实现较复杂。

实现方法

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

image.webp
image.webp

public void saveShop2Redis(Long id,Long expiredSeconds){
//1.查询店铺数据
Shop shop=getById(id);
//2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expiredSeconds));
//3.写入redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
//
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return shop;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
CACHE_REBUILD_EXECUTOR.submit( ()->{
try{
//重建缓存
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}
  • 不给热点Key设置过期时间,而是给数据添加一个过期时间的字段。这样热点Key就不会过期,缓存中永远有数据。

  • 查询到数据时基于其中的过期时间判断key是否过期,如果过期开启独立新线程异步的重建缓存,而查询请求先返回旧数据即可。当然,这个过程也要加互斥锁,但由于重建缓存是异步的,而且获取锁失败也无需等待,而是返回旧数据,这样性能几乎不受影响。

  • 需要注意的是,无论是采用哪种方式,在获取互斥锁后一定要再次判断缓存是否命中,做dubbo check. 因为当你获取锁成功时,可能是在你之前有其它线程已经重建缓存了。

封装工具类#

为了避免在不同的业务(如查询商铺、查询其他信息)中重复写解决穿透和击穿的逻辑,实战中会基于 StringRedisTemplate 封装一个通用的 CacheClient 客户端。

  • 普通的存入带TTL的缓存。

  • 存入带有逻辑过期时间的缓存。

  • 封装解决缓存穿透的通用查询方法(利用函数式编程传入数据库查询逻辑)。

  • 封装解决缓存击穿的通用查询方法(逻辑过期 / 互斥锁方案

@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
public <R,ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if (json != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
return r;
}
public <R, ID> R queryWithLogicalExpire(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}
public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.获取锁成功,根据id查询数据库
r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放锁
unlock(lockKey);
}
// 8.返回
return r;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
@Resource
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
// 解决缓存穿透
Shop shop = cacheClient
.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 互斥锁解决缓存击穿
// Shop shop = cacheClient
// .queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 逻辑过期解决缓存击穿
// Shop shop = cacheClient
// .queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在!");
}
// 7.返回
return Result.ok(shop);
}

#

#

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
黑马点评上
https://blog.f3f3.top/posts/redis/黑马点评上/
作者
Firefly
发布于
2026-08-10
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Firefly
Hello, I'm Firefly.
公告
此网站仅为个人学习笔记
分类
标签
最新动态
站点统计
文章
29
分类
11
标签
24
总字数
245,265
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.15.6
文章许可
CC BY-NC-SA 4.0