一个灵魂拷问,80%的开发都踩过的坑
大家好。今天咱们来聊聊一个老生常谈但又极其容易踩坑的话题:当数据库和缓存一起使用时,到底先操作谁?
这个问题看似简单,但稍有不慎就会导致数据不一致,引发各种诡异的线上问题。下面我直接用最简洁的方式,给你讲透4种经典策略。
问题本质:并发场景下的脏数据
先看一个典型场景:
场景A:先删缓存,再更新数据库
线程1:删除缓存 → 线程2:读缓存(miss)→ 线程2:读数据库(旧值)→ 线程2:写缓存(旧值) → 线程1:更新数据库(新值)结果:缓存里是旧值,数据库是新值,脏数据诞生。
场景B:先更新数据库,再删缓存
线程1:更新数据库 → 线程2:读缓存(命中,旧值)→ 线程1:删除缓存结果:线程2读到了旧值,虽然短暂不一致,但缓存最终被删除,下次读取会重新加载新值。
对比结论:场景B的窗口期更短,风险更小。
策略一:先更新数据库,后删缓存(Cache Aside Pattern)
这是最经典的策略,也是大部分场景的首选。
执行流程:
1. 更新数据库2. 删除缓存(不是更新缓存)为什么是删除而不是更新?
- 更新缓存可能涉及复杂计算
- 如果缓存被频繁更新但很少被读,纯属浪费资源
- 删除缓存,等下次读取时再加载(懒加载)
代码示例:
// 伪代码public void updateProduct(Product product) { // 1. 更新数据库 productDao.update(product); // 2. 删除缓存 redisTemplate.delete("product:" + product.getId());}注意:删除缓存这一步可能失败,需要用重试机制或监听binlog来兜底。
适用场景:读多写少、对短暂不一致不敏感的系统。
策略二:先删缓存,后更新数据库(慎用)
这种策略的问题我们开头已经分析过,并发下极易产生脏数据。如果你非要这么用,必须配合延迟双删。
延迟双删代码:
public void updateProduct(Product product) { // 第一次删除缓存 redisTemplate.delete("product:" + product.getId()); // 更新数据库 productDao.update(product); // 延迟一段时间(比如1秒),再次删除缓存 Thread.sleep(1000); redisTemplate.delete("product:" + product.getId());}延迟的目的是什么? 等待可能存在的其他读线程把旧数据写回缓存,然后第二次删除把它清理掉。
延迟时间怎么定? 大于一次读请求的耗时(通常100-500ms),保守点可以设1秒。
适用场景:读写并发极高的场景,且能接受短暂延迟。
策略三:延迟双删(双删缓存)
其实这是对策略二的优化,但足够经典,值得单独拎出来讲。
完整流程:
public void updateProduct(Product product) { // 1. 第一次删除缓存 delCache(product.getId()); // 2. 更新数据库 updateDb(product); // 3. 异步延迟删除(用消息队列或线程池) asyncDelayDel(product.getId(), 1000);}private void asyncDelayDel(String id, long delayMs) { CompletableFuture.runAsync(() -> { Thread.sleep(delayMs); delCache(id); });}优点:能有效解决并发脏数据问题,第二次删除保证了最终一致性。
缺点:代码复杂度增加,延迟时间内数据可能短暂不一致。
策略四:订阅数据库Binlog(最强兜底方案)
前面几种策略都有一个共同弱点:删除缓存可能失败。而binlog方案能完美解决这个问题。
架构流程:
1. 业务直接更新数据库2. 数据库产生binlog日志3. Canal / Debezium 订阅binlog4. 解析变更消息,异步删除对应缓存代码示例(Canal配置):
# canal 配置canal.instance.master.address=127.0.0.1:3306canal.instance.dbUsername=canalcanal.instance.dbPassword=canalcanal.instance.filter.regex=test\..*消费者伪代码:
public void handleBinlog(BinlogEntry entry) { String tableName = entry.getTableName(); String rowId = entry.getRowId(); if ("product".equals(tableName)) { // 删除缓存 redisTemplate.delete("product:" + rowId); }}优点:
- 删除操作与业务解耦
- 100%保证最终一致性
- 即使业务代码忘记删缓存,兜底也能补救
缺点:
- 引入额外组件,架构变复杂
- 有延迟(毫秒级)
适用场景:对一致性要求极高、愿意接受架构复杂度的系统。
总结对比
策略 | 一致性 | 复杂度 | 适用场景 |
先更新DB后删缓存 | 高 | 低 | 通用首选
|
先删缓存后更新DB | 低 | 低 | 不推荐 |
延迟双删 | 中 | 中 | 读写并发极高 |
Binlog订阅 | 最高 | 高 | 强一致性要求 |
记住一句话:缓存是数据库的复印件,更新时直接扔掉复印件,等下次要用了再复印新的。这就是Cache Aside Pattern的核心思想。
没有银弹,根据你的业务场景选择合适的策略。如果非要我给个建议:大部分业务用策略一 + 重试机制就够了,追求极致就用策略四兜底。
希望这篇文章能帮你彻底搞定缓存一致性问题。如果有不同见解,欢迎留言讨论!
💻 配套实训环境与动手练习
本文涉及的相关技术指令、开发环境与工具链已内置在边学边练在线实验室中,无需繁琐安装配置,随时在浏览器中实践体验:
