高性能 · 高并发 · 高可用 · LITTLE 定律 · 几个 9 · 超时 · 舱壁 · 降级 · 灰度 · 监控

三高要学的,是一本账

“三高”听起来是三门功课,其实是同一个系统的三个指标。性能和并发用一条公式连着:同时在忙的线程 = 每秒请求数 × 每个请求的耗时,下游一慢,能扛的并发就塌。可用靠两件事:多备一份,再把坏掉的部分关在小范围里。缓存、异步、加机器、限流、熔断,每一招都在拿一致性、成本或复杂度去换,所以要先算账,再选招。

假设场景你在做一个电商的下单系统:Spring Boot + MySQL + Redis + Kafka,平时每秒两三百个请求,大促那晚要翻二十倍。日常写代码时要知道哪些写法会埋雷,上线(投产)时要知道该配什么、该看什么、出事了怎么办。本页是一张总地图:④ 把知识点挂到一条请求链路和一条上线流程上,⑥ 给出开发和投产两张清单,很多点链到仓库里已经讲透的页面。

①要解决的问题:三个“高”,各量的是什么

老板说“大促不能出问题”,落到工程上是三句能量出来的话。先把三个词换成三个指标,后面的讨论才不会各说各的。

高性能:一个请求要快 网络 应用代码 MySQL 一个请求 RT ≈ 20 + 30 + 40 = 90 ms 每个点是一个请求:大多数很快, P99 看的是右边那种慢的 指标:RT、P99 目标举例:P99 < 200 ms 高并发:同时来的都接住 工作线程(有限) 每秒进来 4000 个,每个占 0.05 秒 → 同时在忙的 = 4000 × 0.05 = 200 指标:QPS、在途并发数 目标举例:大促峰值 4000 QPS 高可用:坏了也不停 99.9% 一年停 8.8 小时 99.99% 一年停 53 分钟 99.999% 一年停 5 分钟 指标:可用率(几个 9) 目标举例:下单链路 99.99%
红条的长度按停机时间画。每多一个 9,允许停机的时间少到十分之一,靠人半夜爬起来处理已经来不及,必须靠系统自己切换和自保。

日常开发管“上限”

一条 SQL 有没有走索引、一次远程调用有没有超时、循环里是不是一条条查库,这些在写代码时就决定了单机能跑多快、出事时会不会连锁倒下。

投产管“下限”

机器开几台、池子开多大、怎么发版、看哪些监控、出事先关哪个开关。代码写得再好,发版时把正在处理的请求砍掉,照样是事故。

这页要回答

三高到底有哪些知识点?它们之间是什么关系,先学哪个?日常写代码和上线各要守哪些规矩?

②不用它会怎样:一台机器、一个库、全同步

最朴素的做法:一个 Spring Boot 应用直连一个 MySQL,下单时顺手同步调用积分服务和短信服务。平时跑得好好的,问题都出在大促那晚。

用户 平时 200 QPS HTTP 一台 Spring Boot Tomcat 200 个工作线程 查商品 · 下单 · 都在这些线程上跑 MySQL(单库) 连接池 10 个连接 积分服务 同步调用,没设超时 短信服务 同步调用,没设超时 每次都查 等它回 等它回 1 2 3 4 红圈 = 大促那晚出事的地方
1流量 ×20:线程不够用
表现
200 个线程全忙,新请求排队,RT 从几十毫秒涨到几秒。能做,但不完美:加大线程数只是把排队挪到数据库。
2每次都查库:数据库先倒
表现
热门商品被反复查,10 个连接排长队,拿不到连接的线程干等 30 秒(HikariCP 默认 connectionTimeout)。
3积分一慢:全站一起挂
表现
积分服务从 30 ms 变成 2 秒,下单线程全卡在等它,连不调积分的查商品也排不上队。老办法做不到:同步调用加共享线程池,一个依赖的慢会传给所有接口。
4只有一台:宕机和发版都是停机
表现
机器挂了就全挂;每次发版重启,正在处理的请求被砍断,重启那几十秒全部报错。老办法做不到:只有一份,就没有可以切过去的地方。
缺的那块

1、2 是容量问题:每个请求占用资源太久,或者资源太少。3、4 是故障范围问题:一处坏了,没有东西把它挡住,也没有备份顶上。

所以三高的精华就两句:用 Little 定律算清资源账;用冗余和隔离把故障关在小范围里。

典型误用以为“三高 = 上微服务 + 加机器”。加机器救不了 3:积分慢了,每台机器的线程照样被占满,加多少台都会被占满。拆成微服务后调用链更长,每多一个同步依赖,整体可用率就再乘一个小于 1 的数(④ 洞察 2)。

③几种流派:四类手段,各换来什么、付出什么

书上和面试里列的三高手段有几十个,归一归只有四类。前两类解决容量,后两类解决故障范围。

少做→ 高性能

让每个请求占资源的时间变短:索引、缓存、批量、少调几次远程、连接复用。

付出:缓存带来数据不一致,内存成本

多做→ 高并发

让同一时刻能处理的请求变多:无状态加机器、异步(MQ 削峰)、读写分离、分库分表。

付出:异步后结果不是马上可见,拆分后跨库事务难做

冗余→ 高可用

每样东西至少两份,坏一份切到另一份:多实例、主从、哨兵和集群、多可用区。

付出:成本翻倍,主从之间有复制延迟,切换时可能丢最后几笔

自保→ 高可用

坏了不传染:超时、限流、熔断、降级、舱壁隔离、有限重试。

付出:一部分请求会被主动拒绝或降级,要提前想好“降成什么样”

