🐢

黑马点评上

8546 字
43 分钟
黑马点评上

项目总览#

image.webp
image.webp

  • 高并发 Web 服务分层架构,核心目标是解决 “单节点扛不住大流量” 的问题,通过分层和集群化,把压力分散到不同组件上。

  • 前端入口层(Nginx) 作为统一入口,承接手机 / PC 端的所有 HTTP 请求。

  • 做负载均衡把流量分发到多台 Tomcat,避免单台 Tomcat 被打垮。

  • 做动静分离:静态资源(图片、CSS、JS)直接由 Nginx 返回,动态请求才转发到 Tomcat,大幅降低 Tomcat 压力。

  • 利用 Lua 脚本直接访问 Redis,实现热点 数据的快速响应,甚至绕开 Tomcat

  • 缓存层(Redis 集群) 作为 MySQL 的 “前置挡箭牌”,把高频访问的热点数据(如用户信息、商品列表、点评内容)缓存起来,大幅降低 MySQL 的查询压力。

​ 集群化提升 Redis 本身的并发和可用性。

  • 持久化存储层(MySQL 集群)
  • 负责核心数据的持久化和事务保证,通过集群(主从 / 分库分表)提升容量和并发能力。

短信登录#

发送短信#

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格式
//登录功能
// 登录参数,包含手机号、验证码;或者手机号、密码
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
return userService.login(loginForm,session);
}
@Data
public class LoginFormDTO {
private String phone;
private String code;
private String password;
}

Service#

//登录注册功能
@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("验证码错误");
}
// 一致的话根据手机号查询用户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(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;
}
  • 用户将验证码和手机号进行输入,后台从session中拿到当前验证码,和用户输入的验证码进行校验,

  • 如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,

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

登录校验#

拦截器#

//拦截器登录校验
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();
}
}
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"
);
}
}

用户在请求时从cookie中携带着sessionId到后台

后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,

如果有session信息,则将用户信息保存到threadLocal中,并且放行

Controller#

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

Tomcat#

image.webp
image.webp

  1. 连接建立阶段:用户与 Tomcat 握手

用户(浏览器 / APP)发起 HTTP 请求,本质是向 Tomcat 监听的端口(默认 8080)发起 TCP 连接。

Tomcat 内部有专门的 监听线程,它会持续监听这个端口。

当有新连接进来时,监听线程会创建一个 服务端 Socket,与用户端的 Socket 配对,建立起双向数据通道。

  1. 请求分发阶段:把任务交给线程池

服务端 Socket 接收到用户的请求数据后,监听线程不会自己处理,而是从 Tomcat 的 工作线程池 中取出一个空闲的工作线程。 字典与百科全书

这个工作线程会负责:

解析 Socket 中的原始数据,封装成标准的 HttpServletRequest 和 HttpServletResponse 对象。

根据请求的 URL,找到对应的 Web 应用(webApp)和 Servlet。

  1. 业务处理阶段:从 Controller 到数据库

这就是我们熟悉的后端调用链:

工作线程将封装好的 request 和 response 对象,交给 Web 应用中的 Controller 处理。

Controller 调用对应的 Service 层处理业务逻辑。

Service 层调用 DAO 层与 数据库(DB)交互,完成数据的读写。

整个过程都在同一个工作线程中执行,保证了请求的独立性。

  1. 响应返回阶段:把结果还给用户

业务处理完成后,Controller 将结果写入 response 对象。

工作线程将 response 中的数据,通过之前建立的 服务端 Socket 写回给用户端的 Socket。

工作线程完成任务后,被 回收 到线程池中,等待下一个请求。

用户端的 Socket 接收到数据后,渲染页面或处理响应,整个请求 - 响应流程结束。

核心原理

  • 线程池复用: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

发送登录验证码

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

登录注册功能

