diff --git a/notes/pics/circuit-breaker.png b/notes/pics/circuit-breaker.png
index 0af779b2..f1ede929 100644
Binary files a/notes/pics/circuit-breaker.png and b/notes/pics/circuit-breaker.png differ
diff --git a/notes/pics/feed-page-cursor.png b/notes/pics/feed-page-cursor.png
new file mode 100644
index 00000000..10c4ae8f
Binary files /dev/null and b/notes/pics/feed-page-cursor.png differ
diff --git a/notes/pics/feed-push-pull.png b/notes/pics/feed-push-pull.png
index 54a8cc73..f6f75da7 100644
Binary files a/notes/pics/feed-push-pull.png and b/notes/pics/feed-push-pull.png differ
diff --git a/notes/pics/ratelimit-window.png b/notes/pics/ratelimit-window.png
index 38c10607..4261ec39 100644
Binary files a/notes/pics/ratelimit-window.png and b/notes/pics/ratelimit-window.png differ
diff --git a/notes/pics/red-packet-flow.png b/notes/pics/red-packet-flow.png
index 7f441af1..44d58e1f 100644
Binary files a/notes/pics/red-packet-flow.png and b/notes/pics/red-packet-flow.png differ
diff --git a/notes/pics/seckill-oversell.png b/notes/pics/seckill-oversell.png
new file mode 100644
index 00000000..2012b191
Binary files /dev/null and b/notes/pics/seckill-oversell.png differ
diff --git a/notes/pics/seckill.png b/notes/pics/seckill.png
index e91eb02a..2ab86e9a 100644
Binary files a/notes/pics/seckill.png and b/notes/pics/seckill.png differ
diff --git a/notes/pics/short-url.png b/notes/pics/short-url.png
index 3fe81ca3..c13aeca0 100644
Binary files a/notes/pics/short-url.png and b/notes/pics/short-url.png differ
diff --git a/notes/pics/upload-multipart.png b/notes/pics/upload-multipart.png
index d0841811..3c00d03b 100644
Binary files a/notes/pics/upload-multipart.png and b/notes/pics/upload-multipart.png differ
diff --git a/notes/系统设计基础.md b/notes/系统设计基础.md
index 685d3735..202dfb70 100644
--- a/notes/系统设计基础.md
+++ b/notes/系统设计基础.md
@@ -30,7 +30,7 @@
在对响应时间进行测试时,通常采用重复请求的方式,然后计算平均响应时间。平均值会掩盖少数很慢的请求,因此也常用 P99 等分位值来衡量。P99(也写作 TP99)指 99% 的请求的响应时间都不超过的值。
-一个用户请求通常需要调用多个后端服务,只要其中一个慢,整个请求就慢,后端调用越多,受慢请求影响的用户比例越高,这称为尾部延迟放大。
+一个用户请求通常需要调用多个后端服务,只要其中一个慢,整个请求就慢,后端调用越多,受慢请求影响的用户比例越高,这称为尾部延迟放大。例如每个服务都有 1% 的调用慢于自己的 P99,假设各次调用是否变慢相互独立,一个请求并行调用 10 个服务时,至少遇到一个慢调用的概率为 1 − 0.9910 ≈ 9.6%,调用 50 个服务时约为 39.5%。
#### 2. 吞吐量
@@ -42,12 +42,14 @@
三者的关系为:并发数 = 吞吐量 × 响应时间,三者都取平均值,在系统处于稳定状态时成立,称为利特尔法则(Little's Law)。在没有并发存在的系统中,请求被顺序执行,此时响应时间为吞吐量的倒数。例如系统支持的吞吐量为 100 req/s,那么平均响应时间应该为 0.01 s。
-目前的大型系统都支持多线程来处理并发请求,多线程能够提高吞吐量,主要有两个原因:
+这里的并发数指同时在处理的请求数。按在线用户估算时,要加上用户两次操作之间的思考时间 Z:在线用户数 = 吞吐量 ×(响应时间 + Z)。例如 1 万个在线用户平均每 10 秒操作一次、响应时间 0.1 秒,吞吐量约为 990 次/秒,同时在处理的请求只有约 99 个。
-- 多 CPU;
-- I/O 等待时间。
+多线程能够提高吞吐量,主要有两个原因:
-使用 I/O 多路复用等方式,系统在等待一个 I/O 操作完成的这段时间内不需要被阻塞,可以去处理其它请求。通过将这个等待时间利用起来,使得 CPU 利用率大大提高。
+- 多个 CPU 核心可以同时执行多个线程;
+- 一个线程等待 I/O(例如查询数据库)时,CPU 可以去执行其它线程。
+
+I/O 多路复用用的是第二个原因:一个线程同时等待多个连接上的 I/O,哪个就绪就处理哪个,不需要为每个连接准备一个线程,见 [I/O 复用](Socket.md#二io-复用)。
并发用户数不是越高越好,因为如果并发用户数太高,系统来不及处理这么多的请求,会使得过多的请求需要等待,那么响应时间就会大大提高。
@@ -55,7 +57,7 @@
#### 1. 集群
-将多台服务器组成集群,使用负载均衡将请求转发到集群中,避免单一服务器的负载压力过大导致性能降低。
+将多台服务器组成集群,使用负载均衡将请求转发到集群中,避免单一服务器的负载压力过大导致性能降低,见 [负载均衡](集群.md#一负载均衡)。
#### 2. 缓存
@@ -65,9 +67,11 @@
- 缓存数据可以位于靠近用户的地理位置上;
- 可以将计算结果进行缓存,从而避免重复计算。
+缓存的位置和常见问题见 [缓存](缓存.md)。
+
#### 3. 异步
-某些流程可以将操作转换为消息,将消息发送到消息队列之后立即返回,之后这个操作会被异步处理。
+某些流程可以将操作转换为消息,将消息发送到消息队列之后立即返回,之后这个操作会被异步处理。例如大促下单先写入消息队列,订单服务按自己的处理能力消费,见 [削峰](#2-削峰)。
## 二、伸缩性
@@ -86,7 +90,7 @@
关系型数据库通常先通过主从复制实现读写分离来分担读压力,再通过 Sharding 将数据按一定的规则分布到不同的节点上,从而解决单台存储服务器的存储空间限制。
-对于非关系型数据库,它们天生就是为海量数据而诞生,对伸缩性的支持特别好。
+很多非关系型数据库内置了分片和复制,例如 Redis Cluster 按哈希槽把数据分到多个节点,增加节点时迁移部分槽即可,不需要应用自己切分数据。
## 三、扩展性
@@ -125,17 +129,17 @@
限流要判断的是:最近一段时间已经放行了多少请求,下一个请求还能不能放行。下面都以“每个用户每秒最多下单 5 次”为例。
-固定窗口(计数器)把时间切成 1 秒的窗口,每个窗口一个计数器,超过 5 次就拒绝。用 Redis 实现时,键名带上用户和秒数,例如 order:limit:1001:1700000001,INCR 返回 1 时再设置过期时间,Redis 文档中 INCR 一节给出了这种做法。
+固定窗口(计数器)把时间切成 1 秒的窗口,每个窗口一个计数器,超过 5 次就拒绝。用 Redis 实现时,键名带上用户和秒数,例如 order:limit:1001:1700000001,INCR 返回 1 时再设置过期时间。
-固定窗口的问题在窗口边界。0.9 秒和 1.1 秒各来 5 个请求,分别落在两个窗口中,都能通过,0.24 秒内放行了 10 次,是上限的 2 倍。
+固定窗口的问题在窗口边界。0.90~0.94 秒和 1.10~1.14 秒各来 5 个请求,分别落在两个窗口中,都能通过,0.24 秒内放行了 10 次,是上限的 2 倍。
-滑动窗口统计的是“当前时刻往前 1 秒”内放行的请求数,窗口随时间移动。可以用有序集合(ZSET)记录每个放行的请求:成员是唯一的请求 ID,分值是毫秒时间戳;新请求到来时,先删除 1 秒以前的记录,剩下的不足 5 条才放行并写入。
+滑动窗口统计的是“当前时刻往前 1 秒”内放行的请求数,窗口随时间移动。可以用有序集合(ZSET)记录每个放行的请求:成员是唯一的请求 ID,分值是毫秒时间戳;新请求到来时,先删除 1 秒以前的记录(ZREMRANGEBYSCORE),剩下的不足 5 条(ZCARD)才放行并写入(ZADD)。
同样的请求,1.1 秒时往前 1 秒已有 5 次,后 5 个请求被拒绝;1.95 秒时 0.9 秒的记录已经移出窗口,请求通过(Redis 7.4 实测,见[附录 A13](#a13-限流算法)):
-滑动窗口的代价是每个放行的请求都要存一条记录。上限为每秒 1000 次时,一个用户的 ZSET 约占 100 KB,而固定窗口的计数器只占 56 字节。折中的做法是把 1 秒分成若干小格,每格一个计数器,把落在窗口内的格子相加,格子越多越精确。
+滑动窗口的代价是每个放行的请求都要存一条记录。上限为每秒 1000 次时,一个用户的 ZSET 约占 100 KB,而固定窗口的计数器只占 56 字节。折中的做法是把 1 秒分成若干小格,每格一个计数器,把落在窗口内的格子相加,格子越多越精确,Sentinel 统计 QPS 用的就是这种方法。
漏桶把请求放进队列,以固定速率取出处理,例如每 200 毫秒一个,队列满了再来的请求被拒绝。它的输出最平滑,但突发的请求要排队等待。Nginx 的 limit_req 就是漏桶,burst 是允许排队的请求数,加上 nodelay 后 burst 以内的请求不再排队,而是立即处理。
@@ -158,7 +162,9 @@
服务部署了多个实例时,只在每个实例的内存中计数是不够的:4 个实例各限 5 次/秒,总量就变成 20 次/秒,扩容后还会变化;同一个用户的请求被分到不同实例,单个实例看到的次数也不准。因此要把计数放到所有实例共享的 Redis 中。
-判断和扣减必须是原子的,否则两个请求可能同时读到还剩 1 个令牌,都被放行。做法是用 Lua 脚本,令牌数和更新时间存在一个哈希表中:
+判断和扣减必须是原子的。令牌桶要先读出令牌数、计算后再写回,几条命令之间可能插入其它请求的命令:桶中有 5 个令牌时同时来 50 个请求,先 HMGET 再 HSET 的写法把 50 个全部放行(见[附录 A13](#a13-限流算法))。
+
+WATCH 加 MULTI 也能保证只放行 5 个,但冲突时要反复重试,因此一般用 Lua 脚本,脚本执行期间不会插入其它命令。令牌数和更新时间存在一个哈希表中:
```lua
-- KEYS[1] 限流键;ARGV:每秒放入的令牌数、桶的容量、当前时间(毫秒)
@@ -168,8 +174,10 @@ 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
+-- 请求的时间戳可能乱序,时间不能倒退,否则同一段时间会被重复补令牌
+now = math.max(now, ts)
-- 不用定时放令牌,按距离上次请求的时间补上这段时间应放入的令牌
-tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate / 1000)
+tokens = math.min(capacity, tokens + (now - ts) * rate / 1000)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
@@ -188,26 +196,33 @@ return allowed
- 时间由各个实例传入时,实例之间的时钟要同步,也可以在脚本中用 Redis 的 TIME 命令取时间;
- Redis 不可用时,可以退回到每个实例自己的单机限流,避免因为限流组件故障而拒绝所有请求。
-限流通常放在多个位置:网关按 IP、用户或接口限流,挡住大部分突发和恶意的流量;服务内部按接口或热点参数限流,例如 Sentinel 可以按 QPS 或并发线程数设置阈值,超过后直接拒绝、预热(Warm Up)或者匀速排队。阈值来自压测得到的容量,并留出余量,见[压测与容量规划](#压测与容量规划)。
+限流通常放在多个位置:网关按 IP、用户或接口限流,挡住大部分突发和恶意的流量;服务内部按接口或热点参数限流,例如 Sentinel 可以按并发线程数或 QPS 设置阈值,按 QPS 限流时可以选择直接拒绝、预热(Warm Up,放行的量逐渐升到阈值)或者匀速排队(漏桶)。
-被限流的请求返回 429 Too Many Requests,可以带上 Retry-After 首部告诉客户端多久之后重试(RFC 6585)。
+只按接口限制总量时,一个刷接口的调用方就能用完额度,正常用户也跟着被拒绝,因此还要按 IP 或用户分别限制。同一个出口 IP 后面可能是整个公司或学校,登录后的接口按用户限流更准确。
+
+被限流的请求返回 429 Too Many Requests,可以带上 Retry-After 首部告诉客户端多久之后重试。
+
+阈值来自压测得到的容量,并留出余量,见[压测与容量规划](#压测与容量规划);规则放在配置中心,不用重启就能调整。下游的容量会变化时,例如调用第三方接口,可以按响应自动调整:收到 429 或延迟明显升高就按比例降低速率,恢复后再缓慢增加,与 [TCP 拥塞控制](计算机网络%20-%20传输层.md#tcp-拥塞控制) 的思路相同。
#### 3. 熔断
-熔断器有关闭、打开和半开三种状态:失败率超过阈值时打开;打开一段时间后进入半开状态,放行少量请求试探,成功则关闭,失败则重新打开。
+熔断器有关闭、打开和半开三种状态:失败率达到阈值时打开;打开一段时间后进入半开状态,只放行少量请求试探,因为刚恢复的下游可能还承受不住全部流量;试探的失败率低于阈值则关闭,否则重新打开。
-熔断要解决的问题是下游变慢时调用方被拖垮。例如库存服务卡住,订单服务调用它的读取超时为 2 秒、每秒调用 200 次,按 Little 定律,约有 200 × 2 = 400 个线程同时在等待。线程池耗尽后,不依赖库存的接口也无法响应,故障就沿调用链传到了上游。熔断器发现下游持续失败后直接拒绝调用,调用方立即执行降级逻辑,线程不再被占住,下游也得到了恢复的时间。
+熔断要解决的问题是下游变慢时调用方被拖垮。例如库存服务卡住,订单服务调用它的读取超时为 2 秒、每秒调用 200 次,按[利特尔法则](#3-并发用户数),约有 200 × 2 = 400 个线程同时在等待。线程池耗尽后,不依赖库存的接口也无法响应,故障就沿调用链传到了上游,称为服务雪崩(级联故障)。熔断器发现下游持续失败后直接拒绝调用,调用方立即执行降级逻辑,线程不再被占住,下游也得到了恢复的时间。熔断打开之前,慢调用仍会占住线程,因此还要给每个依赖单独的线程池或并发数上限(隔离,例如 Resilience4j 的 Bulkhead),库存服务再慢也只占满自己的那一份。
是否打开由最近一段时间的调用统计决定。以 Resilience4j(Spring Cloud Circuit Breaker 常用的实现)为例,主要的参数和默认值:
- 统计窗口:最近 100 次调用,也可以改为最近若干秒;
- 最小调用数:窗口内至少有 100 次调用才计算失败率,避免刚启动时 1 次失败就算作 100% 的失败率;
- 阈值:失败率达到 50% 时打开,也可以按慢调用的比例打开,下游没有报错、只是变慢时也能熔断;
+- 计入失败的异常:默认所有异常都算失败,参数错误、商品不存在这类业务异常说明下游工作正常,要用 ignoreExceptions 排除,否则一批非法请求就能打开熔断器,连正常的请求也被拒绝(见[附录 A18](#a18-熔断器的状态变化));
- 打开时长:60 秒,之后进入半开状态;
- 半开时放行的请求数:10 个,这些试探请求的失败率仍达到阈值就重新打开,否则关闭,其余请求在试探完成前仍被拒绝。
+阈值参照依赖平时的表现设定:失败率阈值要明显高于平时的错误率,慢调用的时长要高于下游正常时的 P99,避免偶发的抖动打开熔断器。熔断器打开时要告警,降级的结果会掩盖故障,不告警可能长时间没人发现。
+
Sentinel 的半开状态只放行一个请求,这个请求成功就结束熔断,失败则再次熔断。
例如把统计窗口改为最近 10 次、最小调用数 10、打开时长 2 秒、半开时放行 3 个,库存服务从第 5 次调用开始超时,每次调用的结果如下(Resilience4j 2.2 实测,见[附录 A18](#a18-熔断器的状态变化)):
@@ -228,11 +243,11 @@ Sentinel 的半开状态只放行一个请求,这个请求成功就结束熔
熔断依赖超时:没有设置超时的调用只会一直等待,不会失败,熔断器也就无从统计,所以每个远程调用都要先设好超时,见 [OpenFeign](微服务.md#openfeign) 中的超时配置。
-重试则要克制。只对幂等的操作、可能自行恢复的错误(例如超时、连接被拒绝)重试,次数要有上限,间隔按指数增长并加上随机抖动;参数错误、余额不足这类重试也不会成功的错误直接失败。还要避免每一层都重试:调用链上 3 层各重试 3 次,一次用户操作在最底层最多产生 4 × 4 × 4 = 64 次请求([Addressing Cascading Failures](https://sre.google/sre-book/addressing-cascading-failures/))。熔断器打开后,对这个依赖的重试也随之停止。
+重试则要克制。只对幂等的操作、可能自行恢复的错误(例如超时、连接被拒绝、429)重试,次数要有上限,间隔按指数增长并加上随机抖动,响应带有 Retry-After 时按它等待;参数错误、余额不足这类重试也不会成功的错误直接失败。还要避免每一层都重试:调用链上 3 层各重试 3 次,一次用户操作在最底层最多产生 4 × 4 × 4 = 64 次请求。熔断器打开后,对这个依赖的重试也随之停止。
#### 4. 降级
-降级指的是在资源不足或依赖故障时,用代价更低的结果代替完整的结果,以保住核心功能。限流和熔断决定的是“什么时候不再接收、不再调用”,降级决定的是“这时返回什么”。
+降级指的是在资源不足或依赖故障时,用代价更低的结果代替完整的结果,以保住核心功能。
常见的降级做法:
@@ -246,7 +261,7 @@ Sentinel 的半开状态只放行一个请求,这个请求成功就结束熔
哪些功能可以降级、降级成什么样,要在设计时和业务方一起确定。例如商品详情页依赖商品、价格、库存、评论、推荐五个服务:商品和价格是核心,失败时返回错误;库存失败时显示“有货”;评论和推荐失败时不显示这一块。
-大模型应用中也是同样的思路:模型接口超时或被限流时,切换到备用的模型或更小的模型,或者返回缓存的回答;Agent 调用工具超时时,把“工具不可用”作为工具的结果返回给模型,由模型换一种方式回答或如实告知用户,而不是让整个请求失败。
+大模型应用中也是同样的思路:模型接口超时或被限流时,切换到备用的模型或更小的模型,或者返回缓存的回答;RAG 的重排服务超时时,跳过重排,直接用召回结果的前几条;Agent 调用工具超时时,把“工具不可用”作为工具的结果返回给模型,由模型换一种方式回答或如实告知用户,而不是让整个请求失败。模型调用的熔断见[稳定性](大模型应用.md#稳定性),Agent 的步数上限见[可靠性与评测](大模型应用.md#可靠性与评测)。
需要注意的是,降级逻辑平时很少执行,容易到真正需要时才发现它本身有问题,例如兜底的缓存早已过期、开关关闭后功能没有恢复。因此降级方案要和正常功能一样测试,并在故障演练中实际打开一次。
@@ -264,7 +279,7 @@ Sentinel 的半开状态只放行一个请求,这个请求成功就结束熔
- 同一个 IP 每小时最多 20 次,防止一台机器换着手机号刷;
- 超过阈值的设备或账号,先要求完成图形或滑块验证码。
-判断和计数要原子地完成,否则并发的请求会同时通过检查,所以用一个 Lua 脚本实现:
+判断和计数要原子地完成,否则并发的请求会同时通过检查,所以用一个 Lua 脚本实现(单个 Redis 实例;Redis Cluster 中脚本操作的键必须在同一个槽,手机号和 IP 的计数要拆成两个脚本分别判断):
```lua
-- KEYS[1]:手机号的冷却键,KEYS[2]:手机号当天的计数,KEYS[3]:IP 这一小时的计数
@@ -323,7 +338,7 @@ return 0 -- 可以发送
-1. 前端和网关:开售前按钮置灰,提交后禁止重复点击,网关按用户和接口限流,拦截刷单请求;
+1. 前端和网关:商品详情页静态化后放在 CDN 上,开售前的刷新大多由 CDN 返回;开售前按钮置灰,下单地址到点才下发,提交后禁止重复点击;网关按用户和接口[限流](#1-限流算法),拦截刷单请求;
2. 库存预扣:开售前把库存加载到 Redis 中,用 Lua 脚本在 Redis 中判断并扣减库存,没有库存的请求直接返回已抢完;
3. 异步下单:扣减成功的请求写入消息队列,订单服务按自己的处理能力消费,扣减数据库中的库存并创建订单,用户通过轮询等方式查询结果;
4. 隔离:秒杀服务独立部署,避免影响正常的业务。
@@ -335,7 +350,7 @@ return 0 -- 可以发送
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return 2 -- 一人一单,重复购买
end
-if tonumber(redis.call('GET', KEYS[1])) <= 0 then
+if tonumber(redis.call('GET', KEYS[1]) or '0') <= 0 then
return 1 -- 库存不足
end
redis.call('DECR', KEYS[1])
@@ -345,24 +360,38 @@ return 0 -- 抢购成功
Redis 执行 Lua 脚本时不会穿插执行其它命令,判断是否已购、判断库存、扣减库存和记录用户这四步作为一个整体完成。如果在应用中先 GET 库存、判断大于 0 之后再 DECR,两个请求可能同时读到库存为 1,并且都扣减成功。例如库存为 100、1000 个用户各请求 2 次、64 个线程并发:Lua 脚本恰好卖出 100 件;先查询后扣减的写法运行 3 次,分别卖出了 113、106、114 件,库存变成了负数(Redis 7.4 实测,见[附录 A1](#a1-秒杀))。
+不用 Lua 时,可以利用单条命令的原子性:先 SADD 已购集合,返回 0 说明买过;再 DECR 库存,返回值小于 0 说明没货,INCR 加回并 SREM 撤销。这样也恰好卖出 100 件,但没抢到的用户同时发出的另一个请求可能看到还没撤销的占位,被告知已经买过(见附录 A1);应用在两步之间宕机还会留下残留。只用 MULTI 事务时不能根据读到的库存决定是否扣减,见 [Lua 脚本](Redis.md#lua-脚本)。
+
+库存键本身也是热点。一个商品的库存只有一个键,只能落在一个 Redis 节点上,集群的节点再多也分担不了;对单个库存键执行上面的脚本,每秒约 7.5 万~8.6 万次(见附录 A1)。请求量超过这个量级时,常用的做法有:
+
+- 网关按库存放行:100 件商品只放前 1 万个请求进入预扣,其余直接返回已抢完;
+- 进程内售罄标记:脚本返回库存不足后,应用实例在内存中记下这个商品已售罄,之后的请求不再访问 Redis,回补库存时清除标记;
+- 库存分桶:把库存分到 10 个键,按用户 ID 选桶,所有桶都为 0 才算售罄,与数据库[拆分库存行](#1-热点行)是同一个思路。用户换桶时,重复购买由订单表的唯一索引兜底。
+
+脚本同时操作两个键,在 Redis 集群中要用哈希标签让它们落在同一个槽,例如 {sku:1001}:stock 和 {sku:1001}:users。分桶时每个桶用自己的标签,例如 {sku:1001:3},否则所有桶仍在同一个节点上。
+
数据库也要兜底。扣减库存时把库存大于 0 作为条件,影响的行数为 0 说明库存不足:
```sql
UPDATE goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0;
```
-一人一单可以用订单表上(用户 ID,商品 ID)的唯一索引保证,消息被重复消费时插入失败,也就实现了幂等。Redis 中扣减成功、但订单最终创建失败时,需要把 Redis 中的库存和购买记录恢复,并定期与数据库对账,做法见下文。
+一人一单可以用订单表上(用户 ID,商品 ID)的唯一索引保证,消息被重复消费时插入失败,也就实现了幂等。前提是扣减数据库库存和插入订单在同一个事务中,插入失败时整个事务回滚,库存不会被多扣。
### 防止超卖的几种做法
超卖的根源是“判断库存”和“扣减库存”不是一个原子操作:两个请求都读到库存为 1,都判断有货,都去扣减。所以防止超卖的做法都是让判断和扣减成为一个整体,区别在于在哪里做、代价是什么。
+
+
在数据库中有三种写法:
- 悲观锁:在事务中先用 SELECT ... FOR UPDATE 锁住库存行,再判断和扣减,其它请求在锁上排队;
- 乐观锁:表中加一个版本号,UPDATE 时带上条件 version = 读到的值,影响行数为 0 说明这一行已被别人修改,放弃或重试;
- 条件更新:把判断写进 UPDATE 的条件 stock > 0,判断和扣减由一条语句在行锁的保护下完成。
+也可以在应用中加锁。synchronized 这类本地锁只在一个进程内有效,多实例部署时无效;分布式锁能保证正确,但同一商品的请求全部排队,见[分布式锁的对比](分布式.md#三种实现的对比)。一人一单按用户加锁冲突很少,可以使用,但锁可能因过期或主从切换失效,最终仍以唯一索引为准。
+
用 64 个线程模拟抢购库存为 100 的商品,每个用户请求 1 次,每种写法运行 3 轮(MySQL 8.0 实测,见[附录 A19](#a19-防止超卖的几种写法)):
```text
@@ -385,11 +414,13 @@ UPDATE goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0;
按请求经过的步骤,可能出现的不一致和处理方法:
-- Lua 脚本扣减成功,发送消息失败:发送失败时立即执行一段反向的脚本,把库存加回、把用户移出已购集合;应用恰好在两步之间宕机时,这次扣减只留在 Redis 中,由对账发现。也可以用 RocketMQ 的[事务消息](分布式.md#本地消息表),先发送半消息,执行 Lua 脚本后再提交,应用宕机时由 Broker 回查用户是否在已购集合中;
-- 消息已发出,订单服务创建订单失败:数据库库存不足、用户校验不通过这类业务上的失败,订单服务记录失败结果并回补 Redis;网络超时、数据库暂时不可用这类异常则依靠消息重试,订单表的唯一索引保证重试不会创建两个订单;
+- Lua 脚本扣减成功,发送消息失败:明确返回失败时立即执行一段反向的脚本,把库存加回、把用户移出已购集合;发送超时的消息可能已经送达,不能直接回补,交给对账判断;应用恰好在两步之间宕机时,这次扣减只留在 Redis 中,由对账发现。也可以用 RocketMQ 的[事务消息](分布式.md#本地消息表),先发送半消息,执行 Lua 脚本后再提交,应用宕机时由 Broker 回查用户是否在已购集合中;
+- 消息已发出,订单服务创建订单失败:用户校验不通过这类业务上的失败,订单服务记录失败结果并回补 Redis;网络超时、数据库暂时不可用这类异常则依靠消息重试,订单表的唯一索引保证重试不会创建两个订单;
- 订单创建后用户未支付:超时取消订单时,在同一个事务中修改订单状态并把数据库的库存加回,提交后再回补 Redis,超时取消可以用[延迟消息](消息队列.md#延迟消息)实现。
-回补 Redis 时要防止重复执行,例如在脚本中先 SREM 已购集合,返回 1(用户确实在集合中)才把库存加 1,同一条回补消息执行两次也只加一次。
+回补 Redis 时要防止重复执行,例如在脚本中先 SREM 已购集合,返回 1(用户确实在集合中)才把库存加 1,同一条回补消息执行两次也只加一次。如果回补后允许用户再抢一次,迟到的重复回补消息会删掉新的购买记录,这时已购记录要保存这次抢购的请求 ID,回补时请求 ID 一致才执行。
+
+Redis 的主从复制是异步的,主节点宕机时,还没同步到从节点的扣减和购买记录会丢失,切换后多放行的请求在数据库的条件更新或唯一索引处失败,不会超卖。其中因数据库库存不足而失败的(先确认这个用户没有订单,排除重复投递的消息),只把用户移出已购集合,不加回 Redis 的库存,否则加回的库存又会放行新的请求。
对账在活动结束后或定期进行:Redis 中的已购用户应当与数据库中的订单一一对应,只出现在 Redis 中的用户说明消息丢失了,补发消息或回补库存。需要注意的是,库存只在活动开始前从数据库一次性加载到 Redis,活动进行中不要再从数据库同步,否则还在队列中的消息会让两边的数字互相覆盖。
@@ -401,7 +432,7 @@ UPDATE goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0;
券的发放总量就是库存,预扣的 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 删除。
+每人限领 N 张时,把脚本中的已购用户集合换成 Hash(用户 ID → 已领张数),已领张数小于 N 才扣库存并 HINCRBY,返回值就是“第几张”。例如券 501 发 100 张、每人限领 2 张,80 个用户各并发请求 5 次:恰好发出 100 张,没有人超过 2 张(Redis 7.4 实测,见[附录 A17](#a17-抢券的限领与核销))。领取的人很多时这个 Hash 会成为[大 Key](缓存.md#大-key-与热-key),活动结束后要设置过期时间或用 UNLINK 删除。
领到的券写入用户券表,以“第几张”作为 seq,在(user_id, coupon_id, seq)上建唯一索引,消息重复投递时插入失败,不会多发:
@@ -568,15 +599,15 @@ Base62 指的是用 0~9、a~z、A~Z 共 62 个字符表示一个数。例
### 跳转用 301 还是 302
-301 表示永久跳转,响应默认可以被缓存,浏览器之后会直接跳到长链接;302 表示临时跳转,每次访问都会请求短链服务。例如用浏览器访问同一个短链两次,返回 301 时,第二次直接跳到了长链接,服务端只收到一次请求;返回 302 时,两次都请求了服务端(Edge 156 实测,见[附录 A2](#a2-短链跳转))。
+301 表示永久跳转,响应没有缓存指令时浏览器也可以缓存,之后直接跳到长链接;302 表示临时跳转,默认不缓存,每次访问都会请求短链服务。是否缓存最终由 Cache-Control 等首部决定,302 带上 max-age 同样会被缓存。例如用浏览器访问同一个短链两次,响应都不带缓存首部:返回 301 时,第二次直接跳到了长链接,服务端只收到一次请求;返回 302 时,两次都请求了服务端(Edge 156 实测,见[附录 A2](#a2-短链跳转))。
-需要统计点击量、允许修改目标地址或者设置有效期时用 302;只想减轻服务端的压力时可以用 301。
+需要统计点击量、允许修改目标地址或者设置有效期时用 302,并加上 Cache-Control: private, no-store,避免被浏览器和 CDN 缓存;只想减轻服务端的压力时可以用 301,代价是已缓存的跳转无法撤回。
### 存储与缓存
```sql
CREATE TABLE short_url (
- code VARCHAR(8) PRIMARY KEY,
+ code VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
long_url VARCHAR(2048) NOT NULL,
url_hash BINARY(16) NOT NULL, -- 长链接的 MD5,用于查找同一个长链接是否已经生成过
expire_at DATETIME,
@@ -584,6 +615,8 @@ CREATE TABLE short_url (
);
```
+Base62 区分大小写,code 列要用区分大小写的排序规则。MySQL 8.0 默认的 utf8mb4_0900_ai_ci 不区分大小写,6LAze 和 6laze 会被当成同一个主键,第二个插入时报错 Duplicate entry。
+
长链接最长可能有几千个字符,直接在 long_url 上建唯一索引会报错 Specified key was too long(InnoDB 索引最长 3072 字节),所以另存一列 16 字节的 MD5 建索引,查到后再比较完整的长链接。
短链读多写少,热门链接的访问又很集中,适合用 Redis 缓存短码到长链接的映射。不存在的短码会穿透到数据库,可以缓存空值或者使用布隆过滤器,见 [缓存穿透](缓存.md#缓存穿透)。点击统计不要在跳转时同步写数据库,而是把点击事件写入消息队列,异步汇总。
@@ -619,7 +652,9 @@ Feed 流的分页不能用偏移量。例如收件箱中有 p1~p30 共 30 条
p20 p19 p18 ... p11
```
-左括号表示不包含 21 本身,这是 Redis 6.2 起的写法,相当于旧的 ZREVRANGEBYSCORE。分值相同的帖子可能被跳过,可以把分值设为帖子 ID 这类唯一且递增的值,例如雪花算法生成的 ID 本身就按时间递增,见 [分布式 ID](分布式.md#八分布式-id)。
+
+
+左括号表示不包含 21 本身,这是 Redis 6.2 起的写法,相当于旧的 ZREVRANGEBYSCORE。分值相同的帖子可能被跳过,所以分值最好唯一。需要注意的是,ZSET 的分值是双精度浮点数,只能精确表示 253 以内的整数,64 位的雪花 ID(见 [分布式 ID](分布式.md#八分布式-id))不能直接作为分值:260 和 260 + 1 写入后分值相同(Redis 7.4 实测)。可以用毫秒时间戳乘 1000 再加上这一毫秒内的序号,约 1.8 × 1015,小于 253 ≈ 9 × 1015;或者游标同时记下分值和帖子 ID,按包含边界的分值查询,再跳过已经返回的帖子。
## 十、高并发系统的设计 🔥🔥
@@ -629,9 +664,9 @@ p20 p19 p18 ... p11
### 容量估算
-估算的第一步是把日流量换算成峰值 QPS。假设日 PV 为 6 亿,平均每秒约 6944 次;晚高峰一小时的 PV 占全天的 6%,峰值约为 6 亿 × 6% ÷ 3600 ≈ 1 万 QPS。大促期间流量是平时的 10 倍,设计目标为 10 万 QPS。
+估算的第一步是把日流量换算成高峰时的 QPS。假设日 PV 为 6 亿,平均每秒约 6944 次;晚高峰一小时的 PV 占全天的 6%,这一小时的平均 QPS 约为 6 亿 × 6% ÷ 3600 ≈ 1 万。大促期间流量是平时的 10 倍,设计目标为 10 万 QPS。
-容量要按峰值计算,不能按平均值。平时的峰值约是平均值的 1.4 倍,大促峰值则约是平均值的 14 倍。
+容量要按高峰计算,不能按全天的平均值:平时高峰小时的 QPS 约是全天平均的 1.4 倍,大促则约是 14 倍。高峰小时内还有秒级的突发,所以估算值只是起点,要留出余量,并用秒级的监控和压测确认。
10 万 QPS 直接落到数据库上风险很大。实测的 MySQL 主键点查在 16 至 128 个连接时达到 17 万至 19 万 QPS(见[附录 A4](#a4-mysql-主键点查的并发扫描)),但这是客户端与服务端同机、没有网络开销的结果;生产环境经过网络和其它负载后的上限会低很多。因此需要在数据库之前挡住绝大部分请求。
@@ -645,11 +680,11 @@ CDN 边缘节点缓存商品基础信息的 JSON,存活时间(Time To Live,
#### 2. 应用进程缓存
-每台应用实例在进程内保存热门商品,TTL 为 5 秒。它不需要网络往返,但每台实例各存一份。TTL 到期时,同一商品可能在多台实例上同时回源,这就是[缓存击穿](缓存.md#缓存击穿)。
+每台应用实例在进程内保存热门商品,TTL 为 5 秒,假设命中率为 50%,回源 2 万 QPS。它不需要网络往返,但每台实例各存一份。TTL 到期时,同一实例内对这个商品的并发请求要合并成一次回源(例如 Caffeine 的 get,见[多级缓存](缓存.md#多级缓存)),否则会同时回源,与[缓存击穿](缓存.md#缓存击穿)的道理相同。单个爆款商品的请求全部打到存放它的 Redis 节点时,这一层就是主要的解决办法,其它做法见[热 Key](缓存.md#大-key-与热-key)。
#### 3. Redis
-商品详情、价格和库存分别存放在 Redis 的键中,用一次 MGET 取回。Redis 的请求量为 2 万 QPS,约为单实例 GET 实测中位数(12.5 万 QPS,见[附录 A7](#a7-redis-get-的并发扫描))的 1/6。假设 98% 的请求命中,回源到数据库的请求为 400 QPS。
+商品详情、价格和库存分别存放在 Redis 的键中,键名带上同一个哈希标签(例如 {sku:1001}),使它们落在同一个槽,用一次 MGET 取回。Redis 的请求量为 2 万 QPS,约为单实例 GET 实测中位数(12.5 万 QPS,见[附录 A7](#a7-redis-get-的并发扫描))的 1/6。假设 98% 的请求命中,回源到数据库的请求为 400 QPS。
#### 4. 数据库
@@ -667,7 +702,7 @@ CDN 边缘节点缓存商品基础信息的 JSON,存活时间(Time To Live,
#### 1. 热点行
-InnoDB 使用行级锁,并采用两阶段锁(Two-Phase Locking, 2PL),事务持有的行锁一般要到提交或回滚时才释放([InnoDB Transaction Model](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-model.html))。因此同一行的更新只能串行执行,单行的吞吐量约为 1 ÷ 锁持有时间。
+InnoDB 使用行级锁,并采用两阶段锁(Two-Phase Locking, 2PL),事务持有的行锁一般要到提交或回滚时才释放。因此同一行的更新只能串行执行,单行的吞吐量约为 1 ÷ 锁持有时间。
实测验证了这一点:对同一行执行自动提交的 UPDATE,吞吐量在 1 到 64 个并发之间稳定在 0.8 万至 1 万 TPS,不随并发增加;分散到 100 行后,64 个并发达到 3.4 万 TPS(见[附录 A5](#a5-热点行与分散行的-update))。
@@ -697,7 +732,7 @@ WHERE sku_id = 1 AND slot = 3 AND stock > 0;
- 降级:大促时关闭评价摘要、推荐等非核心模块,库存数字改为“有货”“库存紧张”,减少对库存服务的查询;
- 熔断:库存服务的超时比例超过阈值时,详情页直接返回缓存中的库存状态,不再等待超时。
-Google SRE 对过载的建议是先返回降级的响应,即数据更少、计算更简单的结果,只有在极端过载时才返回错误([Handling Overload](https://sre.google/sre-book/handling-overload/))。
+过载时优先返回降级的响应,即数据更少、计算更简单的结果,只有在极端过载时才返回错误。
### 一致性与可用性
@@ -718,10 +753,14 @@ 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/))。
+拐点的位置随环境波动,同一台机器上三轮测试的数值也不完全一致,因此压测要重复多次并取中位数,只在相同规格的环境中比较结果。另外,用 1 个连接逐条同步发送命令,测到的主要是网络和客户端的往返延迟,要测服务端本身的上限需要多个连接。
数据库也有类似的拐点。MySQL 主键点查在 16 个连接时为 16.9 万 QPS,128 个连接时为 19.2 万 QPS,吞吐量只增加 14%,平均响应时间却从 0.095 ms 增至 0.668 ms(见[附录 A4](#a4-mysql-主键点查的并发扫描))。压测时客户端与服务端在同一台机器上,共用 10 个核心,吞吐量很快接近这台机器的上限,之后的并发只增加排队时间。热点行的更新则从一开始就没有并发收益。
+过了拐点继续加压,吞吐量还可能下降:排队时间超过客户端的超时后,请求被放弃,服务端已经做的工作白费;超时又触发重试,请求量进一步增加;线程和连接越多,上下文切换、锁竞争和 GC 的开销越大。
+
+定位时逐层查看各项资源的使用率和等待时间:CPU、线程池和连接池(获取连接的等待时间)、慢 SQL 与锁等待、Redis 延迟、GC 停顿和网卡带宽。饱和的资源不一定是根因,要顺着等待往下游追:例如连接池占满、获取连接要等很久,可能是 SQL 在等行锁、一直占着连接,这时扩大连接池没有用。CPU 不高而吞吐量上不去,通常也是在等锁、等连接或者等下游。
+
压测的结果要能代表生产,容易出错的地方有:
- 环境:机器规格、实例数和数据量与生产一致,或者按比例缩小;表中只有一万行时,索引和缓冲池的表现与一亿行时完全不同;
@@ -732,20 +771,20 @@ Google SRE 对过载的建议是先返回降级的响应,即数据更少、计
JMeter、wrk、k6 等工具负责按设定的并发或速率发出请求,并统计延迟分布;同时要看服务端各层的监控。压测的结论应该是“哪一层先到达拐点、原因是什么”,而不只是一个 QPS 数字。
-大促前通常还要在生产环境做全链路压测,因为测试环境很难复现生产的规模和依赖关系。压测请求带上专门的标记(例如一个请求首部),每个服务识别这个标记并传给下游,传递方式与[链路追踪](微服务.md#八链路追踪)中的 traceId 相同,消息和异步任务中也要带上;带标记的请求写入影子库或影子表,不污染真实的订单和库存([阿里云 PTS:线上业务压测的核心要素](https://help.aliyun.com/zh/pts/performance-test-pts-2-0/product-overview/core-elements-for-online-business-stress-testing))。压测安排在业务低峰期,并准备好随时停止压测的开关。
+大促前通常还要在生产环境做全链路压测,因为测试环境很难复现生产的规模和依赖关系。压测请求带上专门的标记(例如一个请求首部),每个服务识别这个标记并传给下游,传递方式与[链路追踪](微服务.md#八链路追踪)中的 traceId 相同,消息和异步任务中也要带上;带标记的请求写入影子库或影子表,不污染真实的订单和库存。压测安排在业务低峰期,并准备好随时停止压测的开关。
-并发数 = 吞吐量 × 响应时间,这一关系即 Little 定律(Little's law),见[性能指标](#性能指标)。它可以用来估算同时在处理的请求数。例如 10 万 QPS、平均响应时间 5 ms(假设)时,系统中约有 500 个请求同时在处理。单台实例按 1200 QPS 计算时,同时处理约 6 个请求,线程池和连接池要在此基础上留出余量。
+用[利特尔法则](#3-并发用户数)可以估算同时在处理的请求数。例如 10 万 QPS、平均响应时间 5 ms(假设)时,系统中约有 500 个请求同时在处理。单台实例按 1200 QPS 计算时,同时处理约 6 个请求,线程池和连接池要在此基础上留出余量。
实例数的估算方法是:所需实例数 = 峰值 QPS ÷(单实例拐点 QPS × 目标利用率),再按可用区向上取整。例如应用层的回源为 4 万 QPS,单实例拐点按 2000 QPS 计算(假设,需用服务自己的压测结果替换),目标利用率为 60%:4 万 ÷ 1200 ≈ 33.3,取 34 台,三个可用区各 12 台,共 36 台。一个可用区故障时剩余 24 台,每台约 1667 QPS,仍低于拐点。
-Google SRE 的经验是用 CPU 等资源而不是 QPS 作为容量的度量,因为同样的 QPS 在不同接口上的开销可能相差很大([Handling Overload](https://sre.google/sre-book/handling-overload/))。因此上面的 QPS 估算只是起点,最终要用 CPU 使用率校准。
+同样的 QPS 在不同接口上的开销可能相差很大,所以容量最好用 CPU 等资源来度量。上面的 QPS 估算只是起点,最终要用 CPU 使用率校准。
### QPS 突增 10 倍时先做什么
假设线上 QPS 从 1 万突增到 10 万,处理顺序如下:
1. 确认流量的性质:查看来源 IP、User-Agent 和接口分布,区分真实增长、爬虫和客户端重试风暴;
-2. 先保护再扩容:立即在网关限流,关闭非核心功能,熔断慢的依赖。过载后健康的实例会减少,降低负载不一定能让服务恢复,因此保护要先于扩容生效([Addressing Cascading Failures](https://sre.google/sre-book/addressing-cascading-failures/));
+2. 先保护再扩容:立即在网关限流,关闭非核心功能,熔断慢的依赖。过载后健康的实例会减少,降低负载不一定能让服务恢复,因此保护要先于扩容生效;
3. 找出瓶颈层:按读路径逐层查看 QPS、CPU、连接池、慢 SQL 和 Redis 延迟,判断哪一层先到达拐点;
4. 扩容:无状态的应用实例可以较快地加入集群。Redis 增加分片、数据库分库需要迁移数据,耗时较长,因此大促前要按 10 倍峰值提前扩容并压测;
5. 复盘:把新的拐点和瓶颈层记入容量模型,作为下一次估算的依据。
@@ -754,11 +793,9 @@ Google SRE 的经验是用 CPU 等资源而不是 QPS 作为容量的度量,
大模型推理的并发单位不是请求,而是 token。每个请求的输出长度不固定,服务能同时处理的请求数取决于显存中能放下多少键值缓存(KV Cache,见[自回归生成与 KV Cache](大模型应用.md#自回归生成与-kv-cache))。
-以 Qwen2.5-7B-Instruct 为例:28 层,4 个 KV 头,每个头的维度为 128,使用 bfloat16(2 字节)。每个 token 的 KV Cache 为 2 × 28 × 4 × 128 × 2 = 57344 字节,即 56 KiB。模型权重约 15.2 GB(由配置推算为 7.6 B 参数,见[附录 A8](#a8-由配置推算-qwen25-7b-的参数与-kv-cache))。在 24 GB 的显卡上,若保留 2 GB 给框架与激活(假设),KV Cache 空间约 6.8 GB,可缓存约 11.8 万个 token。平均每个请求 2000 token 时,同时并发的请求约 59 个。
+以 Qwen2.5-7B-Instruct 为例,每个 token 的 KV Cache 为 56 KiB,权重约 14.2 GiB(见[附录 A8](#a8-由配置推算-qwen25-7b-的参数与-kv-cache))。24 GiB 的显卡扣除权重、再保留 2 GiB 给框架与激活(假设)后,约能缓存 14.6 万个 token,平均每个请求 2000 token 时同时只能处理约 73 个。计算方法和减少 KV Cache 的做法见[显存:权重与 KV Cache](大模型应用.md#3-显存权重与-kv-cache)。
-若使用 28 个 KV 头的多头注意力(Multi-Head Attention, MHA),每个 token 的 KV Cache 为 392 KiB,同样的显存只能容纳 1/7 的 token。分组查询注意力(Grouped-Query Attention, GQA)让多个查询头共享一组 KV,正是为了减少这部分开销。
-
-因此大模型服务的限流通常按 token 计算,而不是按请求数。vLLM 用分页的方式管理 KV Cache,减少显存的浪费,从而提高可同时处理的请求数([PagedAttention 论文](https://arxiv.org/abs/2309.06180))。
+因此大模型服务的限流通常按 token 计算,而不是按请求数。vLLM 用分页的方式管理 KV Cache(PagedAttention),减少显存的浪费,从而提高可同时处理的请求数。
## 十一、大文件上传
@@ -772,8 +809,8 @@ Google SRE 的经验是用 CPU 等资源而不是 QPS 作为容量的度量,
分片之间互不影响,可以乱序上传,也可以并行上传以提高吞吐量。整个流程分为四步:
-1. 初始化:客户端提交文件名、文件大小、整文件的安全哈希算法 256(Secure Hash Algorithm 256, SHA-256),以及 60 个分片各自的 SHA-256(清单);服务端写入上传记录和 60 条分片记录,返回上传 ID(upload_id);
-2. 上传分片:客户端对每一片发送一个 PUT 请求,服务端校验分片的长度和 SHA-256 与清单一致后保存,并记录分片的实体标签(Entity Tag, ETag),这里取分片内容的消息摘要算法 5(Message-Digest Algorithm 5, MD5)值,与简单存储服务(Simple Storage Service, S3)的做法一致。客户端根据每次 PUT 的响应判断哪一片失败,单片失败时只重试这一片;
+1. 初始化:客户端提交文件名、文件大小、整文件的 SHA-256,以及 60 个分片各自的 SHA-256(清单);服务端写入上传记录和 60 条分片记录,返回上传 ID(upload_id);
+2. 上传分片:客户端对每一片发送一个 PUT 请求,服务端校验分片的长度和 SHA-256 与清单一致后保存,并记录分片的实体标签 ETag(这里取分片内容的 MD5,与 Amazon S3 相同)。客户端根据每次 PUT 的响应判断哪一片失败,单片失败时只重试这一片;
3. 完成:客户端提交完成请求,服务端检查 60 片齐全,按分片号升序拼接,计算整文件的 SHA-256,与初始化时的值相同才生成最终文件;
4. 清理:完成后删除分片;未完成的上传要定期取消,并删除已上传的分片。
@@ -815,7 +852,7 @@ Google SRE 的经验是用 CPU 等资源而不是 QPS 作为容量的度量,
### 重复分片与幂等
-同一分片号再次上传会覆盖之前的分片,这是 S3 的规则。因此客户端在超时或不确定时直接重传即可,不需要判断服务端是否已经收到。
+同一分片号再次上传会覆盖之前的分片,S3 也是这样处理的。因此客户端在超时或不确定时直接重传即可,不需要判断服务端是否已经收到。
本实现还要求分片内容与初始化时提交的清单一致,覆盖规则之外多了这一层检查:
@@ -833,7 +870,7 @@ Google SRE 的经验是用 CPU 等资源而不是 QPS 作为容量的度量,
3. 存储层:完成前逐片核对已保存的分片。实测中把第 7 片的一个字节改掉后,完成请求返回 409 并列出第 7 片,只把它标记为待补传,补传后再次完成即成功;
4. 文件层:按序拼接后计算整文件的 SHA-256,与初始化时的值比较。
-对象存储的校验规则有所不同。S3 多分片对象的 ETag 不是整个对象的 MD5,而是把各分片的 MD5 拼接后再求 MD5,末尾加上分片数,官方文档的示例为 “-9”。多分片上传时,整对象校验只支持 CRC-64/NVME、CRC-32 和 CRC-32C,SHA-256 只能使用组合校验。因此要用 SHA-256 核对整个文件时,需要由应用自己保存整文件的 SHA-256。
+对象存储的校验规则有所不同。S3 多分片对象的 ETag 不是整个对象的 MD5,而是把各分片的 MD5 拼接后再求 MD5,末尾加上分片数,例如 “-9” 表示 9 个分片。多分片上传时,整对象校验只支持 CRC-64/NVME、CRC-32 和 CRC-32C,SHA-256 只能使用组合校验。因此要用 SHA-256 核对整个文件时,需要由应用自己保存整文件的 SHA-256。
### 合并与清理
@@ -841,13 +878,13 @@ S3 的完成请求要求列出每个分片的分片号和 ETag,服务端按分
两个完成请求同时到达时,只能有一个进入合并。实现方法是用条件更新把上传状态从 uploading 改为 completing,影响的行数为 0 说明已有请求在处理。合并失败时把状态改回 uploading,客户端可以重试。如果服务在合并过程中崩溃,状态会停留在 completing,需要用超时机制把它恢复。
-未完成的上传会占用存储。S3 对未完成的分片按存储量计费,直到上传完成或取消;官方建议配置生命周期规则 AbortIncompleteMultipartUpload 自动删除超期的未完成上传。在 S3 中,取消之后原上传 ID 不能再用于上传分片。自建服务同样需要定期取消超期的上传并删除分片,取消接口的实测见[附录 A9](#a9-分片上传的最小实现与实测)。
+未完成的上传会占用存储。S3 对未完成的分片按存储量计费,直到上传完成或取消;可以配置生命周期规则 AbortIncompleteMultipartUpload 自动删除超期的未完成上传。在 S3 中,取消之后原上传 ID 不能再用于上传分片。自建服务同样需要定期取消超期的上传并删除分片,取消接口的实测见[附录 A9](#a9-分片上传的最小实现与实测)。
### 对象存储的分片上传
对象存储(Object Storage)的分片上传是同样的流程,Amazon S3 的接口分别为:CreateMultipartUpload(初始化,返回 UploadId)、UploadPart(上传分片,返回 ETag)、ListParts(列出已上传的分片)、CompleteMultipartUpload(按分片号和 ETag 完成)、AbortMultipartUpload(取消)。
-主要限制以官方文档为准,2026 年 10 月核对:单个对象最大 48.8 TiB;每次上传最多 10000 片,分片号为 1 到 10000;除最后一片外,分片大小为 5 MiB 到 5 GiB;ListParts 每次最多返回 1000 片;单次 PUT 最多上传 5 GB,超过的文件要用分片上传。
+主要限制如下(2026 年 10 月):单个对象最大 48.8 TiB;每次上传最多 10000 片,分片号为 1 到 10000;除最后一片外,分片大小为 5 MiB 到 5 GiB;ListParts 每次最多返回 1000 片;单次 PUT 最多上传 5 GB,超过的文件要用分片上传。
分片本身不是对象,只有完成请求之后才生成对象,分片也随之消失。因此合并是分片上传的最后一步。在 S3 中,合并由存储服务按分片号组合,应用不需要再下载、拼接并重新上传一次。
@@ -861,9 +898,9 @@ MinIO 兼容 S3 的分片上传接口,它的限制与 S3 一致:分片号必
#### 1. 基本模型
-基于角色的访问控制(Role-Based Access Control, RBAC)把权限授予角色,再把角色分配给用户,用户通过角色间接获得权限。Sandhu 等人在 1996 年提出的参考模型中,权限与角色相关联,用户是角色的成员;这一模型与 Ferraiolo 和 Kuhn 的模型在 2000 年统一为 NIST 的 RBAC 模型,2004 年成为 ANSI/INCITS 359-2004 标准。
+基于角色的访问控制(Role-Based Access Control, RBAC)把权限授予角色,再把角色分配给用户,用户通过角色间接获得权限。
-逐个用户授权的成本很高。NIST 指出,管理员通常要为每个用户单独指定访问控制列表(Access Control List, ACL),既费事又容易出错。例如有 200 个采购员,每人 15 个权限,假设共有 4 个角色:逐人授权需要 3000 条用户-权限记录;使用角色时,需要 200 条用户-角色记录,加上 4 个角色各 15 条角色-权限记录,共 260 条(计算值)。权限变更时只需修改角色,不必逐个用户调整。
+逐个用户授权的成本很高,既费事又容易出错。例如有 200 个采购员,每人 15 个权限,假设共有 4 个角色:逐人授权需要 3000 条用户-权限记录;使用角色时,需要 200 条用户-角色记录,加上 4 个角色各 15 条角色-权限记录,共 260 条(计算值)。权限变更时只需修改角色,不必逐个用户调整。
#### 2. 表结构
@@ -933,7 +970,7 @@ SELECT EXISTS (
- 服务端:通过拦截器或注解读取当前用户的权限码集合,与接口要求的权限码比较,不满足时返回 403。例如在接口方法上标注需要 order:create,调用前由框架完成检查;
- 网关:一般只校验登录状态。接口需要哪个权限码由服务定义,网关不能替代服务端的检查。
-OWASP 的授权速查表建议在服务端对每次请求校验权限,并且默认拒绝:没有匹配的授权规则时,结果是拒绝,而不是放行。
+服务端要对每次请求校验权限,并且默认拒绝:没有匹配的授权规则时,结果是拒绝,而不是放行。
#### 4. 权限缓存与失效
@@ -985,7 +1022,7 @@ SELECT id, title FROM sys_order WHERE dept_id = 10 ORDER BY id;
SELECT id, title FROM sys_order ORDER BY id;
```
-用户拥有多个角色时,各角色的条件用 OR 合并;只要有一个角色是 ALL,就不追加条件。没有任何角色时追加 WHERE 1 = 0,孙七的查询因此返回 0 行(MySQL 8.0 实测,见[附录 A12](#a12-数据范围分页总数与写操作))。
+用户拥有多个角色时,先找出拥有当前操作权限的角色,再把这些角色的条件用 OR 合并,其中只要有一个是 ALL,就不追加条件。例如一个人兼任采购主管和财务,审批订单时财务没有审批权限,它的 ALL 不参与合并,范围仍是采购主管的 DEPT。没有任何角色时追加 WHERE 1 = 0,孙七的查询因此返回 0 行(MySQL 8.0 实测,见[附录 A12](#a12-数据范围分页总数与写操作))。
#### 2. 实现与注意事项
@@ -1004,9 +1041,9 @@ SELECT id, title FROM sys_order ORDER BY id;
#### 1. ACL 与 ABAC
-ACL 以资源为中心,每个资源直接列出可以访问它的主体和操作,例如某个网盘文件的 ACL 为 zhangsan 可读、lisi 可读写。它适合个人之间的共享,但授权关系分散在每个资源上,用户和资源很多时难以统一管理。
+访问控制列表(Access Control List, ACL)以资源为中心,每个资源直接列出可以访问它的主体和操作,例如某个网盘文件的 ACL 为 zhangsan 可读、lisi 可读写。它适合个人之间的共享,但授权关系分散在每个资源上,用户和资源很多时难以统一管理。
-基于属性的访问控制(Attribute-Based Access Control, ABAC)根据主体、客体、操作和环境条件的属性计算授权。NIST SP 800-162 的定义是:授权由这些属性与策略、规则或关系比较后决定。例如“金额超过 1 万元的订单只能由采购部主管在工作时间审批”,其中金额、部门和时间都是属性。ABAC 的表达能力强,但策略数量增加后难以理解和审计。
+基于属性的访问控制(Attribute-Based Access Control, ABAC)根据主体、客体、操作和环境条件的属性计算授权。例如“金额超过 1 万元的订单只能由采购部主管在工作时间审批”,其中金额、部门和时间都是属性。ABAC 的表达能力强,但策略数量增加后难以理解和审计。
三者的区别在于授权的依据不同:
@@ -1055,8 +1092,8 @@ Agent 调用工具时,模型只输出调用意图,是否真正执行由权
假设日活用户 2000 万,每人每天看 100 个视频、点赞 5 次,晚高峰一小时占全天流量的 6%(与 [容量估算](#容量估算) 相同):
-- 写:每天 1 亿次点赞,峰值约 1 亿 × 6% ÷ 3600 ≈ 1700 次/秒;
-- 读:每看一个视频都要读点赞数和“是否点过”,每天 20 亿次,峰值约 3.3 万次/秒;
+- 写:每天 1 亿次点赞,高峰小时约 1 亿 × 6% ÷ 3600 ≈ 1700 次/秒;
+- 读:每看一个视频都要读点赞数和“是否点过”,每天 20 亿次,高峰小时约 3.3 万次/秒;
- 存储:点赞关系表每行约 112 字节(含二级索引,MySQL 8.0 实测,见[附录 A21](#a21-点赞系统)),每年约 365 亿行、4 TB。
由此可以得出:读远多于写,适合加缓存;每秒 1700 次写入单库能承受,但几年的数据量单库放不下,要分库分表。
@@ -1097,20 +1134,20 @@ UPDATE video_stat SET like_count = like_count + 1 WHERE video_id = 1024;
COMMIT;
```
-两张表在同一个事务中修改,点赞关系和点赞数不会不一致,规模不大时这样就够了。面试中先给出这个方案,再说明它在估算的规模下哪里会出问题。
+取消点赞也一样,先 DELETE 点赞关系,影响行数为 1 时才把点赞数减 1,重复取消不会把点赞数减多。两张表在同一个事务中修改,点赞关系和点赞数不会不一致,规模不大时这样就够了。面试中先给出这个方案,再说明它在估算的规模下哪里会出问题。
### 找出瓶颈并改进
#### 1. 热门视频的计数
-点赞同一个视频的请求都要更新 video_stat 中的同一行,行锁让它们只能一个接一个地执行。对同一行执行 UPDATE ... + 1,每秒只有 5000~7000 次(见 [Redis 的计数器](Redis.md#计数器)),一个视频同时被几十万人点赞时,请求会排队直至超时。
+点赞同一个视频的请求都要更新 video_stat 中的同一行,行锁让它们只能一个接一个地执行。对同一行执行 UPDATE ... + 1,每秒约 1 万次,并且不随并发增加(见[热点行](#1-热点行)),一个视频同时被几十万人点赞时,请求会排队直至超时。
改进的办法是把计数移出点赞的事务:
- 点赞数放在 Redis 中用 INCR 增加,单个键每秒能处理约 10 万次;单个键仍然太热时,拆成多个键分别累加,读取时求和;
- 数据库中的点赞数改为异步合并写入,例如每秒把视频 1024 这一秒的增量合并成一条 UPDATE ... + 37。
-这样点赞关系和点赞数不在一个事务中,可能出现关系写入成功、点赞数没有加上的情况。以点赞关系为准,点赞数是由它派生出来的:更新失败的计数通过消息重试,再定期对热门视频重新统计 COUNT(*) 校正。
+这样点赞关系和点赞数不在一个事务中,可能出现关系写入成功、点赞数没有加上的情况。以点赞关系为准,点赞数是由它派生出来的:在插入点赞关系的同一个事务中写一条计数消息,由后台任务发送并重试,见[本地消息表](分布式.md#本地消息表)。重试可能让同一条消息到达两次,消费者按消息 ID 去重,用一个 Lua 脚本同时记下消息 ID 和增加计数,重复的消息不会多加;定期对热门视频重新统计 COUNT(*) 只作兜底。
#### 2. 读的压力
@@ -1134,7 +1171,9 @@ COMMIT;
- 点赞关系同步写入数据库,并更新这个用户的缓存,所以用户刷新页面后能看到自己的点赞;
- Redis 宕机时,点赞数改为从数据库的 video_stat 中读取,没有写回的增量通过与点赞关系对账补回。
-前面的秒杀、短链和 Feed 流也可以按这个顺序复述:先问清需求,算出量级,再从简单方案出发逐步改进。
+前面的秒杀、短链和 Feed 流也可以按这个顺序复述:先问清需求,算出量级,再从简单方案出发逐步改进。面试官追问“流量再涨 10 倍怎么办”时,回到估算这一步,重新算出每一层的量,看哪一层先超过上限。
+
+以点赞系统为例,写入约 1.7 万次/秒,分摊到按 user_id 划分的各个分片上,不够时增加分片;读取约 33 万次/秒,Redis 加分片可以分摊,但同一个爆款视频的点赞数只存在一个或几个键中,加分片分不走这部分读取,要在应用中再缓存 1 秒(点赞数本来允许延迟几秒)。线上突发流量的处理顺序见 [QPS 突增 10 倍时先做什么](#qps-突增-10-倍时先做什么)。
## 十四、即时通讯
@@ -1164,7 +1203,7 @@ TCP 只保证一条连接内的数据不丢、有序,但连接会断开、服
3. 推送给 1002 后,1002 回复已经收到的 seq;
4. 1002 断线期间的第 59、60 条推送失败,但消息已经存储。1002 重连后带上自己收到的最大 seq 58,拉取之后的消息。收到第 61 条而本地只有第 58 条时,同样说明中间缺了消息,要按 seq 补拉。
-所以推送只负责及时通知,消息是否收全以 seq 为准,离线消息就是用户上线后按 seq 拉取到的这部分消息。seq 只需要在会话内递增,可以用 Redis 的 INCR 为每个会话生成。
+所以推送只负责及时通知,消息是否收全以 seq 为准,离线消息就是用户上线后按 seq 拉取到的这部分消息。seq 只需要在会话内递增,可以用 Redis 的 INCR 为每个会话生成。需要注意的是,用 INCR 时分配序号和写入消息不是一个原子操作:写入失败会留下空号,后分配序号的消息也可能先写入,客户端看到 60 时 59 可能还没写完。要求序号连续时,可以在同一个事务中把会话的最大序号加 1 并插入消息,行锁让同一会话的消息串行写入,事务回滚时序号也一起回滚。
消息按会话保存,单聊和群聊都是一个会话:
@@ -1198,7 +1237,7 @@ CREATE TABLE conv_member (
- 写扩散:为每个成员保存一份,500 人的群,一条消息要写 500 次,读取时每个人只读自己的消息列表;
- 读扩散:群消息只保存一份,按群分配 seq,每个成员只记录自己读到的位置,即上面的 read_seq。
-大群通常用读扩散。例如群 88 的成员 1002 读到第 1 条后下线,群里又发了 9 条消息,最大的 seq 变为 10。1002 上线时,未读数就是 10 − 1 = 9,再按 seq > 1 拉取这 9 条,不需要逐条记录谁读了哪条消息(MySQL 8.0 实测,见[附录 A22](#a22-长连接与心跳))。推送时也只推给在线的成员,并且可以只推送“群 88 有新消息”的通知,由客户端自己拉取。
+大群通常用读扩散。例如群 88 的成员 1002 读到第 1 条后下线,群里又发了 9 条消息,最大的 seq 变为 10。1002 上线时,未读数就是 10 − 1 = 9(有空号时略偏大),再按 seq > 1 拉取这 9 条,不需要逐条记录谁读了哪条消息(MySQL 8.0 实测,见[附录 A22](#a22-长连接与心跳))。推送时也只推给在线的成员,并且可以只推送“群 88 有新消息”的通知,由客户端自己拉取。
### 心跳与重连
@@ -1249,9 +1288,47 @@ Lua 脚本:成功 100,库存不足 1800,重复购买 100,剩余库存 0
先 GET 再 DECR(第 3 次):成功 114,库存不足 1886,重复购买 0,剩余库存 -14
```
+不用 Lua、按 SADD 和 DECR 的返回值判断的写法,同样的请求运行 3 次:
+
+```python
+def no_lua(u):
+ if c.sadd('buyers', u) == 0: return 2
+ if c.decr('stock') < 0:
+ c.incr('stock'); c.srem('buyers', u)
+ return 1
+ return 0
+```
+
+```text
+第 1 次:成功 100,库存不足 941,重复购买 959,剩余库存 0,已购集合 100
+第 2 次:成功 100,库存不足 915,重复购买 985,剩余库存 0,已购集合 100
+第 3 次:成功 100,库存不足 910,重复购买 990,剩余库存 0,已购集合 100
+```
+
+抢到的 100 个用户,另一个请求都返回重复购买;其余的重复购买来自没抢到的用户:两个请求同时执行,一个已经 SADD 占位、还没有 SREM 撤销,另一个的 SADD 就返回了 0。
+
+库存键不存在时(例如没有预热),GET 返回空值,脚本中不加 or '0' 会报错,加上后返回 1(库存不足):
+
+```text
+修改前,库存键不存在: user_script:5: attempt to compare nil with number
+修改后,库存键不存在:返回 1
+```
+
+单个商品的库存键(另起一个关闭持久化的实例):库存设为 1 亿,用户 ID 随机,redis-benchmark 用 50 个连接对正文的脚本执行 30 万次 EVALSHA,3 轮:
+
+```bash
+redis-benchmark -c 50 -n 300000 -r 100000000 -q EVALSHA 2 sk:stock sk:users __rand_int__
+```
+
+```text
+85763.30 requests per second, p50=0.511 msec
+74552.68 requests per second, p50=0.551 msec
+80171.03 requests per second, p50=0.527 msec
+```
+
### A2 短链跳转
-服务器对 /a 返回 301、对 /b 返回 302,Location 都是 /target。用 Edge 156 分别访问 /a 两次、/b 两次,服务器收到的请求:
+服务器对 /a 返回 301、对 /b 返回 302,Location 都是 /target,响应不带 Cache-Control、Expires 等缓存首部。用 Edge 156 分别访问 /a 两次、/b 两次,服务器收到的请求:
```text
HIT /a
@@ -1336,7 +1413,7 @@ UPDATE t1_stock SET sold = sold + 1 WHERE sku_id = 1;
| 16 | 9005 | 1.777 | 25000 | 0.640 |
| 64 | 8022 | 7.978 | 33730 | 1.897 |
-热点行的吞吐量约为 1 ÷ 0.115 ms,即约 8700 TPS,与并发数无关。这说明同一行的事务是串行执行的。
+并发数从 1 增加到 64,热点行的吞吐量不增加,响应时间随并发数近似线性增长,说明同一行的更新是串行执行的;分散行的吞吐量则随并发数增加。
### A6 本机单条查询的往返延迟
@@ -1375,7 +1452,7 @@ kv_per_token = 2 * L * kv_heads * head_dim * 2 # K 与 V,bfloat16 为 2
print(params / 1e9, kv_per_token)
```
-输出为 `7.615 57344`,即约 7.6 B 参数(官方模型卡标注为 7.61B)和每个 token 57344 字节的 KV Cache。计算忽略了偏置和归一化层的参数,数量级很小。
+输出为 `7.615 57344`,即约 7.6 B 参数(官方模型卡标注为 7.61B)和每个 token 57344 字节(56 KiB)的 KV Cache。bfloat16 的权重为 7.615 × 109 × 2 字节,约 14.2 GiB;24 GiB 扣除权重和假设保留的 2 GiB 后剩约 7.8 GiB,除以 56 KiB 约为 14.6 万个 token。计算忽略了偏置和归一化层的参数,数量级很小。
### A9 分片上传的最小实现与实测
@@ -1778,6 +1855,48 @@ INCR: 125156.45 requests per second, p50=0.311 msec
EVALSHA: 101112.23 requests per second, p50=0.415 msec
```
+判断和扣减分开执行时:桶中有 5 个令牌,时间戳固定(不补令牌),50 个线程各用一个连接(redis-py),同时对同一个键请求一次,每种写法运行 3 次。不用 Lua 和用 WATCH 的写法如下,补令牌的计算与脚本相同:
+
+```python
+def no_lua(c, key): # 先读、在客户端计算、再写回
+ t, ts = c.hmget(key, 'tokens', 'ts')
+ tokens, now = refill(t, ts)
+ if tokens < 1:
+ return 0
+ c.hset(key, mapping={'tokens': tokens - 1, 'ts': now})
+ return 1
+
+def watch_multi(c, key): # 键在 EXEC 之前被改过时,EXEC 失败,重新读
+ with c.pipeline() as p:
+ while True:
+ try:
+ p.watch(key)
+ t, ts = p.hmget(key, 'tokens', 'ts')
+ tokens, now = refill(t, ts)
+ if tokens < 1:
+ return 0
+ p.multi()
+ p.hset(key, mapping={'tokens': tokens - 1, 'ts': now})
+ p.execute()
+ return 1
+ except redis.WatchError:
+ pass # 统计重试次数的代码省略
+```
+
+```text
+不用 Lua(HMGET 后 HSET) 第 1 次:50 个请求通过 50 个,剩余令牌 3
+不用 Lua(HMGET 后 HSET) 第 2 次:50 个请求通过 50 个,剩余令牌 2
+不用 Lua(HMGET 后 HSET) 第 3 次:50 个请求通过 50 个,剩余令牌 3
+WATCH + MULTI 第 1 次:50 个请求通过 5 个,剩余令牌 0,EXEC 失败重试 46 次
+WATCH + MULTI 第 2 次:50 个请求通过 5 个,剩余令牌 0,EXEC 失败重试 67 次
+WATCH + MULTI 第 3 次:50 个请求通过 5 个,剩余令牌 0,EXEC 失败重试 92 次
+Lua 脚本 第 1 次:50 个请求通过 5 个,剩余令牌 0
+Lua 脚本 第 2 次:50 个请求通过 5 个,剩余令牌 0
+Lua 脚本 第 3 次:50 个请求通过 5 个,剩余令牌 0
+```
+
+不用 Lua 时,多数请求在其它请求写回之前就读到了令牌数,都判断为可以放行。
+
### A14 Guava 的 RateLimiter
环境:Guava 33.4.8-jre,JDK 21.0.13,Apple M1 Pro。
@@ -1923,12 +2042,18 @@ CREATE TABLE red_packet_grab (
-- 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
+if tonumber(redis.call('GET', KEYS[1]) or '0') <= 0 then return 0 end
redis.call('DECR', KEYS[1])
return redis.call('HINCRBY', KEYS[2], ARGV[1], 1) -- 返回第几张,落库时作为 seq
```
+运行 5 次的输出:
+
```text
+发出 100 张,库存剩余 0,领到张数分布 [(1, 4), (2, 48)],超过 2 张的用户 0,(用户, seq) 重复 0
+发出 100 张,库存剩余 0,领到张数分布 [(1, 2), (2, 49)],超过 2 张的用户 0,(用户, seq) 重复 0
+发出 100 张,库存剩余 0,领到张数分布 [(1, 2), (2, 49)],超过 2 张的用户 0,(用户, seq) 重复 0
+发出 100 张,库存剩余 0,领到张数分布 [(1, 2), (2, 49)],超过 2 张的用户 0,(用户, seq) 重复 0
发出 100 张,库存剩余 0,领到张数分布 [(2, 50)],超过 2 张的用户 0,(用户, seq) 重复 0
```
@@ -2007,6 +2132,19 @@ CircuitBreakerConfig.ofDefaults() 的输出中,failureRateThreshold=50.0、sli
状态变化的事件在调用过程中触发,每次调用结束后打印,所以 OPEN → HALF_OPEN 出现在第 14 次和第 18 次调用的结果之后。
+业务异常是否计入失败率:同样是最近 10 次、最小调用数 10、阈值 50%,先查询 10 个不存在的商品(queryStock 抛出 BizException("商品不存在")),再查询商品 10086,分别用默认配置和排除 BizException 的配置运行:
+
+```java
+CircuitBreakerConfig.custom().ignoreExceptions(BizException.class) // 第二次运行多加这一项
+```
+
+```text
+默认配置:状态 OPEN,失败 10 次,失败率 100%,查询商品 10086:被熔断
+ignoreExceptions(BizException):状态 CLOSED,失败 0 次,失败率 -1%,查询商品 10086:库存 37
+```
+
+失败率为 -1 表示被统计的调用不足最小调用数,被排除的异常既不算失败也不算成功。
+
### A19 防止超卖的几种写法
环境:MySQL 8.0.44(默认的可重复读),Python 3 + PyMySQL,Apple M1 Pro。表 d5_goods(id, stock, version) 中商品 1001 的库存为 100,d5_order 的主键为 (user_id, goods_id)。每种写法用 64 个线程,每个用户请求 1 次,每个线程使用自己的连接(自动提交):
@@ -2154,6 +2292,13 @@ SET 中 10000 个用户 ID:编码 hashtable,used_memory 增加 410704 字节
SET 中 1000000 个用户 ID:编码 hashtable,used_memory 增加 30070800 字节(每个 30 字节),MEMORY USAGE 48388736 字节
```
+重复取消点赞(MySQL 8.0.44,同一台机器):用户 1001 点赞视频 1024 后,连续执行两次 `DELETE FROM video_like WHERE user_id = 1001 AND video_id = 1024`,用 ROW_COUNT() 查看影响行数:
+
+```text
+第一次 DELETE:ROW_COUNT() = 1
+第二次 DELETE:ROW_COUNT() = 0
+```
+
### A22 长连接与心跳
Docker Desktop 中的 Ubuntu 24.04 容器(内核 6.10.14-linuxkit,aarch64,--privileged,ulimit -n 为 300000),JDK 21。服务端和客户端在同一个容器中:
@@ -2233,6 +2378,7 @@ TCP: inuse 0 orphan 0 tw 0 alloc 200026 mem 1535
- 李智慧. 大型网站技术架构:核心原理与案例分析 [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)
- [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.
@@ -2249,6 +2395,7 @@ TCP: inuse 0 orphan 0 tw 0 alloc 200026 mem 1535
- [UploadPart - Amazon S3 API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPart.html)
- [Resilience4j: CircuitBreaker](https://resilience4j.readme.io/docs/circuitbreaker)
- [Sentinel 熔断降级](https://sentinelguard.io/zh-cn/docs/circuit-breaking.html)
+- [Circuit Breaker pattern(Azure 架构中心)](https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker)
- [阿里云 PTS:线上业务压测的核心要素](https://help.aliyun.com/zh/pts/performance-test-pts-2-0/product-overview/core-elements-for-online-business-stress-testing)
- [ListParts - Amazon S3 API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListParts.html)
- [Uploading objects in Amazon S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html)