越往右:成本 ↑ 复杂度 ↑ 一致性代价 ↑ 0 先测量压测 · 火焰图 · 慢 SQL 1 单机优化索引 · 批量 · 池参数去掉多余的远程调用 2 缓存本地 Caffeine+ Redis 3 异步MQ 削峰非核心步骤后置 4 加机器前提:应用无状态负载均衡分流 5 拆分读写分离分库分表 · 微服务 冗余 + 自保:不在台阶上,第一天就要有
选择规则:容量问题从左往右走,每一步先压测确认瓶颈真的在这里,再上下一级;冗余和自保是地基,和流量大小无关。
同一个问题可选的招先用哪个
商品详情查得太多加索引 / 本地缓存 / Redis / 读写分离先看 SQL 有没有走索引;读多写少再加缓存,热点先上本地缓存
下单后要发短信、加积分同步调用 / 线程池异步 / MQ不影响下单结果的步骤都后置;要保证不丢就用 MQ,不能只丢进内存线程池
大促流量翻 20 倍加机器 / 限流 / 排队 / 预热缓存按压测结果算机器数,再配限流兜底;只加机器不限流,算错一次就雪崩
选哪个消息队列Kafka / RocketMQ / RabbitMQ / Redis Stream日志、埋点、流处理要吞吐,选 Kafka;订单这类业务消息要事务消息、延迟消息、顺序消息、重试和死信开箱即用,选 RocketMQ;量不大、路由规则复杂选 RabbitMQ;已有 Redis、量小选 Stream
写完订单要发消息,两边必须一致先写库再发 / 本地消息表 / RocketMQ 事务消息直接“写库再发”中间挂了就丢;用本地消息表或 RocketMQ 事务消息。Kafka 的事务只保证几条 Kafka 消息一起成功,管不到你的数据库
依赖的服务不稳定重试 / 超时 / 熔断 / 降级 / 舱壁先超时 + 降级,再舱壁;重试最后考虑,而且只在一层做
单表数据量上亿归档历史数据 / 分区表 / 分库分表先归档;分库分表代价最大,放最后

本页学:四类手段背后的两本账(资源账和故障账),以及它们在一条请求链路和一条上线流程上各落在哪里。具体每一招怎么用,多数已有专页,地图上可以直接点过去。

④核心取舍:两本账,四个洞察

先把背后的账讲透:资源账(洞察 1)、故障账(洞察 2)、为什么非要自保(洞察 3)、每一招在拿什么换(洞察 4)。最后把所有知识点挂到一张地图上。

先知道这些

QPS 和 RT

QPS 是每秒处理多少个请求;RT 是一个请求从进来到返回花了多久。P99 是把 100 个请求按 RT 排队,第 99 个的值。

一个请求占一个线程

Tomcat 用固定数量的工作线程接请求(默认最多 200),请求在等数据库、等远程调用时,线程也一直被占着。线程池怎么工作见 ThreadPoolExecutor 那页。

连接池也是有限的

数据库连接、Redis 连接、HTTP 连接都放在池子里复用,池子满了就要等。池子开多大见 连接池与事务性能。

越满越慢

资源用得越接近 100%,排队越长,RT 不是线性涨,而是越来越陡。原因见 响应时间为什么越压越长。

洞察 1性能和并发是一回事:Little 定律资源账

同时在忙的线程数 = 每秒请求数(QPS) × 每个请求占线程的时间(RT)

排队论里叫 Little 定律(L = λW)。它不依赖请求怎么到达、怎么分布,只要系统稳定就成立,所以可以直接拿来算账。

每台 200 个工作线程(一格 = 10 个) 每秒 2000 × 0.05 秒 = 100 个,一半空着 每秒 2000 × 0.1 秒 = 200 个,刚好占满 再多一点就排队 每秒 2000 × 0.5 秒 = 1000 个,差 800 个 +800 在排队 RT 越排越长 QPS 不变,只是 RT 变了
三行的流量一样,只是每个请求占线程的时间不同。RT 涨 5 倍,需要的线程也涨 5 倍:性能变差会直接变成并发能力变差。
  • 反过来算单机上限:200 个线程 ÷ 0.05 秒 = 每秒 4000 个。RT 降一半,同样的机器能扛的 QPS 翻倍,这就是“少做”能换来“多做”的原因。
  • 算池子大小:数据库每秒 500 次查询 × 每次占连接 10 ms = 同时 5 个连接在用,池子开 10 个就够,开 100 个只会让数据库更挤。
  • RT 里包括等待的时间:等下游、等锁、等连接都算。下游从 30 ms 变 2 秒,你的代码一行没改,需要的线程却涨了几十倍。
  • 所以第三方慢 = 你的线程被借走。不设超时,就等于让别人决定你能占多少线程(⑥ 坑 1)。

线程为什么会被“等”占住因为 JDBC、RestTemplate 这些调用用的是阻塞 IO:数据没回来,线程就停在 socket 读上。Tomcat 用 NIO 管连接(8192 个连接只要 1 个 Poller 线程,空闲的 keep-alive 连接不占工作线程),但请求一旦开始处理,等下游仍然占着那 200 个工作线程里的一个。IO 模型、Reactor、虚拟线程改变了什么、没改变什么,见 网络 IO 模型。

洞察 2可用率的算术:串联相乘,并联互补故障账

串联:一个请求要经过的同步环节,全都得好 网关99.9% 订单99.9% 库存99.9% 积分99.9% 短信99.9% = 0.999⁵ ≈ 99.5% 一年约 44 小时 并联:同一件事有两份,坏一份还有另一份 负载均衡 实例 A · 99% 实例 B · 99% 两台同时挂的概率 = 1% × 1% = 0.01% = 99.99% 前提:两台不会因为同一个原因一起挂,负载均衡自己也不是单点
串联的环节越多,整体越差,这是“拆微服务后反而更不稳”的根源;并联把两个不太可靠的东西变成一个很可靠的东西,前提是故障互相独立。
可用率 = 平均多久坏一次(MTBF) ÷ (MTBF + 每次多久修好(MTTR))

