技术面:如何解决缓存和数据库一致性的问题?

前言

缓存(例如:Redis)和数据库的数据一致性问题,也是一个经典的问题,无论是在面试还是在日常工作当中,遇到的概率非常大。尤其是在高并发的场景下,这个问题会变得更加严重。

业内常见的解决方案

  1. 先更新数据库,再删缓存。
  2. 延迟双删:先删缓存,再更新数据库,延时一段时间,再删一次缓存。
  3. Canal/Maxwell订阅 binlog,更新数据库,基于中间件监听binlog后删除缓存。

这三种方案基本上就能解决市面上大部分的业务场景中的缓存不一致的问题了。

方案优点缺点适用场景
先更新数据库,再删缓存实现简单,对业务代码侵入小删缓存失败,存在数据不一致的问题95%一般场景都适合,尤其是并发量不大,或者对一致性要求不太高的。
延迟双删数据一致性保证更好第二次删失败要重试;延迟 时长要靠压测估对数据一致性要求高,并发量大的场景。
更新数据库,基于中间件监听binlog后删除缓存与业务代码解耦,一致性有保障实现复杂,需要引入中间件适合基础建设完善,且并发高对一致性要求高的场景

先写数据库,再删缓存

先来说一下,为什么是删除缓存,而不是更新缓存?

并发写冲突风险高

在高并发场景下,多个线程/服务可能同时更新同一份数据如果采用更新缓存的策略,容易出现竞态条件,导致缓存中的数据是旧值或错误值

假设有两个线程 A 和 B,同时更新数据库和缓存:

时间线程 A线程 B
T1更新数据库为 100
T2更新数据库为 200
T3更新缓存为 100(A 的旧值)
T4更新缓存为 200(B 的新值)

最终缓存中是 200,看起来没问题,但如果 T3 发生在 T4 之后(由于网络延迟、线程调度等),缓存就会被A的旧值覆盖,变成 100,导致缓存和数据库不一致

时间线程 A线程 B
T1更新数据库为 100
T2更新数据库为 200
T3更新缓存为 200(B 的新值)
T4更新缓存为 100(A 的旧值)

但是删除缓存就不存在这个问题,删除缓存操作具有幂等性,多次执行不会产生副作用,相比更新缓存更适合在高并发场景下使用。

缓存更新逻辑复杂,容易出错

更新缓存时,通常需要:

  1. 从数据库读取最新值
  2. 构造缓存对象(可能涉及序列化、字段拼接等)
  3. 写入 Redis

这个过程中,任何一个环节出错,都可能导致缓存中的数据是不完整或错误的。

删除缓存逻辑简单,只要一个 DEL 操作,出错概率低,恢复快。

延迟加载(Lazy Loading)更自然

删除缓存后,下一次读取时会触发缓存未命中,然后从数据库加载最新数据并回填缓存。
这种延迟加载机制:

  • 保证了缓存中的数据总是从数据库加载的最新值
  • 避免了提前写入缓存时可能用到的旧数据
删除缓存配合“先更新数据库,再删除缓存”策略更可靠

先更新数据库,再删除缓存,这个策略在大多数情况下能保证最终一致性,即使出现极端情况(如删除缓存失败),也可以通过消息队列重试或定时任务补偿

但是有时候删除缓存还要考虑一些极端场景,例如:删除缓存后,大量请求打到数据库,造成缓存击穿。通常的解决方案就是
使用互斥锁(如 Redis 分布式锁)保证只有一个线程去加载数据

在一致性优先的场景下,删除缓存比更新缓存更稳妥、更简单、更容易兜底的选择。

先写数据库还是先删缓存?

上面通过比较,我们知道了,删缓存比更新缓存更好,所以为了降低数据不一致的情况产生,选择删缓存。那么是先更新数据库再删缓存呢,还是说,先删缓存再更新数据库呢?

先删缓存,再更新数据库

举个例子来说明:
还是有两个线程 A 和 B,线程A是写操作,线程B是读操作。

时刻线程 A(写)线程 B(读)
T1删除缓存成功
T2缓存 miss,去库读旧值 100
T3把 100 回填缓存
T4数据库更新为 200(还没提交或同步完成)

