【Java】以 Java + Redis + MySQL 为技术栈,模拟电商商品详情的读写场景,Cache Aside+ 延迟双删 方案

针对具体场景的缓存-数据库一致性代码示例,我选择最通用、覆盖90%以上业务场景的 Cache Aside(旁路缓存)+ 延迟双删 方案,以 Java + Redis + MySQL 为例,模拟电商商品详情的读写场景,代码可直接落地,同时标注关键注意事项。


Redis 是一个速度极快的内存数据结构存储系统 ,它不仅可以作为数据库,更常被用作缓存消息队列。它支持字符串、哈希、列表、集合等多种数据类型。

为什么要用 Redis?

引入 Redis 的核心目的是抗住高并发、提升系统性能

  1. 极速读写: 数据主要存储在内存中,读写速度通常是微秒或毫秒级,比磁盘数据库(如 MySQL)快几个数量级。
  2. 减轻数据库压力: 将高频访问的数据(热点数据)放在 Redis 中,让请求在缓存层就被拦截,避免大量请求直接打到后端数据库,防止数据库被压垮。
  3. 支撑高并发: Redis 单机可以支撑数万甚至十万级的 QPS(每秒查询率),是构建高并发系统不可或缺的组件。

然而,在实际使用中,如果设计不当,会出现缓存穿透、击穿、雪崩三大问题。

🔍 缓存穿透、击穿、雪崩

这三者都是导致缓存失效,请求直接打到数据库的情况,但触发原因和解决方案各有不同。

🔎 缓存穿透

  • 现象: 请求的数据在缓存中不存在,数据库中也不存在
  • 原因: 通常是恶意攻击者或爬虫,构造大量不存在的 ID(如负数 ID、随机字符串)进行请求。由于数据库查不到,缓存也不会写入,导致每次请求都穿透缓存直达数据库。
  • 解决方案:
    1. 缓存空值: 当查询数据库结果为 null 时,也将这个空值写入缓存,并设置一个较短的过期时间(如 5 分钟)。这样后续的相同请求会直接命中缓存,不会到达数据库。
    2. 布隆过滤器: 在访问缓存和数据库之前,先通过布隆过滤器判断请求的 ID 是否可能存在。如果布隆过滤器判断不存在,则直接拦截请求,返回空值。
    3. 参数校验: 对请求参数进行合法性检查,如 ID 格式、范围校验,从源头拦截非法请求。

💥 缓存击穿

  • 现象: 某个热点数据 (如爆款商品、热门新闻)在缓存中刚好过期,瞬间有大量并发请求同时访问该数据。
  • 原因: 热点 key 失效的瞬间,所有请求同时穿透缓存,全部打到数据库,造成瞬时压力激增。
  • 解决方案:
    1. 互斥锁: 当缓存失效时,不是让所有请求都去查询数据库,而是使用分布式锁(如 Redis 的 SETNX 命令)只让一个线程去重建缓存。其他线程等待锁释放后,直接从缓存中获取数据。
    2. 逻辑过期: 不设置缓存的物理过期时间(TTL),而是在缓存的 value 中存储一个逻辑过期时间。当请求发现数据已"逻辑过期",则通过后台线程异步更新缓存,而当前请求依然返回旧值,保证服务不中断。

❄️ 缓存雪崩

  • 现象:同一时间 ,有大量 缓存数据同时失效,或者 Redis 实例宕机
  • 原因: 通常是因为在代码中为大量 key 设置了相同的过期时间,导致它们在同一时刻集体失效。或者 Redis 服务本身出现故障。这会导致瞬间大量请求穿透,数据库可能直接被压垮。
  • 解决方案:
    1. 过期时间随机化: 在设置 key 的过期时间时,增加一个随机值(如基础时间 + 0~300秒的随机数),让 key 的失效时间分散开来,避免集体失效。
    2. 多级缓存: 采用本地缓存(如 Caffeine)+ Redis 分布式缓存的架构。即使 Redis 失效,本地缓存还能短暂地兜底,抗住一部分流量。
    3. 高可用架构: 使用 Redis 哨兵(Sentinel)或 Redis Cluster 集群模式,避免单点故障导致的 Redis 宕机。
    4. 服务熔断与降级: 当检测到 Redis 不可用或数据库压力过大时,通过熔断机制(如 Sentinel)直接拦截请求,返回预设的降级信息(如"系统繁忙,请稍后"),保护数据库不被拖垮。