提升可用率有两条路:少坏,或者坏了快恢复。少坏很难再提高;快恢复(自动切换、一键回滚、降级开关)往往便宜得多。Google SRE 的统计是大约 70% 的故障由变更引起,所以发布流程本身就是可用性工程(④ 投产流程、⑥ 坑 7)。

99%一年停 3.65 天
99.9%一年 8.76 小时 · 每月 43 分钟
99.99%一年 52.6 分钟 · 每月 4.4 分钟
99.999%一年 5.3 分钟

出大事(机房断电、误删库)时还有两个数要先定下来:RTO,最多多久恢复服务;RPO,最多丢多久的数据。异步复制的主从,RPO 就是复制延迟;只靠每天一次备份,RPO 就是一天。数字定了,才知道要不要同步复制、多可用区还是多活,以及备份要多久演练一次恢复。

洞察 3为什么非要自保:慢比挂更危险设计对比

逼出这个设计的约束只有一条:线程、连接这些资源是有限的,而且被所有接口共用。一个依赖直接挂掉,调用会马上报错,线程很快就还回来;一个依赖变慢,线程会被它一直占着(洞察 1),最后谁都用不上。

被放弃的:所有调用共用一个池没有超时、没有隔离

200 个线程(一格 10 个) 下单 查商品 ✕ 全部卡在“等积分”,每个占 2 秒 查商品不调积分,也排不上

选中的:超时 + 舱壁 + 降级Resilience4j / Sentinel 做的事

积分舱壁 20 下单 查商品 舱壁满或超过 200 ms → 降级:积分记一笔稍后补 其余 180 个线程照常服务

决定胜负的约束:共享的有限资源。只要资源是共享的,就要给每个外部依赖设一个“最多能占多少”的上限(舱壁)和“最多能占多久”的上限(超时),再准备好“占不到时怎么办”(降级)。熔断是再进一步:发现依赖一直失败,干脆连那 200 ms 也不等,直接降级,过一会儿再放几个请求去试探。这四样是一套,⑤ 会实测它们各自管住了什么。

比喻对不上的地方:舱壁常被比作船舱隔板,但真实实现通常不是单独的一组线程,而是一个计数器(Resilience4j 的 SemaphoreBulkhead,默认最多 25 个并发,等待时间 0),满了就立刻拒绝。

洞察 4每一招都在交换,没有白拿的CAP 只是其中一种

手段换来付出不懂代价会怎样用错
缓存RT ↓、数据库压力 ↓数据可能是旧的改了库忘了删缓存;缓存一失效,流量全砸到库上(穿透、击穿、雪崩)
异步 / MQ削峰、调用方不用等结果不是马上可见;消息可能重复消费端不做幂等,重复消费导致重复发券(Kafka 可靠性)
主从 / 读写分离读能力 ↑、主挂了能切从库有复制延迟;切换时可能丢最后几笔刚下完单马上查从库,提示“订单不存在”
分库分表写能力和容量 ↑跨库事务、跨库查询、扩容迁移都难数据量还没到就拆,复杂度先到了
限流 / 降级系统不被冲垮一部分用户被拒绝或拿到简化结果阈值拍脑袋定,正常流量也被拦
重试扛住偶发失败放大流量每层都重试 3 次,下游被放大几十倍(⑥ 坑 2)

CAP 说的是网络分区时,一致性和可用性只能保一个。日常更常碰到的是它的弱化版:要快、要扛量、要不停,就得接受“数据晚一点才一致”。哪些数据能接受(商品详情、点赞数),哪些不能(库存扣减、余额),要先分清。

地图知识点挂在请求链路上点 ↗ 跳到专页

少做 · 性能多做 · 并发冗余 · 可用自保 · 可用虚线 = 主要在投产时配置,实线 = 主要在写代码时决定
客户端 · 边缘浏览器 / App / CDN
CDN 与浏览器缓存 按钮防重复提交 DNS 多地址、就近接入 客户端超时与退避重试
接入层Nginx / 网关 / 负载均衡
负载均衡 + 健康检查 入口限流(令牌桶) 长连接复用 · HTTPS 卸载 灰度路由 网关超时 > 后端超时
应用层Spring Boot
无状态(会话放 Redis) Tomcat 线程数 线程池参数与拒绝 @Async 异步 超时 · 重试 · 幂等 熔断 · 降级 · 舱壁 本地缓存 Caffeine 批量代替循环 · 并行调用 锁粒度 · CAS · 并发容器 事务边界别包远程调用 JVM 堆与 GC 优雅停机 · 健康探针
消息层Kafka / RocketMQ / Redis Stream
异步削峰 · 非核心后置 acks · ISR · 不丢消息 消费幂等 · 死信 事务消息:写库和发消息一起成功 延迟消息:订单超时取消 顺序消息与消费重试 积压监控 · 分区数 Redis Stream 当队列
数据层MySQL
索引 · 慢 SQL · 深分页 连接池大小 · 长事务 MVCC · 行锁 · 死锁 读写分离 · 主从延迟 分库分表 · 分布式 ID 热点行更新(库存扣减) 主从切换 · 备份恢复
服务治理横跨各层
注册发现 · 健康检查摘除 配置中心 · 动态开关 负载均衡策略 弹性伸缩(K8s HPA) traceId 串起调用链

流程投产要过的六关

1容量评估

  • 按洞察 1 算线程、连接、机器数
  • 按 70% 水位留余量

2压测

3发布

  • 灰度(金丝雀)先放 1%~5%,或蓝绿切换
  • 优雅停机、就绪探针、预热
  • 一键回滚

4观测

  • 四个黄金信号:延迟、流量、错误、饱和度
  • 日志带 traceId,链路追踪
  • 定位:jstack、Arthas、async-profiler 火焰图

5应急

  • 预案:限流、降级开关、弹性扩容
  • 先止血,再查原因

6复盘

  • 改流程、补监控、补预案
  • 回到第 1 关

按洞察 2,可用率 = MTBF ÷ (MTBF + MTTR):第 2、3 关减少“坏”的次数,第 4、5 关缩短“修”的时间。

