前不久,有位朋友去腾讯面试,他说被问到 Redis 与 MySQL 的一致性如何保证? 本文将跟大家一起来探讨如何回答这个问题。
为什么要使用 Redis?
首先为了提升服务器的性能,一般都是给服务器加上 redis,让其作为数据库的缓存。这样,在客户端请求数据时,如果能在缓存中命中数据,那就查询缓存,不用再去查询数据库,从而减轻数据库的压力,提高服务器的性能。
数据更新时,先更新数据库,还是先更新缓存?
由于引入了缓存,那么在数据更新时,不仅要更新数据库,而且要更新缓存,这两个更新操作存在前后的问题:
先更新数据库,再更新缓存;
先更新缓存,再更新数据库;
下面让我们来详细介绍一下这两种更新方式!
先更新数据库,再更新缓存
举个例子,比如「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:
简单分析一下,A 请求先将数据库的数据更新为 1,然后在更新缓存前,请求 B 将数据库的数据更新为 2,紧接着也把缓存更新为 2,然后 A 请求更新缓存为 1。
此时,数据库中的数据是 2,而缓存中的数据却是 1,出现了缓存和数据库中的数据不一致的现象。
数据库的数据是客户第二次更新操作的数据,而缓存确还是第一次更新操作的数据,也就是出现了数据库和缓存的数据不一致的问题,造成缓存和数据库的数据不一致的现象。是因为并发问题!
先更新缓存,再更新数据库
依然还是存在并发的问题,分析思路也是一样。
假设「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:
同样的分析思路,A 请求先将缓存的数据更新为 1,然后在更新数据库前,B 请求来了, 将缓存的数据更新为 2,紧接着把数据库更新为 2,然后 A 请求将数据库的数据更新为 1。
此时,数据库中的数据是 1,而缓存中的数据却是 2,出现了缓存和数据库中的数据不一致的现象。
无论是「先更新数据库,再更新缓存」,还是「先更新缓存,再更新数据库」,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象。
使用 Cache Aside 策略
无论是先更新缓存,还是先更新数据库,都会存在并发问题,导致缓存和数据库的数据不一致。
那我们采用 Cache Aside 策略,决定在更新数据时,不更新缓存,而是删除缓存中的数据。然后,到读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。
Cache Aside 策略又可以细分为「读策略」和「写策略」
新的问题 —— 删除缓存 和 更新数据库
那么现在又有个问题,那就是删除缓存和更新数据库这两个操作,谁在前面呢?
是先更新数据库,再删除缓存?还是先删除缓存,再更新数据库?下面让我们具体分析一下。
先删除缓存,再更新数据库
假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库的数据不一致。
可以看到,先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题。
更新数据库,再删除缓存
假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。
从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。
因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。
而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。
所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
删除缓存 和 更新数据库 都要执行成功
但是又有了新的问题,上述成功的前提,都是建立于这两个操作都能同时执行成功,所以问题就是,在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值,而数据库是最新值。
如何保证两个操作都能执行成功?
在这里有两种方法:
> 1. 消息队列重试机制
消息队列来重试缓存的删除,优点是保证缓存一致性的问题,缺点会对业务代码入侵
> 2. 订阅 MySQL binlog,再操作缓存
订阅 MySQL binlog + 消息队列 + 重试缓存的删除,优点是规避了代码入侵问题,也很好的保证缓存一致性的问题,缺点就是引入的组件比较多,对团队的运维能力比较有高要求。
这两种方法有一个共同的特点,都是采用异步操作缓存。
消息队列重试机制
我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。
如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。
下面是重试机制的过程:
这个方案缺点就是对代码入侵性比较强,因为需要改造原本业务的代码。
订阅 MySQL binlog,再操作缓存
「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。
于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里的开源的 Canal 中间件就是基于这个实现的。
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
下图是 Canal 的工作原理:
前面我们说到直接用消息队列重试机制方案的话,会对代码造成入侵,那么 Canal 方案就能很好的规避这个问题,因为它是直接订阅 binlog 日志的,和业务代码没有藕合关系,因此我们可以通过 Canal+ 消息队列的方案来保证数据缓存的一致性。
具体的做法是:将 binlog 日志采集发送到 MQ 队列里面,然后编写一个简单的缓存删除消息者订阅 binlog 日志,根据更新 log 删除缓存,并且通过 ACK 机制确认处理这条更新 log,保证数据缓存一致性。
这两种方法有一个共同的特点,都是采用异步操作缓存。
就业陪跑训练营学员投稿
欢迎关注 ❤
我们搞了一个免费的面试真题共享群,互通有无,一起刷题进步。
没准能让你能刷到自己意向公司的最新面试题呢。
感兴趣的朋友们可以加我微信:wangzhongyang1993,备注:思否面试群。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。