面试题-缓存与数据库双写一致性相关问题解析
一、面试官心理分析
在使用缓存时,往往会涉及缓存与数据库双存储双写的情况,而只要是双写就必然存在数据一致性问题,面试官旨在考察如何解决该一致性问题。
二、面试题剖析
(一)读、写请求串行化
若系统允许缓存与数据库偶尔存在不一致情况(非严格要求二者必须保持一致),可采用读请求和写请求串行化,将其串到一个内存队列里。不过,串行化虽能保证不出现不一致情况,但会大幅降低系统吞吐量,可能需要比正常情况多几倍的机器来支撑线上请求。
(二)Cache Aside Pattern(缓存旁路模式)
- 读操作流程
读的时候,先读缓存,若缓存没有,则读数据库,取出数据后放入缓存,同时返回响应。 - 更新操作流程
更新的时候,先更新数据库,然后再删除缓存。之所以选择删除缓存而非更新缓存,原因如下:- 在复杂缓存场景中,缓存可能不单单是数据库中直接取出来的值,比如更新某个表的一个字段,其对应的缓存可能需要查询另外两个表的数据并运算才能得出最新值。
- 更新缓存的代价有时很高,并非每次修改数据库都要更新对应的缓存,对于复杂缓存数据计算场景更是如此。例如,一个缓存涉及的表字段在1分钟内修改多次,但缓存1分钟内只被读取1次,存在大量冷数据,此时若只是删除缓存,1分钟内缓存不过重新计算一次,开销大幅降低,这体现的是一种 lazy 计算思想,类似 mybatis、hibernate 的懒加载,按需计算缓存值。
三、最初级的缓存不一致问题及解决方案
(一)问题描述
先更新数据库,再删除缓存,若删除缓存失败,会导致数据库是新数据,缓存是旧数据,出现数据不一致情况(标记为 redis-junior-inconsistent)。
(二)解决思路
- 思路一
先删除缓存,再更新数据库。若数据库更新失败,数据库中是旧数据,缓存中为空,读的时候因缓存没有就去读数据库中的旧数据,然后更新到缓存中,这样数据不会不一致。 - 思路二(延时双删)
依旧先更新数据库,再删除缓存,不同的是,在不久之后(比如5s之后)再执行一次删除动作。示例代码如下:
public void set(key, value) {
putToDb(key, value);
deleteFromRedis(key);
//... a few seconds later
deleteFromRedis(key);
}
删除动作有多种选择,如使用 DelayQueue(存在随 JVM 进程死亡丢失更新的风险)、放在 MQ(会增加编码复杂度)等,需综合各种因素设计,选择最合理的解决方案。
四、比较复杂的数据不一致问题分析
(一)问题场景
数据发生变更,先删除了缓存,然后要去修改数据库但还没修改时,一个读请求过来,发现缓存空了就去查询数据库,查到修改前的旧数据并放到缓存中,随后数据变更程序完成数据库修改,导致数据库和缓存中的数据不一样了。尤其在高并发场景(上亿流量、每秒并发读几万等情况)下,对一个数据并发进行读写时,很容易出现这种不一致情况,而读并发低时出现不一致的场景相对较少。
(二)解决方案
更新数据时,根据数据的唯一标识,将操作路由之后发送到一个 JVM 内部队列中。读取数据时,若发现数据不在缓存中,将重新执行“读取数据 + 更新缓存”的操作,同样根据唯一标识路由后发送到同一个 JVM 内部队列中。一个队列对应一个工作线程,工作线程串行拿到对应的操作并一条条执行。若读请求过来没读到缓存,可先将缓存更新请求发送到队列中积压,同步等待缓存更新完成。同时可做过滤,若队列中已有更新缓存请求,就不用再放入新的更新请求,直接等待前面操作完成。待工作线程完成上一个操作(数据库修改)后,才执行下一个操作(缓存更新,从数据库读取最新值写入缓存)。若请求在等待时间范围内能取到值就直接返回,若超过一定时长就直接从数据库读取当前旧值。
(三)高并发场景下该解决方案要注意的问题
- 读请求长时阻塞
由于读请求进行了轻度异步化,要注意读超时问题,每个读请求必须在超时时间范围内返回。存在数据更新频繁导致队列积压大量更新操作,读请求大量超时,最后大量请求直接走数据库的风险。需通过模拟真实测试,了解更新数据频率等情况。另外,一个队列可能积压针对多个数据项的更新操作,要根据业务情况测试,可能需部署多个服务分摊更新操作,避免读请求长时间阻塞。例如,若内存队列积压过多商品库存修改操作,会导致读请求等待时间过长,所以要根据实际情况做压力测试,看内存队列积压情况及对读请求的影响,必要时加机器减少每个服务实例处理的数据量,降低积压情况。根据项目经验,一般数据写频率较低,正常情况下队列积压更新操作较少。通过测算,如合理划分写操作时间片、分配内存队列等,单机支撑几百的写 QPS 是可行的,若写 QPS 扩大,可相应扩容机器。 - 读请求并发量过高
要做好压力测试,确保在碰上大量读请求几十毫秒延时挂在服务上的情况时,服务能扛得住,确定需要多少机器应对峰值情况。不过,并非所有数据同时更新、缓存同时失效,所以每次只是少数数据缓存失效,对应读请求并发量通常不会特别大。 - 多服务实例部署的请求路由
若服务部署了多个实例,必须保证执行数据更新操作以及执行缓存更新操作的请求,都通过 Nginx 服务器等路由到相同的服务实例上,可采用自己做服务间按请求参数的 hash 路由或利用 Nginx 的 hash 路由功能等,确保对同一个商品的读写请求都路由到同一台机器上。 - 热点商品的路由问题,导致请求的倾斜
万一某个商品的读写请求特别高,全部打到相同机器的相同队列里,可能造成某台机器压力过大。不过若商品更新频率不高,该问题影响相对不大,但可能导致某些机器负载偏高,要根据业务系统情况来看待这一问题。
评论区