📌 引言
在高并发系统架构中,Redis作为缓存中间件与数据库的配合使用已成为标配。然而,当我们同时操作缓存和数据库时,如何保证两者数据的一致性,成为了每个开发者必须面对的挑战。😓
本文将深入探讨Redis缓存与数据库双写不一致问题的原理、常见场景以及多种解决方案,并通过Java代码示例帮助你彻底理解并解决这一技术难题。
🤔 什么是缓存与数据库双写不一致问题?
问题本质
双写不一致问题指的是在更新操作中,缓存数据和数据库数据不一致的情况。当我们对一条数据进行更新时,需要同时更新数据库和缓存,而这两个更新操作的顺序以及并发情况,都可能导致数据不一致。
典型场景分析
在单线程环境下,无论是先更新数据库还是先更新缓存,只要没有异常发生,理论上都能保证数据一致性。但在以下情况下,问题就会显现:
- 单线程异常场景:在更新过程中发生异常,导致只有一方的数据被更新
- 高并发场景:多个线程同时操作同一数据,导致更新顺序错乱
🔍 双写不一致的核心场景分析
场景一:先更新数据库,再更新缓存
假设有两个并发请求A和B同时更新同一条数据:
- 请求A更新数据库中的值为1
- 请求B更新数据库中的值为2
- 请求B更新缓存中的值为2
- 请求A更新缓存中的值为1
最终结果:数据库中的值是2,而缓存中的值是1,出现了不一致!
场景二:先更新缓存,再更新数据库
同样的并发请求A和B:
- 请求A更新缓存中的值为1
- 请求B更新缓存中的值为2
- 请求B更新数据库中的值为2
- 请求A更新数据库中的值为1
最终结果:缓存中的值是2,而数据库中的值是1,同样出现了不一致!
场景三:先删除缓存,再更新数据库(Cache Aside策略)
考虑"读+写"并发的情况:
- 请求A要更新数据,先删除缓存
- 请求B此时读取数据,发现缓存不存在,从数据库读取旧值
- 请求A更新数据库为新值
- 请求B将从数据库读取的旧值写入缓存
最终结果:数据库中是新值,缓存中是旧值,出现了不一致!
场景四:先更新数据库,再删除缓存(推荐策略)
同样考虑"读+写"并发:
- 请求A从数据库读取数据,但还未写入缓存
- 请求B更新数据库,并删除缓存
- 请求A将旧值写入缓存
理论上也会出现不一致,但实际上这种情况发生概率很低,因为缓存的写入通常比数据库的写入要快得多。
💡 主流解决方案及其优缺点
1. Cache Aside策略(旁路缓存模式)
这是最常用的缓存策略,包含读策略和写策略:
写策略:
- 更新数据库
- 删除缓存(不是更新缓存)
读策略:
- 读取缓存,命中则返回
- 未命中则读取数据库,并将结果写入缓存
优点:实现简单,适合读多写少的场景 缺点:在高并发下仍有一致性风险
2. 延迟双删策略
实现方式:
- 删除缓存
- 更新数据库
- 休眠一段时间(例如500ms)
- 再次删除缓存
优点:能够降低不一致发生的概率
缺点:不能完全保证一致性,延迟时间难以确定
3. 分布式读写锁
实现方式:
- 对同一资源的读操作使用共享锁
- 对写操作使用排他锁
- 确保写操作时,不会有并发的读和写
优点:能够从根本上解决一致性问题
缺点:实现复杂,性能开销大,可能影响系统吞吐量
4. 消息队列(MQ)异步处理
实现方式:
- 更新数据库
- 发送消息到MQ
- 消费者接收消息并删除对应缓存
优点:解耦数据库与缓存操作,提高系统可用性
缺点:增加系统复杂度,消息可能丢失
5. 数据库变更日志订阅(如Canal)
实现方式:
- 订阅数据库的binlog
- 当检测到数据变更时,删除对应的缓存
优点:对业务代码无侵入
缺点:增加系统复杂度,依赖外部组件
🚀 如何选择合适的方案?
选择合适的方案需要考虑以下因素:
-
业务对一致性的要求程度
- 对一致性要求极高:考虑分布式读写锁
- 可容忍短暂不一致:Cache Aside + 缓存过期时间可能足够
-
系统的并发规模
- 高并发:考虑MQ或Canal等异步方案
- 并发不高:简单的Cache Aside可能足够
-
技术栈复杂度接受程度
- 倾向于简单实现:Cache Aside + 缓存过期时间
- 可接受复杂实现:分布式锁或异步消息方案
最佳实践建议:对于大多数系统,推荐采用"先更新数据库,再删除缓存"的策略,并设置合理的缓存过期时间作为兜底方案。
📝 Java代码实战:双写不一致问题演示与解决
场景一:先更新数据库,再更新缓存导致的不一致
/**
* 场景一:先更新数据库,再更新缓存导致的不一致问题
*/
public static void demonstrateUpdateDbThenCache() throws InterruptedException {
System.out.println("\n===== 场景一:先更新数据库,再更新缓存 =====");
// 初始化数据
String productId = "product:1";
mysqlDb.update(productId, "原始商品信息");
redisCache.set(productId, "原始商品信息");
// 创建线程池和同步工具
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch latch = new CountDownLatch(2);
// 线程A:更新商品信息为A
executor.submit(() -> {
try {
System.out.println("线程A开始更新商品信息为A");
mysqlDb.update(productId, "商品信息A");
// 模拟线程A执行较慢
Thread.sleep(300);
redisCache.set(productId, "商品信息A");
System.out.println("线程A完成更新");
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
});
// 线程B:更新商品信息为B
executor.submit(() -> {
try {
System.out.println("线程B开始更新商品信息为B");
Thread.sleep(100); // 确保在线程A更新数据库后执行
mysqlDb.update(productId, "商品信息B");
redisCache.set(productId, "商品信息B");
System.out.println("线程B完成更新");
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
});
latch.await();
// 检查最终状态
System.out.println("\n最终状态检查:");
System.out.println("数据库中的值: " + mysqlDb.select(productId));
System.out.println("缓存中的值: " + redisCache.get(productId));
System.out.println("数据一致性: " + mysqlDb.select(productId).equals(redisCache.get(productId)));
executor.shutdown();
}
场景二:先删除缓存,再更新数据库导致的不一致
/**
* 场景二:先删除缓存,再更新数据库导致的不一致问题
*/
public static void demonstrateDeleteCacheThenUpdateDb() throws InterruptedException {
System.out.println("\n===== 场景二:先删除缓存,再更新数据库 =====");
// 初始化数据
String productId = "product:2";
mysqlDb.update(productId, "原始商品信息");
redisCache.set(productId, "原始商品信息");
// 创建线程池和同步工具
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch latch = new CountDownLatch(2);
// 线程A:更新商品信息
executor.submit(() -> {
try {
System.out.println("线程A开始更新商品");
// 先删除缓存
redisCache.delete(productId);
// 模拟网络延迟,此时线程B可能会读取数据
Thread.sleep(200);
// 再更新数据库
mysqlDb.update(productId, "更新后的商品信息");
System.out.println("线程A完成更新");
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
});
// 线程B:读取商品信息
executor.submit(() -> {
try {
// 确保在缓存删除后执行
Thread.sleep(100);
System.out.println("线程B开始读取商品");
// 缓存中没有数据,从数据库读取
Object value = redisCache.get(productId);
if (value == null) {
System.out.println("线程B发现缓存未命中");
value = mysqlDb.select(productId);
// 将从数据库读取的旧值写入缓存
redisCache.set(productId, value);
}
System.out.println("线程B读取完成: " + value);
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
});
latch.await();
// 检查最终状态
System.out.println("\n最终状态检查:");
System.out.println("数据库中的值: " + mysqlDb.select(productId));
System.out.println("缓存中的值: " + redisCache.get(productId));
System.out.println("数据一致性: " + mysqlDb.select(productId).equals(redisCache.get(productId)));
executor.shutdown();
}
推荐方案:先更新数据库,再删除缓存
/**
* 场景三:先更新数据库,再删除缓存的解决方案
*/
public static void demonstrateUpdateDbThenDeleteCache() throws InterruptedException {
System.out.println("\n===== 场景三:先更新数据库,再删除缓存(推荐方案) =====");
// 初始化数据
String productId = "product:3";
mysqlDb.update(productId, "原始商品信息");
redisCache.set(productId, "原始商品信息");
// 创建线程池和同步工具
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch latch = new CountDownLatch(2);
// 线程A:更新商品信息
executor.submit(() -> {
try {
System.out.println("线程A开始更新商品");
// 先更新数据库
mysqlDb.update(productId, "更新后的商品信息");
// 再删除缓存
redisCache.delete(productId);
System.out.println("线程A完成更新");
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
});
// 线程B:读取商品信息
executor.submit(() -> {
try {
// 确保在数据库更新后执行
Thread.sleep(250);
System.out.println("线程B开始读取商品");
// 尝试从缓存读取
Object value = redisCache.get(productId);
if (value == null) {
System.out.println("线程B发现缓存未命中");
value = mysqlDb.select(productId);
// 将从数据库读取的新值写入缓存
redisCache.set(productId, value);
}
System.out.println("线程B读取完成: " + value);
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
});
latch.await();
// 检查最终状态
System.out.println("\n最终状态检查:");
System.out.println("数据库中的值: " + mysqlDb.select(productId));
System.out.println("缓存中的值: " + redisCache.get(productId));
System.out.println("数据一致性: " + mysqlDb.select(productId).equals(redisCache.get(productId)));
executor.shutdown();
}
分布式锁解决方案
/**
* 使用分布式锁保证缓存与数据库的一致性
*/
public static class ProductService {
private static final String LOCK_PREFIX = "lock:product:";
private static final long LOCK_EXPIRE_TIME = 5000; // 5秒过期
// 更新商品信息
public void updateProduct(String productId, String productInfo) {
String lockKey = LOCK_PREFIX + productId;
String requestId = UUID.randomUUID().toString();
try {
// 尝试获取分布式锁
boolean locked = redisClient.tryLock(lockKey, requestId, LOCK_EXPIRE_TIME);
if (!locked) {
System.out.println("获取锁失败,稍后重试");
return;
}
// 获取锁成功,执行更新操作
try {
// 1. 更新数据库
database.update(productId, productInfo);
// 2. 删除缓存
redisClient.delete(productId);
System.out.println("商品信息更新成功: " + productId);
} finally {
// 释放锁
redisClient.releaseLock(lockKey, requestId);
}
} catch (Exception e) {
System.err.println("更新商品信息异常: " + e.getMessage());
}
}
// 获取商品信息
public Object getProduct(String productId) {
String lockKey = LOCK_PREFIX + productId;
String requestId = UUID.randomUUID().toString();
try {
// 1. 先查询缓存
Object productInfo = redisClient.get(productId);
if (productInfo != null) {
return productInfo;
}
// 2. 缓存未命中,尝试获取分布式锁
boolean locked = redisClient.tryLock(lockKey, requestId, LOCK_EXPIRE_TIME);
if (!locked) {
// 获取锁失败,直接查询数据库但不更新缓存
System.out.println("获取锁失败,直接查询数据库");
return database.select(productId);
}
try {
// 双重检查,再次检查缓存
productInfo = redisClient.get(productId);
if (productInfo != null) {
return productInfo;
}
// 3. 查询数据库
productInfo = database.select(productId);
// 4. 更新缓存
if (productInfo != null) {
redisClient.set(productId, productInfo);
}
return productInfo;
} finally {
// 释放锁
redisClient.releaseLock(lockKey, requestId);
}
} catch (Exception e) {
System.err.println("获取商品信息异常: " + e.getMessage());
// 发生异常时,直接查询数据库
return database.select(productId);
}
}
}
📊 总结与最佳实践
在处理Redis缓存与数据库双写一致性问题时,我们需要根据业务场景和一致性要求选择合适的方案:
- 对于一般业务场景,采用"先更新数据库,再删除缓存"的策略,并设置合理的缓存过期时间作为兜底方案。
- 对于高并发且对一致性要求较高的场景,可以考虑"延迟双删"或"消息队列异步处理"方案。
- 对于一致性要求极高的核心业务,可以使用"分布式读写锁"方案,但需要权衡性能开销。
- 无论采用哪种方案,都应该设置缓存过期时间,作为最终的兜底措施。
- 在实际业务中,应该避免过度设计,根据业务对一致性的实际需求选择合适的方案,在一致性和性能之间找到平衡点。
记住,没有完美的解决方案,只有最适合你业务场景的方案。希望本文能帮助你更好地理解和解决Redis缓存与数据库双写一致性问题!