
短信登录:
导入项目:
导入 SQL:

当前模型:
客户端请求统一接入 Nginx 网关层,Nginx 工作在 OSI 七层模型,基于 HTTP 协议提供请求转发能力,支持通过 Lua 脚本直接访问 Redis 实现请求缓存与鉴权,同时可作为静态资源服务器承载海量静态文件访问,轻松支撑万级并发;
通过 Nginx 的负载均衡能力,可将流量均匀分发至后端多台 Tomcat 集群节点,有效解决单机瓶颈;单台 4 核 8G 的 Tomcat 优化后并发处理能力约 1000 左右,通过集群部署可大幅提升系统整体吞吐量;此外,Nginx 可实现动静分离,将静态资源直接响应,进一步减轻后端应用服务压力,是高并发架构中不可或缺的入口层组件;
后端服务若直接访问 MySQL,在高并发场景下极易出现性能瓶颈;企业级 MySQL 服务器在常规配置下,并发支撑能力约 4000~7000,万级并发会导致 CPU、I/O 资源耗尽,服务稳定性急剧下降;因此架构中通常采用 MySQL 集群 提升数据库承载能力,并引入 Redis 缓存集群 承担热点数据查询,大幅降低数据库压力,提升系统响应速度与并发能力

导入后端项目:
修改 application.yaml 文件中的 mysql、redis 地址信息

运行后端项目:

导入前端项目:

运行前端项目:


登录流程:

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

发送验证码:
UserController:
java
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
//发送短信验证码并保存验证码
return userService.sendCode(phone,session);
}
IUserService:
java
Result sendCode(String phone, HttpSession session);
UserServiceImpl:
java
@Override
public Result sendCode(String phone, HttpSession session){
//校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//如果不符合,返回错误
return Result.fail("手机号格式错误!");
}
//符合,生成验证码
String code = RandomUtil.randomNumbers(6);
//保存验证码到 session
session.setAttribute("code",code);
//发送验证码
log.debug("发送短信验证码成功,验证码:{}",code);
//返回ok
return Result.ok();
}
登录:
UserController:
java
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
//实现登录功能
return userService.login(loginForm,session);
}
IUserService:
java
Result login(LoginFormDTO loginForm, HttpSession session);
UserServiceImpl:
java
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
//如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
//校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.toString().equals(code)){
//不一致,报错
return Result.fail("验证码错误");
}
//一致,根据手机号查询用户
User user = query().eq("phone",phone).one();
//判断用户是否存在
if(user == null){
//不存在,则创建
user = createUserWithPhone(phone);
}
//保存用户信息到session中
session.setAttribute("user", user);
return Result.ok();
}
private User createUserWithPhone(String phone){
//创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomNumbers(10));
//保存用户
save(user);
return user;
}

实现登录拦截功能:

Tomcat 处理请求与 ThreadLocal 线程隔离:
用户发起的 HTTP 请求首先接入 Tomcat 监听的指定端口,任何网络服务进程的运行均依赖端口监听线程 ------Tomcat 会启动专属的端口监听线程,持续监听该端口上的连接请求;当监听到客户端连接意图时,监听线程会与客户端建立双向 Socket 连接(Socket 采用端到端成对映射机制,保障数据双向传输);
Tomcat 端的 Socket 接收到请求数据后,监听线程不会直接处理业务,而是从 Tomcat 内置的线程池中获取一个空闲工作线程,将请求交由该线程全权处理;在应用部署至 Tomcat 容器的前提下,工作线程会根据请求路径定位对应应用工程,按照 MVC 分层架构依次调用 Controller、Service、Dao 层组件,并完成与数据库的交互;待业务逻辑执行完毕后,工作线程会封装响应数据,通过 Tomcat 端的 Socket 将数据回写至客户端 Socket,最终完成一次完整的请求 - 响应闭环;
基于上述处理流程可知,Tomcat 采用 "请求 - 线程" 一一对应的处理模型:每个用户请求均由线程池中的独立工作线程处理,处理完成后线程归还至线程池复用;由于各请求的处理线程相互隔离、独立运行,因此可借助 ThreadLocal 实现线程级别的数据隔离,让每个工作线程仅操作自身绑定的专属数据副本,避免多线程间的数据竞争与冲突,保障数据操作的线程安全性
从 ThreadLocal 的源码分析,其 put() 与 get() 核心方法的设计逻辑,是实现线程隔离的关键:在执行 put()(存储数据)或 get()(获取数据)操作时,方法内部先通过 Thread.currentThread() 获取当前执行线程的引用,随后从该线程对象中提取其持有的成员变量 ThreadLocalMap(Thread 类的私有属性);由于 ThreadLocalMap 是线程的专属数据结构 ------ 每个线程实例都维护独立的 ThreadLocalMap 实例,不同线程对应的 ThreadLocalMap 彼此隔离、无共享关系,因此即使是同一个 ThreadLocal 实例,在不同线程中执行数据存取操作时,实际操作的是各自线程绑定的 Threa dLocalMap 中的数据副本

