本文对应知识卡片集:
- url:https://qingk.com/set/3XN3e61khlmM1
- password:asd。!@3ad
相关文章
在此之前先认清两个一致性
强一致性(Strong Consistency)
强一致性要求系统中的所有数据副本在任何时候都是一致的。这意味着,一旦数据更新操作完成,任何对该数据的读取操作都会立即反映出这次更新。强一致性可以保证所有用户在任何时间都能看到相同的数据版本。这种一致性模型适用于对数据一致性要求非常高的场景,如金融交易系统。
最终一致性(Eventual Consistency)
最终一致性是一种更宽松的一致性模型。它只保证如果系统不再接收新的更新,那么最终所有数据副本将会达到一致的状态。在这种模型中,数据的更新可能会在不同副本之间延迟传播,因此在某些时刻,不同的用户可能会看到不同版本的数据。这种模型适用于对实时性要求不是非常高,但需要高可用性和分区容错性的系统,例如许多大规模的互联网应用。
最终一致性方案
优点 | 缺点 | 备注 | |
---|---|---|---|
更新缓存方案 | 出现脏数据 | 本方案仅用于讨论,实际场景下并不使用 | |
先删除缓存,再更新数据库 | 出现脏数据 | 本方案仅用于讨论,实际场景下并不使用 | |
延迟双删 | 较先删除缓存,再更新数据库,一定程度解决了脏数据的问题 | 1. 需要两次删除操作,高并发场景下对 redis 造成了更大的压力。 2. 如果第二次删除失败了,整体的效果会退化至先删除缓存,再更新数据库。 | 个人感觉,改进效果一般,不实用。 |
先更新数据库,再删除缓存 | 1. 写入数据库(操作1)和删缓存(操作2)之间,存在短时间的数据不一致。 2. 如果删缓存失败,则会存在较长时间的数据不一致,这个时间会一直持续到缓存过期。 | 比较常见的方案 | |
先更新数据库,再基于队列删除缓存 | 较先更新数据库,再删除缓存,进一步解决了删除缓存失败的问题 | 1. 如果写操作非常频繁,队列的任务比较多,消费可能会比较慢,需要引入多线程机制,加快消费速度。 2. 程序复杂度成倍上升,引入了消费线程、任务队列,并且还需要不断进行性能优化。 | |
先更新数据库,然后由 binlog+消息队列进行缓存删除 | 进一步解耦缓存的更新操作,并提高拓展性 | 除了先更新数据库,再基于队列删除缓存原有的缺点外,再引入了日志监听工具,进一步增加复杂性。 |
关于缓存的强一致性与最终一致性存在着下列特点:
性能和一致性不能同时满足,为了性能考虑,通常会采用「最终一致性」的方案
掌握缓存和数据库一致性问题,核心问题有 3 点:缓存利用率、并发、缓存 + 数据库一起成功问题
失败场景下要保证一致性,常见手段就是「重试」,同步重试会影响吞吐量,所以通常会采用异步重试的方案
订阅变更日志的思想,本质是把权威数据源(例如 MySQL)当做 leader 副本,让其它异质系统(例如 Redis / Elasticsearch)成为它的 follower 副本,通过同步变更日志的方式,保证 leader 和 follower 之间保持一致
更新缓存方案
缺点 :出现脏数据
在实际的业务场景中,一种常见的并发场景是:微服务 Provider 实例 A、B 同时进行同一个数据的更新操作。
按照先更新数据库,再更新缓存的策略,微服务 Provider 实例A、B可能会出现下面的执行次序:
具体的原因是:Provider B更新到缓存中的数据被Provider A更新到缓存中的数据覆盖了。数据库的更新次序是先A后B,理论上缓存是Provider B的数据而不是Provider A的数据。所以,在上述流程执行完毕后,缓存中的 Provider A 的数据为脏数据。
之所以出现这个问题,是因为以上流程中步骤3与步骤4执行的均为操作缓存,都是高并发的操作,很难保证先后次序,所以缓存出现脏数据的概率很大。
至于先更新缓存再更新数据库的方案也是同理。
除了出现脏数据之外,更新缓存相对于删除缓存还有两点劣势:
- 如果写入缓存的值是经过复杂计算才得到的,更新缓存频率高的话,就会大大降低性能。
- 及时更新缓存属于饿汉模式,适用于数据读取高频的场景。在写多读少的情况下,数据很多时候还没被读取到就又被更新了,这也浪费了缓存的空间,降低了性能。
先删除缓存,再更新数据库
缺点:出现脏数据
在实际的业务场景中,一种常见的并发场景是:微服务Provider实例A进行数据的写操作,而微服务Provider实例B同时进行同一个数据的读操作。按照先删除缓存,再更新数据库的策略,微服务 Provider 实例 A、B 可能会出现下面的执行次序:
Provider B查询缓存的时候,缓存中的数据被删除,Provider B只能去数据库中查找,然后将数据更新到缓存,而Provider A在Provider B查询完之后更新了数据库,导致了数据库和缓存的不一致。
延迟双删
延迟双删是为了解决先删除缓存,再更新数据库出现的脏数据问题而提出的:
其大致方案如下:先删除缓存,再更新数据库,延迟一段时间后去删除缓存(此处延迟,个人理解是为了让数据成功落地到数据库中,但是在部分同步操作情况下,等待数据库返回响应其实就是一种延迟了)
微服务Provider实例A进行数据的写操作,而微服务Provider实例B同时进行同一个数据的读操作。微服务Provider实例A、B可能会出现下面的执行次序:
存在问题:
如果写操作比较频繁,可能会对Redis造成一定的压力。
在极端情况下,第二次延迟删缓存失败,操作的效果退化到先删除缓存,再写数据库:数据库和缓存存在较长时间的数据不一致,这段时间会一直持续到缓存过期,比如4个小时(以项目中的配置时间为准)。
先更新数据库再删除缓存
按照先更新数据库,再删除缓存的策略,微服务Provider实例A、B可能会出现下面的执行次序:
存在问题:
写入数据库(操作1)和删缓存(操作2)之间,存在短时间的数据不一致。
如果删缓存失败,则会存在较长时间的数据不一致,这个时间会一直持续到缓存过期。
先更新数据库,再基于队列删除缓存
本策略是对先更新数据库,再删除缓存的策略进行改进后得到的:
写数据库(操作1)和删缓存(操作2)之间存在短时间的数据不一致。
如果删缓存失败,则会存在较长时间的数据不一致,这个时间会一直持续到缓存过期。
使用队列删除的好处是:可以解耦删除缓存的操作,进行不断重试(毕竟重试就会造成响应速度的降低),知道成功为止,如此解决了问题 2
本策略存在的问题:
如果写操作非常频繁,队列的任务比较多,消费可能会比较慢,需要引入多线程机制,加快消费速度。
程序复杂度成倍上升,引入了消费线程、任务队列,并且还需要不断进行性能优化。
队列的选择与对比
特性 | 消息队列 | 内存队列 |
---|---|---|
响应速度 | 较慢(需网络传输) | 较快 |
效率 | 高(可部署多消费者) | 依实现方式而定 |
可靠性 | 高(支持持久化) | 低(数据易丢失) |
耦合性 | 低 | 高 |
扩展性 | 高(易于增加消费者处理能力) | 依实现方式而定 |
事务性 | 支持(可保证操作顺序和完整性) | 通常不支持 |
成本 | 相对较高(需要维护、可能需支付服务费用) | 通常较低 |
适用场景
- 消息队列:
- 高可靠性需求:需要保证数据不丢失,可以进行持久化存储。
- 大规模数据处理:需要处理大量数据或请求,可通过增加消费者来扩展处理能力。
- 事务性要求:需要保证操作的顺序和完整性,例如订单处理流程。
- 内存队列:
- 快速响应需求:追求最低延迟和最快处理速度,适用于对实时性要求较高的场景。
- 成本敏感型应用:成本考虑优先,适用于小规模或初创项目。
- 简单应用场景:应用逻辑简单,数据丢失风险较低或易于恢复的场景。
先更新数据库,然后由 binlog+消息队列进行缓存删除
和单纯的基于消息队列删除缓存的优势是:微服务Provider在执行数据库和缓存双写时,只需要执行写入数据库的操作就可以了,大大简化了微服务Provider的业务逻辑。
方案示例
此处使用 MySQL 作为示例
使用 maxwell 去监听 binlog ,然后 maxwell 将 binlog 同步到 RabbitMQ 中,负责删除缓存的组件监听 RabbitMQ ,接受到消息后,删除对应的缓存。
其中有几个注意事项:
- 什么 SQL 语句需要被处理
- 如何去提高消费速度,降低 redis 的压力
- 此方案也是只能保证最终一致性,期间还是存在短时间的数据不一致
强一致性方案
分布式读写锁
分布式读写锁用于控制对共享资源的并发访问。它可以确保在任何时刻,要么只有一个节点可以对资源进行写操作,要么有多个节点可以进行读操作,但两者不能同时发生。这种锁通常通过分布式协调服务(如Zookeeper或Etcd)来实现。分布式读写锁有助于保证数据的一致性,但可能会因为锁的竞争和网络延迟而影响系统的性能和可用性。
分布式事务
分布式事务涉及到多个节点上的操作,这些操作要么全部成功,要么全部失败,以此来维持事务的原子性、一致性、隔离性和持久性(ACID属性)。实现分布式事务的方法有两阶段提交(2PC)、三阶段提交(3PC)和基于时间戳的协议等。虽然分布式事务可以提供强一致性保证,但它们往往会引入较大的延迟和复杂性,并可能影响系统的整体性能。
分布式共识算法
在缓存的强一致性方案中,主要是前两种。
其中 Paxos 和 Raft 是两个著名的共识算法。
参考资料
本文资料与论证来自各种网络资料,加之本人实践理解,由于时间跨度较大,无法一一列出。