From 39cac170e9120e5fc0dec267a4365938534d2410 Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Thu, 29 Oct 2026 04:55:49 +0800 Subject: [PATCH] =?UTF-8?q?Update=20=E7=B3=BB=E7=BB=9F=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E5=9F=BA=E7=A1=80.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充限流、熔断与降级的区别和熔断器的状态变化,新增降级、防止超卖的几种做法、Redis 与数据库的库存一致三节,补充压测的注意事项,附录 A18、A19。 --- notes/系统设计基础.md | 253 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 252 insertions(+), 1 deletion(-) diff --git a/notes/系统设计基础.md b/notes/系统设计基础.md index 178d0e64..8f077d82 100644 --- a/notes/系统设计基础.md +++ b/notes/系统设计基础.md @@ -117,6 +117,8 @@ - 降级:主动关闭部分非核心功能,从而保证核心功能可用; - 熔断:某个依赖的服务持续出错时,暂时不再调用它而是直接返回默认结果,避免故障沿着调用链扩散。 +三者针对的对象不同:限流保护的是自己,按进入的请求量拒绝多余的请求;熔断针对的是下游依赖,按依赖的失败情况停止调用;降级是被限流、被熔断或主动关闭功能之后返回的替代结果。三者通常一起使用,例如熔断器打开时执行的就是降级逻辑。 + #### 1. 限流算法 限流要判断的是:最近一段时间已经放行了多少请求,下一个请求还能不能放行。下面都以“每个用户每秒最多下单 5 次”为例。 @@ -194,6 +196,58 @@ return allowed