拦截器:
LoginInterceptor:
java
package com.hmdp.utils;
public class LoginInterceptor implements HandlerInterceptor {
//请求到达Controller之前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,Object handler) throws Exception {
//从请求中获取session
HttpSession session = request.getSession();
//从session中读取登录用户信息
Object user = session.getAttribute("user");
//判断用户是否未登录
if(user == null){
//未登录:设置响应状态码为401
response.setStatus(401);
//返回false:拦截请求,请求不会到达Controller
return false;
}
//已登录:将用户信息存入ThreadLocal
UserHolder.saveUser((User)user);
//返回true:放行请求,继续执行Controller的业务逻辑
return true;
}
//请求处理完成后(包括Controller执行、响应返回)执行(资源清理)
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除ThreadLocal中的用户信息,避免线程池复用导致数据错乱/内存泄漏
UserHolder.removeUser();
}
}
使拦截器生效:
MvcConfig:
java
package com.hmdp.config;
@Configuration//标记为配置类,Spring启动时加载
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"
);
}
}

隐藏用户敏感信息:
在接口返回用户信息场景中,由于直接将包含完整字段的 User 实体对象返回至前端(浏览器),导致用户敏感信息(如密码等)泄露,存在严重的安全风险;为此,需采用数据传输对象(DTO) 设计模式
UserServiceImpl:
java
//保存用户信息到session中
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
LoginInterceptor:
java
//已登录:将用户信息存入ThreadLocal
UserHolder.saveUser((UserDTO)user);
UserHolder:
java
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();
}
}
Session 共享问题:
在 Tomcat 集群部署场景下,由于 session 的 JVM 进程内存储特性,每个 Tomcat 实例维护独立的 session 空间,会引发 "session 不共享" 问题:用户首次请求路由至 Tomcat A 并将登录信息存入其本地 session,二次请求若路由至 Tomcat B,因 Tomcat B 无该用户的 session 数据,会导致登录拦截逻辑失效,用户被判定为未登录;
针对该问题,早期采用 session 拷贝(session 同步)方案:当任意 Tomcat 节点的 session 发生变更时,将变更数据同步至集群内所有 Tomcat 节点,以实现 session 共享;
但该方案存在显著缺陷:
存储压力大:每台 Tomcat 需存储全量 session 数据,集群规模越大,节点内存占用越高,服务器负载显著增加;
数据一致性风险:session 拷贝存在网络传输延迟,可能导致节点间 session 数据不一致,引发登录状态校验异常
因此,现阶段主流解决方案为基于 Redis 实现分布式 session:摒弃 Tomcat 本地 session,将用户登录状态(原 session 数据)存储至 Redis(分布式缓存,数据全局共享);Redis 的高性能、单线程模型及数据共享特性,既解决了 session 跨节点不共享的核心问题,又规避了 session 拷贝的存储压力与数据延迟问题,是高可用集群架构中登录状态管理的最优实践

业务流程:

设计 Key 的细节:
在采用 Redis 的 String 结构存储用户会话数据时,Key 的设计需规避原生 Session 的本地隔离特性(Redis Key 全局共享),同时满足唯一性与易携带性两大核心要求:
Key 唯一性:若直接使用 phone:手机号作为 Key,虽能保证唯一性,但手机号属于用户敏感信息,直接在前端传递或存储易引发数据泄露风险;
Key 易携带性:需设计一种非敏感、可便捷传递的标识作为 Key
因此核心方案为:由后端生成随机唯一串(Token)作为 Redis 的 Key,替代手机号等敏感信息Token 具备无业务含义、随机性强的特点,既满足全局唯一性要求,又可安全地在前端与后端之间传递,规避敏感信息暴露风险
整体访问流程:


用户信息处理:校验通过后,根据手机号查询用户信息(若不存在则创建新用户);
Redis 存储阶段:将用户信息序列化后作为 Value,以生成的随机 Token 作为 Key,存入 Redis(Key = Token,Value = 序列化后的用户信息)
Token 返回前端:后端将 Token 返回至前端,由前端存储(如 Cookie、LocalStorage);
登录态校验阶段:用户发起后续请求时,前端携带 Token 至后端;
Redis 查询校验:后端通过 Token 作为 Key 从 Redis 中查询用户信息:若查询结果为空(Token 无效 / 过期),则拦截请求并返回未授权(401);若查询结果存在,则将用户信息存入 ThreadLo cal 实现线程隔离,放行请求供后续业务逻辑使用
基于 Redis 实现短信登录:
UserServiceImpl:
java
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
//校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//如果不符合,返回错误
return Result.fail("手机号格式错误!");
}
//符合,生成验证码
String code = RandomUtil.randomNumbers(6);
//保存验证码到 Redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
//发送验证码
log.debug("发送短信验证码成功,验证码:{}",code);
//返回ok
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
//如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
//从redis获取并校验验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.equals(code)){
//不一致,报错
return Result.fail("验证码错误");
}
//一致,根据手机号查询用户
User user = query().eq("phone",phone).one();
//判断用户是否存在
if(user == null){
//不存在,则创建
user = createUserWithPhone(phone);
}
//保存用户信息到redis中
//随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
//将User对象转为Hash存储
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()));//字段值转String
//存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
//设置token有效期
stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL, TimeUnit.MINUTES);
//返回token
return Result.ok(token);
}
private User createUserWithPhone(String phone){
//创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomNumbers(10));
//保存用户
save(user);
return user;
}
}
LoginInterceptor:
java
public class LoginInterceptor implements HandlerInterceptor {
//手动注入StringRedisTemplate(因为拦截器不是Spring Bean,不能用@Resource,需构造器注入)
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
//请求到达Controller之前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,Object handler) throws Exception {
//获取请求头中的token(前端将Token放在authorization请求头中)
String token = request.getHeader("authorization");
if(StrUtil.isBlank(token)){
//不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
//基于token获取redis中的用户
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
//判断用户是否存在
if(userMap.isEmpty()){
//不存在,拦截,返回401
response.setStatus(401);
return false;
}
//将查询到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
//存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
//刷新token有效期
stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//放行
return true;
}
//请求处理完成后(包括Controller执行、响应返回)执行(资源清理)
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除ThreadLocal中的用户信息,避免线程池复用导致数据错乱/内存泄漏
UserHolder.removeUser();
}
}
MvcConfig:
java
@Configuration//标记为配置类,Spring启动时加载
public class MvcConfig implements WebMvcConfigurer {
@Resource// 注入StringRedisTemplate(配置类是Spring Bean,可正常注入)
private StringRedisTemplate stringRedisTemplate;
//注册拦截器并配置拦截/放行规则
@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册自定义的登录拦截器
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
//配置放行路径
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}


状态登录刷新:
存在问题:
当前基于 Redis 和 Token 的登录态管理方案存在令牌刷新机制覆盖不全的问题:现有登录拦截器仅对需鉴权的受保护路径生效,当用户访问无需拦截的公开路径时,拦截器不会触发,导致 Token 的过期时间刷新逻辑无法执行;若用户仅操作公开路径,Token 会因未及时刷新而过期,进而引发登录态失效,影响用户体验

优化方案:
采用双拦截器分层设计,核心思路如下:
新增全局刷新拦截器(第一层):拦截所有请求路径(无排除规则),核心职责为:
从请求头解析 Token,若 Token 有效则从 Redis 获取用户信息并存入 ThreadLocal;
无论请求是否为受保护路径,只要 Token 有效,均执行 Token 过期时间刷新操作;
该拦截器仅完成 "Token 解析 - 用户信息存储 - 过期时间刷新",不拦截任何请求,全部放行。
简化原登录拦截器(第二层):仅保留登录态校验逻辑,无需再处理 Token 刷新与用户信息查询:
直接从 ThreadLocal 中读取用户信息,判断是否存在;
若不存在则拦截请求并返回 401 未授权,存在则放行;
因全局拦截器已完成 Token 刷新,该拦截器只需聚焦登录态校验,职责更单一

