分布式系统之缓存的微观应用经验谈(四) 【交互场景篇】
前言
近几个月一直在忙些琐事,几乎年后都没怎么闲过。忙忙碌碌中就进入了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中的一些细节)。
二、谈谈缓存的穿透雪崩等相关问题