也有可能T3发生在T4之后

时刻线程 A(写)线程 B(读)
T1删除缓存成功
T2缓存 miss,去库读旧值 100
T3数据库更新为 200(还没提交或同步完成)
T4把 100 回填缓存

如果先删缓存,就有可能导致缓存里在很长一段时间内都是 旧值 100,直到下一次失效或更新。

先更新数据库,再删缓存

如果我们先更新数据库,再删除缓存,有一个好处,那就是缓存删除失败的概率还是比较低的,除非是网络问题或者缓存服务器宕机的问题,否则大部分情况都是可以成功的。

并且这个方案还有一个好处,那就是数据库是作为持久层存储的,先更新数据库就能确保数据先写入持久层可以保证数据的可靠性和一致性,即使在删除缓存失败的情况下,数据库中已有最新数据。

还是两个线程A和B,线程A写,线程B读。

时刻线程 A(写)线程 B(读)
T1更新数据库为 200(事务已提交)
T2删除缓存(DEL)
T3缓存 miss,去库读 200
T4把 200 回填缓存

延迟双删

这种先更新数据库,再删缓存,也有一定的缓存不一致的概率,例如:在更新完数据库,还未删除缓存的这段时间。还有就是更新完数据库,如果删除缓存失败了的情况。
那么怎么解决这些问题呢?可以使用更复杂的另一个方案延迟双删

延迟双删”是为了解决高并发场景下 缓存与数据库数据不一致 的一种折中策略,它并不能保证强一致性,但可以缩小不一致的时间窗口。之所以要执行两次删除,是为了应对并发读写过程中可能出现的脏数据回写问题。

基本流程

  1. 第一次删除缓存
    在更新数据库前或后,先删除缓存,防止旧数据被读取。
  2. 更新数据库
  3. 延迟一段时间后,再次删除缓存
    这是关键步骤,目的是清除在并发过程中可能被错误写入的旧数据。

image

为什么需要两次删除?

  1. 第一次删除:防止读取旧缓存
  • 如果不删缓存,用户可能一直读到旧数据。
  • 删除缓存后,新的读请求会“穿透”到数据库,理论上能拿到最新数据。
  1. 第二次删除(延迟删):防止并发写入脏数据
    在第一次删除缓存后,如果有并发读请求进来,它可能:
  • 发现缓存为空;
  • 去数据库读数据;
  • 但此时数据库还没更新完,或者主从同步延迟,读到了旧数据;
  • 然后把旧数据写回缓存,造成“脏数据回写”。

👉 延迟一段时间后再次删除缓存,就是为了清除这种被误写入的旧数据,尽可能保证缓存与数据库的一致性

延迟双删需要注意事项

  • 延迟时间设置是个经验值, 应大于主从同步最大延迟 + 一次完整读请求耗时 + 网络抖动缓冲时间,建议通过监控主从延迟动态调整。
  • 延迟双删不能100%避免不一致,只是降低概率 。
  • 如果第二次删除失败,仍可能导致不一致,因此通常会配合消息队列重试机制作为兜底方案。

这是一种最终一致性策略,适用于对一致性要求较高、但无法承受强一致性开销的业务场景

Cache-Aside+监听binlog删缓存

Cache-Aside + 监听 binlog(或异步消息)删除缓存,是目前主流的最终一致性方案,把“业务代码”与“缓存失效”彻底解耦,既保留 Cache-Aside 的简单性,又通过异步机制弥补“删缓存失败”或“并发脏读”带来的不一致窗口。

基本流程

image

这种流程的好处在于

  • 写路径缩短到一次 DB 事务,应用不直接碰 Redis,P99 延迟低。
  • 即使 Redis 宕机、MQ 堆积,binlog 仍在,具备天然的重放机制,可用于故障恢复。

代码示例

/* by 01022.hk - online tools website : 01022.hk/zh/formatjava.html */
// 1. 业务代码——只写库
@Transactional
public void updateOrder(Order order) {
// 写操作不直接更新缓存,由binlog监听异步删除缓存,读操作仍通过Cache-Aside机制回填缓存。
    orderMapper.updateById(order); 
}

