Skip to content

Commit 90e6a6d

Browse files
author
1020325258
committed
docs:提交面经文章
1 parent 463cb3b commit 90e6a6d

File tree

7 files changed

+1109
-4
lines changed

7 files changed

+1109
-4
lines changed

docs/.vuepress/config.js

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,25 @@ module.exports = {
620620
"面试题-Redis.md",
621621
"面试题-场景题.md"
622622
]
623+
},
624+
{
625+
title: "面试高频考点",
626+
collapsable: false,
627+
sidebarDepth: 0,
628+
children: [
629+
"gaopin/00-RocketMQ可靠性、重复消费解决方案.md",
630+
"gaopin/01-RocketMQ有序性、消息积压解决方案.md",
631+
"gaopin/02-Redis的IO多路复用.md",
632+
"gaopin/03-ZooKeeper运行原理.md"
633+
]
634+
},
635+
{
636+
title: "互联网大厂面经",
637+
collapsable: false,
638+
sidebarDepth: 0,
639+
children: [
640+
"mianjing/00-淘天提前批面试.md",
641+
]
623642
}
624643
],
625644
"/md/biz-arch/": [{
@@ -822,7 +841,7 @@ module.exports = {
822841
"JDK21新特性.md",
823842
"JDK22新特性.md",
824843
]
825-
},
844+
},
826845
],
827846
"/md/algorithm/logic/leetcode/": [{
828847
title: "大厂算法面试",
@@ -1009,9 +1028,7 @@ module.exports = {
10091028
"死磕设计模式之抽象策略模式.md",
10101029
// "Builder模式在项目设计中的应用.md",
10111030
"单例+简单工厂模式在项目设计中的应用.md",
1012-
1013-
1014-
1031+
"12306架构设计难点.md"
10151032
]
10161033
}
10171034
],
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
# 12306 架构设计难点
2+
3+
## 12306 中有哪些难点呢?
4+
5+
先从业务角度上来说的话:
6+
7+
- 对于抢票来说,瞬时抢票会导致对服务器有瞬间很大的压力,因此从业务设计上来说需要将抢票的压力给分散开,比如今天才开启抢 15 天之后的车票
8+
9+
- 对于库存来说,车票库存的设计是个难点,就比如 A -> B -> C -> D 共 4 个车站,假如乘客买了 B -> C 的车票,那么同时会影响到 A->C,A->D,B->C,B->D,涉及了多个车站的排列组合(这里计算是比较耗费性能的)
10+
11+
那么这里就涉及到了 `“读扩散”``“写扩散”` 的问题,在 12 年的时候,12306 使用的就是读扩散,也就是在扣减余票库存的时候,直接扣减对应车站,而在查询的时候,进行动态计算,而写扩散就是在写的时候,就动态计算每个车站应该扣除多少余票库存,在查询的时候直接查即可
12+
13+
12306 是读多写少的场景,海哥认为使用写扩散比较好一些,这样可以减轻查询端的压力
14+
15+
- 对于扩容来说,在节假日与非节假日 12306 的流量差别是非常大的,因此必须要有动态扩容的能力
16+
17+
18+
19+
那么在技术角度上来看,难点主要有:
20+
21+
- 首页读多写少,可以给首页部分内容做静态化处理,比如个人身份的信息、列车班次等不会变化的信息
22+
- 抢票时,是一个高并发的操作
23+
24+
25+
26+
**12306 为什么选择 Pivotal Gemfire 而不是 Redis 呢?**
27+
28+
Redis 在互联网公司中使用的是比较多的,而在银行、12306 很多实时交易的系统中,很多采用 Pivotal Gemfire 作为解决方案
29+
30+
Redis 是开源的缓存解决方案,而 Pivotal Gemfire 是商用的,我们在互联网项目中为什么使用 Redis 比较多呢,就是因为 Redis 是开源的,不要钱,开源对应的也就是稳定性不是那么的强,并且开源社区也不会给你提供解决方案,毕竟你是白嫖的,而在银行以及 12306 这些系统中,它们对可靠性要求非常的高,因此会选择商用的 Pivotal Gemfire,不仅性能强、高可用,而且 Gemfire 还会提供一系列的解决方案,据说做到了分布式系统中的 CAP(常识:分布式系统中,CAP 无法同时满足)
31+
32+
33+
34+
**12306 的性能瓶颈**
35+
36+
12306 的性能瓶颈就在于余票的查询操作上,上边已经说了,12306 是采用读扩散,也就是客户买票之后,扣减库存只扣减对应车站之间的余票库存,在读的时候,再来动态的计算每个站点应该有多少余票,因此读性能是 12306 的性能瓶颈
37+
38+
当时 12306 也尝试了许多其他的解决方案,比如 cassandra 和 mamcached,都扛不住查询的流量,而使用 Gemfire 之后扛住了流量,因此就使用了 Gemfire
39+
40+
41+
42+
**Gemfire 的亮点**
43+
44+
Gemfire 的存储和计算都在一个地方,它的存储和实时计算的性能目前还没有其他中间件可以取代
45+
46+
但是 Gemfire 也存在不足的地方,对于扩容的支持不太友好的,因为它里边有一个 Bucket 类似于 Topic 的概念,定好 Bucket 之后,扩容是比较难的,在 12306 中,也有过测试,需要几十个 T 的内存就可以将业务数据全部放到内存中来,因此直接将内存给加够,也就不需要很频繁的扩容
47+
48+
db-engines.com 这个网站可以对比主流数据库之间的差异
49+
50+
51+
52+
**每个车站余票的设计**
53+
54+
就比如, A->B->C->D 共 4 个车站,车上只有 100 个座位,给哪些区间分配多少余票呢?
55+
56+
这个是通过运营部来进行设计,首先考虑的肯定是要盈利,远途票价比较贵,因此比较倾向于远途的旅客,因此不会存在 B->C 站点比较火爆而导致 A->D 买不到票的情况
57+
58+
但是短途旅客又不能没有票,因此给每个车站都会放置一些余票
59+
60+
61+
62+
## 余票库存的表如何设计?
63+
64+
这里的设计思路都是猜测的,并不一定是 12306 真实设计方案
65+
66+
**12306 余票库存的表的设计是非常特色并且重要的**
67+
68+
首先说一下需要几个表来表示余票的库存信息:
69+
70+
1、基础的车次表:表示车次的编号以及发车时间等具体的车次信息,属于比较稳定的数据
71+
72+
2、车的座位表:表示每个座位的具体信息,包括在几车厢、几行、几列,以及 `该座位的售卖情况`
73+
74+
3、车的余票表:通过座位表可以计算出每个车位在各个车站区间还有多少余票,但是动态计算比较浪费性能,因此再添加余票表,通过定时计算余票信息放入到余票表中,提高查询的性能
75+
76+
(其实还应该有一个车厢表,不过不太重要,这里直接就省略了)
77+
78+
**这里说一下这 3 个表的对应关系:**
79+
80+
比如车次为 K123,该车上有很多的座位,每个座位对应座位表中的一条数据
81+
82+
而余票表指的是 K123 车次上,硬座、硬卧、软卧、无座各有多少张余票,余票表的信息可以由座位表来计算得到
83+
84+
**接下来说一下如何通过通过座位表来表示用户购买的车票:**
85+
86+
12306 中的车票信息其实是比较复杂的,因为各个车站之间是有依赖关系的,比如 4 个车站 A->B->C->D
87+
88+
如果乘客购买 B->C 的车票的话,不仅 B->C 的库存要减一,B->D 的库存也要减一,这是排列组合的情况,可以考虑通过二进制去简化车票的表示
89+
90+
在座位表中,我们设置一个字段 `sell varchar(50)` 表示该座位的售卖情况,如果该车次有 4 个站 A->B->C->D,那么 sell 字段的长度就为 3,sell 字段的第一位表示该座位 A->B 的票是否已经被买了,第二位表示 B->C 的票是否已经被买了...
91+
92+
如果乘客购买 B->C 的车票,则 sell 字段的值为:`010`
93+
94+
如果乘客购买 B->D 的车票,此时发现该座位在 B->C 已经被卖出去了,因此不能将该座位出售给这位乘客
95+
96+
如果乘客购买 C->D 的车票,则 sell 字段的值为:`011` ,表示 B->C,C->D 都已经有人了
97+
98+
99+
100+
**通过余票表提升查询性能**
101+
102+
这里余票表就相当于是数据库中的视图
103+
104+
如果要去查询一个车次中某一个类型的余票还有多少,还需要去对座位表进行计算,这个消耗是比较大的 ,因此通过余票表来加快对于余票的查询
105+
106+
可以定时去计算座位表中的数据,将每种类型的座位的余票给统计出来,比如:
107+
108+
```java
109+
硬卧:xx张
110+
硬座:xx张
111+
软卧:xx张
112+
...
113+
```
114+
115+
再将余票表的信息给放入到缓存中,大大提高查询的性能
116+
117+
我们在使用 12306 的时候,也会发现,有时候显示的有票,但是真正去买的时候发现已经没有余票了,这就说明 12306 没有保证实时的一致性,只要保证了最终一致性即可,也就是用户真正去买的时候,保证对于余票数量的查询是准确的就可以了
118+
119+
120+
121+
**怎么避免远程旅客买不到票的情况:**
122+
123+
这个就是处于业务方面的考虑了,比如 A->B->C->D,对于一个车次中的座位来说,如果 B->C 的乘客非常多,那么是不是就会导致 A->D 买不到票了?
124+
125+
其实不会的,我们可以在业务层面去避免这个问题,比如给每个车站区间都留有一些余票,那么就不会因为某一个区间非常火爆,而导致其他乘客买不到长途的票了
126+
127+
至于具体留多少余票,这个就不是我们考虑的事情了,营业部根据具体的实际情况以及盈利情况来定一下各个区间预留多少票
128+
129+
130+
131+
132+
133+
## 坐过高铁吧,有抢过票吗?你说说抢票会有哪些情况?
134+
135+
抢票会存在线程安全的问题,因为高铁票是作为一个共享的数据存在,多个线程去读写共享的数据,就会存现线程安全的问题
136+
137+
具体的线程不安全问题就是:高铁票的 `少卖``超卖`
138+
139+
先说一下整个抢票中所涉及的流程:生成订单、扣减库存、用户支付
140+
141+
那么为了保证高并发,扣减库存的操作可以放在本地去做,生成订单的操作通过异步,可以大幅提高系统并发度
142+
143+
**接下来先说一下如何 `优化抢票性能`**
144+
145+
将库存放在每台机器的本地,比如总共有 1w 个余票库存,共有 100 台机器,那么就在每台机器上方 100 个库存
146+
147+
当用户抢票之后,就会在本地先扣减库存,如果本地库存不足,此时可以给用户返回一个友好提示,让用户稍后再重试抢票,再将用户抢票的请求路由到其他有库存的机器上去
148+
149+
如果本地库存足够的话,就先扣除本地库存,之后再发送一个 MQ 消息异步的生成高铁票的订单,等待用户支付,如果用户十分钟内不支付的话,订单就失效,返还库存
150+
151+
152+
153+
**接下来分析一下上边的流程是否会出现少卖和超卖的问题:**
154+
155+
对于超卖来说,每次用户请求时,先扣除库存,再去生成订单,这样当库存不足时,就不会再生成订单了,因此肯定不会出现超卖的问题
156+
157+
对于少卖来说,总共有 100 台机器,每台机器有 100 个库存,如果其中的几台机器宕机了,那么宕机的机器上的库存就没办法继续售卖,就会出现少卖的问题
158+
159+
160+
161+
**解决少卖问题:**
162+
163+
可以在每台机器上放一些冗余的库存,如果其他机器发生了宕机,就将宕机的机器上的库存给放到健康的机器上去,就可以避免机器宕机而导致一部分库存卖不出去的问题了
164+
165+
那么这样的话,就需要使用 Redis 来统一管理每台机器上的库存,也就是在分布式缓存 Redis 中存储一份缓存,在每台机器的本地也存储一份缓存,当扣减完机器本地的库存之后,再去发送一个远程请求扣减 Redis 上的库存
166+
167+
168+
169+
**最后完整的抢票流程:**
170+
171+
![1706439533556](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/1706439533556.png)
172+
173+
1. 用户发出抢票请求,在本地进行扣减库存操作
174+
2. 如果本地库存不足,返回用户友好提示,可以稍后重试,如果所有机器上的库存都不足的话,可以直接返回用户已售罄的提示
175+
3. 如果本地库存充足,在本地扣减库存之后,再向 Redis 中发送网络请求,进行库存扣减(这里 Redis 的作用就是统一管理所有机器上的库存数量)
176+
4. 扣减库存之后,再发送 MQ 消息,异步的生成订单,之后等待用户支付即可
177+
178+
179+
180+
> 如有不足,欢迎指出
181+
182+
183+
184+
185+
186+
## 现在我们来给 12306 抢票系统设计一个缓存,kv 存什么?
187+
188+
在回答的时候,要先给面试官分析一下业务场景,再说怎么去设计缓存
189+
190+
在 12306 中如果要设计缓存的话,可以考虑给余票设计一个缓存,因为余票信息是读取比较多的数据,并且在首页,放在缓存中可以大大加快用户查询的速度,如下图
191+
192+
![1706439541240](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/1706439541240.png)
193+
194+
195+
196+
197+
198+
- 余票信息缓存
199+
200+
余票信息缓存的话,将车站到车站之间的信息以及余票信息给存储到缓存中,比如当用户查询 A 车站到 B 车站的车票信息时,直接从缓存中获取,如果缓存中没有的话,去数据库中查询,并且在 Redis 缓存中构建一份缓存数据
201+
202+
key 设计为站点的信息,比如查询 2023 年 12 月 15 日 A 车站到 B 车站的车票信息:`remaining_ticket_info:{year}:{month}:{day}:{起使车站}:{终止车站}`
203+
204+
value 为起使车站到终止车站的信息,比如车次号、余票信息、票价信息、经过车站等一些信息
205+
206+
这里我觉得**余票数量可以和其他缓存给分开存储**,因为像余票信息的话,用户购买后是需要修改的,如果将余票数量和其他缓存数据放在一起的话,每次修改的时候,都要重新构建很多数据,比较麻烦
207+
208+
209+
210+
- 余票数量缓存
211+
212+
余票数量缓存的 key 设计为:`remaining_ticket_num:{year}:{month}:{day}:{起使车站}:{终止车站}`
213+
214+
value :存储余票的数量
215+
216+
![1706439550809](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/1706439550809.png)
217+
218+
219+
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# 00-RocketMQ可靠性、重复消费解决方案
2+
## RocketMQ 的消息可靠性如何保证?
3+
4+
RocketMQ 作为分布式消息中间件,肯定是要尽可能保证消息传输的 **可靠性** ,要保证消息的可靠性,先来思考一下从哪些方面保证呢?
5+
6+
这要看消息的生命周期,既然保证可靠性,那么就是要保证 A 发送给 B 的消息一定可以成功,那么首先要保证发送成功,其次要保证 B 接收成功,而在 RocketMQ 中,消息是先发送到 Broker 中了,那么还需要保证 MQ 在 Broker 中不会丢失
7+
8+
因此 RocketMQ 是从三方面保障了消息的可靠性:
9+
10+
- 保证 **生产者发送消息** 的可靠性
11+
- 保证 **Broker 存储消息** 的可靠性
12+
- 保证 **消费者消费消息** 的可靠性
13+
14+
15+
16+
## 发送消息的可靠性
17+
18+
RocketMQ 在发送端保证发送消息的可靠性主要就是通过 **重试机制** 来实现的
19+
20+
生产者发送消息分为了 **同步发送****异步发送****单向发送** 三种方式:
21+
22+
- **同步发送** :发送消息后,阻塞线程等待消息发送结果
23+
- **异步发送** :发送消息后,并不会阻塞等待,回调任务会在另一个线程中执行
24+
- **单向发送** :发送消息后,立即返回,不返回消息发送是否成功,因此不可以保证发送消息的可靠性
25+
26+
27+
28+
只有单向发送没有消息可靠性的保证,在 **同步****异步** 发送中,都可以通过设置发送消息的 **重试次数** 来保证发送端的可靠性,默认重试次数为 2 次
29+
30+
并且还可以设置如果发送失败,尝试发送到其他 Broker 节点
31+
32+
```java
33+
// 同步设置重试次数
34+
producer.setRetryTimesWhenSendFailed(3)
35+
// 异步设置重试次数
36+
producer.setRetryTimesWhenSendAsyncFailed(3);
37+
// 如果发送失败,是否尝试发送到其他 Broker 节点
38+
producer.setRetryAnotherBrokerWhenNotStoreOK(true);
39+
```
40+
41+
42+
43+
44+
45+
## 存储消息的可靠性
46+
47+
**可靠性保证一:消息落盘存储保证消息的可靠性**
48+
49+
在消息发送到 Broker 之后,Broker 会将消息存储在磁盘中,这样如果 Broker 异常宕机之后,可以读取磁盘中的数据来保证消息的 **可靠性**
50+
51+
52+
53+
**RocketMQ 如何存储消息:**
54+
55+
RocketMQ 会先将消息写入到操作系统的 page cache 中,之后消息刷入磁盘分为了 **同步刷盘****异步刷盘** 两种方式, **默认是异步刷盘方式**
56+
57+
page cache 就是将文件映射到内存中,这样直接操作内存比较快,避免了频繁的磁盘 IO
58+
59+
Broker 通过 **page cache****异步刷盘** 在保证消息可靠性的前提下,还尽可能提升了消息写入的性能
60+
61+
62+
63+
**在 Broker 端写入消息的流程如下:**
64+
65+
![image-20240325151146744](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240325151146744.png)
66+
67+
可以看到,这里写消息先写在了 jvm 的 **堆外内存** 中,而不是直接写在了 page cache 中,这是 RocketMQ 提供的 **transientStorePoolEnabled(瞬时存储池启用)机制** 来实现内存级别的读写分离
68+
69+
为什么要将消息先写在 **堆外内存** 呢?如果高并发的读写请求都直接落在 page cache 中的话,那么会导致对 page cache 的竞争太过于激烈,因此令写请求操作 **堆外内存** ,读请求操作 **page cache** ,实现 **读写分离** ,避免高并发情况下对 page cache 的激烈竞争
70+
71+
72+
73+
74+
75+
**可靠性保证二:主从复制保证 Broker 的消息可靠性**
76+
77+
上边是通过将消息写入磁盘来保证 Broker 存储端的消息可靠性,还有另一种方式:通过 **主从复制** 来保证消息的可靠性
78+
79+
在 Broker 主从复制时,会将 master 节点的消息同步到 slave 节点,slave 节点作为 master 节点的 **热备份** 存在,保证消息的可靠性
80+
81+
82+
83+
## 消费消息的可靠性
84+
85+
消费者为了保证消息的可靠性: **会先消费消息,再提交消息消费成功的状态** ,不过可能会出现 **重复消费** 的情况,因此需要业务方保证 **幂等性** 来解决重复消费的问题(可以建立一张消息消费表来避免重复消费)
86+
87+
**可靠性保证一:消息重试保证可靠性**
88+
89+
消费者只有返回 **CONSUME_SUCCESS** 才算消费完成,如果返回 **CONSUME_LATER** 则会按照不同的延迟时间再次消费,如果消费满 16 次之后还是未能消费成功,则会将消息发送到死信队列
90+
91+
92+
93+
**可靠性保证二:死信队列保证可靠性**
94+
95+
如果消息最终重试消费失败,并不会立即丢弃,而是将消息放入到了死信队列,之后还可以通过 MQ 提供的接口获取对应的消息, **保证消费消息的可靠性**
96+
97+
98+
99+
100+
101+
## RocketMQ 如何保证消费不被重复消费?
102+
103+
RocketMQ 可能会出现消息重复消费的情况:
104+
105+
- 生产者可能会重复生产消息
106+
- 消费者也可能会重复消费消息
107+
108+
**重复消费** 就会带来问题,因此我们需要在业务设计时保证消费的 **幂等性** ,避免多次消费
109+
110+
111+
112+
**通过防重表来保证消费逻辑的幂等性:**
113+
114+
保证幂等性的话,可以建立一个 **消费记录表** ,在准备对消息进行消费时,将消息的唯一标识(比如传输订单信息,将订单的多个字段拼接成唯一标识,确定不重复即可)入到消费记录表中,并且对这个唯一标识建立 **唯一索引** ,通过唯一索引避免消息的重复消费
115+
116+
117+
118+
**消息消费的流程如下:**
119+
120+
1、开始消费消息
121+
122+
2、将消息的唯一标识插入到 **消费记录表**
123+
124+
3、如果插入成功,表明没有重复消费,可以执行消费逻辑
125+
126+
4、如果插入失败,捕捉唯一索引冲突异常,唯一索引冲突说明发生了重复消费,可以将该冲突异常 **(DuplicateKeyException)** 捕捉到,直接返回消费成功的提示
127+
128+
129+

0 commit comments

Comments
 (0)