分布式系统之缓存的微观应用经验谈(四) 【交互场景篇】
前言
近几个月一直在忙些琐事,几乎年后都没怎么闲过。忙忙碌碌中就进入了2018年的秋天了,不得不感叹时间总是如白驹过隙,也不知道收获了什么和失去了什么。最近稍微休息,买了两本与技术无关的书,其一是 Yann Martel 写的《The High Mountains of Portugal》(葡萄牙的高山),发现阅读此书是需要一些耐心的,对人生暗喻很深,也有足够的留白,有兴趣的朋友可以细品下。好了,下面回归正题,尝试写写工作中缓存技术相关的一些实战经验和思考。
正文
在分布式Web程序设计中,解决高并发以及内部解耦的关键技术离不开缓存和队列,而缓存角色类似计算机硬件中CPU的各级缓存。如今的业务规模稍大的互联网项目,即使在最初beta版的开发上,都会进行预留设计。但是在诸多应用场景里,也带来了某些高成本的技术问题,需要细致权衡。本系列主要围绕分布式系统中服务端缓存相关技术,也会结合朋友间的探讨提及自己的思考细节。文中若有不妥之处,恳请指正。
为了方便独立成文,原谅在内容排版上的一点点个人强迫症。
第四篇打算作为系列最后一篇,这里尝试谈谈缓存的一些并发交互场景,包括与数据库(特指 RDBMS)交互,和一些独立的高并发场景相关补充处理方案(若涉及具体应用同样将主要以Redis举例)。
另见:分布式系统之缓存的微观应用经验谈(三)(数据分片和集群篇)
(https://yq.aliyun.com/u/autumnbing)
(https://www.cnblogs.com/bsfz/)
一、简单谈下缓存和数据库的交互流程
为了便于后面的相关讨论,这里约定文中的数据库(Database)均指传统的 RDBMS,使用DB标识,同时需区别于缓存(Cache)里的DB划分空间。
我在早前一篇缓存设计细节的文章里,有阐述关于 Cache 自身 CURD 时的一些具体细节,而这里将结合DB,就 DB 和 Cache 之间的并行 CURD 操作进行一些讨论。当然,这里面在交互层面上是一定会涉及到分布式事务(Distributed Transaction)相关的一致性话题,但为了避免表述出现模糊和不必要的边界放大,这里我尽可能剥离开来,专注在基于 Cache 的处理上。
预先抽象这样一个基础场景:DB中存在一张资金关联表(FT),这里 FT 里存储的都是热点条目(属于极高频访问数据),在系统设计时,FT里的数据将与对应的 Cache 服务 C1 进行关联存储(这里仅指一级缓存),以达到提升一定的并发查询性能。
1.1 向 FT 中新增(Create)一条数据
通过 SQL 向 FT中插入一条数据:如果插入失败,则不需要对 C1有任何操作;如果插入成功,则此时需要判断,考虑是否在 C1中同步插入。
这种情景一般比较简单,如果没有特别的情况,此刻不需对 C1 做主动插入,而是后续被动插入(后面会提到)。但是如果插入 FT 中的数据往后操作只有删除这个动作,并且 FT的数据经常被批量操作,那么个人建议同步执行对 C1的插入操作。
(PS:这里也顺便申明下,如果需要往C1插入,但插入失败,请根据业务场景加入重试机制,后面对Cache的操作均包含这个潜在的动作。至于重试处理失败的情况,如往C1插入一条数据,个人建议是不再过度处理,最终默认是整体操作成功,并进行对应状态返回。这里注意不要与分布式事务的一致性进行混合类比,后面不再赘述。)
1.2 准备更新(Update)一条数据
当需要更新 FT 中的一条数据时,意味着之前 C1 中的数据已经无效,而在一个高并发环境中这里无法做到统一的直接更新 C1。首先就需要考虑的是 C1 的数据是主动更新还是被动更新,主动更新即更新完 FT后,同时将数据覆盖进 C1,而被动更新指的是更新完 FT 后,立即淘汰 C1 中的数据,并等待下次查询时重新写入C1。
只要上述请求动作出现了任何并发,比如两个相同动作,动作1和动作2同时发生请求,那么会出现一个不一致的问题:动作1先操作 FT,动作2后操作 FT,然后动作2先操作了C1,动作1后操作了C1。
这样存在不止一个线程并发的更新 FT 数据时,无法确认更新 FT 的顺序和最终更新 C1 的顺序是否保持一致,结果是一定会出现大量 FT 和 C1 中数据出现幻读,而这个在存在主从Cache的情况下这种概率会大大提升(可参见上一章主从复制的部分)。推荐的方式是,如果不考虑Cache 多次需要重写的损耗,在没有其他特殊要求下,可以直接淘汰 C1 中的数据,也额外照顾到了Cache在合适的时候完全命中(Hit)。
其实到这里还没结束,当决定是淘汰 C1 的数据,那么就要选择一个淘汰时机:一种是先更新 FT,然后对C1 执行淘汰;一种则是,先对 C1 执行淘汰,然后才更新FT。
虽然两种方式都有合适的场景,但这里需要权衡一种概率性问题:当对C1执行淘汰时,又并发了一个对C1的查询操作,此时,C1会从DB拉取数据重新写入,那么C1中即为脏数据,当并发越大,存在数据一直“脏”下去的概率更大。所以,这里更推荐的做法是选择前者。
(注意,这里还有一些去讨论的细节并不打算在此话题延伸,比如关于 C1和FT之间的原子性问题,是否可以采用二阶段/三阶段提交等模拟事务方式和对业务造成的影响。)
1.3 开始读取(Read)一条数据
这里就没有太多特别,毕竟应用Cache 的目的就已经说明了读取数据时,只需要遵循“先读Cache再读DB”。即先从C1里拿取数据,如果C1里不存在该数据,则从FT中搜索,搜索完成如果依然不存在该数据,则直接返回Empty状态。如果存在,则同时将该数据保存进C1中,并返回对应状态。
顺带提一下,可能有人会说,在某些场景下,即使 C1中有数据,也要先从 FT里优先获取。我赞同,没错,但注意这里不要混淆讨论的主题了,这本质是属于基于一种业务结果的导向,就类似在传统 RDBMS 读写分离情况下,在关键数据的验证处,直接请求主库获取并操作。所以上面说的其实并没有矛盾,我们讨论时要明确清晰,不要混淆。
1.4 从FT 中删除(Delete)一条数据
与Create相反的操作,通过 SQL 向 FT中移除一条数据:如果移除失败,则不需要对 C1 有任何操作,如删除成功,则将对应C1中数据移除(另外请类比1.2中的一些细节)。
二、谈谈缓存的穿透雪崩等相关问题
在项目发展到后期,一些业务场景整体都处于高并发状态,大量QPS对整体业务的负载要求很高,为了避免很多时候脱离架构优化的初衷,还需要在项目中做到很多预先性的规避和细节把控。
2.1 优化防止缓存击穿
当请求发来的查询 Key 在 Cache 中存在,但某一时刻数据过期了,并且此时出现了大量并发请求,那么这里因为 Cache 中 Miss,就会统一去 DB 中搜索,直接造成在很短的时间内,DB 的 QPS 压力会陡增。
对于这种问题的预防和优化,往往从两方面入手:一是程序中加小粒度的锁/信号(去年有写过一篇关于商城系统里库存并发管控杂记,里面有具体话题的细节扩展,详见:https://www.cnblogs.com/bsfz/ );二是将 DB的读取延迟 和 Cache的写入时间尽可能拉到最低;三是对其中过于热点的数据采取一个较大的过期时间并做一定的随机性(这里非必要,可自行权衡)。其实还有一点,少数情况下,可根据场景是否限制,可以增加适当的到期自动刷新的策略,这里也可以考虑在程序中开启固定的线程通知维护。
2.2 预防大量缓存穿透
当请求发来的查询 Key 在 Cache 中 Miss,自然就会去 DB 里搜索,这里本身没问题,但是假如查询的 Key 在 DB 中也不存在,那么意味着每次请求实际上都是实打实落在了 DB 上。这种问题比较常见,并且即使并发不是很大的时候 DB 的连接数也轻松达到上限,而且本身也不符合我们设计为了提高QPS的初衷。
对于这种漏洞性问题的解决方式,同样可以从两方面入手:一是程序可以在第一次从DB搜索数据为 NULL 的时候,直接将 NULL 或者一个标识符 Sign 缓存起来,同时个人建议尽量设置一个小范围的随机过期时间,避免不必要的长期内存占用;二是程序里限制过滤一些不可能存在的数据KEY,如借鉴 Bloom filter 思想,特别是在前端请求到后端的这里,尽量进行一次中间判断处理(如有时对不合法KEY直接返回NULL)。
2.3 控制缓存雪崩
这里会有某些细节和上面类似,但不完全。当Cache出现不可用,再或者大量数据同一场景里同一时刻失效,批量请求直接访问DB,并且此刻也等同于没有任何Cache措施了。
为了规避这种偏极端的问题,主要可以考虑从三个方面入手:一是增加完善Cache 的高可用机制,并最好有单独的运维监控预警;二是类似上面针对Cache的时间再次作随机,特别是包含预热和批量的场景里。(ps:你看很多地方都有类似设计来降低一定概率,个人在设计时,即使是项目初期阶段的简化版本里也会包含进去。);三是,在部分场景增加多级Cache,但是在很多时候会增加其他的问题(如多级之前的同步问题),所以个人推荐优先增加到二级即可,然后稍微调整下时间尽量不高于一级Cache。
结语
由于个人能力和经验均有限,自己也在持续学习和实践,文中若有不妥之处,恳请指正。
本系列告一段落,正好也要去忙一些事情,暂时可能不写相关的东西了。
个人目前备用地址:
社区1:https://yq.aliyun.com/u/autumnbing
社区2:https://www.cnblogs.com/bsfz/
End.
原文地址:https://www.cnblogs.com/bsfz/p/9867952.html