烛衡 阿里云开发者
利用缓存做性能优化的案例非常多,从基础的操作系统到数据库、分布式缓存、本地缓存等。它们表现形式各异,却有着共同的朴素的本质:弥补 CPU 的高算力和 IO 的慢读写之间巨大的鸿沟。
和架构选型类似,每引入一个组件,都会导致复杂度的上升。以缓存为例,它带来性能提升的同时,也带来一些问题,需要开发者设计和权衡。
本文的思维脉络如下:
一 缓存和多级缓存
1 缓存的引入
在初期业务量小的时候,数据库能承担读写压力,应用可以直接和 DB 交互,架构简单且强壮。
经过一段时间发展后,业务量迎来了大规模增长,此时 DB 查询压力和耗时都在增长。此时引入分布式缓存,在减少 DB 压力的同时,还提供了更高的 QPS。
再往后发展,分布式缓存也成为了瓶颈,高频的 QPS 是一笔负担;另外缓存驱逐以及网络抖动会影响系统的稳定性,此时引入本地缓存,可以减轻分布式缓存的压力,并减少网络以及序列化开销。
2 读写的性能提升
缓存通过减少 IO 操作来获得读写的性能提升。有一个表格,可以看见磁盘、网络的 IO 操作耗时,远高于内存存取。
- 读优化:当请求命中缓存后,可直接返回,从而略过 IO 读取,减小读的成本。
- 写优化:将写操作在缓冲中合并,让 IO 设备可以批量处理,减小写的成本。
缓存带来的 QPS、RT 提升比较直观,不补充介绍。
3 缓存 Miss
缓存 Miss 是必然会面对的问题,缓存需保证在有限的容量下,将热点的数据维护在缓存中,从而达到性能、成本的平衡。
缓存通常使用 LRU 算法淘汰近期不常用的 Key。
近似 LRU
可以先试想严格 LRU 的实现。假设 Redis 当前有 50W 规模的 key,先通过 Keys 遍历获得所有 Key,然后比对出空闲时间最长的某个 key,最后执行淘汰。这样的流程下来,是非常昂贵的,Keys 命令是一笔不小的开销,其次大规模执行比对也很昂贵。
当然严格 LRU 实现的优化空间还是有的,YY 一下,可以通过活跃度分离出活跃 Key 和待回收 Key, 淘汰时只关注待回收 key 即可;回收算法引入链表或者树的结构,使 Key 按空闲时间有序,淘汰时直接获取。然而这些优化不可避免的是,在缓存读写时,这些辅助的数据结构需要同步更新,带来的存储以及计算的成本很高。
在 Redis 中它采用了近似 LRU 的实现,它随机采样 5 个 Key,淘汰掉其中空闲时间最长的那个。近似 LRU 实现起来更简单、成本更低,在效果上接近严格 LRU。它的缺点是存在一定的几率淘汰掉最近被访问的 Key,即在 TTL 到期前也可能被淘汰。
避免短期大量失效
在一些场景中,程序是批量加载数据到缓存的, 比如通过 Excel 上传数据,系统解析后,批量写入 DB 和缓存。此时若不经设计,这批数据的超时时间往往是一致的。缓存到期后,本该缓存承担的流量将打到 DB 上,从而降低接口甚至系统的性能和稳定性。
可以利用随机数打散缓存失效时间,例如设置 TTL=8hr+random(8000)ms。
4 缓存一致性
系统应尽量保证 DB、缓存的数据一致性,较常使用的是 cache aside 设计模式。
避免使用非常规的缓存设计模式:先更新缓存、后更新 DB;先更新 DB、后更新缓存(cache aside 是直接失效缓存)。这些模式的不一致风险较高。
缓存设计模式
业务系统通常使用 cache aside 模式,操作系统、数据库、分布式缓存等会使用 write throgh、write back。
cache aside 的缓存不一致
Cache aside 模式大部分时间运行良好,在一些极端场景下,仍可能出现不一致风险。主要来自两方面:
- 由于中间件或者网络等问题,缓存失效失败。
- 出现意外的缓存失效、读取的时序。
缓存失效失败很容易理解,不做补充。主要介绍时序引起的不一致问题。
考虑这样的时间轴,A 线程发现 cache miss 后重新加载缓存,此时读的数据还是老的, 另一个线程 B 更新数据并失效缓存。若 B 线程失效缓存的操作完成时间早于 A 线程,A 线程会写入老的数据。
- 本文地址:性能优化:关于缓存的一些思考
- 本文版权归作者和AIQ共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出
缓存不一致有一些缓解方法,例如延迟双删、CDC 同步。这些方案都提升了系统复杂度,需综合考虑业务的容忍度,方案的复杂度等。
- 延迟双删:主线程失效缓存后,将失效指令放入延时队列,另一个线程轮询队列获取指令并执行。
- CDC 同步:通过 canal 订阅 MySQL binlog 的变更,上报给 Kafka,系统监听 Kafka 消息触发缓存失效。
二 从堆内存到直接内存
1 直接内存的引入
Java 本地缓存分两类,基于堆内存的、基于直接内存的。
采用堆内存做缓存的主要问题是 GC,由于缓存对象的生命周期往往较长,需要通过 Major GC 进行回收。若缓存的规模很大,那么 GC 会非常耗时。
采用直接内存做缓存的主要问题是内存管理。程序需自主控制内存的分配和回收,存在 OOM 或者 Memory Leak 的风险。另外直接内存不能存取对象,在操作时需进行序列化。
直接内存能减少 GC 压力,因为它只需要保存直接内存的引用,而对象本身是存储在直接内存中。引用晋升到老年代后占用的空间很小,对 GC 的负担可忽略。
直接内存的回收依赖 System。gc 的调用,但这个调用 JVM 不保证执行、也不保证何时执行,它的行为是不可控的。程序一般需要自行管理,成对去调用 malloc、free,依托于这种“手工、类 C”的内存管理,可以增加内存回收的可控性和灵活性。
2 直接内存管理
由于直接内存的分配和回收比较昂贵,需要通过内核操作物理内存。申请的时候一般是申请大的内存快,然后再根据需求分配小块给线程。回收的时候不直接释放,而是放入内存池来重用。
如何快速找到一个空闲块、如何减少内存碎片、如何快速回收等等,它是一个系统性的问题,也有很多专门的算法。
Jemalloc 是综合能力较好的算法,free BSD、Redis 默认采用了该算法,OHC 缓存也建议服务器配置该算法。Netty 的作者实现了 Java 版本,感兴趣的可以阅读。
三 CPU 缓存
利用上分布式缓存、本地缓存之后,还可以继续提升的就是 CPU 缓存了。它虽不易察觉,但在高并发下对性能存在一定的影响。
CPU 缓存分为 L1、L2、L3 三级,越靠近 CPU 的,容量越小,命中率越高。当 L3 等级的缓存都取不到数据的时候,需从主存中获取。
1 CPU cache line
CPU 缓存由 cache line 组成,每一个 cache line 为 64 字节,能容纳 8 个 long 值。在 CPU 从主存获取数据时,以 cache line 为单位加载,于是相邻的数据会一并加载到缓存中。很容易想到,数组的顺序遍历、相邻数据的计算是非常高效的。
2 伪共享 false sharing
CPU 缓存也存在一致性问题,它通过 MESI 协议、MESIF 协议来保证。
伪共享来源于高并发时 cache line 出现了缓存不一致。同一个 cache line 中的数据会被不同线程修改,它们相互影响,导致处理性能降低。
上图模拟一个伪共享场景,NoPadding 是线程共享对象,thread0 会修改 no0、thread1 会修改 no1。当 thread0 修改时,除了修改自身的 cache line,依据 CPU 缓存协议还会导致 thread1 对应的 cache line 失效,这时 thread1 发现 cache miss 后从主存加载,修改后又导致 thread0 的 cache line 失效。
NoPadding {
long no0;
long no1;
}
3 伪共享解决方案
padding
通过填充,让 no0、no1 落在不同的 cache line 中:
Padding {
long p1, p2, p3, p4, p5, p6, p7;
volatile long no0 = 0L;
long p9, p10, p11, p12, p13, p14;
volatile long no1 = 0L;
}
案例:jctools
Contended 注解
委托 JVM 填充 cache line:
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
案例:JDK 源码中 LongAdder 中的 Cell、ConcurrentHashMap 的 CounterCell。
无锁并发
无锁并发可以从本质上解决伪共享问题,它无需填充 cache line,并且执行效率是最高的。
案例:disruptor
四 总结
近来由于业务对接口 RT 提出了更高的要求,在性能优化的过程中,缓存的使用是非常多的。借此机会记录下在这段时间的思考。私以为,在引入某一项技术的时候,需整体的去看,了解其概念、原理、适用场景、注意事项,这样可以在设计之初就规避掉一些风险。
分布式缓存、本地缓存、CPU 缓存涵盖的内容非常多,本文做了一些归纳。对细节感兴趣的同学可以阅读《Redis 设计与实现》、disruptor 设计文档及代码。
注意:本文归作者所有,未经作者允许,不得转载