线上出问题,先分清是所有人都慢还是一个人慢
凌晨一点半,客服在群里发了一句“系统很卡”。
这句话里没有一个能直接动手的信息。是全部客户都慢,还是只有一家;是所有页面都慢,还是只有导出;是十分钟前开始,还是从晚上八点就断断续续。值班的人如果先打开监控大盘,看到一片绿色,往往会回一句“我这边看正常”,然后两边开始互相怀疑。
我后来会先问三句话:哪几个客户在反馈,具体点的哪个页面,第一次出现大概是几点。客户说都卡,但名单里只有三家,而且都在同一个区域,先查网络和那台应用实例——是哪台,用访问日志里的来源 IP 反查,日志里没这两列就先别下“单实例”的结论;如果三家分布在不同区域、卡的是同一个功能,方向就变成这个功能的公共依赖。问完这三句,方向通常只剩“网络或单实例”和“某个公共依赖”中的一个。
先把“卡”拆成范围
所有人都慢,问题一般在共用资源:数据库、缓存、网络、某一台机器。一个客户慢,先看这个客户的数据量、这个租户最近有没有批量导入、有没有跑导出。一个接口慢,看这个接口自己的 SQL 和外部调用。时快时慢,看连接池、线程池、锁和 GC。
方向不同,动作完全不同。把“所有人都慢”当成“某个接口慢”去查,会在应用日志里翻两个小时,最后发现是数据库连接数打满;反过来把单租户的数据问题当成全局故障,会去重启整套服务,把本来只是慢的请求变成失败。
定界靠的是现场能拿到的东西。下面几样平时就要有,出事时才有得看。

