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)