mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-10-30 16:06:24 +00:00
Update 缓存.md
补充三类缓存问题的区别、空值缓存的局限、雪崩的多层防护、延迟双删的间隔与 binlog 方案、大 Key 过期时的阻塞,附录 A7、A8。
This commit is contained in:
+42
-5
@@ -116,6 +116,8 @@ CDN 主要有以下优点:
|
||||
|
||||
## 四、缓存问题
|
||||
|
||||
穿透、击穿和雪崩都是请求越过缓存直接到达数据库,区别在于原因:穿透查询的是缓存和数据库中都不存在的数据,击穿是一个热点键过期,雪崩是大量的键同时过期或者缓存整体不可用。
|
||||
|
||||
### 缓存穿透
|
||||
|
||||
指的是对某个一定不存在的数据进行请求,该请求将会穿透缓存到达数据库。
|
||||
@@ -125,6 +127,8 @@ CDN 主要有以下优点:
|
||||
- 对这些不存在的数据缓存一个空数据,并设置较短的过期时间;
|
||||
- 使用布隆过滤器保存所有存在的 Key,对不存在的 Key 的请求直接拒绝。
|
||||
|
||||
缓存空值只对重复出现的 Key 有效。攻击者每次随机生成不同的 ID 时,每个 ID 第一次都会查询数据库,缓存的空值再也用不上,还占用内存:10 万个空值键约占 15 MB(Redis 7.4 实测,见[附录 A7](#a7-空值缓存的内存))。另外,这个 ID 之后被新增时要删除缓存的空值,否则在空值过期之前一直查不到。随机生成的 ID 需要布隆过滤器来拦截。
|
||||
|
||||
布隆过滤器用多个哈希函数把 Key 映射到位数组的多个位置并置为 1。查询时只要有一个位置为 0,Key 就一定不存在;全部为 1 时 Key 可能存在,存在一定的误判率。
|
||||
|
||||
<div align="center"> <img src="pics/bloom-filter.png" width="620px"/> </div><br>
|
||||
@@ -139,6 +143,8 @@ CDN 主要有以下优点:
|
||||
|
||||
Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis 7 需要加载 RedisBloom 模块。
|
||||
|
||||
实际使用时通常把几种做法组合起来:先在接口层校验参数,格式不对或者超出已分配范围的 ID 直接拒绝;再用布隆过滤器拦截不存在的 Key;少量误判漏过的请求用空值缓存兜底;同一来源大量查询不存在的数据时进行限流。
|
||||
|
||||
### 缓存击穿
|
||||
|
||||
指的是某个热点数据的缓存过期时,大量对它的请求同时到达数据库。
|
||||
@@ -159,9 +165,10 @@ Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis
|
||||
解决方案:
|
||||
|
||||
- 为了防止缓存在同一时间大面积过期导致的缓存雪崩,可以在过期时间上加一个随机值,让过期时间分散开;
|
||||
- 为了防止缓存服务器宕机出现的缓存雪崩,可以使用分布式缓存,分布式缓存中每一个节点只缓存部分的数据,当某个节点宕机时可以保证其它节点的缓存仍然可用;
|
||||
- 为了防止缓存服务器宕机出现的缓存雪崩,可以使用分布式缓存,分布式缓存中每一个节点只缓存部分的数据,当某个节点宕机时可以保证其它节点的缓存仍然可用;每个节点再配上从节点,由哨兵或 Redis Cluster 自动故障转移,见 [Sentinel](Redis.md#十二sentinel);
|
||||
- 也可以进行缓存预热,避免在系统刚启动不久由于还未将大量数据进行缓存而导致缓存雪崩;
|
||||
- 对数据库的访问进行限流,即使缓存失效也不至于压垮数据库。
|
||||
- 在应用中加一层本地缓存,Redis 不可用时热点数据仍能命中,见 [多级缓存](#多级缓存);
|
||||
- 对数据库的访问进行限流,即使缓存失效也不至于压垮数据库;Redis 大面积超时时熔断,直接返回默认值或者提示稍后重试,见 [限流、降级与熔断](系统设计基础.md#限流降级与熔断)。
|
||||
|
||||
### 缓存一致性
|
||||
|
||||
@@ -189,10 +196,15 @@ Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis
|
||||
|
||||
- 为缓存设置过期时间作为兜底;
|
||||
- 延迟双删:更新数据库并删除缓存之后,隔一段时间再删除一次,清掉慢读请求写回的旧值,间隔要大于读请求从查询数据库到写回缓存的耗时;
|
||||
- 删除失败时重试:把要删除的键发到消息队列,由消费者删除,失败就重新投递;
|
||||
- 订阅数据库的 binlog(例如使用 Canal)异步删除缓存,失败时重试,业务代码中不再需要删除缓存;
|
||||
- 写回缓存时检查期间是否有过删除:Facebook 的 Memcache 在缓存未命中时发给读请求一个租约(lease)令牌,写回时要带上令牌,如果这期间键被删除,令牌就会失效,旧值写不进缓存。
|
||||
|
||||
缓存和数据库之间只能做到最终一致,要保证强一致需要付出很大的代价。缓存数据最好是那些对一致性要求不高、能接受短时间读到旧值的数据,余额、库存扣减这类数据应该直接读写数据库。
|
||||
延迟双删的第二次删除不能让写请求同步等待,通常交给线程池或者延迟消息执行。间隔需要估计:附录 A4 中读请求最多停顿 5 毫秒,间隔取 50 毫秒时没有留下旧值;间隔太短清不掉旧值,太长则旧值在缓存中停留更久。第二次删除也可能失败,同样要重试,最后靠过期时间兜底。
|
||||
|
||||
binlog 方案把删除缓存从业务代码中移出:Canal 伪装成 MySQL 的从库读取 binlog,发现某一行被修改就删除对应的缓存键,失败后可以重试,代价是多部署一套组件,缓存的删除也有一定延迟。
|
||||
|
||||
缓存和数据库之间只能做到最终一致,要保证强一致需要付出很大的代价,例如读写都加分布式读写锁,写入期间所有读请求等待。缓存数据最好是那些对一致性要求不高、能接受短时间读到旧值的数据,余额、库存扣减这类数据应该直接读写数据库。秒杀中先在 Redis 中扣减库存、再异步写数据库的做法不属于这里的缓存,Redis 中的库存是主数据,见 [秒杀系统](系统设计基础.md#六秒杀系统)。
|
||||
|
||||
### 缓存 “无底洞” 现象
|
||||
|
||||
@@ -215,13 +227,14 @@ Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis
|
||||
|
||||
- 阻塞:Redis 用一个线程执行命令,对大 Key 执行 HGETALL、DEL 这类 O(N) 的命令时,其它命令都要等待。删除一个有 100 万个字段、占用 64 MB 内存的 HASH,DEL 耗时约 345 ms,而 UNLINK 只需几微秒,它在后台线程中释放内存(Redis 7.4 实测,见[附录 A2](#a2-删除大-key));
|
||||
- 网络:一次读写的数据量大,占用带宽;
|
||||
- 数据倾斜:在集群中,大 Key 所在节点的内存和流量都会高于其它节点。
|
||||
- 数据倾斜:在集群中,大 Key 所在节点的内存和流量都会高于其它节点;
|
||||
- 过期和淘汰:大 Key 过期或被淘汰时,默认也由主线程释放内存。一个 100 万字段的 HASH 过期时,主线程停顿约 400 ms;开启 lazyfree-lazy-expire 后交给后台线程释放,期间最慢的 PING 只有 3.6 ms(Redis 7.4 实测,见[附录 A8](#a8-大-key-过期))。
|
||||
|
||||
大 Key 的发现与处理:
|
||||
|
||||
- 用 redis-cli --bigkeys 或者 --memkeys 扫描,它们使用 SCAN 命令遍历,不会阻塞服务器;用 MEMORY USAGE 查看单个键占用的内存;
|
||||
- 拆分:例如把一个大的 HASH 按字段的哈希值拆成 16 个小 HASH,user:1001:fav:0 到 user:1001:fav:15;
|
||||
- 删除用 UNLINK;读取集合时用 HSCAN、SSCAN 分批读取,不要一次读出所有元素。
|
||||
- 删除用 UNLINK,并开启 lazyfree-lazy-expire 和 lazyfree-lazy-eviction,让过期和淘汰也在后台释放内存;读取集合时用 HSCAN、SSCAN 分批读取,不要一次读出所有元素。
|
||||
|
||||
热 Key 的问题是请求集中到同一个节点,集群中其它节点再多也无法分担。处理方法:
|
||||
|
||||
@@ -592,6 +605,28 @@ n=1000000 m/n=20 k=14 位数组 2.38 MiB:实测误判率 0.0065%,公式 0.00
|
||||
目标误判率 0.1%:每个 Key 14.38 位,k = 10.0,1 亿个 Key 约 171 MiB
|
||||
```
|
||||
|
||||
### A7 空值缓存的内存
|
||||
|
||||
向空的 Redis 写入 10 万个随机的商品 ID,值为空字符串,过期时间 60 秒:
|
||||
|
||||
```python
|
||||
for i in range(100000):
|
||||
pipe.set(f'product:{random.randrange(10**12)}', '', ex=60)
|
||||
```
|
||||
|
||||
```text
|
||||
10 万个空值键 used_memory 增加 14931808,平均每个 149 字节,DBSIZE 100000
|
||||
```
|
||||
|
||||
### A8 大 Key 过期
|
||||
|
||||
user:1001:fav 有 100 万个字段,设置 300 毫秒后过期,之后 2 秒内不停发送 PING 并记录最慢的一次;LATENCY LATEST 中的 expire-cycle 是定期删除一次执行的耗时(latency-monitor-threshold 设为 1 毫秒):
|
||||
|
||||
```text
|
||||
lazyfree-lazy-expire no: HLEN 100 万,MEMORY USAGE 64388720,过期后 EXISTS=0,LATENCY expire-cycle 最大 397 ms,期间最慢的 PING 397.8 ms
|
||||
lazyfree-lazy-expire yes: HLEN 100 万,MEMORY USAGE 64388720,过期后 EXISTS=0,LATENCY 中没有 expire-cycle(低于 1 ms 不记录),期间最慢的 PING 3.6 ms
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- 杨传辉. 大规模分布式存储系统:原理解析与架构实战 [M]. 机械工业出版社, 2013.
|
||||
@@ -607,3 +642,5 @@ n=1000000 m/n=20 k=14 位数组 2.38 MiB:实测误判率 0.0065%,公式 0.00
|
||||
- [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)
|
||||
- [Redis: Lazy freeing of objects(redis.conf)](https://github.com/redis/redis/blob/7.4.1/redis.conf)
|
||||
- [Canal](https://github.com/alibaba/canal)
|
||||
|
||||
Reference in New Issue
Block a user