我们来深入解析这五种缓存一致性方案,理解它们的原理、优缺点和适用场景。
1. 延迟双删(最终一致性)
核心原理
java
public void updateData(String key, Object value) {
// 第一次删除:清除旧缓存
cache.delete(key);
// 更新数据库
database.update(key, value);
// 延迟一段时间后再次删除
Thread.sleep(delayTime); // 通常100-500ms
// 第二次删除:清除可能在此期间被写入的旧缓存
cache.delete(key);
}
为什么要延迟?
时间窗口问题:
时间线:
t1: 线程A删除缓存
t2: 线程A更新数据库(耗时较长)
t3: 线程B读取缓存(未命中)
t4: 线程B读取数据库(旧数据)
t5: 线程B写入缓存(旧数据)
t6: 线程A第二次删除缓存 ← 必须等到t5之后执行
"短暂延迟"的玄学
java
// 不同业务场景的延迟设置
public class DelayConfig {
// 简单业务:100ms足够
public static final int SIMPLE_BUSINESS = 100;
// 复杂业务:需要考虑最慢的读操作完成时间
public static final int COMPLEX_BUSINESS = 300;
// 高并发场景:需要统计P99响应时间
public static final int HIGH_CONCURRENCY = 500;
}
确定延迟时间的实践方法:
- 监控统计:分析读操作的P95/P99耗时
- 压力测试:在测试环境模拟并发场景
- 动态调整:根据线上监控动态调整延迟时间
优缺点
优点:
- 实现相对简单
- 能解决大部分并发不一致问题
缺点:
- 延迟时间难以精确确定
- 仍然有概率出现不一致
- 引入额外延迟影响性能
2. 先写数据库再删除缓存(最终一致性)
核心原理
java
public void updateData(String key, Object value) {
// 1. 先更新数据库
database.update(key, value);
// 2. 再删除缓存
cache.delete(key);
}
为什么概率很低?
不一致的唯一场景:
前提条件同时满足:
1. 缓存刚好失效
2. 读请求在写请求更新数据库后、删除缓存前发生
时间线:
t0: 缓存过期(小概率事件)
t1: 线程A读取缓存(未命中)
t2: 线程A开始读取数据库
t3: 线程B更新数据库(完成)
t4: 线程A读取到旧数据(数据库旧值)
t5: 线程A写入缓存(旧数据)
t6: 线程B删除缓存 ← 太晚了!
结果:缓存中遗留旧数据直到下次过期
概率计算
假设:
- 缓存命中率:99%
- 写操作频率:100次/秒
- 读操作频率:1000次/秒
- 数据库更新+删除缓存耗时:5ms
不一致的概率 ≈ (1%缓存失效) × (5ms/1000ms) ≈ 0.005%
为什么实践中常用?
java
// 实际工程中的增强版
public void updateDataWithRetry(String key, Object value) {
try {
// 1. 更新数据库
database.update(key, value);
// 2. 删除缓存,失败重试
for (int i = 0; i < 3; i++) {
try {
cache.delete(key);
break;
} catch (Exception e) {
if (i == 2) log.error("删除缓存失败", e);
}
}
} catch (Exception e) {
log.error("更新数据库失败", e);
throw e;
}
}
3. 分布式锁(强一致性)
完整的读写锁实现
java
public class CacheConsistencyWithDistributedLock {
private final DistributedLock lock;
// 写操作
public void writeWithLock(String key, Object value) {
String lockKey = "write_lock:" + key;
String requestId = UUID.randomUUID().toString();
try {
// 获取写锁
if (lock.tryLock(lockKey, requestId, 10, TimeUnit.SECONDS)) {
// 更新数据库
database.update(key, value);
// 删除缓存
cache.delete(key);
} else {
throw new RuntimeException("获取写锁失败");
}
} finally {
lock.unlock(lockKey, requestId);
}
}
// 读操作
public Object readWithLock(String key) {
// 先尝试无锁读缓存
Object value = cache.get(key);
if (value != null) return value;
String lockKey = "read_lock:" + key;
String requestId = UUID.randomUUID().toString();
try {
// 获取读锁(与写锁互斥)
if (lock.tryLock(lockKey, requestId, 10, TimeUnit.SECONDS)) {
// 双重检查
value = cache.get(key);
if (value != null) return value;
// 读数据库
value = database.get(key);
// 写缓存
if (value != null) {
cache.set(key, value, 30, TimeUnit.MINUTES);
}
return value;
} else {
throw new RuntimeException("获取读锁失败");
}
} finally {
lock.unlock(lockKey, requestId);
}
}
}
Redis分布式锁实现
java
@Component
public class RedisDistributedLock {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public boolean tryLock(String key, String value, long expire, TimeUnit unit) {
return redisTemplate.opsForValue()
.setIfAbsent(key, value, expire, unit);
}
public void unlock(String key, String value) {
// 使用Lua脚本保证原子性
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key),
value
);
}
}
4. 消息队列序列化(强一致性)
架构设计
┌─────────┐ ┌─────────────┐ ┌─────────────┐
│ 写请求 │───▶│ 消息队列 │───▶│ 消费者 │
└─────────┘ │ (按Key路由) │ │ (顺序处理) │
└─────────────┘ └─────────────┘
│
┌─────▼─────┐
│ 数据库+缓存 │
└───────────┘
具体实现
java
@Component
public class MessageQueueConsistency {
@Autowired
private KafkaTemplate<String, Object> kafkaTemplate;
// 发送写请求到消息队列
public void sendWriteRequest(String key, Object value) {
// 按业务键分区,确保同一Key的操作进入同一分区
String topic = "cache-consistency";
int partition = Math.abs(key.hashCode()) % 100; // 100个分区
kafkaTemplate.send(topic, partition, key, new WriteMessage(key, value));
}
// 消费者处理
@KafkaListener(topics = "cache-consistency")
public void processWriteMessage(WriteMessage message) {
// 顺序处理同一分区的消息
try {
// 1. 更新数据库
database.update(message.getKey(), message.getValue());
// 2. 删除缓存
cache.delete(message.getKey());
} catch (Exception e) {
// 重试机制
throw new RuntimeException("处理失败", e);
}
}
// 读操作也可以走消息队列(强一致但延迟高)
public Object readThroughQueue(String key) {
// 发送读请求到专门的分区
CompletableFuture<Object> future = new CompletableFuture<>();
String correlationId = UUID.randomUUID().toString();
// 注册回调
readCallbacks.put(correlationId, future);
kafkaTemplate.send("read-requests", 0, correlationId,
new ReadMessage(correlationId, key));
// 等待结果
return future.get(5, TimeUnit.SECONDS);
}
}
5. 订阅Binlog日志(最终一致性)
架构流程
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ MySQL │───▶│ Canal │───▶│ MQ │───▶│ Consumer│
│ Binlog │ │(解析) │ │(缓冲) │ │(更新缓存)│
└─────────┘ └─────────┘ └─────────┘ └─────────┘
具体实现
yaml
# canal配置
canal:
instance:
master:
address: 127.0.0.1:3306
filter:
- mydb.mytable
java
@Component
public class BinlogCacheUpdater {
@RabbitListener(queues = "binlog-changes")
public void processBinlogChange(ChangeEvent event) {
String operation = event.getOperation();
String table = event.getTable();
Map<String, Object> data = event.getData();
if ("UPDATE".equals(operation) && "users".equals(table)) {
String userId = (String) data.get("id");
// 删除对应缓存
cache.delete("user:" + userId);
// 或者更新缓存(需要构造完整数据)
// User user = userService.getUser(userId);
// cache.set("user:" + userId, user);
}
}
}
为什么是最终一致性?
- 异步处理:Binlog解析、MQ传递、消费者处理都有延迟
- 顺序保证:虽然Binlog是顺序的,但MQ可能重试导致乱序
- 处理失败:消费者处理失败需要重试,期间数据不一致
方案对比总结
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 延迟双删 | 最终一致 | 中 | 中 | 对一致性要求不高的业务 |
| 先DB后删缓存 | 最终一致 | 高 | 低 | 大多数业务场景 |
| 分布式锁 | 强一致 | 低 | 高 | 金融、交易等强一致性场景 |
| 消息队列 | 强一致 | 中低 | 高 | 高并发写场景,需要顺序保证 |
| Binlog订阅 | 最终一致 | 中 | 很高 | 大数据量、解耦架构 |
工程实践建议
选择策略:
java
public class CacheConsistencyStrategyFactory {
public static CacheConsistencyStrategy getStrategy(BusinessScenario scenario) {
switch (scenario.getConsistencyRequirement()) {
case STRONG:
return scenario.isHighWrite() ?
new MessageQueueStrategy() : new DistributedLockStrategy();
case EVENTUAL:
default:
return new DatabaseFirstStrategy(); // 先DB后删缓存
}
}
}
监控保障:
java
@Component
public class CacheConsistencyMonitor {
// 监控缓存与数据库的一致性
public void checkConsistency(String key) {
Object dbValue = database.get(key);
Object cacheValue = cache.get(key);
if (!Objects.equals(dbValue, cacheValue)) {
log.warn("数据不一致: key={}, dbValue={}, cacheValue={}",
key, dbValue, cacheValue);
// 自动修复
cache.delete(key);
}
}
}
总结:没有银弹,需要根据业务场景的一致性要求、性能需求、系统复杂度来选择合适的方案。在实践中,"先写数据库再删除缓存"因其简单有效成为最常用的方案。
分布式锁(强一致性)方案下,写操作过程中进行读操作会产生数据不一致吗?
在您描述的这种分布式锁(强一致性)方案 下,写操作过程中进行读操作不会产生数据不一致。让我详细解释这个机制:
工作机制分析
完整的读写流程
写操作流程:
java
public void writeOperation(String key, Object newValue) {
// 1. 获取写锁(独占锁)
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
try {
// 获取分布式写锁
while (!tryGetLock(lockKey, requestId, 10000)) {
Thread.sleep(10); // 等待锁
}
// 2. 更新数据库
database.update(key, newValue);
// 3. 删除缓存
cache.delete(key);
// 4. 释放写锁(在finally中)
} finally {
releaseLock(lockKey, requestId);
}
}
读操作流程:
java
public Object readOperation(String key) {
// 1. 先尝试读缓存
Object value = cache.get(key);
if (value != null) {
return value; // 缓存命中直接返回
}
// 2. 缓存未命中,获取读锁
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
try {
// 获取分布式读锁(与写锁互斥)
while (!tryGetLock(lockKey, requestId, 10000)) {
Thread.sleep(10);
}
// 3. 再次检查缓存(防止其他线程已经写入)
value = cache.get(key);
if (value != null) {
return value;
}
// 4. 从数据库读取
value = database.get(key);
// 5. 写入缓存
if (value != null) {
cache.set(key, value);
}
return value;
} finally {
releaseLock(lockKey, requestId);
}
}
为什么不会产生不一致?
1. 锁的互斥性保证
在这种方案中,读写锁是互斥的:
-
写锁:独占锁,其他读写操作都被阻塞
-
读锁:共享锁,多个读可以并发,但与写锁互斥
时间线示例:
t1: 写操作W获取写锁
t2: 读操作R尝试获取读锁 → 等待(被阻塞)
t3: W更新数据库完成
t4: W删除缓存完成
t5: W释放写锁
t6: R获取读锁成功
t7: R读取数据库(最新数据)
t8: R写入缓存(最新数据)
t9: R释放读锁结果:始终保证一致性
2. 关键保护点
保护点1:写操作期间读操作被阻塞
java
// 写操作持有锁期间,所有读操作在获取读锁时被阻塞
写线程: 持有写锁 → 更新DB → 删除缓存 → 释放写锁
读线程: 等待写锁释放 → 获取读锁 → 读取最新数据
保护点2:双重缓存检查
java
// 读操作获取锁后再次检查缓存
value = cache.get(key); // 第一次检查(无锁)
if (value != null) return value;
// 获取读锁后
value = cache.get(key); // 第二次检查(有锁保护)
if (value != null) return value;
// 从数据库读取最新数据
value = database.get(key);
与其他方案的对比
方案对比表
| 方案 | 写过程中读是否不一致 | 性能 | 复杂度 |
|---|---|---|---|
| 无锁旁路模式 | ❌ 可能不一致 | ⭐⭐⭐⭐⭐ 高 | ⭐⭐ 低 |
| 分布式锁(本方案) | ✅ 强一致 | ⭐⭐ 低 | ⭐⭐⭐⭐ 高 |
| 延迟双删 | ⚠️ 最终一致 | ⭐⭐⭐ 中 | ⭐⭐⭐ 中 |
| 数据库事务 | ✅ 强一致 | ⭐ 很低 | ⭐⭐⭐⭐ 高 |
潜在问题和优化
1. 性能瓶颈
由于读写锁的互斥,并发性能会显著下降:
java
// 高并发场景下可能成为瓶颈
public void highConcurrencyScenario() {
// 多个线程同时操作同一数据
executorService.submit(() -> writeOperation("user:1", user1));
executorService.submit(() -> readOperation("user:1")); // 被阻塞
executorService.submit(() -> readOperation("user:1")); // 被阻塞
// 所有操作串行化执行
}
2. 死锁风险
java
// 需要确保锁一定能释放
try {
if (tryGetLock(lockKey, requestId, expireTime)) {
// 业务操作
doBusiness();
}
} finally {
// 必须确保释放锁
releaseLock(lockKey, requestId);
}
3. 锁粒度优化
java
// 粗粒度锁:性能差但简单
String lockKey = "lock:user_data";
// 细粒度锁:性能好但复杂
String lockKey = "lock:user:" + userId;
// 最细粒度:按数据项加锁
String lockKey = "lock:user:" + userId + ":field:" + fieldName;
实际应用建议
适合场景:
- 金融交易:金额变更等强一致性要求
- 库存管理:防止超卖
- 配置信息:全局配置更新
不建议场景:
- 高频读写:如用户浏览记录
- 最终一致可接受:如社交媒体的点赞数
- 读多写少:如商品信息展示
总结
在您描述的分布式锁强一致性方案下:
- ✅ 写过程中读操作不会产生不一致
- ✅ 通过读写锁互斥保证串行化
- ✅ 读操作在锁保护下读取最新数据
- ❌ 但牺牲了并发性能和系统吞吐量
这种方案通过用性能换一致性,在需要强一致性的业务场景中是可靠的选择,但需要根据实际业务权衡一致性和性能的需求。
旁路缓存模式
旁路缓存模式(Cache-Aside Pattern)是应用程序主导缓存与数据库协同的缓存模式,缓存仅作为"数据旁路",不主动同步数据库,由应用在读写时手动维护缓存状态。
核心原理
- 读操作:先查缓存,命中直接返回;未命中则查数据库,将结果写入缓存后再返回。
- 写操作:先更新数据库,成功后删除对应缓存(而非直接更新),避免缓存与数据库数据不一致。
核心特点
- 优点:架构简单无中间件依赖,应用完全掌控缓存逻辑,灵活适配业务;缓存与数据库解耦,系统开销低。
- 缺点:需应用手动保障一致性(漏删缓存会导致脏数据);可能出现缓存穿透、雪崩等问题,需额外设计防护机制。