From b756a14cb69eeddcb4349649f6b4bf9f673336bf Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Wed, 28 Oct 2026 13:35:24 +0800 Subject: [PATCH] =?UTF-8?q?Update=20=E7=BC=93=E5=AD=98.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充缓存击穿的互斥锁和逻辑过期、布隆过滤器的误判率与参数、LRU 的线程安全,增加实测附录。 --- notes/缓存.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/notes/缓存.md b/notes/缓存.md index 4b9204d6..dfc4c069 100644 --- a/notes/缓存.md +++ b/notes/缓存.md @@ -129,14 +129,26 @@ CDN 主要有以下优点:

+误判是因为这些位置可能都被其它 Key 置为 1。插入 n 个 Key、位数组为 m 位、使用 k 个哈希函数时,误判率约为 (1 - e-kn/m)k,k 取 (m/n) ln 2 时最小。每个 Key 占 10 位、k = 7 时,100 万个 Key 只需要 1.2 MB,按公式误判率为 0.82%;实际插入 100 万个 Key,再查询 100 万个不存在的 Key,误判率也是 0.82%(见[附录 A6](#a6-布隆过滤器的误判率))。目标误判率为 1% 时,每个 Key 约需要 9.6 位,1 亿个 Key 约 114 MB。 + +使用时需要注意: + +- 容量要预估准确:插入的 Key 超过预期后误判率迅速上升,例如按 100 万个 Key 配置的过滤器插入了 200 万个 Key,按公式误判率会升到约 14%; +- 不能删除:一个位可能同时属于多个 Key,清零会让其它 Key 被误判为不存在,需要删除时可以定期重建,或者使用计数布隆过滤器、布谷鸟过滤器; +- 新增的数据要同时写入布隆过滤器,否则请求会被误判为不存在而拒绝。 + +Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis 7 需要加载 RedisBloom 模块。 + ### 缓存击穿 指的是某个热点数据的缓存过期时,大量对它的请求同时到达数据库。 解决方案: -- 使用互斥锁,只允许一个请求去数据库加载数据并写回缓存,其它请求等待后直接读缓存; -- 热点数据不设置过期时间,或者由后台线程异步更新。 +- 互斥锁:缓存未命中时用 SET lock NX EX 3 抢锁,抢到的请求先再查一次缓存(可能别人已经写回),仍然没有才查询数据库并写回缓存,最后释放锁(释放时要确认锁仍是自己的,见 [分布式锁](分布式.md#redis-的-set-nx-指令));没抢到的请求等待几十毫秒后重新读缓存; +- 逻辑过期:键不设置过期时间,而是在值中保存一个过期时间。读到已经过期的值时,抢到锁的请求启动后台线程重建缓存,所有请求都直接返回旧值,不等待。 + +两者都只让一个请求查询数据库,区别在于等待重建还是返回旧值。热点键刚过期时 100 个请求同时到达,数据库查询耗时 50 毫秒:不加保护时查询了数据库 68~100 次;互斥锁只查询 1 次,请求耗时的中位数约 90~100 毫秒;逻辑过期也只查询 1 次,耗时中位数约 12 毫秒,但 100 个请求拿到的都是旧值(Redis 7.4、MySQL 8.0 实测,见[附录 A5](#a5-缓存击穿的三种做法))。要求数据准确时用互斥锁,能接受短暂的旧值、更看重响应时间时用逻辑过期。 ### 缓存雪崩 @@ -397,6 +409,8 @@ public class LRU implements Iterable { } ``` +上面的实现不是线程安全的:get() 也会移动链表中的节点,多个线程同时访问会破坏链表,所以读操作也要加锁。简单的做法是 get() 和 put() 使用同一把锁,或者用 Collections.synchronizedMap() 包装上面的 LinkedHashMap。并发高时用 Caffeine,它不在每次读取时调整顺序,而是把访问记录写入缓冲区,再批量调整,读操作不需要获取全局锁。 + ## 附录:实测 以下实验的环境均为 Apple M1 Pro(10 核)、macOS,Redis 7.4.1 使用默认配置,客户端和服务器在同一台机器上。 @@ -522,6 +536,62 @@ update-then-delete stale 542/1000 (54.2%) update-then-delete + double delete stale 0/1000 (0.0%) ``` +### A5 缓存击穿的三种做法 + +MySQL 8.0.44,Redis 7.4.1,Python 3.13(pymysql、redis-py)。数据库查询为 SELECT price, SLEEP(0.05) FROM cache_demo WHERE id = 1,热点键刚过期时 100 个线程同时发起请求,统计查询数据库的次数和每个请求的耗时,逻辑过期的旧价格为 99。每种做法运行两轮: + +```python +def mutex(): + while True: + v = r.get(KEY) + if v is not None: + return int(v) + if r.set(LOCK, 1, nx=True, ex=3): # 只有一个请求拿到锁 + try: + v = r.get(KEY) # 再查一次,可能别人已经写回 + if v is None: + v = query_db(); r.set(KEY, v, ex=60) + return int(v) + finally: + r.delete(LOCK) + time.sleep(0.01) # 没拿到锁,稍后重新读缓存 + +def logical(): + d = json.loads(r.get(KEY)) # 值中带逻辑过期时间 + if d['expire_at'] < time.time() and r.set(LOCK, 1, nx=True, ex=3): + threading.Thread(target=rebuild).start() # 后台重建,完成后删除锁 + return d['price'] # 不等待,直接返回 +``` + +```text +不加保护 查询数据库 68 次,耗时中位数 157.8 ms,最大 181.4 ms,返回旧值 0 次 +互斥锁 查询数据库 1 次,耗时中位数 88.2 ms,最大 95.8 ms,返回旧值 0 次 +逻辑过期 查询数据库 1 次,耗时中位数 13.8 ms,最大 19.8 ms,返回旧值 100 次 +不加保护 查询数据库 100 次,耗时中位数 408.4 ms,最大 444.2 ms,返回旧值 0 次 +互斥锁 查询数据库 1 次,耗时中位数 102.0 ms,最大 118.5 ms,返回旧值 0 次 +逻辑过期 查询数据库 1 次,耗时中位数 11.5 ms,最大 18.6 ms,返回旧值 100 次 +``` + +### A6 布隆过滤器的误判率 + +Python 3.13 模拟:插入 user:0~user:999999,再查询 user:1000000~user:1999999 这 100 万个不存在的 Key。哈希值取 MD5 的两个 64 位整数 h1、h2,第 i 个位置为 (h1 + i × h2) mod m: + +```python +def pos(key): + d = hashlib.md5(key.encode()).digest() + h1, h2 = int.from_bytes(d[:8], 'little'), int.from_bytes(d[8:], 'little') + return [(h1 + i * h2) % m for i in range(k)] +``` + +```text +n=1000000 m/n=10 k=7 位数组 1.19 MiB:实测误判率 0.8199%,公式 0.8194% +n=1000000 m/n=10 k=3 位数组 1.19 MiB:实测误判率 1.7588%,公式 1.7411% +n=1000000 m/n=5 k=3 位数组 0.60 MiB:实测误判率 9.2104%,公式 9.1849% +n=1000000 m/n=20 k=14 位数组 2.38 MiB:实测误判率 0.0065%,公式 0.0067% +目标误判率 1.0%:每个 Key 9.59 位,k = 6.6,1 亿个 Key 约 114 MiB +目标误判率 0.1%:每个 Key 14.38 位,k = 10.0,1 亿个 Key 约 171 MiB +``` + ## 参考资料 - 杨传辉. 大规模分布式存储系统:原理解析与架构实战 [M]. 机械工业出版社, 2013. @@ -533,5 +603,7 @@ update-then-delete + double delete stale 0/1000 (0.0%) - [Redis: UNLINK](https://redis.io/docs/latest/commands/unlink/) - [Redis CLI](https://redis.io/docs/latest/develop/tools/cli/) - [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 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)