RefreshTokenInterceptor:
java
public class RefreshTokenInterceptor implements HandlerInterceptor {
//手动注入StringRedisTemplate(因为拦截器不是Spring Bean,不能用@Resource,需构造器注入)
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
//请求到达Controller之前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取请求头中的token(前端将Token放在authorization请求头中)
String token = request.getHeader("authorization");
if(StrUtil.isBlank(token)){
return true;
}
//基于token获取redis中的用户
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
//判断用户是否存在
if(userMap.isEmpty()){
return true;
}
//将查询到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
//存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
//刷新token有效期
stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//放行
return true;
}
//请求处理完成后(包括Controller执行、响应返回)执行(资源清理)
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除ThreadLocal中的用户信息,避免线程池复用导致数据错乱/内存泄漏
UserHolder.removeUser();
}
}
LoginInterceptor:
java
public class LoginInterceptor implements HandlerInterceptor {
//请求到达Controller之前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,Object handler) throws Exception {
//判断是否需要拦截(ThreadLocal中是否有用户)
if(UserHolder.getUser() == null){
//没有,需要拦截,设置状态码
response.setStatus(401);
//拦截
return false;
}
//有用户,则放行
return true;
}
}
MvcConfig:
java
@Configuration//标记为配置类,Spring启动时加载
public class MvcConfig implements WebMvcConfigurer {
@Resource//注入StringRedisTemplate(配置类是Spring Bean,可正常注入)
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);
}
}
商品查询缓存:
缓存:
缓存(Cache)是构建高性能分布式系统的核心组件,本质是基于局部性原理设计的多层数据存储架构,通过将高频访问的数据从低速存储介质(磁盘、数据库)预加载至高速存储介质(内存、CPU 寄存器),以空间换时间的方式降低数据访问延迟、提升系统吞吐量
缓存是介于数据请求方与原始数据源之间的高性能数据缓冲区,其核心特征包括:
存储介质特性:优先采用易失性高速存储(如内存、CPU 缓存),部分场景结合非易失性存储(如 Redis 持久化),读写性能较磁盘型数据库提升 1-3 个数量级;
数据生命周期:缓存数据为原始数据源的 "副本",需通过过期策略(TTL)、更新策略(写穿透 / 写回)保证与源数据的最终一致性;
java
static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>();//本地用于高并发
static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build();//用于redis等缓存
static final Map<K,V> map = new HashMap();//本地缓存
本地缓存通常通过 static final 修饰的容器实现(如 ConcurrentHashMap、Guava Cache),利用类加载机制将缓存实例常驻内存,final 关键字保证引用不可变,避免因引用重赋值导致缓存失效;
分布式缓存(如 Redis)通过网络协议(TCP)实现跨节点数据共享,弥补本地缓存无法集群共享的缺陷
核心价值:
性能优化:内存读写速度远高于磁盘,缓存可将高频数据访问响应时延从百毫秒级降至毫秒 / 微秒级,高并发场景 QPS 可提升 10 倍以上;
负载削峰:承接 80% 以上高频读请求,大幅降低数据库 / 存储服务 IO 压力,避免数据源因海量并发宕机;
系统弹性:作为服务降级兜底方案,数据源故障时返回缓存数据,提升系统可用性
成本与风险:
开发复杂度:需设计缓存更新策略及穿透 / 击穿 / 雪崩防护机制,增加代码逻辑复杂度;
运维成本:分布式缓存需部署集群、监控命中率、处理数据一致性,提升运维门槛;
数据一致性风险:缓存与源数据异步更新易导致短期不一致,需按业务选择最终 / 强一致性方案

企业级系统通常采用分层缓存架构,从请求端到数据源逐层构建缓存,最大化利用各层级的性能优势,层级划分及技术特征如下:
| 缓存层级 | 存储介质 | 访问延迟 | 核心应用场景 |
|---|---|---|---|
| CPU 缓存(L1/L2/L3) | CPU 内部寄存器 | 纳秒级 | 指令 / 数据的高速运算 |
| 浏览器缓存 | 客户端本地存储 | 微秒级 | 静态资源(JS/CSS/ 图片)、接口数据 |
| 应用层本地缓存 | 应用服务器内存 | 微秒级 | 单机高频热点数据(如字典表) |
| 应用层分布式缓存 | 缓存集群内存 | 毫秒级 | 跨节点共享数据(如用户会话) |
| 数据库缓存 | 数据库内存池 | 毫秒级 | SQL 查询结果、数据页 |

添加商户缓存:

ShopController:
java
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
IShopService:
java
public interface IShopService extends IService<Shop> {
Result queryById(Long id);
}
ShopServiceImpl:
java
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@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);
}
//不存在,根据id查询数据库
Shop shop = getById(id);
//不存在,返回错误
if(shop == null){
return Result.fail("店铺不存在!");
}
//存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));//反序列化
//返回
return Result.ok(shop);
}
}
缓存更新:
Redis 缓存更新:
Redis 缓存更新本质是内存资源治理策略,核心解决内存容量有限导致的数据过载问题,包含三类核心机制:
内存淘汰:当 Redis 内存达到 max-memory 阈值时,自动触发预设策略(如 LRU/LFU)淘汰低优先级数据,释放内存;
超时剔除:基于设置的 TTL 过期时间,自动删除超时数据,主动释放缓存空间;
主动更新:业务侧手动删除缓存,核心用于解决数据库与缓存的数据一致性问题

数据库缓存一致性:
缓存数据源于数据库,数据库数据变更若未同步至缓存会引发数据不一致,导致业务使用过期数据
主流解决方案及选型如下:
| 方案类型 | 核心逻辑 | 适用场景 |
|---|---|---|
| Cache Aside Pattern(双写方案) | 业务编码实现 "数据库更新 + 缓存操作",是最常用的方案 | 绝大多数中小规模业务场景 |
| Read/Write Through Pattern | 由系统层统一处理数据库与缓存的同步,业务侧无需感知 | 封装性要求高的中间件场景 |
| Write Behind Caching Pattern | 仅操作缓存,异步线程批量同步至数据库,最终一致性 | 高并发写、允许短期数据延迟的场景 |

Cache Aside Pattern 实施:
**操作方式:**删缓存而非更缓存更新数据库时直接删除缓存(而非更新),避免无查询场景下的无效写操作,待后续查询时重新加载最新数据至缓存;
**操作原子性:**保证同成功 / 失败
单体系统:将数据库与缓存操作纳入同一事务;
分布式系统:采用 TCC 等分布式事务方案;
**操作顺序:**先更数据库,后删缓存;若先删缓存再更数据库,高并发下可能出现 "缓存空窗期" 导致旧数据写入缓存;先更新数据库再删除缓存,可避免该问题,保障数据一致性

实现商铺缓存与数据库一致:
核心思路:
根据 id 查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
根据 id 修改店铺时,先修改数据库,再删除缓存
ShopServiceImpl:
java
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);//反序列化
ShopController:
java
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
return shopService.update(shop);
}
IShopService:
java
Result update(Shop shop);
ShopServiceImpl:
java
@Override
@Transactional
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 写入空值或占位值并设置过期时间,后续请求直接命中缓存
优点:实现简单、维护成本低
缺点:占用额外内存;存在短期数据不一致风险
布隆过滤器:
思路:基于哈希与位图结构,预先记录存在的 key;请求先过过滤器,判定不存在则直接返回,避免查询数据库
优点:内存占用极低、无冗余 key
缺点:实现复杂;存在哈希冲突导致的误判

解决商品查询的缓存穿透问题:

ShopServiceImpl:
java
@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);
}
//判断命中的是否是空值
if(shopJson != null){
//返回一个错误信息
return Result.fail("店铺信息不存在!");
}
//不存在,根据id查询数据库
Shop shop = getById(id);
//不存在,返回错误
if(shop == null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//返回错误信息
return Result.fail("店铺不存在!");
}
//存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);//反序列化
//返回
return Result.ok(shop);
}
缓存雪崩:
缓存雪崩:大量缓存 Key 在同一时段集中失效,或 Redis 服务整体宕机,导致海量请求绕过缓存直击数据库,引发数据库压力骤增甚至宕机
解决方案:
TTL 随机化:为不同缓存 Key 的过期时间(TTL)添加随机偏移量,避免 Key 集中失效;
Redis 集群化:部署 Redis 集群(主从 / 哨兵 / 集群模式),提升服务可用性,避免单点宕机;
降级限流:对缓存相关业务配置降级、限流策略,高峰期拦截过量请求,保护数据库;
多级缓存:构建本地缓存 + 分布式缓存的多级缓存架构,分散数据库访问压力

缓存冲击:
缓存击穿(热点 Key 问题)是指:一个被高并发访问且缓存重建逻辑复杂的热点 Key 突然失效,导致大量并发请求在缓存重建完成前穿透至数据库,引发数据库压力骤增甚至宕机的风险;其本质是热点 Key 失效后的缓存空窗期与高并发请求的叠加效应