语法落到 Spring Boot 上,最少要认识这几行以 3.x 为例

application.properties参数怎么调放到第⑥步
# 资源账:每台最多 200 个请求同时处理(默认值)
server.tomcat.threads.max=200
# 拿不到数据库连接最多等 3 秒(默认 30 秒)
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.connection-timeout=3000
# 出站 HTTP 超时:没有默认值,不配可能一直等
spring.http.client.connect-timeout=1s
spring.http.client.read-timeout=2s
# 发版:处理完手上的请求再停(3.4 起默认就是)
server.shutdown=graceful
# /actuator/health/readiness 给负载均衡判断能不能接流量
management.endpoint.health.probes.enabled=true
# 积分最多同时占 20 个线程
resilience4j.bulkhead.instances.points.max-concurrent-calls=20
PointsClient.javaresilience4j-spring-boot3 + AOP
@Bulkhead(name = "points", fallbackMethod = "later")
@CircuitBreaker(name = "points", fallbackMethod = "later")
public void addPoints(long userId, int points) {
    restClient.post().uri("/points")
        .body(new AddPoints(userId, points))
        .retrieve().toBodilessEntity();   // 超时来自上面的配置
}

// 舱壁满、熔断打开、调用超时:都走这里
void later(long userId, int points, Throwable t) {
    compensation.save(userId, points);    // 记一笔,稍后补发
}

spring.http.client.* 这组属性按 Spring Boot 3.5.16 的配置元数据核对过(没有默认值);Spring Boot 4 改名为 spring.http.clients.*;Spring Boot 2 没有这组属性,要在 RestTemplate 的 request factory 上自己设。熔断的阈值、滑动窗口、半开放行几个请求这些参数,放到第⑥步。

⑤套进场景:积分卡住 3 秒,四种写法谁活下来

把 ② 的下单系统缩小到一个 Java 文件:一个 20 线程的“Tomcat”,每秒 200 个请求(30% 下单、70% 查商品),用户最多等 1 秒。然后让积分服务卡 3 秒,换四种写法各跑一遍。不需要任何依赖,JDK 8 / 17 / 21 都能跑。

1Avalanche.java缩小比例:20 个线程 ≈ 真实 Tomcat 的 200 个,规律一样
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

/** 积分服务变慢,会不会拖垮整个下单系统?JDK 8 / 17 / 21 都能跑。 */
public class Avalanche {
    static final int RPS = 200, SECONDS = 5;          // 每秒 200 个请求,压 5 秒
    static final int CLIENT_GIVE_UP_MS = 1000;        // 用户(网关)最多等 1 秒
    static final int WORKERS = 20, QUEUE = 50;        // 缩小版 Tomcat:20 个工作线程,50 个排队位
    static final int POINTS_TIMEOUT_MS = 200;         // 调积分服务的超时
    static final int BULKHEAD = 5;                    // 积分调用最多同时占 5 个线程(舱壁)

    enum Mode { NAKED, TIMEOUT, GUARDED }
    static int pointsDelayMs;                         // 积分服务这次要多久才回
    static Semaphore bulkhead;

    /** 下单里要调的积分服务;三种写法 */
    static void callPoints(Mode m) throws Exception {
        if (m == Mode.NAKED) { Thread.sleep(pointsDelayMs); return; }      // 没超时:它多慢我就等多久
        if (m == Mode.GUARDED && !bulkhead.tryAcquire()) throw new RejectedExecutionException("舱壁满");
        try {
            if (pointsDelayMs > POINTS_TIMEOUT_MS) { Thread.sleep(POINTS_TIMEOUT_MS); throw new TimeoutException(); }
            Thread.sleep(pointsDelayMs);
        } finally { if (m == Mode.GUARDED) bulkhead.release(); }
    }

    static final class Req { boolean order; long arrive, done; String result; }

    static void handle(Req r, Mode m) {
        try {
            if (r.order) {
                Thread.sleep(10);                                           // 下单自己的活:10 ms
                try { callPoints(m); r.result = "ok"; }
                catch (Exception e) { r.result = (m == Mode.GUARDED) ? "degraded" : "error"; } // 降级:积分记下来稍后补
            } else { Thread.sleep(5); r.result = "ok"; }                     // 查商品:5 ms,不碰积分
        } catch (InterruptedException e) { r.result = "error"; }
        r.done = System.nanoTime();
    }

    static void run(String title, Mode m, int delay) throws Exception {
        pointsDelayMs = delay; bulkhead = new Semaphore(BULKHEAD);
        ThreadPoolExecutor tomcat = new ThreadPoolExecutor(WORKERS, WORKERS, 0, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<Runnable>(QUEUE));
        List<Req> all = new ArrayList<Req>();
        int peak = 0;
        long start = System.nanoTime();
        for (int i = 0; i < RPS * SECONDS; i++) {
            long due = start + i * 1_000_000_000L / RPS;
            while (System.nanoTime() < due) Thread.sleep(0, 200_000);
            final Req r = new Req(); r.order = i % 10 < 3; r.arrive = System.nanoTime(); all.add(r);
            try { tomcat.execute(() -> handle(r, m)); } catch (RejectedExecutionException e) { r.result = "rejected"; }
            peak = Math.max(peak, tomcat.getActiveCount());
        }
        tomcat.shutdown(); tomcat.awaitTermination(60, TimeUnit.SECONDS);
        int[] q = new int[5], o = new int[5];   // 0 成功 1 降级 2 出错 3 超过1秒 4 被拒
        List<Long> qms = new ArrayList<Long>();
        for (Req r : all) {
            int k;
            if ("rejected".equals(r.result)) k = 4;
            else {
                long ms = (r.done - r.arrive) / 1_000_000;
                if (!r.order) qms.add(ms);
                k = ms > CLIENT_GIVE_UP_MS ? 3 : "ok".equals(r.result) ? 0 : "degraded".equals(r.result) ? 1 : 2;
            }
            (r.order ? o : q)[k]++;
        }
        Collections.sort(qms);
        String p99 = qms.isEmpty() ? "-" : qms.get((int) (qms.size() * 0.99)) + " ms";
        int nq = RPS * SECONDS * 7 / 10, no = RPS * SECONDS * 3 / 10;
        System.out.printf("%-16s 查询 成功 %3d%%  p99 %-8s | 下单 成功 %3d%% 降级 %3d%% | 超1秒 %3d  被拒 %3d | 线程峰值 %d/%d%n",
                title, q[0] * 100 / nq, p99, o[0] * 100 / no, o[1] * 100 / no, q[3] + o[3], q[4] + o[4], peak, WORKERS);
    }

