随着互联网从简单的单向浏览请求,发展为基于用户个性信息的定制化以及社交化的请求,这要求产品需要做到以用户和关系为基础,对海量数据进行分析和计算。对于后端服务来说,意味着用户的每次请求都需要查询用户的个人信息和大量的关系信息,此外大部分场景还需要对上述信息进行聚合、过滤、排序,最终才能返回给用户。
CPU 是信息处理、程序运行的最终执行单元,如果它的世界也有 “秒” 的概念,假设它的时钟跳一下为一秒,那么在 CPU(CPU 的一个核心)眼中的时间概念是什么样的呢?
可见 I/O 的速度与 CPU 和内存相比是要差几个数量级的,如果数据全部从数据库获取,一次请求涉及多次数据库操作会大大增加响应时间,无法提供好的用户体验。
对于大型高并发场景下的 Web 应用,缓存更为重要,更高的缓存命中率就意味着更好的性能。缓存系统的引入,是提升系统响应时延、提升用户体验的唯一途径,良好的缓存架构设计也是高并发系统的基石。
缓存的思想基于以下几点:
时间局限性原理 程序有在一段时间内多次访问同一个数据块的倾向。例如一个热门的商品或者一个热门的新闻会被数以百万甚至千万的更多用户查看。通过缓存,可以高效地重用之前检索或计算的数据。
以空间换取时间 对于大部分系统,全量数据通常存储在 MySQL 或者 Hbase,但是它们的访问效率太低。所以会开辟一个高速的访问空间来加速访问过程,例如 Redis 读的速度是 110000 次 /s,写的速度是 81000 次 /s 。
性能和成本的 Tradeoff 高速的访问空间带来的是成本的提升,在系统设计时要兼顾性能和成本。例如,在相同成本的情况下,SSD 硬盘容量会比内存大 10~30 倍以上,但读写延迟却高 50~100 倍。
引入缓存会给系统带来以下优势:
提升请求性能
降低网络拥塞
减轻服务负载
增强可扩展性
同样的,引入缓存也会带来以下劣势:
毫无疑问会增加系统的复杂性,开发复杂性和运维复杂性成倍提升。
高速的访问空间会比数据库存储的成本高。
由于一份数据同时存在缓存和数据库中,甚至缓存内部也会有多个数据副本,多份数据就会存在数据双写的不一致问题,同时缓存体系本身也会存在可用性问题和分区的问题。
在缓存系统的设计架构中,还有很多坑,很多的明枪暗箭,如果设计不当会导致很多严重的后果。设计不当,轻则请求变慢、性能降低,重则会数据不一致、系统可用性降低,甚至会导致缓存雪崩,整个系统无法对外提供服务。
三种模式各有优劣,适用于不同的业务场景,不存在最佳模式。
● Cache Aside(旁路缓存)
写: 更新 db 时,删除缓存,当下次读取数据库时,驱动缓存的更新。
读: 读的时候先读缓存,缓存未命中,那么就读数据库,并且将数据回种到缓存,同时返回相应结果
特点:懒加载思想,以数据库中的数据为准。在稍微复杂点的缓存场景,缓存都不简单是数据库中直接取出来的,可能还需要从其他表查询一些数据,然后进行一些复杂的运算,才能最终计算出值。这种存储模式适合于对数据一致性要求比较高的业务,或者是缓存数据更新比较复杂、代价比较高的业务。例如:一个缓存涉及多个表的多个字段,在 1 分钟内被修改了 100 次,但是这个缓存在 1 分钟内就被读取了 1 次。如果使用这种存储模式只删除缓存的话,那么 1 分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低。
● Read/Write Through(读写穿透)
写: 缓存存在,更新数据库,缓存不存在,同时更新缓存和数据库
读: 缓存未命中,由缓存服务加载数据并且写入缓存
特点:
读写穿透对热数据友好,特别适合有冷热数据区分的场合。
1)简化应用程序代码
在缓存方法中,应用程序代码仍然很复杂,并且直接依赖于数据库,如果多个应用程序处理相同的数据,甚至会出现代码重复。读写穿透模式将一些数据访问代码从应用程序转移到缓存层,这极大地简化了应用程序并更清晰地抽象了数据库操作。
2)具有更好的读取可伸缩性
在多数情况下,缓存数据过期以后,多个并行用户线程最终会打到数据库,再加上数以百万计的缓存项和数千个并行用户请求,数据库上的负载会显著增加。读写穿透可以保证应用程序永远不会为这些缓存项访问数据库,这也可以让数据库负载保持在最小值。
3)具有更好的写性能
读写穿透模式可以让应用程序快速更新缓存并返回,之后它让缓存服务在后台更新数据库。当数据库写操作的执行速度不如缓存更新的速度快时,还可以指定限流机制,将数据库写操作安排在非高峰时间进行,减轻数据库的压力。
4)过期时自动刷新缓存
读写穿透模式允许缓存在过期时自动从数据库重新加载对象。这意味着应用程序不必在高峰时间访问数据库,因为最新数据总是在缓存中。
● Write Behind Caching(异步缓存写入)
写:只更新缓存,缓存服务异步更新数据库。
读:缓存未命中由封装好的缓存服务加载数据并且写入缓存。
特点:写性能最高,定期异步刷新数据库数据,数据丢失的概率大,适合写频率高,并且写操作需要合并的场景。使用异步缓存写入模式,数据的读取和更新通过缓存进行,与读写穿透模式不同,更新的数据并不会立即传到数据库。相反,在缓存服务中一旦进行更新操作,缓存服务就会跟踪脏记录列表,并定期将当前的脏记录集刷新到数据库中。作为额外的性能改善,缓存服务会合并这些脏记录,合并意味着如果相同的记录被更新,或者在缓冲区内被多次标记为脏数据,则只保证最后一次更新。对于那些值更新非常频繁,例如金融市场中的股票价格等场景,这种方式能够很大程度上改善性能。如果股票价格每秒钟变化 100 次,则意味着在 30 秒内会发生 30 x 100 次更新,合并将其减少至只有一次。
问题的常用解决方案
缓存集中失效大多数情况出现在高并发的时候,如果大量的缓存数据集中在一个时间段失效,查询请求会打到数据库,数据库压力凸显。比如同一批火车票、飞机票,当可以售卖时,系统会一次性加载到缓存,并且过期时间设置为预先配置的固定时间,那过期时间到期后,系统就会因为热点数据的集中没有命中而出现性能变慢的情况。
解决方案:
使用基准时间 + 随机时间,降低过期时间的重复率,避免集体失效。即相同业务数据设置缓存失效时间时,在原来设置的失效时间基础上,再加上一个随机值,让数据分散过期,同时对数据库的请求也会分散开,避免瞬时全部过期对数据库造成过大压力。
缓存穿透是指一些异常访问,每次都去查询压根儿就不存在的 key,导致每次请求都会打到数据库上去。例如查询不存在的用户,查询不存在的商品 id。如果是用户偶尔错误输入,问题不大。但如果是一些特殊用户,控制一批肉鸡,持续的访问缓存不存在的 key,会严重影响系统的性能,影响正常用户的访问,甚至可能会让数据库直接宕机。我们在设计系统时,通常只考虑正常的访问请求,所以这种情况往往容易被忽略。
解决方案:
第一种方案就是,查询到不存在的数据时,首次查询数据库,即便数据库没有数据,仍然回种这个 key 到缓存,并使用一个特殊约定的 value 表示这个 key 的值为空。后面再次出现对这个 key 的请求时,直接返回 null。为了健壮性,设置空缓存 key 时,一定要设置过期时间,以防止之后该 key 被写入了数据。
第二种方案是,构建一个 BloomFilter 缓存过滤器,记录全量数据,这样访问数据时,可以直接通过 BloomFilter 判断这个 key 是否存在,如果不存在直接返回即可,压根儿不需要查询缓存或数据库。比如,可以使用基于数据库增量日志解析框架(阿里的 canal),通过消费增量数据写入到 BloomFilter 过滤器。BloomFilter 的所有操作也是在内存里实现,性能很高,要达到 1% 的误判率,平均单条记录占用 1.2 字节即可。同时需要注意的是 BloomFilter 只有新增没有删除操作,对于已经删除的 key 可以配合上述缓存空值解决方案一起使用。Redis 提供了自定义参数的布隆顾虑器,可以使用 bf.reserve 进行创建,需要设置参数 error_rate(错误率)和 innitial_size。error_rate 越低需要的空间越大,innitial_size 表示预计放入的元素数量,当实际数量超过这个值以后,误判率会上升。
缓存雪崩是缓存机器因为某种原因全部或者部分宕机,导致大量的数据落到数据库,最终把数据库打死。例如某个服务,恰好在请求高峰期间缓存服务宕机,本来打到缓存的请求,这是时候全部打到数据库,数据库扛不住在报警以后也会宕机,重启数据库以后,新的请求会再次把数据库打死。
解决方案:
事前:缓存采用高可用架构设计,redis 使用集群部署方式。对重要业务数据的数据库访问添加开关,当发现数据库出现阻塞、响应慢超过阈值的时候,关闭开关,将一部分或者全都的数据库请求执行 failfast 操作。
事中:引入多级缓存架构,增加缓存副本,比如新增本地 ehcache 缓存。引入限流降级组件,对缓存进行实时监控和实时报警。通过机器替换、服务替换进行及时恢复;也可以通过各种自动故障转移策略,自动关闭异常接口、停止边缘服务、停止部分非核心功能措施,确保在极端场景下,核心功能的正常运行。
事后:redis 持久化,支持同时开启两种持久化方式,我们可以综合使用 AOF 和 RDB 两种持久化机制,用 AOF 来保证数据不丢失,作为数据恢复的第一选择;用 RDB 来做不同程度的冷备,在 AOF 文件都丢失或损坏不可用的时候,还可以使用 RDB 来进行快速的数据恢复。同时把 RDB 数据备份到远端的云服务,如果服务器内存和磁盘的数据同时丢失,依然可以从远端拉取数据做灾备恢复操作。
同一份数据,既在缓存里又在数据库里,肯定会出现数据库与缓存中的数据不一致现象。如果引入多级缓存架构,缓存会存在多个副本,多个副本之间也会出现缓存不一致现象。缓存机器的带宽被打满,或者机房网络出现波动时,缓存更新失败,新数据没有写入缓存,就会导致缓存和 DB 的数据不一致。缓存 rehash 时,某个缓存机器反复异常,多次上下线,更新请求多次 rehash。这样,一份数据存在多个节点,且每次 rehash 只更新某个节点,导致一些缓存节点产生脏数据。再比如,数据发生了变更,先删除了缓存,然后要去修改数据库,此时还没修改。一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。随后数据变更的程序完成了数据库的修改,数据库和缓存中的数据不一样了。
解决方案:
设置 key 的过期时间尽量短,让缓存更早的过期,从 db 加载新数据,这样无法保证数据的强一致性,但是可以保证最终一致性。
cache 更新失败以后引入重试机制,比如连续重试失败以后,可以将操作写入重试队列,当缓存服务可用时,将这些 key 从缓存中删除,当这些 key 被重新查询时,重新从数据库回种。
延时双删除策略,首先删除缓存中的数据,在写数据库,休眠一秒以后(具体时间需要根据具体业务逻辑的耗时进行调整)再次删除缓存。这样可以将一秒内造成的所有脏数据再次删除。
缓存最终一致性,使客户端数据与缓存解耦,应用直接写数据到数据库中。数据库更新 binlog 日志,利用 Canal 中间件读取 binlog 日志。Canal 借助于限流组件按频率将数据发到 MQ 中,应用监控 MQ 通道,将 MQ 的数据更新到 Redis 缓存中。
更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个 jvm 内部队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新执行 “读取数据 + 更新缓存” 的操作,根据唯一标识路由之后,也发送到同一个 jvm 内部队列中。该方案对于读请求进行了非常轻度的异步化,使用一定要注意读超时的问题,每个读请求必须在超时时间范围内返回。因此需要根据自己的业务情况进行测试,可能需要部署多个服务,每个服务分摊一些数据的更新操作。如果一个内存队列里居然会挤压 100 个业务数据的修改操作,每个操作操作要耗费 10ms 去完成,那么最后一个读请求,可能等待 10 * 100 = 1000ms = 1s 后,才能得到数据,这个时候就导致读请求的长时阻塞。
当系统的线上流量特别大的时候,缓存中会出现数据并发竞争的现象。在高并发的场景下,如果缓存数据正好过期,各个并发请求之间又没有任何协调动作,这样并发请求就会打到数据库,对数据造成较大的压力,严重的可能会导致缓存 “雪崩”。另外高并发竞争也会导致数据不一致问题,例如多个 redis 客户端同时 set 同一个 key 时,key 最开始的值是 1,本来按顺序修改为 2,3,4,最后是 4,但是顺序变成了 4,3,2,最后变成了 2。
解决方案:
分布式锁 + 时间戳
可以基于 redis 或者 zookeeper 实现一个分布式锁,当一个 key 被高并发访问时,让请求去抢锁。也可以引入消息中间件,把 Redis.set 操作放在消息队列中。总之,将并行读写改成串行读写的方式,从而来避免资源竞争。对于 key 的操作的顺序性问题,可以通过设置一个时间戳来解决。大部分场景下,要写入缓存的数据都是从数据库中查询出来的。在数据写入数据库时,可以维护一个时间戳字段,这样数据被查询出来时都会带一个时间戳。写缓存的时候,可以判断一下当前数据的时间戳是否比缓存里的数据的时间戳要新,这样就避免了旧数据对新数据的覆盖。
对于大多数互联网系统,数据是分冷热的,访问频率高的 key 被称为热 key,比如热点新闻、热点的评论。而在突发事件发生时,瞬间会有大量用户去访问这个突发热点信息,这个突发热点信息所在的缓存节点由于超大流量而达到理网卡、带宽、CPU 的极限,从而导致缓存访问变慢、卡顿、甚至宕机。接下来数据请求到数据库,最终导致整个服务不可用。比如微博中数十万、数百万的用户同时去吃一个新瓜,秒杀、双 11、618 、春节等线上促销活动,明星结婚、离婚、出轨这种特殊突发事件。
解决方案:
要解决这种极热 key 的问题,首先要找出这些 热 key 。对于重要节假日、线上促销活动、凭借经验可以提前评估出可能的热 key 来。而对于突发事件,无法提前评估,可以通过 Spark 或者 Flink,进行流式计算,及时发现新发布的热点 key。而对于之前已发出的事情,逐步发酵成为热 key 的,则可以通过 Hadoop 进行离线跑批计算,找出最近历史数据中的高频热 key。还可以通过客户端进行统计或者上报。找到热 key 后,就有很多解决办法了。首先可以将这些热 key 进行分散处理。redis cluster 有固定的 16384 个 hash slot,对每个 key 计算 CRC16 值,然后对 16384 取模,可以获取 key 对应的 hash slot。比如一个热 key 名字叫 hotkey,可以被分散为 hotkey#1、hotkey#2、hotkey#3,……hotkey#n,这 n 个 key 就会分散存在多个缓存节点,然后 client 端请求时,随机访问其中某个后缀的热 key,这样就可以把热 key 的请求打散。
其次,也可以 key 的名字不变,对缓存提前进行多副本 + 多级结合的缓存架构设计。比如利用 ehcache,或者一个 HashMap 都可以。在你发现热 key 以后,把热 key 加载到系统的 JVM 中,之后针对热 key 的请求,可以直接从 jvm 中获取数据。再次,如果热 key 较多,还可以通过监控体系对缓存的 SLA 实时监控,通过快速扩容来减少热 key 的冲击。
有些时候开发人员设计不合理,在缓存中会形成特别大的对象,这些大对象会导致数据迁移卡顿,另外在内存分配方面,如果一个 key 特别大,当需要扩容时,会一次性申请更大的一块内存,这也会导致卡顿。如果大对象被删除,内存会被一次性回收,卡顿现象会再次发生。在平时的业务开发中,要尽量避免大 key 的产生。如果发现系统的缓存大起大落,极有可能是大 key 引起的,这就需要开发人员定位出大 key 的来源,然后进行相关的业务代码重构。Redis 官方已经提供了相关的指令进行大 key 的扫描,可以直接使用。
解决方案:
如果数据存在 Redis 中,比如业务数据存 set 格式,大 key 对应的 set 结构有几千几万个元素,这种写入 Redis 时会消耗很长的时间,导致 Redis 卡顿。此时,可以扩展新的数据结构,同时让 client 在这些大 key 写缓存之前,进行序列化构建,然后通过 restore 一次性写入。
将大 key 分拆为多个 key,尽量减少大 key 的存在。同时由于大 key 一旦穿透到 DB,加载耗时很大,所以可以对这些大 key 进行特殊照顾,比如设置较长的过期时间,比如缓存内部在淘汰 key 时,同等条件下,尽量不淘汰这些大 key。