//登录注册功能
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone=loginForm.getPhone();
//校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//校验验证码
//获取Redis中的key
String cacheCode= stringRedisTemplate.opsForValue().get(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对象转成HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//解决user中的id无法转换成string,要求类型都为String
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
//3.存储到redis
String tokenKey=LOGIN_USER_KEY+token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
//4.设置token有效期
stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
return Result.ok(token);
}
//创建用户对象插入数据
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;
}
}
  • 校验用户提交的手机号和验证码,是否一致,如果一致,

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

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

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

拦截器

//拦截器登录校验
public class LoginInterceptor implements HandlerInterceptor {
//引入Redis因为此为自定义工具类不受Spring监管不能用@Resourse利用构造方法
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;
}

拦截状态刷新#

image.webp
image.webp

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

image.webp
image.webp

//拦截器登录校验
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断是否需要拦截(threadlocal是否有用户)
if(UserHolder.getUser()==null){
response.setStatus(401);
return false;
}
//放行
return true;
}
//拦截器登录校验
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) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
//token刷新拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}

本地缓存#

初识#

  • 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服务将来也会部署为集群模式

商品缓存#

Controller#

image.webp
image.webp

@RestController
@RequestMapping("/shop")
public class ShopController {
@Resource
public IShopService shopService;
// 根据id查询商铺信息
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}

Service#

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
// // 根据id查询商铺信息
@Override
public Result queryById(Long id) {
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);
}
}

缓存更新#

image.webp
image.webp

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

  • 内存淘汰:redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)

  • 超时剔除:当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存

  • 主动更新:我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题。

  • 目前方式二与方式三都没有比较好的服务实现,因此我们下面采用方案一人工双写

image.webp
image.webp

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

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

  • 更新缓存:每次更新数据库都更新缓存,无效写操作较多。如果多次更新数据库但却不进行查询,就会导致缓存压根就得不到使用,也就是白更新缓存了。

  • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存。相较于每次更新数据库都更新缓存的方案,这种方案在多数时间内都是执行一次删除缓存操作就完事了,只有在调用者查询时,即真正要用到缓存了再做数据同步更新缓存,更加高效。

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

如何保证原子性

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

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

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

image.webp
image.webp

线程2的查询缓存和写入缓存都是对缓存的操作,速度非常快,而线程1更新数据库的操作是对数据库的操作,速度比较慢,因此线程2的两个操作及其可能在并发情况下插入到线程1的两个操作之间,导致线程2写入旧数据到缓存中,而线程1又在数据库中更新了旧数据,致使最终数据库与缓存数据不一致的情况。

image.webp
image.webp

线程2的更新数据库操作,速度较慢,而线程1的两次操作都是对缓存的操作,速度较快。因此图示的异常情况(线程1查询数据库返回的值是旧的,因为线程2后续更新了数据库的值,所以当线程1把旧值写入缓存后,就造成了缓存与数据库数据不一致)并不容易出现,线程1两个操作之间的衔接时间非常短,线程2几乎很难插进去执行个更新数据库的操作。

设置过期时间

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

原子性事务

//更新店铺信息
//原子性同时删除数据库和缓存
@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();
}

缓存穿透#

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

解决方案:

  • 缓存空对象(项目采用):即使数据库查不到,也把空值(如空字符串)存入Redis,并设置较短的过期时间。优点是简单,缺点是消耗内存且可能引起短期不一致。
  • **布隆过滤器:利用哈希算法加位图,在请求进入Redis前先判断数据是否存在。优点是省内存,缺点是实现复杂且存在误判率。

image.webp
image.webp

缓存雪崩#

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

解决方案:

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

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

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

缓存击穿#

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

image.webp
image.webp

互斥锁#

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

  • 原理:查缓存没命中时,不立刻查库,利用Redis的 setnx 尝试获取一把分布式锁。拿到锁的线程去查库重建缓存;拿不到的线程休眠重试。

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

  • 锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大

  • 影响查询的性能,查询的性能从并行变成了串行,采用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.
公告
此网站仅为个人学习笔记
分类
标签
最新动态
站点统计
文章
26
分类
10
标签
21
总字数
159,373
运行时长
0
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.15.6
文章许可
CC BY-NC-SA 4.0