Appearance
缓存数据库双写一致性
更新缓存还是删除缓存
正解:删除缓存。
- 因为很多时候,缓存中放的并不是简单的从数据中取出来值,而是经过复杂统计计算的值;
- 待更新的缓存有可能是个冷数据,可以等到需要的时候再重新计算就好了,避免非必要的 CPU 和内存浪费;
方案 A:先更新数据库再删除缓存
如果数据库更新成功,但是缓存删除失败了,那么就会出现数据不一致。
方案 B:先删除缓存再更新数据库
在并发情况下:
- A 线程删除缓存成功;
- B 线程读取数据,发现没有缓存,从数据库中取出了旧值并将其放到了缓存中;
- A 线程更新数据库成功;
此时也出现了数据不一致。
方案 C:普通双删
- A 线程删除缓存成功;
- B 线程读取数据,发现没有缓存,从数据库中取出了旧值;
- A 线程更新数据库成功;
- A 线程删除缓存成功;
- B 线程将旧值放到缓存中;
仍然是出现了数据不一致。
方案 D:延迟双删
也就是在方案 C 中第 4 步的时候,线程 A 不立马删除,而是延迟一会儿再删除(也就是尽可能延迟到步骤 5 之后再删除)。
- 这里 A 线程延迟删除缓存的时间很难把控;
- 而且仍然有可能出现缓存删除失败的情况;
方案 E:给缓存增加脏锁字段
给缓存对象增加脏锁字段,字段值推荐使用有序的分布式 Id。
- A 线程生成脏锁,并判断缓存是否存在:
- 不存在,则插入,并设置脏锁;
- 存在且无脏锁,设置脏锁;
- 存在且有脏锁,判断缓存上的脏锁是否小于 A 线程生成的脏锁;
- 是,该缓存不归 A 线程管;
- 否,更新该缓存的版本号为 A 线程生成的脏锁;
- B 线程读缓存:有脏锁则表示当前数据为脏数据,改从数据库取数据;
- C 线程写缓存:只有当缓存的脏锁等于 C 线程的脏锁时,才允许写缓存;
- A 线程更新数据库;
- A 线程写缓存:只有当缓存的脏锁等于 A 线程的脏锁时,才允许写缓存,写缓存时一删除脏锁字段值;
该方案需要考虑脏锁超时的问题。
方案 F:消息队列
把删除失败的 key 放到消息队列,再不断重复尝试删除。
方案 G:订阅 binlog
数据库(以 Mysql 为例)主从之间的数据同步是通过 binlog 同步来实现的,因此这里可以考虑订阅 binlog(可以使用 canal 之类的中间件实现),提取出要删除的缓存项,然后作为消息写入消息队列,然后再由消费端进行慢慢的消费和重试。
方案 H:异步串行化
如果非要保证数据的强一致性和实效性。
那么可以考虑将更新与读取操作进行异步串行化。
实现方案
- 更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个 jvm 内部的队列里面去;
- 读取数据的时候,如果发现缓存中没有,那么将从数据库读取数据的操作和更新缓存的操作一起路由到同一个 JVM 内部的队列中去;
- 一个队列对应一个工作线程,然后线程从队列里面去取请求进行操作;
这样就可以将同一个 key 的操作进行串行化,一个数据变更的操作,先执行删除缓存,然后再去更新数据库,但是还没完成更新,此时如果一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成。
这里有一个优化点,一个队列中,其实多个更新缓存请求串在一起是没意,直接等待前面的更新操作请求完成即可。义的,因此可以做过滤,如果发现队列中已经有一个更新缓存的请求了,那么就不用再放个更新请求操作进去了。
待那个队列对应的工作线程完成了上一个操作的数据库的修改之后,才会去执行下一个操作,也就是缓存更新的操作,此时会从数据库中读取最新的值,然后写入缓存中。
如果请求还在等待时间范围内,不断轮询发现可以取到值了,那么就直接返回; 如果请求等待的时间超过一定时长,那么这一次直接从数据库中读取当前的旧值。
需要注意的问题
不适合数据频繁更新的场景
由于读请求进行了非常轻度的异步化,所以一定要注意读超时的问题,每个读请求必须在超时时间范围内返回。
该解决方案,最大的风险点在于说,可能数据更新很频繁,导致队列中积压了大量更新操作在里面,然后读请求会发生大量的超时,最后导致大量的请求直接走数据库。
务必通过一些模拟真实的测试,看看更新数据的频繁是怎样的。
读请求并发量过高
还有一个风险,就是突然间大量读请求会在几十毫秒的延时 hang 在服务上,看服务能不能抗的住,需要多少机器才能抗住最大的极限情况的峰值。
但是因为并不是所有的数据都在同一时间更新,缓存也不会同一时间失效,所以每次可能也就是少数数据的缓存失效了,然后那些数据对应的读请求过来,并发量应该也不会特别大。
多服务实例部署的请求路由
可能这个服务部署了多个实例,那么必须保证说,执行数据更新操作,以及执行缓存更新操作的请求,都通过 Nginx 服务器路由到相同的服务实例上。
热点商品的路由问题。导致请求的倾斜
万一某个商品的读写请求特别高,全部打到相同的机器的相同的队列里面去了,可能造成某台机器的压力过大。
就是说,因为只有在商品数据更新的时候才会清空缓存,然后才会导致读写并发,所以更新频率不是太高的话,这个问题的影响并不是特别大。
但是的确可能某些机器的负载会高一些。