traceId 是成本最低的一条线
一个请求进来,生成一个 id,放进日志的每一行,返回给调用方,再传给下游。客户报问题时报的是订单号,你从订单号找到那次请求的 traceId,就能把这一单经过的所有日志串起来,不用再靠时间猜。
生成放在最外层,网关或者第一个 Filter。外部传进来的 X-Trace-Id 不能直接信,长度和字符先校验,不合规就新生成一个,避免有人塞一段奇怪的东西进日志。
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 必须压过 Spring Security 默认的 -100,不然拿不到最外层
public class TraceFilter extends OncePerRequestFilter {
// 大写 hex 和带横线的 UUID 原样放行,把上游生成的 id 换掉,跨系统就串不起来了
private static final Pattern OK = Pattern.compile("(?i)[0-9a-f-]{16,36}");
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String inbound = req.getHeader("X-Trace-Id");
String traceId = (inbound != null && OK.matcher(inbound).matches())
? inbound : UUID.randomUUID().toString().replace("-", "").substring(0, 16);
MDC.put("traceId", traceId);
res.setHeader("X-Trace-Id", traceId);
try {
chain.doFilter(req, res);
} finally {
MDC.clear();
}
}
}
日志格式里带上 %X{traceId},这一行才有用。响应头里的 traceId 用户看不见,错误页和前端拦截器要把它显示出来——报错弹窗带一行“故障编号”,客户才有的发。Boot 3 的包是 jakarta.servlet.*,Boot 2 是 javax.servlet.*,import 抄错了编译不过。异步任务和线程池要单独处理,MDC 是 ThreadLocal,任务丢进线程池之后上下文就没了,日志里会出现一堆没有 traceId 的行。04 那篇讲租户上下文丢失时说过同一个机制,这里丢的是同一个东西的另一半。没有 traceId 的那几行日志,事后基本查不动。
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable task) {
Map<String, String> ctx = MDC.getCopyOfContextMap();
return () -> {
if (ctx != null) MDC.setContextMap(ctx);
try {
task.run();
} finally {
MDC.clear();
}
};
}
}
声明成 Bean 只对 @Async 的默认线程池生效;自建的 ThreadPoolTaskExecutor 要手动 executor.setTaskDecorator(new MdcTaskDecorator()),@Scheduled 的调度线程池也要单独包一层,别以为定义了就全生效。
发 MQ 的时候把 traceId 写进消息头,RabbitMQ 生产端是 message.getMessageProperties().setHeader("traceId", MDC.get("traceId")),消费端方法第一行取出来放进 MDC,finally 里 clear;定时任务每次执行生成一个新的,日志里至少能分清是哪一个批次。这些都是一次性工作,做完之后每一次排查都省时间。
只做这一件事,排查效率的变化就很明显。如果今年只能补一样东西,我选它。
日志要能回答“这一次请求发生了什么”
有了 traceId,日志还得有内容可看。最少要有这些字段:时间、级别、traceId、租户、用户、来源 IP、实例标识、方法、路径、状态码、耗时、错误码——来源 IP 和实例标识是“哪几家客户打到哪台机器”的证据,少了它们,同区域慢就查不到那台实例。业务的关键动作再补一条,比如下单、支付回调、退款受理,让日志能当事件流看。不要打印整个请求体和响应体,里面有手机号、身份证、地址、支付报文,出了问题日志本身就是事故。要排查就打印字段级的摘要,敏感值脱敏。异常要打完整堆栈,只打 e.getMessage() 的时候,线上那行日志通常等于“空指针”,什么也说明不了。
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{traceId}] [%X{tenantId}]
%logger{36} - %msg%n</pattern>
pattern 里出现的 %X{tenantId} 要有人放:租户解析的 Filter 里 MDC.put("tenantId", tenant),finally 里 remove;方法、路径、状态码、耗时这几样,走访问日志的过滤器在收尾处补进 MDC,不然 pattern 会打出空括号。
采样和脱敏
访问日志按量采样,错误和慢请求全留。200 QPS 的系统一天几百万行,全量短期内放得下,但按一天 8G 的速度,二十天就把 200G 的盘吃满;按 10% 采样,再加“耗时超过 1 秒的全留”,比例不大,关键的一次也不会漏。采样的位置放在访问日志过滤器的收尾处:boolean keep = status >= 500 || costMs > 1000 || counter.incrementAndGet() % 10 == 0; 正常请求按计数采样,错误和慢请求无条件留。
保留期按磁盘倒推,不要按“合规要求”随口定一个一年。一台 200G 的日志盘,每天写 8G,热数据只能留二十来天,超过就写满;真要留一年,得进对象存储,查的时候再拉回来。保留期最后落在 logback 的 SizeAndTimeBasedRollingPolicy 三个参数上:maxFileSize、maxHistory、totalSizeCap,totalSizeCap 要给数据库目录留出余量。脱敏收口到统一的日志工具方法里做(所有 log 调用走同一个包装),不要散在各业务代码里自己 replace,一旦落盘就已经晚了。
日志级别要克制。参数校验失败是业务结果,不是系统错误,打 WARN 就够了;每一条 ERROR 都应该意味着有人需要来看一眼。生产环境把 DEBUG 打开过一次忘了关,磁盘两天写满,这种事比代码 bug 更常见。09 那篇讲容器日志上限时提过,应用侧和容器侧都要设,别只设一处。
指标先建四个,不要先买 APM
小团队最容易走的两条弯路,一条是买一套重型 APM,装完没人看;另一条是只盯 CPU 和内存,CPU 一高就扩容,扩完还是慢。
我一般按四组建:接口组(p95 加错误率)、池组(数据库连接池等待数加业务线程池队列长度)、磁盘使用率、GC 停顿。前三样第一天就要,GC 可以晚一周。这几样能覆盖大部分“卡”的原因,而且都能落到具体动作上。
CPU 高不等于系统慢。一个批量任务把 CPU 打到 90%,接口响应还是 200 毫秒,这叫资源用满了但没影响业务;反过来 CPU 只有 20%,连接池的等待数一直大于零,请求全卡在拿连接上。看 CPU 决定要不要扩容,经常是拿一个不相关的数做了一个昂贵的决定。平均耗时更会骗人,一百个请求里九十九个用 100 毫秒,一个卡了十秒,平均下来不到 200 毫秒;这个量级的单点长尾,p95 和 p99 都未必看得见,只有最大值抓得住——所以慢请求要单独留痕,不能把宝全押在分位数告警上,等长尾占比上去了,p95 才会开始说话。指标至少要能按实例、按版本拆开,一台机器慢和所有机器一起慢,处理方式差得很远;发布之后新旧版本并存的那几分钟,没有版本标签根本分不清是谁慢。
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
tags:
application: ${spring.application.name}
instance: ${HOSTNAME:unknown}
distribution:
percentiles-histogram:
http.server.requests: true
prometheus 端点要先引入 micrometer-registry-prometheus 依赖,没有它这个端点不存在;不开 percentiles-histogram 就没有 bucket,Prometheus 那边的 histogram_quantile 写不出来,p95 告警无从谈起。对指标名:错误率用 http_server_requests_seconds_count 的 status 标签,连接池等待是 hikaricp_connections_pending,GC 停顿是 jvm_gc_pause_seconds,磁盘是 disk_free_bytes。
接口指标不要拿 userId、orderId 当标签,基数会炸;租户标签让每条接口指标的序列数乘以租户数,拿租户数×接口数×状态码数估一下总量,采集端吃不消再去掉或抽样,不用记死一个数字。这些细节不影响第一天上线,但影响三个月后这套监控还能不能用。
告警要少,每条都得能动手
告警只报用户能感知的症状:五分钟错误率超过 1%,p95 连续十分钟超过一秒,回调队列积压超过一千条,磁盘超过 85%。CPU、内存这类资源指标只在容量规划时看,不直接叫醒人;磁盘是例外,写满就是事故,所以它进告警表。告警一多,等于没有告警。
每条告警都要能点到一个页面,页面上写清楚先看什么、常见的几种原因、现在能做的止血动作、搞不定找谁。没有这个页面的告警,要么补上,要么删掉。值班的人凌晨两点看到“CPU 高”,第一反应是关掉手机继续睡,这不怪他。
| 告警 | 先看什么 | 常见原因 | 止血动作 |
|---|---|---|---|
| 五分钟错误率 >1% | 按接口拆错误码,找第一个报错的时间点 | 新版本、外部依赖、数据库 | 回滚上一版或摘掉异常实例 |
| p95 连续十分钟 >1s | 慢接口排序,再看该接口的 SQL 和外部调用 | 索引失效、连接池、下游超时 | 限流、扩容、临时关掉非核心功能 |
| 回调队列积压 | 消费者是否存活,单条处理耗时 | 消费线程卡住、下游慢、流量突增 | 加消费者,先把毒消息隔离 |
| 磁盘 >85% | 日志目录、上传目录、数据库数据目录 | 日志没切、临时文件、备份堆积 | 清日志、挪备份、扩盘 |
| 数据库连接等待 >0 持续五分钟 | 活跃连接数、慢查询、锁等待 | 连接泄漏、慢 SQL、长事务 | 杀掉长事务,回滚有问题的发布 |
这张表是给值班的人半夜照着做的。表里“限流”“临时关掉非核心功能”这两条有前提:入口有限流配置、功能有开关。功能入口统一读一个配置表(加定时刷新就够),没有开关的功能别写进 Runbook——写了也拔不动;加消费者要同时看分区数上限,消费者比分区多就是白加。每一条后面还应该有一行“上次发生是什么时候、当时怎么处理的”,比任何监控工具都值钱。
告警还要有主人。没有主人的告警,三个月后没人知道它为什么存在,也没人敢删。把告警规则和代码放在同一个仓库,改阈值走同一次评审,谁写的谁负责,这条比阈值定多少更重要。
发到哪里也有讲究。全部丢进一个群,三天后没人再看;按服务分群,关键告警再单独打电话,才能保证有人醒。夜里被叫醒的那条,第二天要回看是不是误报,同一条规则误报三次以上就该改阈值或者删掉——这是我自己的土规矩,不是行业惯例,重点是误报必须有反馈回路,不改,下一次真出事就没人接了。
定界要靠几条命令和 SQL
应用日志说“慢”,下一步要确认慢在哪。
先分客户端和服务端。前端说慢,打开浏览器的时间线,看是请求排队、接口返回慢,还是页面渲染卡。服务端日志显示 200 毫秒,用户还是觉得慢,问题可能在一个页面发了几十个请求,或者一张表渲染了五千行。这个判断不做,应用日志里什么也看不出来。
然后看数据库,先看正在跑的语句和锁,不要一上来就重启。
-- 现在谁在跑,凌晨定位从这张表开始
SELECT id, user, db, time, state, LEFT(info, 100) AS sql_head
FROM information_schema.processlist
WHERE command <> 'Sleep' ORDER BY time DESC;
-- 超过 60 秒的事务,「杀掉长事务」先用它定位,再 KILL 线程号
SELECT trx_mysql_thread_id, trx_started, trx_rows_locked
FROM information_schema.innodb_trx
WHERE trx_started < NOW() - INTERVAL 60 SECOND;
-- 当前连接在等什么,谁挡住了它
SELECT waiting_pid, blocking_pid, waiting_query, blocking_query
FROM sys.innodb_lock_waits;
-- 按总耗时排序的语句,找最贵的 digest;要找最常见就把 ORDER BY 换成 count_star
SELECT digest_text, count_star,
ROUND(sum_timer_wait / 1e12, 2) AS total_sec,
ROUND(avg_timer_wait / 1e9, 2) AS avg_ms
FROM performance_schema.events_statements_summary_by_digest
ORDER BY sum_timer_wait DESC
LIMIT 10;
sys.innodb_lock_waits 里 blocking_query 有时显 NULL——占锁方躺在一个空闲事务里,这时拿 blocking_pid 去 processlist 对。JVM 这边看线程和堆,不要只看一张 CPU 曲线。jcmd <pid> Thread.print 连续打三次,间隔五秒,卡在同一个位置的线程就是嫌疑;jcmd <pid> GC.heap_info 看老年代是不是快满了(JDK 8 没有这条命令,用 jstat -gcutil <pid> 1000 看)。堆 dump 文件很大,别在磁盘快满的时候打。
外部调用要看超时和重试配置。一个下游超时三秒、失败后再重试两次(连首次共三次尝试),一个请求最坏就是九秒;配置写成“失败后再试三次”就是十二秒——重试次数的口径先对清楚,日志里看到的是“很慢”,根因在配置里。把每个外部依赖的超时、重试、熔断值列成一张表,出问题时先对一遍,比翻代码快。
Runbook 是写给别人用的
我见过最没用的一份 Runbook,是事故之后补的三页复盘,里面全是“加强监控”“提高意识”。下一次换个人值班,还是不知道该先看哪里。
有用的一页通常长这样:这条告警意味着什么;影响的是全部还是部分;先打开哪个面板、跑哪条 SQL;三种最常见的原因各怎么确认;确认之后先做哪个动作;多久没恢复要升级给谁。写的时候假设读的人没参与过这套系统的开发,也没看过昨天的聊天记录。
写完要找另一个人照着做一遍。他卡在哪一步,那一步就是缺的。自己读一遍不算验证,作者会不自觉补上脑子里知道但纸上没写的东西。
大盘回答不了为什么慢
顺序别反过来。先把 traceId 打通,再把日志字段补齐,然后建四组指标,接着配少量告警和 Runbook,最后才是大盘。大盘是给人探索用的,不是告警的入口;先有大盘再补 traceId,最后往往会得到一面很好看但回答不了“这一单为什么慢”的墙。
如果只有两天时间,先做 traceId 和一条错误率告警。下一次有人再说“系统很卡”,让他把页面上的 traceId 发过来,你就能从一堆日志里直接走到那一单。这套做法在 200 QPS 以内、十几台实例的规模里够用,再往上,日志管道的成本要单独算,别拿它硬撑。
系列:上一篇 数据迁移完了,没人敢关旧系统 | 下一篇 RAG 知识库实战:检索不准,问题多半不在大模型 | 相关 多租户系统最容易出事的 8 个位置