redis缓存一致性讨论

总结不易,如果对你有帮助,请点赞关注支持一下
微信搜索程序dunk,关注公众号,获取博客源码、数据结构与算法笔记(超级全)、大厂面试、笔试题

上下文 & QA

最近工作遇到一个场景,需要将数据库中某一张表的所有数据全部捞出来,放在缓存中。

为什么要用缓存?

因为对方接口QPS比较高,对方接口每次执行时,最坏情况会调用我方接口三次,其中每次都需要全量查询db进行过滤,因此为了提高接口的QPS,考虑将db所有数据全量捞出,缓存在redis中。

为什么使用hash?

因为该表中一行数据字段较多,一方面:如果采用string存储,那么这个key将会是一个很大的key,会占用大概几KB-几M的内存,不方便保存,每次都要将全量捞出来,在做处理,增加了通信代价,另一方面:考虑后期存在对该表数据的crud,为了保证缓存一致性,需要更新缓存,如果采用string存储,每次刷缓存的时候都需要需要全量来一遍,在缓存没命中的呢次,接口QPS会很高,如果使用hash,每次只用更新对应的field即可,代价很小。

为什么每次crud的时候要异步刷新缓存?

主要目的是为了不让缓存更新失败而导致整个操作失败,允许短期缓存不一致,同时,增加定时任务、监控告警,补偿缓存。

定时任务怎么补偿?

  1. 每1h执行一次,先执行补偿更新(刷新近6h有过更新的数据),实质是补偿update 和 create 操作
  2. 捞db和缓存对应数据量是否一致,不一致找到缓存比db多的数据,删除对应缓存,实质补偿delete操作

如何防止缓存穿透而导致多个线程同时全量扫db?

使用golang自带的singleflight,其实我觉得有点类似于java里面的semaphore多个线程并发访问,我只给一个信号量,其他线程来了等着,执行全量的线程执行完了,其他线程拿结果就行了,后面会讲讲singleflight怎么用的。

tips:

redis不支持设置hash缓存中每个field的过期时间,只支持设置整个key的过期时间;redis也不支持在HSET的时候设置过期时间,所以需要执行Expire为整个key设置过期时间,但是如果访问两次redis,那么这个操作不是原子性的,可能会存在一个成功,一个失败的情况,不利于回滚。所以需要使用lua脚本执行redis的命令,保证操作的原子性。

下面将介绍一下redis的缓存一致性问题和redis执行lua脚本的操作

缓存一致性

使用redis的时候必然会遇到一个问题就是:数据库和缓存的一致性问题,这个问题产生的原因是:更新数据库和更新redis是两个步骤,那就有可能一个更新成功,一个更新失败,这时就是产生缓存一致性问题。

缓存类型

按照Redis缓存是否接受写请求,可以将缓存分为:只读缓存读写缓存

  • 只读缓存:数据库更新后,删掉缓存中的数据,下一次读取缓存时发生缓存缺失,再从数据库读取数据写回缓存。
  • 读写缓存:数据库更新后,同步更新缓存中的数据,下一次读取缓存时就会直接命中缓存。

区别

  • 只读缓存是删除缓存中的数据,下次访问这个数据时,会重新读取数据库中的值,这样可以保证数据库和缓存完全一致,并且缓存中保留的是经常访问的热点数据。缺点是删除缓存后,之后的访问会先触发一次缓存缺失,然后从数据库读取数据,这个过程访问延迟会变大。
  • 读写缓存是同步更新缓存中的值,这样被修改的数据永远都在缓存中,下次访问能够直接命中缓存,不再查询数据库,这个过程性能比较好,比较适合先修改又立即访问的场景。缺点是在高并发场景下,并发更新同一个值时,可能会导致缓存和数据库的不一致;并且对于某些缓存值的计算可能会比较复杂,但是又不常访问,那么缓存的利用率就会降低,更新缓存的代价就比较大。

选择

