性能优化的方式有很多,前一篇文章已经讲过了索引可以带来性能的提升;当有索引性能依旧不理想时可以考虑下缓存当面。使用缓存做性能优化解决的最根本的问题:弥补CPU的高算力和IO的慢读写之间巨大的鸿沟。
缓存和多级缓存
缓存的引入
初期业务量小的时候,数据库能承担读写压力,应用可以直接和DB交互,架构简单且强壮。
经过一段时间发展后,业务量迎来了大规模增长,此时DB查询压力和耗时都在增长。此时引入分布式缓存,在减少DB压力的同时,还提供了更高的QPS。
再往后发展,分布式缓存也成为了瓶颈,高频的QPS是一笔负担;另外缓存驱逐以及网络抖动会影响系统的稳定性,此时引入本地缓存,可以减轻分布式缓存的压力,并减少网络以及序列化开销。
读写的性能提升
缓存通过减少IO操作来获得读写的性能提升。有一个表格,可以看见磁盘、网络的IO操作耗时,远高于内存存取。
- 读优化:当请求命中缓存后,可直接返回,从而略过IO读取,减小读的成本。
- 写优化:将写操作在缓冲中合并,让IO设备可以批量处理,减小写的成本。
缓存Miss
缓存Miss是必然会面对的问题,缓存需保证在有限的容量下,将热点的数据维护在缓存中,从而达到性能、成本的平衡。
缓存通常使用LRU算法淘汰近期不常用的Key。
如何避免短期大量失效
在一些场景中,程序是批量加载数据到缓存的, 比如通过Excel上传数据,系统解析后,批量写入DB和缓存。此时若不经设计,这批数据的超时时间往往是一致的。缓存到期后,本该缓存承担的流量将打到DB上,从而降低接口甚至系统的性能和稳定性。
可以利用随机数打散缓存失效时间,例如设置TTL=Nhr+random(8000)ms。
缓存一致性
系统应尽量保证DB、缓存的数据一致性,较常使用的是cache aside设计模式。
避免使用非常规的缓存设计模式:先更新缓存、后更新DB;先更新DB、后更新缓存(cache aside是直接失效缓存)。这些模式的不一致风险较高。
缓存设计模式
业务系统通常使用cache aside 模式,操作系统、数据库、分布式缓存等会使用write throgh、write back。
cache aside的缓存不一致
Cache aside模式大部分时间运行良好,在一些极端场景下,仍可能出现不一致风险。主要来自两方面:
- 由于中间件或者网络等问题,缓存失效失败。
- 出现意外的缓存失效、读取的时序。
从堆内存到直接内存
直接内存的引入
Java本地缓存分两类,基于堆内存的、基于直接内存的。
采用堆内存做缓存的主要问题是GC,由于缓存对象的生命周期往往较长,需要通过Major GC进行回收。若缓存的规模很大,那么GC会非常耗时。
采用直接内存做缓存的主要问题是内存管理。程序需自主控制内存的分配和回收,存在OOM或者Memory Leak的风险。另外直接内存不能存取对象,在操作时需进行序列化。
直接内存能减少GC压力,因为它只需要保存直接内存的引用,而对象本身是存储在直接内存中。引用晋升到老年代后占用的空间很小,对GC的负担可忽略。
直接内存的回收依赖System。gc的调用,但这个调用JVM不保证执行、也不保证何时执行,它的行为是不可控的。程序一般需要自行管理,成对去调用malloc、free,依托于这种“手工、类C”的内存管理,可以增加内存回收的可控性和灵活性。
直接内存管理
由于直接内存的分配和回收比较昂贵,需要通过内核操作物理内存。申请的时候一般是申请大的内存快,然后再根据需求分配小块给线程。回收的时候不直接释放,而是放入内存池来重用。
如何快速找到一个空闲块、如何减少内存碎片、如何快速回收等等,它是一个系统性的问题,也有很多专门的算法。
Jemalloc是综合能力较好的算法,free BSD、Redis默认采用了该算法,OHC缓存也建议服务器配置该算法。
CPU缓存
利用上分布式缓存、本地缓存之后,还可以继续提升的就是CPU缓存了。它虽不易察觉,但在高并发下对性能存在一定的影响。
CPU缓存分为L1、L2、L3 三级,越靠近CPU的,容量越小,命中率越高。当L3等级的缓存都取不到数据的时候,需从主存中获取。
CPU cache line
CPU缓存由cache line组成,每一个cache line为64字节,能容纳8个long值。在CPU从主存获取数据时,以cache line为单位加载,于是相邻的数据会一并加载到缓存中。很容易想到,数组的顺序遍历、相邻数据的计算是非常高效的。
伪共享 false sharing
CPU缓存也存在一致性问题,它通过MESI协议、MESIF协议来保证。
伪共享来源于高并发时cache line出现了缓存不一致。同一个cache line中的数据会被不同线程修改,它们相互影响,导致处理性能降低。