    public static void main(String[] a) throws Exception {
        System.out.println("Java " + System.getProperty("java.version") + "," + RPS + " 请求/秒 × " + SECONDS + " 秒,下单 30% / 查询 70%");
        run("正常 · 裸奔", Mode.NAKED, 20);
        run("积分卡 3 秒 · 裸奔", Mode.NAKED, 3000);
        run("积分卡 3 秒 · 只加超时", Mode.TIMEOUT, 3000);
        run("积分卡 3 秒 · 超时+隔离+降级", Mode.GUARDED, 3000);
    }
}
2运行本机 JDK 17,用 --release 8 编译,同一个 class 在 JDK 8 上也能跑
$ javac -encoding UTF-8 --release 8 Avalanche.java && java -Dfile.encoding=UTF-8 Avalanche
Java 17.0.20.1,200 请求/秒 × 5 秒,下单 30% / 查询 70%
正常 · 裸奔          查询 成功 100%  p99 5 ms     | 下单 成功 100% 降级   0% | 超1秒   0  被拒   0 | 线程峰值 5/20
积分卡 3 秒 · 裸奔     查询 成功   7%  p99 2936 ms  | 下单 成功   0% 降级   0% | 超1秒 125  被拒 820 | 线程峰值 20/20
积分卡 3 秒 · 只加超时   查询 成功 100%  p99 6 ms     | 下单 成功   0% 降级   0% | 超1秒   0  被拒   0 | 线程峰值 14/20
积分卡 3 秒 · 超时+隔离+降级 查询 成功 100%  p99 5 ms     | 下单 成功   0% 降级 100% | 超1秒   0  被拒   0 | 线程峰值 7/20
场景(积分服务 RT) 查商品:1 秒内成功 下单:成功 / 降级成功 线程峰值 正常 · 裸奔积分 20 ms 100% 成功 100% 5 / 20 积分卡 3 秒 · 裸奔没超时、没隔离 7%(被拒 820 个) 全部失败 20 / 20 积分卡 3 秒 · 只加超时200 ms 超时 100% 全部失败(超时就报错) 14 / 20 积分卡 3 秒 · 超时+舱壁+降级最多 5 个线程等积分 100% 降级成功 100%(积分稍后补) 7 / 20
实测 2 核 ARM 云主机,JDK 17.0.20.1。没有保护时,查商品明明不调积分,也只剩 7% 能在 1 秒内返回:它和下单共用那 20 个线程。
写法按洞察 1 估算的线程数估算实测峰值
裸奔,积分 3 秒下单 60/秒 × 3.01 秒 + 查商品 140/秒 × 0.005 秒≈ 18120(满了,其余排队或被拒)
只加超时60 × (0.01 + 0.2) + 140 × 0.005≈ 13.314
超时 + 舱壁 + 降级舱壁 5 + 60 × 0.01 + 140 × 0.005≈ 6.37
到这里已经能交差了。记住三件事就能避开大多数事故:每个出站调用都有超时;非核心依赖有舱壁和降级;上线前用“QPS × RT”算一遍线程和连接够不够。
已知还没处理的细节
  • 重试加在哪、加几次 → ⑥ 坑 2
  • 降级时这笔积分怎么保证最终补上 → ⑥ 坑 3
  • 熔断:一直失败时连 200 ms 也不等 → ↺ 练习 1
  • 流量本身翻倍时怎么办(限流) → ⑥ 实验台
  • 调积分用的 HTTP 连接池够不够 → ⑥ 坑 9
  • 发版时会不会把这些请求砍掉 → ⑥ 坑 7

⑥反例和误用:名字套上了,约束没满足

三圈:第一圈是不知道就会出事故的坑,每条标了根源在 ④ 的哪个洞察;第二圈是日常开发和投产两张清单;第三圈是实验台和几个更深的点。

第一圈

注意事项

