高并发、一致性、稳定性与成本:怎样在约束下做可验证取舍
一个库存接口收到更多请求后,可能先耗尽工作线程,也可能堵在数据库锁上。增加应用实例能分担线程压力,却可能让更多连接同时争抢数据库锁。扩容决策要从具体等待位置开始,同时检查队列中的请求和已经完成的操作,确定接纳量以及必须保留的业务结果。
高并发系统的容量取决于具体工作量:一次商品查询、一次跨库报表和一次库存扣减,即使都算一次 HTTP 请求,也会消耗不同的资源。可靠性要求和数据承诺进一步决定可用方案。商品介绍可以短暂显示旧版本,扣减库存则需要检查当前可用数量;账单上的低成本只有在这些条件相同的情况下才有比较意义。
负载、排队与瓶颈怎样形成
先区分到达、接纳与完成
用户点击一次提交,客户端超时后又重试两次,服务端实际接到的是三次请求。统计这条业务时,至少要区分以下数量:
1 个业务操作:同一个 operationId
└─ 3 次 HTTP 尝试:首次请求 + 两次重试
├─ 接纳:进入服务端业务执行或等待队列
├─ 拒绝:没有获得本次执行资格
└─ 返回:可能是新执行结果,也可能是已保存结果的重放
业务结果单独计数:扣减成功、售罄、处理中、结果待确认……到达率表示单位时间进入某个观测位置的工作数,吞吐量表示单位时间完成的工作数,并发量表示同时处于该位置之内的工作数。它们的单位分别是“次/秒”“次/秒”和“个”。连接数又是另一项量:连接可能空闲,一条连接也可能先后或并行承载多个请求。
在稳定窗口、同一统计范围和适用的均值条件下,Little 定律给出 L = λW:平均在途量等于平均到达率乘平均停留时间。例如某处理范围平均每秒进入 1,000 个请求,每个请求平均停留 0.2 秒,平均在途量约为 200。这只是关系式的假设算例。W 若包括排队,L 就也要包括排队中的请求;不能把平均值换成 p99 后继续当作定律使用。Little 原始论文给出了相应均值关系及条件。
过载时,长时间进入的工作多于完成的工作,差额会积存在队列里。队列越长,新请求等待越久;客户端到期后重试,又增加了到达量。队列本身不提供额外处理能力。
延迟发生在什么位置
一次同步请求可能等待入口接纳、工作线程、连接池、数据库锁、磁盘或远程响应。顺序执行的步骤要累加耗时;并行分支的响应则通常受关键路径限制,还可能包含汇合与串行部分。简单地累加所有子调用耗时,会把并行执行算重。
| 观察到的现象 | 需要进一步区分的工作 |
|---|---|
| CPU 高、可运行线程多 | 算法、序列化、压缩、加密、GC,以及是否有重复计算 |
| CPU 低、业务线程大量等待 | 连接池借用、锁等待、下游响应和外部配额 |
| 平均延迟变化小,p99 明显上升 | 少量热点、慢分片、偶发长事务、队列突发 |
| 实例增加后数据库更慢 | 总连接数、同一热行的竞争、查询扫描量和 I/O |
| 单个租户批量任务影响普通请求 | 共享池、队列优先级和租户工作量是否有独立预算 |
线程池满时,先看线程在等什么:慢 SQL、远程调用和 CPU 饱和需要不同处理。没有查清等待位置就扩大池,可能只是把等待从应用移到数据库。
压测要保留没有完成的请求
固定并发客户端通常在收到响应后才发下一次请求。服务变慢时,客户端自身的发送速度也会下降。这适合测量某种固定并发使用方式,却可能漏掉实际用户仍以原速度到达时形成的积压。
测试记录应同时保留计划到达量、实际发送量、客户端生成能力、超时和拒绝数。只统计成功响应的延迟,会漏掉等待最久的一批请求。p99 还依赖样本量和窗口;不同实例的 p99 不能直接取平均作为全站 p99,应使用可合并的分布或原始样本重新计算。
数据量、热点分布、响应体大小、缓存冷热和依赖延迟也要与待比较的工作接近。均匀访问一百条内存数据所得的吞吐,无法直接决定千万行报表或热商品扣减的容量。Google SRE 的过载处理讨论了容量测量、队列和拒绝工作之间的关系。
用有界工作控制过载
构建可以观察队列的实验
下载并解压有界准入与库存幂等实验工程,进入 evolution-capacity-lab 目录。命令使用 Linux Bash、Docker Engine 和 Compose v2;普通宿主用户需要运行 Docker 的权限。无需本机安装 Java。
工程使用 Maven 3.9.12,源码编译目标为 Java 17;LAB_JDK 支持 17 或 25。构建容器显式使用宿主 UID/GID,本目录 .m2 保存可写依赖缓存,内存上限 2 GiB。运行时源码目录只读,Java 容器上限 512 MiB,JVM 堆上限 256 MiB。数据库实验另外启动 PostgreSQL 18.6,数据库不映射宿主端口。
export LAB_JDK=17
bash build.sh
bash run.sh admissionbuild.sh 执行 clean verify,应报告 3 个测试通过,并生成类文件和 target/dependency。测试使用实际 HTTP 服务,覆盖工作池饱和、排队时客户端超时以及运行中客户端超时;此阶段不连接数据库。
admission 再执行独立进程中的对照,关键输出如下。Java 补丁版本由所拉取的构建镜像决定:
overload attempts=12, rejected=3, completed=9
cancelled=client-timeout, completed=5, recovered=200
running-client-timeout=true, server-completed=2三行来自三个独立服务实例,计数不能累加解释为同一轮流量。第一轮先运行 4 个任务,再排队 4 个,额外 3 个被拒绝;释放等待后 8 个任务完成,随后新任务也成功,因此共有 12 次尝试、3 次拒绝、9 次完成。
工作线程和队列分别设上限
工程的业务池配置为:
new ThreadPoolExecutor(
4, 4, 0, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(4),
new ThreadPoolExecutor.AbortPolicy()
);这里最多有 4 个运行任务和 4 个排队任务。两处都满时,execute 抛出 RejectedExecutionException;HTTP 处理器捕获它,返回 503 和 OVERLOADED,并移除未获接纳的任务记录。执行器的排队和拒绝策略见 ThreadPoolExecutor。
HTTP 接入线程池与业务池分开,因此实验能在业务任务等待时响应 /inspect 和 /cancel。这只是业务池的局部保护。接入连接、慢速请求体、整个进程的内存、观测记录增长和租户公平性仍需在生产入口另行限制。不要把这段执行器配置直接当作完整网关。
队列上限也需要考虑请求还能等多久。如果请求剩余时间已经不足以覆盖预计等待和执行,把它接进队列往往只会增加无效工作。对确实需要长期排队的业务,应采用持久化任务和独立结果查询,而非让同步连接一直等待。
用 curl 观察拒绝、超时和恢复
一个终端启动手动目标:
bash run.sh serve-admission它只绑定宿主回环地址 127.0.0.1:18259,最多运行五分钟。每个受控任务最多等待释放信号 60 秒,避免忘记操作后永久占用线程。保持该终端运行,在第二个终端进入同一工程目录,先确认目标可达:
curl -q --noproxy '*' --fail-with-body --max-time 3 -sS \
http://127.0.0.1:18259/inspect初始响应应包含 "running":0、"queueSize":0 和 "jobs":{}。-q 位于第一个选项以跳过默认 curl 配置,--noproxy '*' 排除环境代理,成功请求使用 --fail-with-body 拒绝把 HTTP 错误当作成功。选项和退出码见 curl 官方手册。
对尚未接收工作的新目标运行:
bash verify-admission.sh脚本先发起 4 个任务,轮询到 running=4 后再发排队请求。它通过实际状态推进后续操作,没有仅靠请求发送顺序假定服务已经开始执行。
running=4,queued=0:四个工作线程已占用
running=4,queued=1:第五个请求客户端超时,队列项仍在
running=4,queueSize=4:继续填满队列
额外请求:HTTP 503,响应体包含 OVERLOADED
取消超时任务:CANCELLED_BEFORE_START,queueSize 降为 3
释放工作:七个未取消任务完成,新请求返回 200最终应出现:
HTTP overload=503, timeout exit=28, queued cancel succeeded, recovery=200预期的超时由脚本接管:curl 退出码必须为 28。预期的过载则同时检查 HTTP 503 和 OVERLOADED 响应体;连接不上目标、收到其他状态或没有观察到预期队列长度都会使脚本失败。原始响应保存在脚本报告的临时目录中,可用于核对每个请求。
此脚本完成后不能在同一个目标上原样重跑,因为旧任务记录仍在。停止手动目标后重新启动,再执行下一轮。实验用锁存器安排任务等待,输出用于验证处理顺序,不是 QPS 或延迟基准。
客户端到期以后,服务端任务可能仍在运行
HTTP 客户端超时表示本次等待没有在限定时间内完成。排队项不会因此自动消失,已经开始的数据库写入也可能继续提交。Java 客户端的超时异常定义见 HttpRequest.Builder.timeout。
工程用原子状态转换协调取消与开始执行:
QUEUED ──工作线程抢到执行资格──> RUNNING ──结束──> DONE
└────取消请求抢到取消资格──> CANCELLED工作线程只有成功把 QUEUED 改成 RUNNING 才执行;取消入口只有成功把 QUEUED 改成 CANCELLED 才返回“尚未开始即取消”。即使工作线程刚把任务从队列取出,只要取消先取得状态,任务也会跳过执行。仅调用队列 remove 无法处理这个竞争窗口。
任务已经运行或结束时,取消入口返回 409 和 ALREADY_STARTED_OR_FINISHED。此时应查询业务结果,或使用业务定义的撤销操作。中断线程能否停止具体 I/O、外部系统是否已经执行,都要分别确认。
把单个池的限制扩展到服务调用
请求速率限制控制一段时间允许进入多少次,并发限制控制当前允许占用多少工作槽。相同到达率下,依赖变慢会增大在途量,所以仅限制每秒请求数仍可能耗尽连接。租户或客户端配额可以返回 HTTP 429;临时整体过载可使用 HTTP 503。是否提供 Retry-After 取决于服务是否有可用的等待建议,调用方还需遵守自己的总时限。
同步调用链要传播剩余处理预算。上游已经耗时 800 ms,下游不能重新获得完整的 1 s 预算并把总时长继续推高。重试还要限制次数、总耗时与并发,并加入退避和抖动;多层各自重试可能成倍放大下游工作。数据库提交结果未知的写入,只有同一业务操作的安全重放规则成立时才应重试。
读请求使用对冲调用同样会增加尝试量。它可以改善某些慢副本造成的尾延迟,但需要额外容量和取消控制,不能在已过载的依赖上无预算复制请求。Google SRE 的负载管理给出了调用预算和负载控制的具体讨论。
并发写入怎样保持业务结果
同时扣减真实 PostgreSQL 库存
在已经构建的工程中执行:
bash run.sh inventory
bash run.sh inspectinventory 等待数据库就绪,然后以 cap_app 连接。该角色不是超级用户、数据库所有者或表所有者,只获得实验表的 SELECT、INSERT、UPDATE 权限;初始化管理员负责建库、建表与授权。固定口令只用于隔离的本地实验,不可用于公开服务。
初始化要求 stock 和 operation 两张表为空。第一种商品有 10 件库存,程序并发发送 20 次实际 HTTP 扣减,每次数量为 1、操作 ID 不同。首段输出应为:
databaseUser=cap_app, superuser=false
attempts=20, succeeded=10, soldOut=10, stock=0, operationRows=20成功请求返回 200 / DEDUCTED,库存不足返回 409 / SOLD_OUT。两类已确定的业务结果都会保存,以便同一操作重试时得到原响应。非法输入则在进入业务事务前返回 400:包括错误字段、非整数数量、尾随第二个 JSON 根值、非法尾随字符和超过上限的请求体。它们不创建操作记录。
程序会检查实际状态和数据库内容。PostgreSQL 没启动、连接失败或 SQL 超时都会非零退出,不能归入“预期售罄”。
库存条件和结果记录放在同一事务
库存扣减使用以下 JDBC 参数化语句,问号由 Java 绑定,不是可直接粘贴进 psql 的变量:
UPDATE stock
SET available = available - ?
WHERE sku = ? AND available >= ?
RETURNING available;服务端没有先在 Java 中读取余量、判断后再无条件覆盖。数据库在更新时检查剩余数量,只有满足条件才扣减,并返回更新后的值。PostgreSQL 的 UPDATE 文档定义了条件与 RETURNING 的结果;在当前 Read Committed 隔离级别下,并发更新等待后会对相应更新版本重新检查条件,具体行为见事务隔离文档。
整个事务按以下顺序执行:
BEGIN
插入 operationId 与规范化请求摘要,唯一键冲突则读取已保存结果
对首次取得该操作资格的请求执行条件扣减
保存 HTTP 状态与稳定响应体,包括已确定的售罄结果
COMMIT
返回客户端operation.id 的唯一约束协调相同 ID 的并发请求。INSERT ... ON CONFLICT DO NOTHING RETURNING id 成功插入后,当前事务才负责执行扣减;遇到已提交的相同操作则读取原结果。若冲突行属于未完成事务,数据库会等待其决定。在当前 Read Committed 模式中,后续查询语句取得新快照,从而读取已提交结果。语句的并发语义见 INSERT / ON CONFLICT。
事务中途失败会回滚操作记录和库存变更,不留下已经保存成功但未扣减的半成品。这个结论适用于当前同库事务;加入远程支付或另一个数据库后,需要为新增副作用另行设计。
重放原结果与重新计算结果的差别
假设一次扣减成功时剩余库存为 2,之后其他操作把库存减到 1。客户端重发原操作时,工程返回原来的 remaining:2,表示那一次操作完成后的结果。当前库存查询可以返回 1,两者回答的问题不同。
请求摘要由解析后的商品和整数数量形成,JSON 空白和字段顺序变化不会改变业务含义。同一 ID 改变数量则返回 409 / OPERATION_CONFLICT,不会再次执行。实际接口还需把租户、操作类型和契约版本纳入键或摘要,并确定记录保留时间;示例没有提供这些生产身份功能。
确定的售罄结果同样保持不变。后续补货后,旧操作重试仍返回原售罄;如果用户确实要发起新的购买尝试,应使用新的业务操作 ID。对于结果未知的网络超时,不能擅自换 ID,否则服务端可能把重复购买当成新操作。
提交后断开响应,再查结果与重试
工程在另一种商品上注入一个真实故障:数据库扣减和操作结果已经提交,HTTP 处理器随后主动关闭首个响应,客户端实际收到传输异常。程序接着读取数据库,并以原操作 ID 重试,最后再发一个新操作。
after-commit-disconnect=IOException
final stock=1, unknown=1, operationRows=23, replayBody={"code":"DEDUCTED","remaining":2}unknown 是第二种实验商品的名称。它初始有 3 件库存:响应断开那次已经减为 2,同键重放保持 2,新操作再减为 1。第一种商品经过受控补货与新操作后也剩 1;两种商品合计有 23 条已确定的操作记录。
独立的 inspect 进程应输出:
stock=1, unknown=1, operations=23需要查看具体保存值时,在同一工程目录执行只读查询:
docker compose exec -T -e PGPASSWORD=local-capacity-demo postgres \
psql -h 127.0.0.1 -U cap_app -d cap_lab \
-c 'TABLE stock' \
-c 'SELECT id,status,response FROM operation ORDER BY id'再次运行 inventory 会拒绝 LAB_NOT_EMPTY 并保留已有数据。要独立比较另一个 JDK,可设置一个新的、明确的 COMPOSE_PROJECT_NAME,重新构建并使用另一组卷;只切换 Java 版本后重复连接旧库,不构成一次新的库存实验。
不同操作可以采用不同的数据承诺
商品描述、搜索结果和统计报表可以允许一定程度的延迟,但需要说明允许旧到什么程度,以及超过时限后显示提示、回源还是暂不可用。用户刚写完后要读到自己的修改,可以把该查询路由到已确认版本,或等待投影达到该版本。
库存、账户额度和权限判定要按具体规则检查当前可用于决策的状态。缓存中的旧余额适合展示还是能够授权扣款,必须在业务操作层决定。为了保持可用而跳过这项检查,会直接改变可承诺的业务结果。
异步处理适合不必同步完成的导出、通知和长任务。接纳之前应可靠保存任务,并提供操作标识、进度或结果查询。返回 HTTP 202表示接纳处理,业务仍可能在稍后失败。消费者积压、重复投递和恢复所需时间都应进入容量估算,不能只观察接单接口变快了多少。
在相同承诺下比较方案与成本
每种扩展方式都有适用的瓶颈
| 可考虑的调整 | 能改善什么 | 同时需要测量什么 |
|---|---|---|
| 修复查询、索引或重复计算 | 减少每次请求消耗 | 实际执行计划、写放大、结果是否相同 |
| 增加应用实例 | 分散可并行的应用计算 | 数据库总连接、下游配额、故障后剩余容量 |
| 增大线程或连接池 | 原先确实偏小且下游有余量的并发 | 锁等待、切换开销、内存及下游延迟 |
| 缓存与只读副本 | 减少重复读取或分担查询 | 命中分布、过期回源、版本延迟和热键 |
| 批处理与异步任务 | 摊薄固定开销、平滑短时峰值 | 等待时间、积压上限、重复处理和恢复速度 |
| 数据分片 | 分散可按键划分的存储工作 | 热分片、跨分片查询与事务、再均衡成本 |
单个热门商品仍落在同一条库存记录上时,增加其他分片不能消除这条记录的竞争。将库存拆成配额桶可以改变争用方式,但要另外处理配额分配、回收和剩余量展示,不能仅按分片数量推算吞吐。
虚拟线程降低了大量阻塞任务使用平台线程的负担,却不会增加数据库连接、磁盘带宽或下游配额。服务仍需限制进入这些稀缺资源的工作量,JEP 444也说明了对稀缺资源使用单独并发限制的做法。无论采用何种执行模型,队列长度和等待时间都应能被观察。
降级保留哪些结果
过载时可以先关闭成本较高的非必要推荐,减少可选聚合字段,或暂缓低优先级报表。返回旧的商品介绍时可保留版本或更新时间含义,让调用方知道数据的新鲜度。权限检查和库存约束则不能作为节省时间的可选步骤。
对不同租户或业务优先级设置独立工作预算,可以防止批量导出耗尽交互请求的全部连接;全局上限仍需存在。所有请求都被标成最高优先级时,优先级就失去了保护作用。
恢复流量也需要节奏。重新接入的实例可能尚未预热缓存,恢复中的数据库可能正在回放或补偿。先恢复一部分工作,观察队列是否持续下降、关键操作能否完成,再逐步增加接纳量。立刻释放全部重试与积压任务,可能再次触发过载。级联故障处理讨论了这种恢复负载对系统的影响。
用成功业务量计算单位成本
单位成本需要明确时间窗口、成本范围和业务分母。例如:
每个成功创建订单的成本
= 同窗口内为该工作分摊的计算、存储、网络、观测与运营成本
÷ 去重后的成功订单数同一个订单的三次重试仍然只对应一笔业务。用 HTTP 请求量作分母,重复工作越多,算出的单位成本反而可能越低。
售罄属于正确的业务拒绝,可以计入“正确处理的请求”,却不计入“成功订单成本”的成功订单数。两项指标可以同时保留,各用自己的分母。
下面仅用假设预算说明比较方法,不对应厂商价格或实验实测。两方案均完成一百万个去重后的成功操作,并采用相同流量、延迟目标、数据保留与恢复要求:
| 同窗口费用,预算单位 | 方案 A | 方案 B |
|---|---|---|
| 计算 | 1,400 | 1,000 |
| 存储 | 400 | 600 |
| 网络 | 300 | 400 |
| 日志、指标与追踪 | 200 | 300 |
| 分摊的运营工作 | 700 | 1,200 |
| 合计 | 3,000 | 3,500 |
| 每个成功操作 | 0.003 | 0.0035 |
方案 B 的计算费用较低,总费用却更高。是否值得采用,还取决于它是否满足 A 做不到的扩展、恢复或隔离要求。一次性迁移成本可另列,或按明确周期摊销,不能在不同方案中选择性遗漏。
SLO 的分母也应稳定定义。系统因自身过载拒绝了合格请求,这通常影响用户可用性;不能把所有拒绝都剔除后宣称成功率上升。配额外请求、无效输入与正常业务拒绝如何计入,应按用户操作和服务承诺分别规定。实施 SLO提供了以用户体验定义指标的具体方法。
故障时先定位消耗,再验证新工作能完成
实验中出现异常,可先执行以下只读观察:
docker compose ps
docker compose logs --tail 80 postgres
docker stats --no-stream
bash run.sh inspectdocker stats 会列出当前主机容器,核对本组名称后再解释资源占用;其他服务的数据不能计入当前实验。inspect 需要数据库已经启动,连接失败时先修复依赖,不能据此推断库存为空。
| 现象 | 下一步判断 | 恢复后应验证 |
|---|---|---|
| 业务池满,工作线程都在等待 | 查看等待的是实验释放信号、连接、锁还是下游 | 旧队列下降,新请求实际完成 |
| curl 退出 28 | 查询服务端任务状态或已保存业务结果 | 同键重放稳定,未重复执行 |
| SQL 锁等待超时 | 检查长事务及热点更新,保留原操作 ID | 等待消除后重试原操作,再完成新操作 |
LAB_NOT_EMPTY | 当前卷已使用,运行只读查询核对 | 旧结果保留,新实验使用明确的独立项目 |
| 错误率下降但业务完成量也骤降 | 检查拒绝数、分母定义和实际接纳量 | 相同合格负载下的完成量与延迟恢复 |
实验结束后执行:
bash run.sh stop这会停止本组服务并保留数据库卷。手动服务器也可在其终端按 Ctrl+C 停止。完整容量评估还需要生产相近的数据与到达模式,以及少一个实例、冷缓存和依赖变慢等条件;当前受控实验提供的是拒绝、取消和库存重放的行为验证。
权威资料与规范地址
均值关系、执行器行为、HTTP 状态和数据库并发规则可查阅以下原始资料:
容量、执行器与负载控制
- Little:A Proof for the Queuing Formula L = λW
- Google SRE:Handling Overload
- Java 17:ThreadPoolExecutor
- Google SRE Workbook:Managing Load
- OpenJDK:JEP 444 Virtual Threads
HTTP 与客户端行为
- curl:命令手册与退出码
- Java 17:HttpRequest.Builder
- RFC 6585:429 Too Many Requests
- RFC 9110:503 Service Unavailable
- RFC 9110:202 Accepted
