高性能 · 高并发 · 高可用 · LITTLE 定律 · 几个 9 · 超时 · 舱壁 · 降级 · 灰度 · 监控
三高要学的,是一本账
“三高”听起来是三门功课,其实是同一个系统的三个指标。性能和并发用一条公式连着:同时在忙的线程 = 每秒请求数 × 每个请求的耗时,下游一慢,能扛的并发就塌。可用靠两件事:多备一份,再把坏掉的部分关在小范围里。缓存、异步、加机器、限流、熔断,每一招都在拿一致性、成本或复杂度去换,所以要先算账,再选招。
假设场景你在做一个电商的下单系统:Spring Boot + MySQL + Redis + Kafka,平时每秒两三百个请求,大促那晚要翻二十倍。日常写代码时要知道哪些写法会埋雷,上线(投产)时要知道该配什么、该看什么、出事了怎么办。本页是一张总地图:④ 把知识点挂到一条请求链路和一条上线流程上,⑥ 给出开发和投产两张清单,很多点链到仓库里已经讲透的页面。
①要解决的问题:三个“高”,各量的是什么
老板说“大促不能出问题”,落到工程上是三句能量出来的话。先把三个词换成三个指标,后面的讨论才不会各说各的。
日常开发管“上限”
一条 SQL 有没有走索引、一次远程调用有没有超时、循环里是不是一条条查库,这些在写代码时就决定了单机能跑多快、出事时会不会连锁倒下。
投产管“下限”
机器开几台、池子开多大、怎么发版、看哪些监控、出事先关哪个开关。代码写得再好,发版时把正在处理的请求砍掉,照样是事故。
这页要回答
三高到底有哪些知识点?它们之间是什么关系,先学哪个?日常写代码和上线各要守哪些规矩?
②不用它会怎样:一台机器、一个库、全同步
最朴素的做法:一个 Spring Boot 应用直连一个 MySQL,下单时顺手同步调用积分服务和短信服务。平时跑得好好的,问题都出在大促那晚。
- 表现
- 200 个线程全忙,新请求排队,RT 从几十毫秒涨到几秒。能做,但不完美:加大线程数只是把排队挪到数据库。
- 表现
- 热门商品被反复查,10 个连接排长队,拿不到连接的线程干等 30 秒(HikariCP 默认
connectionTimeout)。
- 表现
- 积分服务从 30 ms 变成 2 秒,下单线程全卡在等它,连不调积分的查商品也排不上队。老办法做不到:同步调用加共享线程池,一个依赖的慢会传给所有接口。
- 表现
- 机器挂了就全挂;每次发版重启,正在处理的请求被砍断,重启那几十秒全部报错。老办法做不到:只有一份,就没有可以切过去的地方。
1、2 是容量问题:每个请求占用资源太久,或者资源太少。3、4 是故障范围问题:一处坏了,没有东西把它挡住,也没有备份顶上。
所以三高的精华就两句:用 Little 定律算清资源账;用冗余和隔离把故障关在小范围里。
③几种流派:四类手段,各换来什么、付出什么
书上和面试里列的三高手段有几十个,归一归只有四类。前两类解决容量,后两类解决故障范围。
少做→ 高性能
让每个请求占资源的时间变短:索引、缓存、批量、少调几次远程、连接复用。
付出:缓存带来数据不一致,内存成本
多做→ 高并发
让同一时刻能处理的请求变多:无状态加机器、异步(MQ 削峰)、读写分离、分库分表。
付出:异步后结果不是马上可见,拆分后跨库事务难做
冗余→ 高可用
每样东西至少两份,坏一份切到另一份:多实例、主从、哨兵和集群、多可用区。
付出:成本翻倍,主从之间有复制延迟,切换时可能丢最后几笔
自保→ 高可用
坏了不传染:超时、限流、熔断、降级、舱壁隔离、有限重试。
付出:一部分请求会被主动拒绝或降级,要提前想好“降成什么样”
| 同一个问题 | 可选的招 | 先用哪个 |
|---|---|---|
| 商品详情查得太多 | 加索引 / 本地缓存 / 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 定律资源账
排队论里叫 Little 定律(L = λW)。它不依赖请求怎么到达、怎么分布,只要系统稳定就成立,所以可以直接拿来算账。
- 反过来算单机上限: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可用率的算术:串联相乘,并联互补故障账
提升可用率有两条路:少坏,或者坏了快恢复。少坏很难再提高;快恢复(自动切换、一键回滚、降级开关)往往便宜得多。Google SRE 的统计是大约 70% 的故障由变更引起,所以发布流程本身就是可用性工程(④ 投产流程、⑥ 坑 7)。
出大事(机房断电、误删库)时还有两个数要先定下来:RTO,最多多久恢复服务;RPO,最多丢多久的数据。异步复制的主从,RPO 就是复制延迟;只靠每天一次备份,RPO 就是一天。数字定了,才知道要不要同步复制、多可用区还是多活,以及备份要多久演练一次恢复。
洞察 3为什么非要自保:慢比挂更危险设计对比
逼出这个设计的约束只有一条:线程、连接这些资源是有限的,而且被所有接口共用。一个依赖直接挂掉,调用会马上报错,线程很快就还回来;一个依赖变慢,线程会被它一直占着(洞察 1),最后谁都用不上。
被放弃的:所有调用共用一个池没有超时、没有隔离
选中的:超时 + 舱壁 + 降级Resilience4j / Sentinel 做的事
决定胜负的约束:共享的有限资源。只要资源是共享的,就要给每个外部依赖设一个“最多能占多少”的上限(舱壁)和“最多能占多久”的上限(超时),再准备好“占不到时怎么办”(降级)。熔断是再进一步:发现依赖一直失败,干脆连那 200 ms 也不等,直接降级,过一会儿再放几个请求去试探。这四样是一套,⑤ 会实测它们各自管住了什么。
比喻对不上的地方:舱壁常被比作船舱隔板,但真实实现通常不是单独的一组线程,而是一个计数器(Resilience4j 的 SemaphoreBulkhead,默认最多 25 个并发,等待时间 0),满了就立刻拒绝。
洞察 4每一招都在交换,没有白拿的CAP 只是其中一种
| 手段 | 换来 | 付出 | 不懂代价会怎样用错 |
|---|---|---|---|
| 缓存 | RT ↓、数据库压力 ↓ | 数据可能是旧的 | 改了库忘了删缓存;缓存一失效,流量全砸到库上(穿透、击穿、雪崩) |
| 异步 / MQ | 削峰、调用方不用等 | 结果不是马上可见;消息可能重复 | 消费端不做幂等,重复消费导致重复发券(Kafka 可靠性) |
| 主从 / 读写分离 | 读能力 ↑、主挂了能切 | 从库有复制延迟;切换时可能丢最后几笔 | 刚下完单马上查从库,提示“订单不存在” |
| 分库分表 | 写能力和容量 ↑ | 跨库事务、跨库查询、扩容迁移都难 | 数据量还没到就拆,复杂度先到了 |
| 限流 / 降级 | 系统不被冲垮 | 一部分用户被拒绝或拿到简化结果 | 阈值拍脑袋定,正常流量也被拦 |
| 重试 | 扛住偶发失败 | 放大流量 | 每层都重试 3 次,下游被放大几十倍(⑥ 坑 2) |
CAP 说的是网络分区时,一致性和可用性只能保一个。日常更常碰到的是它的弱化版:要快、要扛量、要不停,就得接受“数据晚一点才一致”。哪些数据能接受(商品详情、点赞数),哪些不能(库存扣减、余额),要先分清。
地图知识点挂在请求链路上点 ↗ 跳到专页
流程投产要过的六关
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 为例
# 资源账:每台最多 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
@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 都能跑。
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);
}
}
$ 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
| 写法 | 按洞察 1 估算的线程数 | 估算 | 实测峰值 |
|---|---|---|---|
| 裸奔,积分 3 秒 | 下单 60/秒 × 3.01 秒 + 查商品 140/秒 × 0.005 秒 | ≈ 181 | 20(满了,其余排队或被拒) |
| 只加超时 | 60 × (0.01 + 0.2) + 140 × 0.005 | ≈ 13.3 | 14 |
| 超时 + 舱壁 + 降级 | 舱壁 5 + 60 × 0.01 + 140 × 0.005 | ≈ 6.3 | 7 |
⑥反例和误用:名字套上了,约束没满足
三圈:第一圈是不知道就会出事故的坑,每条标了根源在 ④ 的哪个洞察;第二圈是日常开发和投产两张清单;第三圈是实验台和几个更深的点。
注意事项
- 现象
- 下游一抖,自己的接口全部超时;线程 dump 里几百个线程停在
socketRead或等连接池。 - 根源
- 等待也占线程。很多客户端默认不超时或超时很长:
HttpURLConnection默认 0,即无限等;Spring 的SimpleClientHttpRequestFactory不设就沿用它;Boot 的spring.http.client.read-timeout没有默认值;OpenFeign 默认连 10 秒、读 60 秒;HikariCP 拿连接默认等 30 秒。 - 做法
- HTTP、RPC、数据库、Redis 每个出站调用都显式设超时,按下游 P99 的两三倍定;一层比一层短(网关 > 应用 > 下游),否则上游已经放弃了,下游还在白干。
- 现象
- 下游刚慢一点,收到的流量反而翻了几倍,从此起不来。
- 根源
- 三层各最多试 3 次,最底层最多收到 3 × 3 × 3 = 27 倍请求;超时后的重试,原来那次往往还在下游跑。排队越长越超时、越超时越重试,是一个正反馈环(响应时间那页有实测)。
- 做法
- 只在一层重试;指数退避加随机抖动;设重试预算(比如重试量不超过正常请求的 10%);只重试幂等操作;熔断打开时不重试。
- 现象
- 系统没挂,但积分一慢,下单 100% 报错。⑤ 实测
- 根源
- 超时只是把线程还回来,没有回答“超时以后这一单怎么办”。
- 做法
- 先分强弱依赖。弱依赖(积分、短信、推荐)失败就降级:写补偿表或发 MQ,由定时任务补发,补发接口要幂等。强依赖(库存、支付)失败就明确返回失败,让用户重试,同样要幂等。
- 现象
- 拆分前一年停几小时;拆分后三天两头“某个服务抖一下,下单就失败”。
- 根源
- 串联相乘:一个请求同步经过 5 个各 99.9% 的服务,整体只剩 99.5%,再加上每次网络调用都可能超时。
- 做法
- 核心链路上的同步依赖越少越好,能异步就异步、能降级就降级;每个服务至少两个实例,跨可用区部署。
- 现象
- 一批缓存同时过期,或 Redis 重启后,数据库瞬间被打满;刚恢复又被打死。
- 根源
- 平时数据库只承担几个百分点的流量,它扛不扛得住全量从没被测过;缓存一失效,压力是平时的几十倍。
- 做法
- 过期时间加随机;热点 key 用互斥重建或逻辑过期;压测里加一轮“缓存全失效”;详见 穿透、击穿、雪崩。
- 现象
- 发版时出现一批 502 / 500;新实例刚接流量时 RT 很高。
- 根源
- 进程被直接停掉,处理到一半的请求断了;负载均衡还没把这台摘掉它就停了;新实例 JIT 没热,连接池和本地缓存是空的。Spring Boot 3.4 之前
server.shutdown默认是immediate。 - 做法
- 优雅停机 + 就绪探针:先让 readiness 变成不可用、摘掉流量,再停;新实例预热后再接流量;灰度 1%~5% 观察指标再全量;回滚一条命令完成。
- 现象
- 下游监控里 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。
- 现象
- 大部分请求几十毫秒,每隔几分钟有一批请求同时慢几百毫秒,和流量、下游都对不上。
- 根源
- GC 停顿(尤其是 Full GC)期间所有业务线程一起停,这段时间进来的请求全部排队。按 Little 定律,停顿越长,攒下的请求越多,恢复后还要一阵才能消化完。常见诱因:堆太小、大对象(一次查出几万行、大 JSON)、内存泄漏。
- 做法
- 开 GC 日志,把 GC 停顿和 P99 曲线叠在一起看;JDK 17 / 21 默认 G1,对停顿敏感的服务可以试 ZGC(JDK 21 起有分代 ZGC);从源头减少大对象,列表接口限制条数。
- 现象
- 订单创建成功,积分、物流却没收到消息;或者反过来,消息发出去了,订单事务却回滚了,下游处理了一个不存在的订单。
- 根源
- 数据库和消息队列是两个系统,没有共同的事务。在
@Transactional方法里发消息,提交前就发出去了;提交后再发,进程可能正好在两步之间挂掉。 - 做法
- 本地消息表:业务表和消息表在同一个本地事务里写,再由定时任务投递。或用 RocketMQ 事务消息:先发“半消息”(消费者看不到),本地事务提交后再确认;Broker 迟迟拿不到结果就回查业务方,默认每 30 秒查一次、最多 15 次(5.5.1 源码)。两种做法消费端都要幂等。
- 现象
- RocketMQ 某个队列的消费堆积越来越多,消费者日志里同一条消息反复失败。
- 根源
- 为了保证顺序,顺序消费失败的消息原地重试,后面的消息只能等着;客户端默认最大重试次数是
Integer.MAX_VALUE,一条坏消息就能把这个队列堵死。并发消费不一样:默认重试 16 次后进死信队列%DLQ%消费组名(5.5.1 源码)。 - 做法
- 只在真正需要顺序的业务用顺序消息,并显式设
maxReconsumeTimes,超过就记录下来跳过、人工补偿;死信队列和消费堆积都要有告警。
- 现象
- 下单成功跳到详情页,提示“订单不存在”,刷新一下又有了。
- 根源
- 写主库、读从库,主从复制有延迟:平时毫秒级,大事务或从库压力大时能到秒级。
- 做法
- “写完马上读”的场景强制走主库;余额、库存这类数据不走从库;把主从延迟加进监控和告警。
使用规范:两张清单
日常开发(代码评审时对照)
- 每个出站调用都设连接和读取超时,一层比一层短。线程被下游借走(坑 1)
- 重试只在一层做,带退避和抖动,只重试幂等操作。重试风暴(坑 2)
- 写接口幂等:唯一业务号 + 唯一索引,或防重 token。重试、重复提交、消息重复导致重复扣款
- 弱依赖写好降级:补偿表、MQ 或默认值。非核心服务拖垮主流程(坑 3)
- 写库和发消息要一致时,用本地消息表或 RocketMQ 事务消息;顺序消费设最大重试次数。消息丢失或错发(坑 11)、一条坏消息堵死队列(坑 12)
- 自建线程池有界、有名字、有拒绝策略,不同业务不共用。OOM、互相拖累
- 事务里不调远程、不发 MQ、不做慢操作。长事务占着连接和行锁
- 循环里不查库、不调远程,改成批量;互不依赖的远程调用并行发。RT 随数据量线性变长
- SQL 走索引,列表接口限制条数,避免深分页。慢 SQL 拖垮连接池
- 缓存有过期时间并加随机,空结果也缓存,先写库再删缓存。雪崩、穿透、脏数据(坑 6)
- 日志带 traceId,热路径不打大对象。出事查不到、日志 IO 拖慢 RT
- HTTP 客户端全局复用一个,连接池按“QPS × RT”配,空闲回收比服务端 keep-alive 超时短。连接池排队(坑 9)、复用到已关闭的连接
- 共享数据先想锁的粒度:能用 CAS、
LongAdder、并发容器就别包大锁;库存这类热点行更新要合并或排队。锁竞争和热点行让加机器也没用
应用投产(上线前对照)
- 用“QPS × RT”估算线程、连接和机器数,按 70% 水位留余量。大促当天现算
- 压测到拐点,知道单机上限和第一个瓶颈(系统压测)。容量靠猜
- 每个服务至少两个实例、跨可用区;MySQL、Redis 有主从切换。单点
- 优雅停机 + 就绪探针 + 预热。发版时的报错尖峰(坑 7)
- 灰度发布、一键回滚,每次变更有记录。变更引起的故障扩散到全量
- 监控四个黄金信号(延迟、流量、错误、饱和度),按 SLO 告警。用户比你先发现问题
- 限流阈值和降级开关提前配好,演练过。出事时现写代码
- JVM:
-Xms等于-Xmx,开 GC 日志和-XX:+HeapDumpOnOutOfMemoryError。出事后没有现场 - 数据库开慢查询日志、监控主从延迟和连接数;定期演练从备份恢复。备份其实恢复不了
- 消息队列监控消费堆积、死信队列和重试次数。消费者卡住几小时才被发现
- 写好预案和值班表,事后复盘、改流程。同一个坑踩两次
- 定下 RTO 和 RPO,按它选复制方式和备份频率,并定期演练切换。出大事时才发现恢复要半天
- 查系统参数:
ulimit -n、somaxconn、临时端口范围。连接数一上来就撞到系统上限
优化和深处
实验台 · 大促那晚的线程账先猜,再运行
模型:每台 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 2 | JDK 17 / Spring Boot 3 | JDK 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,默认 immediate | 3.4 起默认 graceful(3.5.16 配置元数据核对过) | — |
| 健康探针 | Boot 2.3 起有 liveness / readiness | 同左,在 Kubernetes 上自动开启 | — |
| 熔断降级库 | Hystrix 已进入维护模式(最后版本 1.5.18),官方建议换 Resilience4j;国内也常用 Sentinel | resilience4j-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 + ⑥ 版本差异