坑 1远程调用没设超时← ④ 洞察 1
现象
下游一抖,自己的接口全部超时;线程 dump 里几百个线程停在 socketRead 或等连接池。
根源
等待也占线程。很多客户端默认不超时或超时很长:HttpURLConnection 默认 0,即无限等;Spring 的 SimpleClientHttpRequestFactory 不设就沿用它;Boot 的 spring.http.client.read-timeout 没有默认值;OpenFeign 默认连 10 秒、读 60 秒;HikariCP 拿连接默认等 30 秒。
做法
HTTP、RPC、数据库、Redis 每个出站调用都显式设超时,按下游 P99 的两三倍定;一层比一层短(网关 > 应用 > 下游),否则上游已经放弃了,下游还在白干。
坑 2每层都重试,下游被放大几十倍← ④ 洞察 3
现象
下游刚慢一点,收到的流量反而翻了几倍,从此起不来。
根源
三层各最多试 3 次,最底层最多收到 3 × 3 × 3 = 27 倍请求;超时后的重试,原来那次往往还在下游跑。排队越长越超时、越超时越重试,是一个正反馈环(响应时间那页有实测)。
做法
只在一层重试;指数退避加随机抖动;设重试预算(比如重试量不超过正常请求的 10%);只重试幂等操作;熔断打开时不重试。
坑 3只加了超时,下单照样全失败← ④ 洞察 3
现象
系统没挂,但积分一慢,下单 100% 报错。⑤ 实测
根源
超时只是把线程还回来,没有回答“超时以后这一单怎么办”。
做法
先分强弱依赖。弱依赖(积分、短信、推荐)失败就降级:写补偿表或发 MQ,由定时任务补发,补发接口要幂等。强依赖(库存、支付)失败就明确返回失败,让用户重试,同样要幂等。
坑 4拆成微服务,可用率反而降了← ④ 洞察 2
现象
拆分前一年停几小时;拆分后三天两头“某个服务抖一下,下单就失败”。
根源
串联相乘:一个请求同步经过 5 个各 99.9% 的服务,整体只剩 99.5%,再加上每次网络调用都可能超时。
做法
核心链路上的同步依赖越少越好,能异步就异步、能降级就降级;每个服务至少两个实例,跨可用区部署。
坑 5线程池、连接池开得越大越好?← ④ 洞察 1
现象
Tomcat 线程开到 2000、Hikari 开到 200,压测反而更慢,数据库 CPU 打满、锁等待变多。
根源
池子只是排队的位置,真正的容量在数据库和下游。需要的连接数 = QPS × 每次占连接的时间,多出来的连接只会让数据库同时干更多活、互相抢锁。无界队列则让请求一直排,RT 没有上限,还可能 OOM。
做法
先按公式算,再压测验证;自建线程池用有界队列、写明拒绝策略(线程池那页);连接池大小见 连接池那页。
坑 6缓存挡住了数据库,也藏住了它的真实容量← ④ 洞察 4
现象
一批缓存同时过期,或 Redis 重启后,数据库瞬间被打满;刚恢复又被打死。
根源
平时数据库只承担几个百分点的流量,它扛不扛得住全量从没被测过;缓存一失效,压力是平时的几十倍。
做法
过期时间加随机;热点 key 用互斥重建或逻辑过期;压测里加一轮“缓存全失效”;详见 穿透、击穿、雪崩。
坑 7每次发版都有一小波报错← ④ 洞察 2
现象
发版时出现一批 502 / 500;新实例刚接流量时 RT 很高。
根源
进程被直接停掉,处理到一半的请求断了;负载均衡还没把这台摘掉它就停了;新实例 JIT 没热,连接池和本地缓存是空的。Spring Boot 3.4 之前 server.shutdown 默认是 immediate。
做法
优雅停机 + 就绪探针:先让 readiness 变成不可用、摘掉流量,再停;新实例预热后再接流量;灰度 1%~5% 观察指标再全量;回滚一条命令完成。
坑 9下游不慢,调用方却很慢:HTTP 连接池只有 5 条← ④ 洞察 1
现象
下游监控里 RT 正常,调用方 RT 很高;线程 dump 里大量线程停在从连接池拿连接的地方。
根源
需要的连接数 = 调用 QPS × 下游 RT。Apache HttpClient 5 默认每个目标地址 5 条、总共 25 条(4.x 是 2 条、20 条),Spring Boot 3.5 的 RestClient 在 classpath 上有它时优先用它,且不改池子大小(源码核对)。每秒调 200 次、RT 100 ms 就要 20 条。
做法
按“QPS × RT × 1.5”配每个目标的连接数,并设从池里拿连接的超时。详见 网络 IO 模型 · 坑 2。
坑 10平均 RT 很好,P99 隔一阵冒一个尖刺← ④ 洞察 1
现象
大部分请求几十毫秒,每隔几分钟有一批请求同时慢几百毫秒,和流量、下游都对不上。
根源
GC 停顿(尤其是 Full GC)期间所有业务线程一起停,这段时间进来的请求全部排队。按 Little 定律,停顿越长,攒下的请求越多,恢复后还要一阵才能消化完。常见诱因:堆太小、大对象(一次查出几万行、大 JSON)、内存泄漏。
做法
开 GC 日志,把 GC 停顿和 P99 曲线叠在一起看;JDK 17 / 21 默认 G1,对停顿敏感的服务可以试 ZGC(JDK 21 起有分代 ZGC);从源头减少大对象,列表接口限制条数。
坑 11订单写成功了,消息没发出去← ④ 洞察 4
现象
订单创建成功,积分、物流却没收到消息;或者反过来,消息发出去了,订单事务却回滚了,下游处理了一个不存在的订单。
根源
数据库和消息队列是两个系统,没有共同的事务。在 @Transactional 方法里发消息,提交前就发出去了;提交后再发,进程可能正好在两步之间挂掉。
做法
本地消息表:业务表和消息表在同一个本地事务里写,再由定时任务投递。或用 RocketMQ 事务消息:先发“半消息”(消费者看不到),本地事务提交后再确认;Broker 迟迟拿不到结果就回查业务方,默认每 30 秒查一次、最多 15 次(5.5.1 源码)。两种做法消费端都要幂等。
坑 12顺序消息一条消费失败,整个队列卡住← ④ 洞察 3
现象
RocketMQ 某个队列的消费堆积越来越多,消费者日志里同一条消息反复失败。
根源
为了保证顺序,顺序消费失败的消息原地重试,后面的消息只能等着;客户端默认最大重试次数是 Integer.MAX_VALUE,一条坏消息就能把这个队列堵死。并发消费不一样:默认重试 16 次后进死信队列 %DLQ%消费组名(5.5.1 源码)。
做法
只在真正需要顺序的业务用顺序消息,并显式设 maxReconsumeTimes,超过就记录下来跳过、人工补偿;死信队列和消费堆积都要有告警。
坑 8读写分离后,刚下的单查不到← ④ 洞察 4
现象
下单成功跳到详情页,提示“订单不存在”,刷新一下又有了。
根源
写主库、读从库,主从复制有延迟:平时毫秒级,大事务或从库压力大时能到秒级。
做法
“写完马上读”的场景强制走主库;余额、库存这类数据不走从库;把主从延迟加进监控和告警。
第二圈

