Update 缓存.md
新增缓存击穿配图并更新多张配图。补充 LinkedHashMap 实现 LRU 的代码与并发测试附录,调整双写一致性、本地缓存及热 Key 等相关说明。
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 181 KiB |
@@ -53,13 +53,11 @@
|
||||
|
||||
### 本地缓存
|
||||
|
||||
使用 Guava Cache、Caffeine 等将数据缓存在服务器本地内存中,服务器代码可以直接读取本地内存中的缓存,速度非常快。
|
||||
使用 Caffeine、Guava Cache 等把数据缓存在应用进程的内存中,读取时不经过网络,速度非常快。
|
||||
|
||||
### 分布式缓存
|
||||
|
||||
使用 Redis、Memcached 等分布式缓存将数据缓存在分布式缓存系统中。
|
||||
|
||||
相对于本地缓存来说,分布式缓存单独部署,可以根据需求分配硬件资源。不仅如此,服务器集群都可以访问分布式缓存,而本地缓存需要在服务器集群之间进行同步,实现难度和性能开销上都非常大。
|
||||
使用 Redis、Memcached 等单独部署的缓存服务,容量可以单独扩展,所有实例共享同一份数据;本地缓存则在每个实例中各存一份,数据更新后难以保持一致。
|
||||
|
||||
### 多级缓存
|
||||
|
||||
@@ -69,13 +67,13 @@
|
||||
|
||||
Caffeine 读取一次约 21 纳秒;用单个连接逐条向 Redis 发送 GET,每次约 68 微秒,相差三千多倍(JDK 21、Redis 7.4 实测,见[附录 A1](#a1-本地缓存与-redis-的读取耗时)),这还只是客户端与 Redis 在同一台机器上的结果,跨机器访问还要加上网络往返。热点数据放在本地缓存中,既省去了网络开销,也不会让请求集中到存放这个热点键的 Redis 节点上。
|
||||
|
||||
本地缓存在每个实例中各存一份,难点在于数据更新后怎样让所有实例失效:
|
||||
难点在于数据更新后怎样让所有实例的本地缓存失效:
|
||||
|
||||
- 更新数据库后删除 Redis 中的键,再通过 Redis 的发布订阅或消息队列广播一条删除消息,各实例收到后删除自己的本地缓存;
|
||||
- Redis 的发布订阅不保证送达,实例断线期间的消息会丢失,所以本地缓存还要设置较短的过期时间(例如 30 秒)兜底;
|
||||
- 更新数据库后删除 Redis 中的键,再通过 Redis 的发布订阅或消息队列广播一条删除消息,各实例收到后删除自己的本地缓存。顺序不能反过来,否则本地缓存删除后、Redis 删除前的读请求会把 Redis 中的旧值重新加载到本地;
|
||||
- Redis 的发布订阅不保证送达,实例断线期间的消息会丢失,所以本地缓存还要设置较短的过期时间(例如 30 秒)兜底,需要更可靠时改用消息队列;
|
||||
- 因此本地缓存只适合读多写少、能接受短暂不一致的数据,例如商品详情、配置;库存、余额这类数据不要放在本地缓存中。
|
||||
|
||||
Caffeine 是目前常用的本地缓存库,使用方式如下:
|
||||
本地缓存不直接用 HashMap 或 ConcurrentHashMap,是因为它们没有容量上限和过期时间,JVM 内存会一直上涨。常用的是 Caffeine:
|
||||
|
||||
```java
|
||||
Cache<String, Product> cache = Caffeine.newBuilder()
|
||||
@@ -86,7 +84,7 @@ Cache<String, Product> cache = Caffeine.newBuilder()
|
||||
Product p = cache.get("product:1024", key -> loadFromRedisOrDb(key));
|
||||
```
|
||||
|
||||
它的淘汰算法 W-TinyLFU 同时考虑访问频率和最近访问时间,命中率通常高于 LRU;get 传入的加载函数对同一个键只会执行一次,其它并发的请求等待它的结果,避免缓存未命中时大量请求同时查询下游。
|
||||
Caffeine 的淘汰算法 W-TinyLFU 同时考虑访问频率和最近访问时间:新数据先进入一个小的 LRU 窗口区,被窗口淘汰时再和主区将被淘汰的数据比较历史访问频率,频率高的留下,偶发的批量访问挤不掉热点数据,命中率通常高于 LRU。get 传入的加载函数对同一个键只会执行一次,其它并发的请求等待它的结果,避免缓存未命中时大量请求同时查询下游。
|
||||
|
||||
### 数据库缓存
|
||||
|
||||
@@ -102,7 +100,7 @@ CPU 为了解决运算速度与主存访问速度不匹配的问题,引入了
|
||||
|
||||
## 三、CDN
|
||||
|
||||
内容分发网络(Content distribution network,CDN)是一种互连的网络系统,它利用更靠近用户的服务器从而更快更可靠地将 HTML、CSS、JavaScript、音乐、图片、视频等静态资源分发给用户。
|
||||
内容分发网络(Content Delivery Network, CDN)是一种互连的网络系统,它利用更靠近用户的服务器从而更快更可靠地将 HTML、CSS、JavaScript、音乐、图片、视频等静态资源分发给用户。
|
||||
|
||||
用户请求通过 DNS 被调度到最近的 CDN 节点,节点未命中缓存时再回源站获取。
|
||||
|
||||
@@ -120,7 +118,7 @@ CDN 主要有以下优点:
|
||||
|
||||
### 缓存穿透
|
||||
|
||||
指的是对某个一定不存在的数据进行请求,该请求将会穿透缓存到达数据库。
|
||||
指的是对某个一定不存在的数据进行请求,该请求将会穿透缓存到达数据库。例如查询 id 为 -1 的商品,缓存里一直没有,每次请求都会查询数据库。
|
||||
|
||||
解决方案:
|
||||
|
||||
@@ -129,7 +127,7 @@ CDN 主要有以下优点:
|
||||
|
||||
缓存空值只对重复出现的 Key 有效。攻击者每次随机生成不同的 ID 时,每个 ID 第一次都会查询数据库,缓存的空值再也用不上,还占用内存:10 万个空值键约占 15 MB(Redis 7.4 实测,见[附录 A7](#a7-空值缓存的内存))。另外,这个 ID 之后被新增时要删除缓存的空值,否则在空值过期之前一直查不到。随机生成的 ID 需要布隆过滤器来拦截。
|
||||
|
||||
布隆过滤器用多个哈希函数把 Key 映射到位数组的多个位置并置为 1。查询时只要有一个位置为 0,Key 就一定不存在;全部为 1 时 Key 可能存在,存在一定的误判率。
|
||||
布隆过滤器用多个哈希函数把 Key 映射到位数组的多个位置并置为 1。查询时只要有一个位置为 0,Key 就一定不存在;全部为 1 时 Key 可能存在,存在一定的误判率。所以判断不存在时直接返回空结果,可能存在时照常查询,误判只会多一次数据库查询。
|
||||
|
||||
<div align="center"> <img src="pics/bloom-filter.png" width="620px"/> </div><br>
|
||||
|
||||
@@ -138,8 +136,8 @@ CDN 主要有以下优点:
|
||||
使用时需要注意:
|
||||
|
||||
- 容量要预估准确:插入的 Key 超过预期后误判率迅速上升,例如按 100 万个 Key 配置的过滤器插入了 200 万个 Key,按公式误判率会升到约 14%;
|
||||
- 不能删除:一个位可能同时属于多个 Key,清零会让其它 Key 被误判为不存在,需要删除时可以定期重建,或者使用计数布隆过滤器、布谷鸟过滤器;
|
||||
- 新增的数据要同时写入布隆过滤器,否则请求会被误判为不存在而拒绝。
|
||||
- 不能删除:一个位可能同时属于多个 Key,清零会让其它 Key 被误判为不存在,需要删除时可以定期用数据库中的全部 Key 重建(重建期间新增的 Key 同时写入新旧过滤器,建完再切换),或者使用计数布隆过滤器(每一位换成计数器,删除时减 1)、布谷鸟过滤器,它们也只能删除确实插入过、尚未删除的 Key;
|
||||
- 新增的数据要同时写入布隆过滤器,否则请求会被误判为不存在而拒绝。先写过滤器再写数据库:数据库写入失败只是多一个误判,反过来过滤器写入失败,这条数据会一直被拒绝。
|
||||
|
||||
Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis 7 需要加载 RedisBloom 模块。
|
||||
|
||||
@@ -151,36 +149,47 @@ Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis
|
||||
|
||||
解决方案:
|
||||
|
||||
- 互斥锁:缓存未命中时用 SET lock NX EX 3 抢锁,抢到的请求先再查一次缓存(可能别人已经写回),仍然没有才查询数据库并写回缓存,最后释放锁(释放时要确认锁仍是自己的,见 [分布式锁](分布式.md#redis-的-set-nx-指令));没抢到的请求等待几十毫秒后重新读缓存;
|
||||
- 逻辑过期:键不设置过期时间,而是在值中保存一个过期时间。读到已经过期的值时,抢到锁的请求启动后台线程重建缓存,所有请求都直接返回旧值,不等待。
|
||||
- 互斥锁:缓存未命中时用 `SET lock:product:1024 <随机值> NX EX 3` 抢锁,抢到的请求先再查一次缓存(可能别人已经写回),仍然没有才查询数据库并写回缓存,最后释放锁(释放时要确认锁仍是自己的,见 [分布式锁](分布式.md#redis-的-set-nx-指令));没抢到的请求等待一小段时间(例如 10 毫秒)后重新读缓存。锁的过期时间要长于重建缓存的耗时;
|
||||
- 逻辑过期:键不设置过期时间,而是在值中保存一个过期时间。读到已经过期的值时,抢到锁的请求启动后台线程重建缓存,所有请求都直接返回旧值,不等待。这些键不会被 volatile 开头的淘汰策略淘汰,不再需要时要主动删除。
|
||||
|
||||
<div align="center"> <img src="pics/cache-breakdown-lock-vs-logical.png" width="760px"/> </div><br>
|
||||
|
||||
两者都只让一个请求查询数据库,区别在于等待重建还是返回旧值。热点键刚过期时 100 个请求同时到达,数据库查询耗时 50 毫秒:不加保护时查询了数据库 68~100 次;互斥锁只查询 1 次,请求耗时的中位数约 90~100 毫秒;逻辑过期也只查询 1 次,耗时中位数约 12 毫秒,但 100 个请求拿到的都是旧值(Redis 7.4、MySQL 8.0 实测,见[附录 A5](#a5-缓存击穿的三种做法))。要求数据准确时用互斥锁,能接受短暂的旧值、更看重响应时间时用逻辑过期。
|
||||
|
||||
写请求按 Cache Aside 删除热点键后同样会击穿。用逻辑过期时,写请求不删除键,而是把值中的过期时间改为已过期,由后台重建。
|
||||
|
||||
### 缓存雪崩
|
||||
|
||||
指的是由于数据没有被加载到缓存中,或者缓存数据在同一时间大面积失效(过期),又或者缓存服务器宕机,导致大量的请求都到达数据库。
|
||||
|
||||
在有缓存的系统中,系统非常依赖于缓存,缓存分担了很大一部分的数据请求。当发生缓存雪崩时,数据库无法处理这么大的请求,导致数据库崩溃。
|
||||
缓存平时分担了绝大部分的读请求,数据库只按剩下的流量配置。例如命中率为 99% 时,每秒 1 万次读请求中只有 100 次到达数据库;缓存整体失效后这 1 万次都到达数据库,压力变为原来的 100 倍,数据库可能因此崩溃。
|
||||
|
||||
解决方案:
|
||||
|
||||
- 为了防止缓存在同一时间大面积过期导致的缓存雪崩,可以在过期时间上加一个随机值,让过期时间分散开;
|
||||
- 为了防止缓存服务器宕机出现的缓存雪崩,可以使用分布式缓存,分布式缓存中每一个节点只缓存部分的数据,当某个节点宕机时可以保证其它节点的缓存仍然可用;每个节点再配上从节点,由哨兵或 Redis Cluster 自动故障转移,见 [Sentinel](Redis.md#十二sentinel);
|
||||
- 为了防止缓存在同一时间大面积过期导致的缓存雪崩,可以在过期时间上加一个随机值,让过期时间分散开,例如活动开始时一起写入的 10 万个商品,过期时间从统一的 3600 秒改为 3600~3900 秒中的随机值;
|
||||
- 为了防止缓存服务器宕机出现的缓存雪崩,可以使用分布式缓存,每一个节点只缓存部分的数据;每个节点还要配上从节点,由[哨兵](Redis.md#十二sentinel)或 [Redis Cluster](Redis.md#redis-cluster) 自动故障转移;
|
||||
- 也可以进行缓存预热,避免在系统刚启动不久由于还未将大量数据进行缓存而导致缓存雪崩;
|
||||
- 在应用中加一层本地缓存,Redis 不可用时热点数据仍能命中,见 [多级缓存](#多级缓存);
|
||||
- 对数据库的访问进行限流,即使缓存失效也不至于压垮数据库;Redis 大面积超时时熔断,直接返回默认值或者提示稍后重试,见 [限流、降级与熔断](系统设计基础.md#限流降级与熔断)。
|
||||
|
||||
### 缓存一致性
|
||||
|
||||
缓存一致性要求数据更新之后缓存中不能长期保留旧数据。常用的是 Cache Aside 模式:
|
||||
缓存一致性(也常称为双写一致性)要求数据更新之后缓存中不能长期保留旧数据。常用的是 Cache Aside 模式:
|
||||
|
||||
- 读:先读缓存,未命中则读数据库,并将结果写入缓存;
|
||||
- 写:先更新数据库,再删除缓存。
|
||||
|
||||
删除缓存而不是更新缓存,是因为两个并发的写操作到达缓存的顺序可能和到达数据库的顺序不同,导致缓存中是旧值。先更新数据库再删除缓存,是因为反过来的话,删除缓存之后、更新数据库之前的读请求会把旧值重新写入缓存。
|
||||
删除缓存而不是更新缓存,原因有两个:
|
||||
|
||||
- 并发的写操作到达缓存的顺序可能和到达数据库的顺序不同:例如写请求 A 把价格改为 10、写请求 B 改为 20,数据库中 A 先执行、B 后执行,结果为 20,更新缓存时却是 B 先到、A 后到,缓存中留下的是 10;
|
||||
- 写多读少的数据每次写入都更新缓存,值可能还没被读取就又被覆盖。
|
||||
|
||||
先更新数据库再删除缓存,是因为反过来的话,删除缓存之后、更新数据库之前的读请求会把旧值重新写入缓存:
|
||||
|
||||
<div align="center"> <img src="pics/cache-aside-race.png" width="520px"/> </div><br>
|
||||
|
||||
需要注意的是,“更新数据库”指事务已经提交,否则提交之前的读请求仍会把旧值写回缓存;用 Spring 的 @Transactional 时,删除缓存要放到提交之后,例如 @TransactionalEventListener 默认的 AFTER_COMMIT 阶段。
|
||||
|
||||
先更新数据库再删除缓存,仍然有两种情况会留下旧值:
|
||||
|
||||
- 删除缓存失败,例如 Redis 超时;
|
||||
@@ -195,16 +204,18 @@ Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis
|
||||
常用的补救措施:
|
||||
|
||||
- 为缓存设置过期时间作为兜底,需要注意过期时间从这次写回缓存时算起,慢读请求很晚才写回旧值时,旧值最长还会保留一个完整的过期时间;
|
||||
- 延迟双删:更新数据库并删除缓存之后,隔一段时间再删除一次,清掉慢读请求写回的旧值,间隔要大于读请求从查询数据库到写回缓存的耗时;
|
||||
- 延迟双删:更新数据库并删除缓存之后,隔一段时间再删除一次,清掉慢读请求写回的旧值;
|
||||
- 删除失败时重试:把要删除的键发到消息队列,由消费者删除,失败就重新投递;
|
||||
- 订阅数据库的 binlog(例如使用 Canal)异步删除缓存,失败时重试,业务代码中不再需要删除缓存;
|
||||
- 订阅 binlog 删除缓存:Canal 这类组件伪装成 MySQL 的从库读取 binlog,发现某一行被修改就删除对应的缓存键,失败时重试,业务代码中不再需要删除缓存,手工执行的 SQL 也能覆盖,代价是多部署一套组件;
|
||||
- 写回缓存时检查期间是否有过删除:Facebook 的 Memcache 在缓存未命中时发给读请求一个租约(lease)令牌,写回时要带上令牌,如果这期间键被删除,令牌就会失效,旧值写不进缓存。
|
||||
|
||||
延迟双删的第二次删除不能让写请求同步等待,通常交给线程池或者延迟消息执行。间隔需要估计:附录 A4 中读请求最多停顿 5 毫秒,间隔取 50 毫秒时没有留下旧值;间隔太短清不掉旧值,太长则旧值在缓存中停留更久。第二次删除也可能失败,同样要重试,最后靠过期时间兜底。
|
||||
延迟双删的第二次删除不能让写请求同步等待,通常交给线程池或者延迟消息执行。间隔要大于读请求从查询数据库到写回缓存的耗时:附录 A4 中读请求最多停顿 5 毫秒,间隔取 50 毫秒时没有留下旧值;间隔太短清不掉旧值,太长则旧值在缓存中停留更久。第二次删除也可能失败,同样要重试,最后靠过期时间兜底。
|
||||
|
||||
binlog 方案把删除缓存从业务代码中移出:Canal 伪装成 MySQL 的从库读取 binlog,发现某一行被修改就删除对应的缓存键,失败后可以重试,代价是多部署一套组件,缓存的删除也有一定延迟。
|
||||
常见的组合是先更新数据库再删除缓存,删除失败时重试,再用过期时间兜底。延迟双删针对的情况概率很小,还会让热点键多一次未命中,对一致性要求较高时再加。
|
||||
|
||||
缓存和数据库之间只能做到最终一致,要保证强一致需要付出很大的代价,例如读写都加分布式读写锁,写入期间所有读请求等待。缓存数据最好是那些对一致性要求不高、能接受短时间读到旧值的数据,余额、库存扣减这类数据应该直接读写数据库。秒杀中先在 Redis 中扣减库存、再异步写数据库的做法不属于这里的缓存,Redis 中的库存是主数据,见 [秒杀系统](系统设计基础.md#六秒杀系统)。
|
||||
读写分离时,主库提交并删除缓存后从库可能还没有这次更新,未命中的读请求会把从库中的旧值写回缓存。可以在写入后的一小段时间内让这个键的读请求查主库,或者订阅从库的 binlog 删除缓存。
|
||||
|
||||
缓存和数据库之间只能做到最终一致,要保证强一致需要付出很大的代价,例如读写都加分布式读写锁,写入期间所有读请求等待。缓存数据最好是那些对一致性要求不高、能接受短时间读到旧值的数据,余额、库存扣减这类数据应该直接读写数据库。秒杀中先在 Redis 中扣减库存、再异步写数据库的做法不属于这里的缓存,Redis 中的库存不能删除后从数据库重新加载,见 [Redis 与数据库的库存一致](系统设计基础.md#redis-与数据库的库存一致)。
|
||||
|
||||
### 缓存 “无底洞” 现象
|
||||
|
||||
@@ -214,7 +225,7 @@ binlog 方案把删除缓存从业务代码中移出:Canal 伪装成 MySQL 的
|
||||
|
||||
解决方案:
|
||||
|
||||
- 按节点分组:客户端先算出每个键所在的节点,同一个节点的键合并成一次 MGET 或者用流水线发送,网络往返次数从键的数量降为节点的数量;
|
||||
- 按节点分组:客户端先算出每个键所在的节点,同一个节点的键用流水线一次发送,网络往返次数从键的数量降为节点的数量(Redis Cluster 中一条 MGET 的键必须在同一个槽,所以用流水线);
|
||||
- 并行发送:各节点的请求同时发出,耗时取决于最慢的节点;
|
||||
- 哈希标签:经常一起读取的键用 {} 放到 Redis Cluster 的同一个槽中,一次请求取回;
|
||||
- 使用长连接、连接池,减少建立连接的开销。
|
||||
@@ -233,14 +244,15 @@ binlog 方案把删除缓存从业务代码中移出:Canal 伪装成 MySQL 的
|
||||
大 Key 的发现与处理:
|
||||
|
||||
- 用 redis-cli --bigkeys 或者 --memkeys 扫描,它们使用 SCAN 命令遍历,不会阻塞服务器;用 MEMORY USAGE 查看单个键占用的内存;
|
||||
- 拆分:例如把一个大的 HASH 按字段的哈希值拆成 16 个小 HASH,user:1001:fav:0 到 user:1001:fav:15;
|
||||
- 拆分:例如把一个大的 HASH 按字段的哈希值拆成 16 个小 HASH,user:1001:fav:0 到 user:1001:fav:15,读写字段 f 时访问第 hash(f) % 16 个;大的字符串可以压缩,或者把文件放到对象存储,Redis 中只保存地址;
|
||||
- 删除用 UNLINK,并开启 lazyfree-lazy-expire 和 lazyfree-lazy-eviction,让过期和淘汰也在后台释放内存;读取集合时用 HSCAN、SSCAN 分批读取,不要一次读出所有元素。
|
||||
|
||||
热 Key 的问题是请求集中到同一个节点,集群中其它节点再多也无法分担。处理方法:
|
||||
|
||||
- 发现:redis-cli --hotkeys,它要求淘汰策略为 LFU;也可以在客户端或者代理中统计访问频率;
|
||||
- 发现:redis-cli --hotkeys,它读取的是 LFU 的访问计数,要求淘汰策略为 LFU,见 [七、数据淘汰策略](Redis.md#七数据淘汰策略);也可以在客户端或者代理中统计访问频率;
|
||||
- 本地缓存:在应用中缓存热 Key 的值,大部分请求不再访问 Redis,代价是数据会有短暂的不一致,见上文的 [本地缓存](#本地缓存);
|
||||
- 复制多份:把同一个值写到 stock#0 到 stock#9 这 10 个键中,读取时随机选一个。这些键分布在不同的槽上,请求就分散到了多个节点,代价是写入时要更新所有副本。
|
||||
- 复制多份:把同一个值写到 product:1024#0 到 product:1024#9 这 10 个键中,读取时随机选一个。这些键分布在不同的槽上,请求就分散到了多个节点,代价是写入时要更新所有副本,只适合读多写少的数据;
|
||||
- 写热点:上面两种做法只分担读请求。库存可以拆成几份放在不同的键中分别扣减,见[秒杀系统](系统设计基础.md#六秒杀系统);点赞数这类计数可以先在应用内累加,每隔一秒用一次 INCRBY 写入,见[热门视频的计数](系统设计基础.md#1-热门视频的计数)。
|
||||
|
||||
热 Key 过期时大量请求同时打到数据库的问题,见上文的缓存击穿。
|
||||
|
||||
@@ -259,7 +271,7 @@ binlog 方案把删除缓存从业务代码中移出:Canal 伪装成 MySQL 的
|
||||
顺序分布相比于哈希分布的主要优点如下:
|
||||
|
||||
- 能保持数据原有的顺序;
|
||||
- 并且能够准确控制每台服务器存储的数据量,从而使得存储空间的利用率最大。
|
||||
- 能够准确控制每台服务器存储的数据量,从而使得存储空间的利用率最大。
|
||||
|
||||
缺点是容易产生热点,例如按时间分布时,写入都集中在最新的节点上。
|
||||
|
||||
@@ -283,18 +295,38 @@ binlog 方案把删除缓存从业务代码中移出:Canal 伪装成 MySQL 的
|
||||
|
||||
解决方式是通过增加虚拟节点,然后将虚拟节点映射到真实节点上。虚拟节点的数量比真实节点来得多,那么虚拟节点在哈希环上分布的均匀性就会比原来的真实节点好,从而使得数据分布也更加均匀。每个节点放 150 个虚拟节点后,4 个节点的占比在 22.8%~27.0% 之间。
|
||||
|
||||
Redis Cluster 使用的不是一致性哈希,而是把 Key 映射到固定的 16384 个哈希槽上,再把哈希槽分配给各个节点,节点变化时只需要迁移对应的哈希槽。
|
||||
Redis Cluster 使用的不是一致性哈希,而是把 Key 映射到固定的 16384 个哈希槽上,再把哈希槽分配给各个节点,节点变化时只需要迁移对应的哈希槽,见 [Redis Cluster](Redis.md#redis-cluster)。好处是迁移可控:新增节点时可以指定从每个旧节点迁走多少个槽,一致性哈希中新节点分走哪一段由它的哈希值决定。
|
||||
|
||||
## 七、LRU 🔥
|
||||
|
||||
以下是基于双向链表和 HashMap 的 LRU 算法实现,对算法的解释如下:
|
||||
|
||||
- 访问某个节点时,将其从原来的位置删除,并重新插入到链表头部。这样就能保证链表尾部存储的就是最近最久未使用的节点,当节点数量大于缓存最大空间时就淘汰链表尾部的节点;
|
||||
- 为了使删除操作时间复杂度为 O(1),就不能采用遍历的方式找到某个节点。HashMap 存储着 Key 到节点的映射,通过 Key 就能以 O(1) 的时间得到节点,然后再以 O(1) 的时间将其从双向链表中删除。
|
||||
- 为了使删除操作时间复杂度为 O(1),就不能采用遍历的方式找到某个节点。HashMap 存储着 Key 到节点的映射,通过 Key 就能以 O(1) 的时间得到节点,然后再以 O(1) 的时间将其从双向链表中删除;
|
||||
- 用双向链表是因为删除节点时要修改前一个节点的指针,单向链表只能从头遍历才能找到它;节点中还要保存 Key,淘汰尾部节点时才能用它删除 HashMap 中对应的项。
|
||||
|
||||
<div align="center"> <img src="pics/lru.png" width="620px"/> </div><br>
|
||||
|
||||
Java 的 LinkedHashMap 内部就是这种结构,把 accessOrder 设为 true 并重写 removeEldestEntry 方法,就可以直接得到一个 LRU 缓存。
|
||||
Java 的 LinkedHashMap 内部就是这种结构,把 accessOrder 设为 true 并重写 removeEldestEntry 方法,就可以直接得到一个 LRU 缓存:
|
||||
|
||||
```java
|
||||
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
|
||||
|
||||
private final int maxSize;
|
||||
|
||||
public LRUCache(int maxSize) {
|
||||
super(16, 0.75f, true); // 第三个参数 accessOrder 为 true:按访问顺序排列
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
|
||||
return size() > maxSize; // 每次 put 之后调用,返回 true 时删除最久未访问的元素
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
例如容量为 2 时依次 put(1)、put(2)、get(1)、put(3),被淘汰的是 2。LinkedHashMap 把最近访问的放在尾部,与下面手写实现的方向相反。面试中手写 LRU 时通常要求不用 LinkedHashMap,自己实现链表:
|
||||
|
||||
```java
|
||||
public class LRU<K, V> implements Iterable<K> {
|
||||
@@ -422,7 +454,11 @@ public class LRU<K, V> implements Iterable<K> {
|
||||
}
|
||||
```
|
||||
|
||||
上面的实现不是线程安全的:get() 也会移动链表中的节点,多个线程同时访问会破坏链表,所以读操作也要加锁。简单的做法是 get() 和 put() 使用同一把锁,或者用 Collections.synchronizedMap() 包装上面的 LinkedHashMap。并发高时用 Caffeine,它不在每次读取时调整顺序,而是把访问记录写入缓冲区,再批量调整,读操作不需要获取全局锁。
|
||||
上面的实现不是线程安全的:get() 也会移动链表中的节点,多个线程同时访问会破坏链表,所以读操作也要加锁。简单的做法是 get() 和 put() 使用同一把锁,或者用 Collections.synchronizedMap() 包装上面的 LinkedHashMap。需要注意的是,不能用读写锁的读锁保护 get():8 个线程并发访问时没有抛出异常,但链表已被破坏,size() 为 1999,遍历只能走到 1 个节点(JDK 21 实测,见[附录 A9](#a9-lru-的并发访问))。
|
||||
|
||||
并发高时有两种做法:一是分段,按 Key 的哈希分到 16 个各自加锁的 LRU,代价是只在段内淘汰;二是用 Caffeine,它不在每次读取时调整顺序,而是把访问记录写入缓冲区,再批量调整,读操作不需要获取全局锁。
|
||||
|
||||
LRU 只看最近一次访问,一次全表扫描读入的冷数据就会把热点挤出去。MySQL 的 Buffer Pool 因此把链表分为 young 和 old 两段:新读入的页先放在 old 段,距首次访问超过 innodb_old_blocks_time(默认 1000 ms)后再被访问才移到 young 段,全表扫描读入的页很快就被淘汰。Redis 则不维护链表,随机抽样近似 LRU,见 [七、数据淘汰策略](Redis.md#七数据淘汰策略)。
|
||||
|
||||
## 附录:实测
|
||||
|
||||
@@ -551,7 +587,7 @@ 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。每种做法运行两轮:
|
||||
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。每种做法运行两轮。实验只统计查询次数,锁的值固定为 1、用 DELETE 释放;实际使用时按正文写入随机值,释放前确认锁仍是自己的:
|
||||
|
||||
```python
|
||||
def mutex():
|
||||
@@ -627,6 +663,27 @@ lazyfree-lazy-expire no: HLEN 100 万,MEMORY USAGE 64388720,过期后 EXISTS
|
||||
lazyfree-lazy-expire yes: HLEN 100 万,MEMORY USAGE 64388720,过期后 EXISTS=0,LATENCY 中没有 expire-cycle(低于 1 ms 不记录),期间最慢的 PING 3.6 ms
|
||||
```
|
||||
|
||||
### A9 LRU 的并发访问
|
||||
|
||||
JDK 21.0.13,Apple M1 Pro。容量为 1000 的 LinkedHashMap(accessOrder 为 true,重写 removeEldestEntry),先放入 1000 个键;8 个线程各执行 20 万次操作,键在 0~1999 中随机选取,90% 为 get、10% 为 put。一种写法用 ReentrantReadWriteLock,get 加读锁、put 加写锁;另一种 get 和 put 都在同一把锁中执行。结束后读取 size(),并沿链表遍历 keySet():
|
||||
|
||||
```java
|
||||
if (r.nextInt(10) == 0) {
|
||||
rw.writeLock().lock(); try { m.put(k, k); } finally { rw.writeLock().unlock(); }
|
||||
} else {
|
||||
rw.readLock().lock(); try { m.get(k); } finally { rw.readLock().unlock(); }
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
读锁保护 get : 线程结果=[ok] size()=1999 遍历链表得到=1 遍历=ok
|
||||
读锁保护 get : 线程结果=[ok] size()=1999 遍历链表得到=1 遍历=ok
|
||||
读锁保护 get : 线程结果=[ok] size()=1999 遍历链表得到=1 遍历=ok
|
||||
互斥锁 : 线程结果=[ok] size()=1000 遍历链表得到=1000 遍历=ok
|
||||
互斥锁 : 线程结果=[ok] size()=1000 遍历链表得到=1000 遍历=ok
|
||||
互斥锁 : 线程结果=[ok] size()=1000 遍历链表得到=1000 遍历=ok
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- 杨传辉. 大规模分布式存储系统:原理解析与架构实战 [M]. 机械工业出版社, 2013.
|
||||
|
||||