🔒 分布式锁的使用方法

在分布式系统中,为了保证多个节点对共享资源的互斥访问(例如:防止库存超卖),需要使用分布式锁。

基本实现 (SETNX)

最简单的实现是使用 Redis 的 SETNX (SET if Not eXists) 命令。

  • 加锁: SETNX lock_key random_value。只有当 key 不存在时,才能设置成功,返回 1,表示获取锁成功。
  • 设置过期时间: 必须同时为锁 key 设置一个过期时间(EXPIRE),防止持有锁的节点宕机导致死锁。
  • 解锁: 为了保证安全性,解锁操作需要使用 Lua 脚本,先校验 value 是否为自己设置的值,再删除 key,防止误删其他线程的锁。

生产环境最佳实践 (使用 Redisson)

在实际项目中,强烈建议使用成熟的客户端库,如 Redisson,而不是手写复杂的锁逻辑。

  1. 引入依赖: 在项目中引入 Redisson 客户端。

  2. 获取锁对象:

    RLock lock = redissonClient.getLock("seckill_lock:" + productId);

  3. 尝试获取锁:

    // 尝试加锁,最多等待3秒,上锁后30秒自动解锁

    boolean isLocked = lock.tryLock(3, 30, TimeUnit.SECONDS);

  4. 执行业务逻辑: 如果获取锁成功,则执行扣减库存等核心业务逻辑。

  5. 释放锁: 在 finally 块中调用 lock.unlock(),确保锁一定被释放。

Redisson 的优势:

  • 自动续期(Watchdog): 如果业务逻辑执行时间较长,Redisson 会自动延长锁的过期时间,防止业务还没执行完锁就过期了。
  • 可重入: 同一个线程可以多次获取同一把锁。
  • 防误删: 内部通过 Lua 脚本保证了校验和删除的原子性。

Redis 是提升系统性能的利器,但需要通过合理的策略防御缓存异常,并利用成熟的工具(如 Redisson)来正确使用分布式锁,才能构建稳定可靠的系统。

