diff --git a/notes/缓存.md b/notes/缓存.md index 84ff119a..086bb583 100644 --- a/notes/缓存.md +++ b/notes/缓存.md @@ -8,6 +8,7 @@ * [五、数据分布](#五数据分布) * [六、一致性哈希](#六一致性哈希) * [七、LRU](#七lru) + * [附录:实测](#附录实测) * [参考资料](#参考资料) @@ -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 @@

-在本机测试,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, 2n-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% 需要迁移。

@@ -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) 的时间将其从双向链表中删除。

@@ -383,9 +385,92 @@ public class LRU implements Iterable { } ``` +## 附录:实测 + +以下实验的环境均为 Apple M1 Pro(10 核)、macOS,Redis 7.4.1 使用默认配置,客户端和服务器在同一台机器上。 + +### A1 本地缓存与 Redis 的读取耗时 + +Caffeine 3.1.8,JDK 21。缓存中放入 1000 个键,每轮执行 1000 万次 getIfPresent,前几轮用于 JIT 预热: + +```java +Cache 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, 232-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 implements Iterable { - [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)