针对具体场景的缓存-数据库一致性代码示例,我选择最通用、覆盖90%以上业务场景的 Cache Aside(旁路缓存)+ 延迟双删 方案,以 Java + Redis + MySQL 为例,模拟电商商品详情的读写场景,代码可直接落地,同时标注关键注意事项。
Redis 是一个速度极快的内存数据结构存储系统 ,它不仅可以作为数据库,更常被用作缓存 和消息队列。它支持字符串、哈希、列表、集合等多种数据类型。
为什么要用 Redis?
引入 Redis 的核心目的是抗住高并发、提升系统性能。
- 极速读写: 数据主要存储在内存中,读写速度通常是微秒或毫秒级,比磁盘数据库(如 MySQL)快几个数量级。
- 减轻数据库压力: 将高频访问的数据(热点数据)放在 Redis 中,让请求在缓存层就被拦截,避免大量请求直接打到后端数据库,防止数据库被压垮。
- 支撑高并发: Redis 单机可以支撑数万甚至十万级的 QPS(每秒查询率),是构建高并发系统不可或缺的组件。
然而,在实际使用中,如果设计不当,会出现缓存穿透、击穿、雪崩三大问题。
🔍 缓存穿透、击穿、雪崩
这三者都是导致缓存失效,请求直接打到数据库的情况,但触发原因和解决方案各有不同。
🔎 缓存穿透
- 现象: 请求的数据在缓存中不存在,数据库中也不存在。
- 原因: 通常是恶意攻击者或爬虫,构造大量不存在的 ID(如负数 ID、随机字符串)进行请求。由于数据库查不到,缓存也不会写入,导致每次请求都穿透缓存直达数据库。
- 解决方案:
- 缓存空值: 当查询数据库结果为 null 时,也将这个空值写入缓存,并设置一个较短的过期时间(如 5 分钟)。这样后续的相同请求会直接命中缓存,不会到达数据库。
- 布隆过滤器: 在访问缓存和数据库之前,先通过布隆过滤器判断请求的 ID 是否可能存在。如果布隆过滤器判断不存在,则直接拦截请求,返回空值。
- 参数校验: 对请求参数进行合法性检查,如 ID 格式、范围校验,从源头拦截非法请求。
💥 缓存击穿
- 现象: 某个热点数据 (如爆款商品、热门新闻)在缓存中刚好过期,瞬间有大量并发请求同时访问该数据。
- 原因: 热点 key 失效的瞬间,所有请求同时穿透缓存,全部打到数据库,造成瞬时压力激增。
- 解决方案:
- 互斥锁: 当缓存失效时,不是让所有请求都去查询数据库,而是使用分布式锁(如 Redis 的 SETNX 命令)只让一个线程去重建缓存。其他线程等待锁释放后,直接从缓存中获取数据。
- 逻辑过期: 不设置缓存的物理过期时间(TTL),而是在缓存的 value 中存储一个逻辑过期时间。当请求发现数据已"逻辑过期",则通过后台线程异步更新缓存,而当前请求依然返回旧值,保证服务不中断。
❄️ 缓存雪崩
- 现象: 在同一时间 ,有大量 缓存数据同时失效,或者 Redis 实例宕机。
- 原因: 通常是因为在代码中为大量 key 设置了相同的过期时间,导致它们在同一时刻集体失效。或者 Redis 服务本身出现故障。这会导致瞬间大量请求穿透,数据库可能直接被压垮。
- 解决方案:
- 过期时间随机化: 在设置 key 的过期时间时,增加一个随机值(如基础时间 + 0~300秒的随机数),让 key 的失效时间分散开来,避免集体失效。
- 多级缓存: 采用本地缓存(如 Caffeine)+ Redis 分布式缓存的架构。即使 Redis 失效,本地缓存还能短暂地兜底,抗住一部分流量。
- 高可用架构: 使用 Redis 哨兵(Sentinel)或 Redis Cluster 集群模式,避免单点故障导致的 Redis 宕机。
- 服务熔断与降级: 当检测到 Redis 不可用或数据库压力过大时,通过熔断机制(如 Sentinel)直接拦截请求,返回预设的降级信息(如"系统繁忙,请稍后"),保护数据库不被拖垮。
🔒 分布式锁的使用方法
在分布式系统中,为了保证多个节点对共享资源的互斥访问(例如:防止库存超卖),需要使用分布式锁。
基本实现 (SETNX)
最简单的实现是使用 Redis 的 SETNX (SET if Not eXists) 命令。
- 加锁: SETNX lock_key random_value。只有当 key 不存在时,才能设置成功,返回 1,表示获取锁成功。
- 设置过期时间: 必须同时为锁 key 设置一个过期时间(EXPIRE),防止持有锁的节点宕机导致死锁。
- 解锁: 为了保证安全性,解锁操作需要使用 Lua 脚本,先校验 value 是否为自己设置的值,再删除 key,防止误删其他线程的锁。
生产环境最佳实践 (使用 Redisson)
在实际项目中,强烈建议使用成熟的客户端库,如 Redisson,而不是手写复杂的锁逻辑。
-
引入依赖: 在项目中引入 Redisson 客户端。
-
获取锁对象:
RLock lock = redissonClient.getLock("seckill_lock:" + productId);
-
尝试获取锁:
// 尝试加锁,最多等待3秒,上锁后30秒自动解锁
boolean isLocked = lock.tryLock(3, 30, TimeUnit.SECONDS);
-
执行业务逻辑: 如果获取锁成功,则执行扣减库存等核心业务逻辑。
-
释放锁: 在 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));
}
}
}
三、关键代码解释
-
读流程核心:
- 优先读缓存,命中直接返回,未命中才读数据库;
- 缓存空值(防止缓存穿透),且空值过期时间更短,避免长期缓存无效数据。
-
写流程核心:
- 先更新数据库(加事务保证原子性),再删除缓存(而非更新),避免并发更新导致的缓存脏数据;
- 延迟双删:异步延迟500ms再删一次缓存,解决"读请求在写请求删除缓存前读取了旧数据并写入缓存"的极端场景。
-
线程池作用:
- 延迟双删的异步操作通过独立线程池执行,避免阻塞主线程的业务逻辑,保证接口响应速度。
-
过期时间兜底:
- 所有缓存都设置过期时间,即使删除缓存失败,过期后也会自动刷新,避免脏数据长期存在。
四、测试用例(模拟并发场景)
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()));
}
}
总结
- 核心方案 :优先使用
Cache Aside(旁路缓存)作为基础,高并发场景叠加延迟双删,90%的业务场景可覆盖; - 关键要点 :
- 写操作只删缓存不更缓存,避免并发覆盖;
- 所有缓存必须设置过期时间,作为脏数据兜底;
- 延迟双删的延迟时间需压测确定(通常500ms~1s);
- 兜底保障:缓存删除失败时,通过消息队列重试 + 定时任务校验(比如每天凌晨对比缓存和数据库数据),确保最终一致性。