一、技术栈与前置准备

  • 开发语言:Java(Spring Boot)
  • 缓存:Redis(使用 RedisTemplate
  • 数据库:MySQL(使用 MyBatis/MyBatis-Plus)
  • 核心依赖(pom.xml 关键配置):
xml 复制代码
<!-- Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MySQL + MyBatis-Plus -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.3.1</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<!-- 线程池(用于延迟双删) -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>32.1.3-jre</version>
</dependency>

二、核心代码实现

1. 商品实体类(Product.java)
java 复制代码
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.math.BigDecimal;

@Data
@TableName("product")
public class Product {
    // 商品ID(缓存Key的核心标识)
    @TableId(type = IdType.AUTO)
    private Long id;
    // 商品名称
    private String name;
    // 商品价格
    private BigDecimal price;
    // 商品库存
    private Integer stock;
}
2. 缓存工具类(RedisCacheUtil.java)

封装Redis操作,统一缓存Key规则和过期时间:

java 复制代码
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

@Component
public class RedisCacheUtil {
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    // 缓存Key前缀(避免Key冲突)
    private static final String PRODUCT_CACHE_PREFIX = "product:info:";
    // 缓存过期时间:30分钟(兜底,防止脏数据长期存在)
    private static final long CACHE_EXPIRE_TIME = 30L;

    /**
     * 构建商品缓存Key
     */
    public String buildProductKey(Long productId) {
        return PRODUCT_CACHE_PREFIX + productId;
    }

    /**
     * 读取缓存
     */
    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    /**
     * 写入缓存(带过期时间)
     */
    public void set(String key, Object value) {
        redisTemplate.opsForValue().set(key, value, CACHE_EXPIRE_TIME, TimeUnit.MINUTES);
    }

    /**
     * 删除缓存
     */
    public void delete(String key) {
        redisTemplate.delete(key);
    }
}
3. 线程池配置(ThreadPoolConfig.java)

用于延迟双删,避免阻塞主线程:

java 复制代码
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

@Configuration
public class ThreadPoolConfig {

    /**
     * 延迟双删专用线程池
     */
    @Bean("delayDeleteExecutor")
    public ExecutorService delayDeleteExecutor() {
        return new ThreadPoolExecutor(
                2, // 核心线程数
                5, // 最大线程数
                60L, TimeUnit.SECONDS, // 空闲线程存活时间
                new LinkedBlockingQueue<>(1000), // 任务队列
                new ThreadFactoryBuilder().setNameFormat("delay-delete-%d").build(), // 线程命名
                new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略(主线程执行)
        );
    }
}
4. 核心业务逻辑(ProductService.java)

实现 Cache Aside + 延迟双删的读写逻辑:

java 复制代码
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;

@Service
public class ProductService extends ServiceImpl<ProductMapper, Product> {

    @Resource
    private RedisCacheUtil redisCacheUtil;
    @Resource(name = "delayDeleteExecutor")
    private ExecutorService delayDeleteExecutor;

    // 延迟双删的延迟时间:500ms(需根据业务压测调整,大于读请求加载缓存的时间)
    private static final long DELAY_DELETE_TIME = 500L;

    /**
     * 读流程:Cache Aside 核心逻辑(读缓存→未命中读DB→写缓存)
     */
    public Product getProductById(Long productId) {
        // 1. 先读缓存
        String cacheKey = redisCacheUtil.buildProductKey(productId);
        Object cacheObj = redisCacheUtil.get(cacheKey);
        if (cacheObj != null) {
            return (Product) cacheObj;
        }

        // 2. 缓存未命中,读数据库
        Product product = this.getById(productId);
        if (product == null) {
            // 缓存空值(防止缓存穿透),过期时间短一点(比如5分钟)
            redisCacheUtil.set(cacheKey, null, 5L, TimeUnit.MINUTES);
            return null;
        }

        // 3. 将数据库数据写入缓存
        redisCacheUtil.set(cacheKey, product);
        return product;
    }

    /**
     * 写流程:Cache Aside + 延迟双删(更新DB→删缓存→延迟再删一次)
     */
    @Transactional(rollbackFor = Exception.class) // 保证数据库操作事务性
    public boolean updateProductStock(Long productId, Integer newStock) {
        try {
            // 1. 更新数据库(事务内执行)
            LambdaUpdateWrapper<Product> updateWrapper = new LambdaUpdateWrapper<>();
            updateWrapper.eq(Product::getId, productId)
                         .set(Product::getStock, newStock);
            boolean updateResult = this.update(updateWrapper);
            if (!updateResult) {
                return false;
            }

            // 2. 第一次删除缓存(核心:删缓存而非更缓存)
            String cacheKey = redisCacheUtil.buildProductKey(productId);
            redisCacheUtil.delete(cacheKey);

            // 3. 延迟双删:异步延迟500ms再删一次,解决并发读写脏数据
            delayDeleteExecutor.submit(() -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(DELAY_DELETE_TIME);
                    redisCacheUtil.delete(cacheKey);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    log.error("延迟删除缓存失败,productId:{}", productId, e);
                }
            });

            return true;
        } catch (Exception e) {
            log.error("更新商品库存失败,productId:{}", productId, e);
            return false;
        }
    }
}
5. 补充:缓存删除失败的重试机制(可选)

如果Redis删除失败(比如网络抖动),可结合消息队列(如RocketMQ/RabbitMQ)做重试,核心思路:

java 复制代码
// 伪代码:删除缓存失败时发送消息到MQ,消费端重试删除
private void deleteCacheWithRetry(String cacheKey) {
    try {
        redisCacheUtil.delete(cacheKey);
    } catch (Exception e) {
        // 发送重试消息到MQ(设置重试次数,比如3次)
        mqProducer.send("cache_delete_topic", cacheKey);
    }
}

// MQ消费端
@RabbitListener(queues = "cache_delete_queue")
public void handleCacheDelete(String cacheKey) {
    int retryCount = 3;
    while (retryCount > 0) {
        try {
            redisCacheUtil.delete(cacheKey);
            break;
        } catch (Exception e) {
            retryCount--;
            // 指数退避重试(1s, 2s, 4s)
            TimeUnit.SECONDS.sleep((long) Math.pow(2, 3 - retryCount));
        }
    }
}