只读缓存牺牲了一定的性能,优先保证数据库和缓存的一致性,它更适合对于一致性要求比较要高的场景。而如果对于数据库和缓存一致性要求不高,或者不存在并发修改同一个值的情况,那么使用读写缓存就比较合适,它可以保证更好的访问性能,但要考虑到缓存更新的代价。

只读缓存

新增数据

对于新增数据,先将数据写入数据库中,缓存有两种处理方式

  1. 新增时不做任何处理,下次查询缓存时从数据库查询写回缓存;
  2. 新增时同步写入缓存

无论哪种方式,缓存最终都是一致的

更新数据
  1. 先删缓存,再更新数据库,删缓存成功,更新数据库失败:此时缓存没有值,数据库是旧值,下次查询触发缓存缺失,读取数据库的旧值,缓存与数据库是一致的。
  2. 先更新数据库,再删缓存,更新数据库成功,删缓存失败:删缓存失败时
    1. 如果能回滚数据库更新,那么缓存和数据库的值是一致的。
    2. 如果不能回滚数据库更新,那么缓存是旧值,数据库是新值,出现数据不一致。

先删缓存,再更新数据库则没有不一致的问题。所以一般采用先删缓存,再更新数据库的模式。

并发读写
  1. 并发写+读
    1. A线程先删缓存,B线程读缓存,缓存失效,读数据库并写入缓存,A线程更新数据库,数据库时新值,缓存是旧值,数据不一致。
    2. A线程先更新数据库,B线程读缓存,读到旧值,接着A线程删除缓存,缓存失效后会被下次查询操作更新为新值,只会短暂出现缓存不一致现象,对业务影响较小。
  2. 并发写+写 都会先删除缓存,再更新数据库,然后会触发缓存失效从而更新缓存,最终数据一致

对于并发写+读的第一种情况,可以使用延迟双双删:就是在 先删缓存,后更新数据库后,sleep 一小段时间,再进行一次缓存删除操作。sleep 的时间就约等于B线程 读取数据+写入缓存的时间,这样就可以在B线程写入旧缓存,A线程更新完数据库后,再次删掉旧缓存。

读写缓存

新增数据

和只读缓存一样,不会出现数据不一致情况

更新删除数据
  1. 先更新缓存,再更新数据库,更新缓存成功,更新数据库失败:此时缓存中是新值,数据库是旧值,出现数据不一致
  2. 先更新数据库,再更新缓存,更新数据库成功,更新缓存失败
    1. 如果更新缓存失败时,可以回滚数据库操作,那么数据库和缓存都是旧值,数据一致
    2. 如果没有回滚,那么数据库是新值,缓存是旧值,数据不一致

无论先更新缓存还是先更新数据库,只要第二步失败了,就会导致缓存不一致

这里可以增加重试机制,把第二部操作放入MQ中,如果更新没有成功,可以从消息队列中取出消息,执行更新数据库或者缓存的操作,成功后删除消息,否则重试,以此达到数据库和缓存的最终一致。如果多次重试失败,可以发送告警信息。

并发读写

更新缓存和数据库都成功

  1. 并发 写+读,A线程先更新数据库,B线程读缓存,A线程再更新缓存,此时B线程读到旧值,出现短暂的不一致性,对业务影响比较小。
  2. 并发 写+读,A线程先更新缓存,B线程读缓存,A线程再更新数据库,此时B线程读到新值,数据是一致的,对业务没有影响。
  3. 并发 写+写,A、B 线程并发更新同一条数据,先更新缓存,再更新数据库,顺序为 A更新缓存 -> B更新缓存 -> B更新数据库 -> A更新数据库,这时数据库和缓存的数据不一致。
  4. 并发 写+写,A、B 线程并发更新同一条数据,先更新数据库,再更新缓存,顺序为 A更新数据库 -> B更新数据库 -> B更新缓存 -> A更新缓存,这时数据库和缓存的数据不一致。

可以看到并发写+写,会出现数据不一致的情况,对业务影响较大,针对这种情况,可以使用分布式锁来保证多个线程操作同一资源的顺序性,同一时间只允许一个线程去更新数据库和缓存,以此保证一致性。但对并发更新的性能会有较大的影响,

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值