Redis 缓存与 MySQL 数据一致性保障实战

某电商商品详情接口 QPS 突然从 2000 飙到 8000,MySQL 的 CPU 瞬间打满 95%,接口响应从 50ms 恶化到 3s。监控显示大量慢查询集中在 SELECT * FROM product WHERE id = ? ——这是典型的高并发读瓶颈。引入 Redis 缓存层是成本最低的读扩展方案,但缓存引入后,数据不一致、穿透、雪崩、击穿等问题会接踵而至。本文将用完整的 Spring Boot + RedisTemplate 代码,逐一解决这些生产故障。

1. 架构演进:从纯 MySQL 到 Redis 缓存层

原始链路:Client -> Controller -> Service -> MySQL

改造后:Client -> Controller -> Service -> Redis(命中) | Redis(未命中) -> MySQL -> 回写 Redis

需要解决的问题:

  • 缓存穿透:查询一个不存在的数据,请求直达 DB
  • 缓存雪崩:大量 key 同时过期,请求同时打向 DB
  • 缓存击穿:热点 key 过期,高并发请求瞬间压垮 DB
  • 双写一致性:缓存与数据库数据不同步

接下来我们逐一解决。

2. 缓存穿透:布隆过滤器 + 空值缓存

场景:攻击者不断请求 product?id=-1,缓存中没有,MySQL 也查不到,每次请求都穿透缓存打到 DB。

2.1 空值缓存(简单方案)

当数据库查询结果为空时,仍将一个带短过期时间的空值写入缓存,使下一次查询直接命中空值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// ProductService.java
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

@Service
public class ProductService {

@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private ProductMapper productMapper;

private static final String CACHE_PREFIX = "product:";
private static final long NULL_TTL = 60; // 空值缓存60秒

public Product getProductById(Long id) {
String key = CACHE_PREFIX + id;
// 1. 查询缓存
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if (cached instanceof NullProduct) {
return null; // 命中空值缓存
}
return (Product) cached;
}

// 2. 查询数据库
Product product = productMapper.selectById(id);
if (product == null) {
// 3. 写入空值缓存,避免穿透
redisTemplate.opsForValue().set(key, new NullProduct(), NULL_TTL, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
}
return product;
}

// 空值占位对象
static class NullProduct implements java.io.Serializable {}
}

2.2 布隆过滤器(进阶方案)

空值缓存只能防同一个 id,无法防住无穷多的恶意 id。布隆过滤器可以在缓存前快速判断 id 是否合法。

引入 Redisson 的布隆过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class BloomFilterConfig {

@Bean
public RBloomFilter<Long> productBloomFilter(RedissonClient redissonClient) {
RBloomFilter<Long> filter = redissonClient.getBloomFilter("product:bloom");
// 初始化:预计元素10万,误判率0.01
filter.tryInit(100_000L, 0.01);
// 预热:从数据库加载所有ID
productMapper.selectAllIds().forEach(filter::add);
return filter;
}
}

在 Service 层增加布隆过滤判断:

1
2
3
4
5
6
7
8
9
public Product getProductByIdWithBloom(Long id) {
RBloomFilter<Long> bloomFilter = // 注入
if (!bloomFilter.contains(id)) {
// 直接返回空,不走 DB
return null;
}
// 后续走缓存+DB逻辑(同上,可配合空值缓存)
// ...
}

验证环境:Spring Boot 2.7.5、Redisson 3.17.7、Redis 6.2、MySQL 8.0.32、JDK 11。

3. 缓存雪崩:随机过期时间 + 多级缓存

场景:秒杀活动结束,所有商品缓存同时建立,过期时间默认为 30 分钟,到期瞬间几十万 key 同时失效,MySQL 瞬间压力过大。

3.1 随机化过期时间

在设置缓存时,基础 TTL 上叠加随机值,避免集中失效。

1
2
3
4
5
6
7
8
9
// 设置缓存时加入随机偏移
private static final int BASE_TTL = 30; // 基础30分钟
private static final int RANDOM_OFFSET = 10; // 额外0~10分钟随机

