Update 缓存.md

补充缓存命中率的示例和缓存无底洞的解决方法,更新一致性哈希的数据,增加实测附录。
This commit is contained in:
CyC2018
2026-10-28 03:19:07 +08:00
parent 40067d8ef9
commit 80f3696960
+104 -18
View File
@@ -8,6 +8,7 @@
* [五、数据分布](#五数据分布)
* [六、一致性哈希](#六一致性哈希)
* [七、LRU](#七lru)
* [附录:实测](#附录实测)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -18,11 +19,11 @@
当某个请求能够通过访问缓存而得到响应时,称为缓存命中。
缓存命中率越高,缓存的利用率也就越高。
命中率指的是命中的请求占全部请求的比例,命中率越高,缓存的利用率也就越高。例如命中率从 90% 提高到 99%,未命中而到达数据库的请求从 10% 降为 1%,数据库的压力降为原来的 1/10。
### 最大空间
缓存通常位于内存中,内存的空间通常比磁盘空间小的多,因此缓存的最大空间不可能非常大。
缓存通常位于内存中,内存的空间通常比磁盘空间小得多,因此缓存的最大空间不可能非常大。
当缓存存放的数据量超过最大空间时,就需要淘汰部分数据来存放新到达的数据。
@@ -30,7 +31,7 @@
- FIFO(First In First Out):先进先出策略,优先淘汰最早进入缓存的数据。实现简单,但是没有考虑数据的访问情况,命中率较低。
- LRU(Least Recently Used):最近最久未使用策略,优先淘汰最久未使用的数据,也就是上次被访问时间距离现在最久的数据。该策略可以保证内存中的数据都是热点数据,也就是经常被访问的数据,从而保证缓存命中率。缺点是偶发的批量访问会把热点数据挤出缓存。
- LRU(Least Recently Used):最近最久未使用策略,优先淘汰最久未使用的数据,也就是上次被访问时间距离现在最久的数据。该策略尽量把热点数据,也就是经常被访问的数据保留在内存中,从而保证缓存命中率。缺点是偶发的批量访问会把热点数据挤出缓存。
- LFU(Least Frequently Used):最不经常使用策略,优先淘汰一段时间内使用次数最少的数据。缺点是过去的热点数据即使很久不再访问也难以被淘汰。
@@ -66,7 +67,7 @@
<div align="center"> <img src="pics/multilevel-cache.png" width="760px"/> </div><br>
在本机测试,Caffeine 读取一次约 21 纳秒;redis-benchmark 用单个连接逐条发送 GET,每次约 71 微秒,相差三千多倍,这还只是本机回环,跨机器访问还要加上网络往返。热点数据放在本地缓存中,既省去了网络开销,也不会让请求集中到存放这个热点键的 Redis 节点上。
Caffeine 读取一次约 21 纳秒;用单个连接逐条向 Redis 发送 GET,每次约 68 微秒,相差三千多倍(JDK 21、Redis 7.4 实测,见[附录 A1](#a1-本地缓存与-redis-的读取耗时)),这还只是本机回环,跨机器访问还要加上网络往返。热点数据放在本地缓存中,既省去了网络开销,也不会让请求集中到存放这个热点键的 Redis 节点上。
本地缓存在每个实例中各存一份,难点在于数据更新后怎样让所有实例失效:
@@ -97,7 +98,7 @@ Java 为了优化空间,提高字符串、基本数据类型包装类的创建
### CPU 多级缓存
CPU 为了解决运算速度与主存 IO 速度不匹配的问题,引入了多级缓存结构,同时使用 MESI 等缓存一致性协议来解决多核 CPU 缓存数据一致性的问题。
CPU 为了解决运算速度与主存访问速度不匹配的问题,引入了多级缓存结构,同时使用 MESI 等缓存一致性协议来解决多核 CPU 缓存数据一致性的问题。
## 三、CDN
@@ -173,13 +174,14 @@ CDN 主要有以下优点:
指的是为了满足业务要求添加了大量缓存节点,但是性能不但没有好转反而下降了的现象。
产生原因:缓存系统通常采用 hash 函数将 key 映射到对应的缓存节点,随着缓存节点数目的增加,键值分布到更多的节点上,导致客户端一次批量操作会涉及多次网络操作,这意味着批量操作的耗时会随着节点数目的增加而不断增大。此外,网络连接数变多,对节点的性能也有一定影响。
产生原因:缓存系统通常采用哈希函数将键映射到对应的缓存节点,随着缓存节点数目的增加,键值分布到更多的节点上,导致客户端一次批量操作会涉及多次网络操作,这意味着批量操作的耗时会随着节点数目的增加而不断增大。此外,网络连接数变多,对节点的性能也有一定影响。
解决方案:
- 优化批量数据操作命令;
- 减少网络通信次数;
- 降低接入成本,使用长连接 / 连接池,NIO 等。
- 按节点分组:客户端先算出每个键所在的节点,同一个节点的键合并成一次 MGET 或者用流水线发送,网络往返次数从键的数量降为节点的数量;
- 并行发送:各节点的请求同时发出,耗时取决于最慢的节点;
- 哈希标签:经常一起读取的键用 {} 放到 Redis Cluster 的同一个槽中,一次请求取回;
- 使用长连接、连接池,减少建立连接的开销。
### 大 Key 与热 Key
@@ -187,7 +189,7 @@ CDN 主要有以下优点:
大 Key 的问题:
- 阻塞:Redis 用一个线程执行命令,对大 Key 执行 HGETALL、DEL 这类 O(N) 的命令时,其它命令都要等待。在本机删除一个有 100 万个字段、占用 64 MB 内存的 HASH,DEL 耗时 341 ms,而 UNLINK 只需 35 μs,它在后台线程中释放内存;
- 阻塞:Redis 用一个线程执行命令,对大 Key 执行 HGETALL、DEL 这类 O(N) 的命令时,其它命令都要等待。删除一个有 100 万个字段、占用 64 MB 内存的 HASH,DEL 耗时约 345 ms,而 UNLINK 只需几微秒,它在后台线程中释放内存(Redis 7.4 实测,见[附录 A2](#a2-删除大-key));
- 网络:一次读写的数据量大,占用带宽;
- 数据倾斜:在集群中,大 Key 所在节点的内存和流量都会高于其它节点。
@@ -211,7 +213,7 @@ CDN 主要有以下优点:
哈希分布就是将数据计算哈希值之后,按照哈希值分配到不同的节点上。例如有 N 个节点,数据的主键为 key,则将该数据分配的节点序号为:hash(key)%N。
传统的哈希分布算法存在一个问题:当节点数量变化时,也就是 N 值变化,那么几乎所有的数据都需要重新分布,将导致大量的数据迁移。另外哈希打乱了数据的顺序,不能高效地进行范围查询。
传统的哈希分布算法存在一个问题:当节点数量变化时,也就是 N 值变化,那么几乎所有的数据都需要重新分布,将导致大量的数据迁移。例如节点从 4 个增加到 5 个时,只有 hash(key) % 4 与 hash(key) % 5 相等的键可以留在原节点,约 80% 的键都要迁移(见[附录 A3](#a3-哈希分布与一致性哈希))。另外哈希打乱了数据的顺序,不能高效地进行范围查询。
### 顺序分布
@@ -232,7 +234,7 @@ CDN 主要有以下优点:
将哈希空间 [0, 2<sup>n</sup>-1] 看成一个哈希环,每个服务器节点都通过哈希配置到哈希环上。每个数据对象通过哈希函数得到哈希值之后,存放到哈希环中顺时针方向第一个大于等于该哈希值的节点上。
一致性哈希在增加或者删除节点时只会影响到哈希环中相邻的节点,例如下图中在 B 和 C 之间新增节点 X,只需要将节点 C 上哈希值位于 B 和 X 之间的数据迁移到 X 上即可,对于节点 A、B、D 都没有影响。
一致性哈希在增加或者删除节点时只会影响到哈希环中相邻的节点,例如下图中在 B 和 C 之间新增节点 X,只需要将节点 C 上哈希值位于 B 和 X 之间的数据迁移到 X 上即可,对于节点 A、B、D 都没有影响。同样从 4 个节点增加到 5 个,100 万个键中只有约 18% 需要迁移。
<div align="center"> <img src="pics/consistent-hash.png" width="760px"/> </div><br>
@@ -240,18 +242,18 @@ CDN 主要有以下优点:
上面描述的一致性哈希存在数据分布不均匀的问题,节点存储的数据量有可能会存在很大的不同。
数据不均匀主要是因为节点在哈希环上分布的不均匀,这种情况在节点数量很少的情况下尤其明显。
数据不均匀主要是因为节点在哈希环上分布的不均匀,这种情况在节点数量很少的情况下尤其明显。例如 4 个节点各在环上放 1 个位置时,100 万个键中最多的节点分到 44.7%,最少的只有 9.0%。
解决方式是通过增加虚拟节点,然后将虚拟节点映射到真实节点上。虚拟节点的数量比真实节点来得多,那么虚拟节点在哈希环上分布的均匀性就会比原来的真实节点好,从而使得数据分布也更加均匀。
解决方式是通过增加虚拟节点,然后将虚拟节点映射到真实节点上。虚拟节点的数量比真实节点来得多,那么虚拟节点在哈希环上分布的均匀性就会比原来的真实节点好,从而使得数据分布也更加均匀。每个节点放 150 个虚拟节点后,4 个节点的占比在 22.8%~27.0% 之间。
Redis Cluster 使用的不是一致性哈希,而是把 Key 映射到固定的 16384 个哈希槽上,再把哈希槽分配给各个节点,节点变化时只需要迁移对应的哈希槽。
## 七、LRU
以下是基于 双向链表 + HashMap 的 LRU 算法实现,对算法的解释如下:
以下是基于双向链表和 HashMap 的 LRU 算法实现,对算法的解释如下:
- 访问某个节点时,将其从原来的位置删除,并重新插入到链表头部。这样就能保证链表尾部存储的就是最近最久未使用的节点,当节点数量大于缓存最大空间时就淘汰链表尾部的节点。
- 为了使删除操作时间复杂度为 O(1),就不能采用遍历的方式找到某个节点。HashMap 存储着 Key 到节点的映射,通过 Key 就能以 O(1) 的时间得到节点,然后再以 O(1) 的时间将其从双向队列中删除。
- 访问某个节点时,将其从原来的位置删除,并重新插入到链表头部。这样就能保证链表尾部存储的就是最近最久未使用的节点,当节点数量大于缓存最大空间时就淘汰链表尾部的节点;
- 为了使删除操作时间复杂度为 O(1),就不能采用遍历的方式找到某个节点。HashMap 存储着 Key 到节点的映射,通过 Key 就能以 O(1) 的时间得到节点,然后再以 O(1) 的时间将其从双向链表中删除。
<div align="center"> <img src="pics/lru.png" width="620px"/> </div><br>
@@ -383,9 +385,92 @@ public class LRU<K, V> implements Iterable<K> {
}
```
## 附录:实测
以下实验的环境均为 Apple M1 Pro(10 核)、macOS,Redis 7.4.1 使用默认配置,客户端和服务器在同一台机器上。
### A1 本地缓存与 Redis 的读取耗时
Caffeine 3.1.8,JDK 21。缓存中放入 1000 个键,每轮执行 1000 万次 getIfPresent,前几轮用于 JIT 预热:
```java
Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(30))
.build();
for (int i = 0; i < 1000; i++) cache.put("product:" + i, "{\"id\":" + i + ",\"price\":99}");
for (int r = 0; r < 5; r++) {
long t0 = System.nanoTime();
for (int i = 0; i < 10_000_000; i++) sink += cache.getIfPresent(keys[i % 1000]).length();
System.out.printf("round %d: %.1f ns/get%n", r, (System.nanoTime() - t0) / 1e7);
}
```
Redis 用一个连接逐条发送 10 万次 GET:
```bash
redis-benchmark -c 1 -n 100000 -t get -q
```
```text
round 0: 22.5 ns/get
round 1: 21.1 ns/get
round 2: 20.7 ns/get
round 3: 20.7 ns/get
round 4: 20.7 ns/get
GET: 14626.30 requests per second, p50=0.063 msec
```
每秒 14626 次,平均每次约 68 微秒。
### A2 删除大 Key
用流水线向 user:1001:fav 写入 100 万个字段,分别用 DEL 和 UNLINK 删除,执行前设置 CONFIG SET slowlog-log-slower-than 0,从 SLOWLOG 读取服务器的执行时间:
```text
> HLEN user:1001:fav
1000000
> MEMORY USAGE user:1001:fav
64388720
> DEL user:1001:fav
1
SLOWLOG: 345637 微秒 DEL
(重新写入 100 万个字段)
> UNLINK user:1001:fav
1
SLOWLOG: 8 微秒 UNLINK
```
### A3 哈希分布与一致性哈希
100 万个键 user:0~user:999999,哈希值取 MD5 的前 4 个字节,哈希环的范围为 [0, 2<sup>32</sup>-1]。节点 A~E 在环上的位置为节点名的哈希值,虚拟节点为 A#0~A#149 这样的名字的哈希值,键存放在顺时针方向第一个大于等于它的位置对应的节点上:
```python
def h(s):
return int.from_bytes(hashlib.md5(s.encode()).digest()[:4], "big")
keys = [h(f"user:{i}") for i in range(1_000_000)]
moved = sum(1 for k in keys if k % 4 != k % 5)
def ring(nodes, vnodes):
pts = sorted((h(f"{n}#{v}" if vnodes > 1 else n), n) for n in nodes for v in range(vnodes))
return [p for p, _ in pts], [n for _, n in pts]
def owner(r, k):
pos, names = r
i = bisect.bisect_left(pos, k)
return names[i % len(names)]
```
```text
取模:4 个节点增加到 5 个,迁移 80.0% 的键
一致性哈希,每个节点 1 个位置:4 个节点的占比 A 44.7%、B 11.6%、C 9.0%、D 34.6%;增加节点 E 后迁移 17.6% 的键
一致性哈希,每个节点 150 个位置:4 个节点的占比 A 27.0%、B 26.1%、C 24.1%、D 22.8%;增加节点 E 后迁移 18.4% 的键
```
## 参考资料
- 大规模分布式存储系统
- 杨传辉. 大规模分布式存储系统:原理解析与架构实战 [M]. 机械工业出版社, 2013.
- [Designing Data-Intensive Applications 中文版](https://github.com/vonng/ddia)
- [缓存那些事](https://tech.meituan.com/cache_about.html)
- [一致性哈希算法](https://my.oschina.net/jayhu/blog/732849)
@@ -394,3 +479,4 @@ public class LRU<K, V> implements Iterable<K> {
- [Redis: UNLINK](https://redis.io/docs/latest/commands/unlink/)
- [Redis CLI](https://redis.io/docs/latest/develop/tools/cli/)
- [Caffeine Wiki](https://github.com/ben-manes/caffeine/wiki)
- [Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web](https://dl.acm.org/doi/10.1145/258533.258660)