Skip to content

Commit 1f9e5c5

Browse files
committed
add:缓存常用的 3 种读写策略重构完善
1 parent 5cf3058 commit 1f9e5c5

File tree

3 files changed

+81
-50
lines changed

3 files changed

+81
-50
lines changed

docs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ JavaGuide 已经持续维护 6 年多了,累计提交了接近 **6000** commit
4444

4545
## 面试辅导
4646

47-
给自己打个小广告,如果需要面试辅导(比如简历优化、一对一模拟问答、高频考点突击资料等),欢迎了解我的[知识星球](https://javaguide.cn/about-the-author/zhishixingqiu-two-years.html)。已经坚持维护六年,内容持续更新,虽白菜价(**0.4元/天**)但质量很高,主打一个良心!
47+
给自己打个小广告,如果需要面试辅导(比如简历优化、一对一提问、高频考点突击资料等),欢迎了解我的[知识星球](https://javaguide.cn/about-the-author/zhishixingqiu-two-years.html)。已经坚持维护六年,内容持续更新,虽白菜价(**0.4元/天**)但质量很高,主打一个良心!
4848

4949
<a href="https://javaguide.cn/about-the-author/zhishixingqiu-two-years.html" target="_blank" rel="noopener noreferrer">
5050
<img src="https://oss.javaguide.cn/github/javaguide/zhishixingqiuhaibao.png"

docs/database/redis/3-commonly-used-cache-read-and-write-strategies.md

Lines changed: 72 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ tag:
66
head:
77
- - meta
88
- name: keywords
9-
content: 缓存读写策略,Cache Aside,Read Through,Write Through,一致性,失效
9+
content: 缓存读写策略,Cache Aside,Read Through,Write Through,Write Behind,Write Back,缓存一致性,缓存失效,旁路缓存,读写穿透,异步缓存写入,Redis缓存策略,缓存更新策略
1010
- - meta
1111
- name: description
12-
content: 总结三种常见缓存读写策略及适用场景,分析一致性与失效处理,指导业务选型与问题规避。
12+
content: 深入对比 Cache Aside、Read/Write Through、Write Behind 三种缓存读写策略,附详细时序图、一致性问题分析及生产级解决方案,Redis 实战必备!
1313
---
1414

1515
看到很多小伙伴简历上写了“**熟练使用缓存**”,但是被我问到“**缓存常用的 3 种读写策略**”的时候却一脸懵逼。
@@ -22,104 +22,137 @@ head:
2222

2323
### Cache Aside Pattern(旁路缓存模式)
2424

25-
**Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。**
25+
这是我们日常开发中**最常用、最经典**的一种模式,几乎是互联网应用缓存方案的事实标准,尤其适合**读多写少**的业务场景。
2626

27-
Cache Aside Pattern 中服务端需要同时维系 db 和 cache,并且是以 db 的结果为准。
27+
这个模式之所以被称为**“旁路”(Aside)**,是因为应用程序的**写操作完全绕过了缓存,直接操作数据库**
28+
29+
应用程序扮演了数据流转的“指挥官”,需要同时维护 Cache 和 DB 两个数据源。
2830

2931
下面我们来看一下这个策略模式下的缓存读写步骤。
3032

31-
****
33+
**写操作 :**
3234

33-
- 先更新 db
34-
- 然后直接删除 cache
35+
1. 应用**先更新 DB**
36+
2. 然后**直接删除 Cache**中对应的数据
3537

3638
简单画了一张图帮助大家理解写的步骤。
3739

3840
![](https://oss.javaguide.cn/github/javaguide/database/redis/cache-aside-write.png)
3941

40-
**** :
42+
**读操作:**
4143

42-
- 从 cache 中读取数据,读取到就直接返回
43-
- cache 中读取不到的话,就从 db 中读取数据返回
44-
- 再把数据放到 cache 中
44+
1. 应用先从 Cache 读取数据。
45+
2. 如果命中(Hit),则直接返回。
46+
3. 如果未命中(Miss),则从 DB 读取数据,成功读取后,**将数据写回 Cache**,然后返回
4547

4648
简单画了一张图帮助大家理解读的步骤。
4749

4850
![](https://oss.javaguide.cn/github/javaguide/database/redis/cache-aside-read.png)
4951

5052
你仅仅了解了上面这些内容的话是远远不够的,我们还要搞懂其中的原理。
5153

52-
比如说面试官很可能会追问:“**在写数据的过程中,可以先删除 cache ,后更新 db 么?**
54+
比如说面试官很可能会追问:
55+
56+
1. 为什么写操作是“先更新 DB,后删除 Cache”?顺序能反过来吗?
57+
2. 那“先更新 DB,后删除 Cache”就绝对安全吗?
58+
3. 为什么是“删除 Cache”,而不是“更新 Cache”?
5359

54-
**答案:** 那肯定是不行的!因为这样可能会造成 **数据库(db)和缓存(Cache)数据不一致**的问题
60+
接下来我会以此分析解答这些问题
5561

56-
举例:请求 1 先写数据 A,请求 2 随后读数据 A 的话,就很有可能产生数据不一致性的问题。
62+
**1. 为什么写操作是“先更新 DB,后删除 Cache”?顺序能反过来吗?**
5763

58-
这个过程可以简单描述为:
64+
**答:** 绝对不能。如果“先删 Cache,后更新 DB”,在高并发下会引入经典的数据不一致问题。
5965

60-
> 请求 1 先把 cache 中的 A 数据删除 -> 请求 2 从 db 中读取数据->请求 1 再把 db 中的 A 数据更新
66+
- **时序分析 (请求 A 写, 请求 B 读):**
67+
1. 请求 A: 先将 Cache 中的数据删除。
68+
2. 请求 B: 此时发现 Cache 为空,于是去 DB 读取**旧值**,并准备写入 Cache。
69+
3. 请求 A : 将**新值**写入 DB。
70+
4. 请求 B: 将之前读到的**旧值**写入了 Cache。
71+
- **结果:** DB 中是新值,而 Cache 中是旧值,数据不一致。
6172

62-
当你这样回答之后,面试官可能会紧接着就追问:“**在写数据的过程中,先更新 db,后删除 cache 就没有问题了么**
73+
**2. 那“先更新 DB,后删除 Cache”就绝对安全吗**
6374

64-
**答案:** 理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多
75+
**答案:** 也不是绝对安全的!因为这样也可能会造成 **数据库和缓存数据不一致**的问题
6576

66-
举例:请求 1 先读数据 A,请求 2 随后写数据 A,并且数据 A 在请求 1 请求之前不在缓存中的话,也有可能产生数据不一致性的问题。
77+
- **时序分析 (请求 A 读, 请求 B 写):**
78+
1. 请求 A : 缓存未命中,从 DB 读取到**旧值**
79+
2. 请求 B: 迅速完成了 DB 的更新,并将 Cache 删除。
80+
3. 请求 A : 将自己之前拿到的**旧值**写入了 Cache。
81+
- **结果:** DB 中是新值,Cache 中又是旧值。
82+
- **为什么概率极小?** 这个问题本质上是一个并发时序问题:只要“读 DB → 写 Cache”这段时间窗口内,恰好有写请求完成了 DB 更新,就有可能产生不一致。在大多数业务里,这个窗口时间相对较短,而且还需要与写请求并发“撞车”,所以发生概率不算高,但绝不是不可能。
6783

68-
这个过程可以简单描述为:
84+
**3. 为什么是“删除 Cache”,而不是“更新 Cache”?**
6985

70-
> 请求 1 从 db 读数据 A-> 请求 2 更新 db 中的数据 A(此时缓存中无数据 A ,故不用执行删除缓存操作 ) -> 请求 1 将数据 A 写入 cache
86+
- **性能开销:** 写操作往往只更新了对象的部分字段,如果为了“更新 Cache”而去重新查询或计算整个缓存对象,开销可能很大。相比之下,“删除”是一个轻量级操作。
87+
- **懒加载思想:** “删除”操作遵循懒加载原则。只有当数据下一次被真正需要(被读取)时,才触发从 DB 加载并写入缓存,避免了无效的缓存更新。
88+
- **并发安全:** “更新缓存”在高并发下可能出现更新顺序错乱的问题导致脏数据的概率会更大。
89+
90+
当然,这一切都建立在一个重要的前提之上:我们缓存的数据,是可以通过数据库进行确定性重建的,并且业务上可以容忍从‘缓存删除’到‘下一次读取并回填’之间这个极短时间窗口内的数据不一致。
7191

7292
现在我们再来分析一下 **Cache Aside Pattern 的缺陷**
7393

74-
**缺陷 1:首次请求数据一定不在 cache 的问题**
94+
**缺陷 1:首次请求数据一定不在 Cache 的问题**
7595

76-
解决办法:可以将热点数据可以提前放入 cache 中
96+
解决办法:对于访问量巨大的热点数据,可以在系统启动或低峰期进行缓存预热
7797

78-
**缺陷 2:写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率 。**
98+
**缺陷 2:写操作比较频繁的话导致 Cache 中的数据会被频繁被删除,这样会影响缓存命中率 。**
7999

80100
解决办法:
81101

82-
- 数据库和缓存数据强一致场景:更新 db 的时候同样更新 cache,不过我们需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题。
83-
- 可以短暂地允许数据库和缓存数据不一致的场景:更新 db 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。
102+
- 数据库和缓存数据强一致场景:更新 DB 的时候同样更新 Cache,不过我们需要加一个锁/分布式锁来保证更新 Cache 的时候不存在线程安全问题。
103+
- 可以短暂地允许数据库和缓存数据不一致的场景:更新 DB 的时候同样更新 Cache,但是给缓存加一个比较短的过期时间(如 1 分钟),这样的话就可以保证即使数据不一致的话影响也比较小。
84104

85105
### Read/Write Through Pattern(读写穿透)
86106

87-
Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。
107+
在这种模式下,应用程序将**Cache 视为唯一的、主要的存储**。所有的读写请求都直接打向 Cache,而 Cache 服务自身负责与 DB 进行数据同步。
108+
109+
对应用程序**透明**,应用开发者无需关心 DB 的存在。
88110

89-
这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入 db 的功能。
111+
这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 本身并没有提供 Cache 将数据写入 DB 的功能,需要我们在业务侧或中间件里自己实现
90112

91113
**写(Write Through):**
92114

93-
- 先查 cache,cache 中不存在,直接更新 db
94-
- cache 中存在,则先更新 cache,然后 cache 服务自己更新 db(**同步更新 cachedb**
115+
- 先查 Cache,Cache 中不存在,直接更新 DB
116+
- Cache 中存在,则先更新 Cache,然后 Cache 服务自己更新 DB。只有当 CacheDB 都写入成功后,才向上层返回成功
95117

96118
简单画了一张图帮助大家理解写的步骤。
97119

98120
![](https://oss.javaguide.cn/github/javaguide/database/redis/write-through.png)
99121

100122
**读(Read Through):**
101123

102-
- 从 cache 中读取数据,读取到就直接返回 。
103-
- 读取不到的话,先从 db 加载,写入到 cache 后返回响应。
124+
- 应用从 Cache 读取数据。
125+
- 如果命中,直接返回。
126+
- 如果未命中,由**Cache 服务自己**负责从 DB 加载数据,加载成功后先写入自身,再返回给应用。
104127

105128
简单画了一张图帮助大家理解读的步骤。
106129

107130
![](https://oss.javaguide.cn/github/javaguide/database/redis/read-through.png)
108131

109-
Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。
132+
Read-Through 实际只是在 Cache-Aside 之上进行了封装。在 Cache-Aside 下,发生读请求的时候,如果 Cache 中不存在对应的数据,是由客户端自己负责把数据写入 Cache,而 Read Through 则是 Cache 服务自己来写入缓存的,这对客户端是透明的。
110133

111-
和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。
134+
从实现角度看,Read-Through 本质上是把 Cache-Aside 中“读 Miss → 读 DB → 回填 Cache”的逻辑,下沉到了缓存服务内部,对客户端透明。
135+
136+
和 Cache Aside 一样, Read-Through 也有首次请求数据一定不再 Cache 的问题,对于热点数据可以提前放入缓存中。
112137

113138
### Write Behind Pattern(异步缓存写入)
114139

115-
Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 db 的读写。
140+
Write Behind(也常被称为 Write-Back) Pattern 和 Read/Write Through Pattern 很相似,两者都是由 Cache 服务来负责 Cache 和 DB 的读写。
141+
142+
但是,两个又有很大的不同:**Read/Write Through 是同步更新 Cache 和 DB,而 Write Behind 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。**
143+
144+
**写操作 (Write Behind):**
116145

117-
但是,两个又有很大的不同:**Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。**
146+
1. 应用将数据写入 Cache,然后**立即返回**
147+
2. Cache 服务将这个写操作放入一个队列中。
148+
3. 通过一个独立的异步线程/任务,将队列中的写操作**批量地、合并地**写入 DB。
118149

119-
很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了
150+
这种模式对数据一致性带来了挑战(例如:Cache 中的数据还没来得及写回 DB,系统就宕机了),因此不适用于需要强一致性的场景(如交易、库存)
120151

121-
这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。
152+
但是,它的异步和批量特性,带来了**无与伦比的写性能**。它在很多高性能系统中都有广泛应用:
122153

123-
Write Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。
154+
- **MySQL 的 InnoDB Buffer Pool 机制:** 数据修改先在内存 Buffer Pool 中完成,然后由后台线程异步刷写到磁盘。
155+
- **操作系统的页缓存(Page Cache):** 文件写入也是先写到内存,再由操作系统异步刷盘。
156+
- **高频计数场景:** 对于文章浏览量、帖子点赞数这类允许短暂数据不一致、但写入极其频繁的场景,可以先在 Redis 中快速累加,再通过定时任务异步同步回数据库。
124157

125158
<!-- @include: @article-footer.snippet.md -->

docs/java/basis/java-basic-questions-02.md

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -504,18 +504,16 @@ public native int hashCode();
504504

505505
### 为什么要有 hashCode?
506506

507-
我们以“`HashSet` 如何检查重复”为例子来说明为什么要有 `hashCode`
507+
我们以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode?
508508

509-
当你把对象加入 `HashSet` 时,`HashSet` 会先计算对象的 `hashCode()` 值,并通过内部的散列函数根据这个值决定对象应该落入哪个桶(bucket,对应到底层数组的某个位置)
509+
当我们把对象加入 HashSet 时,HashSet 会先调用对象的 `hashCode()` 方法,得到一个“哈希值”,并通过内部散列函数对这个哈希值再做一次简单的转换(比如取余),决定这条数据应该放进底层数组的哪一个桶(bucket,对应到底层数组的某个位置)
510510

511-
如果该桶当前是空的,就直接将对象对应的节点插入到这个桶中。
512-
513-
如果该桶中已经有其他元素,`HashSet` 会在这个桶对应的链表或红黑树中逐个比较:
514-
515-
- 对于 **`hash` 不同** 的节点,直接跳过,不会调用 `equals()`
516-
- 对于 **`hash` 相同** 的节点,则会进一步调用 `equals()` 方法来检查这两个对象是否“相等”:
517-
- 如果 `equals()` 返回 `true`,说明集合中已经存在与当前对象等价的元素,`HashSet` 就不会再次加入它;
518-
- 如果遍历完整个桶,都没有找到 `equals()` 返回 `true` 的元素,则会将该对象作为一个新节点加入到**同一个桶**的链表或红黑树中(不会“重新散列到其他位置”)。
511+
1. 如果该桶当前是空的,就直接将对象对应的节点插入到这个桶中。
512+
2. 如果该桶中已经有其他元素,HashSet 会在这个桶对应的链表或红黑树中逐个比较:
513+
- 对于**哈希值不同**的节点,直接跳过;
514+
- 对于**哈希值相同**的节点,则会进一步调用 equals() 方法来检查这两个对象是否“相等”:
515+
– 如果 `equals()` 返回 true,说明集合中已经存在与当前对象等价的元素,`HashSet` 就不会再次加入它;
516+
– 如果返回 false, 则认为是新元素,会将该对象作为一个新节点加入到**同一个桶**的链表或红黑树中。
519517

520518
通过先利用 `hashCode()` 将候选范围缩小到同一个桶内,再在桶内少量元素上调用 `equals()` 做精确判断,`HashSet` 大大减少了 `equals()` 的调用次数,从而提高了查找和插入的执行效率。
521519

0 commit comments

Comments
 (0)