Redis(实战篇)

短信登录:

导入项目:

导入 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);
}
相关推荐
总要冲动一次1 小时前
MySQL 5.7 全量 + 增量备份方案(本地执行 + 远程存储)
数据库·mysql·adb
猿小喵1 小时前
MySQL数据库源码调试
数据库·mysql
Qlittleboy1 小时前
thinkphp如何配置模版缓存,来显著提高页面加载速度
缓存·php
WangJunXiang61 小时前
Mysql数据库操作
数据库·mysql·oracle
星辰_mya1 小时前
三级缓存破局:Spring 如何优雅解决循环依赖?
java·spring·缓存·面试
2401_858936881 小时前
51 单片机入门踩坑实录:从编译报错到数码管显示 1234 的完整解决
数据库
洛邙1 小时前
互联网大厂Java求职面试实录:Spring Boot与微服务实战解析
java·spring boot·缓存·微服务·面试·分布式事务·电商
java1234_小锋1 小时前
Java高频面试题:Spring框架中的单例bean是线程安全的吗?
java·数据库·spring
代码探秘者1 小时前
【大模型应用】5.深入理解向量数据库
java·数据库·后端·python·spring·面试
2401_832035341 小时前
使用Python处理计算机图形学(PIL/Pillow)
jvm·数据库·python