使用规范:两张清单

日常开发(代码评审时对照)

  1. 每个出站调用都设连接和读取超时,一层比一层短。线程被下游借走(坑 1)
  2. 重试只在一层做,带退避和抖动,只重试幂等操作。重试风暴(坑 2)
  3. 写接口幂等:唯一业务号 + 唯一索引,或防重 token。重试、重复提交、消息重复导致重复扣款
  4. 弱依赖写好降级:补偿表、MQ 或默认值。非核心服务拖垮主流程(坑 3)
  5. 写库和发消息要一致时,用本地消息表或 RocketMQ 事务消息;顺序消费设最大重试次数。消息丢失或错发(坑 11)、一条坏消息堵死队列(坑 12)
  6. 自建线程池有界、有名字、有拒绝策略,不同业务不共用。OOM、互相拖累
  7. 事务里不调远程、不发 MQ、不做慢操作。长事务占着连接和行锁
  8. 循环里不查库、不调远程,改成批量;互不依赖的远程调用并行发。RT 随数据量线性变长
  9. SQL 走索引,列表接口限制条数,避免深分页。慢 SQL 拖垮连接池
  10. 缓存有过期时间并加随机,空结果也缓存,先写库再删缓存。雪崩、穿透、脏数据(坑 6)
  11. 日志带 traceId,热路径不打大对象。出事查不到、日志 IO 拖慢 RT
  12. HTTP 客户端全局复用一个,连接池按“QPS × RT”配,空闲回收比服务端 keep-alive 超时短。连接池排队(坑 9)、复用到已关闭的连接
  13. 共享数据先想锁的粒度:能用 CAS、LongAdder、并发容器就别包大锁;库存这类热点行更新要合并或排队。锁竞争和热点行让加机器也没用

应用投产(上线前对照)

  1. 用“QPS × RT”估算线程、连接和机器数,按 70% 水位留余量。大促当天现算
  2. 压测到拐点,知道单机上限和第一个瓶颈(系统压测)。容量靠猜
  3. 每个服务至少两个实例、跨可用区;MySQL、Redis 有主从切换。单点
  4. 优雅停机 + 就绪探针 + 预热。发版时的报错尖峰(坑 7)
  5. 灰度发布、一键回滚,每次变更有记录。变更引起的故障扩散到全量
  6. 监控四个黄金信号(延迟、流量、错误、饱和度),按 SLO 告警。用户比你先发现问题
  7. 限流阈值和降级开关提前配好,演练过。出事时现写代码
  8. JVM:-Xms 等于 -Xmx,开 GC 日志和 -XX:+HeapDumpOnOutOfMemoryError。出事后没有现场
  9. 数据库开慢查询日志、监控主从延迟和连接数;定期演练从备份恢复。备份其实恢复不了
  10. 消息队列监控消费堆积、死信队列和重试次数。消费者卡住几小时才被发现
  11. 写好预案和值班表,事后复盘、改流程。同一个坑踩两次
  12. 定下 RTO 和 RPO,按它选复制方式和备份频率,并定期演练切换。出大事时才发现恢复要半天
  13. 查系统参数:ulimit -n、somaxconn、临时端口范围。连接数一上来就撞到系统上限
第三圈

优化和深处

实验台 · 大促那晚的线程账先猜,再运行

总流量6000 QPS
实例数3 台
积分服务 RT
保护措施
选一个剧情,先猜再运行;之后拖滑块、切开关,结果会跟着变。
每台的线程(Tomcat 最多 200 个)
查商品下单自身等积分超出 200,只能排队
先选一个剧情,猜一下,再点“运行”。

模型:每台 200 个线程;查商品占 20 ms,下单自己占 30 ms 再加调积分的时间;下单占 30%;用户最多等 3 秒。每台在途线程按 Little 定律算(洞察 1),超过 200 就判为“越排越长”,持续一阵后几乎所有请求都超过 3 秒,记为失败。舱壁限制等积分的线程最多 20 个,满了立刻降级;限流每台每秒最多放进 4000 个,多出的立刻返回“稍后再试”。只算稳态,不模拟 GC、数据库和积分服务自己的负载。逻辑在 node 里跑过几组典型参数,又随机测了 2 万组:加机器不会让每台更忙,舱壁打开时等积分的线程不超过 20。

尾延迟会被扇出放大

一个请求要并行调 100 个后端,每个后端只有 1% 的概率慢,整体却有 1 − 0.99100 ≈ 63% 的请求会碰上至少一个慢的(Dean 和 Barroso,《The Tail at Scale》,2013)。

  • 所以扇出大的接口(聚合页、搜索)要盯 P99、P999,平均值没有意义。
  • 做法:减少扇出;每个子调用单独超时,缺一部分也能返回;对幂等读请求,等一会儿没回就再发一份给别的副本(hedged request),谁先回用谁。

触发原因消失了,系统还是起不来

这类故障叫亚稳态故障(metastable failure,Bronson 等,HotOS 2021):一次抖动引发重试,重试把负载推高,负载高又引发更多超时和重试。抖动早就结束了,系统却一直卡在高负载状态。

  • 常见的放大器:重试、缓存失效后的回源、请求在队列里排到超时后还被处理。
  • 恢复要靠主动卸载负载:限流、熔断、清掉积压的队列,而不是加机器等它自己好。队列里排到时已经超过上游超时的请求直接丢掉,别再白白处理。

版本差异

