Update 系统设计基础.md

补充限流算法与分布式限流(新增附录 A13、A14)、抢券抢红包与抽奖(新增附录 A15~A17),增加配图;正文中“本机”的说法改为实测,漏桶与令牌桶、熔断器的配图改由脚本生成。
This commit is contained in:
CyC2018
2026-10-28 22:51:26 +08:00
parent b07e0972af
commit 2ae79fb325
5 changed files with 448 additions and 9 deletions
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
View File
@@ -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)