常见解决方案:
互斥锁方案(一致性优先):
核心原理:利用分布式锁(如 Redis SETNX、Redisson)的互斥性,确保同一时间仅一个线程执行 "查库 + 写缓存" 的重建操作,其余线程阻塞等待锁释放后,直接从缓存中读取数据
优化实现:采用 tryLock 非阻塞锁 + 双重校验机制,线程获取锁失败时短暂休眠重试,避免死锁;双重校验确保锁释放后缓存已完成重建
优点:严格保证数据一致性,实现简单,无额外内存消耗
缺点:高并发下线程阻塞导致接口响应延迟升高,存在锁竞争和死锁风险

逻辑过期方案(性能优先):
核心原理:不设置 Redis Key 的物理过期时间,而是将过期时间嵌入缓存 Value 的业务字段中;当检测到逻辑过期时,加锁并启动异步线程重建缓存,主线程及其他未获取锁的线程直接返回旧数据,直至缓存重建完成
优点:线程无需阻塞等待,接口响应性能高,能支撑极高并发场景
缺点:缓存重建期间返回旧数据,存在短期数据不一致风险;实现复杂,需维护逻辑过期字段

| 方案 | 核心优势 | 核心劣势 | 适用场景 |
|---|---|---|---|
| 互斥锁 | 数据一致性强,实现简单,无额外内存消耗 | 高并发下性能受影响,存在死锁风险 | 对数据准确性要求高、缓存重建耗时适中的场景(如订单详情、用户信息) |
| 逻辑过期 | 响应性能高,无线程阻塞 | 存在短期脏数据,实现复杂 | 允许短期数据不一致、高并发读写的场景(如首页热点商品、活动数据) |
解决缓存击穿问题:
互斥锁:

ShopServieImpl:
java
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
//缓存穿透
//Shop shop = queryWithPassThrough(id);
//互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if(shop==null){
return Result.fail("店铺不存在!");
}
//返回
return Result.ok(shop);
}
public Shop queryWithPassThrough(Long id){
String key = CACHE_SHOP_KEY + id;
//从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);//序列化
}
//判断命中的是否是空值
if(shopJson != null){
//返回一个错误信息
return null;
}
//不存在,根据id查询数据库
Shop shop = getById(id);
//不存在,返回错误
if(shop == null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//返回错误信息
return null;
}
//存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);//反序列化
//返回
return shop;
}
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
//第一次检查:从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断是否存在有效缓存
if(StrUtil.isNotBlank(shopJson)){
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断是否是缓存穿透的空值标记
if(shopJson != null){
return null;
}
//缓存未命中,准备重建缓存(加互斥锁避免缓存击穿)
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
//获取互斥锁
boolean isLock = tryLock(lockKey);
//未获取到锁,休眠后递归重试
if(!isLock){
Thread.sleep(50);
return queryWithMutex(id);
}
//DoubleCheck:二次检查缓存(核心优化!)
//原因:可能其他线程已经在当前线程等待锁时重建了缓存
shopJson = stringRedisTemplate.opsForValue().get(key);
//二次检查到有效缓存,直接返回
if (StrUtil.isNotBlank(shopJson)){
return JSONUtil.toBean(shopJson, Shop.class);
}
//二次检查到空值标记,直接返回
if(shopJson != null){
unlock(lockKey);
return null;
}
//真正需要重建缓存,查询数据库
shop = getById(id);
//数据库也不存在,写入空值标记防止缓存穿透
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//数据库存在,写入redis重建缓存
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//最终释放锁(保证锁一定被释放,避免死锁)
unlock(lockKey);
}
return shop;
}
private boolean tryLock(String key){
//SET key "1" NX EX 10
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key){
stringRedisTemplate.delete(key);
}
@Override
@Transactional
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();
}
}
逻辑过期:

