谈谈数据库,缓存一致性
几年前,我在看博客的时候,看到有一篇博客的标题就是关于数据库,缓存一致性的,不以为然,直接跳过去了,心想,这么简单的问题还讨论个鬼啊。这种想法持续了很久,直到某天,我看到越来越多的人都在讨论数据库,缓存一致性的问题,才好好的看了下博客,才发现原来数据库,缓存一致性真不是一个简单的问题。今天我也来谈谈数据库,缓存一致性问题。
科普
考虑到有一些小伙伴可能技术不是那么好,可能没有接触过缓存,所以这里还是花上一分钟的时间,来介绍下什么是缓存,为什么要有缓存,以及数据库和缓存是如何搭配使用的。
读取数据库是比较耗时的操作,如果每次都需要去数据库读取数据,会对数据库造成一定的压力,程序性能也会比较低下,所以需要引入缓存。
缓存是提升程序性能的最重要、最有效、也是最简单的手段之一。
引入缓存后,读操作会先去缓存中看下,如果没有命中缓存,才去读取数据库,然后把读取出来的数据再放到缓存中去,这样下一次读操作就可以命中缓存了,如果命中缓存,就可以直接把数据返回出去了。
写操作,除了修改数据库,还需要删除缓存,因为不删除缓存,读的操作读到的永远都是缓存中的旧数据。
先删除缓存,后修改数据库
这个方案显然是有问题的。
两个并发的读写操作:
- 一个写的操作先进来,把缓存删除了;
- 在写操作还没有更新数据库的时候,一个读的请求又进来了,发现没有命中缓存,就去数据库把老数据取出来了;
- 写操作更新了数据库;
- 读操作把老数据放在了缓存中。
这样,数据库中的数据和缓存中的数据就不一致了,为了更好的让大家理解这个过程,献上一张丑到无法自拔的图:
这个方案显然不行,但是这个方案真的一无是处吗?
非也,让我们设想下这样的场景:一个写的请求进来,删除缓存,这个时候,Redis服务器突然出问题了,或者网络突然出问题了,导致删除缓存失败,抛出了一个异常,导致程序没有继续执行修改数据库的操作。从数据库、缓存一致性的角度来说,这里很好的保证了数据库、缓存的一致性,两者保存的数据是一样的,尽管保存的都是老数据。
先修改数据库,后删除缓存
相信绝大多数小伙伴都是运用的这个方案, 先前我觉得数据库,缓存一致性没有什么好讨论的,太简单了,就是因为我觉得这个方案是如此完美,但是后面我才慢慢发现这个方案也有一定的问题。
看到第一种方案存在的问题,大家也一定想到了这个方案也有同样的问题。
在没有缓存的情况下,两个并发的读写操作:
- 读操作先进来,发现没有缓存,去数据库中读数据,这个时候因为某种原因卡了,没有及时把数据放入缓存;
- 写的操作进来了,修改了数据库,删除了缓存;
- 读操作恢复,把老数据写进了缓存。
这样就造成了数据库、缓存不一致,不过,这个概率出现的非常低,因为这需要在没有缓存的情况下,有读写的并发操作,在一般情况下,写数据库的操作要比读数据库操作慢得多,在这种情况下,还要保证读操作写缓存晚于写操作删除缓存才会出现这个问题,所以这个问题应该可以忽略不计。
说了这么多,并没有看到先修改数据库,后删除缓存的致命问题啊,别急,让我们继续设想这样的场景:一个写的操作进来,修改了数据库,但是删除缓存的时候 ,由于Redis服务器出现问题了,或者网络出现问题了,导致删除缓存失败,这样数据库保存的是新数据,但是缓存里面的数据还是老数据,妥妥的数据库、缓存不一致啊。
延迟双删
可以看到修改数据库,后删除缓存有两个问题,虽然两个问题都是低概率的,但是永远追求完美的程序员可不能允许有这样的事情发生,所以第三种方案出现了:延迟双删。
延迟双删就是先删除缓存,后修改数据库,最后延迟一定时间,再次删除缓存。
这么做就可以在一定程度上缓解上述两个问题,第一次删除缓存相当于检测下缓存服务是否可用,网络是否有问题,第二次延迟一定时间,再次删除缓存,是因为要保证读的请求在写的请求之前完成。
但是这么做,还是有一定问题,比如第一次删除缓存是成功的,第二次删除缓存才失败,又该怎么办?
内存队列
上面三种方式,都有一定的问题:
- 修改数据库、删除缓存这两个操作耦合在了一起,没有很好的做到单一职责;
- 如果写操作比较频繁,可能会对Redis造成一定的压力;
- 如果删除缓存失败,该怎么办?
为了解决上面三个问题,第四种方式出现了:内存队列删除缓存:写操作只是修改数据库,然后把数据的Id放在内存队列里面,后台会有一个线程消费内存队列里面的数据,删除缓存,如果缓存删除失败,可以重试多次。
这样,就把修改数据库和删除缓存两个操作解耦了,如果删除缓存失败,也可以多次尝试。由于后台有一个线程去消费内存队列去删除缓存,不是直接删除缓存,所以修改数据库和删除缓存之间产生了一定的延迟,这延迟应该可以保证读操作已经执行完毕了。
但是这么做也有不好的地方:
- 程序复杂度成倍上升,需要维护线程、队列以及消费者;
- 如果写操作非常频繁,队列的数据比较多,可能消费会比较慢,修改数据库后,间隔了一定的时间,缓存才被删除。
但是这也是没有办法的事情,哪有十全十美的解决方案。
第三方队列
一般来说,系统分为前台系统和后台系统,前台系统主要是读操作,后台系统才有写操作。
比如商品中心,前台是面向用户的,当用户打开商品详情页,会去缓存中拿数据,后台是面向业务人员的,业务人员可以在后台系统对商品信息进行修改。
如果是具有一定规模的公司,前台系统和后台系统肯定不在同一个服务器上,而且是由不同的部门去负责的,所以内存队列是肯定用不了的,如果后台系统修改数据库后,直接删除缓存,一定会发生如下的故事。
后台系统 小明:你们前台系统的产品详情缓存的key是什么格式的?发我下。
前台系统 小花:Product:XXXXX。
后台系统 小明:好的。
过了几天,小花找到小明。
前台系统 小花:不对啊。你们怎么没有把活动中的产品详情缓存给删掉啊?
后台系统 小明:纳尼,我怎么知道你们是两个缓存啊,把活动中的产品详情缓存的key的格式发我下。
前台系统 小花:Activity:Product:XXXX。
后台系统 小明:好的。
过了几天,订单系统的开发又找到小明。
订单系统 小强:你们修改了产品详情后,还要把订单中的产品详情缓存给删除。
后台系统 小明:。。。
过了几天,广告系统的开发又找到小明。
广告系统 小王:你们修改了产品详情后,还要把广告中的产品详情缓存给删除。
后台系统 小明 卒,享年25。
如果引用了第三方队列,如RabbitMQ,Kafka,小明就不会“卒”了,后台系统的小明修改了数据库后,不需要关心缓存的事情,只要把数据的Id丢到消息队列,前台系统、广告系统、订单系统的开发消费消息队列中的数据删除缓存。
上面说的几种方案,都是比较常见的,也比较简单,当然不同的方案也可以搭配使用,但是没有“银弹”,没有完美的解决方案,就看你们的研发团队,你们的场景适合哪种解决方案了。
今天的话题到这里就结束了。