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

同步更新 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


上一篇:《问卷数据分析——破解SPSS的六类分析思路》| 每日读本书


下一篇:javascript设计模式--接口