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)