缓存一致性就是让缓存中的数据和数据库(持久化)中的数据保持一致。 一致性可以分为不同的级别:
- 强一致性:它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
不过说实在的,缓存的数据一致性不应当保持强一致性,若追求强一致性就不要使用缓存层
- 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
- 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。
旁路缓存
旁路缓存模式是为了尽可能地解决缓存与数据库的数据不一致问题,也是最常用的缓存模式之一。旁路缓存分为读模式和写模式。
读模式
读取数据的时候先从缓存中取,如有则直接返回,如无则在数据库中获取数据、放入缓存后返回
写模式
更新数据的时候先更新数据库,再删除缓存
为什么是删除缓存而不是更新缓存呢?
在并发情况下,可能会将旧值更新到缓存中(两个更新同一数据的线程,首先更新了数据库的线程却比后更新数据库的线程后更新缓存,导致更新到缓存中的数据是旧的数据)
不过,删除缓存的方式仍有问题:如果删除缓存的操作失败了怎么办?
这个问题的解决方案大致有两种:
- 引入重试机制
- 方法1:可以给redis客户端封装重试方法,如果删除失败则进行重试操作,此方法最简单,但是可能造成接口响应缓慢
- 方法2:将删除失败的key发送到消息队列中,然后消费消息队列的消息进行重试删除缓存的操作(此方法是弱一致性,并且对业务代码有入侵,当然也可以通过封装redis客户端的方式进行解决)
- 同步Binlog异步删除缓存 订阅binlog日志,根据更新log删除缓存,并且通过ACK机制确认处理这条更新log,保证数据缓存一致性
这种方式要求缓存的key满足规定的格式,否则应用层/中间层无法进行名称映射
问题解决了吗?并没有完全解决。如果重试(无论是同步的还是异步的)始终失败怎么办?如果是要求强一致性的场景,不仅不能使用异步方法,出现缓存不一致的影响会更大。
那么还要怎么做呢?应对的连招是:重试、报警、补偿、手动干预
- 重试 & 报警
持续重试更新或删除缓存操作直到成功。可以设置重试次数和延时策略以避免无限重试。如果在一定次数内仍未成功,就需要做报警了。
- 补偿事务
如果缓存操作失败,可以执行一个补偿事务来"撤销"原事务所做的更改。这意味着需要设计可以逆向操作的逻辑,将数据恢复到更新前的状态。这种方法可能在逻辑上比较复杂,且不总是可行。
- 使用后台任务修正数据
如果实时性要求不是极端严格,可以将失败的缓存操作标记起来,并通过后台任务定期检查和修正这些不一致的数据。这个后台任务可以尝试重新同步数据库和缓存中的数据,直到一致性被恢复。
- 手动干预
在一些情况下,自动化的补偿或修正机制可能无法完全解决问题,或者风险较高,可能需要人工介入来评估情况并手动修复数据一致性问题。
- 自动降级
以上的措施是尽量去操作缓存和修复问题,如果缓存持续出现问题,此时就需要自动降级了。 在无法保证数据一致性时,自动降级服务,比如临时关闭写入缓存的功能,直接从数据库读取数据。当然,直接访问数据库会导致数据库负载很高,可以实施多级缓存策略,比如本地缓存和分布式缓存的组合使用。在这种情况下,如果分布式缓存更新失败,系统仍然可以依赖本地缓存来提供一定程度的数据一致性和访问速度。
那么在解决了删除缓存失败的问题之后,旁路缓存是否就能保证缓存与数据库的数据保持一致了呢?答案是仍旧不能,在写模式下,当一个线程更新数据库并删除缓存后,如果此时另一个线程读取同一数据,在缓存中未命中,它会从数据库中读取数据并将这个可能已经是旧数据的值设置回缓存中。 仔细思考下会发现,无论是将设置缓存值的过程放在读策略和写策略中,都无法保证设置的值是数据库中的最新值。因为在不加锁的情况下,不同线程从数据库中查值和设置缓存值的先后顺序无法保证。因此必须找到一个能保证顺序的"操作命令队列"。那么这个保证顺序的操作命令队列可以是什么呢?没错,可以是Binlog。总体思路是
- 订阅Binlog :使用适合的库(如
go-mysql
或canal
)订阅MySQL的Binlog。 - 解析Binlog事件:实现事件处理器来解析binlog事件。对于CRUD操作,分别处理INSERT、UPDATE和DELETE事件。
- 更新缓存 :根据binlog事件内容更新缓存。例如,对于DELETE操作,删除对应的缓存项;对于INSERT和UPDATE操作,更新或设置缓存项。 在业界,使用数据库的Binlog来同步更新缓存的这种方法通常被称为Change Data Capture (CDC)
旁路缓存结合CDC
读写模式都有修改
读模式
原先的流程是"读取数据的时候先从缓存中取,如有则直接返回,如无则在数据库中获取数据、放入缓存后返回。"现在要稍作修改,将放入缓存改为SETNX(不存在键值则设置,否则放弃)
写模式
写模式的变更则较大
- 直接写入数据库:应用不再直接操作缓存,所有的写操作(插入、更新、删除)直接对数据库进行。
- 通过CDC同步缓存 :数据库的变更通过CDC机制捕获。CDC工具监听数据库变更事件(如Binlog),然后根据这些变更事件来同步更新缓存。
- 插入操作:新记录被添加到数据库后,CDC捕获这一变更事件并将对应的数据添加到缓存中。
- 更新操作:记录在数据库中被更新后,CDC捕获这一变更事件并同步更新(或创建)缓存中的数据。
- 删除操作:记录从数据库中删除后,CDC捕获这一变更事件并从缓存中移除对应的数据。 核心思路是通过CDC订阅顺序的日志,保证将新值写入缓存。读模式起到辅助作用,它设置缓存值的优先级低于写模式。
再看下之前的问题,读模式读到旧值,然后CDC捕捉到了更新的日志,然后CDC创建了对应的缓存值(之前的缓存值不存在,不然读模式读到缓存就直接返回了),此时读模式去设置缓存值的时候发现,值已存在,所以不会将旧值更新到缓存中。
也许有人会有疑问,为什么传统的旁路缓存,存在如此明显的问题(读模式读到旧值然后将旧值设置到缓存中造成缓存与数据库不一致),却依然得到了广泛使用呢?因为大部分场景可以接受短暂的数据不一致,而旁路缓存又有实现简单、灵活的优势,因此让其收到广大开发者的追捧。做工程,考虑适用性、资源和成本,不外如是~