先更新数据库,还是先更新缓存?
「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据。
先更新数据库,再更新缓存
用户肯定先访问缓存,如果更新数据库的顺序和更新缓存的顺序不同,那就会导致命中的缓存不是最新的数据。
如下图所示,数据库中的数据是 2,但是缓存中的数据却是 1。
先更新缓存,再更新数据库
如果更新缓存的顺序和更新数据库的顺序不同,那就会导致数据库中存储的不是最新的数据,这比上面那种情况还要糟糕。
如下图所示,缓冲中的数据是 2,这是没有问题,但是数据库中存储的数据居然是 1,这是严重的错误。
数据库作为实际存储数据的地方,绝不能有错,因为它是数据的本源。
先更新数据库,还是先删除缓存?
不管是【先更新数据库,再更新缓存】,还是【先更新缓存,再更新数据库】,都不能保证,我们还是另寻它路吧。
先更新数据库,再把缓存删除。等到客户端访问的时候,发现没有命中缓存,就从数据库中读取数据给到客户端,再把该数据会写到缓存中,保证下一次可以命中。就可以避免前面讨论的并发问题。
先删除缓存,再更新数据库
如果先删除缓存,但还没有来得及更新数据库,此时客户端访问就会无法命中缓存,就会读取数据库的数据(旧数据),返回给客户端之后,再把这个数据回写到缓存中,下次访问还是命中,依旧不是最新的数据。此时,更新数据库的命令才执行完成,可谓姗姗来迟。
如下图所示,按理应该读取最新数据 21,但是由于此期间客户端请求数据,导致返回旧数据,并且还更新到缓存中,下次命中还是属于旧数据。
先更新数据库,再删除缓存
假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。
最终,该用户年龄在缓存中是20(旧值),在数据库中是21(新值),缓存和数据库数据不一致。
从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。
因此,我们认为【先更新数据库,再删除缓存】的方案,是可以保证数据一致性的。我想我们应该先保证源头的正确性,才是最重要的。
如何保证两个操作都能执行成功?
我们已经确定【先更新数据库,再删除缓存】作为保证数据一致性的方案,这是两个独立的操作,必须确保两个操作都能执行成功。
下面介绍两种方案,归根结底就是把操作的数据行为另存一份,直到保证两个操作成功再移除,否则多次重试。
(一)消息队列重试机制
引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据:
- 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要先业务层发送报错信息了。
- 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则继续重试。
这个方案的缺点是,对代码入侵性比较强,需要改造原本业务的代码。
(二)订阅 MySQL binlog,再操作缓存
【先更新数据库,再删除缓存】的策略第一步是更新数据库,那么更新数据库成功,就会产生对应的日志,记录在 binlog 里。
于是,我们可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除。
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
Canal 方案不会对代码造成入侵,因为它是直接订阅 binlog 日志,和业务代码没有耦合关系,因此我们可以通过 Canal + 消息队列的方案来保证数据缓存的一致性。
具体的做法是:将 binlog 日志采集发送到MQ队列里面,然后编写一个简单的缓存删除消息者订阅 binlog 根据更新 log 删除缓存,并且通过ACK机制确认处理这条更新log,保证数据缓存一致性。
日志这里有一个很关键的点,必须是删除缓存成功,再回 ACK 机制给消息队列,否则可能会造成消息丢失的问题。比如消费服务从消息队列拿到事件之后,直接回了 ACK,然后再执行删除缓存操作的话,如果删除缓存的操作还是失败了,那么因为提前给消息队列回 ACK 了,就没办重试了。
⭐️内容取自《小林Coding》,仅从中取出个人以为需要纪录的内容。不追求内容的完整性,却也不会丢失所记内容的逻辑性。如果需要了解细致,建议访问官方网站。