当前位置: 首页 > 科技观察

性能优化:关于缓存的一些思考

时间:2023-03-16 23:09:48 科技观察

使用缓存进行性能优化的案例很多,从基础操作系统到数据库,分布式缓存,本地缓存。它们有不同的表现形式,但都有一个共同而简单的本质:弥合CPU的高计算能力和IO的慢速读写之间的巨大差距。与架构选择类似,每引入一个组件都会导致复杂度的增加。以缓存为例,在带来性能提升的同时,也带来了一些问题,需要开发者进行设计和权衡。这篇文章的思路是这样的:一、缓存与多级缓存1、缓存的介绍。当初始业务量较小时,数据库可以承担读写压力,应用可以直接与DB交互。该架构简单而强大。经过一段时间的发展,业务量迎来了大规模的增长,此时DB查询的压力和耗时越来越大。这时候引入了分布式缓存,在降低DB压力的同时提供了更高的QPS。进一步发展,分布式缓存也成为瓶颈,高频QPS成为负担;此外,缓存逐出和网络抖动都会影响系统的稳定性。这时候本地缓存的引入可以减轻分布式缓存的压力,减少网络和序列化的开销。2.提高读写性能缓存通过减少IO操作提高读写性能。有一张表,你可以看到磁盘和网络IO操作比内存访问花费的时间要长得多。读取优化:当请求命中缓存后,可以直接返回,从而跳过IO读取,降低读取成本。写入优化:合并缓冲区中的写入操作,使IO设备可以批量处理,降低写入成本。缓存带来的QPS和RT的提升比较直观,就不做补充介绍了。3、CacheMissCacheMiss是一个不可避免的问题。缓存需要保证热点数据在有限的容量下保持在缓存中,以达到性能和成本的平衡。缓存通常使用LRU算法来剔除最近不经常使用的key。近似LRU首先可以想象一下严格LRU的实现。假设Redis当前有50万个key,首先通过Keys遍历得到所有key,然后比较空闲时间最长的key,最后执行淘汰。这样的过程非常昂贵。Keys命令的开销不小,其次大规模执行比较的开销也很大。当然严格LRU的实现还是有优化空间的。YY,可以通过activity把活跃的key和要回收的key分开,淘汰的时候只关注要回收的key;回收算法引入链表或树结构,使密钥处于空闲状态。时间有规律,淘汰就可以直接拿到。但是,这些优化都不可避免的需要在缓存读写的时候同步更新这些辅助数据结构,导致存储和计算成本很高。在Redis中,它使用了一个近似的LRU实现。它随机抽取5个键并淘汰空闲时间最长的键。近似LRU实现起来更简单、成本更低,并且在效果上接近于严格LRU。它的缺点是最近访问的Key有一定的概率会被淘汰,也就是有可能在TTL到期之前就被淘汰。避免短期大量故障在某些场景下,程序会批量加载数据到缓存中,比如通过Excel上传数据,系统解析后写入DB并批量缓存。如果此时不设计,这批数据的超时时间往往是一致的。缓存过期后,本应由缓存承担的流量会打到DB上,从而降低接口乃至系统的性能和稳定性。可以使用随机数来分散缓存过期时间,比如设置TTL=8hr+random(8000)ms。4.缓存一致性系统要尽量保证DB和缓存的数据一致性,比较常用的是cacheaside设计模式。避免使用非常规的缓存设计模式:先更新缓存,再更新数据库;先更新DB,再更新缓存(cacheaside是直接失效缓存)。这些模式具有更高的不一致风险。缓存设计模式业务系统通常采用cacheaside模式,操作系统、数据库、分布式缓存采用writethrogh和writeback。Cacheaside缓存不一致Cacheaside模式在大多数情况下效果很好,但在某些极端场景下,仍然可能会出现不一致的风险。主要来自两个方面:由于中间件或网络问题导致缓存失效失败。意外的缓存失效,读取时间。缓存失效失败很容易理解,不再补充。主要介绍时序导致的不一致问题。考虑到这样的时间线,线程A在发现缓存未命中后重新加载缓存。此时读取的数据还是旧的,另一个线程B更新数据,使缓存失效。如果线程B缓存失效操作的完成时间早于线程A,则线程A将写入旧数据。缓存不一致的缓解方法有延迟双删、CDC同步等。这些解决方案增加了系统的复杂度,需要综合考虑业务的容忍度和解决方案的复杂度。延迟双删:主线程使缓存失效后,将失效的指令放入延迟队列,另一个线程轮询队列获取指令并执行。CDC同步:通过canal订阅MySQLbinlog变化,上报给Kafka,系统监听Kafka消息触发缓存失效。二、从堆内存到直接内存1、直接内存介绍Java本地缓存分为两类,基于堆内存的和基于直接内存的。使用堆内存作为缓存的主要问题是GC。因为缓存对象的生命周期往往很长,所以需要通过MajorGC进行回收。如果缓存的大小很大,GC会非常耗时。使用直接内存作为缓存的主要问题是内存管理。程序需要独立控制内存的分配和回收,存在OOM或者MemoryLeak的风险。另外,直接内存不能访问对象,操作时需要序列化。直接内存可以减少GC压力,因为它只需要保存直接内存的引用,而对象本身是存放在直接内存中的。引用提升到老年代后,占用的空间非常小,对GC的负担可以忽略不计。直接内存的回收依赖于System.gc调用,但是这个调用JVM不保证执行,也不保证什么时候执行,其行为是不可控的。程序一般需要自己管理,成对调用malloc和free。依靠这种“手动的,类C”的内存管理,可以增加内存回收的可控性和灵活性。2.直接内存管理由于直接内存的分配和回收比较昂贵,所以需要通过内核来操作物理内存。申请的时候一般是先申请一个大内存块,然后根据需要分配小块给线程。回收的时候不是直接释放,而是放到内存池中重复使用。如何快速找到空闲块,如何减少内存碎片,如何快速回收等等,是一个系统性的问题,有很多专门的算法。Jemalloc是一个综合能力很好的算法。FreeBSD和Redis默认采用这种算法。OHC缓存也建议服务器配置此算法。Netty的作者已经实现了Java版,有兴趣的可以去看看。3、CPU缓存采用分布式缓存和本地缓存后,CPU缓存还是可以提升的。虽然不易察觉,但在高并发下对性能有一定的影响。CPU缓存分为三级:L1、L2、L3。离CPU越近,容量越小,命中率越高。当无法从L3缓存中取数据时,需要从主存中取数据。1.CPUcachelineCPUcache由cacheline组成,每条cacheline为64字节,可以容纳8个long值。当CPU从主存中获取数据时,是以缓存行为单位加载的,所以相邻的数据会一起加载到缓存中。很容易想到数组的顺序遍历和相邻数据的计算是非常高效的。2.伪共享伪共享CPU缓存还有一个一致性问题,由MESI协议和MESIF协议来保证。假共享来源于高并发时缓存行缓存不一致。同一个缓存行中的数据会被不同的线程修改,相互影响,导致处理性能下降。上图模拟了一个伪共享场景。NoPadding是线程共享对象,thread0会修改no0,thread1会修改no1。当thread0被修改时,除了修改自己的cacheline外,thread1对应的cacheline也会根据CPU缓存协议失效。此时thread1发现cachemiss并从主存加载,修改导致thread0的cacheline失效。NoPadding{longno0;longno1;}3.伪共享解padding让no0和no1落入不同的cacheline:Padding{longp1,p2,p3,p4,p5,p6,p7;volatilelongno0=0L;longp9,p10,p11,p12,p13,p14;volatilelongno1=0L;}案例:jctoolsContended注解委托JVM填充缓存行:@sun.misc.ContendedstaticfinalclassCounterCell{volatilelongvalue;CounterCell(longx){value=x;}}案例:JDK源码CellinLongAdder,ConcurrentHashMap中的CounterCell。Lock-freeconcurrencyLock-freeconcurrency可以从本质上解决falsesharing的问题,不需要填充cacheline,执行效率最高。案例:disruptor4.总结最近由于业务对接口RT的要求比较高,在性能优化的过程中,缓存的使用量非常大。借此机会记录一下这段时间的想法。我个人认为,在介绍某项技术时,需要从整体上看,了解其概念、原理、适用场景和注意事项,这样在设计之初就可以规避一些风险。分布式缓存、本地缓存、CPU缓存涵盖了很多内容,本文做一些总结。对细节感兴趣的同学可以阅读《Redis 设计与实现》,disruptor设计文档和代码。