前言:
本文主要介绍使用分布式缓存的优秀实践和线上案例。这些案例是笔者在多家互联网公司里积累并形成的优秀实践,能够帮助大家在生产实践中避免很多不必要的生产事故。
一、缓存设计的核心要素
我们在应用中决定使用缓存时,通常需要进行详细的设计,因为设计缓存架构看似简单,实则不然,里面蕴含了很多深奥的原理,如果使用不当,则会造成很多生产事故甚至是服务雪崩之类的严重问题。
1、容量规划
缓存内容的大小
缓存内容的数量
淘汰策略
缓存的数据结构
每秒的读峰值
每秒的写峰值
2、性能优化
线程模型
预热方法
缓存分片
冷热数据的比例
3、高可用
复制模型
失效转移
持久策略
缓存重建
4、缓存监控
缓存服务监控
缓存容量监控
缓存请求监控
缓存响应时间监控
5、注意事项
是否有可能发生缓存穿透
是否有大对象
是否使用缓存实现分布式锁
是否使用缓存支持的脚本(Lua)
是否避免了Race Condition
二、缓存设计的优秀实践
优秀实践1
缓存系统主要消耗的是服务器的内存,因此,在使用缓存时必须先对应用需要缓存的数据大小进行评估,包括缓存的数据结构、缓存大小、缓存数量、缓存的失效时间,然后根据业务情况自行推算在未来一定时间内的容量的使用情况,根据容量评估的结果来申请和分配缓存资源,否则会造成资源浪费或者缓存空间不够。
优秀实践2
建议将使用缓存的业务进行分离,核心业务和非核心业务使用不同的缓存实例,从物理上进行隔离,如果有条件,则请对每个业务使用单独的实例或者集群,以减小应用之间互相影响的可能性。笔者就经常听说有的公司应用了共享缓存,造成缓存数据被覆盖以及缓存数据错乱的线上事故。
优秀实践3
根据缓存实例提供的内存大小推算应用需要使用的缓存实例数量,一般在公司里会成立一个缓存管理的运维团队,这个团队会将缓存资源虚拟成多个相同内存大小的缓存实例。
例如一个实例有4GB内存,在应用申请时可以按需申请足够的实例数量来使用,对这样的应用需要进行分片,详情请参考《可伸缩服务架构:框架与中间件》中4.4.3的内容。这里需要注意,如果我们使用了RDB备份机制,每个实例使用4GB内存,则我们的系统需要大于8GB内存,因为RDB备份时使用了 copy-on-write 机制,需要fork出一个子进程,并且复制一份内存,因此需要双份的内存存储大小。
优秀实践4
缓存一般是用来加速数据库的读操作的,一般先访问缓存后访问数据库,所以缓存的超时时间的设置是很重要的。笔者曾经在一家互联网公司遇到过由于运维操作失误导致缓存超时设置得较长,从而拖垮服务的线程池,最终导致服务雪崩的情况。
优秀实践5
所有的缓存实例都需要添加监控,这是非常重要的,我们需要对慢查询、大对象、内存使用情况做可靠的监控。
优秀实践6
我们不推荐多个业务共享一个缓存实例,但是由于成本控制的原因,这种情况经常出现,我们需要通过规范来限制各个应用使用的key有唯一的前缀,并进行隔离设计,避免产生缓存互相覆盖的问题。
优秀实践7
任何缓存的key都必须设定缓存失效时间,且失效时间不能集中在某一点,否则会导致缓存占满内存或者缓存雪崩。
优秀实践8
低频访问的数据不要放在缓存中,如我们前面所说的,我们使用缓存的主要目的是提高读取性能。
曾经有个小伙伴设计了一套定时的批处理系统,由于批处理系统需要对一个大的数据模型进行计算,所以该小伙伴把这个数据模型保存在每个节点的本地缓存中,并通过消息队列接收更新的消息来维护本地缓存中模型的实时性,但是这个模型每个月只用了一次,所以这样使用缓存是很浪费的。
既然是批处理任务,就需要把任务进行分割,进行批量处理,采用分而治之、逐步计算的方法,得出最终的结果即可。
优秀实践9
缓存的数据不易过大,尤其是Redis,因为Redis使用的是单线程模型,在单个缓存key的数据过大时,会阻塞其他请求的处理。
优秀实践10
对于存储较多value的key,尽量不要使用HGETALL等集合操作,该操作会造成请求阻塞,影响其他应用的访问。
优秀实践11
缓存一般用于在交易系统中加速查询的场景,有大量的更新数据时,尤其是批量处理时,请使用批量模式,但是这种场景较少。
优秀实践12
如果对性能的要求不是非常高,则尽量使用分布式缓存,而不要使用本地缓存,因为本地缓存在服务的各个节点之间复制,在某一时刻副本之间是不一致的,如果这个缓存代表的是开关,而且分布式系统中的请求有可能会重复,就会导致重复的请求走到两个节点,一个节点的开关是开,一个节点的开关是关,如果请求处理没有做到幂等,就会造成处理重复,在严重情况下会造成资金损失。
优秀实践13
在写缓存时一定要写入完全正确的数据,如果缓存数据的一部分有效、一部分无效,则宁可放弃缓存,也不要把部分数据写入缓存,否则会造成空指针、程序异常等。
优秀实践14
在通常情况下,读的顺序是先缓存,后数据库;写的顺序是先数据库,后缓存。
优秀实践15
在使用本地缓存(如Ehcache)时,一定要严格控制缓存对象的个数及声明周期。由于JVM的特性,过多的缓存对象会极大影响JVM的性能,甚至导致内存溢出等。
优秀实践16
在使用缓存时,一定要有降级处理,尤其是对关键的业务环节,缓存有问题或者失效时也要能回源到数据库进行处理。
三、关于常见的缓存问题的线上案例
案例1
现象:某应用程序的数据库负载瞬时升高。
原因:在应用程序中对使用的大量缓存key设置了同一个固定的失效时间,当缓存失效时,会造成在一段时间内同时访问数据库,造成数据库的压力较大。
总结:在使用缓存时需要进行缓存设计,要充分考虑如何避免常见的缓存穿透、缓存雪崩、缓存并发等问题,尤其是对于高并发的缓存使用,需要对key的过期时间进行随机设置,例如,将过期时间设置为10秒+random(2),也就是将过期时间随机设置成10~12秒。
案例2
现象:导致迁移前后两个系统的核心操作重复。
原因:在迁移的过程中,重复的流量进入了不同的节点,由于使用了本地缓存存储迁移开关,而迁移开关在开关打开的瞬间导致各个节点的开关状态不一致,有的是开、有的是关,所以对于不同节点的流量的处理重复,一个走了开关开的逻辑,一个走了开关关的逻辑。
总结:避免使用本地缓存来存储迁移开关,迁移开关应该在有状态的订单上标记。
案例3
现象:某模块设计使用了缓存加速数据库的读操作的性能,但发现数据库负载并没有明显下降。
原因:由于这个模块的使用方查询请求的数据在数据库中不存在,是非法的数据,所以导致缓存没有命中,每次都穿透到数据库,且量级较大。
总结:在使用缓存时需要进行缓存设计,要充分考虑如何避免常见的缓存穿透、缓存雪崩、缓存并发等问题,尤其是对高并发的缓存使用,需要对无效的key进行缓存,以抵挡恶意的或者无意的对无效缓存查询的***或影响。
案例4
现象:监控系统报警,Redis中单个哈希键占用的空间巨大。
原因:应用系统使用了哈希键,哈希键本身有过期时间,但是哈希键里面的每个键值对没有过期时间。
总结:在设计Redis的过程中,如果有大量的键值对要保存,则请使用字符串键的数据库类型,并对每个键都设置过期时间,请不要在哈希键内部存储一个没有边界的集合数据。实际上,无论是对缓存、内存还是对数据库的设计,如果使用任意一个集合的数据结构,则都要考虑为它设置最大限制,避免内存用光,最常见的是集合溢出导致的内存溢出的问题。
案例5
现象:某业务项目由于缓存宕机导致业务逻辑中断,数据不一致。
原因:Redis进行主备切换,导致瞬间内应用连接Redis异常,应用并没有对缓存做降级处理。
总结:对于核心业务,在使用缓存时一定要有降级方案。常见的降级方案是在数据库层次预留足够的容量,在某一部分缓存出现问题时,可以让应用暂时回源到数据库继续业务逻辑,而不应该中断业务逻辑,但是这需要严格的容量评估,请参考《分布式服务架构:原理设计与实战》第3章的内容。
案例6
现象:某应用系统负载升高,响应变慢,发现应用进行频繁GC,甚至出现OutOfMemroyError: GC overhead limt exceed的错误日志。
原因:
因为这个项目是个历史项目,使用了Hibernate ORM框架,在Hibernate中开启了二级缓存,使用了Ehcache;但是在Ehcache中没有控制缓存对象的个数,缓存对象增多,导致内存紧张,所以进行了频繁的GC操作。
总结:
使用本地缓存(如Ehcache、OSCache、应用内存)时,一定要严格控制缓存对象的个数及声明周期。
案例7
现象:某个正常运行的应用突然报警线程数过高,之后很快就出现了内存溢出。
原因:由于缓存连接数达到最大限制,应用无法连接缓存,并且超时时间设置得较大,导致访问缓存的服务都在等待缓存操作返回,由于缓存负载较高,处理不完所有的请求,但是这些服务都在等待缓存操作返回,服务这时在等待,并没有超时,就不能降级并继续访问数据库。这在BIO模式下线程池就会撑满,使用方的线程池也都撑满;在NIO模式下一样会使服务的负载增加,服务响应变慢,甚至使服务被压垮。
总结:在使用远程缓存(如Redis、Memcached)时,一定要对操作超时时间进行设置,这是非常关键的,一般我们设计缓存作为加速数据库读取的手段,也会对缓存操作做降级处理,因此推荐使用更短的缓存超时时间,如果一定要给出一个数字,则希望是100毫秒以内。
案例8
现象:某项目使用缓存存储业务数据,上线后出现错误问题,开发人员束手无策。
原因:开发人员不知道如何发现、排查、定位和解决缓存问题。
总结:在设计缓存时要有降级方案,在遇到问题时首先使用降级方法,还要设计完善的监控和报警功能,帮助开发人员快速发现缓存问题,进而来定位和解决问题。
案例9
现象:某项目在使用缓存后,开发测试通过,到生产环境后,服务却出现了不可预知的问题。
原因:该应用的缓存key与其他应用缓存 key冲突,导致互相覆盖,出现逻辑错误。
总结:在使用缓存时一定要有隔离的设计,可以通过不同的缓存实例来做物理隔离,也可以通过各个应用的缓存key使用不同的前缀进行逻辑隔离。