Redis与MySQL双写一致性:从问题根源到工程落地
前言
在高并发系统中,Redis 作为缓存层几乎是标配。但只要同时使用了 Redis 和 MySQL,就必然面临一个问题:两份数据如何保持一致?
这个问题在面试中出现频率极高,在生产环境中也是事故高发区。本文从问题根源出发,逐步分析各种方案的优缺点,最终给出可直接落地的工程实践代码。
一、为什么会有双写一致性问题?
先看一个典型场景:用户查询商品库存。
正常流程:
1. 请求到达,先查 Redis 缓存
2. 缓存命中,直接返回库存数量
3. 缓存未命中,查 MySQL,将结果写入 Redis,再返回
这个流程看起来没问题,但当写操作发生时,麻烦就来了:
更新库存流程(朴素实现):
1. 更新 MySQL 库存为 99
2. 更新 Redis 库存为 99
问题在于,这两步操作不是原子的。在步骤 1 和步骤 2 之间,如果:
- 进程崩溃了 → Redis 还是旧值
- 网络超时了 → Redis 更新失败
- 另一个线程并发读取了 → 读到了不一致的数据
根本原因:Redis 和 MySQL 是两个独立的存储系统,没有分布式事务保证两者同时成功或同时失败。
二、四种缓存操作策略
在解决一致性问题之前,先理清四种常见的缓存策略,它们是后续所有方案的理论基础。
2.1 Cache Aside(旁路缓存)
最常用的策略,由应用层自己管理缓存。
读操作:
读缓存 → 命中则返回
→ 未命中则查DB → 写入缓存 → 返回
写操作:
更新DB → 删除缓存(注意:是删除,不是更新)
为什么是删除缓存而不是更新缓存?
更新缓存在并发场景下会引发"写写竞争":两个线程同时更新DB,后更新DB的线程可能先更新了缓存,导致缓存里存的是"旧DB值"。删除缓存则不存在这个问题,下次读取时会从DB重新加载最新值。
2.2 Write Through(直写)
写操作同步更新 DB 和缓存,由缓存层负责写 DB。
写请求 → 写缓存 → 缓存层同步写DB → 返回成功
优点:数据一致性强。缺点:写延迟高,且缓存层需要支持此模式(Redis 原生不支持,需要自己实现)。
2.3 Write Behind(异步回写)
先写缓存,缓存层异步批量写 DB。
写请求 → 写缓存 → 立即返回成功
→ 异步批量刷新到DB
优点:写性能极高。缺点:数据可能丢失(缓存宕机时未刷盘的数据丢失),一致性最弱。
2.4 Read Through(读穿透)
缓存未命中时,由缓存层自动从 DB 加载数据。
读请求 → 查缓存 → 命中则返回
→ 未命中,缓存层自动查DB并回填 → 返回
与 Cache Aside 的区别:Cache Aside 由应用层回填缓存,Read Through 由缓存层自动回填。
策略对比总结:
| 策略 | 写操作 | 一致性 | 性能 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 先写DB,删缓存 | 最终一致 | 高 | 通用,最常用 |
| Write Through | 同步写DB和缓存 | 强一致 | 中 | 写少读多 |
| Write Behind | 先写缓存,异步写DB | 弱一致 | 极高 | 允许数据丢失的写多场景 |
| Read Through | 缓存层自动回填 | 最终一致 | 高 | 读多场景 |
三、Cache Aside 的并发陷阱
Cache Aside 是最常用的策略,但它在并发场景下有几个经典的坑,必须搞清楚。
3.1 先删缓存 vs 先更新DB
方案A:先删缓存,再更新DB(错误做法)
时间线:
T1: 线程A 删除缓存
T2: 线程B 读取缓存,miss,查DB得到旧值,写入缓存
T3: 线程A 更新DB为新值
结果:缓存里是旧值,DB里是新值 → 不一致!
方案B:先更新DB,再删缓存(正确做法)
时间线:
T1: 线程A 更新DB为新值
T2: 线程B 读取缓存,命中旧值(此时缓存还未删除)
T3: 线程A 删除缓存
T4: 线程C 读取缓存,miss,查DB得到新值,写入缓存
结果:最终一致 ✓
方案B 也存在极小概率的不一致窗口(T2 时刻读到旧缓存),但这个窗口极短,且下次读取后会自动修正,属于可接受的最终一致性。
3.2 方案B 的极端并发场景
方案B(先更新DB,再删缓存)虽然是正确做法,但在极端情况下仍有一个低概率的不一致场景,面试中经常被追问:
前提:缓存中的旧值恰好在此时过期(或从未被缓存过)
T1: 线程A 开始更新DB(还未提交)
T2: 线程B 读取缓存,miss,查DB读到旧值(A的事务未提交,读到提交前的值)
T3: 线程A 更新DB提交成功
T4: 线程A 删除缓存(此时缓存里没有数据,删除是空操作)
T5: 线程B 将旧值写入缓存
结果:缓存里是旧值,DB里是新值 → 不一致!
这个场景需要同时满足三个条件:
- 缓存恰好在此时 miss(过期或冷数据)
- 线程B 的读DB操作发生在线程A 事务提交之前
- 线程B 的写缓存操作发生在线程A 删缓存之后
三个条件同时成立的概率极低,且这个不一致会在下次写操作时自动修复。对于绝大多数业务,这是可以接受的。 如果无法接受,则需要升级到读写锁方案(见第六章)。
3.2 删缓存失败怎么办?
先更新DB、再删缓存的方案中,如果删缓存这一步失败了(网络抖动、Redis 宕机),缓存里就会一直是旧值,直到 TTL 过期。
解决方案:引入重试机制
java
@Service
public class ProductService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* 更新商品库存
* 先更新DB,再删缓存,删除失败则发送MQ异步重试
*/
public void updateStock(Long productId, Integer stock) {
// 1. 更新数据库
productMapper.updateStock(productId, stock);
// 2. 删除缓存
String cacheKey = "product:stock:" + productId;
try {
redisTemplate.delete(cacheKey);
} catch (Exception e) {
// 3. 删除失败,发送MQ消息,异步重试
log.error("删除缓存失败,发送重试消息, key={}", cacheKey, e);
rabbitTemplate.convertAndSend("cache.delete.exchange", "cache.delete", cacheKey);
}
}
}
java
@Component
public class CacheDeleteConsumer {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 消费缓存删除重试消息
* 配置死信队列,超过最大重试次数后告警
*/
@RabbitListener(queues = "cache.delete.queue")
public void handleCacheDelete(String cacheKey) {
try {
redisTemplate.delete(cacheKey);
log.info("缓存删除重试成功, key={}", cacheKey);
} catch (Exception e) {
log.error("缓存删除重试失败, key={}", cacheKey, e);
// 抛出异常,触发 RabbitMQ 的重试机制
throw new RuntimeException("缓存删除失败", e);
}
}
}
3.3 TTL 是最后的兜底保障
无论采用哪种方案,给缓存设置合理的 TTL 是必须的。它是所有一致性方案的最后一道防线:即使缓存因为某种极端情况存了脏数据,TTL 到期后也会自动失效,下次读取时从 DB 重新加载。
TTL 设置原则:
| 数据类型 | 建议 TTL | 理由 |
|---|---|---|
| 用户信息、商品详情 | 30 分钟 ~ 2 小时 | 变更不频繁,可以容忍短暂不一致 |
| 库存、价格等敏感数据 | 5 ~ 10 分钟 | 变更较频繁,TTL 要短 |
| 热点排行榜、统计数据 | 1 ~ 5 分钟 | 允许一定延迟,TTL 可以短一些 |
| 验证码、Token | 与业务过期时间一致 | 强业务语义,不能随意设置 |
防止缓存雪崩的 TTL 写法:
java
// 错误:所有 key 使用固定 TTL,可能同时大量过期
redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
// 正确:在基础 TTL 上加随机偏移,打散过期时间
long baseTtl = 30;
long randomOffset = ThreadLocalRandom.current().nextLong(0, 10); // 0~10 分钟随机偏移
redisTemplate.opsForValue().set(key, value, baseTtl + randomOffset, TimeUnit.MINUTES);
四、延迟双删策略
在某些场景下(比如先删缓存再更新DB的旧代码改造),可以使用延迟双删来降低不一致的概率:
java
public void updateWithDelayDoubleDelete(String key, Object data) {
// 第一次删除缓存
redisTemplate.delete(key);
// 更新数据库
db.update(data);
// 延迟一段时间后,第二次删除缓存
// 目的:覆盖掉在"删缓存→更新DB"期间,其他线程写入的脏缓存
CompletableFuture.runAsync(() -> {
try {
// 延迟时间 > 一次读DB+写缓存的耗时,通常 500ms~1s
Thread.sleep(500);
redisTemplate.delete(key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
延迟双删的局限性:
- 延迟时间难以精确估算,设短了不够,设长了影响一致性窗口
- 异步线程本身也可能失败
- 只是降低了不一致概率,并不能完全消除
结论:延迟双删适合作为临时补丁,不建议作为长期方案。
五、Canal + MQ 方案(推荐的工程级方案)
上面的方案都需要在业务代码里手动处理缓存,侵入性强,容易遗漏。更优雅的方式是监听 MySQL binlog,异步同步缓存。
5.1 架构图
┌─────────────┐ 写操作 ┌─────────────┐
│ 业务服务 │ ──────────→ │ MySQL │
└─────────────┘ └──────┬──────┘
│ binlog
┌──────▼──────┐
│ Canal Server │
└──────┬──────┘
│
┌──────▼──────┐
│ MQ(Kafka) │
└──────┬──────┘
│
┌──────▼──────┐
│ 缓存消费者 │ ──→ 删除/更新 Redis
└─────────────┘
5.2 MySQL 前置配置
Canal 通过伪装成 MySQL 从库来接收 binlog,因此 MySQL 必须先开启 binlog,并创建一个有复制权限的账号。
第一步:开启 binlog(修改 my.cnf)
ini
[mysqld]
# 开启 binlog
log-bin=mysql-bin
# binlog 格式必须是 ROW,Canal 依赖行级变更数据
binlog-format=ROW
# server-id 在集群中必须唯一
server-id=1
# 可选:只监听指定数据库,减少 binlog 体积
binlog-do-db=your_database_name
修改后重启 MySQL,验证是否生效:
sql
SHOW VARIABLES LIKE 'log_bin';
-- 结果应为 ON
SHOW VARIABLES LIKE 'binlog_format';
-- 结果应为 ROW
第二步:创建 Canal 专用账号
sql
-- 创建账号
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal_password';
-- 授予复制权限(Canal 需要这两个权限来读取 binlog)
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
第三步:Canal Server 配置(conf/example/instance.properties)
properties
# MySQL 地址
canal.instance.master.address=127.0.0.1:3306
# 上面创建的账号
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal_password
# 监听的数据库和表,支持正则
# 监听所有库所有表:.*\\..*
# 只监听 shop 库的 product 表:shop\\.product
canal.instance.filter.regex=shop\\.product
5.3 Canal 简介
Canal 是阿里开源的 MySQL binlog 增量订阅组件,伪装成 MySQL 的从库,实时接收主库的 binlog 变更事件。
优点:
- 业务代码零侵入,不需要在每个写操作后手动删缓存
- 天然异步,不影响主链路性能
- 失败可重试,消息不丢失
缺点:
- 引入了 Canal 和 MQ 两个额外组件,运维成本增加
- 存在毫秒级延迟(binlog 传输 + MQ 消费)
5.3 Spring Boot 集成 Canal 示例
引入依赖:
xml
<dependency>
<groupId>top.javatool</groupId>
<artifactId>canal-spring-boot-starter</artifactId>
<version>1.2.1-RELEASE</version>
</dependency>
application.yml 配置:
yaml
canal:
server: 127.0.0.1:11111
destination: example
定义数据模型:
java
@CanalTable("product")
@Data
public class Product {
@Id
private Long id;
private String name;
private Integer stock;
private BigDecimal price;
}
编写监听器:
java
@Component
@CanalTable("product")
public class ProductCanalHandler implements EntryHandler<Product> {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String CACHE_KEY_PREFIX = "product:";
/**
* 数据插入时,可选择预热缓存或直接忽略(等待下次查询时自动加载)
*/
@Override
public void insert(Product product) {
// 通常插入时不主动写缓存,等查询时懒加载
log.info("product insert, id={}", product.getId());
}
/**
* 数据更新时,删除对应缓存
*/
@Override
public void update(Product before, Product after) {
String key = CACHE_KEY_PREFIX + after.getId();
redisTemplate.delete(key);
log.info("product updated, delete cache key={}", key);
}
/**
* 数据删除时,同步删除缓存
*/
@Override
public void delete(Product product) {
String key = CACHE_KEY_PREFIX + product.getId();
redisTemplate.delete(key);
log.info("product deleted, delete cache key={}", key);
}
}
这样,业务代码只需要正常操作 MySQL,缓存的同步完全由 Canal 监听器自动处理,业务层完全解耦。
六、强一致性场景:Redisson 读写锁
对于库存扣减、账户余额等绝对不能读到脏数据的场景,最终一致性是不够的,需要强一致性方案。
使用 Redisson 读写锁可以保证:读操作之间不互斥,读写操作之间互斥,写写操作之间互斥。
引入依赖:
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.2</version>
</dependency>
读写锁实现:
java
@Service
public class StockService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
private static final String LOCK_KEY_PREFIX = "lock:product:";
private static final String CACHE_KEY_PREFIX = "product:stock:";
private static final long CACHE_TTL = 30L;
/**
* 查询库存(加读锁)
* 多个读操作可以并发执行,但写操作进行时读操作会阻塞
*/
public Integer getStock(Long productId) {
String lockKey = LOCK_KEY_PREFIX + productId;
String cacheKey = CACHE_KEY_PREFIX + productId;
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(lockKey);
RLock readLock = readWriteLock.readLock();
readLock.lock();
try {
// 先查缓存
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return Integer.parseInt(cached);
}
// 缓存未命中,查DB
Integer stock = productMapper.selectStockById(productId);
if (stock != null) {
redisTemplate.opsForValue().set(cacheKey, String.valueOf(stock), CACHE_TTL, TimeUnit.MINUTES);
}
return stock;
} finally {
readLock.unlock();
}
}
/**
* 更新库存(加写锁)
* 写锁期间,所有读操作和写操作都会阻塞
*/
public void updateStock(Long productId, Integer stock) {
String lockKey = LOCK_KEY_PREFIX + productId;
String cacheKey = CACHE_KEY_PREFIX + productId;
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(lockKey);
RLock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
// 更新DB
productMapper.updateStock(productId, stock);
// 删除缓存(下次读取时重新加载)
redisTemplate.delete(cacheKey);
} finally {
writeLock.unlock();
}
}
}
注意事项:
- 读写锁会降低并发性能,仅在强一致性要求的场景使用
- 锁的粒度要尽量细(按 productId 加锁,而不是全局锁)
- 锁超时时间要合理设置,避免死锁
七、缓存预热
服务刚启动时,Redis 中没有任何数据,所有请求都会打到 MySQL,这就是冷启动问题。在高并发场景下,这可能直接压垮数据库。
缓存预热就是在服务启动时,提前将热点数据加载到 Redis,避免冷启动时的流量冲击。
7.1 启动时预热(ApplicationRunner)
Spring Boot 提供了 ApplicationRunner 接口,在应用启动完成后自动执行,适合做缓存预热:
java
@Component
@Slf4j
public class CacheWarmUpRunner implements ApplicationRunner {
@Autowired
private ProductMapper productMapper;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String CACHE_KEY_PREFIX = "product:";
private static final long CACHE_TTL_MINUTES = 60L;
@Override
public void run(ApplicationArguments args) {
log.info("开始缓存预热...");
long start = System.currentTimeMillis();
// 查询热点商品(比如销量 Top 1000)
List<Product> hotProducts = productMapper.selectTopSaleProducts(1000);
// 批量写入 Redis,使用 Pipeline 减少网络往返次数
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (Product product : hotProducts) {
String key = CACHE_KEY_PREFIX + product.getId();
String value = JSON.toJSONString(product);
// 加随机偏移,避免同时过期
long ttl = CACHE_TTL_MINUTES + ThreadLocalRandom.current().nextLong(0, 10);
connection.setEx(
key.getBytes(StandardCharsets.UTF_8),
ttl * 60,
value.getBytes(StandardCharsets.UTF_8)
);
}
return null;
});
log.info("缓存预热完成,共预热 {} 条数据,耗时 {} ms",
hotProducts.size(), System.currentTimeMillis() - start);
}
}
7.2 定时刷新热点缓存
对于排行榜、首页推荐等数据,可以用定时任务定期刷新,保证热点数据始终在缓存中:
java
@Component
@Slf4j
public class HotCacheRefreshTask {
@Autowired
private ProductMapper productMapper;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 每 5 分钟刷新一次热点商品缓存
*/
@Scheduled(fixedRate = 5 * 60 * 1000)
public void refreshHotProductCache() {
List<Product> hotProducts = productMapper.selectTopSaleProducts(1000);
for (Product product : hotProducts) {
String key = "product:" + product.getId();
redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 10, TimeUnit.MINUTES);
}
log.info("热点缓存刷新完成,共刷新 {} 条", hotProducts.size());
}
}
注意: 使用 @Scheduled 需要在启动类上加 @EnableScheduling。
八、缓存穿透、击穿、雪崩
这三个问题与双写一致性密切相关,是缓存体系稳定性的三大威胁,必须一并掌握。
8.1 缓存穿透
现象: 查询一个数据库中根本不存在的数据,缓存永远 miss,每次都打到 DB。恶意攻击者可以用大量不存在的 key 发起请求,直接压垮数据库。
方案一:缓存空值
java
public Product getProduct(Long productId) {
String key = "product:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
// 命中缓存,判断是否是空值占位符
if ("NULL".equals(cached)) {
return null; // 数据不存在
}
return JSON.parseObject(cached, Product.class);
}
// 查DB
Product product = productMapper.selectById(productId);
if (product == null) {
// 数据不存在,缓存空值,TTL 设短一点(5分钟),避免占用过多内存
redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
return null;
}
redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
return product;
}
方案二:布隆过滤器(推荐,适合数据量大的场景)
布隆过滤器可以用极小的内存判断一个元素"一定不存在"或"可能存在",在查缓存之前先过滤掉不存在的 key:
java
@Component
public class BloomFilterService {
@Autowired
private RedissonClient redissonClient;
private static final String BLOOM_FILTER_NAME = "product:bloom";
/**
* 初始化布隆过滤器(服务启动时调用)
* expectedInsertions:预期插入数量
* falseProbability:误判率(0.01 表示 1%)
*/
public void initBloomFilter(List<Long> productIds) {
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);
bloomFilter.tryInit(1_000_000L, 0.01);
for (Long id : productIds) {
bloomFilter.add(id);
}
}
/**
* 判断 productId 是否可能存在
*/
public boolean mightExist(Long productId) {
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);
return bloomFilter.contains(productId);
}
}
java
public Product getProductWithBloom(Long productId) {
// 布隆过滤器判断:如果一定不存在,直接返回
if (!bloomFilterService.mightExist(productId)) {
return null;
}
// 后续正常走缓存 → DB 流程
// ...
}
8.2 缓存击穿
现象: 某个热点 key 突然过期,大量并发请求同时 miss,全部打到 DB 重建缓存,造成 DB 瞬间压力飙升。
方案一:互斥锁重建缓存
只让一个线程去查 DB 重建缓存,其他线程等待:
java
public Product getProductWithMutex(Long productId) {
String key = "product:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 缓存 miss,尝试获取互斥锁
String lockKey = "lock:product:" + productId;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 获取到锁,查DB重建缓存
Product product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 未获取到锁,短暂等待后重试(自旋)
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductWithMutex(productId); // 递归重试
}
}
方案二:逻辑过期(适合极热点数据)
不设置 Redis 的物理 TTL,而是在缓存值中存一个逻辑过期时间。过期后异步重建,读请求直接返回旧值,不阻塞:
java
@Data
public class CacheWrapper<T> {
private T data;
private LocalDateTime expireTime;
}
java
public Product getProductWithLogicalExpire(Long productId) {
String key = "product:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached == null) {
// 未命中(冷数据),返回 null 或触发缓存重建
return null;
}
CacheWrapper<Product> wrapper = JSON.parseObject(cached, new TypeReference<CacheWrapper<Product>>() {});
// 判断逻辑是否过期
if (LocalDateTime.now().isBefore(wrapper.getExpireTime())) {
// 未过期,直接返回
return wrapper.getData();
}
// 已过期,尝试获取锁,异步重建缓存
String lockKey = "lock:product:" + productId;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 异步重建,不阻塞当前请求
CompletableFuture.runAsync(() -> {
try {
Product newProduct = productMapper.selectById(productId);
CacheWrapper<Product> newWrapper = new CacheWrapper<>();
newWrapper.setData(newProduct);
newWrapper.setExpireTime(LocalDateTime.now().plusMinutes(30));
// 不设置 TTL,永不物理过期
redisTemplate.opsForValue().set(key, JSON.toJSONString(newWrapper));
} finally {
redisTemplate.delete(lockKey);
}
});
}
// 返回旧数据(允许短暂不一致)
return wrapper.getData();
}
8.3 缓存雪崩
现象: 大量 key 在同一时间过期,或 Redis 服务宕机,导致所有请求同时打到 DB。
防范措施:
java
// 措施1:TTL 加随机偏移(已在第三章提到)
long ttl = 30 + ThreadLocalRandom.current().nextLong(0, 10);
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.MINUTES);
// 措施2:服务降级 + 限流(使用 Sentinel)
// 当 Redis 不可用时,限制打到 DB 的 QPS,保护数据库
// 具体配置见 Sentinel 文档
// 措施3:多级缓存(本地缓存 + Redis)
// 即使 Redis 宕机,本地缓存(Caffeine)仍可承接部分流量
本地缓存兜底示例(Caffeine + Redis 二级缓存):
java
@Component
public class TwoLevelCacheService {
// 本地缓存:最多 1000 条,过期时间 5 分钟
private final Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
public Product getProduct(Long productId) {
// 第一级:本地缓存
Product product = localCache.getIfPresent(productId);
if (product != null) {
return product;
}
// 第二级:Redis 缓存
String key = "product:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
product = JSON.parseObject(cached, Product.class);
localCache.put(productId, product); // 回填本地缓存
return product;
}
// 第三级:数据库
product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
localCache.put(productId, product);
}
return product;
}
}
使用 Caffeine 需要引入依赖:
xml<dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>3.1.8</version> </dependency>
九、方案选型指南
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 普通业务,允许短暂不一致(秒级) | Cache Aside + MQ 异步重试删缓存 | 实现简单,性能好 |
| 大规模微服务,不想改业务代码 | Canal + MQ | 业务零侵入,天然解耦 |
| 库存、余额等强一致性场景 | Redisson 读写锁 | 保证强一致,但性能有损耗 |
| 写操作极其频繁,允许少量数据丢失 | Write Behind | 写性能最高,适合日志类数据 |
十、常见面试追问
Q:为什么是删除缓存,而不是更新缓存?
更新缓存在并发写场景下会出现"写写竞争":两个线程同时更新 DB,后写 DB 的线程可能先写了缓存,导致缓存里存的是"旧 DB 值"。删除缓存不存在这个问题,下次读取时会从 DB 加载最新值。此外,很多缓存值是经过计算或聚合的(比如用户的订单总金额),更新缓存需要重新计算,不如直接删除让下次读时重建来得简单。
Q:Cache Aside 先更新 DB 再删缓存,还是有不一致的可能吗?
有,但概率极低。需要同时满足三个条件:
- 缓存恰好在此时 miss(过期或冷数据)
- 另一个线程在写线程更新 DB 之前读取了旧值
- 该线程的写缓存操作发生在写线程删缓存之后
这个时间窗口极短(微秒级),通常可以接受。如果无法接受,升级到读写锁方案。
Q:Canal 方案能保证强一致性吗?
不能。Canal 是异步的,存在毫秒级延迟。在 binlog 传输和 MQ 消费期间,缓存里可能还是旧值。如果需要强一致性,必须使用读写锁方案。
Q:如果 MQ 消费失败,缓存删除消息丢了怎么办?
需要配置死信队列(DLQ)和重试策略:
- RabbitMQ:配置
x-dead-letter-exchange,消息重试超过次数后进入死信队列,触发告警人工处理 - Kafka:消费失败后不提交 offset,消息会被重新消费,天然支持重试
- 最终兜底:TTL 到期后缓存自动失效,下次读取时从 DB 重新加载
Q:Redis 主从切换时,会不会丢失删缓存的操作?
会。Redis 主从复制是异步的,主节点删除了缓存,如果在同步到从节点之前主节点宕机,从节点晋升为主节点后,缓存里仍然是旧值。这是 Redis 本身的 AP 特性(可用性优先,牺牲强一致性)。
应对方式:
- 接受这个极低概率的不一致,依赖 TTL 兜底
- 对于强一致性场景,使用读写锁,不依赖缓存删除的可靠性
Q:缓存穿透、缓存击穿、缓存雪崩的区别?
| 问题 | 触发条件 | 影响 | 解决方案 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的 key | 每次都打到 DB | 缓存空值 / 布隆过滤器 |
| 缓存击穿 | 热点 key 突然过期 | 瞬间大量请求打到 DB | 互斥锁重建 / 逻辑过期 |
| 缓存雪崩 | 大量 key 同时过期 / Redis 宕机 | DB 被全量流量压垮 | TTL 随机偏移 / 多级缓存 / 限流降级 |
Q:本地缓存(Caffeine)和 Redis 同时使用,如何保证本地缓存的一致性?
本地缓存的一致性比 Redis 更难保证,因为每个服务实例都有自己的本地缓存,更新时需要通知所有实例。常见方案:
- MQ 广播:数据变更时,发送广播消息,所有实例收到后清除本地缓存对应的 key
- Redis Pub/Sub:利用 Redis 的发布订阅功能,数据变更时发布消息,所有订阅的实例清除本地缓存
- 短 TTL:本地缓存设置较短的 TTL(1~5 分钟),接受短暂不一致,到期自动失效
java
// Redis Pub/Sub 通知本地缓存失效示例
@Component
public class LocalCacheInvalidator {
private final Cache<Long, Product> localCache; // 注入 Caffeine 缓存
@Autowired
private StringRedisTemplate redisTemplate;
// 发布失效通知
public void publishInvalidate(Long productId) {
redisTemplate.convertAndSend("cache:invalidate:product", String.valueOf(productId));
}
// 订阅失效通知,清除本地缓存
@Bean
public MessageListenerAdapter cacheInvalidateListener() {
return new MessageListenerAdapter(new MessageListener() {
@Override
public void onMessage(Message message, byte[] pattern) {
Long productId = Long.parseLong(new String(message.getBody()));
localCache.invalidate(productId);
log.info("本地缓存失效, productId={}", productId);
}
});
}
}
总结
Redis 与 MySQL 的双写一致性没有银弹,核心是根据业务对一致性的要求选择合适的方案。
一句话记忆:
- 普通业务 → Cache Aside(先更新 DB,再删缓存)+ MQ 重试兜底
- 工程解耦 → Canal 监听 binlog,业务代码零侵入
- 强一致性 → Redisson 读写锁,用性能换一致性
- 防冷启动 → ApplicationRunner 预热 + 定时刷新热点数据
- 防三大问题 → 布隆过滤器(穿透)+ 互斥锁/逻辑过期(击穿)+ TTL 随机偏移(雪崩)
理解每种方案背后的权衡,才能在面试和实际工作中做出正确的技术决策。