diff --git a/notes/pics/circuit-breaker.png b/notes/pics/circuit-breaker.png
index 025bdec1..0af779b2 100644
Binary files a/notes/pics/circuit-breaker.png and b/notes/pics/circuit-breaker.png differ
diff --git a/notes/pics/rate-limit.png b/notes/pics/rate-limit.png
index b5c608ad..1138ff63 100644
Binary files a/notes/pics/rate-limit.png and b/notes/pics/rate-limit.png differ
diff --git a/notes/pics/ratelimit-window.png b/notes/pics/ratelimit-window.png
new file mode 100644
index 00000000..38c10607
Binary files /dev/null and b/notes/pics/ratelimit-window.png differ
diff --git a/notes/pics/red-packet-flow.png b/notes/pics/red-packet-flow.png
new file mode 100644
index 00000000..7f441af1
Binary files /dev/null and b/notes/pics/red-packet-flow.png differ
diff --git a/notes/系统设计基础.md b/notes/系统设计基础.md
index ffa828dc..178d0e64 100644
--- a/notes/系统设计基础.md
+++ b/notes/系统设计基础.md
@@ -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-限流算法)):
+
+
+
+滑动窗口的代价是每个放行的请求都要存一条记录。上限为每秒 1000 次时,一个用户的 ZSET 约占 100 KB,而固定窗口的计数器只占 56 字节。折中的做法是把 1 秒分成若干小格,每格一个计数器,把落在窗口内的格子相加,格子越多越精确。
+
+漏桶把请求放进队列,以固定速率取出处理,例如每 200 毫秒一个,队列满了再来的请求被拒绝。它的输出最平滑,但突发的请求要排队等待。Nginx 的 limit_req 就是漏桶,burst 是允许排队的请求数,加上 nodelay 后 burst 以内的请求不再排队,而是立即处理。
+
+令牌桶以固定速率放入令牌,例如每秒 5 个,桶中最多存 5 个,请求取到令牌才放行。空闲一段时间后桶是满的,可以一次放行 5 个请求,之后每 200 毫秒放行一个。
+令牌桶不需要定时放入令牌,只要记下剩余的令牌数和上次更新的时间,请求到来时补上这段时间应放入的令牌,再判断够不够。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. 熔断
+
熔断器有关闭、打开和半开三种状态:失败率超过阈值时打开;打开一段时间后进入半开状态,放行少量请求试探,成功则关闭,失败则重新打开。
@@ -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 并发领取与落库
+
+
+
+领取用下面的 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 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 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)