Skip to content

Commit 13f39b2

Browse files
committed
perf:修正标题
1 parent 5b5c64a commit 13f39b2

File tree

3 files changed

+184
-8
lines changed

3 files changed

+184
-8
lines changed

docs/.vuepress/config.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ module.exports = {
391391
},
392392

393393
{
394-
text: '微服务',
394+
text: '分布式',
395395
items: [{
396396
text: 'Dubbo',
397397
items: [{
@@ -1228,12 +1228,12 @@ module.exports = {
12281228
collapsable: false,
12291229
sidebarDepth: 0,
12301230
children: [
1231-
"00-数据结构的最佳实践.md",
1232-
"01-Redis和ZK分布式锁优缺点对比以及生产环境使用建议.md",
1233-
"02-Redisson可重入锁加锁源码分析.md",
1234-
"03-Redisson公平锁加锁源码分析.md",
1235-
"04-Redisson读写锁加锁机制分析.md",
1236-
"缓存读写策略模式详解",
1231+
"00-数据结构的最佳实践",
1232+
"01-Redis和ZK分布式锁优缺点对比以及生产环境使用建议",
1233+
"02-Redisson可重入锁加锁源码分析",
1234+
"03-Redisson公平锁加锁源码分析",
1235+
"04-Redisson读写锁加锁机制分析",
1236+
"05-缓存读写策略模式详解",
12371237
"06-如何快速定位 Redis 热 key"
12381238
]
12391239
}],
@@ -1300,6 +1300,7 @@ module.exports = {
13001300
"死磕设计模式之抽象策略模式.md",
13011301
// "Builder模式在项目设计中的应用.md",
13021302
"单例+简单工厂模式在项目设计中的应用.md",
1303+
"选择合适的缓存过期策略",
13031304
"12306架构设计难点.md"
13041305
]
13051306
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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+
![](https://my-img.javaedge.com.cn/javaedge-blog/2024/06/3c06b7672e3cb3d32db25ff2372af004.png)
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

docs/md/redis/缓存读写策略模式详解.md renamed to docs/md/redis/05-缓存读写策略模式详解.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# 缓存读写策略模式详解
1+
# 05-缓存读写策略模式详解
22

33
缓存读写操作,很多人不假思索:
44

0 commit comments

Comments
 (0)