Redis 缓存和 MySQL 数据一致性方案详解
先更新数据库,然后删除缓存,删除缓存时,把 key 放到消息队列,直到删除成功,或者订阅 binlog 去删除缓存。这种方案也不完美,但是出问题概率很小,简单方便。
对于用户金额这样的数据,当下单的时候直接从数据库读取后校验,一切以数据库为准,此方案数据不一致的概率极低,既然没有完美的方案,这样就挺好了。
正常下单更新金额这种场景最好全部走主数据库。主键查询很快的,而且有主从模式。订阅 binlog 用 Canal 也不好,实际中 canal 不稳定,增加了系统复杂性。秒杀场景下全部走 redis,然后异步(定时脚本)更新用户金额和订单状态
# 需求
在高并发的业务场景下,数据库大多数情况都是用户并发访问最薄弱的环节。所以,就需要使用 redis 做一个缓冲操作,让请求先访问到 redis,而不是直接访问 MySQL 等数据库。
这个业务场景,主要是解决读数据从 Redis 缓存,一般都是按照下图的流程来进行业务操作。
从缓存中读取数据,如果数据存在,则返回;如果不存在,则去数据库中查询,查询到结果返回同时更新到缓存中。
读取缓存步骤一般没有什么问题,但是一旦涉及到数据更新:数据库和缓存更新,就容易出现缓存(Redis)和数据库(MySQL)间的数据一致性问题。
不管是先写 MySQL 数据库,再删除 Redis 缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。举一个例子:
如果删除了缓存 Redis,还没有来得及写库 MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。
如果先写了库,在删除缓存前,写库的线程宕机了,没有删除掉缓存,则也会出现数据不一致情况。
因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题。
如何来解决?这里给出两个解决方案,先易后难,结合业务和技术代价选择使用。
# 缓存和数据库一致性解决方案
# 是删除缓存还是更新缓存
当数据库数据发生变化的时候,Redis 的数据也需要进行相应的操作,那么这个「操作」到底是用「更新」还是用「删除」呢?
「更新」的话调用 Redis 的 set 方法,新值替换旧值,很多时候,在复杂点的缓存场景,缓存不单是数据库中直接取出来的值,而是要经过运算之后才去更新缓存,这个时候可能更新缓存的消耗很大;「删除」直接删除原来的缓存,下次查询的时候重新读取数据库,然后再更新 Redis。这是一种惰性计算的思想。
结论:推荐直接使用「删除」操作。
笔记
淘汰缓存
优点:操作简单,不用关心更新操作,直接将缓存中的旧值淘汰
缺点:淘汰缓存后,下一次查询无法命中缓存,需要重新读取数据库,业务复杂或者数据量大时,响应慢
更新缓存
优点:命中率高,简单
key-value
更新缓存和淘汰缓存效率差不多缺点:更新缓存消耗较大。当更新操作简单,如只是将某个值直接修改时,更新缓存和淘汰缓存的消耗差不多;但当更新操作逻辑较复杂时,需要涉及到其他数据或者计算、比较才能得到最终结果,此时更新缓存的消耗要大于直接淘汰缓存。
所以实践中我一般是:简单key-value
可以依据个人习惯采用更新缓存或淘汰缓存都可以,复杂的key-value
一般采用淘汰缓存机制。
想要保证缓存与数据库的双写一致,一共有 4 种方式,即 4 种同步策略:
- 先更新缓存,再更新数据库;
- 先更新数据库,再更新缓存;
- 先删除缓存,再更新数据库;
- 先更新数据库,再删除缓存。
# 先更新缓存,再更新数据库
如果缓存更新成功了,但数据库更新失败,那么此时缓存中是新值,数据库中的是旧值。虽然此时读请求可以命中缓存,拿到正确的值,但是,一旦缓存失效,就会从数据库中读取到旧值,重建缓存也是这个旧值。这时用户会发现自己之前修改的数据又变回去了,会对业务造成影响。
# 先更新数据库,再更新缓存
如果数据库更新成功了,但缓存更新失败,那么此时数据库中是新值,缓存中是旧值。而之后的读请求读到的都是旧数据,只有当缓存失效后,才能从数据库中得到正确的值。这时用户会发现,自己刚刚修改了数据,但却看不到变更,一段时间过后,数据才变更过来,对业务也会有影响。
可以看到,无论先更新谁,但凡后者发生异常,都会对业务造成影响。
如果我们把这两步做成一个事务,会产生什么问题?
- 采用分布式事务实际上会牺牲系统的可用性,也就是 CAP 中 A。
- 缓存和数据库是两个独立的存储介质,我们不应该将两者的写操作绑定在一个事务里。
假设我们采用 “先更新数据库,再更新缓存” 的方案,并且两步都可以成功执行的前提下,如果存在并发,情况会是怎样的呢?假设有线程 A 和线程 B 两个线程,需要更新同一条数据,那么可能会发生这样的场景:
1. 线程 A 更新数据库(value = 1)
2. 线程 B 更新数据库(value = 2)
3. 线程 B 更新缓存(value = 2)
4. 线程 A 更新缓存(value = 1)
2
3
4
最终 value 的值在数据库中是 2,但在缓存中是 1。也就是说,虽然 A 先于 B 发生,但操作数据库加缓存的整个过程,B 却比 A 先完成。
除此之外,我们从缓存利用率的角度来评估这个方案,也是不太推荐的。这是因为每次数据发生变更,都无脑更新缓存,但是缓存中的数据不一定会被马上读取,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。
由此可见,这种同时更新数据库和缓存的方案,不仅缓存利用率不高,还会造成机器性能的浪费,应该在读缓存的时候发现数据不存在,然后读取数据库并将数据写入缓存。
明确这个问题之后,摆在我们面前的就只有两个选择了:
- 先更新数据库,再删除缓存
- 先删除缓存,再更新数据库
# 先删除缓存,后更新数据库
如果有两个线程要并发读写数据,可能会发生以下场景:
1. 线程 A 要更新数据为 value = 2(之前 value = 1),但是更新之前先删除缓存
2. 线程 B 读缓存,发现不存在,因为 A 已经删掉了,所以会从数据库中读取到旧值(value = 1)
3. 线程 A 将新值写入数据库(value = 2)
4. 线程 B 在读缓存的时候发现 Cache Miss,于是将从数据库中读取的值写入缓存(value = 1)
2
3
4
最终 value 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。可见,先删除缓存,后更新数据库,当发生读写并发时,还是存在数据不一致的情况。
这时,Redis 中存储的旧数据,数据库的值是新数据,导致数据不一致。这时我们可以采用延时双删的策略,即更新数据库数据之后,再删除一次缓存。(下文我们会进一步讨论该方案的实现)
# 先更新数据库,后删除缓存
依旧是两个线程并发「读写」数据:
- 线程 A 读缓存,发现不存在
- 线程 A 读取数据库,得到值(value = 1)
- 线程 B 更新数据库(value = 2)
- 线程 B 删除缓存
- 线程 A 将旧值写入缓存(value = 1)
最终 value 的值在缓存中是 1(旧值),在数据库中是 2(新值),也发生不一致。咦,不是说可以解决并发带来的不一致吗?为啥两种方式都会导致数据不一致呢?
我们不妨再仔细看一下 "先更新数据库,后删除缓存" 这种方式,它所造成的数据不一致真的有可能发生吗?首先它如果想发生,必须满足 3 个条件:
- 缓存刚好已失效
- 读请求 + 写请求并发
- 更新数据库 + 删除缓存的时间(步骤 3、4),要比读数据库 + 写缓存时间短(步骤 2、5)
首先条件 1 和 2 的概率虽然低,但也有可能发生,但条件 3 发生的概率可以说是微乎其微的。因为写数据库一般会先加锁,所以写数据库通常是要比读数据库的时间更长的。所以 “先更新数据库,后删除缓存” 在并发层面是可以保证数据一致性的,那么接下来的问题就是当两个操作中的第二个(显然是删除缓存)执行失败时,该怎么办?
无论是更新缓存还是删除缓存,只要第二步发生失败,那么就会导致数据库和缓存不一致。只不过更新缓存这种做法即使在两步都成功的前提下也会出现数据不一致,而删除缓存不会,所以我们最终决定采用“更新数据库+删除缓存”这一策略。所以剩下的问题就是如何保证第二步的成功,这是问题的关键。
程序在执行过程中发生异常,最简单的解决办法是什么?没错,就是重试。这里我们也是同样的做法,无论是先操作缓存,还是先操作数据库,但凡后者执行失败了,我们就可以发起重试,尽可能地去做补偿。但这仍然会带来几个问题:
- 立即重试很大概率还会失败
- 重试次数设置多少才合理
- 重试会一直占用这个线程资源,无法服务其它客户端请求
虽然我们想通过重试的方式解决问题,但采用同步重试的方案依旧不严谨,因此最正确的做法是采用异步重试。
# 解决方案
# 采用延时双删策略
该方案的不合理之处,参见此处 (opens new window) 评论区的讨论
在写库前后都进行 redis.del(key)操作,并且设定合理的超时时间。
- 伪代码
public void write(String key,Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(500);
redis.delKey(key);
}
2
3
4
5
6
具体步骤
- 先删除缓存
- 再写数据库
- 休眠一定时间
- 再次删除缓存
那么,这个休眠时间怎么确定的,具体该休眠多久呢?
需要评估自己的项目的读数据业务逻辑的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
当然这种策略还要考虑 redis 和数据库主从同步的耗时。最后的写数据的休眠时间,则在读数据业务逻辑的耗时基础上加几百 ms 即可。比如:休眠 1 秒。
- 设置缓存过期时间
从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。所有的写操作以数据库为准,只要到达缓存过期时间,则后面的读请求自然会从数据库中读取新值然后回填进缓存。
# 弊端
不能保证比它之前的 redis 更新不会因为网络原因比它延迟更长时间执行,而这几乎是致命的! 结合双删策略+缓存超时设置,这样最差的情况就是在超时时间内数据存在不一致,而且又增加了写请求的耗时。
# 异步更新缓存(基于订阅 binlog 的同步机制)
# 整体思路
MySQL binlog 增量订阅消费+消息队列+增量数据更新到 redis
读 Redis:热数据基本都在 Redis
写 MySQL:增删改都是操作 MySQL
更新 Redis 数据:MySQL 的数据操作 binlog 来更新到 Redis
# Redis 更新
数据操作主要分为两大块:
- 一个是全量(将全部数据一次写入到 redis)
- 一个是增量(实时更新) 这里说的是增量,指的是 mysql 的 update、insert、delete 变更数据之后,读取 binlog 并分析,利用消息队列,推送更新各台的 redis 缓存数据。
这样一旦 MySQL 中产生了新的写入、更新、删除等操作,就可以把 binlog 相关的消息推送至 Redis,Redis 再根据 binlog 中的记录,对 Redis 进行更新。
其实这种机制,很类似 MySQL 的主从备份机制,因为 MySQL 的主备也是通过 binlog 来实现的数据一致性。
这里可以结合使用 canal(阿里的一款开源框架),通过该框架可以对 MySQL 的 binlog 进行订阅,而 canal 正是模仿了 mysql 的 slave 数据库的备份请求,使得 Redis 的数据更新达到了相同的效果。
当然,这里的消息推送工具你也可以采用别的第三方:kafka、rabbitMQ 等来实现推送更新 Redis。
# 基于消息队列
其实就是把重试请求写到消息队列中,然后由专门的消费者来重试,直到成功。或者更直接的做法,为了避免第二步执行失败,我们可以把删除缓存这一步,直接放到消息队列中,由消费者来删除缓存。到这里你可能会问,写消息队列也有可能会失败啊?而且,引入消息队列,这又增加了更多的维护成本,这样做值得吗?这个问题很好,但我们思考这样一个问题:如果在执行失败的线程中一直重试,还没等执行成功,此时如果项目重启了,那这次重试请求也就丢失了,那这条数据就一直不一致了。
所以,这里我们必须把重试或第二步操作放到另一个服务中,这个服务用消息队列最为合适,因为消息队列的特性,正好符合我们的需求:
- 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)
- 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)
至于写队列失败和消息队列的维护成本问题:
- 写队列失败:操作缓存和写消息队列,同时失败的概率其实是很小的
- 维护成本:我们项目中一般都会用到消息队列,维护成本并没有新增很多
所以,引入消息队列来解决这个问题,是比较合适的。此时架构模型就变成了这样:
至此,我们可以得出结论,想要保证数据库和缓存一致性,推荐采用“先更新数据库,再删除缓存” 方案,并配合消息队列或订阅变更日志来实现。所以对于业务调用方而言,如果数据库更新成功,那么直接返回成功即可,删除缓存这一步异步实现;如果数据库更新失败,那么直接返回失败,删除缓存也无需再进行了。
以上就是 Redis 和 MySQL 数据一致性详解,相关的 MySQL 数据库主从同步一致性可以参考:MySQL 数据库主从同步的 3 种一致性方案实现,及优劣比较 (opens new window)
# 主从库延迟和延迟删除
目前还没有万事大吉,这里还有一个问题,我们说 "更新数据库 + 删除缓存" 可以解决数据不一致,但如果遇到了 "读写分离 + 主从复制延迟",那么还是会导致数据不一致的。举个栗子:
- 线程 A 更新主库 value = 2(旧值 value = 1)
- 线程 A 删除缓存
- 线程 B 查询缓存,没有命中,于是查询从库得到旧值(从库 value = 1)
- 从库同步完成(主从库 value = 2)
- 线程 B 将旧值写入缓存(value = 1)
最终 value 的值在缓存中是 1(旧值),在主从库中是 2(新值),也发生不一致。所以我们在删除缓存的时候不能立即删,而是需要延迟删。
具体做法就是:线程 A 可以生成一条延时消息,写到消息队列中,消费者延时删除缓存。但问题来了,这个延迟删除缓存,延迟时间到底设置要多久呢?
- 延迟时间要大于主从复制的延迟时间
- 延迟时间要大于线程 B 读取数据库 + 写入缓存的时间
而一旦涉及到时间,就意味着不精确,因为谁也说不清这个时间到底应该设置多长,尤其是在分布式和高并发场景下就变得更加难评估。很多时候,我们都是凭借经验大致估算这个延迟时间,例如延迟 1 到 5 秒,只能尽可能地降低不一致的概率,这个过程当中如果有请求过来,还是可能会读到旧数据的。但通过消息队列或订阅变更日志,我们是可以实现最终一致性的。所以实际使用中,建议采用先更新数据库,再删除缓存的方案,同时,要尽可能地保证主从复制不要有太大延迟,降低出问题的概率。
以上就是删除缓存所采用的策略,但其实这背后还有一个问题,那就是如果删除的数据是一个热点数据,是有可能造成缓存击穿的。针对这个问题,国外的 Facebook 给出了一个解决方案:就是在删除的时候,如果判定这是一个热门数据,那么不直接删,而是将它的生命周期设置的更短一些,比如 10 到 30 秒,然后业务方在调用的时候会表明这是一个脏数据。至于你要不要用,则交给业务方进行判断。
# 可以做到强一致吗
看到这里你可能会想,这些方案还是不够完美,我就想让缓存和数据库强一致,到底能不能做到呢?首先要想做到强一致,最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议,但它们的性能往往比较差,而且这些方案也比较复杂,还要考虑各种容错问题。
相反,这时我们换个角度思考一下,我们引入缓存的目的是什么?答案很明显,就是性能。一旦我们决定使用缓存,那必然要面临一致性问题,性能和一致性就像天平的两端,无法做到都满足要求。而且,就拿我们前面讲到的方案来说,当操作数据库和缓存完成之前,只要有其它请求可以进来,都有可能查到中间状态的数据。所以如果非要追求强一致,那必须要求所有更新操作完成之前期间,不能有任何请求进来。虽然我们可以通过加分布锁的方式来实现,但我们要付出的代价,很可能会超过引入缓存带来的性能提升。因此既然决定使用缓存,就必须容忍一致性问题,我们只能尽可能地去降低问题出现的概率。
# 相关链接
- 高并发架构系列:Redis 缓存和 MySQL 数据一致性方案详解 - 从程序员到架构师需要掌握的技术、知识、实战等干货,都在这里了~ - OSCHINA - 中文开源技术交流社区 (opens new window)
- 如何保持 mysql 和 redis 中数据的一致性? - 知乎 (opens new window)
- 高并发场景下,怎么保证缓存和数据库的数据一致性?具体解决方案是什么?有哪些框架?具体怎么实现? - 知乎 (opens new window)
- Redis 和数据库的数据一致性问题 - 蝉沐风 - 博客园 (opens new window)
- 一个经典面试题:如何保证缓存与数据库的双写一致性? - 知乎 (opens new window)
- 缓存和数据库一致性问题,看这篇就够了 (opens new window)