线上出问题,先分清是所有人都慢还是一个人慢

杜恒 1 阅读 技术沉淀

凌晨一点半,客服在群里发了一句“系统很卡”。

这句话里没有一个能直接动手的信息。是全部客户都慢,还是只有一家;是所有页面都慢,还是只有导出;是十分钟前开始,还是从晚上八点就断断续续。值班的人如果先打开监控大盘,看到一片绿色,往往会回一句“我这边看正常”,然后两边开始互相怀疑。

我后来会先问三句话:哪几个客户在反馈,具体点的哪个页面,第一次出现大概是几点。客户说都卡,但名单里只有三家,而且都在同一个区域,先查网络和那台应用实例——是哪台,用访问日志里的来源 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 个位置