Java高性能系统缓存的最佳实践

1 屈服于现实的磁盘

MQ都使用磁盘来存储消息。这样服务器下电也不会丢数据。绝大多数用于生产系统的服务器,都会使用多块磁盘组成磁盘阵列,这样即使其中的一块异常,也可把数据从其他磁盘中恢复。

另外磁盘也便宜,就可用较低成本,存储海量消息。所以,不仅仅是MQ,几乎所有存储系统的数据,都需保存到磁盘。

但磁盘读写很慢。SSD可读写几千次/s,若程序在处理业务请求时直接读写磁盘,假设处理每次请求需要读写3~5次,即使每次请求数据量不大,程序最多也就能处理1000次/s左右请求。

而内存随机读写速度是磁盘10万倍!内存作为缓存来加速程序访问速度,是所有高性能系统都会采用的方案。

缓存思想简单,就是把低速存储的数据,复制一份放到高速存储,加速数据访问。使用也简单

  • 在做业务系统时,在一些执行较慢方法上加个@Cacheable

2 缓存最佳实践

采用@Cacheable注解缓存的命中率如何?
怎样才能提高缓存命中率?
缓存是否总能返回最新的数据?
如果缓存返回了过期的数据该怎么办?

只读缓存 VS 读写缓存

唯一区别:更新数据时,是否经过缓存。

Kafka使用的PageCache,是个典型的读写缓存。os会利用系统空闲物理内存给文件读写做缓存,这缓存叫做PageCache。应用程序在写文件时,os会先把数据写入PageCache,成功写进后,对于用户代码,写入就结束了。

然后,os再异步更新数据到磁盘。应用程序在读文件时,os是先尝试从PageCache查数据,找到就直接返回,找不到会触发一个缺页中断,然后os把数据从文件读取到PageCache,再返回给应用程序。

数据写到PageCache后,并不是同时写到磁盘,这其间有个延迟。
os可保证即使程序异常退出,os也会把这部分数据同步到磁盘。但若服务器都突然掉下电,这部分数据就丢了。

读写缓存的设计,本身就不可靠,牺牲数据一致性换取性能。当然,程序可以调用sync等系统调用,强制操作系统立即把缓存数据同步到磁盘文件中去,但是该同步过程很慢,也失去了缓存意义。

写缓存实现非常复杂。应用程序不停更新PageCache数据,os需记录哪些数据变化,同时还要在另外一个线程,把缓存中变化的数据更新到磁盘。
在提供并发读写同时异步更新数据,这过程要保证数据一致性,且有非常好性能,可为强人锁男。
所以不推荐使用读写缓存。

那为什Kafka可使用PageCache提升性能?
这由MQ特点决定。

MQ读写比例大致1:1,因大部分MQ都是一收一发。这种读写比例,只读缓存既无法给写加速,读加速也有限,并不能提升多少性能。
Kafka并不是只靠磁盘保证数据可靠性,它更依赖在不同节点上的多副本保证数据可靠性,这样即使某服务器掉电丢失一部分文件内容,也可从其他节点找到正确数据,不会丢消息。

而且PageCache读写缓存是os实现,Kafka只要按照正确姿势使用即可,不涉及实现复杂度问题。所以,Kafka其实在设计上,充分利用PageCache读写缓存的优势,且规避了PageCache一些劣势,达到很好效果。

和Kafka一样,大部分其他MQ,也会采用读写缓存加速消息写入,只是实现方式不同。

不同于MQ,大部分业务类程序,读写比都是严重不均衡,一般读频率远高于写数,一般都几倍到几十倍。使用只读缓存来加速系统才是明智选择。

设计只读缓存又该考虑哪些问题呢?

维护缓存数据时效性

对只读缓存,缓存中数据源只有一个途径:磁盘。当数据需更新时,磁盘数据和缓存副本都需更新。在分布式系统中,除非是使用事务(性能差)或者一些分布式一致性算法(复杂)保证数据一致性。否则,由于节点宕机、网络传输故障等,是无法保证缓存中数据和磁盘中的数据完全一致的。

若出现数据不一致,数据一定是以磁盘上那份拷贝为准的。
需解决问题:尽量让缓存数据与磁盘数据保持同步。

何时更新缓存数据

在更新磁盘数据同时,更新下缓存数据不就行?想法没任何问题,缓存中数据会一直保持最新。但在并发环境,实现起来不太容易。

同步更新 VS异步更新缓存

  • 如果同步,更新磁盘成功了,但更新缓存失败了,你是不是要反复重试保证更新成功?如果多次重试都失败,那这次更新是算成功还是失败?
  • 如果是异步,怎么保证更新时序?

比如,我先把一个文件中某个数据设成0,然后又设为1,这时文件中数据肯定是1,但缓存中数据不一定是1。因为把缓存中数据更新为0,和更新为1是两个并发的异步操作,无法保证谁先执行。
这些问题都会导致缓存数据和磁盘数据不一致,而且,在下次更新这条数据前,这个不一致问题一直存在。
当然,这些问题也不是不能解决,比如使用分布式事务,只是牺牲性能、实现复杂度,代价很大。

另一种较简单方法

定时刷盘

一般每次同步时直接全量更新,因为是在异步线程中更新,同步速度即使慢点也不是大问题。
如果缓存数据太大,更新慢到无法接受,也可选择增量更新,每次只更新从上次缓存同步至今这段时间内变化的数据,代价是实现起来会稍微有些复杂。