// 2. Canal 适配器——把 binlog 转成简单事件
@CanalEventListener
public class OrderHandler {
    @KafkaSender(topic = "order_binlog")
    public void onUpdate(CanalEntry.Entry entry) {
        // 解析出主键 orderId
        String orderId = parseOrderId(entry);
        return new BinlogEvent("UPDATE", orderId, entry.getHeader().getExecuteTime());
    }
}

// 3. 缓存失效服务——消费并删除
@KafkaListener(topics = "order_binlog", groupId = "cache_clean")
public void cleanCache(BinlogEvent event) {
    try {
        redisTemplate.del("order:" + event.getOrderId());
    } catch (Exception e) {
        // 失败抛异常,让 MQ 重试
        sendRetryMsg(event.getOrderId());
        throw new RuntimeException("DEL failed", e);
    }
}

上面代码示例,我是分开处理的,先处理canal的消息,再处理删除缓存的消息。其实理论上也可以合并到一起,并且如果删除缓存失败,就不提交MQ的offset,只有消费端 ACK 只有 DEL 成功才提交 offset。

Cache-Aside 负责“让缓存可旁路”,binlog 监听负责“让缓存一定失效”;两者结合,写性能不衰减,一致性可兜底,架构可水平扩展,在大多数读多写少、对一致性要求较高的业务场景中,是一种均衡且可扩展的方案。

总结

前面介绍了几种情况的具体问题和解决方案,那么实际工作中应该如何选择呢?

  1. 没 MQ、没 DBA、团队小 → 先更新库再删缓存,极限情况下脏读 1 s 能忍就忍(例如:电商商品详情、用户资料)。

  2. 读 QPS 爆表(10w+)且同一条 key 被反复热读 → 延迟双删把“刚写立刻被读脏”概率再压一个量级(例如:秒杀库存、热点配置)。

  3. 已经上了 Canal/RocketMQ,或者团队有“消息驱动”规范 → 业务代码最干净,后续谁改库都不用管缓存;同时做好“消息失败可回查 DB 修复”的兜底脚本(订单、支付、账务等“不能错钱”场景)。

任何的技术方案,都是一个权衡的过程,要权衡的问题有很多,业务的具体情况,实现的复杂度、实现的成本,团队成员的接受度、可维护性、容易理解的程度等等。

所以,没有一个"完美"的方案,只有"适合"的方案。
但是,如何能选出一个更适合的方案,这里面就需要有更多的输入参考来做支撑了。

作者:纪莫
欢迎任何形式的转载,但请务必注明出处。
限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。

欢迎扫描二维码关注公众号:Jimoer

文章会同步到公众号上面,大家一起成长,共同提升技术能力。

声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。

您的鼓励是博主的最大动力!

微信公众号
内容概要:本文介绍了基于贝叶斯优化的CNN-LSTM混合神经网络在时间序列预测中的应用,并提供了完整的Matlab代码实现。该模型结合了卷积神经网络(CNN)在特征提取方的优势与长短期记忆网络(LSTM)在处理时序依赖问题上的强大能力,形成一种高效的混合预测架构。通过贝叶斯优化算法自动调参,提升了模型的预测精度与泛化能力,适用于风电、光伏、负荷、交通流等多种复杂非线性系统的预测任务。文中还展示了模型训练流程、参数优化机制及实际预测效果分析,突出其在科研与工程应用中的实用性。; 适合人群:具备一定机器学习基基于贝叶斯优化CNN-LSTM混合神经网络预测(Matlab代码实现)础Matlab编程经验的高校研究生、科研人员及从事预测建模的工程技术人员,尤其适合关注深度学习与智能优化算法结合应用的研究者。; 使用场景及目标:①解决各类时间序列预测问题,如能源出力预测、电力负荷预测、环境数据预测等;②学习如何将CNN-LSTM模型与贝叶斯优化相结合,提升模型性能;③掌握Matlab环境下深度学习模型搭建与超参数自动优化的技术路线。; 阅读建议:建议读者结合提供的Matlab代码进行实践操作,重点关注贝叶斯优化模块与混合神经网络结构的设计逻辑,通过调整数据集参数加深对模型工作机制的理解,同时可将其框架迁移至其他预测场景中验证效果。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值