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