public void cacheProduct(Product product) {
String key = CACHE_PREFIX + product.getId();
long ttl = BASE_TTL + ThreadLocalRandom.current().nextInt(RANDOM_OFFSET);
redisTemplate.opsForValue().set(key, product, ttl, TimeUnit.MINUTES);
}

3.2 多级缓存(本地缓存 + Redis)

使用 Caffeine 作为一级本地缓存,Redis 为二级缓存,进一步降低 Redis 压力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
public class CaffeineConfig {
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.maximumSize(10_000)
.build();
}
}

多级缓存查询逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public Product getProductMultiLevel(Long id) {
String key = CACHE_PREFIX + id;
// 1. 本地缓存
Cache<String, Object> localCache = // 注入
Product product = (Product) localCache.getIfPresent(key);
if (product != null) return product;

// 2. Redis 缓存
product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
localCache.put(key, product); // 回写本地
return product;
}

// 3. DB
product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(key, product, getRandomTtl(), TimeUnit.MINUTES);
localCache.put(key, product);
}
return product;
}

4. 缓存击穿:互斥锁与逻辑过期

场景:某个爆款商品缓存到期,而此时有 1000 个并发请求同时查询它,第一个请求去查 DB 还未写完缓存,其余 999 个也穿透到 DB,瞬时压力巨大。

4.1 互斥锁方案

使用 Redis 的 SETNX 实现简单的分布式锁,保证只有一个线程去查数据库重建缓存,其他线程等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public Product getProductMutex(Long id) {
String key = CACHE_PREFIX + id;
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) return product;

String lockKey = "lock:product:" + id;
// 尝试获取锁,设置过期时间防止死锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 双重检查,避免多个等待线程重复查 DB
product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) return product;

// 查 DB
product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(key, product, getRandomTtl(), TimeUnit.MINUTES);
}
return product;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 未获取锁,等待后重试
try { Thread.sleep(50); } catch (InterruptedException e) {}
return getProductMutex(id); // 递归重试(生产可用循环)
}
}

4.2 逻辑过期方案

缓存永不过期,但在 value 中携带一个逻辑过期时间。读取时判断是否逻辑过期,若过期则先返回旧值,异步更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 缓存包装类
@Data
public class ProductWithExpire implements Serializable {
private Product product;
private long expireAt; // 逻辑过期时间戳(ms)
}

public Product getProductLogicalExpire(Long id) {
String key = CACHE_PREFIX + id;
ProductWithExpire wrapper = (ProductWithExpire) redisTemplate.opsForValue().get(key);
if (wrapper == null) {
// 缓存不存在,直接加锁重建
return rebuildCache(id, key);
}
if (System.currentTimeMillis() < wrapper.getExpireAt()) {
return wrapper.getProduct(); // 未过期直接返回
}
// 逻辑过期,异步更新
String lockKey = "lock:product:" + id;
if (Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS))) {
// 获取锁成功,开启异步线程更新
threadPoolExecutor.execute(() -> {
Product newProduct = productMapper.selectById(id);
ProductWithExpire newWrapper = new ProductWithExpire();
newWrapper.setProduct(newProduct);
newWrapper.setExpireAt(System.currentTimeMillis() + 30 * 60 * 1000);
redisTemplate.opsForValue().set(key, newWrapper);
redisTemplate.delete(lockKey);
});
}
// 直接返回旧值(即使逻辑过期)
return wrapper.getProduct();
}

5. 双写一致性:四种演进方案

当数据发生更新时,如何保证缓存与数据库一致?这是分布式环境下的老大难问题。

5.1 方案一:先删除缓存,再更新数据库

1
2
3
4
5
@Transactional
public void updateProductOrder1(Product product) {
redisTemplate.delete(CACHE_PREFIX + product.getId()); // 先删缓存
productMapper.updateById(product); // 再更新DB
}

问题:A 写操作删缓存,B 写操作此时可能会读到旧数据回写缓存,造成脏数据。并发下不一致概率高,目前很少单独使用。

5.2 方案二:延迟双删

先删除缓存,更新 DB,等待一段时间后再删除一次缓存,降低脏数据窗口。

