先确定“处理成功”,再讨论每秒处理多少
高并发系统的目标,是在正确性、延迟和成本约束下持续完成业务。入口返回得快,却把任务无限积压在队列里,不等于系统处理能力提高;订单接口返回成功,却没有可靠保存订单,也不能算有效吞吐。
下面以商品查询与下单链路为例,依次回答三个问题:瓶颈在哪里,哪些改动能增加有效容量,以及流量超出容量时如何保留正确行为。示例数字用于解释计算方法,不代表已经测得的生产容量。
理论模型帮助限定推论
吞吐、延迟和在途量必须使用同一口径
至少同时记录到达速率、成功吞吐、拒绝与失败数量、端到端延迟、在途请求和资源消耗。平均延迟适合容量关系分析,P95、P99 等分位数描述尾部体验;二者都不能单独概括系统。
若一次请求必须等待多个下游全部完成,较慢的分支会影响最终响应。假设 100 个分支各自有 1% 概率超过某个阈值,且事件相互独立,则至少一个超过阈值的概率为 1 - 0.99^100 ≈ 63.4%。实际分支常有相关性,因此这只是说明扇出效应的模型,不能直接当作生产尾延迟预测。The Tail at Scale
在稳定且统计边界一致的系统中,Little 定律表示为:
L = λ × W
L 是平均在途数量,λ 是平均有效到达速率,W 是平均停留时间。例如系统稳定处理 2000 次/秒,平均请求停留 50 ms,则平均约有 100 个请求在途。这不是“线程池必须配置 100 个线程”的公式:请求可能在连接池排队、由异步事件处理,或同时占用多个下游资源。Little 原始论文
反过来用“线程池 200 × 平均响应时间 50 ms”推导最大 QPS,也需要线程始终满载、每请求占一线程、没有其他瓶颈等条件。真实容量仍应通过测量确认。
并行扩展受串行部分与协调开销约束
Amdahl 定律讨论固定工作量下的理想加速比:
S(N) = 1 / (σ + (1 - σ) / N)
σ 是基准执行时间中的串行比例,不是串行代码行数。若 σ 为 5%,理想无限并行的上限为 20 倍;真实系统还会受到调度、通信和资源竞争影响。
USL 用另一种模型描述扩展曲线:
C(N) = N / (1 + α(N - 1) + βN(N - 1))
α、β 可通过特定负载的测量拟合,用来讨论竞争和协调开销。出现拐点时,应继续定位锁、网络或共享资源,而不是看到公式的二次项就断定所有系统的协调开销必然按节点平方增长。Gunther 的 USL 论文
这些模型帮助形成检查方向:扩容前测量工作量是否可拆分,扩容后检查共享依赖是否成为瓶颈。它们不能替代具体调用链和执行计划。
一致性约束不能被“性能优先”省略
CAP 中的 C 通常指线性一致性,A 指非故障节点收到的每个请求都能得到协议要求的响应。网络分区下不能同时满足这两项要求;它不意味着平时所有副本在物理上同一瞬间更新,也不是所有缓存问题的唯一解释。
商品展示可以接受一定陈旧度,账户扣款却有不同约束。同一系统的不同操作可以采用不同策略,不能给整个互联网业务统一贴上“AP 优先”。最终一致性也需要可靠传播、冲突处理和恢复机制,不会因为用了消息队列自动成立。
计算层:增加处理能力,限制资源占用
扩容先看共享依赖
无状态应用便于负载均衡,但有状态服务也可以通过分片扩展。把会话放到 Redis、改用 JWT 或增加服务实例,都不会自动消除数据库、配置中心和下游容量限制。
扩容前应明确每次业务操作消耗多少数据库连接、CPU 时间和外部调用。如果应用实例翻倍、每实例连接池保持不变,数据库接收的最大连接数也会翻倍。若数据库已经饱和,应用扩容可能让排队更严重。
服务拆分的依据是需要独立扩容、独立发布或隔离的业务边界。不同模块 QPS 相差十倍可以成为调查线索,却不是必须拆分的阈值;进程内资源隔离有时已经足够。
异步 I/O 与异步业务是两种改动
异步 I/O 在等待网络时不持续占用一个工作线程,但不会自动减少远端处理时长,也不能突破下游容量。把 JDBC 阻塞调用直接放进事件循环,还会抵消这种收益。
异步业务则改变响应承诺:下单接口先返回“已受理”,最终结果稍后查询。它需要持久化任务、明确状态和重试语义。支付也可以采用异步受理,只是不能把“受理成功”写成“扣款成功”。
消息队列削峰使用缓冲空间和等待时间吸收差额。若到达速率为 5000 次/秒、处理速率为 2000 次/秒,持续 10 秒会积压约 30000 个任务。之后到达速率降到 1000 次/秒,净消化速率只有 1000 次/秒,还需约 30 秒清空。若长期到达速率不低于处理速率,队列只会继续增长。
池与并发限制保护的是具体资源
连接池减少重复建连成本,但越大并不越快。应测量连接持有时间、等待时间、数据库并行能力,以及所有实例的连接总量。HikariCP 对池大小的讨论强调“小池也可能更快”,其中经验公式不能写成适用于所有 PostgreSQL 工作负载的官方标准。HikariCP 池大小说明
下面是一个同步调用的局部并发保护器,可在 JDK 17 编译。它只限制当前进程中的指定资源调用,不限制每秒请求数:
import java.util.concurrent.Callable;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.Semaphore;
public final class Bulkhead {
private final Semaphore permits;
public Bulkhead(int maxConcurrent) {
if (maxConcurrent <= 0) {
throw new IllegalArgumentException("maxConcurrent must be positive");
}
permits = new Semaphore(maxConcurrent);
}
public <T> T call(Callable<T> action) throws Exception {
if (!permits.tryAcquire()) {
throw new RejectedExecutionException("resource concurrency exhausted");
}
try {
return action.call();
} finally {
permits.release();
}
}
}
许可只在成功获取后释放,异常也会释放。调用方必须把拒绝转成明确响应,并给实际 I/O 设置超时。若返回的是异步 Future,这个同步包装器会过早释放许可,应改为在异步完成时释放。
线程池还要设置有界队列和拒绝策略。CallerRunsPolicy 会把任务交给提交线程;在事件循环或关键接入线程上使用它,可能把下游阻塞传回上游,不能无条件称为安全的限流方案。
数据层:减少工作量,同时定义数据新鲜度
缓存命中率是结果,不是配置保证
商品详情可以缓存,但价格、库存、用户权限等数据有不同的新鲜度要求。先明确允许多旧、失效如何传播、缓存不可用时能否查库,再选择本地缓存、Redis 或多级缓存。读多写少也不必然需要两级缓存。
| 模式 | 主要职责 | 仍需处理的边界 |
|---|---|---|
| Cache Aside | 应用查缓存,未命中时加载并回填 | 回填与更新竞争、失效失败 |
| Read Through | 缓存组件封装读取与加载 | 写入策略需要另行定义 |
| Write Through | 经缓存组件同步写入后端 | 两处失败与并发读的协调,不自动等于强一致 |
| Write Behind | 先接收写入,随后异步落后端 | 持久性、积压、重试与丢失范围 |
数据库提交后失效缓存,是常见起点。一个关键竞争是:读线程读到旧数据库值,写线程提交并删除缓存,旧读线程随后又把旧值回填。延迟再删一次只能覆盖部分时序,固定等待 500 ms 不能证明一致性,进程内延迟任务也可能随宕机丢失。
可按业务要求结合事务提交后的可靠失效事件、TTL、版本约束及对账。Outbox 能保证失效事件不会因业务提交与发消息之间的崩溃而消失,但它本身仍不能让所有并发读瞬间一致;严格读取应使用权威状态或更强的协调协议。
穿透、击穿与雪崩分别处理
布隆过滤器对已正确加入的集合没有假阴性,但更新延迟、重建遗漏和集合选错,会让新商品被业务层误判不存在。用于前置拦截时,要把同步与切换过程纳入设计。
空值缓存要有明确类型和较短有效期,读取时区分“真实商品”“确认不存在”和“缓存未命中”。热点重建可以做请求合并;跨进程使用锁时还要处理唯一持有者标识、释放校验、租约到期和超时等待,不能用固定值加锁后无条件删除,更不能无限递归重试。
随机 TTL 可以分散同时到期,但不能处理 Redis 整体故障。回源仍需并发预算。热点只读副本可以分散读取,余额或库存计数器不能随意复制成多个 key 各自修改。
读副本、分片与读模型各有前提
半同步复制确认不等于副本已应用事务。写后固定三秒读主库只能降低概率,不能保证复制落后超过三秒时仍满足读己之写。对强要求的读取,可以访问权威主库,或使用复制位置等机制确认目标副本已达到所需进度。
分库分表应由容量、写入、热点或运维边界驱动,不存在通用的“一千万行就必须分表”。同实例拆表也不等于增加实例的总 I/O 能力。路由键要同时考虑写入所有权与主要查询,跨分片事务、迁移和查询聚合都应计入成本。
全文检索可以使用专门索引。普通 B-tree 通常无法对前导通配的 LIKE '%keyword%' 做有效范围定位,但仍可能出现覆盖索引扫描,不能概括成“绝不会用索引”。
CQRS 区分命令与查询模型,可以在同一个数据库内实现,不必强制两套技术栈。若读模型通过事件更新,就要暴露或处理延迟、重复事件、删除和重建,而不是把“AP”当作省略这些工作的理由。
流量层:把准入与资源预算联系起来
CDN 可以承接可缓存资源,内容哈希文件名便于长缓存与版本更新。但动态个性化数据、鉴权和缓存键必须配套,不能承诺所有静态请求都永不回源。
入口速率限制、资源并发限制和租户配额解决不同问题:
| 控制方式 | 约束对象 | 需要观察 |
|---|---|---|
| 令牌桶等速率限制 | 一段时间内的准入量与允许突发 | 通过量、拒绝量与突发后的排队 |
| 并发限制 | 同时占用某资源的请求数 | 在途数量、持有时长和等待 |
| 有界队列 | 允许暂存的任务数 | 积压年龄、剩余容量与消化速度 |
| 配额 | 用户或租户可消费的总量 | 持久账本、重复请求与周期边界 |
固定一秒窗口允许每窗 100 次时,边界前后各 100 ms 都可能放入 100 次,即 200 ms 内通过 200 次。这才是窗口边界突发;“两秒内通过 200 次”本身并没有超过平均每秒 100 次的额度。
滑动日志可以精确统计指定时间区间,分桶或加权滑动窗口则可能是近似。漏桶若只做准入判断,并不会把已放行请求自动排成匀速输出;真正整形需要有界队列与调度器。令牌桶允许在容量范围内突发,也需要验证下游能否承受。
Redis Lua 能让单实例内的读改写原子执行,但分布式限流还要考虑时钟、状态过期、故障转移和不可用策略。限流器不是库存或支付账本,不能把其近似额度当作资金一致性保证。
阈值由负载分布、延迟目标和故障冗余确定,不固定为压测峰值的某个百分比。被限流时给出明确状态;可重试时提供合理退避信息,避免客户端立即重试造成更多流量。
容错层:故障时保留正确的业务含义
超时、重试和熔断共同使用预算
超时从端到端期限倒推,区分建连、排队、读取和业务执行。用下游 P99 乘一个固定倍数,可能已经超过整个用户请求的期限。
重试前必须确认操作可安全重复,并区分“明确失败”与“结果未知”。调用链每层最多三次尝试,两层嵌套最多放大到九次;若是“额外重试三次”,每层总计四次,两层最多十六次。尝试次数和重试次数不能混写。
由了解业务语义且能管理剩余期限的一层协调重试,限制总次数并加入退避、抖动。取消本地等待不一定取消远端副作用,支付超时后尤其不能换一个新幂等键重新扣款。
熔断器根据窗口统计进入 Open,在恢复阶段限制探测数量。自行写几个原子计数器并不足以实现正确状态机:旧请求的迟到结果可能干扰新状态,Half-Open 也需要控制并发。生产实现可使用具备这些状态管理能力的库,并配置最小样本量与探测策略。Resilience4j 熔断器说明
熔断不替代并发限制:在达到统计阈值之前,请求仍可能大量在途。两者应分别保护故障传播和资源占用。
降级不能伪造成功
推荐不可用时可以展示已缓存的热门列表;库存未知时应显示暂时无法确认,而不是返回“有货”。优惠金额不能随意丢弃后继续按另一价格扣款;审计日志也不能因为被称为辅助功能就默认丢弃。
核心交易同样需要在容量不足时拒绝或排队,不能把“核心不降级”理解为无限接受。需要提前定义哪些能力可简化、哪些状态必须保留,以及拒绝后用户怎样查询已有操作。
动态开关还需要实际的配置监听或刷新机制。给 @Value 字段加 volatile 只影响内存可见性,不会让配置中心的变更自动写入字段。
隔离与反压都要检查传播边界
独立线程池、连接预算和进程可以限制相互干扰;SET 按业务归属组织故障隔离单元,灰度泳道通常按版本或流量标签组织,两者不能直接等同。共享数据库、网络和控制面仍可能把故障传出局部范围。
反压允许下游表达处理能力,但只有需求信号能穿过中间层时才有效。如果一个中间层无界缓存,压力只是被藏起来。限流、反压和有界排队可以共同使用,不是谁在所有场景下更优。
用下单链路核对完整性
商品浏览可使用缓存与 CDN,下单则应重新核对当前价格、权限和库存。一个数据库内的简化方案是,在同一事务中保存订单、执行有条件的库存更新,并记录待发布事件。
以下 SQL 仅展示库存更新条件,调用前必须把数量校验为正整数,绑定参数,并检查受影响行数:
UPDATE inventory
SET available = available - :quantity
WHERE sku_id = :sku_id
AND available >= :quantity;
零行更新表示商品不存在或额度不足,订单事务不能继续提交为成功。订单还需要业务唯一键;重复请求应返回原订单状态,而不是再次扣库存。
若业务与事件记录共享同一数据库事务,提交后发送器可以反复发送 Outbox 事件。消息必须携带稳定事件 ID;消费端在自己的事务中将去重记录与业务效果一起提交。发送成功但标记失败时会重发,所以这不是“只投递一次”的保证。
2PC 解决原子提交,不单独保证所有操作的隔离级别;TCC 通过预留、确认与取消表达业务协议;Saga 的补偿也不等于数据库回滚。方案选择取决于业务不变量和中间状态能否被接受。
引入 Redis 预扣时,要额外解释预扣记录、消息和最终订单之间如何恢复。单次 Lua 扣减原子成功,随后发消息失败,仍会留下无法自动结算的预占。预扣需要可恢复凭据、幂等业务 ID、过期处理和对账;支付结果未知时不能直接释放库存并开始另一笔扣款。
验证:从可观测的症状到有区分度的实验
RED 观察请求速率、错误和时长,USE 观察资源利用率、饱和与错误。CPU 达到 90% 是线索,不是根因证明;还要看热点栈、调度等待、锁和负载变化,确认减少相应工作是否改善目标指标。
链路追踪依赖上下文传播。头部采样已经丢弃的链路,不能在末尾出错后凭空恢复完整信息;尾部采样可以按结果保留,但需要采集与缓冲容量。监控应说明实际采集范围,而不是承诺固定采样下“错误必然全部可见”。
压测至少区分三种问题:
| 实验 | 回答的问题 | 关键观察 |
|---|---|---|
| 梯度加压 | 正常负载下何时开始不满足目标? | 有效吞吐、尾延迟、错误、资源等待 |
| 持续超载再恢复 | 队列和重试是否导致恢复困难? | 积压年龄、拒绝、取消与消化速度 |
| 依赖变慢或退出 | 隔离和接管是否符合预期? | 核心业务成功率、重复副作用、恢复耗时 |
压测客户端也可能成为瓶颈。固定并发、收到响应后才发下一次的闭环负载,会在服务变慢时自动降低到达速率;模拟独立外部到达时,应核对调度延迟与遗漏等待,避免漏计最差时段。
影子流量不仅要隔离数据库,还要隔离支付、短信、邮件和外部回调。内部压测标记不能任由外部请求伪造,标记丢失或未知时也不能默认落到真实写入路径。
容量估算可以用“峰值需求 / 满足延迟目标的单节点吞吐”作为起点,再考虑不均匀流量与故障冗余。例如 10000 QPS、单节点 2000 QPS、系数 1.5,结果向上取整为八台;只有扩展效率和共享依赖符合假设时,这个数才有意义。
弹性伸缩还需要指标管道、资源配置、启动与预热时间。自动扩容不能即时解决秒级突发,也不能靠增加应用实例修复数据库热点。
架构演进由证据推动
日活、代码行数和单表行数都不足以单独决定是否微服务、分库或 SET 化。同样的日活,访问峰值、单次请求成本和一致性要求可以完全不同。
更有用的决策顺序是:先固定业务成功标准和负载,定位占用时间与资源,再选择能减少该项工作的改动,最后在正常、超载和故障场景下验证。索引、缓存、异步化和拆分都可能有效,也都可能把成本转移到另一个位置。
高并发设计的成熟度体现在容量边界清晰:正常负载下完成承诺,超出容量时有序拒绝,局部故障时不制造错误业务状态,恢复后能解释每笔操作去了哪里。