1. 查询(Read):旁路缓存模式 (Cache Aside)
查询操作是维护一致性的起点。其核心逻辑是:缓存作为数据库的影子,随需而生。
正例:延迟加载
- 先从缓存读取数据。
- 若缓存命中,直接返回。
- 若缓存未命中,从数据库读取,并将其写入缓存。
示例代码
java
public DataDO getData(Long id) {
String key = "data:" + id;
// 1. 查缓存
DataDO data = redis.get(key);
if (data != null) return data;
// 2. 查库并回写(注意设置过期时间防止冷数据堆积)
data = mapper.selectById(id);
if (data != null) {
redis.setex(key, 30, TimeUnit.MINUTES, data);
}
return data;
}
2. 增加(Create):先库后球 (Database First)
对于新产生的数据,由于缓存中本就不存在,一致性维护相对简单,但必须遵守时序规则。
反例:先写缓存,再写数据库
- 现象 :先执行
redis.set(token),随后数据库insert抛出主键冲突或字段非空异常(如之前遇到的expires_time漏填)。 - 后果:数据库事务回滚,但 Redis 写入不可逆,产生"孤儿缓存",前端持有 Token 却在库中找不到对应记录。
正例:先操作数据库,成功后再写入/删除缓存
java
@Transactional
public void create(DataDO data) {
// 1. 先写数据库(最重、最易失败的操作先走)
mapper.insert(data);
// 2. 再写/删除缓存(即使失败,后续读操作也能通过从库加载来补救)
redis.set(key, data);
}
3. 修改(Update):先改库,再删缓存
这是缓存一致性讨论中最激烈的环节。我们不仅要掌握正确顺序,更要理解其背后的极端边界情况。
在分布式一致性设计的讨论中,针对"修改(Update)"操作,最关键的抉择在于:到底是"更新"缓存,还是"删除"缓存?
以下是针对第三章的补充内容,详细说明了方案选型的底层逻辑。
3. 修改(Update):先改库,再删缓存
3.1 方案选型:为什么是"删除"而不是"更新"?
在更新数据库时,针对缓存的操作有两种理论方案:
- 更新缓存:每次数据库改完,就把新值写回 Redis。
- 删除缓存:数据库改完,直接把旧缓存删了,等下次查询时再从库里加载。
我们之所以选择"删除缓存",主要基于以下两个原因:
A. 规避并发下的数据错位
假设有两个线程 A 和 B 同时更新同一条数据,如果使用"更新缓存"方案:
- 线程 A 更新了数据库。
- 线程 B 更新了数据库。
- 线程 B 网络较快,先更新了缓存。
- 线程 A 网络较慢,后更新了缓存。
- 后果:数据库里是 B 的新值,但缓存里却是 A 的旧值。
- 结论:如果采用"删除缓存",无论谁先谁后,缓存最终都是空的,下次查询都会拿到数据库的最新的正确值。
B. 降低无效计算与写操作
- 场景:某个业务字段(如商铺详情)更新非常频繁(每秒 100 次),但查询相对较少(每分钟 1 次)。
- 更新缓存:1 分钟内会执行 6000 次 Redis 写入,其中 5999 次都是无意义的。
- 删除缓存:1 分钟内只需执行几次删除操作,极大节省了系统资源。
3.2 方案改进:有没有其他更强的方案?
虽然"先改库,再删缓存"是主流,但在对一致性要求极高的场景下,还有两个进阶方案:
方案一:延迟双删(Cache Aside 升级版)
为了解决我们之前提到的"读写竞态导致旧值回写"的极小概率问题,可以采用:
- 先删除缓存。
- 更新数据库。
- 睡眠 500ms(根据业务读耗时决定)。
- 再次删除缓存。
- 原理:确保在数据库更新期间,那些由于并发读操作产生并试图写回缓存的"脏数据",会被第二次删除动作彻底清理。
方案二:监听 Binlog 异步同步(Canal 方案)
这是最优雅的解耦方案:
- 业务只管改库:程序只负责修改 MySQL。
- 监听 Binlog:通过 Canal 等中间件模拟成 MySQL 的从库,实时获取数据库的变化。
- 异步删缓存:Canal 接收到数据变动消息后,再去操作 Redis。
- 优点:即使业务代码执行完后系统宕机,由于 Binlog 已经产生,后续的删除动作依然会由 Canal 保证执行,且不占用业务主流程的时间。
反例:先删缓存,再更新数据库
这是绝对禁止的操作,因为它会导致缓存中被永久性写入"旧数据"。
- 线程 A 删除了缓存。
- 线程 B 进来查询,发现缓存为空,从数据库读取了旧值。
- 线程 B 将旧值回写进缓存。
- 线程 A 完成数据库更新。
- 结果:数据库已是最新,但缓存永远停留在旧值,除非缓存过期。
正例:先更新数据库,再删除缓存
这是目前公认的推荐方案,虽然它也并非"银弹"。
场景分析:该方案的"理论漏洞"
在极端的并发竞态下,即使先改库再删缓存,也可能发生以下时序:
- 缓存刚好失效。
- 线程 A(读操作) :查询缓存发现未命中,去数据库读到了旧值。
- 线程 B(写操作) :更新数据库为新值。
- 线程 B(写操作):执行删除缓存(此时缓存本就是空的,删除无影响)。
- 线程 A(读操作) :将刚才读到的旧值写入缓存。
- 后果:此时数据库是新值,缓存是旧值。
为什么这种"不一致"在可接受范围内?
尽管存在上述漏洞,但它在实际生产中发生的概率极低,原因有二:
- 概率极低:发生该情况的前提是"缓存刚好失效"且"读写高度并发"。
- 速度差异:步骤 2(数据库读)和步骤 5(写入缓存)之间的耗时,通常远小于步骤 3(数据库写)和步骤 4(删除缓存)的耗时。在现实中,读操作几乎总是先于写操作完成,从而避免了旧值回写覆盖新值的尴尬。
示例代码
java
@Transactional(rollbackFor = Exception.class)
public void updateBusiness(BusinessDO business) {
// 1. 先操作数据库,保证持久化成功
businessMapper.updateById(business);
// 2. 数据库成功后,删除缓存
// 注意:如果是重要的业务,建议配合重试机制防止删除失败
businessRedisDAO.delete(business.getId());
}
💡 针对读者的建议
为了进一步弥补这个极小的漏洞,我们通常会配合以下手段:
- 给缓存设置合理的过期时间(TTL):即使发生了上述不一致,数据也会在一段时间后自动修正。
- 双删延迟(针对特殊场景):在修改数据库后,先删一次缓存,等待几百毫秒后再删一次,以确保那些执行慢的"旧值回写"动作被最终清理。
4. 删除(Delete):事务同步 (Transactional Sync)
删除操作要求物理数据与索引数据同步消失。
反例:缓存与数据库操作不在同一事务
- 现象 :数据库执行
delete成功,但因代码异常导致未执行redis.delete。 - 后果:查询请求会持续命中缓存中的旧数据,呈现"删不掉"的假象。
正例:声明式事务保证最终一致性
java
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
// 1. 物理删除
mapper.deleteById(id);
// 2. 逻辑/物理清除缓存
redis.delete(key);
}
讲解 :在单体系统中,利用 @Transactional 确保数据库失败时不会触发缓存删除;在分布式场景下,若缓存删除失败,需通过 MQ 重试 或 Canal 监听 Binlog 异步清理。
5. 核心原则总结
| 维度 | 操作建议 | 关键理由 |
|---|---|---|
| 查询 | 先缓存 -> 后数据库 | 降低数据库压力,按需加载 |
| 增加 | 先写库 -> 后写缓存 | 防止数据库回滚导致缓存数据"虚晃一枪" |
| 修改 | 先改库 -> 后删缓存 | 避免并发读写引发的旧数据回写覆盖新数据 |
| 删除 | 先删库 -> 后删缓存 | 保证数据的一致下线 |