1. Cache-Aside模式概述
Cache-Aside模式(旁路缓存模式)是最常用的缓存使用策略之一,其核心思想是应用程序直接与缓存和数据库交互,缓存只作为数据的一个副本。
1.1 基本工作流程
text
读操作流程:
1. 先查询缓存
2. 缓存命中 → 返回数据
3. 缓存未命中 → 查询数据库 → 将数据写入缓存 → 返回数据
写操作流程:
1. 更新数据库
2. 删除缓存
2. 数据一致性问题产生原因
2.1 操作非原子性 ⭐⭐⭐⭐⭐
问题本质: 缓存和数据库是两个独立的系统,无法在同一事务中保证原子性。
java
// 伪代码
@Transactional
public void updateUser(User user) {
// MySQL事务内
mysql.update(user); // ✅ 成功
// Redis不在事务内
redis.delete(key); // ❌ 可能失败
}
// 如果Redis删除失败,MySQL事务已提交,导致不一致
2.1 并发读写冲突(经典问题)⭐⭐⭐⭐⭐
场景一:读操作在写操作之前启动但完成较晚;
text
时间线:
T1: 线程A(读操作)发现缓存未命中(Cache Miss)
T2: 线程A开始查询数据库,读取到旧值 value_old
T3: 线程B(写操作)更新数据库为新值 value_new
T4: 线程B删除缓存成功
T5: 线程A将查询到的旧值 value_old 写入缓存(覆盖了删除)
结果:MySQL=new_value,Redis=old_value ❌
影响:后续读请求直接命中缓存,读取到过期数据,直到缓存过期或被再次删除
补充:为什么会发生?
| 操作 | 平均耗时 | 说明 |
|---|---|---|
| MySQL查询 | 10-100ms | 网络IO + 磁盘IO + 索引查找 |
| MySQL更新 | 5-50ms | 写日志 + 更新索引 |
| Redis删除 | <1ms | 纯内存操作 |
| Redis写入 | <1ms | 纯内存操作 |
结论: MySQL查询慢 + Redis操作快 = 时序错乱
2.3:先删缓存后更新DB的时序问题 ⭐⭐⭐⭐
text
时间线:
T1: 线程A(写)删除Redis
T2: 线程B(读)查Redis → Miss
T3: 线程B(读)查MySQL → 获取旧值 old_value
T4: 线程B(读)写Redis → old_value
T5: 线程A(写)更新MySQL → new_value
结果:MySQL=new_value,Redis=old_value ❌
结论:先删缓存后更新DB的方案,不一致概率更高,不推荐。 ❌
2.4 缓存删除失败
数据库更新成功,但缓存删除失败
text
时间线:
T1: 写操作A更新数据库成功
T2: 写操作A尝试删除缓存,但失败(网络问题等)
结果:缓存中仍保留旧数据,与数据库不一致
常见失败原因:
java
1. 网络问题
- Redis连接超时
- 网络抖动
- 连接池耗尽
2. Redis问题
- Redis宕机
- 主从切换
- 内存满OOM
3. 代码问题
- 异常被吞掉
try {
redis.delete(key);
} catch (Exception e) {
// ❌ 没有处理,导致不一致
log.error("...", e);
}
- 事务回滚但缓存已操作
@Transactional
void update() {
mysql.update();
redis.delete(key); // ✅ 删除成功
throw new Exception(); // ❌ 事务回滚,但缓存已删
}
3. 解决方案详解
3.1 先更新数据库,后删除缓存(标准Cache-Aside)
3.1.1 原理
这是Cache-Aside模式的标准实现,其核心思想是:
- 先确保数据库数据正确
- 然后删除缓存,强制后续读操作重新从数据库加载最新数据
3.1.2 优点
- 实现简单:逻辑清晰,易于实现和维护
- 最终一致性:虽然可能出现短暂不一致,但最终会达到一致
- 性能影响小:相比更新缓存,删除操作更轻量
- 避免缓存数据过期问题 :不需要关心缓存中存储的数据结构与数据库是否匹配
3.1.3 缺点
- 存在短暂不一致窗口:在高并发场景下可能出现缓存与数据库不一致
- 缓存删除失败风险:如果删除缓存失败,会导致数据不一致
3.2 延迟双删策略 ⭐⭐⭐⭐
3.2.1 原理
延迟双删策略是对标准Cache-Aside的改进,通过二次删除缓存来解决并发读写导致的不一致问题:
- 先删除缓存
- 更新数据库
- 延迟一段时间后再次删除缓存
java
public void updateData(String key, Object value) {
// 1. 先删除缓存
redis.delete(key);
// 2. 更新数据库
mysql.update(key, value);
// 3. 延迟一段时间后再次删除缓存
executorService.schedule(() -> {
redis.delete(key);
}, delayTime, TimeUnit.MILLISECONDS);
}
3.2.2 关键参数:延迟时间
延迟时间的设置需要考虑:
- 数据库主从复制延迟
- 业务读操作的执行时间
- 系统响应时间
通常设置为500ms-1s,具体需要根据实际业务情况调整。
3.2.3 优点
- 解决并发读写问题:第二次删除可以清除掉并发读操作写入的旧数据
- 增强一致性:相比标准Cache-Aside,提供更好的一致性保证
3.2.4 缺点
- 实现复杂度增加:需要引入异步任务和延迟机制
- 短暂性能影响:在两次删除之间,可能有更多请求直接查询数据库
- 延迟时间难以精确设置:设置过短可能无效,设置过长影响性能
3.3:订阅MySQL Binlog ⭐⭐⭐⭐⭐
架构设计
text
MySQL → Binlog → Canal/Maxwell/Debezium → MQ(Kafka/RocketMQ) → 缓存服务 → Redis
java
// 1. Canal客户端监听Binlog
@Component
@Slf4j
public class CanalBinlogListener {
@Autowired
private RocketMQTemplate mq;
@PostConstruct
public void start() {
// 连接Canal Server
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress("canal-server", 11111),
"example", "", ""
);
connector.connect();
connector.subscribe("your_db\\..*"); // 订阅数据库
connector.rollback();
// 持续消费
while (true) {
Message message = connector.getWithoutAck(100);
long batchId = message.getId();
List<Entry> entries = message.getEntries();
if (batchId != -1 && !entries.isEmpty()) {
processEntries(entries);
}
connector.ack(batchId);
}
}
private void processEntries(List<Entry> entries) {
for (Entry entry : entries) {
if (entry.getEntryType() != EntryType.ROWDATA) {
continue;
}
RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
EventType eventType = rowChange.getEventType();
// 只处理UPDATE和DELETE
if (eventType == EventType.UPDATE || eventType == EventType.DELETE) {
String tableName = entry.getHeader().getTableName();
for (RowData rowData : rowChange.getRowDatasList()) {
// 提取主键
Long primaryKey = extractPrimaryKey(rowData);
// 发送删除缓存消息
CacheInvalidateMessage msg = new CacheInvalidateMessage();
msg.setTable(tableName);
msg.setKey(primaryKey);
msg.setTimestamp(System.currentTimeMillis());
mq.asyncSend("cache-invalidate-topic", msg, new SendCallback() {
@Override
public void onSuccess(SendResult result) {
log.debug("Sent cache invalidate: {}:{}", tableName, primaryKey);
}
@Override
public void onException(Throwable e) {
log.error("Failed to send cache invalidate", e);
// 降级:直接删除
redis.delete(buildKey(tableName, primaryKey));
}
});
}
}
}
}
}
// 2. MQ消费者删除缓存
@Component
@RocketMQMessageListener(
topic = "cache-invalidate-topic",
consumerGroup = "cache-service-group",
messageModel = MessageModel.BROADCASTING // 广播模式,所有实例都删除
)
public class CacheInvalidateConsumer implements RocketMQListener<CacheInvalidateMessage> {
@Autowired
private RedisTemplate redis;
@Autowired
private Cache<String, Object> localCache; // 本地缓存
@Override
public void onMessage(CacheInvalidateMessage msg) {
String key = buildKey(msg.getTable(), msg.getKey());
// 删除本地缓存
localCache.invalidate(key);
// 删除Redis缓存
redis.delete(key);
log.info("Cache invalidated: {}", key);
}
}
3.4:分布式锁(强一致性)⭐⭐⭐
java
@Service
public class LockBasedCacheService {
@Autowired
private RedissonClient redisson;
@Autowired
private RedisTemplate redis;
/**
* 读操作:加锁保证不会读到中间状态
*/
public User getUser(Long userId) {
String key = "user:" + userId;
String lockKey = "lock:user:" + userId;
RLock lock = redisson.getLock(lockKey);
try {
// 尝试获取锁,最多等5秒,持有10秒
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
// 双重检查
User user = (User) redis.opsForValue().get(key);
if (user != null) {
return user;
}
// 查询数据库
user = userMapper.selectById(userId);
// 写入缓存
if (user != null) {
redis.opsForValue().set(key, user, 1, TimeUnit.HOURS);
}
return user;
} else {
// 获取锁超时,降级查DB
log.warn("Failed to acquire lock, fallback to DB: {}", userId);
return userMapper.selectById(userId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("Interrupted while acquiring lock", e);
return userMapper.selectById(userId);
} finally {
// 释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 写操作:加锁保证原子性
*/
@Transactional(rollbackFor = Exception.class)
public void updateUser(User user) {
String key = "user:" + user.getId();
String lockKey = "lock:user:" + user.getId();
RLock lock = redisson.getLock(lockKey);
try {
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
// 更新数据库
userMapper.updateById(user);
// 删除缓存
redis.delete(key);
log.info("Updated user with lock: {}", user.getId());
} else {
throw new RuntimeException("Failed to acquire lock for update");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted while acquiring lock", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
4. 方案中的注意事项
4.1 延迟双删的注意事项
⚠️ 注意点1:延迟时间的确定
⚠️ 注意点2:第二次删除失败的处理
java
// ❌ 错误:忽略异常
asyncExecutor.schedule(() -> {
redis.delete(key);
}, 500, TimeUnit.MILLISECONDS);
// ✅ 正确:重试机制
asyncExecutor.schedule(() -> {
boolean success = false;
int retries = 3;
while (!success && retries > 0) {
try {
Boolean deleted = redis.delete(key);
success = Boolean.TRUE.equals(deleted);
if (!success) {
retries--;
Thread.sleep(100);
}
} catch (Exception e) {
log.error("Delayed delete failed, retries left: {}", retries, e);
retries--;
}
}
if (!success) {
// 发送告警
alertService.send("Delayed delete failed: " + key);
}
}, 500, TimeUnit.MILLISECONDS);
⚠️ 注意点3:事务回滚的处理
java
// ❌ 错误:事务内调度异步任务
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
// 如果后续发生异常回滚,这个任务已经提交了
asyncExecutor.schedule(() -> redis.delete(key), 500, TimeUnit.MILLISECONDS);
throw new RuntimeException(); // 事务回滚,但删除任务已提交
}
// ✅ 正确:事务提交后才调度
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
// 注册事务同步回调
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 事务提交后才执行
String key = "user:" + user.getId();
redis.delete(key);
asyncExecutor.schedule(() -> {
redis.delete(key);
}, 500, TimeUnit.MILLISECONDS);
}
}
);
}
4.2 Binlog订阅的注意事项
⚠️ 注意点1:Binlog格式必须是ROW
sql
-- 检查Binlog格式
SHOW VARIABLES LIKE 'binlog_format';
-- 如果是STATEMENT或MIXED,需要改为ROW
SET GLOBAL binlog_format = 'ROW';
-- 配置文件 my.cnf
[mysqld]
binlog_format = ROW
binlog_row_image = FULL # 完整行镜像
原因:
text
STATEMENT格式:只记录SQL语句,无法准确提取变更的行
MIXED格式:混合模式,不保证都是ROW
ROW格式:记录每行的变更,可以准确提取主键
⚠️ 注意点2:主从延迟问题
java
// ❌ 问题场景
T1: 写主库 → 更新成功
T2: Canal监听Binlog → 删除缓存
T3: 读请求 → 查缓存Miss → 查从库 → 从库还未同步 → 读到旧值 → 写入缓存
// ✅ 解决方案1:延迟删除
@Override
public void onBinlogEvent(BinlogEvent event) {
String key = buildKey(event);
// 考虑主从延迟,延迟删除
asyncExecutor.schedule(() -> {
redis.delete(key);
}, 200, TimeUnit.MILLISECONDS); // 延迟200ms
}
// ✅ 解决方案2:读主库
public User getUser(Long userId) {
String key = "user:" + userId;
User user = redis.get(key);
if (user == null) {
// 缓存未命中,强制读主库
user = userMapper.selectByIdFromMaster(userId);
redis.set(key, user);
}
return user;
}
⚠️ 注意点3:Binlog解析失败的处理
java
@Component
public class RobustBinlogListener {
@Autowired
private RocketMQTemplate mq;
public void processBinlog(Entry entry) {
try {
RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
// 处理逻辑
} catch (InvalidProtocolBufferException e) {
log.error("Failed to parse binlog entry", e);
// 发送到死信队列
DeadLetterMessage dlm = new DeadLetterMessage();
dlm.setEntry(entry);
dlm.setError(e.getMessage());
mq.send("binlog-dead-letter", dlm);
// 发送告警
alertService.send("Binlog parse failed", e);
}
}
}
4.3 分布式锁的注意事项。
⚠️ 注意点1:锁超时时间设置(看门狗机制续期)
⚠️ 注意点2:锁重入问题(AQS思路,统计重入次数)
⚠️ 注意点3:锁未正确释放
📊 总结与最佳实践
核心要点
- 首选方案:延迟双删 + 短TTL
- 高级方案:Binlog订阅 + 延迟双删
- 强一致:分布式锁(牺牲性能)
- 监控必备:一致性检查 + 告警
常见实施检查清单
✅ 选择合适的更新策略(先更新DB后删缓存)
✅ 实施延迟双删(动态计算延迟时间)
✅ 设置合理的TTL(根据业务特性)
✅ 添加监控告警(不一致率、命中率)
✅ 实施降级方案(Redis故障时直接查DB)
✅ 防止缓存穿透(布隆过滤器 + 空值缓存)
✅ 防止缓存击穿(互斥锁)
✅ 防止缓存雪崩(随机TTL)
✅ 事务同步(只在事务提交后操作缓存)
✅ 异常处理(重试机制 + 告警)
从CAP视角分析DB与Cache的数据一致性
在DB和Cache的分布式架构中,加入分布式Cache的目的是为了获得高性能、高吞吐,就是为了获得分布式系统的AP特性。所以,如果需要数据库和缓存数据保持强一致(强CP特性),就不适合使用缓存。从CAP的理论出发,使用缓存提升性能,就是会有数据更新的延迟,就会产生数据的不一致。使用分布式Cache,可以通过一些方案优化,保证弱一致性,最终一致性的。我们只能通过不断的方案迭代,减少不一致性的时间长度。
追问: 为什么选择删除缓存而非更新缓存
1. 更新缓存的潜在问题:
1.1 数据结构不一致问题
问题本质 :缓存中存储的数据结构可能与数据库中的结构不同
text
数据库:规范化的关系表结构
缓存:可能是聚合后的、优化查询的结构(如JSON、Hash等)
当直接更新缓存时,需要维护两套数据转换逻辑:
- 数据库→缓存的数据转换
- 业务逻辑→缓存的数据转换
这增加了系统复杂度和出错概率。
2.2 并发更新冲突
场景分析 :两个并发写操作可能导致数据覆盖错误
text
时间线:
T1: 写操作A更新数据库(X=1)
T2: 写操作B更新数据库(X=2)
T3: 写操作B更新缓存(X=2)
T4: 写操作A更新缓存(X=1)→ 错误!覆盖了更新的值
结果:数据库X=2,缓存X=1,数据不一致
而使用删除策略时:
text
时间线:
T1: 写操作A更新数据库(X=1)
T2: 写操作B更新数据库(X=2)
T3: 写操作A删除缓存
T4: 写操作B删除缓存
结果:数据库X=2,缓存已删除,后续读取会加载正确数据
2.3 缓存更新失败的风险
更新操作的问题 :更新缓存需要更多的业务逻辑和数据转换,失败概率更高
如果更新缓存失败,会导致:
- 缓存中保留旧数据
- 数据库已有新数据
- 系统出现不一致状态
而删除操作更为简单直接,失败风险更低。
3. 删除缓存的优势分析
3.1 简单性与可靠性
核心优势 :删除操作比更新操作简单可靠
- 操作原子性 :删除是一个简单的键操作,成功或失败明确
- 逻辑解耦 :不需要在写路径维护复杂的数据转换逻辑
- 减少错误 :避免了缓存数据结构与业务逻辑的同步问题
3.2 懒加载模式的优势
性能优化 :采用"按需加载"策略,减少不必要的计算
- 缓存利用率更高 :只缓存实际被请求的数据
- 资源利用更合理 :避免为很少访问的数据更新缓存
- 计算成本分摊 :将数据转换和计算成本分摊到读操作中
3.3 自动处理缓存过期
一致性保障 :删除缓存天然与过期策略协同工作
- 即使删除失败,缓存过期机制也能最终保证一致性
- 避免了更新过期或即将过期的缓存造成的资源浪费
4. 实际场景对比分析
4.1 读多写少场景
更新缓存策略 :
- 写操作时执行复杂的缓存更新
- 但大部分更新的缓存可能很少被读取
- 导致计算资源浪费
删除缓存策略 :
- 写操作简单高效
- 只有实际被读取的数据才会重新加载到缓存
- 资源利用更高效
4.2 缓存数据结构复杂场景
更新缓存策略 :
- 需要在写操作中维护复杂的数据转换逻辑
- 业务逻辑变更时需要同时更新多处代码
- 容易出错,维护成本高
删除缓存策略 :
- 写操作只关注数据库更新
- 数据转换逻辑集中在读取路径
- 维护成本低,一致性更容易保证
4.3 高并发场景
更新缓存策略 :
- 并发写可能导致缓存数据覆盖错误
- 复杂的更新操作增加锁竞争
- 性能影响较大
删除缓存策略 :
- 删除操作简单快速
- 并发删除不会导致数据错误
- 性能更好,扩展性更强