在实习项目中,用了Redis来作为缓存。是怎么保证Reids缓存中的数据和数据库中的数据一直的呢?
Redis因为是单线程的,并且很快的读、写速度,常常用来作为缓存加快读写速度。但是缓存数据和数据库数据之间可能存在不一致,在项目中是重点关注的点。
缓存一般在如下的流程中操作:
但是在更新缓存这方面是存在争议的:更新完数据库,是更新缓存呢,还是删除缓存;又或则是先删除缓存再更新数据库。
查到的通常又一下 3 种解决策略:
先更新数据库,在更新缓存;先删除缓存,再更新数据库;先更新数据库,再删除缓存这套解决方案大家是普遍反对的。原因如下 原因一(线程安全角度) 同时有请求A和请求B进行更新操作,那么会出现: (1) 线程A更新了数据库; (2) 线程B更新了数据库; (3) 线程B更新了缓存; (4) 线程A更新了缓存; 线程A更新缓存应该比线程B更新缓存更早才对,但是因为网络等原因,B却比A更早更新了缓存,这就导致了脏数据,因此不考虑。
原因二(业务场景角度) 有如下两点: (1) 如果是一个写数据库较多,读数据库场景比较少的业务需求,采用这种方案就会导致缓存被更新了还根本没有被读到,就又被频繁地更新,这无疑是浪费了性能。 (2) 如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为合适。
那到底是先删缓存再更新数据库,还是先更新数据库再删缓存呢?
该方案会导致不一致的原因是:同时又一个请求A进行更新操作,另一个请求B进行查询操作,那么就有可能出现如下的场景: (1) 请求A删除缓存; (2) 请求B查询发现缓存不存在; (3) 请求B查询数据库,得到旧值; (4) 请求B将查到的旧值写入到缓存; (5) 请求A将新值写入到数据库中。 这就出现了缓存和数据库不一致的情况。如果缓存过期时间较长或则没有设置过期时间,那么将又很长一段时间数据都是脏数据。
那么,如何解决这个问题呢?
采用 延时双删 策略
public void write(String key, Object data) { redis.delKey(key); db.updateData(data); Thread.sleep(1000); redis.delKey(key); }(1) 先删除对应的缓存; (2) 更新数据库(这两步和原来一样) (3) 休眠1秒,再次淘汰缓存
这么做将1秒内所造成的缓存脏数据再次删除。
那么,这个1秒怎么确定的,具体该休眠多久呢? 针对上面的情形,应该评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加上几百毫秒。(数据写入到数据库中需要几百毫秒的时间,等到数据写入到数据库后再删除缓存,那么再读的话就是写入到数据库的值;否则还没写入到数据库中,就又有请求读取,就仍会造成脏数据)。
如果使用了mysql的读写分离架构该怎么办? OK,在这种情况下,造成数据不一致的原因如下,两个请求,一个请求A进行更新操作,另一个请求B进行查询操作,有可能出现下面的情况: (1) 请求A删除缓存; (2) 请求A更新数据库; (3) 请求B查询,没有缓存; (4) 请求B到从数据库中查询,这时hi没有完成主从数据库的同步,查到旧值; (5) 请求B将查到的旧值写入缓存。
这就出现了脏数据。解决策略仍然是 延时双删 策略,只是休眠的时间变得更长了,不仅要等待数据写入到数据库中,还要等待主从数据库的同步。
采用这种同步淘汰策略,吞吐量降低怎么办? 将第二次删除缓存的操作作为异步的,自己另外开启一个线程,异步删除。这样,写的请求就不用再沉睡一段时间后再返回。这么做加大了系统的吞吐量。
第二次删除,如果失败怎么办? 第二次删除失败,就会出现如下情形。还是有两个请求,一个请求A进行更新操作,另一个请求B进行查询草哦做,为了方便,假设是单库: (1) 请求A进行写操作,删除缓存; (2) 请求B查询发现缓存不存在; (3) 请求B去数据库查询得到旧值; (4) 请求B将旧值写入缓存; (5) 请求A将新值写入数据库; (6) 请求A试图去删除请求B写入的缓存,结果失败了。
这就出现了脏数据,仍然是数据不一致。该如何解决呢?看后面删除缓存失败的解决办法。
Facebook公司在论文《Scaling Memcache at Facebook》中提出,他们用的也是先更新数据库,再删缓存的策略。
这种情况下也存在并发问题:
仍然两个请求,请求A更新数据库,请求B查询数据库。 (1) 缓存刚好失效; (2) 请求B查询数据库,得到旧值; (3) 请求A将新值写入数据库中; (4) 请求A删除缓存; (5) 请求B将查到的旧值写入缓存。
这就出现了脏数据。
但是,仔细想想,这种情况出现的概率又有多大呢?
上述情景的出现有一个条件,就是步骤(2)的查询操作比步骤(3)写入数据库更耗时,才有可能使得步骤(4)发生在不受(5)之前。
可是数据库的读操作是远远快于写操作的,不然还那么费劲地去做数据库的读写分离干什么,不就是充分利用数据库读远快于写的特性提高系统性能吗。
因此,这种方法中出现脏数据的概率很小。
如果有强迫症,一定要解决该怎么做? (1) 给缓存设一个有效时间; (2) 采用策略2中给出的延时删除策略,保证读请求完成之后,再进行删除缓存操作。
如果删除缓存失败怎么办,这也是策略2和策略3中都存在的一个问题。
如何解决?
提供一个保障的重试机制即可,这里给出两套方案。
流程如下: (1) 更新数据库数据; (2) 缓存因为种种原因删除失败; (3) 将需要删除的key发送至消息队列; (4) 自己消费消息,获得需要删除的key; (5) 继续重试删除操作,直到成功。
该方案有一个缺点就是对业务代码侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的消息,进行删除缓存操作。
流程如下: (1) 更新数据库数据; (2) 数据库会将操作信息写入binlog日志当中; (3) 订阅程序日去处所需要的数据以及key; (4) 另起一段非业务代码,获取该信息; (5) 尝试删除缓存操作,发现删除失败; (6) 讲这些信息发送至消息队列; (7) 重新从消息队列中获得该数据,重试操作。
备注说明: 上述的订阅binlog程序在MySQL中现成的中间件叫canal,可以完成订阅binlogr日志的功能。
参考: https://blog.csdn.net/diweikang/article/details/94406186