如果说,某次同步过程中发生了错误,等到下一个同步周期也会自动把数据纠正过来。这种定时同步缓存的方法,缺点是缓存更新不那么及时,优点是实现起来非常简单,鲁棒性非常好。

更简单的方法

TTL

从不更新缓存数据,而是给缓存中的每条数据设较短的过期时间,数据过期后即使还存在缓存,也认为不再有效,需从磁盘再次加载这数据,变相实现数据更新。

很多情况下,缓存数据更新不及时,系统也能够接受。
比如你刚发了一封邮件,收件人过了一会儿才收到。或你改了自己头像,在一段时间内,你的好友看到还是旧头像,都可接受。
这种对数据一致性没有那么敏感场景,一定要选择后两种方法。

而像交易系统,对数据一致性敏感。
比如,你给别人转了一笔钱,别人查询自己余额却没变化,这肯定无法接受。对这样系统,一般都不使用缓存或使用提到的第一种方法,在更新数据时同时更新缓存。

缓存置换

除考虑数据一致性,还需关注内存有限,要优先缓存哪些数据,让缓存命中率最高。

当程序要访问某些数据时,如果这些数据在缓存,那直接访问缓存中数据,这次访问速度很快,称为缓存命中;
如果这些数据不在缓存=》只能去磁盘=》较慢=》“缓存穿透”。
显然,缓存命中率越高,程序总体性能越好。

那用什么策略选择缓存的数据,能使缓存命中率尽量高?

如果你的系统是那种可预测未来访问哪些数据的,比如有的系统它会定期做数据同步,每次同步数据范围都一样,这样的系统,缓存策略简单,你要访问什么数据,就缓存什么数据,甚至可做到百分百命中。
但大部分系统没办法准确预测会有哪些数据会被访问,只能使用一些策略尽可能地提高命中率。

一般都会在数据首次被访问时,顺便把这条数据放到缓存。
随访问数据越来越多,总有把缓存占满时,这时就需要把缓存中一些数据删除,以存放新数据,这过程称为缓存置换。
问题就成了:当缓存满,删除哪些数据,会使缓存命中率更高,采用什么置换策略呢。
命中率最高的置换策略,一定是根据你的业务定制化的。
比如,你如果知道某些数据已删除,永远不会再访问,那优先置换这些数据肯定没问题。
再比如,有会话的系统,你知道现在哪些用户是在线,哪些用户已离线,那优先置换那些已离线用户的数据,尽量保留在线用户的数据也是好策略。

  • 另外就是使用通用置换算法LRU
    最近刚刚被访问的数据,它在将来被访问的可能性也很大,而很久都没被访问过的数据,未来再被访问的几率也不大。

LRU原理简单,总把最长时间未被访问的数据置换出去。别看这么简单,效果非常非常好。

Kafka使用的PageCache,是由Linux内核实现,它的置换算法的就是一种LRU变种体:LRU 2Q。设计JMQ缓存策略时,也是采用一种改进LRU算法。
LRU淘汰最近最少使用的页,JMQ根据消息这种流数据存储的特点,在淘汰时增个考量维度:页面位置与尾部的距离。因为越是靠近尾部的数据,被访问的概率越大。

综合考虑下的淘汰算法,不仅命中率更高,还能有效地避免“挖坟”问题:例如某个客户端正在从很旧的位置开始向后读取批历史数据,内存中缓存很快都会被替换成这些历史数据,相当于大部分缓存资源都被消耗,这会导致其他客户端访问命中率下降。加入位置权重后,比较旧的页面会很快被淘汰掉,减少“挖坟”对系统影响。所以经常看到很多挖坟贴不再提供任何服务功能,甚至还会被删除。

总结

按读写性质,可分为读写缓存和只读缓存,读写缓存实现复杂,且只在MQ等少数情况适用。
只读缓存适用的范围更广,实现更简单。

在实现只读缓存的时候,你需要考虑的第一个问题是如何来更新缓存。这里面有三种方法

  1. 在更新数据的同时去更新缓存
  2. 定期来更新全部缓存
  3. 给缓存中的每个数据设置一个有效期,让它自然过期以达到更新的目的

这三种方法在更新的及时性上和实现的复杂度这两方面,都是依次递减的,你可以按需选择。

对于缓存的置换策略,最优的策略一定是你根据业务来设计的定制化的置换策略,当然你也可以考虑LRU这样通用的缓存置换算法。

手写LRU缓存置换

/**
 * KV存储抽象
 */
public interface Storage<K,V> {
    /**
     * 根据提供的key来访问数据
     * @param key 数据Key
     * @return 数据值
     */
    V get(K key);
}

/**
 * LRU缓存。你需要继承这个抽象类来实现LRU缓存。
 * @param <K> 数据Key
 * @param <V> 数据值
 */
public abstract class LruCache<K, V> implements Storage<K,V>{
    // 缓存容量
    protected final int capacity;
    // 低速存储,所有的数据都可以从这里读到
    protected final Storage<K,V> lowSpeedStorage;

    public LruCache(int capacity, Storage<K,V> lowSpeedStorage) {
        this.capacity = capacity;
        this.lowSpeedStorage = lowSpeedStorage;
    }
}

需继承LruCache,实现自己的LRU缓存。lowSpeedStorage是提供给你可用的低速存储,你不需要实现它。

  • https://github.com/swgithub1006/mqlearning
  • https://gist.github.com/imgaoxin/ed59397c895b5a8a9572408b98542015

猜你喜欢

转载自blog.csdn.net/qq_33589510/article/details/107873811