缓存是应对高并发场景下的一大神器,而如何设计好缓存模块并非直观想象的那么简单。本文聊一聊缓存模块设计过程中的那些事儿。涉及到的讨论有:
缓存与数据库操作的非原子性引发的一致性问题 并发引发的一致性问题 写链路中是选择更新缓存还是删除缓存 主从延迟和延迟双删问题
随着业务的发展,QPS有了一定的升高,对数据库造成的压力越来越大。这一阶段主要是希望通过加一层缓存,分担数据库的读请求压力
方案示意图如下:
方案示意图如下:
方案 | 优点 | 缺点 |
方案一 | 实现简单 | 1. 缓存利用率低,冷数据会长期占用redis空间 |
方案二 | 缓存利用率高,通过设置过期时间 | 1. 过期时间不好设置,一般依靠经验值会设计为秒级 |
简单来说需要考虑以下几个问题
详细解决方案可以参考后端研发避坑指南-1.1 缓存设计
方案之间的对比需要放在场景中分析,没有绝对的好与坏,只有适不适合
另外,由于二者在写时都不会去操作缓存,因此在缓存和数据库的实时一致性方面都是比较差的(理论上都是秒级以上),对实时一致性要求比较高的场景不适合用这两种方案
上面提到的2种方案,在写请求时都没有去操作缓存,如果在写DB的同时主动去操作缓存,是不是会在实时一致性方面表现更好呢。简单分析下:
这样看,在写请求时操作缓存确实可以使得实时一致性至少从秒级提高到毫秒级
但事情似乎没有这么简单
当写请求从只写DB到需要写DB+写缓存时,我们需要考虑的点就变多了,总的来说需要考虑到:
在回答是选删除还是选更新前,先按照以上三点分别剖析这两种方案,最后再来做比较(虽然缓存带过期时间是个比较好的实践,但下面讨论的方案中如没有特别说明都是没有过期时间的缓存)
读写并发
最终导致 DB 中的值是新值,缓存中的值是旧值,发生不一致
写并发
写并发并不会对写操作有影响,因为实际上底层数据库的更新还是串行的。影响可能是在写多的场景下,会导致缓存频繁删除,进而读请求频繁回源,对DB产生压力
第2步失败
删除缓存成功,更新数据库失败,此时请求同步返回失败。
读写并发
最终同样会导致缓存和DB中的值不一致
写并发
同上
第2步失败
更新数据库成功,删除缓存失败,假设请求返回失败
假设请求返回成功
从「并发」的角度
不管顺序如何都有导致缓存和数据库不一致的可能,那到底该如何选呢?需要定性分析下这两种情况的可能性到底谁大谁小
对于前者,写请求线程A的操作是2+5,两步写操作,读请求3+4两步读操作。通常写数据库时底层数据库会加锁,而读数据库不会加锁,因此理论上2+5的时间会大于3+4的时间;
对于后者,读请求的操作是2+5,写请求是3+4,根据上面所说的“2步写请求的时间一般会大于2步读请求的时间”,从这点来看,后者发生的可能性是要小于前者的。
除此之后,后者还需要叠加另外两个条件
所以总体上,「先更新数据库后删除缓存」的方案出现缓存和数据库不一致的可能性更小
从「第2步失败」的角度
看起来是「先删除缓存再更新数据库」更胜一筹
在实际生产环境中,更倾向于选择「先更新数据库,再删除缓存」的方案。对于该方案在「第2步失败」方面的短板,一般解决方案是:
还有一种近乎无解的情况:主从延迟
不管是用哪种方式,如果回源DB时,由于主从延迟导致查询到值本身就是旧值,那写入缓存的也必定是旧值了。这里是有解决方案的,就是缓存回源的时候强制读主库。但是一般都不会使用这种方案,原因是这会使得回源的读请求直接打到主库,风险非常大,另外本身用于承担查询请求的从库也就没有了其存在的意义
还有一种解决方案:延迟双删。所谓的双删是:写请求中更新数据库+删除缓存后,再通过一条延时消息随后触发再次删除缓存。这样的目的是为了把读请求中在从库读出的数据清掉。但这个方案有个很大的问题,延迟时间如何设置?只能按照经验去设置
所以,缓存和数据库之间的一致性是很难做到强一致的,只能是尽可能减小产生不一致的可能性和不一致状态的时间
同样采取刚刚的分析框架
读写并发
这么一看,好像没啥问题,此时仅仅只有读写并发确实没有问题,等会结合「第2步失败」一起看
写并发
最终导致缓存中的值是3,数据库中的值是2
第2步失败
更新缓存成功,更新数据库失败,此时请求同步返回失败。
读写并发
最终的值是一致的,但是步骤2中读到的值与当时数据库中的值不一致
写并发
导致了不一致
第2步失败
更新数据库成功,更新缓存失败,假设请求返回失败
假设请求返回成功
从「并发」的角度:两种顺序都会导致不一致,且可能性是类似的(因为都是两步写操作),不一致的时间取决于缓存的过期时间
从「第2步失败」的角度,相对于读到旧值,读到不存在的值更不可接受,因此从这点来看「先更新数据库,后更新缓存」的方案更好一些
从尽可能保证缓存和数据库一致性的角度,选删除好一些。这也是业界比较推荐的一种方式,被称为Cache-Aside(旁路缓存)。流程如下:
除此之外还需要考虑的点是:当缓存的值需要经过一系列的计算得到时,删除也比更新合适。删除使得缓存类似于一种懒加载的模式,有请求才会去构建缓存,可以节省计算资源
但是笔者有了解到,某些大型互联网电商也有采用写请求时更新缓存的方式 。其给出的理由是:写时删除缓存,会导致C端读请求的集中回源(比如秒杀场景)会对DB造成很大的压力。值得一提的是,它们的方案中写时更新缓存是异步的,并且通过一些防抖设计减少了更新次数以降低缓存侧的写压力
其实这也道出了删除缓存和更新缓存一个很大的区别:更新缓存可以最大程度的保证读请求能Hit cache,提高缓存命中率;而删除缓存实际上是依靠回源DB来保持数据的新鲜程度的。因此在一些特定场景下,如果回源DB的请求都足以打垮数据库时,是可以考虑使用更新缓存的方式的
另一方面,删除缓存的方案在回源DB的场景下是可以做一些优化,以降低数据库的压力。比如golang中有Singleflight,可以在单机层面减少回源的请求(比如原本有100个请求同一行数据的请求,Singleflight会拦截后99个)
接下来会介绍四种缓存的读、写模式,分别对应读、写请求的策略。按读、写区分,理论上是可以两两组合
意为读穿透模式,它的流程和Cache-Aside中的读流程类似,不同点在于Read-through多了一个访问控制层,如下图
优点是:上游只和访问控制层交互,并不关心下游是否有缓存以及是什么缓存策略,上游的业务层会更加简洁;同时对缓存层和持久化层交互的封装程度更高,更便于移植
该模式适合的场景是:read-heavy
当然这种方式会存在不一致的问题,在下面写模式中会有相应的策略
意为直写模式,如图:
注意这里与 Cache-Aside 模式不同的是:
这种方式的优缺点在上面已经分析过了。该模式适合的场景是:写操作较多且对一致性要求比较高的场景。理论上 Read-Through 和Write-Though组合可以获得不错的缓存利用率和实时一致性,据说亚马逊的 DynamoDB Accelerator 就是采用了这两种模式
如果对一致性的要求较弱,可以选择在Cache-Aside读链路中增加缓存的过期时间,在写链路中仅仅更新数据库,不做任何的删除或更新缓存的操作。这其实就是第一部分中的方案二。这种方案实现简单,但缓存中的数据和数据库数据一致性较差
意为异步回写模式,它具有类似Write-Through的访问控制层,不同的是,该模式下的写链路,只更新缓存而不更新数据库,对于数据库的更新,则是通过批量异步更新的方式进行的,并且可以通过上面提到的防抖设计聚合更新请求,以减少对DB的实际写访问
该模式下,写请求延迟较低,具有较好的系统吞吐。但缺点也很明显:
因此该模式比较适合瞬时写操作的场景,比如电商领域的秒杀场景
在笔者的工作中,一开始采用的方案是第一部分的方案二,即设置缓存时间,后续采用的是Cache-Aside的方案,并对回源请求引入了SingleFlight以保护DB
developer.baidu.com/article/det…
codeahoy.com/2017/08/11/…
**最后**
- 如果觉得有收获,三连支持下;
- 文章若有错误,欢迎评论留言指出,也欢迎转载,转载请注明出处;
- 个人vx:Echo-Ataraxia, 交流技术、面试、学习资料、帮助一线互联网大厂内推等
- 个人博客建设中:https://blog.echo-ataraxia.icu/
复制代码
页面更新:2024-05-05
本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828
© CopyRight 2008-2024 All Rights Reserved. Powered By bs178.com 闽ICP备11008920号-3
闽公网安备35020302034844号