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
| 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;
public Product getProductById(Long id) { String key = CACHE_PREFIX + id; Object cached = redisTemplate.opsForValue().get(key); if (cached != null) { if (cached instanceof NullProduct) { return null; } return (Product) cached; }
Product product = productMapper.selectById(id); if (product == null) { 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"); filter.tryInit(100_000L, 0.01); 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)) { return null; } }
|
验证环境: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; private static final int RANDOM_OFFSET = 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; Cache<String, Object> localCache = Product product = (Product) localCache.getIfPresent(key); if (product != null) return product;
product = (Product) redisTemplate.opsForValue().get(key); if (product != null) { localCache.put(key, product); return product; }
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 { product = (Product) redisTemplate.opsForValue().get(key); if (product != null) return product;
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; }
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); }
|
问题: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); 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
| @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
| @Transactional public void updateProductWithMq(Product product) { productMapper.updateById(product); 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。