近水楼台先得月。html
综述入口: 互联网应用服务端的经常使用技术思想与机制纲要java
实际应用中,一些数据在短时间内会反复屡次访问。好比循环访问、热点畅销商品、爆热优惠活动。在一次下单中,提交中的订单基本信息会被反复访问、刚建立的订单很快会被查询屡次。算法
数据在短时间内被反复访问的场景下,缓存可用来提高查询性能。缓存是用一个小而快的存储来存放一个大而慢的存储的数据子集,在查询时经过缓存命中而提高性能。缓存是最基本的计算思想之一。在计算机系统的各个层次结构上,缓存无处不在。数据库
本文总结互联网技术体系中尤其重要的缓存技术。
数组
缓存问题主要包括缓存结构设计、缓存一致性分析、缓存策略(热身/替换/清理)、缓存保护(击穿/雪崩/穿透)。 一致性问题涉及准确性;缓存策略涉及性能(缓存命中率及主存占用);而缓存保护涉及稳定性(在大并发请求下且缓存未能命中时保护原始数据源不被压倒)。
浏览器
缓存数据结构主要包括记录型和哈希型。记录型的缓存,是一个连续存储阵列,可简化为多维数组;哈希型的缓存,是基于哈希表。 CPU 高速缓存是基于记录型的,由于硬件上不宜作复杂的运算;应用缓存一般是基于哈希型的,好比 Redis 缓存。
缓存
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 协议是 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 定义了以下值:
缓存条目状态简称为 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,则遵循以下步骤:
注意:任何一个处理器在嗅探到缓存块的 BUS Read 事务,且相应缓存块为 M 状态时,都会执行 STEP2 操做。
缓存写
写是指将变量 x 的最新值写到缓存块。对一个处于 E 或 I 状态的缓存块的写操做,将其置为 M 状态以前,全部其余处理器缓存拷贝都必须经过一个排它读总线事务将本身的缓存做废。若是缓存状态是 M/E ,则不发送总线事务;遵循以下步骤:
缓存替换
当一个缓存块被替换时:
写等待问题
写缓冲器(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 获取。在大并发的情形下,会有缓存击穿/穿透的问题。缓存击穿和穿透的问题在后面讨论。
当两个线程处于并发读-并发写,或者并发写-并发写的时候,能够有两种方案:加锁和不加锁。
如下主要讨论不加锁的方案。分情形讨论:
A写-B读
先指明指望结果:
那么 A 该如何写,才能保证 B 读到最新的值?
A写-B写
从上述分析可知:1. 更新缓存操做多是一个代价昂贵的操做,会致使 DB 与 C 达到最终一致性的不一致时延较长,对业务有影响; 2. 在并发写-写模式下,DB 和 C 的数据会不一致,从而读到不一致的数据。所以,通常不采用更新缓存的方式,而是直接删除缓存。
常见的缓存读写模式有 Cache Aside Pattern 和 Write Behind Caching Pattern 。
空缓存会直接致使不命中,从而影响第一次读的性能。若是大并发访问空缓存(相似缓存雪崩),很容易致使大量并发请求直接打到 DB 上,使得 DB 压力陡增。
缓存热身便是预先把一些数据加载到缓存,提高第一次访问的性能,同时防止第一次访问面临大并发时会将后台打出问题。好比在应用启动后,能够将一些 TOPN 商品异步加载到缓存(不能影响应用启动);商家作活动前,把一些活动商品和活动信息数据加载到缓存(可配置化);把一些极少变更的静态数据加载到缓存。加载缓存可使用应用通知机制,好比实现 ApplicationListener 的
onApplicationEvent 方法。
缓存总有未命中的状况:
缓存替换策略是指当缓存未命中,且缓存容量已满时,判断要替换哪一个块的缓存数据。原则上,应该淘汰:1. 只访问过一次的数据; 2. 相比其余数据更少访问的; 3. 在一段时间内没有再访问的。
缓存替换策略主要有 FIFO, LRU, LFU。
当缓存对应的原始数据更新后,缓存里的数据就与原始数据不一致了,即缓存失效了。这时候须要及时清理缓存,避免读到过时数据以及过时数据占用过大的内存。缓存清理策略是指何时清理过时或失效缓存。
以本地缓存为例,来分析缓存实现。本地缓存一般在单机共享范围内:某个进程内的被屡次访问的主存数据;单机范围内的多进程共享的主存数据。要实现缓存功能,一般须要考虑以下因素:
Guava.Cache 是本地缓存的一个实现。核心类是 CacheBuilderSpec (规格指定)、CacheBuilder (根据缓存规格建立缓存)、LocalCache (缓存功能的核心实现类)。 LocalCache 的底层是一个哈希表,支持并发访问,实现了 ConcurrentMap 接口。实现要点以下:
缓存友好的代码
针对连续型存储的高速缓存,编写对缓存友好的代码。好比聚焦核心函数的循环;减小循环内部不命中的数量;对局部变量的反复引用;步长为 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 来作多机共享的分布式缓存。一些有效作法:
要避免的坑: