问题:如何保证缓存和数据库的一致性
方案
常见更新缓存方式的几种方案:
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
方案分析
方案一:先更新缓存,再更新数据库
最不可取的方案,因为当缓存更新成功,但是数据库更新失败时(往往数据库更新失败时,会有一些补救方法),会出大问题。
方案二:先更新数据库,再更新缓存
俗称双写,存在的问题:并发更新数据库场景下,会将脏数据刷到缓存。
场景:
更新数据库操作1 ----》更新数据库操作2—》更新缓存操作2—》更新缓存操作1
方案三:删除缓存,更新数据库
这个问题也比较明显,并发情况下,在更新数据库之前,先来了读请求,那么缓存里还是旧数据。
方案四:更新数据库,再删除缓存
不太可能出现的风险:先读后更新,读数据时缓存正好已经过期,去读数据库并读到数据但是还未返回,此时更新数据库完成,并且完成删除缓存操作,这时候前面的读操作才来写缓存,则此时缓存是脏数据。
因为数据库读操作的速度要远快于写操作,所以很难出现读数据库慢于写数据库的情况。
方案对比
方案1和方案2的共同缺点:
并发更新数据库场景下,会将脏数据刷入缓存,但并发写的场景概率相对较小一些,但是总是有可能存在。
方案3和方案4的共同缺点:
- 主从延时问题:不管先删除还是后删除,数据库主从延时都可能导致脏数据的产生(一般都是写主,读从)
- 删除缓存失败,都会产生脏数据
更新还是删除缓存?
- 更新缓存需要有一定的维护成本,并且会存在并发更新的问题
- 写多读少的情况下,频繁跟新缓存,缓存意义何在
- 有些场景下,缓存值是经过复杂计算的,每次都更新,会频繁计算写入,浪费性能
而删除的优点:简单、成本低,容易开发;缺点:会造成一次cache miss(这里曾经踩过坑,使用redis的 list结构 缓存一个有排序的数据,设置了过期时间,每次读取都是全部数据,因此,没有pop操作只有push。预想流程为,redis有,读redis,只有redsi过期,再去读数据库写入。出现故障:一段时间后,发现机器内存打满崩了,redis把内存占完了,就是这个key数据过多造成的。分析原因:1、当过期或者没有缓存的时候,大量读请求进来,一起没读到,一起去读数据库,读完一起返回,写入缓存,因为只有push,就撑了太多数据;2、不明原因读缓存没读到,就读数据库,然后push,并更新了时间,如果存在某种固定错误,则会一直push,过不了期。但是这个问题为什么在测试环境没有复现就很奇怪)。
如果更新缓存开销较小并且读多写少,基本不会有写并发的时候,可以用更新缓存,否则建议使用删除缓存。
总结
方案 | 问题 | 问题出现概率 | 推荐程度 |
---|---|---|---|
先更新缓存,再更新数据库 | 更新数据库失败,将导致数据错误 | 大概率出现 | 不推荐 |
先更新数据库,再更新缓存 | 并发场景下,易出现脏数据情况 | 概率一般 | 不推荐 |
先删除缓存,再更新数据库 | 数据库更新前的读操作,会导致脏数据 | 概率较大 | 不推荐 |
先更新数据库,再删除缓存 | 特殊情况下,写数据库快于读数据库,则会出现脏数据 | 概率较小 | 依据场景可使用 |
推荐方案
延迟双删
采用更新前后双删除策略
- 先淘汰缓存
- 再写数据库
- 休眠定时,再次淘汰缓存
双删是为了确保并发读请求结束后,写请求能够删除读请求造成的脏数据,具体的休眠时间,应该结合业务逻辑耗时来定。第二次的删除可做异步处理,添加删除失败重试机制。
注意
关于缓存过期时间的问题,如果设置了过期时间,则不一致情况,可能只是暂时的,而如果没有设置过期时间,那不一致问题只有在下一次更新时,才有可能解决,所以一定要设置缓存过期时间,哪怕时间很长。