缘起:为何使用缓存
在应用对外提供服务时,其稳定性受到诸多因素影响,其中比较重要的有CPU、内存、IO(磁盘IO、网络IO)等,这些硬件资源十分宝贵,因此对于那些需要经过复杂计算才能得到结果的,或者需要频繁读取磁盘数据的,最好将结果缓存起来,避免资源的重复消耗。
CPU瓶颈
如果项目中有很多正则表达式计算,或者某个计算结果是多次中间结果合并后才得出的,且CPU的使用率一直居高不下,那么就可以考虑是否应该将这些结果缓存起来,根据特定Key直接获取Value结果,减少中间链路的传递过程,减少CPU的使用率。
IO瓶颈
众所周知,从磁盘获取数据受到磁盘转速、寻道速度、磁盘缓冲区大小等诸多因素影响,这些因素决定了磁盘的IOPS,同时我们也知道对于数据的读写来说,CPU的缓存读写速度> 内存的读写速度>磁盘的读写速度。虽然磁盘内部也配备了缓存以匹配内存的读写速度,但其容量毕竟是有限的,那么当磁盘的IOPS无法进一步提升的时候,便会想到将数据缓存到内存中,从而降低磁盘的访问压力。这一策略常被应用于缓解DB数据库的数据访问压力。
选择本地缓存和分布式缓存的考量点
既然可以使用缓存来提升系统吞吐能力,那么紧接着遇到的问题就是选择本地缓存,还是分布式缓存?什么时候需要使用多级缓存呢?接下来,让我们聊一聊在使用缓存优化项目的过程中,本地缓存和分布式缓存的应用场景和优缺点。
本地缓存的优缺点和应用场景
统一进程带来了以下优势:
- 由于本地缓存和应用在同一个进程中,因而其稳定性很高,达到了和应用同生共死的境界;
- 由于在同一进程中,避免了网络数据传输带来的消耗,所有缓存数据直接从进程所在的内存区域获取即可。
强耦合性也会导致以下这些劣势:
- 本地缓存和应用共享一片内存,争抢内存资源,无法水平扩展,且可能造成频繁的GC,影响线上应用的稳定性。
- 由于没有持久化机制,在项目重启后缓存内数据就会丢失,对于高频访问数据,需要对数据进行预热操作。
- 多份进程内缓存存储着同样的数据内容,造成内存使用浪费。
- 同样的数据存储在不同的本地机器,数据变化后,很难保证数据的一致性。
结合以上优缺点,我们就会想到,如果有一种数据需要频繁访问,但一旦创建后就轻易不会改变,而且初始创建时就能预估占用的内存空间,那么这种类型的数据无疑是最适合用本地缓存存储了。
既然有了上述的应用场景,反观技术开发中的诉求,发现其实很多优秀的框架已经在这样使用了,比如缓存类class的反射信息,包括field、method等。因为class的数量是有限的,且内容不会轻易改变,在使用时无需再使用反射机制,而只需要从本地缓存读取数据即可。
分布式缓存的优缺点和应用场景
优势:
- 数据集中存储,消除冗余数据,解决整体内存的占用率,易于维护集群建缓存数据的一致性。
- 缓存中间件可以对缓存进行统一管理,便于水平扩容。
劣势:
- 依赖分布式缓存中间件稳定性,一旦挂了,容易造成缓存雪崩;
- 由于是跨机器获取缓存数据,因此会造成数据传输的网络消耗,以及一些序列化/反序列化的时间开销。
对于上述缺点中,网络耗时等开销是难免的,而且这些操作耗费的时间在可接受范围内,而对于中间件的稳定性则可以通过服务降级、限流或者多级缓存思路来保证。主要看中的是它的优点,既然分布式缓存天然能保证缓存一致性,那么我们倾向于将需要频繁访问却又经常变化的数据存放于此。
缓存框架使用过程的注意点
不论是本地缓存还是分布式缓存,在使用缓存提升性能的时候,必然会考虑缓存命中率的高低,考虑缓存数据的更新和删除策略,考虑数据一致性如何维护,本小节主要针对以上的问题来分析不同实现方案的优缺点。
缓存命中率
缓存命中率不仅是系统性能的一个侧面指标,也是优化缓存使用方案的一个重要依据。缓存命中率=请求命中数/请求总数。接下来的若干缓存使用策略所围绕的核心考量点就是在保证系统稳定性的同时,旨在提升缓存命中率。
缓存更新策略
主动请求DB数据,更新缓存
通过在集群中的每台机器都部署一套定时任务,每隔一段时间就主动向数据库DB请求最新数据,然后更新缓存。这样做的好处是可以避免缓存击穿的风险,在缓存失效前就主动请求加载DB数据,完成缓存数据更新的无缝连接。
但这样做也增加了机器的CPU和内存的占用率,因为即使有若干Key的缓存始终不被访问,可还是会被主动加载加载到内存中。也就是说,提高了业务抗风险能力,但对CPU和内存资源并不友好。
详情可参见下图,分布式缓存中存储着DB中的数据,每隔4.9s就会有定时任务执行去更新缓存,而缓存数据失效时间为5s,从而保证缓存中的数据永远存在,避免缓存击穿的风险。但对于Web请求来说,只会访问k1的缓存数据,也即对于k2和k3数据来说,是无效缓存。
被动请求DB数据,更新缓存
当有请求到达且发现缓存没数据时,就向DB请求最新数据并更新缓存。这种方案完全可以看做是方案一的互斥方案,它解决的是机器CPU和内存浪费的问题,内存中存储的数据始终是有用的,但却无法避免缓存失效的瞬间又突然流量峰值带来的缓存击穿问题,在业务上会有一定的风险。
详情见下图,缓存不会主动加载数据,而是根据Web请求懒加载数据。对于请求k1数据来说,发现缓存没有对应数据,到DB查询,然后放入Cache,这是常规流程;但如果有突发流量,大量请求同时访问k2数据,但Cache中没有数据时,请求就会同时落到DB上,可能压垮数据库。
缓存过期策略
依赖时间的过期策略
- 定时删除
对于需要删除的每个Key都配备一个定时器,元素超时时间一到就删除元素,释放元素占用的内存,同时释放定时器自身资源。其优点是元素的删除很及时,但缺点也很明显,比如为每个Key配备定时器肯定会消耗CPU和内存资源,严重影响性能。这种策略只适合在小数据量且对过期时间又严格要求的场景能使用,一般生产环境都不会使用。
- 惰性删除
元素过期后并不会立马删除,而是等到该元素的下一次操作(如:访问、更新等)才会判断是否过期,执行过期删除操作。这样的好处是节约CPU资源,因为只有当元素真的过期了,才会将其删除,而不用单独管理元素的生命周期。但其对内存不友好,因为如果若干已经过期的元素一直不被访问的话,那就会一直占用内存,造成内存泄漏。
- 定期删除
以上两种元素删除策略各有优缺点,无非是对CPU友好,还是对内存友好。为了结合两者的优点,一方面减少了元素定时器的配备,只使用一个定时器来统一扫描过期元素;另一方面加速了判断元素过期的时间间隔,不是被动等待检测过期,而是间隔一段时间就主动执行元素过期检测任务。正是由于以上的改进点,此方案是元素过期检测的惯常手段。
我们假设一个场景,为了保护用户隐私,通常在用户电话和商家电话之间,会使用一个虚拟电话作为沟通的桥梁。业务使用中,往往同一个虚拟号码在一定时间内是可以对相同的用户和商家建立连接的,而当超出这个时间后,这个虚拟号码就不再维护映射关系了。
虚拟电话号码的资源是有限的,自然会想到创建一个虚拟号码资源池,管理虚拟号码的创建和释放。比如规定一个虚拟号码维持的关系每次能使用15分钟,那么过期后要释放虚拟号码,我们有什么方案呢?
A. 方案一:全量数据扫描,依次遍历判断过期时间
对于DB中存储的以上内容,每天记录都存储着虚拟号码的创建时间,以及经过expire_seconds就会删除此记录。那么需要配备一个定时任务扫描表中的所有记录,再判断current_time - create_time >expire_seconds,才会删除记录。
如果数据量很大的情况,就会导致数据删除延迟时间很长,这并不是可取的方案。那是否有方案能直接获取到需要过期的vr_phone,然后批量过期来解决上述痛点呢?来看看方案二吧。
B.方案二:存储绝对过期时间+BTree索引,批量获取过期的vr_phone列表
将相对过期时间expire_seconds改为记录过期的时间戳expire_timestamp,同时将其添加BTree索引提高检索效率。仍然使用一个定时器,在获取待删除vr_phone列表时只需要select vr_phone from table where now()>=expire_timestamp即可。
对于空间复杂度增加了一个BTree数据结构,而基于BTree来考虑时间复杂度的话,对于元素的新增、修改、删除、查询的平均时间复杂度都是O(logN)。