mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-10-31 11:05:36 +00:00
Update 系统设计基础.md
为面试中常问的小节加上高频标记;补充接口防刷、系统设计题的思路(以点赞系统为例)和即时通讯(长连接、可靠投递、群聊、心跳与重连),附实测 A20~A22。
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
+412
-10
@@ -4,15 +4,17 @@
|
||||
* [一、性能](#一性能)
|
||||
* [二、伸缩性](#二伸缩性)
|
||||
* [三、扩展性](#三扩展性)
|
||||
* [四、可用性](#四可用性)
|
||||
* [四、可用性](#四可用性) 🔥🔥
|
||||
* [五、安全性](#五安全性)
|
||||
* [六、秒杀系统](#六秒杀系统)
|
||||
* [六、秒杀系统](#六秒杀系统) 🔥🔥
|
||||
* [七、海量数据处理](#七海量数据处理)
|
||||
* [八、短链系统](#八短链系统)
|
||||
* [九、Feed 流](#九feed-流)
|
||||
* [十、高并发系统的设计](#十高并发系统的设计)
|
||||
* [九、Feed 流](#九feed-流) 🔥
|
||||
* [十、高并发系统的设计](#十高并发系统的设计) 🔥🔥
|
||||
* [十一、大文件上传](#十一大文件上传)
|
||||
* [十二、权限系统](#十二权限系统)
|
||||
* [十三、系统设计题的思路](#十三系统设计题的思路)
|
||||
* [十四、即时通讯](#十四即时通讯)
|
||||
* [附录:实测](#附录实测)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
@@ -26,13 +28,13 @@
|
||||
|
||||
指某个请求从发出到接收到响应消耗的时间。
|
||||
|
||||
在对响应时间进行测试时,通常采用重复请求的方式,然后计算平均响应时间。平均值会掩盖少数很慢的请求,因此也常用 P99 等分位值来衡量。
|
||||
在对响应时间进行测试时,通常采用重复请求的方式,然后计算平均响应时间。平均值会掩盖少数很慢的请求,因此也常用 P99 等分位值来衡量。P99(也写作 TP99)指 99% 的请求的响应时间都不超过的值。
|
||||
|
||||
一个用户请求通常需要调用多个后端服务,只要其中一个慢,整个请求就慢,后端调用越多,受慢请求影响的用户比例越高,这称为尾部延迟放大。
|
||||
|
||||
#### 2. 吞吐量
|
||||
|
||||
指系统在单位时间内可以处理的请求数量,通常使用每秒的请求数(QPS)来衡量。
|
||||
指系统在单位时间内可以处理的请求数量,通常使用每秒的请求数(QPS)来衡量,处理的是事务时也用每秒事务数(TPS)。
|
||||
|
||||
#### 3. 并发用户数
|
||||
|
||||
@@ -95,7 +97,7 @@
|
||||
- 使用消息队列进行解耦,应用之间通过消息传递进行通信;
|
||||
- 使用分布式服务将业务和可复用的服务分离开来,业务使用分布式服务框架调用可复用的服务。新增的产品可以通过调用可复用的服务来实现业务逻辑,对其它产品没有影响。
|
||||
|
||||
## 四、可用性
|
||||
## 四、可用性 🔥🔥
|
||||
|
||||
可用性用系统可用时间占总时间的比值来衡量,4 个 9 表示系统 99.99% 的时间是可用的,一年的不可用时间不超过 53 分钟。
|
||||
|
||||
@@ -252,7 +254,64 @@ Sentinel 的半开状态只放行一个请求,这个请求成功就结束熔
|
||||
|
||||
要求系统在应对各种攻击手段时能够有可靠的应对措施,常见的攻击手段和防范方法见 [攻击技术](攻击技术.md)。
|
||||
|
||||
## 六、秒杀系统
|
||||
### 接口防刷
|
||||
|
||||
接口防刷指的是拦截格式合法、但由脚本发出的大量请求,例如脚本抢购、批量注册、撞库登录,或者利用发送短信验证码的接口给别人的手机号连续发短信。这些请求单独看都是正常的,只能按来源和频率识别。
|
||||
|
||||
以发送短信验证码的接口为例,每条短信都要付费,被刷时既浪费钱,又骚扰收到短信的人。常用的做法是按多个维度限制频率:
|
||||
|
||||
- 同一个手机号 60 秒内只能发一次,每天最多 10 次;
|
||||
- 同一个 IP 每小时最多 20 次,防止一台机器换着手机号刷;
|
||||
- 超过阈值的设备或账号,先要求完成图形或滑块验证码。
|
||||
|
||||
判断和计数要原子地完成,否则并发的请求会同时通过检查,所以用一个 Lua 脚本实现:
|
||||
|
||||
```lua
|
||||
-- KEYS[1]:手机号的冷却键,KEYS[2]:手机号当天的计数,KEYS[3]:IP 这一小时的计数
|
||||
-- ARGV[1]:冷却秒数,ARGV[2]:手机号每天的上限,ARGV[3]:IP 每小时的上限
|
||||
if redis.call('EXISTS', KEYS[1]) == 1 then
|
||||
return 1 -- 60 秒内已经发过
|
||||
end
|
||||
if tonumber(redis.call('GET', KEYS[2]) or '0') >= tonumber(ARGV[2]) then
|
||||
return 2 -- 这个手机号今天的次数用完
|
||||
end
|
||||
if tonumber(redis.call('GET', KEYS[3]) or '0') >= tonumber(ARGV[3]) then
|
||||
return 3 -- 这个 IP 请求太多
|
||||
end
|
||||
redis.call('SET', KEYS[1], 1, 'EX', ARGV[1])
|
||||
if redis.call('INCR', KEYS[2]) == 1 then redis.call('EXPIRE', KEYS[2], 86400) end
|
||||
if redis.call('INCR', KEYS[3]) == 1 then redis.call('EXPIRE', KEYS[3], 3600) end
|
||||
return 0 -- 可以发送
|
||||
```
|
||||
|
||||
同一个手机号 1 秒内请求 5 次,只有第 1 次发送了短信;把冷却时间改为 1 秒后连续请求 12 次,第 11、12 次因为当天的次数用完被拒绝;同一个 IP 给 30 个手机号发验证码,从第 21 个开始被拒绝(Redis 7.4 实测,见[附录 A20](#a20-接口防刷))。按小时、按天计数是固定窗口,窗口边界附近可能短时间内放过两倍的请求,需要更精确时改用滑动窗口,见 [限流算法](#1-限流算法)。
|
||||
|
||||
验证码本身也要防止被猜中。6 位数字只有 100 万种,不限制输错次数时,在本机直接调用校验脚本逐个尝试,第 835770 次猜中,只用了 8 秒(见附录 A20);通过网络调用接口会慢一些,但按每秒 1000 次计算,平均约 8 分钟也能猜中。所以校验时:
|
||||
|
||||
- 验证码保存在 Redis 中,5 分钟后过期,多个服务实例都能读到;
|
||||
- 输对后立即删除,一个验证码只能用一次;
|
||||
- 同一个验证码输错 5 次就作废,要重新发送。
|
||||
|
||||
加上发送的限制,一个手机号每天最多能猜 50 次,猜中的概率只有 50 / 10<sup>6</sup> = 0.005%。
|
||||
|
||||
开放给合作方的接口还要防止请求被篡改或者重放。重放指的是把截获的合法请求原样再发一次,例如把一次退款请求重复提交。做法是双方事先约定一个密钥,调用方把请求的关键字段和时间戳、随机数(nonce)拼在一起,用密钥计算 HMAC 签名:
|
||||
|
||||
```text
|
||||
签名原文:POST\n/api/refund\n1793000000\nn-8f3a2c\n{"order": 1024, "amount": 3900}
|
||||
签名:HMAC-SHA256(密钥, 签名原文)
|
||||
```
|
||||
|
||||
服务端依次检查:
|
||||
|
||||
- 时间戳与当前时间相差不超过 5 分钟,很久以前的请求直接拒绝;
|
||||
- 用同样的密钥重新计算签名并比较,请求中任何一个字段被改过都对不上;
|
||||
- 执行 SET nonce:n-8f3a2c 1 NX EX 600 记录随机数,失败说明这个请求已经处理过。随机数保存的时间要覆盖时间戳允许的范围,否则过期后又能重放。
|
||||
|
||||
原样重放的请求因为随机数已经用过被拒绝,改掉金额的请求签名对不上,10 分钟后再重放则时间戳超出了范围(见附录 A20)。需要注意的是,签名只适合密钥保存在服务端的调用,例如开放平台和支付回调;写在 App 或网页中的密钥可以被逆向取出,只能增加刷接口的成本。
|
||||
|
||||
识别出的刷接口来源,还可以在网关中加入黑名单,例如 SET ban:ip:203.0.113.7 1 EX 3600,一小时内直接拒绝它的请求。规模更大的系统会接入风控,根据设备指纹、操作轨迹等判断请求是否来自机器,可疑时要求验证码。图形和滑块验证码能被打码平台破解,同样只能提高成本,所以要和限频、黑名单一起使用。
|
||||
|
||||
## 六、秒杀系统 🔥🔥
|
||||
|
||||
秒杀指的是少量商品在某一时刻开售,大量用户同时抢购,例如 100 件商品在开售瞬间收到 10 万个请求。难点在于:
|
||||
|
||||
@@ -529,7 +588,7 @@ CREATE TABLE short_url (
|
||||
|
||||
短链读多写少,热门链接的访问又很集中,适合用 Redis 缓存短码到长链接的映射。不存在的短码会穿透到数据库,可以缓存空值或者使用布隆过滤器,见 [缓存穿透](缓存.md#缓存穿透)。点击统计不要在跳转时同步写数据库,而是把点击事件写入消息队列,异步汇总。
|
||||
|
||||
## 九、Feed 流
|
||||
## 九、Feed 流 🔥
|
||||
|
||||
Feed 流指的是把关注的人发布的内容按时间倒序合并后展示,例如朋友圈和微博的首页。每个用户有一个发件箱,保存自己发的帖子;难点在于怎样把几百个人的发件箱合成一个首页。
|
||||
|
||||
@@ -562,7 +621,7 @@ p20 p19 p18 ... p11
|
||||
|
||||
左括号表示不包含 21 本身,这是 Redis 6.2 起的写法,相当于旧的 ZREVRANGEBYSCORE。分值相同的帖子可能被跳过,可以把分值设为帖子 ID 这类唯一且递增的值,例如雪花算法生成的 ID 本身就按时间递增,见 [分布式 ID](分布式.md#八分布式-id)。
|
||||
|
||||
## 十、高并发系统的设计
|
||||
## 十、高并发系统的设计 🔥🔥
|
||||
|
||||
高并发指的是系统在某段时间内要同时处理大量请求。设计时通常先估算峰值 QPS,再让绝大部分请求在到达数据库之前被挡住,最后用异步、限流和降级保证剩下的请求不会压垮系统。
|
||||
|
||||
@@ -967,6 +1026,206 @@ RBAC 还可以扩展两类约束。角色继承(role hierarchy)指高级角
|
||||
|
||||
Agent 调用工具时,模型只输出调用意图,是否真正执行由权限检查决定。最小权限的做法是:为每个 Agent 只配置完成任务所需的工具和数据范围,读操作与写操作分开授权,高风险操作需要人工审批,见 [大模型应用](大模型应用.md#安全)。
|
||||
|
||||
## 十三、系统设计题的思路
|
||||
|
||||
系统设计题通常只给一句话,例如“设计一个点赞系统”,没有标准答案。面试官看的是能否把模糊的需求变成具体的方案,以及能否说清每个选择的代价。可以按以下顺序展开:
|
||||
|
||||
1. 明确需求:要支持哪些功能、规模多大、哪些数据不能出错;
|
||||
2. 估算规模:算出读写的峰值和存储量,判断需要什么量级的方案;
|
||||
3. 定义接口和数据模型;
|
||||
4. 先给出一个能工作的简单方案;
|
||||
5. 按估算找出瓶颈,逐个改进;
|
||||
6. 说明一致性、故障时的表现和取舍。
|
||||
|
||||
以点赞系统为例走一遍。
|
||||
|
||||
### 明确需求
|
||||
|
||||
功能:用户可以给视频点赞和取消,视频下方显示点赞数和自己是否点过赞,“我赞过的视频”按时间倒序列出。
|
||||
|
||||
非功能需求决定了后面的方案,要和面试官确认:
|
||||
|
||||
- 同一个用户对同一个视频只能计一次;
|
||||
- 点赞数允许延迟几秒,但用户自己点过赞,刷新页面后必须显示已点赞;
|
||||
- 热门视频可能在短时间内收到大量点赞。
|
||||
|
||||
直播间的点赞是另一种需求:只累计次数,不记录谁点过,同一个人可以连续点很多下,见下文。
|
||||
|
||||
### 估算规模
|
||||
|
||||
假设日活用户 2000 万,每人每天看 100 个视频、点赞 5 次,晚高峰一小时占全天流量的 6%(与 [容量估算](#容量估算) 相同):
|
||||
|
||||
- 写:每天 1 亿次点赞,峰值约 1 亿 × 6% ÷ 3600 ≈ 1700 次/秒;
|
||||
- 读:每看一个视频都要读点赞数和“是否点过”,每天 20 亿次,峰值约 3.3 万次/秒;
|
||||
- 存储:点赞关系表每行约 112 字节(含二级索引,MySQL 8.0 实测,见[附录 A21](#a21-点赞系统)),每年约 365 亿行、4 TB。
|
||||
|
||||
由此可以得出:读远多于写,适合加缓存;每秒 1700 次写入单库能承受,但几年的数据量单库放不下,要分库分表。
|
||||
|
||||
### 接口与数据模型
|
||||
|
||||
```text
|
||||
POST /videos/{id}/like 点赞,重复调用只计一次
|
||||
DELETE /videos/{id}/like 取消点赞
|
||||
GET /videos/like-status?ids=1024,1025 一页视频的点赞数和是否点过
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE video_like (
|
||||
user_id BIGINT NOT NULL,
|
||||
video_id BIGINT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, video_id), -- 同一个用户对同一个视频只能有一行
|
||||
KEY idx_user_time (user_id, created_at) -- 我赞过的视频
|
||||
);
|
||||
|
||||
CREATE TABLE video_stat (
|
||||
video_id BIGINT PRIMARY KEY,
|
||||
like_count BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
```
|
||||
|
||||
点赞关系以 (user_id, video_id) 为主键,重复点赞时插入失败(ERROR 1062 Duplicate entry),接口自然是幂等的;查询一页视频是否点过,只需要一条按 user_id 的查询。点赞数单独保存,显示时不必 COUNT(*)。
|
||||
|
||||
### 简单方案
|
||||
|
||||
单库时在一个事务中完成:
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
INSERT INTO video_like (user_id, video_id) VALUES (1001, 1024); -- 重复点赞时报错 1062,回滚
|
||||
UPDATE video_stat SET like_count = like_count + 1 WHERE video_id = 1024;
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
两张表在同一个事务中修改,点赞关系和点赞数不会不一致,规模不大时这样就够了。面试中先给出这个方案,再说明它在估算的规模下哪里会出问题。
|
||||
|
||||
### 找出瓶颈并改进
|
||||
|
||||
#### 1. 热门视频的计数
|
||||
|
||||
点赞同一个视频的请求都要更新 video_stat 中的同一行,行锁让它们只能一个接一个地执行。对同一行执行 UPDATE ... + 1,每秒只有 5000~7000 次(见 [Redis 的计数器](Redis.md#计数器)),一个视频同时被几十万人点赞时,请求会排队直至超时。
|
||||
|
||||
改进的办法是把计数移出点赞的事务:
|
||||
|
||||
- 点赞数放在 Redis 中用 INCR 增加,单个键每秒能处理约 10 万次;单个键仍然太热时,拆成多个键分别累加,读取时求和;
|
||||
- 数据库中的点赞数改为异步合并写入,例如每秒把视频 1024 这一秒的增量合并成一条 UPDATE ... + 37。
|
||||
|
||||
这样点赞关系和点赞数不在一个事务中,可能出现关系写入成功、点赞数没有加上的情况。以点赞关系为准,点赞数是由它派生出来的:更新失败的计数通过消息重试,再定期对热门视频重新统计 COUNT(*) 校正。
|
||||
|
||||
#### 2. 读的压力
|
||||
|
||||
点赞数直接从 Redis 中读。“是否点过”按用户查询,一页 10 个视频用一条按 user_id 的查询就能查完,再缓存用户最近点过赞的视频,大部分请求不需要访问数据库。
|
||||
|
||||
用 Redis 的 SET 保存每个视频的点赞用户看起来更直接,但热门视频的集合很大:100 万个用户 ID 约占 30 MB(Redis 7.4 实测,见附录 A21),1000 万个就是约 300 MB 的大 Key,删除和迁移时都会阻塞 Redis,见 [大 Key 与热 Key](缓存.md#大-key-与热-key)。所以“谁赞过什么”按用户保存,而不是按视频保存。
|
||||
|
||||
#### 3. 数据量
|
||||
|
||||
一年约 4 TB,需要分库分表,见 [水平切分](MySQL.md#水平切分)。分片键选 user_id:“是否点过”和“我赞过的视频”都按用户查询,按 user_id 分片后每次只访问一个分片;按 video_id 分片的话,这两类查询都要访问所有分片。需要按视频列出点赞的用户时(例如“谁赞了我的视频”),另外异步写一份按 video_id 分片的副本。
|
||||
|
||||
#### 4. 直播间的点赞
|
||||
|
||||
直播间的点赞只累计次数。观众连续点了 10 下,客户端不必发 10 个请求,而是攒 1 秒后发送一次“+10”;服务端再把同一个直播间的增量在内存中累加,每秒用 INCRBY 写入 Redis 一次。几万人同时点赞时,这个键每秒只被每个服务实例更新一次,观众看到的数字比实际晚 1~2 秒,这在直播间中可以接受。
|
||||
|
||||
### 说明取舍
|
||||
|
||||
最后说明方案的取舍和故障时的表现:
|
||||
|
||||
- 点赞关系是准确的,点赞数是最终一致的,可能延迟几秒;
|
||||
- 点赞关系同步写入数据库,并更新这个用户的缓存,所以用户刷新页面后能看到自己的点赞;
|
||||
- Redis 宕机时,点赞数改为从数据库的 video_stat 中读取,没有写回的增量通过与点赞关系对账补回。
|
||||
|
||||
前面的秒杀、短链和 Feed 流也可以按这个顺序复述:先问清需求,算出量级,再从简单方案出发逐步改进。
|
||||
|
||||
## 十四、即时通讯
|
||||
|
||||
即时通讯(Instant Messaging, IM)指的是聊天这类实时收发消息的系统,例如用户 1001 问用户 1002“订单 1024 到了吗”,1002 的手机要马上收到。它要解决三个问题:服务器要能主动把消息推给客户端;消息不能丢、不能重复,顺序不能乱;大量用户同时在线,每个用户都要保持一条连接。
|
||||
|
||||
### 长连接与整体结构
|
||||
|
||||
HTTP 只能由客户端发起请求,服务器要主动推送,就要让客户端和服务器之间保持一条长连接:网页中通常用 [WebSocket](HTTP.md#websocket),App 中也可以在 TCP 上使用自己定义的协议。App 退到后台后,连接可能被系统断开,这时通过手机厂商的推送通道(例如苹果的 APNs)发送通知。
|
||||
|
||||
有了长连接,服务器就要记住每个用户连在哪台机器上。通常把维护连接和处理业务分成两层:
|
||||
|
||||
<div align="center"> <img src="pics/im-arch.png" width="780px"/> </div><br>
|
||||
|
||||
- 连接网关:维护长连接、收发心跳,不包含业务逻辑。用户连上网关时,把“用户 → 网关”写入路由表,例如 route:1002 → gw-3;
|
||||
- 消息服务:无状态,负责鉴权、存储和投递消息。投递给 1002 时,先查路由表找到 gw-3,再把消息交给 gw-3,由它写到 1002 的连接上。
|
||||
|
||||
分开的原因是长连接有状态:重启网关会断开上面所有用户的连接,这些用户会同时重连。经常改动的业务代码放在消息服务中,发布时不会断开用户的连接,网关则很少需要发布。网关扩容后,新的连接写入新网关的地址,消息服务按路由表投递,不需要知道有多少个网关。
|
||||
|
||||
### 消息的可靠投递
|
||||
|
||||
TCP 只保证一条连接内的数据不丢、有序,但连接会断开、服务会重启,消息可能在任何一个环节丢失或者重复,所以应用层要自己做确认、去重和排序:
|
||||
|
||||
<div align="center"> <img src="pics/im-message.png" width="640px"/> </div><br>
|
||||
|
||||
1. 发送方为每条消息生成唯一的 msg_id,例如 c-77。消息服务存储成功后回复确认(ACK),发送方超时没有收到 ACK,就用同一个 msg_id 重发,消息服务按 msg_id 去重,见 [幂等](分布式.md#九幂等);
|
||||
2. 消息服务为每个会话分配递增的序号 seq,例如这条是 1001 和 1002 的会话中的第 58 条。接收方按 seq 排序显示,而不是按发送时间,因为各台机器的时钟并不一致;
|
||||
3. 推送给 1002 后,1002 回复已经收到的 seq;
|
||||
4. 1002 断线期间的第 59、60 条推送失败,但消息已经存储。1002 重连后带上自己收到的最大 seq 58,拉取之后的消息。收到第 61 条而本地只有第 58 条时,同样说明中间缺了消息,要按 seq 补拉。
|
||||
|
||||
所以推送只负责及时通知,消息是否收全以 seq 为准,离线消息就是用户上线后按 seq 拉取到的这部分消息。seq 只需要在会话内递增,可以用 Redis 的 INCR 为每个会话生成。
|
||||
|
||||
消息按会话保存,单聊和群聊都是一个会话:
|
||||
|
||||
```sql
|
||||
CREATE TABLE message (
|
||||
conv_id BIGINT NOT NULL, -- 会话 ID
|
||||
seq BIGINT NOT NULL, -- 会话内递增的序号
|
||||
sender_id BIGINT NOT NULL,
|
||||
msg_id VARCHAR(32) NOT NULL, -- 发送方生成,用于去重
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (conv_id, seq),
|
||||
UNIQUE KEY uk_sender_msg (sender_id, msg_id)
|
||||
);
|
||||
|
||||
CREATE TABLE conv_member (
|
||||
conv_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
read_seq BIGINT NOT NULL DEFAULT 0, -- 读到了第几条
|
||||
PRIMARY KEY (conv_id, user_id),
|
||||
KEY idx_user (user_id)
|
||||
);
|
||||
```
|
||||
|
||||
拉取第 58 条之后的消息是一次主键上的范围查询:SELECT ... FROM message WHERE conv_id = ? AND seq > 58 ORDER BY seq LIMIT 100。重发的消息因为 (sender_id, msg_id) 已经存在而插入失败。
|
||||
|
||||
### 群聊
|
||||
|
||||
群聊消息的投递和 [Feed 流](#九feed-流) 一样有两种方式:
|
||||
|
||||
- 写扩散:为每个成员保存一份,500 人的群,一条消息要写 500 次,读取时每个人只读自己的消息列表;
|
||||
- 读扩散:群消息只保存一份,按群分配 seq,每个成员只记录自己读到的位置,即上面的 read_seq。
|
||||
|
||||
大群通常用读扩散。例如群 88 的成员 1002 读到第 1 条后下线,群里又发了 9 条消息,最大的 seq 变为 10。1002 上线时,未读数就是 10 − 1 = 9,再按 seq > 1 拉取这 9 条,不需要逐条记录谁读了哪条消息(MySQL 8.0 实测,见[附录 A22](#a22-长连接与心跳))。推送时也只推给在线的成员,并且可以只推送“群 88 有新消息”的通知,由客户端自己拉取。
|
||||
|
||||
### 心跳与重连
|
||||
|
||||
连接断开时两端不一定能收到通知,例如手机切换网络或者进入电梯,中间的 NAT 和代理也可能删除长时间没有数据的连接。TCP 自带的保活机制(keepalive)默认空闲 2 小时后才开始探测,每 75 秒探测一次、共 9 次,要 2 小时 11 分钟才能发现断线(Ubuntu 24.04 的默认值,见附录 A22);而且它由内核回复,只能说明对方的系统还在,不能说明对方的程序还能处理消息。所以应用层要自己发心跳:
|
||||
|
||||
- 客户端每隔一段时间(例如 30 秒)发送一个心跳包,服务端回复;
|
||||
- 服务端记录每个连接最后一次收到数据的时间,超过 3 个心跳周期(例如 90 秒)没有收到就关闭连接,并删除路由表中的记录;
|
||||
- 客户端连续几次收不到回复,就认为连接已经断开,主动重连。
|
||||
|
||||
10 万个连接每 30 秒发一次心跳,服务端每秒要处理约 3300 个心跳包,单线程的 NIO 服务约占用一个 CPU 核的 11%;其中 1000 个连接不发心跳,超过 90 秒后被服务端全部关闭(JDK 21 实测,见附录 A22)。心跳间隔是一种取舍:间隔越短,越早发现断线,但手机越耗电,服务端负担也越重。
|
||||
|
||||
重连时要避免所有客户端同时重连。一个网关重启时,上面的几万个用户会在同一时刻重连,可能压垮其它网关。所以重连的间隔按指数增长(1 秒、2 秒、4 秒……),再加上随机的抖动,把重连分散开。重连成功后,先按 seq 拉取断线期间的消息。
|
||||
|
||||
### 单机能支撑多少连接
|
||||
|
||||
一个常见的误解是一台服务器最多只能有 65535 个连接。TCP 连接由四元组(源 IP、源端口、目的 IP、目的端口)区分,服务端的所有连接共用一个监听端口,只要客户端的地址或者端口不同,就是不同的连接。
|
||||
|
||||
端口数的限制在发起连接的一方:从同一个源 IP 连接同一个目的地址和端口,可用的源端口只有 ip_local_port_range 规定的范围,Ubuntu 默认是 32768~60999,实测第 28233 个连接失败,报错 Cannot assign requested address(见附录 A22)。网关连接后端服务、压测的客户端都可能遇到这个限制。
|
||||
|
||||
服务端的限制主要是:
|
||||
|
||||
- 文件描述符:每个连接占用一个,要调大 ulimit -n,见 [epoll](Socket.md#epoll);
|
||||
- 内存:10 万个空闲连接,Java 进程的内存增加约 120 MB,内核中每个套接字约 4 KB,合计每个连接约 5 KB,100 万个连接约需 5 GB;
|
||||
- CPU 和带宽:取决于消息量,空闲的连接只需要处理心跳。
|
||||
|
||||
管理这么多连接要用 epoll 这类 [I/O 复用](Socket.md#二io-复用):实测中一个线程就管理了全部 10 万个连接,每个连接一个线程的话需要 10 万个线程。
|
||||
|
||||
## 附录:实测
|
||||
|
||||
环境为 Apple M1 Pro、macOS,Redis 7.4.1 使用默认配置,客户端为 redis-py。
|
||||
@@ -1825,6 +2084,149 @@ def conditional(uid): # 条件更新
|
||||
条件更新 stock > 0 请求 150 成功 100 售罄 50 冲突 0 剩余库存 0 订单 100 66 ms
|
||||
```
|
||||
|
||||
### A20 接口防刷
|
||||
|
||||
Redis 7.4.1,Apple M1 Pro,macOS,默认配置。发送限制的脚本与正文相同,校验的脚本如下,客户端用 redis-py 调用:
|
||||
|
||||
```lua
|
||||
-- KEYS[1]:验证码,KEYS[2]:输错的次数;ARGV[1]:用户输入,ARGV[2]:最多输错几次
|
||||
local code = redis.call('GET', KEYS[1])
|
||||
if not code then
|
||||
return -1 -- 过期或已经作废
|
||||
end
|
||||
if code == ARGV[1] then
|
||||
redis.call('DEL', KEYS[1], KEYS[2]) -- 验证通过,验证码只能用一次
|
||||
return 1
|
||||
end
|
||||
local n = redis.call('INCR', KEYS[2])
|
||||
if n == 1 then redis.call('EXPIRE', KEYS[2], 300) end
|
||||
if n >= tonumber(ARGV[2]) then
|
||||
redis.call('DEL', KEYS[1], KEYS[2]) -- 输错太多次,作废
|
||||
return -2
|
||||
end
|
||||
return 0 -- 输错,还可以再试
|
||||
```
|
||||
|
||||
实验 B 为了不等太久,把冷却时间改为 1 秒;实验 D 先用限制 5 次的脚本猜 10 次,再把上限改为 10<sup>9</sup>,用流水线每批 1000 次从 000000 开始穷举。签名用 HMAC-SHA256,服务端依次检查时间戳(前后 5 分钟)、签名和 nonce(SET NX EX 600):
|
||||
|
||||
```text
|
||||
== A 同一个手机号 1 秒内请求 5 次
|
||||
['发送', '冷却中', '冷却中', '冷却中', '冷却中']
|
||||
sms:cd 的剩余秒数 60
|
||||
== B 冷却改为 1 秒,同一个手机号每隔 1.1 秒请求一次,共 12 次(每天上限 10 次)
|
||||
['发送', '发送', '发送', '发送', '发送', '发送', '发送', '发送', '发送', '发送', '今天次数已用完', '今天次数已用完']
|
||||
== C 同一个 IP 依次给 30 个不同的手机号发验证码(每小时上限 20 次)
|
||||
发送 20 次;被拒绝 10 次,从第 21 个开始
|
||||
== D 穷举验证码
|
||||
真实验证码 835769
|
||||
限制输错 5 次:前 10 次猜测的返回值 [0, 0, 0, 0, -2, -1, -1, -1, -1, -1] (0 输错,-2 作废,-1 已不存在)
|
||||
不限制输错次数:第 835770 次猜中 835769,用时 8.1 秒(本机,流水线每批 1000 次)
|
||||
限制后的上限:每个验证码 5 次 × 每天 10 个验证码 = 50 次,每天猜中的概率 50 / 10^6 = 0.005%
|
||||
== E 签名与防重放
|
||||
签名原文: 'POST\n/api/refund\n1793000000\nn-8f3a2c\n{"order": 1024, "amount": 3900}'
|
||||
签名 2f908ffdaf053064...
|
||||
第一次请求: 通过
|
||||
原样重放: 拒绝:nonce 已经用过
|
||||
改掉金额再发: 拒绝:签名不对
|
||||
10 分钟后重放: 拒绝:时间戳超过 5 分钟
|
||||
```
|
||||
|
||||
### A21 点赞系统
|
||||
|
||||
MySQL 8.0.44,Apple M1 Pro,默认配置。video_like 表与正文相同,10 万个用户各点赞 10 个随机的视频,按随机顺序分批插入,再用 ANALYZE TABLE 更新统计信息:
|
||||
|
||||
```text
|
||||
插入 999997 行,用时 52.9 秒
|
||||
COUNT(*) = 999997,data_length = 68.6 MB,index_length = 38.6 MB,每行合计 112 字节
|
||||
表空间文件 116.0 MB,每行 122 字节
|
||||
重复点赞: (1062, "Duplicate entry '10064451-51075055' for key 'video_like.PRIMARY'")
|
||||
INSERT IGNORE 重复点赞,影响行数 0
|
||||
一次查询 5 个视频是否点过: (None, 'ref', 'PRIMARY,idx_user_time', 'idx_user_time', '8', 'const', 10)
|
||||
```
|
||||
|
||||
最后一行是 EXPLAIN 的 type、possible_keys、key、key_len、ref、rows 列:查一个用户对 5 个视频是否点过赞,用 user_id 等值查询,扫描 10 行。
|
||||
|
||||
Redis 7.4.1,同一台机器。向一个 SET 中加入 n 个 8 位数字的用户 ID,记录 INFO memory 中 used_memory 的增量;MEMORY USAGE 是估算值,偏大:
|
||||
|
||||
```text
|
||||
SET 中 100 个用户 ID:编码 intset,used_memory 增加 592 字节(每个 6 字节),MEMORY USAGE 488 字节
|
||||
SET 中 10000 个用户 ID:编码 hashtable,used_memory 增加 410704 字节(每个 41 字节),MEMORY USAGE 596736 字节
|
||||
SET 中 1000000 个用户 ID:编码 hashtable,used_memory 增加 30070800 字节(每个 30 字节),MEMORY USAGE 48388736 字节
|
||||
```
|
||||
|
||||
### A22 长连接与心跳
|
||||
|
||||
Docker Desktop 中的 Ubuntu 24.04 容器(内核 6.10.14-linuxkit,aarch64,--privileged,ulimit -n 为 300000),JDK 21。服务端和客户端在同一个容器中:
|
||||
|
||||
- 服务端:一个线程、一个 Selector 管理所有连接;收到 PING 回复 PONG,并记录连接最后一次收到数据的时间;每 5 秒扫描一次,关闭超过 90 秒没有收到数据的连接;
|
||||
- 客户端:先不指定源地址,连接 127.0.0.1:9000,直到失败;然后把 ip_local_port_range 改为 10000~65000(第一次实验用默认范围连接 4 个目的地址、每个 2.5 万个连接,用了 233 秒才建立完),依次连接 127.0.0.2~127.0.0.9 共 8 个目的地址,建立 10 万个连接,每个连接每 30 秒发一次心跳,前 1000 个连接不发;
|
||||
- Slab 是整个虚拟机的内核内存,包括两端的套接字,10 万个连接共 20 万个套接字。
|
||||
|
||||
```text
|
||||
== 内核参数
|
||||
ip_local_port_range: 32768 60999
|
||||
tcp_keepalive_time/intvl/probes: 7200 75 9
|
||||
ulimit -n: 300000
|
||||
(第一部分)ip_local_port_range: 32768 60999
|
||||
同一个目的地址:成功 28232 个,第 28233 个失败:java.net.BindException: Cannot assign requested address
|
||||
== 服务端启动后
|
||||
VmRSS: 41632 kB
|
||||
Slab: 67152 kB
|
||||
TCP: inuse 0 orphan 0 tw 0 alloc 26 mem 767
|
||||
连接 8 个目的地址,建立 100000 个连接,用时 5.6 秒
|
||||
== 10 万个连接建立后
|
||||
VmRSS: 162056 kB
|
||||
Slab: 910068 kB
|
||||
TCP: inuse 0 orphan 0 tw 0 alloc 200026 mem 1023
|
||||
== 之后 60 秒(心跳),服务端 CPU 时间 688 个时钟周期(CLK_TCK=100)
|
||||
== 心跳 60 秒后
|
||||
VmRSS: 173692 kB
|
||||
Slab: 910820 kB
|
||||
TCP: inuse 0 orphan 0 tw 0 alloc 200026 mem 1535
|
||||
全部建立后又运行 150 秒:累计收到 PONG 506741 个,被服务端关闭的连接 1000 个
|
||||
== 服务端日志(节选)
|
||||
10 s 连接 100000 收到心跳累计 19662 因超时关闭 0 堆已用 79 MB
|
||||
30 s 连接 100000 收到心跳累计 85662 因超时关闭 0 堆已用 80 MB
|
||||
90 s 连接 100000 收到心跳累计 283703 因超时关闭 0 堆已用 77 MB
|
||||
100 s 连接 99000 收到心跳累计 316976 因超时关闭 1000 堆已用 79 MB
|
||||
150 s 连接 99000 收到心跳累计 482088 因超时关闭 1000 堆已用 87 MB
|
||||
```
|
||||
|
||||
由此计算:服务端进程的 VmRSS 增加约 120 MB,每个连接约 1.2 KB;Slab 增加 843 MB,每个套接字约 4.2 KB;心跳阶段服务端 60 秒内用了 6.88 秒 CPU,约为一个核的 11%。
|
||||
|
||||
群聊的未读数和拉取,表结构与正文相同,群 88 的成员 1002 读到第 1 条,群里共有 10 条消息:
|
||||
|
||||
```text
|
||||
+-----------------+
|
||||
| resend_affected |
|
||||
+-----------------+
|
||||
| 0 |
|
||||
+-----------------+
|
||||
+--------+
|
||||
| unread |
|
||||
+--------+
|
||||
| 9 |
|
||||
+--------+
|
||||
+-----+---------+
|
||||
| seq | content |
|
||||
+-----+---------+
|
||||
| 2 | 2 |
|
||||
| 3 | 3 |
|
||||
| 4 | 4 |
|
||||
| 5 | 5 |
|
||||
| 6 | 6 |
|
||||
| 7 | 7 |
|
||||
| 8 | 8 |
|
||||
| 9 | 9 |
|
||||
| 10 | 10 |
|
||||
+-----+---------+
|
||||
+----+-------------+---------+------------+-------+---------------+---------+---------+------+------+----------+-------------+
|
||||
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|
||||
+----+-------------+---------+------------+-------+---------------+---------+---------+------+------+----------+-------------+
|
||||
| 1 | SIMPLE | message | NULL | range | PRIMARY | PRIMARY | 16 | NULL | 9 | 100.00 | Using where |
|
||||
+----+-------------+---------+------------+-------+---------------+---------+---------+------+------+----------+-------------+
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Redis: Scripting with Lua](https://redis.io/docs/latest/develop/programmability/eval-intro/)
|
||||
|
||||
Reference in New Issue
Block a user