如何保证缓存(Redis)与数据库(MySQL)的一致性?对于热点数据(经常被查询,但不经常被修改的数据),我们一般会将其放入 Redis 缓存中,以增加查询效率,但需要保证从 Redis 中读取的数据与数据库中存储的数据 终是一致的,这就是经典的缓存与数据库同步问题。
那么,如何保证缓存(Redis)与数据库(MySQL)的一致性呢?根据缓存是删除还是更新,以及操作顺序大概是可以分为下面四种情况:
1.先更新数据库,再更新缓存
2.先更新缓存,再更新数据库
3.先删除缓存,再更新数据库
4.先更新数据库,再删除缓存
例如:只是简单的更新一下用户积分,只操作一个字段,那就可以采用更新缓存,还有类似秒杀下商品库存数量这种并发下查询频繁的数据,也可以使用更新缓存,不过也要注意线程安全的问题,防止产生脏数据。但是当更新操作的逻辑较复杂时,需要涉及到其它数据,如用户购买商品付款时,需要考虑打折、优惠券、红包等多种因素,这样需要缓存与数据库进行多次交互,将打折等信息传入缓存,再与缓存中的其它值进行计算才能得到 终结果,此时更新缓存的消耗要大于直接淘汰缓存。
所以还是要根据业务场景来进行选择,不过大部分场景下删除缓存操作简单,并且带来的副作用只是增加了一次 Cache Miss,建议作为通用的处理方式。
这种方式就适合更新缓存的代价很小的数据,例如上面说的用户积分,库存数量这类数据,同样还是要注意线程安全的问题。
线程安全角度
同时有请求 A 和请求 B 进行更新操作,那么会出现
1.线程 A 更新了数据库
2.线程 B 更新了数据库
3.线程 B 更新了缓存
4.线程 A 更新了缓存
这就出现请求 A 更新缓存应该比请求 B 更新缓存早才对,但是因为网络等原因,B 却比 A 更早更新了缓存,这就导致了脏数据。
业务场景角度
有如下两种不适合场景:
1.如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能
2.如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是也浪费性能的
这种情况应该是和第一种情况一样会存在线程安全问题的,但是这种情况是有人使用过的,根据书籍《淘宝技术这十年》里,多隆把商品详情页放入缓存,采取的正是先更新缓存,再将缓存中的数据异步更新到数据库这种方式,
有兴趣了解的可以查看这篇博客: https://www.cnblogs.com/rjzheng/p/9240611.html
还有现在互联网常见的点赞功能,也可以采用这种方式,有兴趣了解的可以查看这篇文章: https://juejin.im/post/5bdc257e6fb9a049ba410098
简单的想一下,好像这种方式不错,就算是第一步删除缓存成功,第二步写数据库失败,则只会引发一次 Cache Miss,对数据没有影响,其实仔细一想并发下也很容易导致了脏数据,例如
1.请求 A 进行写操作,删除缓存
2.请求 B 查询发现缓存不存在
3.请求 B 去数据库查询得到旧值
4.请求 B 将旧值写入缓存
5.请求 A 将新值写入数据库
那怎么解决呢,先看第四种情况(先更新数据库,再删除缓存),后面再统一说第三种和第四种的解决方案。
先说一下,国外有人提出了一个缓存更新套路,名为 Cache-Aside Pattern:
https://docs.microsoft.com/en-us/azure/architecture/patterns/cache-aside
失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中命中:应用程序从 cache 中取数据,渠道后返回
更新:先把数据存到数据库中,成功后再让缓存失效
更新操作就是先更新数据库,再删除缓存;读取操作先从缓存取数据,没有,则从数据库中取数据,成功后,放到缓存中;这是标准的设计方案,包括 Facebook 的论文 Scaling Memcache at Facebook也使用了这个策略。
为什么他们都用这种方式呢,这种情况不存在并发问题么?
答案是也存在,但是出现概率比第三种低,例如:
1.请求缓存刚好失效
2.请求 A 查询数据库,得一个旧值
3.请求 B 将新值写入数据库
4.请求 B 删除缓存
5.请求 A 将查到的旧值写入缓存
这样就出现脏数据了,然而,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作删除缓存,所有的这些条件都具备的概率基本并不大,但是还是会有出现的概率。
并且假如第一步写数据库成功,第二步删除缓存失败,这样也导致脏数据,请看解决方案。
那怎么解决呢,可以采用延时双删策略(缓存双淘汰法),可以将前面所造成的缓存脏数据,再次删除:
1.先删除(淘汰)缓存
2.再写数据库(这两步和原来一样)
3.休眠 1 秒,再次删除(淘汰)缓存
4.先写数据库
5.再删除(淘汰)缓存(这两步和原来一样)
6.休眠 1 秒,再次删除(淘汰)缓存
这个 1 秒应该看你的业务场景,应该自行评估自己的项目的读数据业务逻辑的耗时,然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百毫秒即可,这么做确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
如果你用了 MySql 的读写分离架构怎么办?,例如:
1.请求 A 进行写操作,删除缓存
2.请求 A 将数据写入数据库了,(或者是先更新数据库,后删除缓存)
3.请求 B 查询缓存发现,缓存没有值
4.请求 B 去从库查询,这时,还没有完成主从同步,因此查询到的是旧值
5.请求 B 将旧值写入缓存
6.数据库完成主从同步,从库变为新值
这种情景,就是数据不一致的原因,还是采用延时双删策略(缓存双淘汰法),只是,休眠时间修改为在主从同步的延时时间基础上,加几百毫秒。
并且为了性能更快,可以把第二次删除缓存可以做成异步的,这样不会阻塞请求了,如果再严谨点,防止第二次删除缓存失败,这个异步删除缓存可以加上重试机制,失败一直重试,直到成功。
这里给出两种重试机制参考
1.更新数据库数据
2.缓存因为种种问题删除失败
3.将需要删除的 key 发送至消息队列
4.自己消费消息,获得需要删除的 key
5.继续重试删除操作,直到成功
然而,该方案有一个缺点,对业务线代码造成大量的侵入,于是有了方案二,启动一个订阅程序去订阅数据库的 Binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作
1.更新数据库数据
2.数据库会将操作信息写入 binlog 日志当中
3.订阅程序提取出所需要的数据以及 key
4.另起一段非业务代码,获得该信息
5.尝试删除缓存操作,发现删除失败
6.将这些信息发送至消息队列
7.重新从消息队列中获得该数据,重试操作
上述的订阅 Binlog 程序在 MySql 中有现成的中间件叫 Canal,可以完成订阅 Binlog 日志的功能,另
外,重试机制,这里采用的是消息队列的方式。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试即可,这些大家可以灵活自由发挥,只是提供一个思路。
总结: 大部分应该使用的都是第三种或第四种方式,如果都是采用延时双删策略(缓存双淘汰法),可能区别不会很大,不过第四种方式出现脏数据概率是更小点,更多的话还是要结合自身业务场景使用,灵活变通。