+熔断要解决的问题是下游变慢时调用方被拖垮。例如库存服务卡住,订单服务调用它的读取超时为 2 秒、每秒调用 200 次,按 Little 定律,约有 200 × 2 = 400 个线程同时在等待。线程池耗尽后,不依赖库存的接口也无法响应,故障就沿调用链传到了上游。熔断器发现下游持续失败后直接拒绝调用,调用方立即执行降级逻辑,线程不再被占住,下游也得到了恢复的时间。 + +是否打开由最近一段时间的调用统计决定。以 Resilience4j(Spring Cloud Circuit Breaker 常用的实现)为例,主要的参数和默认值: + +- 统计窗口:最近 100 次调用,也可以改为最近若干秒; +- 最小调用数:窗口内至少有 100 次调用才计算失败率,避免刚启动时 1 次失败就算作 100% 的失败率; +- 阈值:失败率达到 50% 时打开,也可以按慢调用的比例打开,下游没有报错、只是变慢时也能熔断; +- 打开时长:60 秒,之后进入半开状态; +- 半开时放行的请求数:10 个,这些试探请求的失败率仍达到阈值就重新打开,否则关闭,其余请求在试探完成前仍被拒绝。 + +Sentinel 的半开状态只放行一个请求,这个请求成功就结束熔断,失败则再次熔断。 + +例如把统计窗口改为最近 10 次、最小调用数 10、打开时长 2 秒、半开时放行 3 个,库存服务从第 5 次调用开始超时,每次调用的结果如下(Resilience4j 2.2 实测,见[附录 A18](#a18-熔断器的状态变化)): + +```text +#01~#04 库存 37 +#05~#10 失败(读取超时),降级:有货 第 10 次后失败率 60%,CLOSED → OPEN +#11~#13 被熔断,降级:有货 不再调用库存服务 +(2 秒后,库存服务仍然超时) +#14~#16 失败(读取超时),降级:有货 OPEN → HALF_OPEN,3 次试探都失败,回到 OPEN +#17 被熔断,降级:有货 +(再过 2 秒,库存服务已恢复) +#18~#20 库存 37 OPEN → HALF_OPEN,3 次试探都成功,HALF_OPEN → CLOSED +#21 库存 37 +``` + +21 次调用中只有 17 次真正发给了库存服务。需要注意的是,打开时长过后要有调用到来才会转为半开,默认不启动后台线程检查状态,所以是第 14 次调用触发了 OPEN → HALF_OPEN。 + +熔断依赖超时:没有设置超时的调用只会一直等待,不会失败,熔断器也就无从统计,所以每个远程调用都要先设好超时,见 [OpenFeign](微服务.md#openfeign) 中的超时配置。 + +重试则要克制。只对幂等的操作、可能自行恢复的错误(例如超时、连接被拒绝)重试,次数要有上限,间隔按指数增长并加上随机抖动;参数错误、余额不足这类重试也不会成功的错误直接失败。还要避免每一层都重试:调用链上 3 层各重试 3 次,一次用户操作在最底层最多产生 4 × 4 × 4 = 64 次请求([Addressing Cascading Failures](https://sre.google/sre-book/addressing-cascading-failures/))。熔断器打开后,对这个依赖的重试也随之停止。 + +#### 4. 降级 + +降级指的是在资源不足或依赖故障时,用代价更低的结果代替完整的结果,以保住核心功能。限流和熔断决定的是“什么时候不再接收、不再调用”,降级决定的是“这时返回什么”。 + +常见的降级做法: + +- 返回缓存或旧的数据:例如推荐服务超时时,返回前一天算好的热门商品; +- 返回默认值或简化的结果:库存服务被熔断时,详情页显示“有货”,下单时仍以数据库为准; +- 关闭非核心功能:大促时关闭评论、积分抵扣,把资源留给下单和支付; +- 延后写操作:积分、通知这类操作先写入消息队列,下游恢复后再处理; +- 快速失败:核心功能无法降级时,立即提示“稍后再试”,而不是让请求一直等待。 + +降级有两种触发方式。自动降级由超时、熔断和限流触发,兜底的逻辑(fallback)事先写在代码中,例如上面熔断时返回“有货”。手动降级由开关控制,开关放在[配置中心](微服务.md#六配置中心),大促前或故障时修改配置即可生效,不需要重新发布。 + +哪些功能可以降级、降级成什么样,要在设计时和业务方一起确定。例如商品详情页依赖商品、价格、库存、评论、推荐五个服务:商品和价格是核心,失败时返回错误;库存失败时显示“有货”;评论和推荐失败时不显示这一块。 + +大模型应用中也是同样的思路:模型接口超时或被限流时,切换到备用的模型或更小的模型,或者返回缓存的回答;Agent 调用工具超时时,把“工具不可用”作为工具的结果返回给模型,由模型换一种方式回答或如实告知用户,而不是让整个请求失败。 + +需要注意的是,降级逻辑平时很少执行,容易到真正需要时才发现它本身有问题,例如兜底的缓存早已过期、开关关闭后功能没有恢复。因此降级方案要和正常功能一样测试,并在故障演练中实际打开一次。 + ## 五、安全性 要求系统在应对各种攻击手段时能够有可靠的应对措施,常见的攻击手段和防范方法见 [攻击技术](攻击技术.md)。 @@ -238,7 +292,47 @@ Redis 执行 Lua 脚本时不会穿插执行其它命令,判断是否已购、 UPDATE goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0; ``` -一人一单可以用订单表上(用户 ID,商品 ID)的唯一索引保证,消息被重复消费时插入失败,也就实现了幂等。Redis 中扣减成功、但订单最终创建失败时,需要把 Redis 中的库存和购买记录恢复,并定期与数据库对账。 +一人一单可以用订单表上(用户 ID,商品 ID)的唯一索引保证,消息被重复消费时插入失败,也就实现了幂等。Redis 中扣减成功、但订单最终创建失败时,需要把 Redis 中的库存和购买记录恢复,并定期与数据库对账,做法见下文。 + +### 防止超卖的几种做法 + +超卖的根源是“判断库存”和“扣减库存”不是一个原子操作:两个请求都读到库存为 1,都判断有货,都去扣减。所以防止超卖的做法都是让判断和扣减成为一个整体,区别在于在哪里做、代价是什么。 + +在数据库中有三种写法: + +- 悲观锁:在事务中先用 SELECT ... FOR UPDATE 锁住库存行,再判断和扣减,其它请求在锁上排队; +- 乐观锁:表中加一个版本号,UPDATE 时带上条件 version = 读到的值,影响行数为 0 说明这一行已被别人修改,放弃或重试; +- 条件更新:把判断写进 UPDATE 的条件 stock > 0,判断和扣减由一条语句在行锁的保护下完成。 + +用 64 个线程模拟抢购库存为 100 的商品,每个用户请求 1 次,每种写法运行 3 轮(MySQL 8.0 实测,见[附录 A19](#a19-防止超卖的几种写法)): + +```text +写法 用户数 卖出 剩余库存 +先查询再扣减(对照) 1000 134~162 -62~-34 +悲观锁 FOR UPDATE 1000 100 0 +乐观锁,不重试 1000 70~83 17~30 +乐观锁,最多重试 3 次 1000 100 0 +乐观锁,最多重试 3 次 150 48~57 43~52 +条件更新 stock > 0 1000 100 0 +``` + +先查询再扣减多卖了 34~62 件。乐观锁不会超卖,但同一时刻读到同一个版本号的请求中只有一个能更新成功,其余请求即使还有库存也会失败:150 个用户抢 100 件,重试 3 次也只卖出了 48~57 件,出现了“少卖”。因此版本号适合冲突少的场景,例如修改个人资料;库存这类同一行被大量请求同时修改的场景用条件更新,它不需要重试,语句也最少。悲观锁同样正确,但锁要持有到事务提交,事务中的操作越多,其它请求等待得越久。 + +无论哪种写法,同一行的更新在数据库中都是串行执行的,单行能承受的写入量有上限,见[热点行](#1-热点行)。所以秒杀先用 Redis 挡住绝大部分请求,数据库的条件更新作为最后一道防线。 + +### Redis 与数据库的库存一致 + +库存同时保存在 Redis 和数据库中,两者无法在一个事务中同时修改,所以先要确定以谁为准:数据库中的库存和订单是最终结果,Redis 中的库存只是一道闸门,用来挡住注定失败的请求。因此可以接受 Redis 的库存暂时偏少(少卖,事后补回),但数据库不能超卖,数据库的条件更新始终保留。 + +按请求经过的步骤,可能出现的不一致和处理方法: + +- Lua 脚本扣减成功,发送消息失败:发送失败时立即执行一段反向的脚本,把库存加回、把用户移出已购集合;应用恰好在两步之间宕机时,这次扣减只留在 Redis 中,由对账发现。也可以用 RocketMQ 的[事务消息](分布式.md#本地消息表),先发送半消息,执行 Lua 脚本后再提交,应用宕机时由 Broker 回查用户是否在已购集合中; +- 消息已发出,订单服务创建订单失败:数据库库存不足、用户校验不通过这类业务上的失败,订单服务记录失败结果并回补 Redis;网络超时、数据库暂时不可用这类异常则依靠消息重试,订单表的唯一索引保证重试不会创建两个订单; +- 订单创建后用户未支付:超时取消订单时,在同一个事务中修改订单状态并把数据库的库存加回,提交后再回补 Redis,超时取消可以用[延迟消息](消息队列.md#延迟消息)实现。 + +回补 Redis 时要防止重复执行,例如在脚本中先 SREM 已购集合,返回 1(用户确实在集合中)才把库存加 1,同一条回补消息执行两次也只加一次。 + +对账在活动结束后或定期进行:Redis 中的已购用户应当与数据库中的订单一一对应,只出现在 Redis 中的用户说明消息丢失了,补发消息或回补库存。需要注意的是,库存只在活动开始前从数据库一次性加载到 Redis,活动进行中不要再从数据库同步,否则还在队列中的消息会让两边的数字互相覆盖。 ### 同类场景:抢券、抢红包与抽奖 @@ -569,6 +663,18 @@ Google SRE 对过载的建议是先返回降级的响应,即数据更少、计 数据库也有类似的拐点。MySQL 主键点查在 16 个连接时为 16.9 万 QPS,128 个连接时为 19.2 万 QPS,吞吐量只增加 14%,平均响应时间却从 0.095 ms 增至 0.668 ms(见[附录 A4](#a4-mysql-主键点查的并发扫描))。压测时客户端与服务端在同一台机器上,共用 10 个核心,吞吐量很快接近这台机器的上限,之后的并发只增加排队时间。热点行的更新则从一开始就没有并发收益。 +压测的结果要能代表生产,容易出错的地方有: + +- 环境:机器规格、实例数和数据量与生产一致,或者按比例缩小;表中只有一万行时,索引和缓冲池的表现与一亿行时完全不同; +- 数据的分布:读接口如果总是请求同一个商品,所有请求都命中缓存,测出的只是缓存的上限;请求的参数要按生产的访问日志回放或者按热度分布生成,才能得到真实的命中率和回源量; +- 读写分开:先单独压读接口和写接口,分别找到瓶颈(读接口通常是缓存和应用的 CPU,写接口通常是数据库的锁和连接池),再按生产中的比例混合压测; +- 压测客户端:客户端的 CPU、连接数和带宽要有余量,不能先于服务端成为瓶颈; +- 持续时间:每个并发级别至少运行几分钟,时间太短看不到连接池耗尽、GC 和缓存过期这类随时间出现的问题。 + +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))。压测安排在业务低峰期,并准备好随时停止压测的开关。 + 并发数 = 吞吐量 × 响应时间,这一关系即 Little 定律(Little's law),见[性能指标](#性能指标)。它可以用来估算同时在处理的请求数。例如 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,仍低于拐点。 @@ -1577,6 +1683,148 @@ return redis.call('HINCRBY', KEYS[2], ARGV[1], 1) -- 返回第几张,落库 订单 9001 取消,释放 70001(WHERE order_id = 9001 AND status = 1):影响 1 行 ``` +### A18 熔断器的状态变化 + +环境:Resilience4j 2.2.0(resilience4j-circuitbreaker),JDK 21.0.13,Apple M1 Pro。库存服务用一个方法模拟,inventoryDown 为 true 时抛出 TimeoutException: + +```java +CircuitBreakerConfig cfg = CircuitBreakerConfig.custom() + .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) + .slidingWindowSize(10) + .minimumNumberOfCalls(10) + .failureRateThreshold(50) + .waitDurationInOpenState(Duration.ofSeconds(2)) + .permittedNumberOfCallsInHalfOpenState(3) + .build(); +CircuitBreaker cb = CircuitBreaker.of("inventory", cfg); + +Supplier call = () -> { + try { + return cb.executeCallable(() -> queryStock(10086)); + } catch (CallNotPermittedException e) { + return "被熔断,降级:有货"; + } catch (Exception e) { + return "失败(" + e.getMessage() + "),降级:有货"; + } +}; +// 先调用 4 次;inventoryDown = true 后调用 9 次;等待 2.1 秒后调用 4 次; +// inventoryDown = false,再等待 2.1 秒后调用 4 次。状态变化由 onStateTransition 事件打印 +``` + +CircuitBreakerConfig.ofDefaults() 的输出中,failureRateThreshold=50.0、slidingWindowSize=100、minimumNumberOfCalls=100、permittedNumberOfCallsInHalfOpenState=10、automaticTransitionFromOpenToHalfOpenEnabled=false、slowCallDurationThreshold=PT1M。运行输出(方括号中是距开始的时间): + +```text +#01 库存 37 +#02 库存 37 +#03 库存 37 +#04 库存 37 +-- 库存服务开始超时 +#05 失败(读取超时),降级:有货 +#06 失败(读取超时),降级:有货 +#07 失败(读取超时),降级:有货 +#08 失败(读取超时),降级:有货 +#09 失败(读取超时),降级:有货 +#10 失败(读取超时),降级:有货 + [0.01 s] State transition from CLOSED to OPEN +#11 被熔断,降级:有货 +#12 被熔断,降级:有货 +#13 被熔断,降级:有货 +-- 等待 2 秒,库存服务仍然故障 +#14 失败(读取超时),降级:有货 + [2.12 s] State transition from OPEN to HALF_OPEN +#15 失败(读取超时),降级:有货 +#16 失败(读取超时),降级:有货 + [2.12 s] State transition from HALF_OPEN to OPEN +#17 被熔断,降级:有货 +-- 再等待 2 秒,库存服务已恢复 +#18 库存 37 + [4.22 s] State transition from OPEN to HALF_OPEN +#19 库存 37 +#20 库存 37 + [4.22 s] State transition from HALF_OPEN to CLOSED +#21 库存 37 +共调用 21 次,真正发给库存服务 17 次 +``` + +状态变化的事件在调用过程中触发,每次调用结束后打印,所以 OPEN → HALF_OPEN 出现在第 14 次和第 18 次调用的结果之后。 + +### 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 次,每个线程使用自己的连接(自动提交): + +```python +def naive(uid): # 先查询,大于 0 再扣减 + cur.execute("SELECT stock FROM d5_goods WHERE id = 1001") + if cur.fetchone()[0] <= 0: return 'soldout' + cur.execute("UPDATE d5_goods SET stock = stock - 1 WHERE id = 1001") + cur.execute("INSERT INTO d5_order VALUES (%s, 1001)", (uid,)) + return 'ok' + +def pessimistic(uid): # SELECT ... FOR UPDATE + c.begin() + cur.execute("SELECT stock FROM d5_goods WHERE id = 1001 FOR UPDATE") + if cur.fetchone()[0] <= 0: c.rollback(); return 'soldout' + cur.execute("UPDATE d5_goods SET stock = stock - 1 WHERE id = 1001") + cur.execute("INSERT INTO d5_order VALUES (%s, 1001)", (uid,)) + c.commit(); return 'ok' + +def optimistic(uid, retries=0): # 版本号,冲突时最多重试 retries 次 + for _ in range(retries + 1): + cur.execute("SELECT stock, version FROM d5_goods WHERE id = 1001") + stock, ver = cur.fetchone() + if stock <= 0: return 'soldout' + n = cur.execute("UPDATE d5_goods SET stock = stock - 1, version = version + 1 " + "WHERE id = 1001 AND version = %s", (ver,)) + if n == 1: + cur.execute("INSERT INTO d5_order VALUES (%s, 1001)", (uid,)) + return 'ok' + return 'conflict' + +def conditional(uid): # 条件更新 + n = cur.execute("UPDATE d5_goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0") + if n == 0: return 'soldout' + cur.execute("INSERT INTO d5_order VALUES (%s, 1001)", (uid,)) + return 'ok' +``` + +运行 3 轮的原始输出(耗时受机器负载影响,只作参考): + +```text +== 第 1 轮 +先查询后扣减 请求 1000 成功 162 售罄 838 冲突 0 剩余库存 -62 订单 162 122 ms +悲观锁 FOR UPDATE 请求 1000 成功 100 售罄 900 冲突 0 剩余库存 0 订单 100 183 ms +乐观锁(不重试) 请求 1000 成功 70 售罄 0 冲突 930 剩余库存 30 订单 70 134 ms +乐观锁(重试 3 次) 请求 1000 成功 100 售罄 708 冲突 192 剩余库存 0 订单 100 147 ms +条件更新 stock > 0 请求 1000 成功 100 售罄 900 冲突 0 剩余库存 0 订单 100 80 ms +先查询后扣减 请求 150 成功 122 售罄 28 冲突 0 剩余库存 -22 订单 122 79 ms +悲观锁 FOR UPDATE 请求 150 成功 100 售罄 50 冲突 0 剩余库存 0 订单 100 85 ms +乐观锁(不重试) 请求 150 成功 29 售罄 0 冲突 121 剩余库存 71 订单 29 41 ms +乐观锁(重试 3 次) 请求 150 成功 55 售罄 0 冲突 95 剩余库存 45 订单 55 64 ms +条件更新 stock > 0 请求 150 成功 100 售罄 50 冲突 0 剩余库存 0 订单 100 60 ms +== 第 2 轮 +先查询后扣减 请求 1000 成功 134 售罄 866 冲突 0 剩余库存 -34 订单 134 89 ms +悲观锁 FOR UPDATE 请求 1000 成功 100 售罄 900 冲突 0 剩余库存 0 订单 100 162 ms +乐观锁(不重试) 请求 1000 成功 83 售罄 0 冲突 917 剩余库存 17 订单 83 112 ms +乐观锁(重试 3 次) 请求 1000 成功 100 售罄 634 冲突 266 剩余库存 0 订单 100 155 ms +条件更新 stock > 0 请求 1000 成功 100 售罄 900 冲突 0 剩余库存 0 订单 100 83 ms +先查询后扣减 请求 150 成功 115 售罄 35 冲突 0 剩余库存 -15 订单 115 39 ms +悲观锁 FOR UPDATE 请求 150 成功 100 售罄 50 冲突 0 剩余库存 0 订单 100 81 ms +乐观锁(不重试) 请求 150 成功 38 售罄 0 冲突 112 剩余库存 62 订单 38 58 ms +乐观锁(重试 3 次) 请求 150 成功 48 售罄 0 冲突 102 剩余库存 52 订单 48 69 ms +条件更新 stock > 0 请求 150 成功 100 售罄 50 冲突 0 剩余库存 0 订单 100 56 ms +== 第 3 轮 +先查询后扣减 请求 1000 成功 147 售罄 853 冲突 0 剩余库存 -47 订单 147 87 ms +悲观锁 FOR UPDATE 请求 1000 成功 100 售罄 900 冲突 0 剩余库存 0 订单 100 139 ms +乐观锁(不重试) 请求 1000 成功 70 售罄 0 冲突 930 剩余库存 30 订单 70 93 ms +乐观锁(重试 3 次) 请求 1000 成功 100 售罄 646 冲突 254 剩余库存 0 订单 100 135 ms +条件更新 stock > 0 请求 1000 成功 100 售罄 900 冲突 0 剩余库存 0 订单 100 69 ms +先查询后扣减 请求 150 成功 126 售罄 24 冲突 0 剩余库存 -26 订单 126 62 ms +悲观锁 FOR UPDATE 请求 150 成功 100 售罄 50 冲突 0 剩余库存 0 订单 100 77 ms +乐观锁(不重试) 请求 150 成功 31 售罄 0 冲突 119 剩余库存 69 订单 31 56 ms +乐观锁(重试 3 次) 请求 150 成功 57 售罄 0 冲突 93 剩余库存 43 订单 57 69 ms +条件更新 stock > 0 请求 150 成功 100 售罄 50 冲突 0 剩余库存 0 订单 100 66 ms +``` + ## 参考资料 - [Redis: Scripting with Lua](https://redis.io/docs/latest/develop/programmability/eval-intro/) @@ -1597,6 +1845,9 @@ return redis.call('HINCRBY', KEYS[2], ARGV[1], 1) -- 返回第几张,落库 - [Checking object integrity for data uploads in Amazon S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity-upload.html) - [CompleteMultipartUpload - Amazon S3 API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html) - [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) +- [阿里云 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) - [MinIO 源码 cmd/api-errors.go、cmd/object-api-errors.go](https://github.com/minio/minio/tree/master/cmd)