新建实体类 RedisData:
java
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
ShopServiceImpl 新增方法:
java
public void saveShop2Redis(Long id,Long expireSeconds){
//查询店铺数据
Shop shop = getById(id);
//封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(redisData));
}
利用单元测试进行缓存预热:
java
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private ShopServiceImpl shopService;
@Test
void testSaveShop(){
shopService.saveShop2Redis(1L,10L);
}
}
ShopServiceImpl:
java
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicExpire(Long id){
String key = CACHE_SHOP_KEY + id;
//从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断是否存在(缓存未命中)
if(StrUtil.isBlank(shopJson)){
//缓存未命中直接返回null(预热过的热点数据一定有缓存)
return null;
}
//命中,把json反序列化为RedisData对象(包含数据+逻辑过期时间)
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
//解析出店铺数据(RedisData的data字段是JSON对象,需二次反序列化)
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
//解析出逻辑过期时间
LocalDateTime expireTime = redisData.getExpireTime();
//判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
//未过期,直接返回店铺信息
return shop;
}
//已过期,需要缓存重建(但不阻塞用户请求)
//获取互斥锁(防止多个线程同时异步重建缓存)
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
//判断是否获取锁成功
if(isLock){
//DoubleCheck:再次检查缓存是否已经被更新
shopJson = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson)){
RedisData newRedisData = JSONUtil.toBean(shopJson, RedisData.class);
if(newRedisData.getExpireTime().isAfter(LocalDateTime.now())){
//缓存已被其他线程更新,直接返回新数据
unlock(lockKey);//先释放锁
return JSONUtil.toBean((JSONObject) newRedisData.getData(), Shop.class);
}
}
//成功:开启独立线程,异步重建缓存(用户请求不等待)
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//调用预热方法,重建缓存(设置新的逻辑过期时间)
this.saveShop2Redis(id, 1800L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁(异步线程中释放,避免死锁)
unlock(lockKey);
}
});
}
//无论是否获取锁,都返回过期的店铺数据(用户无感知延迟)
return shop;
}
封装 Redis 工具类:
CacheClient:
java
package com.hmdp.utils;
@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;
//从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if(StrUtil.isNotBlank(json)){
//存在,直接返回
return JSONUtil.toBean(json,type);//序列化
}
//判断命中的是否是空值
if(json != null){
//返回一个错误信息
return null;
}
//不存在,根据id查询数据库
R r = dbFallback.apply(id);
//不存在,返回错误
if(r == null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//返回错误信息
return null;
}
//存在,写入redis
this.set(key,r,time,unit);
//返回
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;
//第一次检查:从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
//判断是否存在有效缓存
if(StrUtil.isNotBlank(json)){
return JSONUtil.toBean(json, type);
}
//判断是否是缓存穿透的空值标记
if(json != null){
return null;
}
//缓存未命中,准备重建缓存(加互斥锁避免缓存击穿)
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
//获取互斥锁
boolean isLock = tryLock(lockKey);
//未获取到锁,休眠后递归重试
if(!isLock){
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
//DoubleCheck:二次检查缓存(核心优化!)
//原因:可能其他线程已经在当前线程等待锁时重建了缓存
json = stringRedisTemplate.opsForValue().get(key);
//二次检查到有效缓存,直接返回
if (StrUtil.isNotBlank(json)){
return JSONUtil.toBean(json, type);
}
//二次检查到空值标记,直接返回
if(json != null){
unlock(lockKey);
return null;
}
//真正需要重建缓存,查询数据库
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);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//最终释放锁(保证锁一定被释放,避免死锁)
unlock(lockKey);
}
return r;
}
public <R, ID> R queryWithLogicExpire(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
//从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
//判断是否存在(缓存未命中)
if(StrUtil.isBlank(json)){
//缓存未命中直接返回null(预热过的热点数据一定有缓存)
return null;
}
//命中,把json反序列化为RedisData对象(包含数据+逻辑过期时间)
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
//解析出店铺数据(RedisData的data字段是JSON对象,需二次反序列化)
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){
//DoubleCheck:再次检查缓存是否已经被更新
json = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(json)){
RedisData newRedisData = JSONUtil.toBean(json, RedisData.class);
if(newRedisData.getExpireTime().isAfter(LocalDateTime.now())){
//缓存已被其他线程更新,直接返回新数据
unlock(lockKey);//先释放锁
return JSONUtil.toBean((JSONObject) newRedisData.getData(), type);
}
}
//成功:开启独立线程,异步重建缓存(用户请求不等待)
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);
}
});
}
//无论是否获取锁,都返回过期的店铺数据(用户无感知延迟)
return r;
}
private boolean tryLock(String key){
//SET key "1" NX EX 10
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key){
stringRedisTemplate.delete(key);
}
}
ShopServiceImpl:
java
@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("店铺不存在!");
}
//返回
return Result.ok(shop);
}