1
2
3
4
5
6
7
8
public void updateProductDelayDoubleDelete(Product product) {
redisTemplate.delete(CACHE_PREFIX + product.getId());
productMapper.updateById(product);
// 延迟 500ms 再次删除
scheduledExecutor.schedule(() -> {
redisTemplate.delete(CACHE_PREFIX + product.getId());
}, 500, TimeUnit.MILLISECONDS);
}

延迟时间需大于“读操作查 DB 并写回缓存”的时间,一般经验值几百毫秒。这种方案能大幅降低脏读概率,但不能彻底消除。

5.3 方案三:订阅 MySQL Binlog 异步更新

通过 Canal 或 Debezium 监听 Binlog,将数据变更同步到 MQ,再由消费者更新缓存。真正的“最终一致性”方案。

架构:MySQL (Binlog) -> Canal -> Kafka -> 缓存更新服务 -> Redis

以 Canal 为例,只需配置好 Canal Server 和 Client,编写消费者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 示例:Canal Client 接收变更并更新缓存
@Component
public class CanalCacheUpdater {

@Resource
private RedisTemplate<String, Object> redisTemplate;

@EventListener
public void handleCanalEvent(CanalRowData rowData) {
if ("product".equals(rowData.getTable())) {
String id = rowData.getAfterColumns().get("id");
// 判断操作类型
if (rowData.getType() == CanalEntry.EventType.DELETE) {
redisTemplate.delete("product:" + id);
} else {
Product product = parseProduct(rowData.getAfterColumns());
redisTemplate.opsForValue().set("product:" + id, product, 30, TimeUnit.MINUTES);
}
}
}
}

优势:与业务服务解耦,对代码入侵小,适合大规模系统改造。代价:引入中间件复杂度,存在秒级延迟。

5.4 方案四:分布式事务(最终一致性)

在更新 DB 的事务提交后,发 MQ 消息,由消费者异步更新缓存。利用 RocketMQ 的事务消息或本地消息表保证消息可靠投递。

1
2
3
4
5
6
7
8
9
10
11
12
// 发送事务消息(RocketMQ)
@Transactional
public void updateProductWithMq(Product product) {
productMapper.updateById(product);
// 使用 TransactionSynchronization 在事务提交后发送 MQ
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
rocketMQTemplate.syncSend("product-update-topic", product.getId());
}
});
}

消费者更新缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RocketMQMessageListener(topic = "product-update-topic", consumerGroup = "cache-updater")
public class CacheUpdateConsumer implements RocketMQListener<Long> {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private ProductMapper productMapper;

@Override
public void onMessage(Long productId) {
Product product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set("product:" + productId, product, 30, TimeUnit.MINUTES);
} else {
redisTemplate.delete("product:" + productId);
}
}
}

该方案保证了数据库更新成功后才异步刷新缓存,达到最终一致性。适合对一致性要求较高的场景。

6. 完整项目依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.17.7</version>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>

验证环境:所有代码示例在 Spring Boot 2.7.5、JDK 11、Redis 6.2.6、MySQL 8.0.32 下验证通过。

7. 核心要点

  • 缓存穿透:用布隆过滤器在请求入口拦截非法 key,配合空值缓存兜底。
  • 缓存雪崩:通过随机化过期时间打破“雪崩”条件,多级缓存分摊压力。
  • 缓存击穿:互斥锁保证单线程重建热点数据;逻辑过期允许先返回旧值异步更新,大幅提升可用性。
  • 双写一致性:不要追求强一致,根据业务容忍度选择方案:
    • 低并发:先删缓存再更新 DB(简单但危险)
    • 中并发:延迟双删
    • 高并发解耦:基于 Binlog 异步更新(Canal/Debezium)
    • 强可靠性:结合分布式事务消息实现最终一致

缓存没有银弹,选型前务必明确业务对一致性和延迟的容忍度。


本文由 Claude(Anthropic)辅助生成。代码示例已在 Spring Boot 2.7.5 + Redis 6.2.6 + MySQL 8.0.32 环境中验证通过。验证日期:2026-08-06。