近水楼台先得月。
综述入口: 互联网应用服务端的常用技术思想与机制纲要
实际应用中,一些数据在短期内会反复多次访问。比如循环访问、热点畅销商品、爆热优惠活动。在一次下单中,提交中的订单基本信息会被反复访问、刚创建的订单很快会被查询多次。
数据在短期内被反复访问的场景下,缓存可用来提升查询性能。缓存是用一个小而快的存储来存放一个大而慢的存储的数据子集,在查询时通过缓存命中而提升性能。缓存是最基本的计算思想之一。在计算机系统的各个层次结构上,缓存无处不在。
- CPU 高速缓存:位于 CPU 芯片上。L1,L2,L3 缓存。 L1 - 4 个时钟;L2 - 10 个时钟;L3 - 50 个时钟。
- 虚拟主存: 作为磁盘数据的缓存。
- 磁盘缓存: 难以装进主存的大对象、网络内容的本地缓存
- 网络缓存: 浏览器缓存、HTTP 代理缓存、负载均衡缓存、CDN。
本文总结互联网技术体系中尤为重要的缓存技术。
基本思想
- 缓存是以空间换时间,提升查询性能。缓存遵循“近水楼台先得月”法则:邻近 CPU 优先,邻近用户优先(CDN)。
- 缓存依据:访问局部性原理。时间局部性 - 某个存储器位置在短时间内被再次访问;空间局部性 - 若某个存储器位置被访问,则邻近存储器位置也很可能会被访问。重复引用相同变量的程序具有良好的时间局部性。步长为 1 的引用模式的程序具有良好的空间局部性。一个典型例子是数组求和。求和变量体现了时间局部性,数组访问体现了空间局部性。可以用缓存命中率来衡量局部性。
- 顺序引用模式:顺序地每隔 k 个元素地访问一个连续向量中的每个元素,称为步长为 k 的顺序引用模式 。 k 越大,空间局部性越差。步长为 1 的顺序引用模式是局部性原理的重要应用之一。高效访问顺序与存储结构设计及存储细节是紧密关联的。数组和列表是连续存储结构,因此顺序引用模式很吃香。
- 存储器层次结构:对于每个 k, 位于第 k 层的更快更小的存储设备作为位于第 k+1 层的更慢更大的存储设备的缓存。数据总是以块为传送单元,在第 k 层和第 k+1 层之间进行复制的。层次结构中,相邻的两层的块大小是一样的;不同层次的块大小可以不同。越靠近慢而大的存储层次,块大小越大。
缓存问题
缓存问题主要包括缓存结构设计、缓存一致性分析、缓存策略(热身/替换/清理)、缓存保护(击穿/雪崩/穿透)。 一致性问题涉及准确性;缓存策略涉及性能(缓存命中率及主存占用);而缓存保护涉及稳定性(在大并发请求下且缓存未能命中时保护原始数据源不被压倒)。
缓存结构设计
缓存数据结构主要包括记录型和哈希型。记录型的缓存,是一个连续存储阵列,可简化为多维数组;哈希型的缓存,是基于哈希表。 CPU 高速缓存是基于记录型的,因为硬件上不宜做复杂的运算;应用缓存通常是基于哈希型的,比如 Redis 缓存。
CPU高速缓存
CPU 高速缓存可使用 (S, E, B, m) 来表示组织结构。m 位存储器具有 2^m 个存储器地址,其对应的高速缓存组织划分为 S = 2^s 个组,每组 E 个缓存行,每个缓存行包括一个有效位、t 个标记位、B = 2^b 个字节,缓存大小 C = S * E * B。 其中 s 是组索引,标识缓存块在哪个组里;t = m-s-b 标识缓存块在缓存组的哪个缓存行里;b 是字节在缓存行里的偏移量。[s,t,b] 标识了缓存字节在缓存结构里的位置。发生缓存替换时,替换的是某个组里的某个缓存行。
E = 1 时,DMC Directed-Map Cache ;1 < E < C/B 时,SAC Set Associative Cache ;E = C/B 时 Full Associative Cache FAC。 DMC 每组只有一个缓存行,在组中查找缓存行没有开销,但容易发生组的冲突不命中; SAC 在组中查找缓存行有一定开销,但可以减少组的冲突不命中概率; FAC 只有一个组,在定位组时无开销,替换缓存行时有更大的选择,但在查找缓存行时开销比较大。在硬件层,搜索和匹配标记位是昂贵的操作,因此 FAC 一般应用在搜索和匹配操作代价不高的地方,比如虚拟主存或应用缓存。
高速缓存定位字的步骤是:首先从 m 中拿到 s 位组索引,找到缓存行所在的组;再根据 t 位标记位找到匹配的组内的缓存行;最后,根据 b 位偏移量找到字在缓存块中的位置。如果有效位未置位,则可能是过期缓存;如果 t 位标记位无法匹配所有的组,则是缓存未命中。
CPU 写主存时可采用两种方式:直写和回写。直写会在更新缓存是直接写入缓存,而回写在更新缓存时只是标记缓存块的缓存状态,只有在替换缓存块时才会写回主存。这就导致了 CPU 缓存与主存的一致性问题。这个问题是通过 MESI 协议来解决的。
MESI协议
MESI 协议是 SMP 体系结构的 CPU 缓存一致性协议,涉及读写时多个 CPU 高速缓存如何与主存保持一致 。主要设计思想包括:缓存条目状态的状态转换自动机、写缓冲器、总线事务定义及缓存控制、操作异步化队列、操作屏障。
一致性概念
多处理器存储系统是一致的,如果某个程序的任何执行结果都满足下列条件:对于任何单元,有可能建立一个假想的操作序列(将所有进程的读写操作排成一个全序),此序列与执行结果一致,并且在此序列中:
- 任何特定进程发出的操作,所表现出的序和该进程向存储系统发出他们的序相同;
- 每个读操作返回的值是对相应单元按串行顺序写入的最后一个值。
一致性前提
- 系统总线上的所有事务对所有处理器的高速缓存控制器可见,且以相同顺序可见。
- 为响应存储的所有必要事务都出现在总线上,且缓存控制器采取适当的措施。
- 当高速缓存监听到与之相关的写操作事务时,要么使缓存块拷贝作废,要么更新它。处理器随后的访问,要么缓存不命中而加载新的值,要么直接看到新的值。
CPU宏观结构
CPU 宏观结构主要包括:CPU Core, Store Buffer , CPU Cache , System BUS 。 CPU Cache 和 Store Buffer 是 CPU 专有的,System BUS 是共享的消息通道。 CPU Cache 是一个缓存条目的阵列(多维数组),每个缓存条目有 tag, data, flag 三个值,tag 表示主存地址,flag 表示缓存条目的状态。flag 定义了如下值:
- Modified(M):已修改状态。某个处理器缓存副本拥有已修改的值, 主存里的是过期的;
- Exclusive(E):干净独占状态。仅有该处理器缓存副本与主存一致且主存状态是最新的,独占控制权,缓存能够写操作并转移到 M 状态,却不产生总线事务;
- Shared(S):至少两个处理器缓存副本与主存一致,主存有最新的值,其他处理器可能有最新的或者过期的值;
- Invalid(I):初始状态,缓存无效状态。
缓存条目状态简称为 CES。CES 的状态转换图可以定位为一个有限状态自动机。理解 CES 的有限状态转换机是关键。如下图所示,A/B 表示当观察 A 事件时,将产生一个 B 总线事务。Flush’ 表示清除相应的存储块,前提是使用了缓存到缓存的共享,且清除是由提供数据的缓存。BusRd(S) 表示由共享信号 S 生成的总线读事务。缓存控制器通过共享信号 S 在地址阶段确定是否有其它缓存拥有同样的缓存拷贝。如果一个缓存确定自己拥有同样的存储块拷贝,就会发出 S 信号。
MESI 协议定义了一些总线事务(总线读事务、总线排它读事务、总线写事务、回写事务)。结合 CES 状态转换图、总线事务及 CPU 缓存读写控制来实现一致性。
缓存读
读是指拿到变量的最新值并读取到 CPU 寄存器。假设处理器 P1 和 P2 均拥有变量 x 的副本。如果 P1 发现 x 的 CES 为 M/E/S,则直接获取副本 x 的值。若 P1 发现变量 x 的 CES 为 I,则遵循如下步骤:
- STEP1 -- 发送 BUS Read 事务;
- STEP2 -- P2 拥有变量 x 的最新副本( CES 为 M),嗅探到 x Read 事务,就会将 x 的最新副本写入主存,构造 Read Response 发送到 BUS 上,并将 CES 更新为 S ;如果有多个处理器缓存都拥有变量 x 的最新副本,则通过某种策略来选择从某个高速缓存来提供新值还是直接由主存来提供新值。
- STEP3 -- P1 嗅探到到 x Read Response ,将 CES 更新为 S,写入相应的缓存块。
注意:任何一个处理器在嗅探到缓存块的 BUS Read 事务,且相应缓存块为 M 状态时,都会执行 STEP2 操作。
缓存写
写是指将变量 x 的最新值写到缓存块。对一个处于 E 或 I 状态的缓存块的写操作,将其置为 M 状态之前,所有其他处理器缓存拷贝都必须通过一个排它读总线事务将自己的缓存作废。如果缓存状态是 M/E ,则不发送总线事务;遵循如下步骤:
- STEP1:P1 发送总线排它读事务;
- STEP2:其他处理器嗅探到总线排它读事务,更新 CES 为 I,再发送 Invalidate Acknowledge ;后续读会产生一次缓存不命中,从而通过一次总线读事务读取最新值。
- STEP3:P1 收到所有 Invalidate Acknowledge ,将 CES 更新为 E,获得数据控制权。然后写入缓存行,将 CES 更新为 M。CPU 写需要等待其他处理器都发送 Invalidate Acknowledge 消息,此时会有写等待问题。
缓存替换
当一个缓存块被替换时:
- 如果缓存块处于 S 或 I, 则逻辑上直接更新为 I; 如果缓存块处于 M 状态,则从 M 到 I 的状态转换会触发一次回写事务,将缓存块的状态写入主存。
写等待问题
写缓冲器(Store Buffer)、无效化队列(Invalidate Queue)。CPU 会直接先写 Store Buffer ,再同步缓存。其他处理器则会将消息存入 Invalidate Queue 就发送 Invalidate Acknowledge ,异步去更新 CES 。 写缓冲器和无效化队列将 CPU 缓存副本更新变成异步处理。读则采用存储转发,先查询写缓冲器,再查询高速缓存。相当于写缓冲器又加了一层缓存。写缓存异步化又会带来一致性问题。
主存屏障
Store Barrier 和 Load Barrier 。Store Barrier 将 Store Buffer 的数据写入缓存; Load Barrier 根据 Invalidate Queue 的主存地址,将相应的 CES 更新为 I。
缓存读写一致性
要正确使用缓存,必然要保证缓存并发读写的一致性。缓存读写一致性需要保证:
- 源数据与缓存的同一数据达成一致性(或者达成不一致时延可接受的最终一致性)。
- 以源数据的数据为准。
可以采用 [ xC, xDB, yC, yDB ] 操作序列分析读写一致性问题,x,y 是读、更新、删除,C 表示缓存,DB 表示数据库(源数据)。
首先框定讨论范围:两个线程 A, B,一个变量 x ,数据源 DB 和 缓存 C ,其中 C 从 DB 中获取,需要与 DB 保持一致, A,B 有读写操作,读为 RD, 写可以进一步分为更新值 UP 和删除值操作 DE,读写时序不确定。
缓存读模式是确定的:读取数据时,先读缓存,缓存命中则直接返回(查询性能提升体现在这里),未命中再去读 DB。这点无异议。如果 A, B 并发读,均直接从 C 中获取当前值即可。如果 C 中没有值,那么 A, B 可能都会从 DB 获取。在大并发的情形下,会有缓存击穿/穿透的问题。缓存击穿和穿透的问题在后面讨论。
当两个线程处于并发读-并发写,或者并发写-并发写的时候,可以有两种方案:加锁和不加锁。
- 对更新 DB 和更新 C 进行加分布式锁,使之原子化。加锁会更简单,但吞吐量会比较低;
- 对更新 DB 和 更新 C 不加锁,保证合理的执行顺序,使之达到最终一致性(业务可接受一定的不一致时延)。
以下主要讨论不加锁的方案。分情形讨论:
A写-B读
先指明期望结果:
- 如果 B 在 A 写之前读,那么 B 读到的是写之前的值,直接从 C 中获取即可。没问题。
- 如果 B 在 A 写之后读,那么 B 读到的应该是写之后的值。
那么 A 该如何写,才能保证 B 读到最新的值?
- A 先更新 C,再写 DB。会有什么问题 ? 如果 A 先更新了 C ,然后更新 DB 失败了,那么 C 与 DB 就不一致了。DB 里是旧值。不符合一致性保证。当然,读 C 会读到新值。可以有一种思路,就是缓存作为读写的前置环节,负责缓存与 DB 的最终一致性。这种方案也是可以接受的。这是 Write Behind Caching Pattern 模式。
- A 先删除 C, 再写 DB。会有什么问题? 如果操作时序是 [ A 删除 C, B 读 C,A 更新 DB ], 那么在 A 更新 DB 之前,B 读 C 未命中,从 DB 读到旧值,而 A 更新 DB, DB 与 C 的值不一致。不符合一致性保证。
- A 先写 DB ,再更新 C。会有什么问题? 1. 更新缓存可能是代价昂贵的操作,频繁更新缓存会导致吞吐量降低;在更新 C 之前读到的是旧值,如果更新操作比较耗时,那么 DB 和 C 的不一致时延会比较大,会影响业务。
- A 先写 DB ,再删除 C。 会有什么问题? 避免了更新代价高的问题,在删除 C 之前读到的是旧值,在删除 C 之后读到新值。由于删除缓存往往代价很小,不一致时延通常可接受。这是 Cache Aside Pattern 模式。
A写-B写
- 期望结果:无论 A 先写还是 B 先写,最终写入的 DB 和 C 的值应当一致。
- A 先更新 C,再写 DB。 会有什么问题?如果操作时序是 [ A 写 C x, B 写 C y, B 写 DB y, A 写 DB x],会导致 C 是 y , DB 是 x ,DB 与 C 中的数据不一致。
- A 先写 DB, 再更新 C。会有什么问题?[ A 写 DB x, B 写 DB y, B 写 C y, A 写 C x],会导致 C 是 x , DB 是 y ,DB 与 C 中的数据不一致。
从上述分析可知:1. 更新缓存操作可能是一个代价昂贵的操作,会导致 DB 与 C 达到最终一致性的不一致时延较长,对业务有影响; 2. 在并发写-写模式下,DB 和 C 的数据会不一致,从而读到不一致的数据。因此,一般不采用更新缓存的方式,而是直接删除缓存。
常见的缓存读写模式有 Cache Aside Pattern 和 Write Behind Caching Pattern 。
- Cache Aside Pattern:读取数据时,先读缓存,缓存命中则直接返回(查询性能提升体现在这里),未命中再去读 DB。读更写删。读模式基本是固定的;写入数据时,先更新 DB ,再删除缓存。可以采集 DB binlog 异步删除缓存。如果是主从 DB,则必须采集最后一个从库 binlog (最终一致性)。
- Write Behind Caching Pattern --- 写入时只更新缓存,异步去更新 DB 。牺牲短暂的一致性来获得高吞吐量。
缓存热身
空缓存会直接导致不命中,从而影响第一次读的性能。如果大并发访问空缓存(类似缓存雪崩),很容易导致大量并发请求直接打到 DB 上,使得 DB 压力陡增。
缓存热身即是预先把一些数据加载到缓存,提升第一次访问的性能,同时防止第一次访问面临大并发时会将后台打出问题。比如在应用启动后,可以将一些 TOPN 商品异步加载到缓存(不能影响应用启动);商家做活动前,把一些活动商品和活动信息数据加载到缓存(可配置化);把一些极少变动的静态数据加载到缓存。加载缓存可以使用应用通知机制,比如实现 ApplicationListener 的
onApplicationEvent 方法。
缓存替换策略
缓存总有未命中的情况:
- 空不命中:总是不会命中,亦称冷缓存。避免冷缓存的方法是进行“缓存热身”。将 k+1 层的缓存块放到第 k 层的策略称为放置策略。通常采用取模的方式: j = i Mod N ,即:将第 k+1 层的第 i 个块对 N 取模后,放到第 k 层的第 i 个块里。
- 冲突不命中:比如按取模的放置策略,有可能在缓存未满的情况下,总是对第 k 层的同一个块进行替换。比如 j mod 4 ,当 j=0,4,8,12 时,总是会放在到第 0 块上。缓存抖动是一种特殊的冲突不命中,指高速缓存反复加载或驱逐相同的高速缓存块/组/行。
- 容量不命中:缓存容量满了。
缓存替换策略是指当缓存未命中,且缓存容量已满时,判断要替换哪个块的缓存数据。原则上,应该淘汰:1. 只访问过一次的数据; 2. 相比其他数据更少访问的; 3. 在一段时间内没有再访问的。
缓存替换策略主要有 FIFO, LRU, LFU。
- FIFO : 最先进入缓存的首先被淘汰。队列实现。或者使用双向链表,新进入元素添加到链表尾,丢弃链表头的元素。FIFO适合丢弃那些只有一次访问的数据。
- LRU :最近最少使用淘汰。使用链表实现,若缓存命中,则将节点移至首部,淘汰尾部节点。 LRU 适合热点数据访问。LRU 无法识别哪些缓存是最多被访问的。偶发性、周期性的批量操作可能导致缓存被大量替换,造成缓存污染,使得 LRU 的效率大幅下降。实际采用 LRU-K 算法,将缓存分为两级,数据在较短时间被访问 K 次以上,则进入二级缓存。两级都采用 LRU 策略。
- LFU : 最少次数使用淘汰。引用计数 + 优先级队列(堆)。
缓存清理策略
当缓存对应的原始数据更新后,缓存里的数据就与原始数据不一致了,即缓存失效了。这时候需要及时清理缓存,避免读到过期数据以及过期数据占用过大的内存。缓存清理策略是指什么时候清理过期或失效缓存。
- TTL: 设置过期时间。 TTL 一般以数据变化频繁度为依据来设置。不同业务数据的过期时间不一样。
- 写时失效: 写失效、写更新。写失效 - 标记缓存数据已过期,读时清理或替换;写更新 - 在更新数据时就替换缓存项。
- 读时失效:写时只标注失效信息,读时判断是否失效并加载最新数据。如果有大量缓存对象要更新,可以采用读时失效将写更新成本分摊到每一个读上。缓存对象时,同时存储相应的版本号或时间戳。需要展示数据时,通过对比版本号来判断是否缓存已失效。
缓存击穿/雪崩/穿透
- 缓存击穿【重点】。 热点问题。大并发集中对热点 key 进行访问,当这个 key 在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。基本方案:多级缓存(不同失效时间)+ 热点散列 + 热点识别、熔断降级、互斥锁、不过期+异步更新。
- 缓存雪崩。 大量 key 同时失效,导致大量请求打到 DB,造成巨大 DB 压力和系统不稳定。基本方案:过期时间+随机化。
- 缓存穿透。大量不存在的 key 的非法访问请求,同样会使得大量请求打到 DB。使用布隆过滤器过滤大量非法请求。还有一种方法是空值缓存,失效时间设置小一些,应对短时间内无效重复 key 的大量查询。
- 缓存命中统计、缓存监控。
缓存实现
以本地缓存为例,来分析缓存实现。本地缓存通常在单机共享范围内:某个进程内的被多次访问的主存数据;单机范围内的多进程共享的主存数据。要实现缓存功能,通常需要考虑如下因素:
- 缓存的规格指定,会影响缓存的创建和性能。
- 缓存的值的计算和迟加载。
- 缓存策略的配置。
- 缓存对并发的支持。
- 缓存更新的通知与监听。
- 缓存的监控与统计。
Guava.Cache 是本地缓存的一个实现。核心类是 CacheBuilderSpec (规格指定)、CacheBuilder (根据缓存规格创建缓存)、LocalCache (缓存功能的核心实现类)。 LocalCache 的底层是一个哈希表,支持并发访问,实现了 ConcurrentMap 接口。实现要点如下:
- 缓存数据的读写与 ConcurrentHashMap 类似。
- 有两个用双向链表实现的优先级队列: writeQueue 和 accessQueue ,用来控制缓存何时过期。writeQueue 按写时间排序,accessQueue 按访问时间排序。在每次写入或更新或清理操作的时候,会执行清理操作,根据这两个队列来判断缓存数据是否过期,如果过期则从缓存数据哈希表中移除。
高效应用缓存
缓存友好的代码
针对连续型存储的高速缓存,编写对缓存友好的代码。比如聚焦核心函数的循环;减少循环内部不命中的数量;对局部变量的反复引用;步长为 1 的顺序引用模式;多重循环中的循环变量的次序。
换言之,每个循环都会在高速缓存上产生很大的影响,进而影响程序运行性能。对于上层应用可能感知不明显,但是对于底层却很重要。
缓存与动态规划
动态规划法通常会复用到子问题的解,因此可以使用缓存来存储子问题的解。一个简单的例子如下,计算阶乘:
public class factorialCalc {
private static Log log = LogFactory.getLog(factorialCalc.class);
static Random random = new Random(System.currentTimeMillis());
public static void main(String[]args) {
for (int i=1; i < 10; i++) {
int num = random.nextInt(15);
String info = String.format("fac(%d)=%d", num, fac(num));
log.info(info);
String info2 = String.format("facWithCache(%d)=%d", num, facWithCache(num));
log.info(info2);
printCacheInfo(cache);
}
}
private static void printCacheInfo(Cache<Integer, Long> cache) {
log.info("cache contents: " + cache.asMap());
log.info("cache stat: " + cache.stats());
}
public static long fac(int n) {
if (n <= 1) return 1;
return n * fac(n-1);
}
private static Cache<Integer, Long> cache = CacheBuilder.newBuilder().recordStats().build();
public static long facWithCache(int n) {
if (n <= 1) {
cache.put(1, 1L);
return 1L;
}
Long facN_1 = cache.getIfPresent(n-1);
if (facN_1 == null) {
facN_1 = facWithCache(n-1);
}
long facN = n * facN_1;
cache.put(n, facN);
return facN;
}
}
分布式缓存
一般采用 Redis 来做多机共享的分布式缓存。一些有效做法:
- 命名空间规范和隔离,部署隔离,避免业务相互影响和耦合。
- 采用批量获取缓存数据的方法提升查询性能,减少网络传输开销。
- 尽量使用 O(1) 的命令,避免使用遍历性命令。
- 单个 key 的 value 不超过 10KB, list, set, map 等不超过 1000 个元素。
- 设置合理的定期删除/惰性删除/缓存替换策略。
- 测量缓存的命中率及性能提升情况;若数据不理想,则要仔细分析原因并优化。
- 监控大对象缓存。
要避免的坑:
- 内存占用和缓存同步要特别注意,避免内存占用大、同步慢影响了业务。
- 缓存主要用来提升性能,不要当做持久化存储使用,避免数据丢失的风险。
- 避免滥用和浪费缓存资源。主存缓存是比较昂贵的资源。