|
| 1 | +# 选择合适的缓存过期策略 |
| 2 | + |
| 3 | +## 1 屈服于现实的磁盘 |
| 4 | + |
| 5 | +MQ都使用磁盘来存储消息。这样服务器下电也不会丢数据。绝大多数用于生产系统的服务器,都会使用多块磁盘组成磁盘阵列,这样即使其中的一块异常,也可把数据从其他磁盘中恢复。 |
| 6 | + |
| 7 | +另外磁盘也便宜,就可用较低成本,存储海量消息。所以,不仅仅是MQ,几乎所有存储系统的数据,都需保存到磁盘。 |
| 8 | + |
| 9 | +但磁盘读写很慢。SSD可读写几千次/s,若程序在处理业务请求时直接读写磁盘,假设处理每次请求需要读写3~5次,即使每次请求数据量不大,程序最多也就能处理1000次/s左右请求。 |
| 10 | + |
| 11 | +而内存随机读写速度是磁盘10万倍!内存作为缓存来加速程序访问速度,是所有高性能系统都会采用的方案。 |
| 12 | + |
| 13 | +缓存思想简单,就是把低速存储的数据,复制一份放到高速存储,加速数据访问。使用也简单 |
| 14 | + |
| 15 | +业务开发时,在一些执行较慢方法上加@Cacheable: |
| 16 | + |
| 17 | + |
| 18 | + |
| 19 | +## 2 缓存难题 |
| 20 | + |
| 21 | +- 采用@Cacheable注解缓存的命中率如何? |
| 22 | +- 咋才能提高缓存命中率? |
| 23 | +- 缓存是否总能返回最新数据? |
| 24 | +- 缓存若返回过期数据咋办? |
| 25 | + |
| 26 | +下面来看看只读缓存 VS 读写缓存,唯一区别:更新数据时,是否经过缓存。 |
| 27 | + |
| 28 | +## 3 读写缓存 |
| 29 | + |
| 30 | +Kafka的PageCache,典型的读写缓存。os会利用系统空闲物理内存给文件读写做缓存,即PageCache。应用程序在写文件时,os先把数据写入PageCache,成功写进后,对于用户代码,写入就结束了。 |
| 31 | + |
| 32 | +然后,os再异步更新数据到磁盘。应用程序在读文件时,os先尝试从PageCache查数据,找到就直接返回,找不到就触发一个缺页中断,然后os把数据从文件读到PageCache,再返给应用程序。 |
| 33 | + |
| 34 | +数据写到PageCache后,并非同时写磁盘,期间有延迟。os可保证即使程序异常退出,os也会把这部分数据同步到磁盘。但若服务器突然下电,这部分数据就丢了。 |
| 35 | + |
| 36 | +读写缓存的设计,本身就不可靠,牺牲数据一致性换取性能。当然,程序可调用sync等系统调用,强制os立即把缓存数据同步到磁盘文件,但该同步过程很慢,也失去缓存意义。 |
| 37 | + |
| 38 | +写缓存实现复杂。应用程序不停更新PageCache数据,os需记录哪些数据变化,同时还要在另外一个线程,把缓存中变化的数据更新到磁盘。 |
| 39 | +在提供并发读写同时异步更新数据,这过程要保证数据一致性,且有非常好性能,可为强人锁男。所以不推荐使用读写缓存。 |
| 40 | + |
| 41 | +### 为啥Kafka可用PageCache提升性能? |
| 42 | + |
| 43 | +这由MQ特点决定。 |
| 44 | + |
| 45 | +MQ读写比例大致1:1,因大部分MQ都是一收一发。这种读写比例,只读缓存既无法给写加速,读加速也有限,并不能提升啥性能。 |
| 46 | +Kafka并不是只靠磁盘保证数据可靠性,它更依赖在不同节点上的多副本保证数据可靠性,这样即使某服务器掉电丢失一部分文件内容,也可从其他节点找到正确数据,不丢消息。 |
| 47 | + |
| 48 | +而且PageCache读写缓存是os实现,Kafka只要按照正确姿势使用即可,不涉及实现复杂度问题。所以,Kafka设计上充分利用PageCache读写缓存优势,且规避PageCache一些劣势,达到很好效果。 |
| 49 | + |
| 50 | +大部分其他MQ,也采用读写缓存加速消息写入,只是实现方式不同。 |
| 51 | + |
| 52 | +不同于MQ,大部分业务类程序,读写比都是严重不均衡,一般读频率远高于写数,一般都几倍到几十倍。使用只读缓存来加速系统才是明智选择。 |
| 53 | + |
| 54 | +## 4 只读缓存 |
| 55 | + |
| 56 | +又该考虑啥问题? |
| 57 | + |
| 58 | +### 4.1 维护缓存数据时效性 |
| 59 | + |
| 60 | +对只读缓存,缓存中数据源只有一个途径:磁盘。当数据需更新,磁盘数据和缓存副本都要更新。分布式系统中,除非使用事务(性能差)或分布式一致性算法(复杂)保证数据一致性。否则,由于节点宕机、网络传输故障等,无法保证缓存和磁盘中数据完全一致。 |
| 61 | + |
| 62 | +若出现数据不一致,数据一定是以磁盘上那份拷贝为准。需解决问题:尽量让缓存数据与磁盘数据保持同步。 |
| 63 | + |
| 64 | +### 4.2 何时更新缓存数据 |
| 65 | + |
| 66 | +更新磁盘数据同时,更新缓存数据不就行?想法没问题,缓存中数据会一直保持最新。但并发环境下实现不太容易。 |
| 67 | + |
| 68 | +#### ① 同步更新 VS 异步更新缓存 |
| 69 | + |
| 70 | +- 如同步,更新磁盘成功,但更新缓存失败,你是不是要反复重试保证更新成功?如多次重试失败,那这次更新是算成功还是失败? |
| 71 | +- 如异步,咋保证更新时序? |
| 72 | + |
| 73 | +比如,先把一个文件中某数据设0,然后又设1,这时文件中数据肯定是1,但缓存数据不一定是1。因为把缓存中数据更新为0,和更新为1是两个并发的异步操作,无法保证谁先执行。 |
| 74 | +这些问题都会导致缓存和磁盘数据不一致,而且,下次更新这条数据前,这个不一致问题一直存在。这些问题也不是不能解决,如用分布式事务,但牺牲性能、实现复杂度,代价过于大。 |
| 75 | + |
| 76 | +另一种较简单方法 |
| 77 | + |
| 78 | +#### ② 定时刷盘 |
| 79 | + |
| 80 | +一般每次同步时直接全量更新,因为是在异步线程中更新,同步速度即使慢点也不是大问题。 |
| 81 | +如果缓存数据太大,更新慢到无法接受,也可增量更新,每次只更新从上次缓存同步至今这段时间内变化的数据,就是实现起来稍微复杂。 |
| 82 | + |
| 83 | +如果说,某次同步过程中发生错误,等到下一个同步周期也会自动把数据纠正过来。这种定时同步缓存的方法,缺点是缓存更新不那么及时,优点是实现非常简单,鲁棒性好。 |
| 84 | + |
| 85 | +更简单的方法: |
| 86 | + |
| 87 | +#### ③ TTL |
| 88 | + |
| 89 | +从不更新缓存数据,而是给缓存中的每条数据设较短TTL,数据过期后即使还存在缓存,也认为不再有效,需从磁盘再次加载,变相实现数据更新。 |
| 90 | + |
| 91 | +很多情况下,缓存数据更新不及时,业务其实也能接受,如: |
| 92 | + |
| 93 | +- 刚发一封邮件,收件人过会儿才收到 |
| 94 | +- 或你改了自己头像,一段时间内,你的好友看到还是旧头像 |
| 95 | + |
| 96 | +这都可接受。这种对数据一致性不那么敏感场景,一定要选择后两种方法。 |
| 97 | + |
| 98 | +而类似交易系统的,对数据一致性敏感。如给别人转一笔钱,别人查询自己余额却没变化,这无法接受!对这样的系统,一般都不使用缓存或使用提到的第一种方法,在更新数据时同时更新缓存。 |
| 99 | + |
| 100 | +## 5 缓存置换 |
| 101 | + |
| 102 | +除考虑数据一致性,还需关注内存有限,要优先缓存哪些数据,让缓存命中率最高。 |
| 103 | + |
| 104 | +当程序要访问某些数据时,如果这些数据在缓存,那直接访问缓存中数据,这次访问速度很快,即缓存命中; |
| 105 | +如这些数据不在缓存=》只能去磁盘=》较慢=》“缓存穿透”。 |
| 106 | +显然,缓存命中率越高,程序总体性能越好。 |
| 107 | + |
| 108 | +用啥策略选择缓存的数据,能使缓存命中率尽量高? |
| 109 | + |
| 110 | +系统是可预测未来访问哪些数据的,如定期数据同步,每次同步数据范围都一样,这样的系统,缓存策略简单,你要访问啥数据,就缓存啥数据,甚至可做到百分百命中。 |
| 111 | +但大部分系统无法准确预测会有哪些数据会被访问,只能使用一些策略尽可能提高命中率。 |
| 112 | + |
| 113 | +一般会在数据首次被访问时,顺便把这条数据放到缓存。随访问数据越来越多,总有把缓存占满时,就要把缓存中一些数据删除,以存放新数据,即缓存置换。这就带来问题:当缓存满,删除哪些数据,会使缓存命中率更高,采用啥置换策略呢?命中率最高的置换策略,一定是根据你的业务定制化的,如: |
| 114 | + |
| 115 | +- 你如果知道某些数据已删除,永远不会再访问,优先置换这些数据肯定没问题 |
| 116 | +- 有会话的系统,你知道现在哪些用户在线,哪些用户离线,优先置换那些已离线用户的数据,尽量保留在线用户的数据也是好策略 |
| 117 | +- 使用通用置换算法LRU:最近刚刚被访问的数据,它在将来被访问的可能性也很大,而很久都没被访问过的数据,未来再被访问的几率也不大。LRU原理简单,总把最长时间未被访问的数据置换出去。别看这么简单,效果非常非常好 |
| 118 | + |
| 119 | +Kafka使用的PageCache,是由Linux内核实现,它的置换算法的就是一种LRU变种体:LRU 2Q。设计JMQ缓存策略时,也是采用一种改进LRU算法。 |
| 120 | +LRU淘汰最近最少使用的页,JMQ根据消息这种流数据存储的特点,在淘汰时增个考量维度:页面位置与尾部的距离。因为越是靠近尾部的数据,被访问的概率越大。 |
| 121 | + |
| 122 | +综合考虑下的淘汰算法,不仅命中率更高,还能有效地避免“挖坟”问题:例如某个客户端正在从很旧的位置开始向后读取批历史数据,内存中缓存很快都会被替换成这些历史数据,相当于大部分缓存资源都被消耗,这会导致其他客户端访问命中率下降。加入位置权重后,比较旧的页面会很快被淘汰掉,减少“挖坟”对系统影响。所以经常看到很多挖坟贴不再提供任何服务功能,甚至还会被删除。 |
| 123 | + |
| 124 | +## 6 总结 |
| 125 | + |
| 126 | +按读写性质,可分为读写缓存和只读缓存,读写缓存实现复杂,且只在MQ等少数情况适用。 |
| 127 | +只读缓存适用的范围更广,实现更简单。 |
| 128 | + |
| 129 | +在实现只读缓存的时候,要考虑的第一问题是咋更新缓存: |
| 130 | + |
| 131 | +1. 更新数据的同时,去更新缓存 |
| 132 | +2. 定期更新全部缓存 |
| 133 | +3. 给缓存中的每个数据设置一个TTL,让它自然过期以达到更新目的 |
| 134 | + |
| 135 | +三种方法在更新及时性和实现复杂度两方面,都是依次递减,可按需选择。 |
| 136 | + |
| 137 | +缓存的置换策略,最优策略一定是你根据业务来设计的定制化的置换策略,当然也可考虑LRU这样通用的缓存置换算法。 |
| 138 | + |
| 139 | +## 7 手写LRU缓存置换 |
| 140 | + |
| 141 | +```java |
| 142 | +/** |
| 143 | + * KV存储抽象 |
| 144 | + */ |
| 145 | +public interface Storage<K,V> { |
| 146 | + /** |
| 147 | + * 根据提供的key来访问数据 |
| 148 | + * @param key 数据Key |
| 149 | + * @return 数据值 |
| 150 | + */ |
| 151 | + V get(K key); |
| 152 | +} |
| 153 | + |
| 154 | +/** |
| 155 | + * LRU缓存。你需要继承这个抽象类来实现LRU缓存。 |
| 156 | + * @param <K> 数据Key |
| 157 | + * @param <V> 数据值 |
| 158 | + */ |
| 159 | +public abstract class LruCache<K, V> implements Storage<K,V>{ |
| 160 | + // 缓存容量 |
| 161 | + protected final int capacity; |
| 162 | + // 低速存储,所有的数据都可以从这里读到 |
| 163 | + protected final Storage<K,V> lowSpeedStorage; |
| 164 | + |
| 165 | + public LruCache(int capacity, Storage<K,V> lowSpeedStorage) { |
| 166 | + this.capacity = capacity; |
| 167 | + this.lowSpeedStorage = lowSpeedStorage; |
| 168 | + } |
| 169 | +} |
| 170 | +``` |
| 171 | + |
| 172 | +需继承LruCache,实现自己的LRU缓存。lowSpeedStorage是提供给你可用的低速存储,你不需要实现它。 |
| 173 | + |
| 174 | +- https://github.com/swgithub1006/mqlearning |
| 175 | +- https://gist.github.com/imgaoxin/ed59397c895b5a8a9572408b98542015 |
0 commit comments