mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-11-06 21:34:09 +00:00
Update 系统设计基础.md
补充心跳与保活、限流的兜底、扩容时的数据库连接数、支付回调的验签、先写 Redis 再异步落库的风险、systemd 服务的文件描述符上限等说明,调整 Feed 拉模式的表述。
This commit is contained in:
+72
-24
@@ -104,7 +104,9 @@ I/O 多路复用用的是第二个原因:一个线程同时等待多个连接
|
||||
|
||||
## 四、可用性 🔥🔥
|
||||
|
||||
可用性用系统可用时间占总时间的比值来衡量,4 个 9 表示系统 99.99% 的时间是可用的,一年的不可用时间不超过 53 分钟。
|
||||
可用性用系统可用时间占总时间的比值来衡量,4 个 9 表示系统 99.99% 的时间是可用的,一年的不可用时间不超过 53 分钟。服务只有部分请求失败时,也常按请求计算,即成功的请求数除以总请求数。可用性的目标称为 SLO,1 − SLO 就是允许出错的预算:按请求计算,99.99% 允许 0.01% 的请求失败;按时间计算,30 天内允许约 4.3 分钟不可用。预算用完时应放慢发布,先处理稳定性问题。
|
||||
|
||||
服务依赖的几个关键依赖都要可用时,整体的可用性不会高于其中最低的那个;各自独立失败时,约等于各项相乘。例如服务自身 99.99%,依赖 3 个 99.9% 的服务,整体约为 99.69%。所以关键依赖的可用性要比服务本身高,或者用缓存、降级让它不再是关键依赖。
|
||||
|
||||
### 冗余
|
||||
|
||||
@@ -116,7 +118,9 @@ I/O 多路复用用的是第二个原因:一个线程同时等待多个连接
|
||||
|
||||
### 监控
|
||||
|
||||
对 CPU、内存、磁盘、网络等系统负载信息进行监控,当某个信息达到一定阈值时通知运维人员,从而在系统发生故障之前及时发现问题。
|
||||
对 CPU、内存、磁盘、网络等系统负载信息进行监控,当某个信息达到一定阈值时通知运维人员,从而在系统发生故障之前及时发现问题。除了资源的阈值,还要对错误率、延迟这类用户能感受到的指标告警:按时间计算,一年 3 次、每次 20 分钟的完全不可用就会超出 99.99% 的预算,发现和恢复得越快,可用性越高。
|
||||
|
||||
延迟的 P95、P99 这类分位数,不能把各实例的值直接平均作为整体的分位数。例如实例 A 处理了 9900 个请求,P99 为 29.9 ms,实例 B 只处理了 100 个慢请求,P99 为 866.8 ms,两者平均是 448.3 ms,而把全部 1 万个请求放在一起算,P99 只有 51.5 ms(模拟数据,见[附录 A27](#a27-分位数不能平均))。Prometheus 的 Summary 在客户端算好的分位数不能跨实例聚合(请求总数和耗时总和仍然可以);经典的 Histogram 记录落在各个区间(按上界累计)的请求数以及总数和总和,各实例使用相同的区间时,服务端把对应区间的计数相加后再估算分位数,误差取决于区间的划分。指标的标签也不能用用户 ID 这类取值很多的字段,每种标签组合都是一条新的时间序列。
|
||||
|
||||
### 限流、降级与熔断
|
||||
|
||||
@@ -195,7 +199,7 @@ return allowed
|
||||
|
||||
- 每次判断都多一次 Redis 往返,并且一个限流键只落在一个 Redis 节点上,一个键每秒约能判断 9 万~10 万次(Redis 7.4 实测,见[附录 A13](#a13-限流算法));全局总量的限流超过这个量级时,可以把令牌分到多个键,或者让每个实例一次领取一批令牌在本地使用;
|
||||
- 时间由各个实例传入时,实例之间的时钟要同步,也可以在脚本中用 Redis 的 TIME 命令取时间;
|
||||
- Redis 不可用时,可以退回到每个实例自己的单机限流,避免因为限流组件故障而拒绝所有请求。
|
||||
- Redis 不可用时,可以退回到每个实例自己的单机限流,单机阈值取总量除以实例数,避免因为限流组件故障而拒绝所有请求;短信验证码这类放过去代价很高的接口则相反,宁可暂时拒绝。
|
||||
|
||||
限流通常放在多个位置:网关按 IP、用户或接口限流,挡住大部分突发和恶意的流量;服务内部按接口或热点参数限流,例如 Sentinel 可以按并发线程数或 QPS 设置阈值,按 QPS 限流时可以选择直接拒绝、预热(Warm Up,放行的量逐渐升到阈值)或者匀速排队(漏桶)。
|
||||
|
||||
@@ -244,7 +248,7 @@ Sentinel 的半开状态只放行一个请求,这个请求成功就结束熔
|
||||
|
||||
熔断依赖超时:没有设置超时的调用只会一直等待,不会失败,熔断器也就无从统计,所以每个远程调用都要先设好超时,见 [OpenFeign](微服务.md#openfeign) 中的超时配置。
|
||||
|
||||
重试则要克制。只对幂等的操作、可能自行恢复的错误(例如超时、连接被拒绝、429)重试,次数要有上限,间隔按指数增长并加上随机抖动,响应带有 Retry-After 时按它等待;参数错误、余额不足这类重试也不会成功的错误直接失败。还要避免每一层都重试:调用链上 3 层各重试 3 次,一次用户操作在最底层最多产生 4 × 4 × 4 = 64 次请求。熔断器打开后,对这个依赖的重试也随之停止。
|
||||
重试则要克制。只对幂等的操作、可能自行恢复的错误(例如超时、连接被拒绝、429)重试,次数要有上限,间隔按指数增长并加上随机抖动,响应带有 Retry-After 时按它等待;参数错误、余额不足这类重试也不会成功的错误直接失败。还要避免每一层都重试:调用链上 3 层各重试 3 次,一次用户操作在最底层最多产生 4 × 4 × 4 = 64 次请求。反向代理也算一层,例如 Nginx 默认在连接出错或超时时把请求转给下一台后端(proxy_next_upstream error timeout),已经发给后端的 POST、LOCK、PATCH 请求除外。除了每个请求的次数上限,还可以限制重试占全部请求的比例,例如 Google SRE 书中只在重试占比低于 10% 时才重试,gRPC 的重试限流也用令牌桶实现类似的效果。熔断器打开后,对这个依赖的重试也随之停止。
|
||||
|
||||
#### 4. 降级
|
||||
|
||||
@@ -306,9 +310,10 @@ return 0 -- 可以发送
|
||||
|
||||
- 验证码保存在 Redis 中,5 分钟后过期,多个服务实例都能读到;
|
||||
- 输对后立即删除,一个验证码只能用一次;
|
||||
- 同一个验证码输错 5 次就作废,要重新发送。
|
||||
- 同一个验证码输错 5 次就作废,要重新发送;
|
||||
- 按手机号累计连续输错的次数,重新发送不清零、输对才清零,超过上限就暂时停用这个手机号的短信登录。
|
||||
|
||||
加上发送的限制,一个手机号每天最多能猜 50 次,猜中的概率只有 50 / 10<sup>6</sup> = 0.005%。
|
||||
加上发送的限制,一个手机号每天最多能猜 50 次,每天猜中的概率只有 50 / 10<sup>6</sup> = 0.005%;但攻击者每天都能换新的验证码再猜,连续一年约为 1.8%,所以还要有上面按手机号累计的限制。
|
||||
|
||||
开放给合作方的接口还要防止请求被篡改或者重放。重放指的是把截获的合法请求原样再发一次,例如把一次退款请求重复提交。做法是双方事先约定一个密钥,调用方把请求的方法、路径、查询参数、请求体和时间戳、随机数(nonce)拼在一起(漏掉的部分可以被改掉而签名不变),用密钥计算 HMAC 签名:
|
||||
|
||||
@@ -409,6 +414,8 @@ UPDATE goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0;
|
||||
|
||||
无论哪种写法,同一行的更新在数据库中都是串行执行的,单行能承受的写入量有上限,见[热点行](#1-热点行)。所以秒杀先用 Redis 挡住绝大部分请求,数据库的条件更新作为最后一道防线。
|
||||
|
||||
写完之后要用并发请求验证,而不是只看代码。[附录 A19](#a19-防止超卖的几种写法) 的每一轮都核对成功的请求数、剩余库存和订单数:正确的写法中三者满足“成功数 = 订单数 = 初始库存 − 剩余库存”,剩余库存不为负;需要一人一单时,还要按用户分组检查每人最多一个订单。只有几个请求时很难触发竞争,要像上面这样用几十个线程同时发起。
|
||||
|
||||
### Redis 与数据库的库存一致
|
||||
|
||||
库存同时保存在 Redis 和数据库中,两者无法在一个事务中同时修改,所以先要确定以谁为准:数据库中的库存和订单是最终结果,Redis 中的库存只是一道闸门,用来挡住注定失败的请求。因此可以接受 Redis 的库存暂时偏少(少卖,事后补回),但数据库不能超卖,数据库的条件更新始终保留。
|
||||
@@ -425,6 +432,8 @@ Redis 的主从复制是异步的,主节点宕机时,还没同步到从节
|
||||
|
||||
对账在活动结束后或定期进行:Redis 中的已购用户应当与数据库中的订单一一对应,只出现在 Redis 中的用户说明消息丢失了,补发消息或回补库存。需要注意的是,库存只在活动开始前从数据库一次性加载到 Redis,活动进行中不要再从数据库同步,否则还在队列中的消息会让两边的数字互相覆盖。
|
||||
|
||||
Redis 整体不可用时,一般直接快速失败或暂停活动,不把流量转给数据库:数据库单行的写入量远低于入口流量(见[热点行](#1-热点行)),而常用的分布式限流本身也依赖 Redis。确实要退回数据库扣减时,要先用各实例的本地限流把流量压到数据库能承受的量,并事先压测和演练。Redis 恢复后要先对账;故障期间退回过数据库扣减,或者无法确认 Redis 的数据是否完整时,先暂停入口,等这个商品的在途请求和排队的消息处理完,再按数据库的库存和订单重建 Redis。
|
||||
|
||||
### 同类场景:抢券、抢红包与抽奖
|
||||
|
||||
抢券、抽奖和抢红包也是大量用户在同一时刻争抢有限的资源,上面的限流、Redis 预扣、异步落库和数据库兜底都可以直接套用。下面只讲它们各自不同的部分。
|
||||
@@ -559,6 +568,8 @@ WHERE id = 8001 AND remain_num > 0 AND remain_amount >= 276;
|
||||
|
||||
这样做的关键是相同的 URL 一定落在同一个小文件中,所以每个文件单独统计出的次数就是这个 URL 的总次数。HashMap 占用的内存取决于文件中不同 URL 的数量,如果某个文件中不同的 URL 仍然太多,可以换一个哈希函数对它再次切分。
|
||||
|
||||
日志分散在多台机器上时也要先按 hash(url) 把记录发到对应的机器,再各自统计。每台机器只用自己的日志求出前 10 再合并是错的。例如求前 1 名:机器 1 上 a 出现 100 次、c 出现 60 次,机器 2 上 b 出现 100 次、c 出现 60 次,两台各自的第 1 名是 a 和 b,而总次数最多的是 c(120 次)。
|
||||
|
||||
### 两个大文件的交集
|
||||
|
||||
两个文件各有 50 亿个 URL,每个 URL 64 字节,找出两个文件共同的 URL。每个文件有 320 GB,用同一个哈希函数把两个文件各切成 1000 份,每份约 320 MB。相同的 URL 一定落在编号相同的一对小文件中,只需要依次把 a<sub>i</sub> 读入 HashSet,再逐行检查 b<sub>i</sub>。
|
||||
@@ -567,7 +578,7 @@ WHERE id = 8001 AND remain_num > 0 AND remain_amount >= 276;
|
||||
|
||||
### 去重与判断是否存在
|
||||
|
||||
数据是整数时可以用位图,每个整数只用 1 位表示是否出现。int 共有 2<sup>32</sup> 个取值,位图只需要 512 MB。
|
||||
数据是整数时可以用位图,每个整数只用 1 位表示是否出现。int 共有 2<sup>32</sup> 个取值,位图只需要 512 MB。用 int 数组实现时,把整数 n 当作无符号数,记在第 n >>> 5 个元素(即除以 32)的第 n & 31 位(即除以 32 的余数),例如 100 在第 3 个元素的第 4 位(都从 0 开始数)。
|
||||
|
||||
例如在 2 亿个整数中找出只出现一次的数,可以给每个整数分配 2 位:00 表示没出现,01 表示出现一次,10 表示出现多次,共需 1 GB。读完所有数据后扫描位图,取出状态为 01 的数。
|
||||
|
||||
@@ -578,6 +589,8 @@ WHERE id = 8001 AND remain_num > 0 AND remain_amount >= 276;
|
||||
1. 分段排序:每次读入 1 GB 数据,在内存中排序后写回磁盘,得到 100 个有序的文件;
|
||||
2. 多路归并:同时打开这 100 个文件,用大小为 100 的小顶堆,每次取出最小的元素写入结果文件,再从它所在的文件读入下一个元素。
|
||||
|
||||
归并时每个文件都要有一块读缓冲,还要留出写结果的缓冲,例如 100 个文件各 8 MB,共 800 MB。分段很多时每块缓冲就很小,磁盘要频繁地在文件之间跳着读,这时先把每几十个段归并成一个较大的有序文件,再对这些文件归并一趟。
|
||||
|
||||
如果数据的取值范围很小,例如 1 亿个 10000 到 20000 之间的整数,一共只有 10001 种取值,用长度为 10001 的数组统计每个值出现的次数,一次扫描就能完成排序,也就是计数排序。
|
||||
|
||||
## 八、短链系统
|
||||
@@ -590,7 +603,7 @@ WHERE id = 8001 AND remain_num > 0 AND remain_amount >= 276;
|
||||
|
||||
常见的方法有三种:
|
||||
|
||||
- 哈希:对长链接计算哈希(例如 MurmurHash),再把结果转成 Base62。同一个长链接总是得到同一个短码,但不同的长链接也可能得到同一个短码,冲突时要加上后缀重新计算;
|
||||
- 哈希:对长链接计算哈希(例如 MurmurHash),再把结果转成 Base62。同一个长链接总是得到同一个短码,但不同的长链接也可能得到同一个短码,而且冲突来得很早:6 位 Base62 共约 568 亿个,存 10 万条时出现至少一次冲突的概率约 8%,30 万条时约 55%。所以写入时要靠主键冲突检测,冲突时比较完整的长链接,相同就复用,不同就加上后缀重新计算;
|
||||
- 随机:随机生成 6 位字符,由唯一索引检查是否重复,重复就重新生成。已经用掉的短码越多越容易重复,例如已有 10 亿个短链时,新生成的短码约有 1.8% 的概率重复;
|
||||
- 发号器:先取一个全局唯一的自增 ID,再转成 Base62。不会冲突,短码也最短。
|
||||
|
||||
@@ -612,7 +625,8 @@ CREATE TABLE short_url (
|
||||
long_url VARCHAR(2048) NOT NULL,
|
||||
url_hash BINARY(16) NOT NULL, -- 长链接的 MD5,用于查找同一个长链接是否已经生成过
|
||||
expire_at DATETIME,
|
||||
KEY idx_url_hash (url_hash)
|
||||
KEY idx_url_hash (url_hash),
|
||||
KEY idx_expire_at (expire_at)
|
||||
);
|
||||
```
|
||||
|
||||
@@ -622,6 +636,8 @@ Base62 区分大小写,code 列要用区分大小写的排序规则。MySQL 8.
|
||||
|
||||
短链读多写少,热门链接的访问又很集中,适合用 Redis 缓存短码到长链接的映射。不存在的短码会穿透到数据库,可以缓存空值或者使用布隆过滤器,见 [缓存穿透](缓存.md#缓存穿透)。点击统计不要在跳转时同步写数据库,而是把点击事件写入消息队列,异步汇总。
|
||||
|
||||
设置了有效期的短链,跳转时先判断 expire_at,过期的不再跳转,返回失效提示;写缓存时让过期时间与 expire_at 对齐(例如 SET ... EXAT 时间戳),后台任务再按 expire_at 索引分批删除过期的记录。
|
||||
|
||||
## 九、Feed 流 🔥
|
||||
|
||||
Feed 流指的是把关注的人发布的内容按时间倒序合并后展示,例如朋友圈和微博的首页。每个用户有一个发件箱,保存自己发的帖子;难点在于怎样把几百个人的发件箱合成一个首页。
|
||||
@@ -630,18 +646,20 @@ Feed 流指的是把关注的人发布的内容按时间倒序合并后展示,
|
||||
|
||||
### 拉模式与推模式
|
||||
|
||||
- 拉模式(读扩散):打开首页时,读取所有关注者的发件箱,合并后取最新的一页。发帖只写一次,但关注的人越多读得越慢;
|
||||
- 拉模式(读扩散):打开首页时,读取自己关注的所有用户的发件箱,合并后取最新的一页。发帖只写一次,但关注的人越多读得越慢;
|
||||
- 推模式(写扩散):发帖时把帖子写入每个粉丝的收件箱,打开首页只需读自己的收件箱。读很快,但粉丝越多写得越多,而且每个粉丝都存一份帖子 ID。
|
||||
|
||||
用有序集合实现时(分值为发布时间),一个帖子推给 10 万个粉丝,用流水线写入 10 万个收件箱共约 480 ms,已有的收件箱每多一条约占 16 字节;读自己的收件箱一页只需 0.11 ms;拉模式从 500 个发件箱各取最新 20 条再合并,一次要 22.8 ms(Redis 7.4 实测,见[附录 A3](#a3-feed-流))。按这个速度,一个有 1000 万粉丝的大 V 发一条帖子,单个节点要写入约 50 秒;粉丝的收件箱都已存在时多占用约 150 MB 内存,需要新建收件箱时约 1 GB。
|
||||
|
||||
所以实际的系统大多推拉结合:
|
||||
粉丝数差别很大时,常用推拉结合:
|
||||
|
||||
- 粉丝少的普通用户使用推模式;
|
||||
- 粉丝很多的大 V 不推,粉丝打开首页时,从自己的收件箱和所关注的少数几个大 V 的发件箱中拉取,再合并;
|
||||
- 粉丝很多的大 V 不推,粉丝打开首页时,从自己的收件箱和所关注的少数几个大 V 的发件箱中拉取,再合并。大 V 的阈值没有通用的数值,取决于发帖与粉丝刷首页的频率之比,以及能接受的扇出时间,例如按上面的速度,阈值为 10 万粉丝时一次扇出约 0.5 秒;
|
||||
- 只推给最近活跃的粉丝,长期不登录的用户回来时再用拉模式重建收件箱;
|
||||
- 收件箱只保留最近的若干条,例如每次写入后执行 ZREMRANGEBYRANK inbox:1001 0 -1001,只留下分值最大的 1000 条,更早的内容到数据库中查。
|
||||
|
||||
按相关性排序也会影响推拉的取舍。例如 LinkedIn 的 FollowFeed 选择了读时扇出,理由是写扩散的数据量是拉模式的 62 倍,而且预先写好的收件箱要按排序模型的顺序保存,换一个排序模型做 A/B 测试时要重排所有用户的收件箱。
|
||||
|
||||
### 分页
|
||||
|
||||
Feed 流的分页不能用偏移量。例如收件箱中有 p1~p30 共 30 条帖子,第 1 页取到 p30~p21;用户翻页之前又有 3 条新帖子进来,这时按偏移量取第 11~20 条,结果是 p23~p14,p23、p22、p21 重复出现了。
|
||||
@@ -707,7 +725,9 @@ InnoDB 使用行级锁,并采用两阶段锁(Two-Phase Locking, 2PL),事
|
||||
|
||||
实测验证了这一点:对同一行执行自动提交的 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,远低于上限。
|
||||
这些数字在 macOS 上测得,macOS 的 fsync 只把数据交给磁盘,不要求磁盘清空自己的写缓存。Linux 服务器上的吞吐量要结合存储设备和配置重新测量,刷盘延迟更大时,提交的吞吐量可能更低;同一行的更新仍然受行锁限制,只能一个接一个地执行。
|
||||
|
||||
客户端与服务端在同一台机器上时,单条查询的往返约为 0.03 ms(见[附录 A6](#a6-同一台机器上单条查询的往返延迟)),生产环境的事务内则有多次网络往返。假设同机房往返为 0.5 ms,事务内有 3 次往返(查询并加锁、更新库存、插入订单),锁持有约 1.6 ms,单行上限约为 625 TPS,低于 800 QPS。解决办法是拆分库存:把 1 万件分成 10 行,每行 1000 件,每行约 80 QPS,远低于上限。
|
||||
|
||||
拆分后,某一行售罄而其它行仍有库存时,要换一行重试,并定期把库存在各行之间调拨:
|
||||
|
||||
@@ -790,6 +810,8 @@ JMeter、wrk、k6 等工具负责按设定的并发或速率发出请求,并
|
||||
4. 扩容:无状态的应用实例可以较快地加入集群。Redis 增加分片、数据库分库需要迁移数据,耗时较长,因此大促前要按 10 倍峰值提前扩容并压测;
|
||||
5. 复盘:把新的拐点和瓶颈层记入容量模型,作为下一次估算的依据。
|
||||
|
||||
扩容应用实例时还要核对数据库的连接数。每台实例都有自己的连接池,例如前文的 36 台各用 HikariCP 默认的最多 10 个连接,都连同一个 MySQL 时上限合计 360,超过了 MySQL 8.0 默认的 max_connections(151)。连接多了并不会让数据库处理得更快,要先按数据库的承载能力分配连接,并给其它服务和运维留出余量,再调整每台的连接池;能接受数据延迟的读请求交给缓存和从库,确认数据库有余量时才调高连接上限。
|
||||
|
||||
### 大模型服务的高并发
|
||||
|
||||
大模型推理的并发单位不是请求,而是 token。每个请求的输出长度不固定,服务能同时处理的请求数取决于显存中能放下多少键值缓存(KV Cache,见[自回归生成与 KV Cache](大模型应用.md#自回归生成与-kv-cache))。
|
||||
@@ -969,7 +991,7 @@ SELECT EXISTS (
|
||||
|
||||
- 前端:根据权限码显示菜单和按钮。这只改善使用体验,前端的判断可以被绕过,不能作为安全边界;
|
||||
- 服务端:通过拦截器或注解读取当前用户的权限码集合,与接口要求的权限码比较,不满足时返回 403。例如在接口方法上标注需要 order:create,调用前由框架完成检查;
|
||||
- 网关:一般只校验登录状态。接口需要哪个权限码由服务定义,网关不能替代服务端的检查。
|
||||
- 网关:校验登录状态,也可以做粗粒度的检查,例如按路径拒绝没有管理员角色的请求,前提是服务只接受经过网关的请求。涉及具体订单、租户的检查仍要在服务内做,网关不能替代服务端的检查。
|
||||
|
||||
服务端要对每次请求校验权限,并且默认拒绝:没有匹配的授权规则时,结果是拒绝,而不是放行。
|
||||
|
||||
@@ -980,7 +1002,7 @@ SELECT EXISTS (
|
||||
- 修改用户的角色:删除该用户的缓存;
|
||||
- 修改角色的权限:找出拥有该角色的全部用户并删除他们的缓存。用户很多时,可以改为给权限数据加版本号,角色变更时递增版本号,缓存中的版本号与之不一致时重新加载。
|
||||
|
||||
删除缓存与更新数据库之间的先后顺序问题见 [缓存一致性](缓存.md#缓存一致性)。权限码不要直接写入 JSON Web Token(JWT):已签发的 Token 不会随数据库变化,权限变更要等到 Token 过期才生效。Token 中通常只保存用户 ID,权限在服务端查询并缓存。
|
||||
删除缓存与更新数据库之间的先后顺序问题见 [缓存一致性](缓存.md#缓存一致性)。权限也可以写进 JSON Web Token(JWT)省去查询,代价是已签发的 Token 不会随数据库变化,权限变更要等到 Token 过期才生效,有效期短的访问令牌加刷新令牌可以缩短这个时间(见 [JWT](HTTP.md#11-jwt))。权限变更需要立即生效时,Token 中只保存用户 ID,权限在服务端查询并缓存。
|
||||
|
||||
#### 5. 动态权限
|
||||
|
||||
@@ -1151,6 +1173,8 @@ COMMIT;
|
||||
|
||||
这样点赞关系和点赞数不在一个事务中,可能出现关系写入成功、点赞数没有加上的情况。以点赞关系为准,点赞数是由它派生出来的:在插入点赞关系的同一个事务中写一条计数消息,由后台任务发送并重试,见[本地消息表](分布式.md#本地消息表)。重试可能让同一条消息到达两次,消费者按消息 ID 去重,用一个 Lua 脚本同时记下消息 ID 和增加计数,重复的消息不会多加;定期对热门视频重新统计 COUNT(*) 只作兜底。
|
||||
|
||||
另一种常见的写法是先在 Redis 中记录点赞,再经消息队列异步写入数据库。这时点赞关系还没有入库,如果 Redis 中的记录和待发的消息都丢失了(例如 AOF 每秒刷盘时宕机),数据库对账也无法恢复,所以这里选择先把点赞关系写入数据库。
|
||||
|
||||
#### 2. 读的压力
|
||||
|
||||
点赞数直接从 Redis 中读。“是否点过”按用户查询,一页 10 个视频用一条按 user_id 的查询就能查完,再缓存用户最近点过赞的视频,大部分请求不需要访问数据库。
|
||||
@@ -1205,7 +1229,9 @@ TCP 只保证一条连接内的数据不丢、有序,但连接会断开、服
|
||||
3. 推送给 1002 后,1002 回复已经收到的 seq;
|
||||
4. 1002 断线期间的第 59、60 条推送失败,但消息已经存储。1002 重连后带上自己收到的最大 seq 58,拉取之后的消息。收到第 61 条而本地只有第 58 条时,同样说明中间缺了消息,要按 seq 补拉。
|
||||
|
||||
所以推送只负责及时通知,消息是否收全以 seq 为准,离线消息就是用户上线后按 seq 拉取到的这部分消息。seq 只需要在会话内递增,可以用 Redis 的 INCR 为每个会话生成。需要注意的是,用 INCR 时分配序号和写入消息不是一个原子操作:写入失败会留下空号,后分配序号的消息也可能先写入,客户端看到 60 时 59 可能还没写完。要求序号连续时,可以在同一个事务中把会话的最大序号加 1 并插入消息,行锁让同一会话的消息串行写入,事务回滚时序号也一起回滚。
|
||||
所以推送只负责及时通知,消息是否收全以 seq 为准,离线消息就是用户上线后按 seq 拉取到的这部分消息。seq 只需要在会话内递增,可以用 Redis 的 INCR 为每个会话生成。
|
||||
|
||||
需要注意的是,用 INCR 时分配序号和写入消息不是一个原子操作:写入失败会留下空号,后分配序号的消息也可能先写入,客户端看到 60 时 59 可能还没写完,如果客户端据此把位置推进到 60,之后按 seq > 60 拉取就再也拿不到 59。要求序号连续时,可以在同一个事务中把会话的最大序号加 1 并插入消息,行锁让同一会话的消息串行写入,事务回滚时序号也一起回滚。
|
||||
|
||||
消息按会话保存,单聊和群聊都是一个会话:
|
||||
|
||||
@@ -1243,9 +1269,9 @@ CREATE TABLE conv_member (
|
||||
|
||||
### 心跳与重连
|
||||
|
||||
连接断开时两端不一定能收到通知,例如手机切换网络或者进入电梯,中间的 NAT 和代理也可能删除长时间没有数据的连接。TCP 自带的保活机制(keepalive)默认空闲 2 小时后才开始探测,每 75 秒探测一次、共 9 次,要 2 小时 11 分钟才能发现断线(Ubuntu 24.04 的默认值,见附录 A22);而且它由内核回复,只能说明对方的系统还在,不能说明对方的程序还能处理消息。所以应用层要自己发心跳:
|
||||
连接断开时两端不一定能收到通知,例如手机切换网络或者进入电梯,中间的 NAT 和代理也可能删除长时间没有数据的连接。TCP 自带的保活机制(keepalive)默认关闭,要在连接上用 SO_KEEPALIVE 打开(RFC 9293),打开后默认空闲 2 小时才开始探测,每 75 秒探测一次、共 9 次,要 2 小时 11 分钟才能发现断线(Ubuntu 24.04 的默认值,见附录 A22);而且它由内核回复,只能说明对方的系统还在,不能说明对方的程序还能处理消息。keepalive 也只在连接完全空闲时探测,有数据等待确认时对端消失,要靠重传超时发现,Linux 默认约 15 分钟以上,可以用 TCP_USER_TIMEOUT 缩短。所以应用层要自己发心跳:
|
||||
|
||||
- 客户端每隔一段时间(例如 30 秒)发送一个心跳包,服务端回复;
|
||||
- 客户端每隔一段时间(例如 30 秒)发送一个心跳包,服务端回复,间隔要小于链路上最短的空闲超时,例如 AWS ALB 和 Nginx 的 proxy_read_timeout 默认都是 60 秒,AWS NAT 网关为 350 秒;
|
||||
- 服务端记录每个连接最后一次收到数据的时间,超过 3 个心跳周期(例如 90 秒)没有收到就关闭连接,并删除路由表中的记录;
|
||||
- 客户端连续几次收不到回复,就认为连接已经断开,主动重连。
|
||||
|
||||
@@ -1261,7 +1287,7 @@ CREATE TABLE conv_member (
|
||||
|
||||
服务端的限制主要是:
|
||||
|
||||
- 文件描述符:每个连接占用一个,要调大 ulimit -n,见 [epoll](Socket.md#epoll);
|
||||
- 文件描述符:每个连接占用一个,要调大进程能打开的描述符数,在 shell 中用 ulimit -n,由 systemd 启动的服务不继承登录 shell 的设置,默认软限制 1024、硬限制 524288(systemd 240 起),要在 unit 文件中设置 LimitNOFILE,见 [epoll](Socket.md#epoll);
|
||||
- 内存:10 万个空闲连接,Java 进程的内存增加约 120 MB,内核中每个套接字约 4 KB,合计每个连接约 5 KB,100 万个连接约需 5 GB;
|
||||
- CPU 和带宽:取决于消息量,空闲的连接只需要处理心跳。
|
||||
|
||||
@@ -1330,6 +1356,8 @@ COMMIT;
|
||||
|
||||
同一笔通知并发到达 10 次,先查状态再更新的写法每轮发货 10 次,上面的写法每轮 1 次(见[附录 A23](#a23-超时关单与支付回调))。重复的通知也要应答成功,否则平台会继续重发。
|
||||
|
||||
以微信支付 API v3 为例,验签要用收到的原始报文,框架解析后再序列化的内容可能与原文不同;验签失败时返回 4xx 或 5xx,等待平台重发,微信支付还会故意发出少量错误签名的通知,检查商户是否真的验签。验签通过也只能说明通知来自平台,不能说明金额等于订单应付的金额,所以还要核对金额。
|
||||
|
||||
重复支付指一个订单被付了两次钱,常见的原因有两种:
|
||||
|
||||
- 用户连续点击支付按钮:每次都用订单号 1024 作为商户订单号向平台下单,平台上只有一笔交易,只能付一次;每次点击都换一个新单号的话,平台上就有多笔都能付款的交易。微信支付也要求再次发起支付时使用原单号;
|
||||
@@ -1510,7 +1538,7 @@ Benchmark
|
||||
|
||||
### A5 热点行与分散行的 UPDATE
|
||||
|
||||
环境同[附录 A4](#a4-mysql-主键点查的并发扫描)。表 `sku_stock` 有 100 行(`sku_id` 为 1 至 100,`sold` 为计数)。热点负载为:
|
||||
环境同[附录 A4](#a4-mysql-主键点查的并发扫描)(macOS 上 MySQL 8.0 刷盘调用的 fsync 不要求磁盘清空写缓存,见 fsync(2))。表 `sku_stock` 有 100 行(`sku_id` 为 1 至 100,`sold` 为计数)。热点负载为:
|
||||
|
||||
```sql
|
||||
UPDATE sku_stock SET sold = sold + 1 WHERE sku_id = 1;
|
||||
@@ -1527,9 +1555,9 @@ UPDATE sku_stock SET sold = sold + 1 WHERE sku_id = 1;
|
||||
|
||||
并发数从 1 增加到 64,热点行的吞吐量不增加,响应时间随并发数近似线性增长,说明同一行的更新是串行执行的;分散行的吞吐量则随并发数增加。
|
||||
|
||||
### A6 本机单条查询的往返延迟
|
||||
### A6 同一台机器上单条查询的往返延迟
|
||||
|
||||
单连接执行 `SELECT 1`(pymysql,unix socket,20000 次):平均 0.030 ms,中位数 0.030 ms,p99 0.048 ms。本机往返远小于同机房的网络往返,正文中的锁持有时间按假设的 0.5 ms 计算,不能用本机数据代替。
|
||||
单连接执行 `SELECT 1`(pymysql,unix socket,20000 次):平均 0.030 ms,中位数 0.030 ms,p99 0.048 ms。同一台机器上的往返远小于同机房的网络往返,正文中的锁持有时间按假设的 0.5 ms 计算,不能用这里的数据代替。
|
||||
|
||||
### A7 Redis GET 的并发扫描
|
||||
|
||||
@@ -1950,7 +1978,7 @@ return 0
|
||||
|
||||
内存用 MEMORY USAGE \<key> SAMPLES 0 测量:写入 1000 条记录的 ZSET(成员形如 req-1000000,分值为毫秒时间戳)为 102016 字节,多次运行在 101888~102384 字节之间;固定窗口的计数器为 56 字节,令牌桶的哈希表为 104 字节。
|
||||
|
||||
单个键的吞吐量用 redis-benchmark 测量(本机回环,50 个连接,每项 200000 次,同一个键),令牌桶脚本先用 SCRIPT LOAD 加载:
|
||||
单个键的吞吐量用 redis-benchmark 测量(客户端与服务器在同一台机器上,经回环地址,50 个连接,每项 200000 次,同一个键),令牌桶脚本先用 SCRIPT LOAD 加载:
|
||||
|
||||
```text
|
||||
redis-benchmark -n 200000 -c 50 -q INCR t-rl:bench:incr
|
||||
@@ -2370,7 +2398,7 @@ return 0 -- 输错,还可以再试
|
||||
== D 穷举验证码
|
||||
真实验证码 835769
|
||||
限制输错 5 次:前 10 次猜测的返回值 [0, 0, 0, 0, -2, -1, -1, -1, -1, -1] (0 输错,-2 作废,-1 已不存在)
|
||||
不限制输错次数:第 835770 次猜中 835769,用时 8.1 秒(本机,流水线每批 1000 次)
|
||||
不限制输错次数:第 835770 次猜中 835769,用时 8.1 秒(客户端与 Redis 在同一台机器上,流水线每批 1000 次)
|
||||
限制后的上限:每个验证码 5 次 × 每天 10 个验证码 = 50 次,每天猜中的概率 50 / 10^6 = 0.005%
|
||||
== E 签名与防重放
|
||||
签名原文: 'POST\n/api/refund\n1793000000\nn-8f3a2c\n{"order": 1024, "amount": 3900}'
|
||||
@@ -2700,13 +2728,33 @@ redis-cli shutdown nosave # 重新启动 redis-server 后再执行上一行
|
||||
|
||||
三次调用的结果不同,说明不是每次调用都把种子重设为同一个值;重启后得到同样的序列,说明初值是固定的。源码中 math.random 被替换为 redisLrand48,只有脚本调用 math.randomseed 时才重设种子(src/rand.c、src/script_lua.c)。
|
||||
|
||||
### A27 分位数不能平均
|
||||
|
||||
Python 3.13、numpy 2.3。两个实例的请求耗时(毫秒)按对数正态分布生成,比较“各实例 P99 的平均值”和“合并所有请求后的 P99”:
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
rng = np.random.default_rng(7)
|
||||
a = rng.lognormal(np.log(15), 0.3, 9900) # 实例 A:9900 个请求
|
||||
b = rng.lognormal(np.log(400), 0.4, 100) # 实例 B:100 个慢请求
|
||||
pa, pb = np.percentile(a, 99), np.percentile(b, 99)
|
||||
p = np.percentile(np.concatenate([a, b]), 99)
|
||||
print(f"A P99 {pa:.1f} B P99 {pb:.1f} 平均 {(pa + pb) / 2:.1f} 合并后 P99 {p:.1f}")
|
||||
```
|
||||
|
||||
```text
|
||||
A P99 29.9 B P99 866.8 平均 448.3 合并后 P99 51.5
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Prometheus: Histograms and summaries](https://prometheus.io/docs/practices/histograms/)
|
||||
- [Redis: Scripting with Lua](https://redis.io/docs/latest/develop/programmability/eval-intro/)
|
||||
- 李智慧. 大型网站技术架构:核心原理与案例分析 [M]. 电子工业出版社, 2013.
|
||||
- [Designing Data-Intensive Applications 中文版](https://github.com/vonng/ddia)
|
||||
- [RFC 9110: HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
|
||||
- [RFC 9111: HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111)
|
||||
- [RFC 9293: Transmission Control Protocol (TCP)](https://www.rfc-editor.org/rfc/rfc9293)
|
||||
- [Handling Overload(Google SRE 书,过载处理)](https://sre.google/sre-book/handling-overload/)
|
||||
- [Addressing Cascading Failures(Google SRE 书,级联故障)](https://sre.google/sre-book/addressing-cascading-failures/)
|
||||
- Beyer B, Jones C, Petoff J, Murphy N R. Site Reliability Engineering [M]. O'Reilly Media, 2016.
|
||||
|
||||
Reference in New Issue
Block a user