三、关键代码解释

  1. 读流程核心

    • 优先读缓存,命中直接返回,未命中才读数据库;
    • 缓存空值(防止缓存穿透),且空值过期时间更短,避免长期缓存无效数据。
  2. 写流程核心

    • 先更新数据库(加事务保证原子性),再删除缓存(而非更新),避免并发更新导致的缓存脏数据;
    • 延迟双删:异步延迟500ms再删一次缓存,解决"读请求在写请求删除缓存前读取了旧数据并写入缓存"的极端场景。
  3. 线程池作用

    • 延迟双删的异步操作通过独立线程池执行,避免阻塞主线程的业务逻辑,保证接口响应速度。
  4. 过期时间兜底

    • 所有缓存都设置过期时间,即使删除缓存失败,过期后也会自动刷新,避免脏数据长期存在。

四、测试用例(模拟并发场景)

java 复制代码
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@SpringBootTest
public class ProductServiceTest {

    @Resource
    private ProductService productService;

    // 模拟100个并发读 + 1个写
    @Test
    public void testCacheConsistency() throws InterruptedException {
        Long productId = 1L;
        int readThreadCount = 100;
        CountDownLatch countDownLatch = new CountDownLatch(readThreadCount + 1);

        // 100个读线程
        ExecutorService readExecutor = Executors.newFixedThreadPool(readThreadCount);
        for (int i = 0; i < readThreadCount; i++) {
            readExecutor.submit(() -> {
                try {
                    Product product = productService.getProductById(productId);
                    System.out.println("读线程:" + Thread.currentThread().getName() + ",库存:" + (product == null ? "null" : product.getStock()));
                } finally {
                    countDownLatch.countDown();
                }
            });
        }

        // 1个写线程(更新库存)
        new Thread(() -> {
            try {
                // 延迟100ms执行写操作,模拟并发
                Thread.sleep(100);
                productService.updateProductStock(productId, 999);
                System.out.println("写线程:更新库存为999");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                countDownLatch.countDown();
            }
        }).start();

        countDownLatch.await();
        readExecutor.shutdown();

        // 最终校验缓存和数据库数据一致
        Product dbProduct = productService.getBaseMapper().selectById(productId);
        Product cacheProduct = (Product) redisCacheUtil.get(redisCacheUtil.buildProductKey(productId));
        System.out.println("最终数据库库存:" + dbProduct.getStock());
        System.out.println("最终缓存库存:" + (cacheProduct == null ? "null" : cacheProduct.getStock()));
    }
}

总结

  1. 核心方案 :优先使用 Cache Aside(旁路缓存) 作为基础,高并发场景叠加 延迟双删,90%的业务场景可覆盖;
  2. 关键要点
    • 写操作只删缓存不更缓存,避免并发覆盖;
    • 所有缓存必须设置过期时间,作为脏数据兜底;
    • 延迟双删的延迟时间需压测确定(通常500ms~1s);
  3. 兜底保障:缓存删除失败时,通过消息队列重试 + 定时任务校验(比如每天凌晨对比缓存和数据库数据),确保最终一致性。
相关推荐
爱敲代码的TOM2 小时前
详解布隆过滤器及其实战案例
redis·布隆过滤器
SuperherRo2 小时前
JAVA攻防-Ys项目Gadget链分析&CC2&CC4&CC5&CC7&入口点改动&触发点改动
java·cc2·cc4·cc5·cc7·gadget链
2501_944526422 小时前
Flutter for OpenHarmony 万能游戏库App实战 - 关于页面实现
android·java·开发语言·javascript·python·flutter·游戏
毕设源码-赖学姐2 小时前
【开题答辩全过程】以 高校实验室教学管理系统的设计和实现为例,包含答辩的问题和答案
java
田地和代码2 小时前
linux应用用户安装jdk以后 如果root安装hbase客户端需要jdk还需要再次安装吗
java·linux·hbase
Dem12 小时前
怎么安装jdk
java·开发语言
咸鱼2.02 小时前
【java入门到放弃】VUE部分知识点
java·javascript·vue.js
浔川python社2 小时前
《C++ 小程序编写系列》(第六部)
java·网络·rpc
それども2 小时前
怎么理解 HttpServletRequest @Autowired注入
java