diff --git a/notes/缓存.md b/notes/缓存.md
index 39cb6a0f..59f5c5fc 100644
--- a/notes/缓存.md
+++ b/notes/缓存.md
@@ -70,7 +70,7 @@ Caffeine 读取一次约 21 纳秒;用单个连接逐条向 Redis 发送 GET
难点在于数据更新后怎样让所有实例的本地缓存失效:
- 更新数据库后删除 Redis 中的键,再通过 Redis 的发布订阅或消息队列广播一条删除消息,各实例收到后删除自己的本地缓存。顺序不能反过来,否则本地缓存删除后、Redis 删除前的读请求会把 Redis 中的旧值重新加载到本地;
-- Redis 的发布订阅不保证送达,实例断线期间的消息会丢失,所以本地缓存还要设置较短的过期时间(例如 30 秒)兜底,需要更可靠时改用消息队列;
+- Redis 的发布订阅不保证送达,实例断线期间的消息会丢失,所以本地缓存还要设置较短的过期时间(例如 30 秒)兜底,需要更可靠时改用消息队列。检测到订阅连接断开时清空本地缓存,并在订阅恢复前暂停使用和回填本地缓存;本地缓存从 Redis 回填时,过期时间取本地 TTL 与 Redis 中剩余 TTL 的较小值,避免本地副本比 Redis 中的值更晚过期;
- 因此本地缓存只适合读多写少、能接受短暂不一致的数据,例如商品详情、配置;库存、余额这类数据不要放在本地缓存中。
本地缓存不直接用 HashMap 或 ConcurrentHashMap,是因为它们没有容量上限和过期时间,JVM 内存会一直上涨。常用的是 Caffeine:
@@ -86,6 +86,10 @@ Product p = cache.get("product:1024", key -> loadFromRedisOrDb(key));
Caffeine 的淘汰算法 W-TinyLFU 同时考虑访问频率和最近访问时间:新数据先进入一个小的 LRU 窗口区,被窗口淘汰时再和主区将被淘汰的数据比较历史访问频率,频率高的留下,偶发的批量访问挤不掉热点数据,命中率通常高于 LRU。get 传入的加载函数对同一个键只会执行一次,其它并发的请求等待它的结果,避免缓存未命中时大量请求同时查询下游。加载函数抛出异常或返回 null 时不写入缓存,仍未命中的请求会再次执行它。通过 Spring 的 @Cacheable 使用时,默认不合并并发的未命中,要设置 sync = true 才走这个加载函数。
+过期和刷新是两回事:expireAfterWrite 到期后,下一次读取要同步回源,请求会等待;refreshAfterWrite 要在构建时提供加载函数(build(loader) 得到 LoadingCache),到期后的第一次读取触发后台重新加载,刷新还没完成时返回旧值,同一个键在刷新期间不会重复发起刷新。两者常一起用,例如 1 分钟刷新、5 分钟过期,热点键一直由后台刷新,长时间没人访问的键最终过期,不会一直返回很旧的值(Caffeine 3.3 实测)。
+
+maximumSize 限制的是条目数。值的大小相差很大时,例如商品详情从 0.1 KB 到 2 KB,同样 1 万个条目,值本身的大小就相差 20 倍。要按内存限制时改用 maximumWeight 加 weigher,由 weigher 返回每个条目估算的字节数。需要注意的是,权重只在写入或更新时计算一次,只用来判断是否超出上限,不影响淘汰哪一个条目,两种上限也不能同时设置。
+
### 数据库缓存
MySQL 等数据库管理系统具有自己的缓存机制来提高查询效率,例如 InnoDB 的 Buffer Pool 将数据页和索引页缓存在内存中。MySQL 的查询缓存因为命中率低、维护开销大,已经在 8.0 版本中被移除。
@@ -135,11 +139,11 @@ CDN 主要有以下优点:
使用时需要注意:
-- 容量要预估准确:插入的 Key 超过预期后误判率迅速上升,例如按 100 万个 Key 配置的过滤器插入了 200 万个 Key,按公式误判率会升到约 14%;
+- 容量要预估准确:插入的 Key 超过预期后误判率迅速上升,例如按 100 万个 Key 配置的过滤器插入了 200 万个 Key,按公式误判率会升到约 14%。这是位数组大小固定(例如用 SETBIT 自己实现)的情况;Redis 的 BF.RESERVE 默认在容量用满后自动追加一层容量翻倍的子过滤器,并收紧各层的误判率,使总的误判率仍不超过设定值,代价是更多的内存和更慢的查询,加 NONSCALING 则用满后直接报错;
- 不能删除:一个位可能同时属于多个 Key,清零会让其它 Key 被误判为不存在,需要删除时可以定期用数据库中的全部 Key 重建(重建期间新增的 Key 同时写入新旧过滤器,建完再切换),或者使用计数布隆过滤器(每一位换成计数器,删除时减 1)、布谷鸟过滤器,它们也只能删除确实插入过、尚未删除的 Key;
- 新增的数据要同时写入布隆过滤器,否则请求会被误判为不存在而拒绝。先写过滤器再写数据库:数据库写入失败只是多一个误判,反过来过滤器写入失败,这条数据会一直被拒绝。
-Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis 7 需要加载 RedisBloom 模块。
+Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis 7 需要加载 RedisBloom 模块。用 BF.RESERVE key 误判率 容量 预先创建,例如 BF.RESERVE product:bf 0.01 100000000;不先创建就执行 BF.ADD 时,会按默认的容量 100、误判率 1% 自动创建一个,之后频繁扩展。
实际使用时通常把几种做法组合起来:先在接口层校验参数,格式不对或者超出已分配范围的 ID 直接拒绝;再用布隆过滤器拦截不存在的 Key;少量误判漏过的请求用空值缓存兜底;同一来源大量查询不存在的数据时进行限流。
@@ -149,7 +153,7 @@ Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis
解决方案:
-- 互斥锁:缓存未命中时用 `SET lock:product:1024 <随机值> NX EX 3` 抢锁,抢到的请求先再查一次缓存(可能别人已经写回),仍然没有才查询数据库并写回缓存,最后释放锁(释放时要确认锁仍是自己的,见 [分布式锁](分布式.md#redis-的-set-nx-指令));没抢到的请求等待一小段时间(例如 10 毫秒)后重新读缓存。锁的过期时间要长于重建缓存的耗时;
+- 互斥锁:缓存未命中时用 `SET lock:product:1024 <随机值> NX EX 3` 抢锁,抢到的请求先再查一次缓存(可能别人已经写回),仍然没有才查询数据库并写回缓存,最后释放锁(释放时要确认锁仍是自己的,见 [分布式锁](分布式.md#redis-的-set-nx-指令));没抢到的请求等待一小段时间(例如 10 毫秒)后重新读缓存。锁的过期时间要长于重建缓存的耗时,否则锁会在重建期间过期,其它请求再次抢到锁并回源,例如锁 100 毫秒、重建 300 毫秒时,100 个并发请求回源 4 次(见[附录 A5](#a5-缓存击穿的三种做法));耗时难以估计时可以给锁续期,见[分布式锁](分布式.md#redis-的-set-nx-指令);
- 逻辑过期:键不设置过期时间,而是在值中保存一个过期时间。读到已经过期的值时,抢到锁的请求启动后台线程重建缓存,所有请求都直接返回旧值,不等待。这些键不会被 volatile 开头的淘汰策略淘汰,不再需要时要主动删除。
@@ -209,11 +213,11 @@ Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis
- 订阅 binlog 删除缓存:Canal 这类组件伪装成 MySQL 的从库读取 binlog,发现某一行被修改就删除对应的缓存键,失败时重试,业务代码中不再需要删除缓存,手工执行的 SQL 也能覆盖,代价是多部署一套组件,而且和直接删除一样挡不住在删除之后才写回的旧值;
- 写回缓存时检查期间是否有过删除:Facebook 的 Memcache 在缓存未命中时发给读请求一个租约(lease)令牌,写回时要带上令牌,如果这期间键被删除,令牌就会失效,旧值写不进缓存。
-延迟双删的第二次删除不能让写请求同步等待,通常交给线程池或者延迟消息执行。间隔要大于读请求从查询数据库到写回缓存的耗时:附录 A4 中读请求最多停顿 5 毫秒,间隔取 50 毫秒时没有留下旧值;间隔太短清不掉旧值,太长则旧值在缓存中停留更久。第二次删除也可能失败,同样要重试,最后靠过期时间兜底。
+延迟双删的第二次删除不能让写请求同步等待,通常交给线程池或者延迟消息执行。间隔要大于读请求从查询数据库到写回缓存的耗时,读请求走从库时还要加上主从复制的延迟:附录 A4 中读请求最多停顿 5 毫秒,间隔取 50 毫秒时没有留下旧值;间隔太短清不掉旧值,太长则旧值在缓存中停留更久。第二次删除也可能失败,同样要重试,最后靠过期时间兜底。
-常见的组合是先更新数据库再删除缓存,删除失败时重试,再用过期时间兜底。延迟双删针对的情况概率很小,还会让热点键多一次未命中,对一致性要求较高时再加。
+常见的组合是先更新数据库再删除缓存,删除失败时重试,再用过期时间兜底。读主库时,延迟双删针对的情况概率很小,还会让热点键多一次未命中,对一致性要求较高时再加。
-读写分离时,主库提交并删除缓存后从库可能还没有这次更新,未命中的读请求会把从库中的旧值写回缓存。可以在写入后的一小段时间内让这个键的读请求查主库,或者订阅从库的 binlog 删除缓存。
+读写分离时,主库提交并删除缓存后从库可能还没有这次更新,未命中的读请求会把从库中的旧值写回缓存。这种情况不需要读请求很慢,只要它在复制延迟期间读从库就会发生。可以在写入后的一小段时间内让这个键的读请求查主库,或者订阅从库的 binlog 删除缓存;延迟双删也可以,间隔要求见上文。上面的租约挡不住这种情况,因为读请求是在删除之后才拿到令牌的。
缓存和数据库之间只能做到最终一致,要保证强一致需要付出很大的代价,例如读写都加分布式读写锁,写入期间所有读请求等待。缓存数据最好是那些对一致性要求不高、能接受短时间读到旧值的数据,余额、库存扣减这类数据应该直接读写数据库。秒杀中先在 Redis 中扣减库存、再异步写数据库的做法不属于这里的缓存,Redis 中的库存不能删除后从数据库重新加载,见 [Redis 与数据库的库存一致](系统设计基础.md#redis-与数据库的库存一致)。
@@ -249,8 +253,8 @@ Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis
热 Key 的问题是请求集中到同一个节点,集群中其它节点再多也无法分担。处理方法:
-- 发现:redis-cli --hotkeys,它读取的是 LFU 的访问计数,要求淘汰策略为 LFU,见 [七、数据淘汰策略](Redis.md#七数据淘汰策略);也可以在客户端或者代理中统计访问频率;
-- 本地缓存:在应用中缓存热 Key 的值,大部分请求不再访问 Redis,代价是数据会有短暂的不一致,见上文的 [本地缓存](#本地缓存);
+- 发现:redis-cli --hotkeys,它读取的是 LFU 的访问计数,要求淘汰策略为 LFU,见 [七、数据淘汰策略](Redis.md#七数据淘汰策略)。这个计数按概率对数增长,默认配置下连续访问 10 万次约为 148,100 万次就到上限 255(Redis 7.4 实测,每次结果略有波动);停止访问后每分钟减 1。它反映的是带衰减的累计热度,分不出两个都已饱和的键,也不能可靠地判断哪个键刚刚变热。实时发现要在客户端或者代理中按时间窗口计数,例如 2 秒内访问超过 10 次;
+- 本地缓存:在应用中缓存热 Key 的值,大部分请求不再访问 Redis,代价是数据会有短暂的不一致,见上文的 [本地缓存](#本地缓存)。热 Key 事先不知道时,可以由探测服务汇总各实例上报的访问次数,判定为热点后把键名推送给所有实例,实例只为名单中的键开启本地缓存,并按规则设置过期时间,例如京东开源的 hotkey;
- 复制多份:把同一个值写到 product:1024#0 到 product:1024#9 这 10 个键中,读取时随机选一个。这些键分布在不同的槽上,请求就分散到了多个节点,代价是写入时要更新所有副本,只适合读多写少的数据;
- 写热点:上面两种做法只分担读请求。库存可以拆成几份放在不同的键中分别扣减,见[秒杀系统](系统设计基础.md#六秒杀系统);点赞数这类计数可以先在应用内累加,每隔一秒用一次 INCRBY 写入,见[热门视频的计数](系统设计基础.md#1-热门视频的计数)。
@@ -283,7 +287,7 @@ Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis
将哈希空间 [0, 2n-1] 看成一个哈希环,每个服务器节点都通过哈希配置到哈希环上。每个数据对象通过哈希函数得到哈希值之后,存放到哈希环中顺时针方向第一个大于等于该哈希值的节点上。
-一致性哈希在增加或者删除节点时只会影响到哈希环中相邻的节点,例如下图中在 B 和 C 之间新增节点 X,只需要将节点 C 上哈希值位于 B 和 X 之间的数据迁移到 X 上即可,对于节点 A、B、D 都没有影响。同样从 4 个节点增加到 5 个,100 万个键中只有约 18% 需要迁移。
+一致性哈希在增加或者删除节点时只会影响到哈希环中相邻的节点,例如下图中在 B 和 C 之间新增节点 X,只需要将节点 C 上哈希值位于 B 和 X 之间的数据迁移到 X 上即可,对于节点 A、B、D 都没有影响。同样从 4 个节点增加到 5 个,理论上约 1/5 的键需要迁移,100 万个键实测为约 18%。
@@ -621,6 +625,13 @@ def logical():
逻辑过期 查询数据库 1 次,耗时中位数 11.5 ms,最大 18.6 ms,返回旧值 100 次
```
+锁的过期时间短于重建耗时的情况:把查询数据库换成固定休眠 300 ms,锁用 SET NX PX 设置过期时间,不主动释放,模拟锁在重建期间过期,每种设置运行 3 轮:
+
+```text
+锁 3000 ms、重建 300 ms:[1, 1, 1] 次回源
+锁 100 ms、重建 300 ms:[4, 4, 4] 次回源
+```
+
### A6 布隆过滤器的误判率
Python 3.13 模拟:插入 user:0~user:999999,再查询 user:1000000~user:1999999 这 100 万个不存在的 Key。哈希值取 MD5 的两个 64 位整数 h1、h2,第 i 个位置为 (h1 + i × h2) mod m:
@@ -697,6 +708,7 @@ if (r.nextInt(10) == 0) {
- [Scaling Memcache at Facebook](https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf)
- [Redis: Bloom filter](https://redis.io/docs/latest/develop/data-types/probabilistic/bloom-filter/)
- [Caffeine: Design](https://github.com/ben-manes/caffeine/wiki/Design)
+- [Caffeine: Eviction](https://github.com/ben-manes/caffeine/wiki/Eviction)
- [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)
- [Redis: Lazy freeing of objects(redis.conf)](https://github.com/redis/redis/blob/7.4.1/redis.conf)