Hello 大家好,今天跟大家聊的一个话题就是:缓存
目前,面向C端的服务架构中,除开管理后台等访问量很少、实时性要求较高的服务可不使用缓存外,缓存已成为高性能分布式系统里不可或缺的一环。
本文不打算过多涉及具体的缓存组件如Memcached,Redis等,将缓存做一个整体的概念来展开,至于具体的缓存组件及各自的优缺点、工作原理,则是更细一个维度的问题了。
缓存的本质
缓存的目的其实说白了就一点:加速访问。稍微细分一下可分为加速数据访问和加速计算:前者是将热点数据暂存在访问速度更高的硬件中,例如内存的访问速度比机械硬盘的速度高上几个数量级,那么就可以将内存作为硬盘的缓存介质。而内存的速度相对CPU的计算速度又慢了许多,因此现在都会在CPU和内存间再设计几级速度更快的缓存,以加速CPU的数据访问。
而后者则是在假设某份数据并不是直接存储在数据库中,而是通过许多数据,通过复杂的业务运算,乃至加入外部服务的部分结果聚合计算而成。一则需要访问过多的数据,二来涉及到外部调用的网络开销,那么即使使用同样的存储介质,也可以达到加速访问的目的。
此外,缓存的存在是为了对应大量的查询请求,因此如果查询请求量不大,或者数据更新频繁导致缓存频繁失效,就需要斟酌是否要引入缓存。
一致性问题
设计架构的核心就是做取舍(trade off)。引入一个组件,就要考虑随之带来的问题。除开必然带来的额外维护成本外,缓存还带来了分布式系统的一个典型问题:一致性问题。
其实数据只要分布在多个节点,那么就不可能完全一致,多份数据达成一致总需要一定的同步时间。所谓强一致性,只不过达成数据一致的时间相对较短,而弱一致性则是这个时间相对较长。
而自缓存引入的那一天,就带来这样的一致性问题:如何保证数据库和缓存服务的数据一致性?为了解决这个问题,前辈们讨论总结了许多种数据更新策略。
缓存更新策略
下面我们对缓存的一些更新策略做一个归纳分析,需要说明的是:以下策略暂时不讨论更新失败、网络异常问题,这是所有更新策略都存在的问题,放在最后统一讨论。
先更新缓存,后更新数据库
这个策略带来问题显而易见,是个典型的并发问题,考虑如下场景:
假设原始数据为a = 1(竖向为时间轴)
线程 |
1 |
2 |
t1 |
修改缓存数据:a = 2 |
|
t2 |
|
修改缓存:a=3 |
t3 |
|
修改数据库:a=3 |
t4 |
修改数据库:a=2 |
|
经过上述场景,数据库和缓存内的数据就不一致了,有问题的场景还有很多:
线程 |
1 |
2 |
t1 |
|
读取缓存:数据不存在 |
t2 |
修改缓存数据:a=2 |
查询数据库:a=1 |
t3 |
修改数据库:a=2 |
|
t4 |
|
设置缓存:a=1 |
先更新数据库,后更新缓存的策略有同样的问题,不再赘述。而即使不考虑并发问题,这个方案也有一个小问题:数据虽然修改了,但是不一定马上就要读取,设置缓存这个操作是否有些浪费?
先删缓存,再更数据库
既然更新会有并发修改的问题,那我把更新缓存换成幂等的删除操作,问题是否就解决了?再考虑如下场景:
线程 |
1 |
2 |
t1 |
删除缓存 |
|
t2 |
|
读取缓存不存在; 查询数据库:a=1 |
t3 |
修改数据库:a=2 |
|
t4 |
|
设置缓存:a=1 |
可以看到,由于先删除了缓存,那么在数据库修改成功之前,只要有请求访问,就会在缓存里设置脏数据,因此这个方案缺点也比较明显。
先更新数据库,后删除缓存
这应该是目前使用得比较多个一个方案,成本低,缺点少,配合缓存过期时间,几乎没有并发问题。这个方案解决了并发修改时的一致性问题,数据库本身有锁支持并发修改,缓存操作由于是幂等的删除操作,多线程执行也不影响。
即使修改数据库前有线程读取到旧版本的数据,只要在删除缓存前写入的旧缓存,数据会被操作删除掉,配合缓存过期时间,几乎没有问题。之所以说“几乎没有问题”,是因为理论上还是存在下列场景,旧数据缓存在删除操作之后写入:
线程 |
1 |
2 |
t1 |
|
读取缓存:数据不存在 |
t2 |
|
查询数据库:a=1 |
t3 |
修改数据库:a=2 |
|
t4 |
删除缓存:a |
|
t5 |
|
设置缓存:a=1 |
但是这个场景出现的概率不高,因为缓存几乎都是基于内存的,写缓存比操作数据库更快,较难出现线程1修改完数据库及缓存,线程2才完成缓存设置的情况,即使少量出现这个情况,因为缓存一般都设置有有效时间,所以脏数据最多存活一个缓存生命周期后,就会被淘汰掉。
延时双删
延时双删是对上述先更后删方案的补充完善。其流程是:
-
删除缓存
-
更新数据库
-
休眠一段时间
-
再次删除缓存
其中步骤3就是进一步减少先更后删方案的并发访问问题出现的概率,但我个人不是很推崇这个方案,原因如下:
-
休眠实现不优雅,且不同业务具体的休眠时长还需要观察调试,取一个平衡值,有实施成本。
-
影响修改操作的吞吐量
-
只是进一步减少问题概率,仍不能确保解决问题
尤其是第三点,如果业务不能容忍上述不一致场景,对数据有较强一致性要求,就应该采用更可靠的方案来实现,本方案采用延时,只是进一步减少异常场景的概率,并不是避免。牺牲了吞吐量还不能保证完全解决问题,就显得有些食之无味。
因此大部分业务场景,其实采用先更后删的方案是比较理想的trade off,成本小,方案也不复杂,但我们还是应该探究一下,假设我们少量的不一致都无法接受,应该怎么设计方案策略?这就是接下来要讨论的分布式事务问题。
分布式事务
上述策略还未讨论的一个问题是,网络原因更新失败了又该如何处理?以先更后删为例,更新数据库失败自然可以直接响应失败,但删除缓存失败了要如何补救呢?
其实保证数据库、缓存两个节点的数据一致性,是一个最简单的分布式事务问题。分布式事务的解决方案有很多:2PC, 3PC, TCC, Seata等,读者有兴趣可以自行查阅相关资料。个人建议缓存的场景使用最终一致的解决方案即可。
最终一致可以使用消息中间件的事务消息,或者使用本地消息表来实现。
用事务消息的实现流程是:
-
先发送一个事务消息,此时消息处于prepare状态,消费者还拉取不到
-
更新数据库
-
发送该事务消息的commit信号
-
消费者订阅事务消息,执行缓存相关操作
如果1,2任何一步失败,则整个事务直接失败;如果3失败,消息队列会对prepare的消息定时执行业务回调,来向业务方确认该消息是应当提交还是回滚。如果4失败,可依赖消息系统执行消息重发。由此保证了数据的最终一致性。
但事务消息的方案也有相应的问题:事务消息性能不佳(相对普通消息),且只有部分消息中间件支持。是否值得引入特定的消息中间件解决这个问题,又是另一个需要考虑的问题。
本地消息表方案相对简单一些,在更新数据库时,在消息表里也插入一条要删除的缓存的数据,这个可以用数据库的事务特性来支持。 然后定时任务扫描消息表执行缓存的清除操作。
缓存服务常见的三个问题
除开一致性外,缓存还需要考虑其他维护问题,比较典型的缓存问题有三个:缓存雪崩、缓存穿透、缓存击穿。
缓存雪崩
缓存雪崩的场景是:大量缓存短时间内同时失效,请求好似雪崩一样涌向数据库。大部分使用缓存的场景,基于成本的考量,数据库都不会达到能单独支撑全量查询的性能,因此会造成数据库负载升高乃至宕机。
常规的解决方案也比较简单,设置缓存过期时间时,采用固定业务时间+随机几分钟的波动时间,减少数据同时过期的概率。
缓存穿透
所谓缓存穿透,是指访问业务上不存在的数据,由于缓存中没有数据,每次查询都打到数据库上。如果有大量的恶意穿透请求就会影响到数据库。常规解决方案有二:
-
设置短时间的空缓存。如果查询数据不存在,设置一个空数据到缓存内,有效时间可以设置短一些(例如一分钟),短时间读取到这个空内容时,就知道这个数据是不存在的,直接返回即可,不用再查数据库。但只能解决同样的key的多次访问,不能应对一直更换不同key的恶意请求。
-
使用布隆过滤器。
布隆过滤器思路是用多个hash函数,将一个数据映射到多个不同的hash槽上,查询数据时,检查这对应的几个槽位是否都有值,如果没有值则说明数据不存在,都有值则说明数据可能存在(因为hash冲突)。
使用布隆过滤器时,只要将全量的业务数据映射好,查询时,只有布隆过滤器显示可能存在的,才正常进入业务流程,否则直接返回数据不存在。但最初的布隆过滤器为了节约空间,采用bit数组存储,只能表示0或1,这就造成了布隆过滤器里数据只能新增,不能删除,要解决这个问题可以将bit数组扩展为数字数组(如byte, int),采用计数法记录,并增加数据删除的处理逻辑,当然空间占用会增多,读者有兴趣可自行查阅相关资料。
缓存击穿
缓存雪崩是大量key过期带来的问题,缓存击穿则是热点key过期,在一个请求成功加载数据到缓存的过程中,针对该key的请求大量打到数据库。
我个人了解到的解决思路也有两个:
-
使用锁限制访问数据库。同一个时间只有一个请求落到数据库。(这个场景下,个人认为单体锁和分布式锁区别不是很大)
-
主动加载热点数据到缓存。例如,一个热点数据,如果其过期时间是一个小时,那么定时任务每隔五十分钟就主动加载一下数据库的数据到缓存,直接避免热点数据过期的情况。
其他问题
其实笔者之前面试初中级应聘者的时候,缓存相关问题不喜欢问上述三个缓存八股文的问题,因为并不能真正考察应聘者的技术能力,只能说明应聘者查阅过相关知识点。真正缓存的问题绝不仅是上面几个典型的问题。
本系列缓存暂时分成两篇,这篇总结了缓存的一些基本概念及常见问题和解决方案,下一篇准备介绍一些进阶知识点,敬请期待。