这些年,使用缓存踩过的坑
缓存技术作为提升系统性能的关键技术,能很好解决高并发系统高 QPS、低 RT 的性能诉求。如何合理使用这一技术,是系统设计研发人员经常思考的问题。
缓存普遍应用在众多的产品之中,从简单的使用分布式缓存,到后面演进使用多级缓存。根据不同场景应用需求而不断调整架构,为性能提升提供了强有力的支撑,但是也因此使得业务系统架构日趋复杂,导致系统在稳定性方面也面临了一些挑战。
本文结合这些年在缓存使用过程中的一些经验教训,重点着力于常用场景和常见问题,以及对应的处理思路,前人之坑,后事之桥。
分享线索:为什么要使用缓存 -> 哪些场景适用,能带来什么好处 -> 坑人呐,怎么办 -> 例行总结,语重心长。
缓存引入
架构设计很重要的一部分内容便是如何满足业务的性能诉求,在性能优化上,利用缓存的案例非常多,其本质都是为了弥补内存高读写与磁盘慢读写之间的鸿沟。
一个系统的长期建设,所采用的架构肯定不是一成不变的,会随着业务不断变化而进行对应调整,以下便是在系统建设的不同阶段逐步引入不同级别缓存的过程。
1.0 时代,业务量小,应用直接通过数据库进行数据读写。
2.0 时代,业务量有了一定的增长,数据库出现性能瓶颈,使用分布式缓存进行热点数据的访问加速。
3.0 时代,业务量开始暴增,更高频的热点数据访问,因为网络 io、序列化操作等带来了性能压力,采用本地缓存再次进行加速,减少网络请求同时还省去了序列化的开销。
目前分布式缓存、本地缓存相关技术栈都非常多,分布式缓存成熟的有 redis、memcached 等,目前使用最广泛的还是 redis,本地缓存常用的有 ConcurrentHashMap、Guava、caffeine、ehcache、spring cache 等,其中 spring cache 因为整合简单,支持缓存组件广,使用方便,使用越来越广泛。
从具体缓存实现来看,绝大部分情况都是采用旁路缓存,通过应用程序更新缓存,缓存组件不直接操作数据源。
常用场景
会话共享
分布式架构情况下,多个微服务节点,必须通过会话共享,才能保证一次登录,在分发请求后每个服务节点的登录状态一致,所以引入分布式缓存进行会话共享。
数据访问加速
分布式缓存
在高并发访问数据库读多写少时,为了抗住频繁查询而对数据库造成压力,将数据缓存起来,当有请求过来,直接返回数据。
同时也可以将一些过程数据的频繁变更基于缓存执行,在过程完成以后再持久化到数据库,能有效降低数据库访问压力,并能带来数据操作性能的提升。
典型应用场景:购物车、订单过程数据等。
本地缓存
本地缓存规避了网络及序列化开销,本地缓存使用 JVM 内存和直接内存。在容器化部署情况下,内存 limit 的设置,使缓存空间有限,所以不能进行大数据量的存储,比较适合热点只读数据。
典型应用场景:字典数据、热卖产销品。
计数
业务应用上经常要控制在一定时间内对某个数据对象的操作次数,在分布式应用情况下,对同一数据对象计数很容易产生重复计算,数量失控的情况,而使用分布式计数器功能特性可以很好规避。
典型应用场景:防止刷单、限制登录次数、活动限额等。
分布式锁
本地锁只能锁住当前进程,已经无法满足当前的系统设计需求。分布式锁支撑同时去一个地方“锁占”,如果占到,就执行逻辑。否则就必须等待,直到释放锁,等待可以自旋的方式。
典型应用场景:在购物车或者提交订单情况下,系统如何防止重复提交。
常见问题
1 缓存一致性问题
缓存的一致性就是指缓存中的数据是否和目标存储中的数据是一样的,也就是说缓存中已经修改的数据是否已经保存到了物理存储中,物理存储中已经被修的内容,是否与缓存的内容是一样的。在多级缓存情况下,物理存储与多级缓存之间的内容也需保持一样。
案例:
项目上经常听见这类声音,缓存数据与数据库数据不一致,缓存刷不成功,部分节点数据不一致等等,案例非常多,比如:
某集团项目经常出现销售品属性、产品属性缓存与数据库不一致的问题,最终确定是因为刷新缓存读取的数据源是外部接口,外部接口偶尔失败,导致取到结果为空,将空对象写入了缓存中。
某省份项目经常出现通过清空缓存刷新时,一部分节点没有执行成功,后面定位到是本地缓存刷新线程一定概率发生异常终止导致,程序没有捕获异常,导致清空失败,没有加载到最新数据。
某项目使用 zk 广播的方式刷新本地缓存,由于应用 FGC 很频繁,刷新缓存时部分节点一定概率出现 FGC,导致 zk 通知失败,没有进行结果处理并重试,造成节点间本地缓存不一致。
解决方案:
旁路缓存模式(Cache Aside Pattern)问题分析问题前,我们先了解下该模式。
写(Write)
读(Read)
以上模式基本可以解决绝大部分场景的使用情况,但是在更新缓存时,因为数据库操作效率肯定比缓存操作效率慢,比如更新数据的查询语句性能较差,或者并发情况下出现 A 线程获取数据后写入缓存,B 线程同时在更新数据并删除缓存,若 B 线程完成时间早于 A 线程,那么最终缓存将会是 A 线程读取的旧数据。这种情况下,为了保证强一致性,可以采用延迟双删,删除缓存线程执行完以后,再增加一个删除命令,等待一定时间进行二次删除。这样会增加复杂度,具体要看业务容忍度。
本地缓存与分布式缓存不一致问题
分布式缓存一般只有一个数据源,所以一致性容易保证,但是本地缓存分散到各个应用节点中,在更新分布式缓存同时,如何保证所有应用节点本地缓存都能更新,一般有如下方案:
① zk 广播通知,事务执行节点在刷新分布式缓存以后,发起一条通知通过 zk 广播通知的方式,通知给所有消费节点,消费节点收到消息以后,执行对应逻辑刷新本地缓存。该方式存在一定缺陷,如果期间服务异常,或者服务进程在做 Crash,可能收到通知后处理失败,失败后没有重试机制。
② 消息发布/订阅,redis 具备消息发布/订阅能力,事务执行节点写入一条消息,各应用节点进行订阅消费,接收消息后进行刷新逻辑执行,redis 消息满足了绝大部分场景,但是如果为了提高稳定性可以采用消息中间件代替。
更新缓存操作事务一致性问题
更新缓存时,业务系统可能存在数据库更新操作,数据库更新成功后,更新缓存可能存在外部接口调用获取缓存更新依赖的数据,但是外部接口一旦失败,事物上缺少控制,就会导致数据库更新成功,缓存更新失败的情况。这种情况需要业务系统对事物中的所有异常进行捕获,规避不一致的风险。
2 缓存穿透问题
缓存穿透指查询一个一定不存在的数据,由于缓存没命中,将去查数据库,但是数据库也无此记录,这将导致每次请求都打到了数据库,失去了缓存的意义。缓存穿透可能会使数据库负载加大,由于数据库在高并发下性能较差,甚至可能造成数据库宕机,该场景在项目上经常碰见。
案例:
某省份项目由于订单处理时,从缓存中没有读取到规则配置数据,执行了从数据库加载全量配置,数据库中也没有对应配置数据,导致每次请求都会全量加载一遍规则配置数据,严重影响了订单处理性能,对数据库性能也产生了较大影响。
解决方案:
通常可以在程序中统计总调用数、缓存层命中数、如果同一个 Key 的缓存命中率很低,可能就是出现了缓存穿透问题。
一般可以通过如下方案解决:
设置 NullObject,访问数据库 miss 时,设置一个空对象到缓存中,防止下次继续请求数据库。该方法实现简单,但也存在一定的缺陷,需要业务侧自行评估。一是,如果 miss 数据很多,大量 key 写入缓存,会占用内存空间。二是,数据一致性问题,如果 NullObject 设置以后,数据库新增了数据,无法自动更新到缓存,需要业务侧额外实现逻辑进行更新。
布隆过滤器,其实就是在访问缓存层和数据库之前,将存在的 key 用布隆过滤器提前保存起来,做第一层拦截(当收到一个对 key 请求时先用布隆过滤器验证是 key 否存在,如果存在再进入缓存层、存储层)。布隆过滤器优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。
3 缓存雪崩问题
缓存充当了数据库访问的保护层,防止数据库访问压力过大而宕机,但是如果缓存出现宕机,或大批量同时失效,大量请求打到数据库,导致数据库负荷突然拉高,压力过大而导致雪崩。(该问题在项目上出现的概率也不低,一般是缓存时效时间设置不合理导致。)
案例:
某省份项目由于楼层数据缓存采用固定有效期,一次版本升级以后,缓存数据全量做了一次更新,在失效期到了以后,所有缓存失效,页面请求同一时间点全部打到数据库加载缓存数据,导致数据库压力瞬间过大,影响整个系统性能响应。
解决方案:
针对缓存雪崩,通过如下方案可规避:
缓存服务搭建采用高可用模式,防止单节点宕机导致整个服务受影响。
采用多级缓存,本地进程作为一级缓存,redis 作为二级缓存,不同级别的缓存设置的超时时间不同,即使某级缓存过期了,也有其他级别缓存兜底。
缓存的过期时间使用固定值+随机值,尽量让不同的 key 的过期时间不同。
4 序列化问题
序列化主要的用处就是在传递和保存对象的时候,保证对象的完整性和可传递性。序列化是把对象转换成有序字节流,以便在网络上传输。反序列化便是根据网络传输字节流中所保存的对象状态及描述信息,通过反序列化重建对象。
在保存对象和重建对象过程中,不同序列化工具对描述信息差异容忍度不一致、性能不一致,容易出现问题。
案例:
某产品缓存序列化工具采用 kryo,某次版本修改了对象属性,版本发布时忘记清理已有缓存,导致存量数据在反序列化时全部报错,造成生产故障。
某门户产品,由于会话用户信息使用 kryo 存储,多系统共用会话缓存,某次门户升级增加了用户属性,未及时通知下游系统升级,导致升级后下游系统出现用户信息反序列失败的故障。
解决方案:
分布式缓存访问的,涉及实体对象,必须通过序列、反序列化来进行存取,实体对象一般映射数据库模型,在业务需求变更时,模型字段发生了变化,对于已经写入的缓存,部分序列化工具会存在反序列失败的问题。同时不同序列化工具在性能上面也存在一定差异,业务侧根据各自情况进行选择使用。
5 热 key 问题
所谓热 key 问题就是,突然有几十万的请求去访问 redis 上的某个特定 key。那么,这样会造成流量过于集中,达到物理网卡上限,从而导致这台 redis 的服务器宕机。
案例:
某项目一个静态配置数据放在了 redis 缓存,业务规则处理中存在大循环调用,一次业务处理重复获取了几百次静态数据,业务量大时导致该 key 的访问非常频繁。
解决方案:
将每次业务访问的 key 进行拆分,避免总是访问同一个 key。
对需要频繁访问的 key 进行本地缓存,本地缓存数据可以通过定时策略进行更新。
优化业务处理逻辑,减少无效交互访问:例如一个服务里面有 N 次访问某个配置的逻辑,那么在服务逻辑开始时从缓存里面取一次配置就好,避免单一服务大量重复缓存交互。
6 分布式锁死锁问题
在分布式高并发的条件下,如果有个线程获得锁的同时,还没有来得及去释放锁,就因为系统故障或者其它原因使它无法执行释放锁的命令,导致其它线程都无法获得锁,造成死锁。
案例:
某采购项目,为了避免订单、购物车重复提交,在服务入口处,使用 SETNX 获取分布式锁,在服务出口进行分布式锁解锁操作,由于没有考虑执行异常的情况,异常后没有执行解锁,导致锁一直无法释放。
解决方案:
项目上使用分布式锁经常出现死锁,或者锁失效的情况,基本都是在使用原理及业务场景匹配上存在不清晰所导致,一把稳定的分布式锁一般具有如下特征:
互斥性, 任意时刻,只有一个客户端能持有锁。
锁超时释放,持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。
可重入性,一个线程如果获取了锁之后,可以再次对其请求加锁。
高性能和高可用,加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。
安全性,锁只能被持有的客户端删除,不能被其他客户端删除。
从实现上,一般有如下几种方案:
SETNX + EXPIRE,SETNX 获取锁以后,再使用 EXPIRE 进行过期时间设置,防止客户端崩溃后,锁无法释放,但是这样存在问题,SETNX + EXPIRE 并非原子操作,如果发送 EXPIRE 时正好应用 Crash,一样会导致死锁。
使用 Lua 脚本(包含 SETNX + EXPIRE 两条指令),通过 Lua 脚本执行,保证了两条指令的原子性。但还是存在一定风险,比如 A 线程锁到期释放了,但是业务逻辑还没执行完,导致 B 线程又重新获取了锁,最后 B 线程把 A 线程的锁给删除。
使用 Lua 脚本(SET EX PX NX + 校验唯一随机值,再释放锁),采用 set 命令,结合扩展参数,同时通过唯一随机值校验,解决了锁被误删的情况,但是这样还是解决不了锁过期释放而业务没有执行完的问题。
开源框架 Redisson,通过开启守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。但是该方案在集群模式下,会存在同步延时的问题。
实现的分布式锁 Redlock,由 Redis 作者 antirez 提出一种高级的分布式锁算法,按顺序向多个 master 节点获取锁,按一定比例成功率进行计算。
总结
缓存使用场景及使用中可能遇到的问题,远不止上面列出来的内容,需要注意的点非常多,所以我们在引入时,或者用到其中的特性要从整体去看,了解相关原理、适用场景、注意事项,多方面规避风险,总结下来,在缓存使用过程中,应该要从以下方面进行考虑:
设计上确保稳定、安全、高性能
根据业务特性、体量等,合理选择缓存产品
根据缓存产品特性,结合业务对稳定性、性能等方面的要求,对部署架构进行规划评估
结合安全管控要求,在账号管理、网络、灾备方面进行安全设计
结合业务容忍度,在一致性、健壮性上进行分析考虑,同时要规避一些风险命令的使用
做好缓存数据生命周期的规划,不同业务数据设计合适的生命周期
做好 key、value 设计,易维护,合理选择数据类型
研发上注意关键配置,熟悉产品特性
注意缓存连接池的管理,根据业务指标评估生产所需的最大连接数、空闲连接数等
熟悉每个缓存产品的研发指导,熟悉相关 API、命令及适用场景
运维上确保快速响应、勤总结
根据不同的缓存产品,选择合适的监控工具,熟悉其监控工具
熟悉缓存产品相关监控指标
勤于总结相关运维问题,沉淀知识库,不断提升运维质量及效
版权声明: 本文为 InfoQ 作者【鲸品堂】的原创文章。
原文链接:【http://xie.infoq.cn/article/01202d87f32edb8a80027399d】。文章转载请联系作者。
评论