介绍
数据一致性的概念
数据一致:
- 缓存中有数据:缓存的数据值 = 数据库中的值
- 缓存中没有数据:数据库中的值是最新值
数据不一致:
- 缓存中有数据,但缓存的数据值 != 数据库中的值
- 缓存或数据库中存在旧的数据
数据不一致的原因
缓存(Redis)和数据库(MySQL)是两套系统,任何一方的数据改动,都需要另一方的协同来保证。但这种协同可能存在一定的问题:
- 数据库更新出错:在更新数据库时发生错误,导致缓存中的数据与数据库中的数据不一致
- 缓存刷新机制错误:一些缓存系统可能存在刷新机制的问题,导致缓存中的数据没有及时更新,从而与数据库出现不一致的情况
- 并发请求:当有多个请求同时进行操作时,由于缓存、数据库操作的顺序和时机不同,可能造成不一致的情况
- 数据一致性策略不当:在实现缓存和数据库的数据一致性策略时,如果选择不当的数据一致性策略,可能会导致数据不一致的情况
常用的缓存策略
以下缓存策略并不是非此即彼的,而是可能作用于不同方面的考量
Cache-Aside(缓存旁路)
最常见的缓存策略之一,主要思想是缓存只作为一个辅助数据源
- 读取数据:先从缓存中读取,如果有,则直接返回;如果没有,则从数据库中读取,并将读取到的数据存储到缓存中,以备将来的请求
- 写入数据:先更新数据库,然后删除缓存中的旧数据
是一种简单、易于实现的策略,非常适合读多写少的场景
Read-Through(读穿透)
与 Cache-Aside 读取数据流程非常相似,但有一个关键区别:==读取缓存未命中时,从数据库获取数据的逻辑是由缓存层自动处理的,而不是由应用程序显式处理
工作流程:
- 应用程序向缓存发起数据请求
- 如果缓存命中,则直接从缓存中返回数据
- 如果缓存未命中,缓存会自动从数据库中读取数据,然后将数据存储到缓存中,并将结果返回给应用程序
优点:简化代码逻辑(单一职责),应用程序只需要与缓存进行交互,无需关心数据具体来源
Write-Through(写穿透)
- 写入数据:数据会同步写入缓存和数据库
- 优点:将更新缓存提前到写入时,而不是在读取时才更新缓存
- 缺点:缓存利用率低,缓存可能留存很多不经常访问的数据,而不是热点数据
适合读多写少,并要求读取迅速的场景
Write-Behind(写后置)
- 写入数据:数据先写入缓存,然后异步地将数据写入数据库
- 优点:可以提高写操作的性能,因为不需要等待数据库操作完成
- 缺点:由于写入数据库是异步的,缓存和数据库之间可能会出现短时间的不一致性。如果缓存崩溃或系统异常,未写入数据库的数据可能会丢失
适合需要更高写入性能的场景,但要权衡一致性问题
Write-Back(回写)
类似于 Write-Behind,但更加激进。它先将数据写入缓存,而不立即同步到数据库,缓存会在一定条件下(如:缓存满了或设定的时间间隔到达)将数据批量写入数据库
- 优点:极大地提高了写入性能,尤其是高频写入的场景。因为可以将多次写入合并为一次批量操作
- 缺点:如果缓存崩溃或系统发生故障,缓存中的数据可能尚未写入数据库,导致数据丢失
适合高频写入的场景,十分强调写性能,但有数据丢失的风险
Update-In-Place(原地更新)
主要思想是直接在缓存中更新数据,而不是先删除旧数据再插入新数据
- 优点:避免了删除和重新插入缓存数据的操作,减少了缓存的冲突,提高缓存命中率
- 缺点:缓存数据可能会增长到较大的规模,导致缓存占用较多内存。另外,缓存利用率低,缓存可能留存很多不经常访问的数据,而不是热点数据
适合频繁更新的数据,但需要注意缓存内存的管理
Partitioning(分区)
并不是缓存的执行策略,而是对缓存进行分区管理的一种方式。不同的数据分区可以采用不同的缓存策略,以适应不同的访问模式和负载
- 分区:将缓存按数据类别或某些规则分成多个部分,每个部分可以独立设置缓存策略。如:热数据使用 Write-Through 策略、冷数据使用 Cache-Aside 策略
- 优点:提供了灵活性,可以根据不同数据的访问模式优化缓存的性能
- 缺点:实现复杂度较高,需要在缓存管理中引入更多逻辑,且分区策略的选择需要根据业务场景精心设计
适用于大规模系统,通过对缓存进行分区来优化性能,但增加了系统复杂度
双写一致性问题
需要抉择:
- 先更新缓存还是数据库?
- 选择更新缓存还是删除缓存?
需要考虑到:
- 操作原子性问题,其中一个操作失败会有什么问题
- 数据一致性问题,高并发下会否有数据不一致情况
先更新缓存还是数据库?
异常情况:第一步成功、第二步失败
先更新缓存,后更新数据库:
- 缓存更新成功,但数据库更新失败,那么此时缓存中是新值,数据库中是「旧值」
- 虽然此时读请求可以命中缓存,拿到正确的值,但是,一旦缓存「失效」,就会从数据库中读取到「旧值」,重建缓存也是这个旧值
- 这时用户会发现自己之前修改的数据又「变回去」了,对业务造成影响
先更新数据库,后更新缓存:
- 数据库更新成功,但缓存更新失败,那么此时数据库中是新值,缓存中是「旧值」
- 之后的读请求读到的都是旧数据,只有当缓存「失效」后,才能从数据库中得到正确的值
- 这时用户会发现,自己刚刚修改了数据,但却看不到变更,一段时间后,数据才变过来,对业务也会有影响
可以看到,异常情况下,都可能导致问题
并发情况:两个请求并发写
先更新缓存,后更新数据库:
先更新数据库,后更新缓存:
可以看到,并发情况下,都可能导致数据不一致
更新缓存还是删除缓存?
删除缓存相较于更新缓存的优势:
- 删除一个数据,相比更新一个数据更加轻量级,出问题的概率更小
- 在实际业务中,缓存的数据可能不是直接来自数据库表,也许来自多张数据表的聚合
- 缓存利用率:不是所有的缓存数据都是频繁访问的,更新后的缓存可能会长时间不被访问
异常情况:第一步成功、第二步失败
先删除缓存,后更新数据库:
- 删除缓存成功,更新数据库失败,数据库还是「旧值」
- 下次读缓存发现不存在,则从数据库中读取,并重建缓存
- 此时数据库和缓存保持一致,但都是「旧值」
先更新数据库,后删除缓存:
- 更新数据库成功,删除缓存失败
- 数据库是最新值,缓存中是「旧值」,发生不一致
并发情况:读 + 写
先删除缓存,后更新数据库:
先更新数据库,后删除缓存:
理论上分析,先更新数据库,再删除缓存也会出现数据不一致性的情况,但实际上,出现的概率并不高
必须满足 3 个条件才会出现:
- 缓存刚好已失效
- 读 + 写请求并发
- 更新数据库 + 删除缓存的时间,要比读数据库和写回缓存的间隔时间长
其中条件 3 发生的几率是很低的,因为一般写数据库要比读数据库的时间更长
因此:先更新数据库,后删除缓存是较优策略
保证两步都执行成功
重试机制
执行失败后,可以发起重试,尽可能地去做「补偿」
但并不是同步重试:
- 立即重试很大概率还会失败
- 重试次数设置多少才合理?
- 重试会一直占用这个线程资源,无法服务其它客户端请求
而是异步重试,把重试请求写到「消息队列」中,然后由专门的消费者来重试:
- 写队列队列失败:操作缓存和写消息队列,同时失败的概率是很小的
- 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启也不担心)
- 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者
事务保证
加入事务,无论哪步出错都进行回滚,这需要接入 2PC、Paxos 等分布式一致性协议,可以保证强一致,但会在一定程度上影响系统的复杂度和延迟
订阅数据库变更日志
可以把同步缓存的操作交给独立的能力中,从应用层解耦,业务应用只需修改数据库即可
- 更新数据库数据
- 数据库更新完成之后会把变更记录在 binlog 中
- 使用 canal 订阅 binlog 日志获取待删除的 key(或者更完整的数据对象)
- 消费者(缓存删除服务)获取到 canal 数据,获得待删除的 key,并删除缓存
阿里巴巴开源的 Canal 中间件模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用
其实很多数据库都逐渐开始提供「订阅变更日志」的功能了,相信不远的将来,就不用通过中间件来拉取日志,这样可以进一步简化流程
解决并发问题
加锁
在更新缓存前先加个分布式锁,保证同一时间只运行一个请求更新缓存,就会不会产生并发问题了,当然引入了锁后,对于写入的性能就会带来影响
版本控制
给缓存中的数据加上版本号或时间戳,在更新时对比版本号或时间戳,确保不会被旧数据覆盖
设置过期时间
在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的
主从库延迟和延迟双删
第一个问题:先删除缓存,再更新数据库在并发读写下的不一致问题:
- 线程 A 要更新 X = 2(原值 X = 1)
- 线程 A 先删除缓存
- 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
- 线程 A 将新值写入数据库(X = 2)
- 线程 B 将「旧值」写入缓存(X = 1)
第二个问题:先更新数据库,后删除缓存在「读写分离 + 主从复制延迟」情况下,也可能产生不一致问题:
- 线程 A 更新主库 X = 2(原值 X = 1)
- 线程 A 删除缓存
- 线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 X = 1)
- 从库「同步」完成(主从库 X = 2)
- 线程 B 将「旧值」写入缓存(X = 1)
这 2 个问题的核心在于:缓存都被回种了「旧值」
解决方案是缓存延迟双删策略:把被回种了「旧值」的缓存清掉,下次就可以从数据库读取到最新值,写入缓存
- 解决第一个问题:线程 A 删除缓存;更新完数据库;再「延迟删除」一次缓存
- 解决第二个问题:线程 A 删除缓存后;再「延迟删除」一次缓存
如何实现延迟删除?
生成一条「延时消息」,写到消息队列中,到达时间后消费者删除缓存
「延迟删除」缓存,延迟时间要设置要多久?
- 延迟时间要大于「主从复制」的延迟时间
- 延迟时间要大于线程 B 读取数据库 + 写入缓存的时间
这个时间在分布式和高并发场景下,其实是很难评估的
大多数时候都是凭借经验大致估算这个延迟时间,只能尽可能地降低不一致的概率,极端情况下,还是有可能发生不一致
所以实际使用中,建议还是采用「先更新数据库,再删除缓存」的方案,同时,要尽可能地保证「主从复制」不要有太大延迟,降低出问题的概率
总结
缓存和数据库一致性问题,可选的方案有:更新数据库 + 更新缓存、更新数据库 + 删除缓存
- 更新数据库 + 更新缓存:在「并发」场景下无法保证缓存和数据一致性,解决方案是加「分布锁」,但这种方案存在「缓存资源浪费」和「机器性能浪费」的情况
- 先删除缓存,再更新数据库:在「并发」场景下依旧有不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估
- 先更新数据库,再删除缓存:为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据最终一致
- 先更新数据库,再删除缓存:在「读写分离 + 主从库延迟」下也会导致缓存和数据库不一致,缓解此问题的方案是「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率
性能和一致性不能同时满足,为了性能考虑,通常会采用「最终一致性」的方案
不同的业务场景对一致性的要求不同,需要根据系统的需求权衡一致性、性能和复杂性,选择合适的策略