点JDK 8 / Spring Boot 2JDK 17 / Spring Boot 3JDK 21
线程是不是稀缺资源是,一个请求占一个平台线程同左可以用虚拟线程(JEP 444):等 IO 时不占平台线程。Boot 3.2 起 spring.threads.virtual.enabled=true,此时 server.tomcat.threads.max 不再生效
虚拟线程后 Little 定律还在吗—在。等积分的请求数照样是 QPS × RT,只是不再卡在 Tomcat 线程,而是压到连接池、下游和内存上。超时和舱壁照样要。JDK 21 在 synchronized 里阻塞会钉住载体线程,JDK 24(JEP 491)才解决
优雅停机Boot 2.3 起可配 server.shutdown=graceful,默认 immediate3.4 起默认 graceful(3.5.16 配置元数据核对过)—
健康探针Boot 2.3 起有 liveness / readiness同左,在 Kubernetes 上自动开启—
熔断降级库Hystrix 已进入维护模式(最后版本 1.5.18),官方建议换 Resilience4j;国内也常用 Sentinelresilience4j-spring-boot3;Spring Cloud CircuitBreaker 封装了 Resilience4j—

下一圈

  • 限流算法:计数器、滑动窗口、漏桶、令牌桶,单机和分布式限流怎么做
  • 熔断器的状态机:关闭、打开、半开,滑动窗口怎么统计失败率
  • 分库分表和分布式 ID:分片键怎么选,扩容怎么迁移
  • 分布式事务:本地消息表、Saga、TCC,以及为什么尽量别用
  • RocketMQ:事务消息、延迟消息、顺序消息的原理,以及和 Kafka 怎么选
  • 多活和单元化:同城双活、异地多活要解决的数据冲突
  • 全链路压测:影子库、流量染色,怎么压生产又不污染数据
  • 可观测性:OpenTelemetry 的 trace、metric、log 怎么串起来
  • JVM 调优:G1 和 ZGC 的停顿从哪来,怎么调
  • 秒杀系统:热点库存、预扣减、排队和防刷
  • 服务治理:注册中心、配置中心、动态开关的原理和坑

↺回到第⑤步:改 Avalanche.java

三个练习各用一条 ⑥ 的内容。下面的预期是按洞察 1 估算的(估算,没有在本机跑过),你跑出来的数字应该落在附近。

练习 1 · 加熔断

在 GUARDED 模式里记录最近 20 次积分调用,失败超过一半就“打开”2 秒:这 2 秒内直接降级,不调积分;2 秒后放 1 个请求去试,成功就关上。

预期:等积分的线程从最多 5 个降到接近 0,线程峰值从 7 降到 2~3 左右(估算:60 × 0.01 + 140 × 0.005 ≈ 1.3,加上偶尔试探的那一个)。

用到:④ 洞察 3

练习 2 · 先猜,再加重试

在 TIMEOUT 模式里,积分超时后再重试 2 次(一共 3 次)。先猜:查商品还能 100% 成功吗?

预期:每个下单占线程 0.01 + 3 × 0.2 = 0.61 秒,60 × 0.61 ≈ 37 个线程,超过 20,系统重新被拖垮,查商品的成功率会掉得很厉害。

用到:坑 2

练习 3 · 流量翻 10 倍,加限流

积分恢复 20 ms,把 RPS 改成 2000。再在 execute 之前加一个 Semaphore(16),拿不到就立刻返回“稍后再试”。

预期:不限流时需要 2000 × (0.7 × 0.005 + 0.3 × 0.03) = 25 个线程,超过 20 会越排越长;限流后被放进来的请求都能在 1 秒内完成,多出的那部分立刻被拒。

用到:实验台的限流开关

1. 用一句话说出三高的精华。

两本账:资源账用 Little 定律算(在忙的线程 = QPS × RT,性能差会直接变成并发差);故障账靠冗余(多一份)和隔离(超时、舱壁、降级)把坏的部分关在小范围。每一招都在拿一致性、成本或复杂度交换。

← 开头 + ②

2. 一台机器、一个库、全同步调用,痛在哪?

容量上:线程和连接有限,流量一大或者每次都打库就排队。故障上:只有一份,宕机和发版就是停机;同步调用 + 共用线程池,一个依赖慢了会拖死所有接口。

← ②

3. “积分慢了,连不调积分的查商品也挂了”,根源是 ④ 的哪一条?

洞察 1 + 洞察 3:积分慢让每个下单占线程的时间变长,按 QPS × RT 需要的线程超过 200;而查商品和下单共用这 200 个线程,资源是共享的。

← ④ 洞察 1、3,⑤ 实测

4. 预测:每台 2000 QPS(30% 下单),积分从 30 ms 变成 500 ms,没有任何保护,会怎样?

会挂。下单 600/秒 × (0.03 + 0.5) 秒 ≈ 318 个线程,加上查商品 1400 × 0.02 = 28 个,共约 346 个,超过 200。下游只慢到半秒就够了,不用等到“卡死”。实验台把积分 RT 切到 500 ms 就能看到。

← ④ 洞察 1

5. 预测:限流能挡住“下游变慢”吗?

挡不住。限流限的是 QPS,阈值是按正常 RT 算的;下游变慢时 RT 变大,同样的 QPS 需要的线程成倍增加。实验台里只开限流、把积分切到 2 秒,照样会挂。限流防的是流量突增,超时和舱壁防的是下游变慢。

← ④ 洞察 1 + 洞察 3

6. 预测:两台各 99.9% 的实例挂在负载均衡后面,理论上整体是多少?实际为什么达不到?

理论上 1 − 0.001 × 0.001 = 99.9999%。实际达不到:两台常常因为同一个原因一起挂(同一个版本的 bug、同一个数据库、同一个机房断电),负载均衡和数据库本身也是串联在链路上的环节。

← ④ 洞察 2

7. 预测:换成 JDK 21 虚拟线程后,积分卡 2 秒还会拖垮查商品吗?

Tomcat 线程不再是瓶颈,查商品不会因为“没有线程”而排队。但等积分的请求数照样是 QPS × RT,会无限往上涨:积分服务自己、HTTP 连接池、内存会先被压垮;如果查商品和下单共用某个连接池,查商品照样受影响。超时和舱壁还是要。

← ④ 洞察 1 + ⑥ 版本差异