mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-10-30 18:23:52 +00:00
Update 系统设计基础.md
补充限流算法与分布式限流(新增附录 A13、A14)、抢券抢红包与抽奖(新增附录 A15~A17),增加配图;正文中“本机”的说法改为实测,漏桶与令牌桶、熔断器的配图改由脚本生成。
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 235 KiB |
+448
-9
@@ -113,14 +113,83 @@
|
||||
|
||||
### 限流、降级与熔断
|
||||
|
||||
- 限流:请求量超过系统的处理能力时,拒绝多余的请求,常用令牌桶和漏桶算法;
|
||||
- 限流:请求量超过系统的处理能力时,拒绝多余的请求;
|
||||
- 降级:主动关闭部分非核心功能,从而保证核心功能可用;
|
||||
- 熔断:某个依赖的服务持续出错时,暂时不再调用它而是直接返回默认结果,避免故障沿着调用链扩散。
|
||||
|
||||
漏桶以固定速率处理请求,桶满时丢弃新的请求;令牌桶以固定速率放入令牌,请求拿到令牌才能被处理,桶中积累的令牌允许一定的突发流量。
|
||||
#### 1. 限流算法
|
||||
|
||||
限流要判断的是:最近一段时间已经放行了多少请求,下一个请求还能不能放行。下面都以“每个用户每秒最多下单 5 次”为例。
|
||||
|
||||
固定窗口(计数器)把时间切成 1 秒的窗口,每个窗口一个计数器,超过 5 次就拒绝。用 Redis 实现时,键名带上用户和秒数,例如 order:limit:1001:1700000001,INCR 返回 1 时再设置过期时间,Redis 文档中 INCR 一节给出了这种做法。
|
||||
|
||||
固定窗口的问题在窗口边界。0.9 秒和 1.1 秒各来 5 个请求,分别落在两个窗口中,都能通过,0.24 秒内放行了 10 次,是上限的 2 倍。
|
||||
|
||||
滑动窗口统计的是“当前时刻往前 1 秒”内放行的请求数,窗口随时间移动。可以用有序集合(ZSET)记录每个放行的请求:成员是唯一的请求 ID,分值是毫秒时间戳;新请求到来时,先删除 1 秒以前的记录,剩下的不足 5 条才放行并写入。
|
||||
|
||||
同样的请求,1.1 秒时往前 1 秒已有 5 次,后 5 个请求被拒绝;1.95 秒时 0.9 秒的记录已经移出窗口,请求通过(Redis 7.4 实测,见[附录 A13](#a13-限流算法)):
|
||||
|
||||
<div align="center"> <img src="pics/ratelimit-window.png" width="720px"/> </div><br>
|
||||
|
||||
滑动窗口的代价是每个放行的请求都要存一条记录。上限为每秒 1000 次时,一个用户的 ZSET 约占 100 KB,而固定窗口的计数器只占 56 字节。折中的做法是把 1 秒分成若干小格,每格一个计数器,把落在窗口内的格子相加,格子越多越精确。
|
||||
|
||||
漏桶把请求放进队列,以固定速率取出处理,例如每 200 毫秒一个,队列满了再来的请求被拒绝。它的输出最平滑,但突发的请求要排队等待。Nginx 的 limit_req 就是漏桶,burst 是允许排队的请求数,加上 nodelay 后 burst 以内的请求不再排队,而是立即处理。
|
||||
|
||||
令牌桶以固定速率放入令牌,例如每秒 5 个,桶中最多存 5 个,请求取到令牌才放行。空闲一段时间后桶是满的,可以一次放行 5 个请求,之后每 200 毫秒放行一个。
|
||||
|
||||
<div align="center"> <img src="pics/rate-limit.png" width="640px"/> </div><br>
|
||||
|
||||
令牌桶不需要定时放入令牌,只要记下剩余的令牌数和上次更新的时间,请求到来时补上这段时间应放入的令牌,再判断够不够。Guava 的 RateLimiter 和 Spring Cloud Gateway 的 Redis 限流器都是这样实现的。RateLimiter 的 acquire() 在令牌不足时让调用线程等待,tryAcquire() 则立即返回 false(Guava 33.4 实测,见[附录 A14](#a14-guava-的-ratelimiter))。
|
||||
|
||||
四种算法的对比:
|
||||
|
||||
| 算法 | 突发请求 | 需要保存的数据 | 适用场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| 固定窗口 | 窗口边界处最多放行 2 倍 | 每个窗口一个计数器 | 精度要求不高的限制,例如每天的调用次数 |
|
||||
| 滑动窗口 | 任意 1 秒内都不超过上限 | 每个请求一条记录,或分格计数 | 按用户、按接口的精确限制 |
|
||||
| 漏桶 | 排队,按固定速率处理 | 请求队列 | 保护处理能力固定的下游,例如调用外部接口 |
|
||||
| 令牌桶 | 允许不超过桶容量的突发 | 令牌数和时间戳 | 允许短时突发的接口限流 |
|
||||
|
||||
#### 2. 分布式限流
|
||||
|
||||
服务部署了多个实例时,只在每个实例的内存中计数是不够的:4 个实例各限 5 次/秒,总量就变成 20 次/秒,扩容后还会变化;同一个用户的请求被分到不同实例,单个实例看到的次数也不准。因此要把计数放到所有实例共享的 Redis 中。
|
||||
|
||||
判断和扣减必须是原子的,否则两个请求可能同时读到还剩 1 个令牌,都被放行。做法是用 Lua 脚本,令牌数和更新时间存在一个哈希表中:
|
||||
|
||||
```lua
|
||||
-- KEYS[1] 限流键;ARGV:每秒放入的令牌数、桶的容量、当前时间(毫秒)
|
||||
local rate = tonumber(ARGV[1])
|
||||
local capacity = tonumber(ARGV[2])
|
||||
local now = tonumber(ARGV[3])
|
||||
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
|
||||
local tokens = tonumber(b[1]) or capacity
|
||||
local ts = tonumber(b[2]) or now
|
||||
-- 不用定时放令牌,按距离上次请求的时间补上这段时间应放入的令牌
|
||||
tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate / 1000)
|
||||
local allowed = 0
|
||||
if tokens >= 1 then
|
||||
tokens = tokens - 1
|
||||
allowed = 1
|
||||
end
|
||||
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
|
||||
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) * 2)
|
||||
return allowed
|
||||
```
|
||||
|
||||
调用时传入用户的限流键、每秒 5 个、容量 5 和当前的毫秒时间。同一时刻连续 7 个请求,前 5 个通过、后 2 个被拒绝;400 毫秒后补充了 2 个令牌,3 个请求中通过 2 个;空闲 10 秒后,令牌也只补到容量 5 个(见[附录 A13](#a13-限流算法))。
|
||||
|
||||
分布式限流还要考虑以下几点:
|
||||
|
||||
- 每次判断都多一次 Redis 往返,并且一个限流键只落在一个 Redis 节点上,一个键每秒约能判断 9 万~10 万次(Redis 7.4 实测,见[附录 A13](#a13-限流算法));全局总量的限流超过这个量级时,可以把令牌分到多个键,或者让每个实例一次领取一批令牌在本地使用;
|
||||
- 时间由各个实例传入时,实例之间的时钟要同步,也可以在脚本中用 Redis 的 TIME 命令取时间;
|
||||
- Redis 不可用时,可以退回到每个实例自己的单机限流,避免因为限流组件故障而拒绝所有请求。
|
||||
|
||||
限流通常放在多个位置:网关按 IP、用户或接口限流,挡住大部分突发和恶意的流量;服务内部按接口或热点参数限流,例如 Sentinel 可以按 QPS 或并发线程数设置阈值,超过后直接拒绝、预热(Warm Up)或者匀速排队。阈值来自压测得到的容量,并留出余量,见[压测与容量规划](#压测与容量规划)。
|
||||
|
||||
被限流的请求返回 429 Too Many Requests,可以带上 Retry-After 首部告诉客户端多久之后重试(RFC 6585)。
|
||||
|
||||
#### 3. 熔断
|
||||
|
||||
熔断器有关闭、打开和半开三种状态:失败率超过阈值时打开;打开一段时间后进入半开状态,放行少量请求试探,成功则关闭,失败则重新打开。
|
||||
|
||||
<div align="center"> <img src="pics/circuit-breaker.png" width="520px"/> </div><br>
|
||||
@@ -171,6 +240,120 @@ UPDATE goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0;
|
||||
|
||||
一人一单可以用订单表上(用户 ID,商品 ID)的唯一索引保证,消息被重复消费时插入失败,也就实现了幂等。Redis 中扣减成功、但订单最终创建失败时,需要把 Redis 中的库存和购买记录恢复,并定期与数据库对账。
|
||||
|
||||
### 同类场景:抢券、抢红包与抽奖
|
||||
|
||||
抢券、抽奖和抢红包也是大量用户在同一时刻争抢有限的资源,上面的限流、Redis 预扣、异步落库和数据库兜底都可以直接套用。下面只讲它们各自不同的部分。
|
||||
|
||||
#### 1. 抢券
|
||||
|
||||
券的发放总量就是库存,预扣的 Lua 脚本与秒杀基本相同,区别主要在限领和使用。
|
||||
|
||||
每人限领 N 张时,把脚本中的已购用户集合换成 Hash(用户 ID → 已领张数),已领张数小于 N 才扣库存并 HINCRBY,返回值就是“第几张”。例如券 501 发 100 张、每人限领 2 张,80 个用户各并发请求 5 次:恰好发出 100 张,50 个用户各领到 2 张,没有人超过 2 张(Redis 7.4 实测,见[附录 A17](#a17-抢券的限领与核销))。领取的人很多时这个 Hash 会成为[大 Key](缓存.md#大-key-与热-key),活动结束后要设置过期时间或用 UNLINK 删除。
|
||||
|
||||
领到的券写入用户券表,以“第几张”作为 seq,在(user_id, coupon_id, seq)上建唯一索引,消息重复投递时插入失败,不会多发:
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_coupon (
|
||||
id BIGINT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
coupon_id BIGINT NOT NULL, -- 券的模板,例如 501:满 100 减 20
|
||||
seq TINYINT NOT NULL, -- 该用户领的第几张
|
||||
status TINYINT NOT NULL, -- 0 未使用,1 已锁定,2 已使用
|
||||
order_id BIGINT,
|
||||
valid_end DATETIME NOT NULL, -- 有效期,例如领取后 7 天
|
||||
UNIQUE KEY uk_user_coupon (user_id, coupon_id, seq)
|
||||
);
|
||||
```
|
||||
|
||||
用户查询“我的券”时按 user_id 查询,因此这张表分库分表时按 user_id 切分。
|
||||
|
||||
券的有效期写在每条用户券上,是否过期在使用时按 valid_end 判断,不依赖定时任务或延迟消息把状态改为“已过期”。这类任务只用于提醒和展示,即使延迟或积压,也不会让过期的券被用掉。
|
||||
|
||||
核销分两步:下单时锁定,支付成功后改为已使用。锁定用条件更新,影响行数为 1 才算成功,同一张券因此只能被一个订单锁定;订单取消或超时未支付时,再把状态改回未使用:
|
||||
|
||||
```sql
|
||||
UPDATE user_coupon SET status = 1, order_id = 9001
|
||||
WHERE id = 70001 AND user_id = 1001 AND status = 0 AND valid_end > NOW();
|
||||
```
|
||||
|
||||
#### 2. 抽奖
|
||||
|
||||
抽奖比抢券多了一步:先按概率决定抽中哪个奖品,再扣这个奖品的库存。
|
||||
|
||||
概率一般配置为权重。例如一等奖 1%(库存 10 个)、二等奖 9%(库存 500 个)、三等奖 30%(不限量)、谢谢参与 60%,按 10000 份累加成区间 [0, 100)、[100, 1000)、[1000, 4000)、[4000, 10000)。服务端生成 0~9999 的随机数,落在哪个区间就是哪个奖品;奖品多时可以对区间二分查找,或者预先展开成 10000 格的数组直接按下标取,用空间换时间。
|
||||
|
||||
抽中限量的奖品后,再用与秒杀相同的 Lua 脚本扣减这个奖品的库存。扣减失败说明奖品已经发完,这时应降级为“谢谢参与”或兜底奖品,而不是重新抽一次。这是因为重新抽相当于把一等奖的 1% 按比例分给其它奖品,二等奖的实际概率会从 9% 变为 9 ÷ 99 ≈ 9.09%,发出的奖品超出预算。
|
||||
|
||||
每人每天的抽奖次数可以在 Redis 中用 INCR 计数并按天设置过期时间,返回值超过上限就拒绝。“每人最多中一次大奖”这类限制也按降级处理,因此实际中奖率会低于配置值,上线前可以用大量模拟统计各奖品的频率来核对。
|
||||
|
||||
#### 3. 抢红包
|
||||
|
||||
抢红包与秒杀的区别在于每一份的金额不同:发红包时要把总金额拆成若干份,抢的时候每人拿走一份。下面以用户 1001 在群里发的拼手气红包 8001 为例,共 100 元、10 个。
|
||||
|
||||
##### 3.1 金额的拆分
|
||||
|
||||
金额用整数的分表示,避免浮点数的误差。常用的拆分方法是二倍均值法:剩余 R 分、还有 k 份时,本份在 1 到 2R ÷ k − 1 分(除法向下取整)之间均匀随机,最后一份拿走剩余的全部金额。例如第一份在 1~1999 分之间,即 0.01~19.99 元。
|
||||
|
||||
上限比剩余平均值的 2 倍少 1 分,保证拿走本份之后,剩下的每一份至少还有 1 分。均匀随机的均值约等于当时的剩余平均值,所以每个位置的期望都是 10 元,先抢和后抢的期望相同;但越靠后的位置可取的范围越大,波动也越大。
|
||||
|
||||
对这个红包模拟拆分 10 万次,每次的总额都是 100.00 元、每份至少 0.01 元,各位置的统计如下(单位:元,见[附录 A15](#a15-红包金额的拆分)):
|
||||
|
||||
```text
|
||||
位置 1 2 3 4 5 6 7 8 9 10
|
||||
平均值 10.02 10.01 9.95 10.02 9.99 9.98 10.00 10.01 9.99 10.03
|
||||
标准差 5.77 5.80 5.89 5.97 6.06 6.20 6.41 6.84 7.69 7.71
|
||||
最小值 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.01
|
||||
最大值 19.99 22.19 24.57 27.70 31.79 34.46 39.05 45.85 60.50 68.33
|
||||
```
|
||||
|
||||
##### 3.2 预先拆分与实时计算
|
||||
|
||||
拆分的时机有两种:
|
||||
|
||||
- 预先拆分:发红包时就拆成 10 份,用 RPUSH 放入 Redis 列表,抢的时候 LPOP 一份。抢的路径只有一次弹出,金额在发的时候就已确定,可以随红包一起落库备查;代价是每一份都要占用内存,份数多时更明显;
|
||||
- 实时计算:Redis 中只保存剩余金额和剩余个数,抢的时候在 Lua 脚本中按二倍均值法算出本份金额,再更新这两个值。占用的内存固定,但每次领取都要在脚本中计算。Redis 7 中脚本的随机数生成器每次调用都会随机设置种子,可以直接调用 math.random。
|
||||
|
||||
两种做法用的是同一个拆分方法,金额的分布相同,在 Lua 脚本中执行时也都不会超发。红包的份数通常不多,预先拆分的实现更简单。
|
||||
|
||||
##### 3.3 并发领取与落库
|
||||
|
||||
<div align="center"> <img src="pics/red-packet-flow.png" width="720px"/> </div><br>
|
||||
|
||||
领取用下面的 Lua 脚本,判断是否抢过、弹出一份、记录领取者三步作为一个整体执行:
|
||||
|
||||
```lua
|
||||
-- KEYS[1]:金额列表,KEYS[2]:领取记录(Hash,用户 ID → 金额),ARGV[1]:用户 ID
|
||||
if redis.call('HEXISTS', KEYS[2], ARGV[1]) == 1 then
|
||||
return -1 -- 已经抢过
|
||||
end
|
||||
local amount = redis.call('LPOP', KEYS[1])
|
||||
if not amount then
|
||||
return 0 -- 已抢完
|
||||
end
|
||||
redis.call('HSET', KEYS[2], ARGV[1], amount)
|
||||
return tonumber(amount)
|
||||
```
|
||||
|
||||
列表中只有 10 份,LPOP 每次弹出一份,最多 10 个请求成功,发出的总额不会超过 100 元。每人只能抢一次则依赖脚本的原子性:如果在应用中依次执行 HEXISTS、LPOP、HSET,同一个用户的两个并发请求可能都通过判断,各弹出一份。
|
||||
|
||||
例如 100 个用户各并发请求 2 次:使用脚本时恰好 10 人各抢到一份,合计 100.00 元;分成三条命令执行 3 次,分别有 4、3、4 个用户抢到了两份,而领取记录被 HSET 覆盖,只剩 6、7、6 条,发出的金额与记录对不上(Redis 7.4 实测,见[附录 A16](#a16-并发抢红包与异步落库))。
|
||||
|
||||
同一个红包的键名都带上 {8001} 这样的哈希标签,使它们落在 Redis 集群的同一个槽中,脚本才能同时操作;不同的红包则分散到不同的节点。
|
||||
|
||||
抢红包是写密集的场景,每抢到一份都要写一条记录、修改一次剩余金额。这些写入由 Redis 先承接,脚本返回成功后再发送消息,由消费者在一个事务中写入数据库:
|
||||
|
||||
```sql
|
||||
INSERT INTO red_packet_grab (packet_id, user_id, amount) VALUES (8001, 7, 276);
|
||||
UPDATE red_packet SET remain_amount = remain_amount - 276, remain_num = remain_num - 1
|
||||
WHERE id = 8001 AND remain_num > 0 AND remain_amount >= 276;
|
||||
```
|
||||
|
||||
领取记录表在(packet_id, user_id)上有唯一索引,重复投递的消息插入失败,整个事务回滚,剩余金额不会被扣两次(见[接收端的可靠性](消息队列.md#接收端的可靠性));UPDATE 的条件是数据库这一层的兜底。
|
||||
|
||||
应用在脚本执行成功之后、消息发出之前宕机时,这条领取只记录在 Redis 中。因此红包抢完或过期后,要用 Redis 中的领取记录与数据库对账,补发缺失的消息。例如每条消息都投递两次、其中一个用户的消息全部丢失:18 条消息插入 9 条、冲突 9 条,对账补发后剩余金额为 0、领取记录 10 条,合计正好 100 元(MySQL 8.0 实测,见[附录 A16](#a16-并发抢红包与异步落库))。
|
||||
|
||||
红包一般有有效期,例如 24 小时。到期后先完成对账,再把数据库中的剩余金额退回给发送者。
|
||||
|
||||
## 七、海量数据处理
|
||||
|
||||
这类问题的特点是数据量远大于内存,例如“100 GB 的文件,内存只有 4 GB”。常用的思路是分而治之:先把数据切成能放进内存的小块,分别处理后再合并结果。
|
||||
@@ -297,7 +480,7 @@ p20 p19 p18 ... p11
|
||||
|
||||
容量要按峰值计算,不能按平均值。平时的峰值约是平均值的 1.4 倍,大促峰值则约是平均值的 14 倍。
|
||||
|
||||
10 万 QPS 直接落到数据库上风险很大。本机回环实测的 MySQL 主键点查在 16 至 128 个连接时达到 17 万至 19 万 QPS(见[附录 A4](#a4-mysql-主键点查的并发扫描)),但这是客户端与服务端同机、没有网络开销的结果;生产环境经过网络和其它负载后的上限会低很多。因此需要在数据库之前挡住绝大部分请求。
|
||||
10 万 QPS 直接落到数据库上风险很大。实测的 MySQL 主键点查在 16 至 128 个连接时达到 17 万至 19 万 QPS(见[附录 A4](#a4-mysql-主键点查的并发扫描)),但这是客户端与服务端同机、没有网络开销的结果;生产环境经过网络和其它负载后的上限会低很多。因此需要在数据库之前挡住绝大部分请求。
|
||||
|
||||
### 读路径的逐层分流
|
||||
|
||||
@@ -313,7 +496,7 @@ CDN 边缘节点缓存商品基础信息的 JSON,存活时间(Time To Live,
|
||||
|
||||
#### 3. Redis
|
||||
|
||||
商品详情、价格和库存分别存放在 Redis 的键中,用一次 MGET 取回。Redis 的请求量为 2 万 QPS,约为本机 GET 实测中位数(12.5 万 QPS,见[附录 A7](#a7-redis-get-的并发扫描))的 1/6。假设 98% 的请求命中,回源到数据库的请求为 400 QPS。
|
||||
商品详情、价格和库存分别存放在 Redis 的键中,用一次 MGET 取回。Redis 的请求量为 2 万 QPS,约为单实例 GET 实测中位数(12.5 万 QPS,见[附录 A7](#a7-redis-get-的并发扫描))的 1/6。假设 98% 的请求命中,回源到数据库的请求为 400 QPS。
|
||||
|
||||
#### 4. 数据库
|
||||
|
||||
@@ -323,7 +506,7 @@ CDN 边缘节点缓存商品基础信息的 JSON,存活时间(Time To Live,
|
||||
|
||||
命中率的影响比想象中大。Redis 命中率从 98% 降到 90%,回源 QPS 从 400 增至 2000。
|
||||
|
||||
百万 QPS 时,若命中率保持不变,进入 Redis 的请求增至 20 万 QPS。按每个分片约 4 万 QPS 的规划值(假设为本机实测的约 1/3)计算,需要 5 个分片;回源到数据库的请求只增至 4000 QPS。因此高并发读的扩展主要落在缓存层和应用层的水平扩展上,而不是升级单台数据库。
|
||||
百万 QPS 时,若命中率保持不变,进入 Redis 的请求增至 20 万 QPS。按每个分片约 4 万 QPS 的规划值(假设为上述实测值的约 1/3)计算,需要 5 个分片;回源到数据库的请求只增至 4000 QPS。因此高并发读的扩展主要落在缓存层和应用层的水平扩展上,而不是升级单台数据库。
|
||||
|
||||
### 写路径:库存与下单
|
||||
|
||||
@@ -333,9 +516,9 @@ CDN 边缘节点缓存商品基础信息的 JSON,存活时间(Time To Live,
|
||||
|
||||
InnoDB 使用行级锁,并采用两阶段锁(Two-Phase Locking, 2PL),事务持有的行锁一般要到提交或回滚时才释放([InnoDB Transaction Model](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-model.html))。因此同一行的更新只能串行执行,单行的吞吐量约为 1 ÷ 锁持有时间。
|
||||
|
||||
本机实测验证了这一点:对同一行执行自动提交的 UPDATE,吞吐量在 1 到 64 个并发之间稳定在 0.8 万至 1 万 TPS,不随并发增加;分散到 100 行后,64 个并发达到 3.4 万 TPS(见[附录 A5](#a5-热点行与分散行的-update))。
|
||||
实测验证了这一点:对同一行执行自动提交的 UPDATE,吞吐量在 1 到 64 个并发之间稳定在 0.8 万至 1 万 TPS,不随并发增加;分散到 100 行后,64 个并发达到 3.4 万 TPS(见[附录 A5](#a5-热点行与分散行的-update))。
|
||||
|
||||
本机回环上单条查询的往返约为 0.03 ms(见[附录 A6](#a6-本机单条查询的往返延迟)),生产环境的事务内则有多次网络往返。假设同机房往返为 0.5 ms,事务内有 3 次往返(查询并加锁、更新库存、插入订单),锁持有约 1.6 ms,单行上限约为 625 TPS,低于 800 QPS。解决办法是拆分库存:把 1 万件分成 10 行,每行 1000 件,每行约 80 QPS,远低于上限。
|
||||
客户端与服务端在同一台机器上时,单条查询的往返约为 0.03 ms(见[附录 A6](#a6-本机单条查询的往返延迟)),生产环境的事务内则有多次网络往返。假设同机房往返为 0.5 ms,事务内有 3 次往返(查询并加锁、更新库存、插入订单),锁持有约 1.6 ms,单行上限约为 625 TPS,低于 800 QPS。解决办法是拆分库存:把 1 万件分成 10 行,每行 1000 件,每行约 80 QPS,远低于上限。
|
||||
|
||||
拆分后,某一行售罄而其它行仍有库存时,要换一行重试,并定期把库存在各行之间调拨:
|
||||
|
||||
@@ -384,7 +567,7 @@ Google SRE 对过载的建议是先返回降级的响应,即数据更少、计
|
||||
|
||||
拐点的位置随环境波动。同一台机器上的三轮测试,数值并不完全一致:第一轮 16 个连接时已达到 9.5 万 QPS,200 个连接时的 P99 为 7.19 ms,而第二、三轮约为 2 ms。因此压测要重复多次并取中位数,只在相同规格的环境中比较结果。Redis 文档也指出,逐条同步发送命令测的是网络和客户端的延迟,要测服务端本身需要多个连接([Redis Benchmark](https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/benchmarks/))。
|
||||
|
||||
数据库也有类似的拐点。MySQL 主键点查在 16 个连接时为 16.9 万 QPS,128 个连接时为 19.2 万 QPS,吞吐量只增加 14%,平均响应时间却从 0.095 ms 增至 0.668 ms(见[附录 A4](#a4-mysql-主键点查的并发扫描))。本机压测的客户端与服务端共用 10 个核心,吞吐量很快接近这台机器的上限,之后的并发只增加排队时间。热点行的更新则从一开始就没有并发收益。
|
||||
数据库也有类似的拐点。MySQL 主键点查在 16 个连接时为 16.9 万 QPS,128 个连接时为 19.2 万 QPS,吞吐量只增加 14%,平均响应时间却从 0.095 ms 增至 0.668 ms(见[附录 A4](#a4-mysql-主键点查的并发扫描))。压测时客户端与服务端在同一台机器上,共用 10 个核心,吞吐量很快接近这台机器的上限,之后的并发只增加排队时间。热点行的更新则从一开始就没有并发收益。
|
||||
|
||||
并发数 = 吞吐量 × 响应时间,这一关系即 Little 定律(Little's law),见[性能指标](#性能指标)。它可以用来估算同时在处理的请求数。例如 10 万 QPS、平均响应时间 5 ms(假设)时,系统中约有 500 个请求同时在处理。单台实例按 1200 QPS 计算时,同时处理约 6 个请求,线程池和连接池要在此基础上留出余量。
|
||||
|
||||
@@ -453,7 +636,7 @@ Google SRE 的经验是用 CPU 等资源而不是 QPS 作为容量的度量,
|
||||
|
||||
秒传指的是服务端已经有内容相同的文件时,客户端不需要上传任何分片,直接建立引用。判断依据是文件的内容哈希:客户端在初始化前计算整文件的 SHA-256,服务端在文件表中查到相同的哈希,就直接返回文件已存在。
|
||||
|
||||
秒传的代价在于先要读完整个文件来计算哈希。在本机上,同时计算整文件和全部分片的 SHA-256,300 MiB 耗时约 0.31 秒,2 GiB 约 2 秒(Python hashlib 实测,见[附录 A9](#a9-分片上传的最小实现与实测))。浏览器中的 Web Crypto API 不支持流式输入,digest 要求把完整数据读入内存,因此大文件的哈希通常要用增量算法计算。
|
||||
秒传的代价在于先要读完整个文件来计算哈希。同时计算整文件和全部分片的 SHA-256,300 MiB 耗时约 0.31 秒,2 GiB 约 2 秒(Python hashlib 实测,见[附录 A9](#a9-分片上传的最小实现与实测))。浏览器中的 Web Crypto API 不支持流式输入,digest 要求把完整数据读入内存,因此大文件的哈希通常要用增量算法计算。
|
||||
|
||||
需要注意的是,只按哈希判断文件是否存在时,任何知道哈希的用户都可以“秒传”别人的文件,并借此探测某个文件是否存在。秒传应当只在当前用户自己的文件空间内建立引用,存储中的文件仍然只保存一份。实测中,同一内容换了文件名再次上传,初始化直接返回已存在,没有发送任何分片。
|
||||
|
||||
@@ -1144,6 +1327,256 @@ SELECT id, amount FROM sys_order WHERE id = 2;
|
||||
+----+---------+
|
||||
```
|
||||
|
||||
### A13 限流算法
|
||||
|
||||
环境:Redis 7.4.1,Apple M1 Pro,macOS,默认配置。客户端用 redis-py 调用 Lua 脚本,时间戳由客户端以毫秒传入,便于精确复现窗口边界。令牌桶的脚本与正文相同,固定窗口和滑动窗口的脚本如下:
|
||||
|
||||
```lua
|
||||
-- 固定窗口:KEYS[1] 为“限流键:窗口编号”;ARGV:上限、窗口毫秒数
|
||||
local n = redis.call('INCR', KEYS[1])
|
||||
if n == 1 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
||||
end
|
||||
if n <= tonumber(ARGV[1]) then
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
```
|
||||
|
||||
```lua
|
||||
-- 滑动窗口(日志):KEYS[1] 限流键;ARGV:窗口毫秒数、上限、当前时间(毫秒)、请求 ID
|
||||
local window = tonumber(ARGV[1])
|
||||
local limit = tonumber(ARGV[2])
|
||||
local now = tonumber(ARGV[3])
|
||||
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
|
||||
if redis.call('ZCARD', KEYS[1]) < limit then
|
||||
redis.call('ZADD', KEYS[1], now, ARGV[4])
|
||||
redis.call('PEXPIRE', KEYS[1], window)
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
```
|
||||
|
||||
固定窗口的键为“限流键:时间戳 // 1000”,上限 5,窗口 1000 毫秒;滑动窗口的窗口为 1000 毫秒、上限 5,请求 ID 为 req-0、req-1……;令牌桶为每秒 5 个、容量 5。原始输出:
|
||||
|
||||
```text
|
||||
== 固定窗口与滑动窗口:上限 5 次/秒,用户 1001
|
||||
时间(ms) 固定窗口 滑动窗口
|
||||
900 通过 通过
|
||||
910 通过 通过
|
||||
920 通过 通过
|
||||
930 通过 通过
|
||||
940 通过 通过
|
||||
1100 通过 拒绝
|
||||
1110 通过 拒绝
|
||||
1120 通过 拒绝
|
||||
1130 通过 拒绝
|
||||
1140 通过 拒绝
|
||||
1950 拒绝 通过
|
||||
0.90~1.14 秒内通过:固定窗口 10 次,滑动窗口 5 次
|
||||
== 令牌桶:每秒 5 个令牌,容量 5,用户 1001
|
||||
0 ms 通过 剩余令牌 4.0
|
||||
0 ms 通过 剩余令牌 3.0
|
||||
0 ms 通过 剩余令牌 2.0
|
||||
0 ms 通过 剩余令牌 1.0
|
||||
0 ms 通过 剩余令牌 0.0
|
||||
0 ms 拒绝 剩余令牌 0.0
|
||||
0 ms 拒绝 剩余令牌 0.0
|
||||
400 ms 通过 剩余令牌 1.0
|
||||
400 ms 通过 剩余令牌 0.0
|
||||
400 ms 拒绝 剩余令牌 0.0
|
||||
10400 ms 通过 剩余令牌 4.0
|
||||
10400 ms 通过 剩余令牌 3.0
|
||||
10400 ms 通过 剩余令牌 2.0
|
||||
10400 ms 通过 剩余令牌 1.0
|
||||
10400 ms 通过 剩余令牌 0.0
|
||||
10400 ms 拒绝 剩余令牌 0.0
|
||||
10400 ms 拒绝 剩余令牌 0.0
|
||||
```
|
||||
|
||||
内存用 MEMORY USAGE <key> SAMPLES 0 测量:写入 1000 条记录的 ZSET(成员形如 req-1000000,分值为毫秒时间戳)为 102016 字节,多次运行在 101888~102384 字节之间;固定窗口的计数器为 56 字节,令牌桶的哈希表为 104 字节。
|
||||
|
||||
单个键的吞吐量用 redis-benchmark 测量(本机回环,50 个连接,每项 200000 次,同一个键),令牌桶脚本先用 SCRIPT LOAD 加载:
|
||||
|
||||
```text
|
||||
redis-benchmark -n 200000 -c 50 -q INCR t-rl:bench:incr
|
||||
redis-benchmark -n 200000 -c 50 -q EVALSHA <sha1> 1 t-rl:bench:tb 5 5 1700000000000
|
||||
|
||||
-- 第 1 次
|
||||
INCR: 138696.25 requests per second, p50=0.279 msec
|
||||
EVALSHA: 99700.90 requests per second, p50=0.415 msec
|
||||
-- 第 2 次
|
||||
INCR: 143266.47 requests per second, p50=0.263 msec
|
||||
EVALSHA: 87719.30 requests per second, p50=0.479 msec
|
||||
-- 第 3 次
|
||||
INCR: 125156.45 requests per second, p50=0.311 msec
|
||||
EVALSHA: 101112.23 requests per second, p50=0.415 msec
|
||||
```
|
||||
|
||||
### A14 Guava 的 RateLimiter
|
||||
|
||||
环境:Guava 33.4.8-jre,JDK 21.0.13,Apple M1 Pro。
|
||||
|
||||
```java
|
||||
RateLimiter limiter = RateLimiter.create(5); // 每秒 5 个
|
||||
for (int i = 1; i <= 7; i++) {
|
||||
double waited = limiter.acquire(); // 返回等待的秒数
|
||||
}
|
||||
Thread.sleep(2000);
|
||||
for (int i = 1; i <= 7; i++) {
|
||||
double waited = limiter.acquire();
|
||||
}
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
limiter.tryAcquire(); // 不等待
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
== 新建后连续 7 次 acquire()
|
||||
第 1 次 等待 0.000 秒,此时 0.001 秒
|
||||
第 2 次 等待 0.188 秒,此时 0.211 秒
|
||||
第 3 次 等待 0.189 秒,此时 0.410 秒
|
||||
第 4 次 等待 0.190 秒,此时 0.605 秒
|
||||
第 5 次 等待 0.195 秒,此时 0.808 秒
|
||||
第 6 次 等待 0.191 秒,此时 1.009 秒
|
||||
第 7 次 等待 0.191 秒,此时 1.210 秒
|
||||
== 空闲 2 秒后连续 7 次 acquire()
|
||||
第 1 次 等待 0.000 秒,此时 0.000 秒
|
||||
第 2 次 等待 0.000 秒,此时 0.000 秒
|
||||
第 3 次 等待 0.000 秒,此时 0.000 秒
|
||||
第 4 次 等待 0.000 秒,此时 0.000 秒
|
||||
第 5 次 等待 0.000 秒,此时 0.001 秒
|
||||
第 6 次 等待 0.000 秒,此时 0.001 秒
|
||||
第 7 次 等待 0.199 秒,此时 0.210 秒
|
||||
== 紧接着 tryAcquire()(不等待)
|
||||
tryAcquire() = false
|
||||
tryAcquire() = false
|
||||
tryAcquire() = false
|
||||
```
|
||||
|
||||
RateLimiter.create() 创建的 SmoothBursty 最多积累 1 秒的令牌,即 5 个。空闲后前 5 次用的是积累的令牌;第 6 次也不等待,因为 RateLimiter 让当前请求先通过,把欠下的时间算到下一个请求上(SmoothRateLimiter 源码注释),所以第 7 次等待约 0.2 秒。
|
||||
|
||||
### A15 红包金额的拆分
|
||||
|
||||
100 元(10000 分)分 10 份,二倍均值法拆分 10 万次,统计每个位置的平均值、标准差、最小值和最大值,并检查每次的总额和最小的一份(Python 3):
|
||||
|
||||
```python
|
||||
def split(total, n):
|
||||
"""单位为分。前 n-1 份在 [1, 2 × 剩余平均 − 1] 中均匀随机,最后一份拿剩余。"""
|
||||
res = []
|
||||
for left in range(n, 1, -1): # left:包括本份在内还剩几份
|
||||
x = random.randint(1, 2 * total // left - 1)
|
||||
res.append(x)
|
||||
total -= x
|
||||
res.append(total)
|
||||
return res
|
||||
```
|
||||
|
||||
```text
|
||||
runs=100000 total=100.00 n=10 bad=0 # bad:总额不等于 10000 分或某份小于 1 分的次数
|
||||
pos mean std min max
|
||||
1 10.02 5.77 0.01 19.99
|
||||
2 10.01 5.80 0.01 22.19
|
||||
3 9.95 5.89 0.01 24.57
|
||||
4 10.02 5.97 0.01 27.70
|
||||
5 9.99 6.06 0.01 31.79
|
||||
6 9.98 6.20 0.01 34.46
|
||||
7 10.00 6.41 0.01 39.05
|
||||
8 10.01 6.84 0.01 45.85
|
||||
9 9.99 7.69 0.01 60.50
|
||||
10 10.03 7.71 0.01 68.33
|
||||
```
|
||||
|
||||
### A16 并发抢红包与异步落库
|
||||
|
||||
红包 8001 为 100 元分 10 个,用户 0~99 各请求 2 次,同一用户的两次请求相邻提交,共 200 个请求由 64 个线程并发执行。预先拆分的脚本与正文相同;实时计算的脚本如下:
|
||||
|
||||
```lua
|
||||
-- KEYS[1]:红包(Hash:remain 剩余金额,count 剩余个数),KEYS[2]:领取记录,ARGV[1]:用户 ID
|
||||
if redis.call('HEXISTS', KEYS[2], ARGV[1]) == 1 then
|
||||
return -1
|
||||
end
|
||||
local remain = tonumber(redis.call('HGET', KEYS[1], 'remain'))
|
||||
local count = tonumber(redis.call('HGET', KEYS[1], 'count'))
|
||||
if count == 0 then
|
||||
return 0
|
||||
end
|
||||
local amount = remain -- 最后一个拿剩余
|
||||
if count > 1 then
|
||||
amount = math.random(1, math.floor(2 * remain / count) - 1)
|
||||
end
|
||||
redis.call('HSET', KEYS[1], 'remain', remain - amount, 'count', count - 1)
|
||||
redis.call('HSET', KEYS[2], ARGV[1], amount)
|
||||
return amount
|
||||
```
|
||||
|
||||
对比的写法把三步分开执行:
|
||||
|
||||
```python
|
||||
def naive(u):
|
||||
if c.hexists(H, u): return -1
|
||||
a = c.lpop(L)
|
||||
if a is None: return 0
|
||||
c.hset(H, u, a)
|
||||
return int(a)
|
||||
```
|
||||
|
||||
```text
|
||||
预拆 + Lua:成功 10,已抢完 180,已经抢过 10,抢到 2 次的用户 0,发出总额 100.00,领取记录 10 条
|
||||
实时计算 + Lua:成功 10,已抢完 180,已经抢过 10,抢到 2 次的用户 0,发出总额 100.00,领取记录 10 条
|
||||
分开执行(第 1 次):成功 10,已抢完 190,已经抢过 0,抢到 2 次的用户 4,发出总额 100.00,领取记录 6 条
|
||||
分开执行(第 2 次):成功 10,已抢完 189,已经抢过 1,抢到 2 次的用户 3,发出总额 100.00,领取记录 7 条
|
||||
分开执行(第 3 次):成功 10,已抢完 190,已经抢过 0,抢到 2 次的用户 4,发出总额 100.00,领取记录 6 条
|
||||
实时计算 1 万个红包:总额或最小值不符 0 个,第一份金额有 1985 种取值
|
||||
```
|
||||
|
||||
落库在 MySQL 8.0.44 上执行。红包表和领取记录表如下,消费者在一个事务中执行正文中的 INSERT 和 UPDATE,唯一索引冲突时回滚:
|
||||
|
||||
```sql
|
||||
CREATE TABLE red_packet (
|
||||
id BIGINT PRIMARY KEY, total INT NOT NULL, num INT NOT NULL,
|
||||
remain_amount INT NOT NULL, remain_num INT NOT NULL);
|
||||
CREATE TABLE red_packet_grab (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, packet_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
|
||||
amount INT NOT NULL, UNIQUE KEY uk_packet_user (packet_id, user_id));
|
||||
```
|
||||
|
||||
10 条领取消息各投递两次并打乱顺序,去掉其中一个用户的两条,剩余 18 条由 8 个线程并发消费;之后查出数据库中已有的领取者,与 Redis 中的领取记录比较,补发缺失的消息:
|
||||
|
||||
```text
|
||||
消息 18 条:插入成功 9,唯一索引冲突 9
|
||||
红包剩余金额 154 分,剩余个数 1;领取记录 9 条,合计 9846 分
|
||||
对账发现缺失: [(8001, 83, 154)] 补发结果: ['ok']
|
||||
红包剩余金额 0 分,剩余个数 0;领取记录 10 条,合计 10000 分
|
||||
```
|
||||
|
||||
### A17 抢券的限领与核销
|
||||
|
||||
券 501 库存 100 张,每人限领 2 张,用户 0~79 各请求 5 次,共 400 个请求由 64 个线程并发执行:
|
||||
|
||||
```lua
|
||||
-- KEYS[1]:库存,KEYS[2]:领取记录(Hash,用户 ID → 已领张数),ARGV[1]:用户 ID,ARGV[2]:每人限领张数
|
||||
local n = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
|
||||
if n >= tonumber(ARGV[2]) then return -1 end
|
||||
if tonumber(redis.call('GET', KEYS[1])) <= 0 then return 0 end
|
||||
redis.call('DECR', KEYS[1])
|
||||
return redis.call('HINCRBY', KEYS[2], ARGV[1], 1) -- 返回第几张,落库时作为 seq
|
||||
```
|
||||
|
||||
```text
|
||||
发出 100 张,库存剩余 0,领到张数分布 [(2, 50)],超过 2 张的用户 0,(用户, seq) 重复 0
|
||||
```
|
||||
|
||||
核销在 MySQL 8.0.44 上执行,表结构与正文相同。用户 1001 有两张券:70001 未过期,70002 已于前一天过期:
|
||||
|
||||
```text
|
||||
重复消息再插入 (1001, 501, 1):ERROR 1062 (23000): Duplicate entry '1001-501-1' for key 'uk_user_coupon'
|
||||
订单 9001 锁定 70001:影响 1 行
|
||||
订单 9002 再锁定 70001:影响 0 行
|
||||
订单 9002 锁定已过期的 70002:影响 0 行
|
||||
订单 9001 取消,释放 70001(WHERE order_id = 9001 AND status = 1):影响 1 行
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Redis: Scripting with Lua](https://redis.io/docs/latest/develop/programmability/eval-intro/)
|
||||
@@ -1174,3 +1607,9 @@ SELECT id, amount FROM sys_order WHERE id = 2;
|
||||
- Hu V C, Ferraiolo D, Kuhn R, et al. Guide to Attribute Based Access Control (ABAC) Definition and Considerations. NIST SP 800-162, 2014. [https://doi.org/10.6028/NIST.SP.800-162](https://doi.org/10.6028/NIST.SP.800-162)
|
||||
- [Authorization Cheat Sheet,OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)
|
||||
- [Row Security Policies,PostgreSQL 文档](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
|
||||
- [Redis: INCR(Pattern: Rate limiter)](https://redis.io/docs/latest/commands/incr/)
|
||||
- [Nginx ngx_http_limit_req_module](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html)
|
||||
- [Guava RateLimiter](https://github.com/google/guava/blob/master/guava/src/com/google/common/util/concurrent/RateLimiter.java)、[SmoothRateLimiter](https://github.com/google/guava/blob/master/guava/src/com/google/common/util/concurrent/SmoothRateLimiter.java)
|
||||
- [Spring Cloud Gateway request_rate_limiter.lua](https://github.com/spring-cloud/spring-cloud-gateway/blob/4.1.x/spring-cloud-gateway-server/src/main/resources/META-INF/scripts/request_rate_limiter.lua)
|
||||
- [Sentinel:流量控制](https://sentinelguard.io/zh-cn/docs/flow-control.html)
|
||||
- [RFC 6585: Additional HTTP Status Codes](https://www.rfc-editor.org/rfc/rfc6585)
|
||||
|
||||
Reference in New Issue
Block a user