CatchAdmin PHP 后台管理框架 Logo CatchAdmin

五条监控告警,能覆盖大多数 PHP 线上故障

每一次故障都有一套叙述,而这些叙述里几乎都会出现同一句话:

"直到用户来抱怨,我们才察觉。"

等到用户开始抱怨,事件已经进行了 20 分钟。连接池耗尽、队列工作进程卡死、500 错误开始级联。修复动作通常很小——回滚、重启、清缓存——但排障时间被拉长,因为所有工作都在压力下展开,要打开 18 个月前配置的看板,还得努力回忆哪个 Grafana 面板展示的是什么。

观察下来,那些擅长处理故障的团队,并不是事故响应更快,而是检测更早。告警先于用户感知触发,值班工程师在第一张用户工单到来之前就已经在看日志。一场原本会拖一小时的故障,被压缩成十分钟;甚至根本没能凑齐"故障"的定义,就已经被拦在上游。

本文把多份事故复盘浓缩为五条真正能提前捕捉问题的监控告警。没有一条依赖昂贵的 APM。它们都能用 Prometheus + Grafana、Datadog、CloudWatch,甚至一个定时检查指标并推送 Slack 消息的 cron 任务实现。工具名字不重要,信号才重要。

TL;DR 速览

  • 多数 PHP 故障都可归入相同的五类:工作进程饱和、数据库连接耗尽、队列拥塞、外部 API 超时、与某次发布强相关的错误率突增。
  • 每类问题对应的"好指标"是比率和速率,而不是绝对数字。"每分钟 500 条 500 错误"只有放在总请求速率里才有意义。
  • 要告警在前置指标(接近饱和、队列堆积)上,而不是只盯着后置指标(超时、500)。前置指标给时间恢复,后置指标只告诉你故障已经发生。
  • 真正难的不是写查询表达式,而是选出不会频繁误报的阈值。经验值是:容量的 70%,持续 2–5 分钟。
  • 五条告警都能从 PHP-FPM、数据库和队列系统已经暴露的数据里建出来,不需要任何厂商绑定。

本文要点

  • 覆盖 95% 真实 PHP 线上事件的五个指标
  • 每条告警背后的典型故障场景
  • 每条告警对应的 PromQL、Shell 命令或 PHP 代码
  • 如何选出既能出信号、又不会造成告警疲劳的阈值
  • 哪些看起来很重要、其实是滞后指标,应当降级处理
  • 如何搭建一张"黄金看板",让值班工程师在 30 秒内完成分诊

告警一,PHP-FPM 工作进程饱和度

它要抓什么: 慢请求在 FPM 进程池中堆积,直至所有工作进程全部忙碌,新请求排队或被拒绝。

为什么排在第一: 这是 PHP 故障最常见的形态。某个下游依赖变慢(通常是数据库或第三方 API),每条 PHP 请求都跟着变慢。请求速率并未变化,但同时在途的请求数显著上升,FPM 池被填满,新请求要么在 listen queue 里等,要么被 Nginx 以 503 打回。

用户端的症状先是"页面变慢",然后"超时",再然后是 Nginx 返回的 502/504。数据库看着正常,CPU、内存都正常。实际情况是每个工作进程都卡在 poll() 系统调用上等某件事。没有工作进程饱和度告警时,第一条信号只能是用户投诉。

需要的指标: PHP-FPM 活跃工作进程数与总工作进程数。

PHP-FPM 自带的状态页能提供这些值。在 pool 配置里开启:

ini
; /etc/php/8.3/fpm/pool.d/www.conf
pm.status_path = /status
ping.path = /ping

再通过 nginx 暴露:

nginx
location ~ ^/(fpm-status|fpm-ping)$ {
    allow 127.0.0.1;
    deny all;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param PATH_INFO $fastcgi_script_name;
}

此时 curl http://localhost/fpm-status 会返回:

pool:                 www
process manager:      dynamic
start time:           22/Apr/2026:14:32:18 +0000
start since:          3600
accepted conn:        145812
listen queue:         0
max listen queue:     18
listen queue len:     511
idle processes:       2
active processes:     48
total processes:      50
max active processes: 50
max children reached: 12
slow requests:        47

真正重要的是这几项:

  • active processes / total processes——饱和度
  • listen queue——此刻等待空闲工作进程的请求数
  • max children reached——所有工作进程全忙的累计次数(是计数器,需要看增长速率)
  • slow requests——超过 request_slowlog_timeout 的请求数

如何采集到 Prometheus: 社区有多款 php-fpm_exporter,其中 hipages/php-fpm_exporter 最常用。它可以把状态页转换为 Prometheus 指标。与 FPM 部署在一起即可:

yaml
# docker-compose.yml 片段
php-fpm-exporter:
  image: hipages/php-fpm_exporter:latest
  environment:
    PHP_FPM_SCRAPE_URI: "tcp://php-fpm:9000/fpm-status"
  ports:
    - "9253:9253"

暴露出来的指标包括 phpfpm_active_processesphpfpm_total_processesphpfpm_listen_queuephpfpm_max_children_reached 等。

告警表达式:

# 70% 工作进程忙碌,持续 2 分钟触发
(phpfpm_active_processes / phpfpm_total_processes) > 0.7
# ... for 2m

Alertmanager 格式:

yaml
- alert: PhpFpmPoolSaturation
  expr: (phpfpm_active_processes / phpfpm_total_processes) > 0.7
  for: 2m
  labels:
    severity: warning
  annotations:
    summary: "PHP-FPM 进程池 {{ $labels.pool }} 已饱和 {{ $value | humanizePercentage }}"
    description: "持续 2 分钟高饱和度,预计会出现级联变慢。"

- alert: PhpFpmPoolExhausted
  expr: phpfpm_listen_queue > 0
  for: 30s
  labels:
    severity: critical
  annotations:
    summary: "PHP-FPM 进程池 {{ $labels.pool }} 已有 {{ $value }} 个请求排队"
    description: "所有工作进程忙碌,请求开始排队,用户已受影响。"

warning 在 70% 触发,还有回旋空间,但趋势已经不妙;critical 在 listen queue 出现排队时触发,说明所有工作进程已饱和,新请求正在排队等待。

阈值取舍: 70% 是"既能过滤日常流量抖动又不至于拖到出故障"的甜点。如果 70% 对当前业务噪声太大,可以上调到 80%,但不要再高。到 90% 时,离把整个池打满就只差一次请求。

触发后要做什么: 查看 FPM 慢日志(在 pool 配置中开启 slowlogrequest_slowlog_timeout = 5s)。几乎永远是三种情形之一:一条慢查询、一次卡住的外部 HTTP 调用,或者某个接口忽然比平时慢 10 倍。

告警二,数据库连接池利用率

它要抓什么: 在撞上 "Too many connections" 那堵墙之前就提前发现连接即将耗尽。

为什么重要: 数据库连接是有界资源。大多数 MySQL 默认配置是 max_connections = 151,即便是生产配置,一般也不会超过 500–1000。每个 PHP-FPM 工作进程可能持有 1–3 条连接,100 个工作进程在空闲状态下就已经占掉 100–300 条连接。一旦查询变慢、事务持有时间变长,就会碰到上限。

它的阴险之处在于失败方式:新连接失败,已建立的连接照常工作。于是请求成功失败掺在一起,错误信息 SQLSTATE[HY000] [1040] Too many connections 散落在日志里,并不明显地与某个接口绑定。没有连接数上升的告警,就只能在撞到 100% 那一刻才发现。

指标: 当前连接数与配置上限的比值。

MySQL 的底层值来自状态变量:

sql
-- 当前连接数
SHOW GLOBAL STATUS LIKE 'Threads_connected';

-- 自服务器启动以来的历史峰值
SHOW GLOBAL STATUS LIKE 'Max_used_connections';

-- 配置上限
SHOW GLOBAL VARIABLES LIKE 'max_connections';

Postgres:

sql
-- 当前连接数
SELECT count(*) FROM pg_stat_activity;

-- 配置上限
SHOW max_connections;

-- 按状态分组
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;

如何采集: MySQL 用 mysqld_exporter,Postgres 用 postgres_exporter,两者在 Prometheus 生态中都是标配。

# MySQL 利用率
mysql_global_status_threads_connected / mysql_global_variables_max_connections

# Postgres 利用率
pg_stat_database_numbackends / pg_settings_max_connections

告警表达式:

yaml
- alert: DatabaseConnectionsHigh
  expr: |
    mysql_global_status_threads_connected
    / mysql_global_variables_max_connections > 0.6
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "MySQL 已用连接达到 max_connections 的 {{ $value | humanizePercentage }}"
    description: "连接占用持续高于 60%,需要在耗尽前介入。"

- alert: DatabaseConnectionsCritical
  expr: |
    mysql_global_status_threads_connected
    / mysql_global_variables_max_connections > 0.85
  for: 1m
  labels:
    severity: critical
  annotations:
    summary: "MySQL 已用连接达到 max_connections 的 {{ $value | humanizePercentage }}"
    description: "接近连接耗尽,新请求即将失败。"

阈值取舍: 60% 的 warning 偏保守,因为连接增长往往是加速的。"60% 仍然稳" 到 "100% 彻底耗尽" 在事故期间可能只相差几分钟。85% 的 critical 给出的是"在错误真正出现之前还有几分钟可介入"的窗口。

额外有用的信号——空闲连接: MySQL 的 Sleep 和 Postgres 的 idle in transaction 值得单独告警:

# Postgres:idle in transaction 超过 30 秒
pg_stat_activity_max_tx_duration{state="idle in transaction"} > 30

单独一个超过 30 秒的 idle in transaction 几乎必然是 bug——某个 PHP 请求开启了事务,转身又去做与数据库无关的事情。一条还不算故障,十条就是故障的开端。

触发后要做什么: MySQL 执行 SHOW PROCESSLIST,Postgres 查 pg_stat_activity。重点看是不是同一条查询出现在多条连接上——那就是一条慢查询在被多个 PHP 请求反复命中。必要时先杀掉长跑查询,再修代码。

告警三,队列深度与最旧任务时长

它要抓什么: 工作进程跟不上消费速度的时刻,在用户感知到之前就暴露出来。

为什么重要: 队列拥塞是沉默的。任务在 Redis 或数据库中堆积。本该发给用户的邮件没发出去、上传的文件没被处理、触发的工作流没跑起来。UI 上只写着"我们会给您发邮件",邮件却一小时后才到,或者第二天、或者压根没到。

有两种典型失效:

  • 工作进程饥饿。 所有工作进程都被某一种慢任务占住,后面排着一大串本该很快的小任务。
  • 工作进程崩溃。 worker 崩了或被 OOM killed,supervisor 自动重启,期间队列持续增长。

两种情况的信号都一样:队列深度在增长,最老任务的等待时长在增长。

指标: 以 Laravel 队列为例,每条队列的深度与最旧任务等待时长。

Redis 后端队列可以直接查:

bash
# 队列深度
redis-cli LLEN queues:default

# 延迟队列(按时间戳排序的有序集合)
redis-cli ZCARD queues:default:delayed

# 最旧任务的入队时间(探查不出队)
redis-cli LINDEX queues:default 0 | jq '.pushedAt'

数据库后端队列(jobs 表):

sql
-- 深度
SELECT queue, COUNT(*) AS depth
FROM jobs
WHERE available_at <= UNIX_TIMESTAMP()
GROUP BY queue;

-- 最旧等待任务的等待时长
SELECT queue, MIN(created_at) AS oldest,
       UNIX_TIMESTAMP() - MIN(available_at) AS age_seconds
FROM jobs
WHERE available_at <= UNIX_TIMESTAMP()
GROUP BY queue;

Horizon 在自己的面板中能看到这两项,但告警仍需要抓取一份独立指标。

PHP 侧自定义 exporter: 对于定制指标,一个接受 Prometheus 抓取的小接口就足够。Laravel 的最小实现:

php
// routes/web.php
Route::get('/metrics', function () {
    $lines = [];

    foreach (['default', 'emails', 'exports'] as $queue) {
        $depth = Redis::llen("queues:$queue");
        $lines[] = "queue_depth{queue=\"$queue\"} $depth";

        $oldestRaw = Redis::lindex("queues:$queue", 0);
        $age = 0;
        if ($oldestRaw) {
            $payload  = json_decode($oldestRaw, true);
            $pushedAt = $payload['pushedAt'] ?? null;
            if ($pushedAt) {
                $age = time() - (int) $pushedAt;
            }
        }
        $lines[] = "queue_oldest_age_seconds{queue=\"$queue\"} $age";
    }

    return response(implode("\n", $lines) . "\n", 200, [
        'Content-Type' => 'text/plain; version=0.0.4',
    ]);
})->middleware('internal-only');

这个接口务必保护起来,只给 Prometheus 和管理员访问,别公开暴露。

告警表达式:

yaml
- alert: QueueBacklogHigh
  expr: queue_depth > 1000
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "队列 {{ $labels.queue }} 当前有 {{ $value }} 个待处理任务"
    description: "工作进程可能跟不上,检查 worker 状态与慢任务。"

- alert: QueueOldestJobTooOld
  expr: queue_oldest_age_seconds > 300
  for: 1m
  labels:
    severity: critical
  annotations:
    summary: "队列 {{ $labels.queue }} 存在等待超过 5 分钟的任务"
    description: "极可能是 worker 饥饿或崩溃,需要立刻处理。"

阈值取舍: 仅看深度噪声太大(一次批量导入会在瞬间塞进一万条任务)。真正的信号是最旧任务的年龄——一条任务等了 5 分钟还没被处理,几乎一定出了问题。warning 绑在深度上作为前置指标;critical 绑在年龄上,对应"用户已经感受到"的阶段。

阈值要按队列分别设置。允许 5 分钟延迟的通知队列,和要求秒级一致的库存同步队列不可同日而语:

yaml
- alert: CriticalQueueOldestJobTooOld
  expr: queue_oldest_age_seconds{queue="inventory-sync"} > 60
  for: 30s
  # ...

触发后要做什么: 先看 worker 是否在跑(supervisorctl status)。如果在跑,则需要确认是不是"表面活着却没在干活"——用 strace 附到某个进程,或者在 Horizon 面板上看每个 worker 当前在处理什么任务。单条慢任务把 worker 占住,是最常见的成因。

告警四,错误率与发布事件相关联的突变

它要抓什么: 一次问题发布引入了新的错误类别,趁它还没演变成完整事故就拦下来。

为什么重要: "我们的代码把生产弄坏了"这一类事故有固定的剧本:发布上线 → 某一类请求开始以特定错误失败 → 错误在 Sentry 或日志中累积 → 20 分钟后团队才在错误总量压过日常噪声时发现。

信号在于变化率,不在于绝对值。一个平日 0.1% 错误率的服务升到 0.5%,是 5 倍增长——这是明确信号,哪怕 99.5% 的请求仍然正常。等绝对值涨到 5% 或 10%,抢救窗口早就错过了。

指标: 错误率,即错误请求与总请求的比值,在较短窗口里与较长的基线窗口作比较。

Laravel/PHP 的这类指标通常从访问日志或插桩中间件获取。下面是一个简化版本的中间件:

php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Prometheus\CollectorRegistry;

class TrackRequestMetrics
{
    public function __construct(private CollectorRegistry $registry) {}

    public function handle(Request $request, Closure $next)
    {
        $start = microtime(true);
        $response = $next($request);

        $duration = microtime(true) - $start;
        $status   = $response->getStatusCode();
        $route    = $request->route()?->getName() ?? 'unknown';

        $this->registry->getOrRegisterCounter(
            'app', 'http_requests_total',
            'Total HTTP requests',
            ['route', 'status']
        )->inc(['route' => $route, 'status' => (string) $status]);

        $this->registry->getOrRegisterHistogram(
            'app', 'http_request_duration_seconds',
            'HTTP request duration',
            ['route']
        )->observe($duration, ['route' => $route]);

        return $response;
    }
}

再把 Prometheus registry 暴露在 /metrics,就能得到 app_http_requests_total{route=..., status=...} 这样的计数器。

告警表达式:

yaml
- alert: ErrorRateSpike
  expr: |
    (
      sum(rate(app_http_requests_total{status=~"5.."}[5m]))
      /
      sum(rate(app_http_requests_total[5m]))
    ) > 0.02
    and
    (
      sum(rate(app_http_requests_total{status=~"5.."}[5m]))
      /
      sum(rate(app_http_requests_total[5m]))
    )
    >
    (
      sum(rate(app_http_requests_total{status=~"5.."}[1h] offset 1h))
      /
      sum(rate(app_http_requests_total[1h] offset 1h))
    ) * 3
  for: 2m
  labels:
    severity: warning
  annotations:
    summary: "错误率 {{ $value | humanizePercentage }},为基线的 3 倍"

这条告警只在两个条件同时成立时触发:当前错误率绝对值高于 2%,并且至少是一小时前基线的 3 倍。这一组合既能避开"基线本来就高,什么也触发不了",也能避开"任何微小抖动都触发"的失效模式。

与发布事件做关联: 真正让这个告警变强的,是在错误率曲线上叠加"发布标记"。大多数监控系统都支持把发布事件作为 annotation 推送:

bash
# 发布成功后调用监控系统
curl -X POST https://api.datadoghq.com/api/v1/events \
  -H "DD-API-KEY: $DD_API_KEY" \
  -d '{
    "title": "Deploy: order-service",
    "text": "Deployed commit abc1234 by alice",
    "tags": ["service:order-service", "deploy"]
  }'

Grafana 也可以通过 API 推送 annotation。当错误率峰值和发布标记出现在同一个 X 坐标上,原因一目了然;没有发布标记,就只能靠猜。

阈值取舍: 2% 的绝对下限能过滤掉低流量路由上的小错误抖动;3 倍基线的相对值既能在基线为 0.01% 时抓住真实回退,也能在基线 0.5% 时正常工作;for: 2m 能避免单次发布预览请求把值班人员惊醒。

触发后要做什么: 先看发布日志,再看错误消息(Sentry、Bugsnag 或 tail -f storage/logs/laravel.log | grep -v INFO)。如果时间上与某次最近发布高度重合,先回滚,再排查。

告警五,外部 API 的成功率与时延

它要抓什么: 第三方依赖在把整个 PHP 应用拖垮之前,先捕捉到它们的劣化。

为什么重要: PHP 应用很少独自存在。它要调支付网关、邮件服务、CRM API、地理编码服务、SSO 供应商。每一个都是潜在的故障源,而在用户眼里,它们失败的样子都跟应用失败一模一样。Stripe 出问题,结账页看上去就是"支付失败";Twilio 变慢,登录页就会在 2FA 步骤上卡住。

常见的错误判断是"要么通、要么不通"。现实里它们是逐步劣化的:时延从 200ms 升到 2s,成功率从 99.9% 降到 97%。应用开始累积超时,队列开始因为重试而拥塞,一小时后演变成一场由合作方轻度劣化引发的事故。

修复之道不是去让第三方变快(也不可能),而是早点知道,好尽早触发熔断、切换兜底、或者提前告诉用户"支付暂时不可用",而不是让他们自己傻等 30 秒。

指标: 每个对外 HTTP 调用的目标端点的成功率与时延。

最简单的插桩方式是为 Guzzle 挂一个中间件:

php
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Prometheus\CollectorRegistry;

class PrometheusHttpMiddleware
{
    public function __construct(private CollectorRegistry $registry) {}

    public function __invoke(callable $handler)
    {
        return function (RequestInterface $request, array $options) use ($handler) {
            $start = microtime(true);
            $host  = $request->getUri()->getHost();

            return $handler($request, $options)->then(
                function (ResponseInterface $response) use ($host, $start) {
                    $this->record($host, $response->getStatusCode(), $start);
                    return $response;
                },
                function ($reason) use ($host, $start) {
                    // 异常或连接错误
                    $this->record($host, 0, $start);
                    throw $reason;
                }
            );
        };
    }

    private function record(string $host, int $status, float $start): void
    {
        $duration = microtime(true) - $start;

        $this->registry->getOrRegisterCounter(
            'app', 'external_http_total',
            'Outbound HTTP requests',
            ['host', 'status']
        )->inc(['host' => $host, 'status' => (string) $status]);

        $this->registry->getOrRegisterHistogram(
            'app', 'external_http_duration_seconds',
            'Outbound HTTP duration',
            ['host']
        )->observe($duration, ['host' => $host]);
    }
}

把中间件注册到 Guzzle 客户端上:

php
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;

$stack = HandlerStack::create();
$stack->push(app(PrometheusHttpMiddleware::class));

$client = new Client(['handler' => $stack, 'timeout' => 10]);

每一次对外请求都带上了 host 和状态码标签,可以清楚看到每个依赖的成功率和速度。

告警表达式:

yaml
- alert: ExternalApiDegraded
  expr: |
    (
      sum by (host) (rate(app_external_http_total{status!~"2..|3.."}[5m]))
      /
      sum by (host) (rate(app_external_http_total[5m]))
    ) > 0.05
  for: 3m
  labels:
    severity: warning
  annotations:
    summary: "外部 API {{ $labels.host }} 失败率 {{ $value | humanizePercentage }}"

- alert: ExternalApiLatencyHigh
  expr: |
    histogram_quantile(0.95,
      sum by (host, le) (rate(app_external_http_duration_seconds_bucket[5m]))
    ) > 2
  for: 3m
  labels:
    severity: warning
  annotations:
    summary: "外部 API {{ $labels.host }} p95 时延达到 {{ $value }}s"

阈值取舍: 5% 的失败率是"必须叫醒"的触发点,低于这个水位的偶发失败在网络抖动后重试成功属于正常。p95 时延超过 2 秒意味着每二十个请求就有一个慢到让用户能感觉出来,如果不拦下来,会级联成 FPM 饱和。

阈值按依赖区分。 Stripe 的 api.stripe.com 应比营销邮件服务苛刻得多:

yaml
- alert: PaymentGatewayDegraded
  expr: |
    (
      sum(rate(app_external_http_total{host="api.stripe.com", status!~"2..|3.."}[5m]))
      /
      sum(rate(app_external_http_total{host="api.stripe.com"}[5m]))
    ) > 0.01
  for: 1m

支付链路 1% 的失败率值得半夜呼叫;日志 webhook 的 1% 则不值得。

触发后要做什么: 先看服务商状态页(status.stripe.com 等)。他们承认问题的话,立即触发熔断并通知用户;否则要判断是不是自家的 DNS、网络或者只在你的服务器上失败(换一台机器 curl 一下)。

那些该降级的滞后指标

下面五类信号看起来很重要,但多数情况下是滞后指标——等它们触发,事故已经对用户可见,它们只能给出"确认"而不是"预警":

  • PHP 服务器的 CPU 和内存。 它们的变化都发生在真正的原因(进程池饱和、内存泄漏)之后。CPU 打满的时候,用户早就感知到了。放在看板里做上下文就好,不要用来呼叫。
  • 绝对数量的 HTTP 5xx。 离开请求速率的上下文没有意义。10,000 RPS 下每分钟 500 条 500 错误是正常的;1,000 RPS 下每分钟 500 条就是紧急情况。应采用告警四里的比率版本。
  • 数据库 CPU。 与应用服务器 CPU 同理——它是症状,不是原因。把数据库 CPU 打满的慢查询,会在告警二(连接饱和)里先一步体现出来。
  • 磁盘使用率。 变化慢,很少是事故的真实触发点。每晚一次 cron 做磁盘检查通常就够了,除非确实余量紧张,不必做实时告警。
  • 外部拨测。 它只能告诉你伦敦节点 30 秒前拿到了 200 状态码,完全不知道结账正在失败、队列正在堆积。作为健康自检尚可,作为主告警就不合适。

可以用一个判断准则:这条告警是让我能提前阻止事故,还是只能用来确认事故? PagerDuty 级别的告警应是前者,后者可以放在看板上。

搭一张黄金看板

凌晨 3 点触发的告警没有用,如果响应工程师还得翻七张看板才能弄清楚发生了什么。解决办法是一张集中在一处的"黄金看板",把上面五个信号同时展示出来,每一项的健康状态一眼可见。

自上而下的最小布局:

  • 第一行: 五条告警各自的状态灯——绿色/黄色/红色,并附当前值和阈值。Grafana 的 stat 面板能很干净地做出来。
  • 第二行: 请求速率、错误率的时间序列,叠加发布标记。"过去 30 分钟有什么变化"的问题一眼就能回答。
  • 第三行: FPM 饱和度、数据库连接利用率、各队列深度——三个内部资源指标。
  • 第四行: 各外部依赖 host 的 p95 时延与成功率。
  • 第五行: 过去 15 分钟的 Top 10 慢查询和 Top 10 PHP 错误消息。这些不是告警指标,但在事故期间是"当前有什么异常"最直接的入口。

这张看板的 Grafana JSON 本身就比这篇文章还长——但真正关键的是结构模式,而不是具体面板。每个团队都应当搭一次,放进值班手册,作为事故期间的"事实来源"长期维护。

30 秒分诊流程

配齐这些告警后,事故响应就有了稳定的套路。值班工程师按顺序完成一个 30 秒级别的分诊,就能把问题归到某一类:

告警名告诉他触发的是哪一类。打开黄金看板,依次看五行信号:

  1. FPM 是否饱和? 大概率是慢请求模式——查慢日志和 Top 接口。
  2. 数据库连接是否高?SHOW PROCESSLIST,找被反复命中的慢查询。
  3. 队列是否在堆积? 看 worker 状态,strace 附到卡死的 worker。
  4. 错误率是否突增? 看发布标记,若高度对齐某次发布,立即回滚。
  5. 外部 API 是否劣化? 查服务商状态页,触发熔断。

30 秒内,问题就能被归到五类之一。真正的深度排查从这里开始——但它从一个清晰的方向开始。没有这套流程,每次事故的前五分钟都会耗在"打开对的工具"上。

简明 Q&A

Q:必须用 Prometheus 吗?公司已经在用 Datadog、New Relic 或 CloudWatch。
指标和模式完全一致,只是查询语法不同。Datadog 的查询语言、CloudWatch 的 Metric Math、New Relic 的 NRQL 都能表达这里展示的"当前值对基线值"对比。底层信号是工具无关的:FPM 状态页、数据库状态变量、队列深度、HTTP 计时。把 PromQL 翻译成对应语法即可。

Q:应用规模很小,真的需要全部五条告警吗?
只要有真实用户,就需要。这套模式并不会随着规模缩小而变得不适用——10 个工作进程的 FPM 池同样会饱和,而小团队其实更难承受工作时间中途被抓去调 40 分钟。这套告警一次设置几个小时,摊销下来价值巨大。

Q:怎么避免告警疲劳?
两条原则。第一,每条告警都必须配有 runbook——触发时有一页固定的"该去查哪里"的文档。缺少 runbook 的告警最终都会被忽略。第二,积极调优阈值。如果一条告警在日常运营里就频繁触发又不需要真动作,就提高阈值。一周喊一次狼来了的告警,一个月内就会彻底失效。

Q:合成监测(每分钟跑一次模拟用户流程)呢?
作为端到端流程是否通畅的兜底检查有价值,但不适合作为主告警。合成探测按固定间隔(通常 1–5 分钟)运行,看不到用户层级的差异,也区分不出"流程真的坏了"和"十个节点里某一个网络抖了一下"。把它和上面五条告警搭配使用即可,不要替代。

Q:要不要对"发布成功/失败率"告警?
这是发布流水线团队的运营指标,不是故障告警。故障视角已经被告警四(发布后的错误率)覆盖。如果发布频繁不稳定到需要单独告警,优先修复流水线,不要用告警兜底。

Q:如何对"没发生的事"告警?比如"10 分钟内没有创建任何订单"?
对事件缺失的告警较难调,但很有价值——它能抓到错误率告警漏掉的失败(请求成功返回,但副作用没发生)。做法是跟踪一个"业务心跳"指标——成功订单数、成功登录数等——并在速率低于基线时触发。阈值要仔细调,低流量时段容易误报,还要考虑工作日/周末差异。

Q:这套基础设施成本几何?
开源栈(Prometheus + Grafana + 各类 exporter)的边际成本是一个小 VM 用于 Prometheus 服务器加日常维护。商用托管方案(Datadog、New Relic)一般每个主机每月 10–50 美元。3–5 台服务器的小型生产环境,任一方案月成本都在 300 美元以内——远比一次大事故的代价便宜。

结语

监控有一件特别的事:它阻止的那些故障,没有任何人会记得。没有工单、没有复盘、没有"那次 PHP-FPM 在 70% 饱和度时触发告警,值班工程师在任何用户感知之前回滚了一条慢查询"的故事。成功是沉默的,只有失败才会被写进谁的记忆里。

这种"看不见"正是监控长期被投资不足的原因。同样也是它会产生复利的原因:三年前就把这套告警建起来的团队,今天仍在享受三年来无声无息被挡下的每一次事故;而一直推迟的团队,还在用老方法应对同样的事故。

这篇文章里的五条告警并不是什么奇技淫巧,它们是任何一个 PHP 生产环境最低限度的可观测性。尽早建好,最糟糕的事故也会变成"在 70% 饱和时被捕捉到"而不是"在 100% 时才暴露"。所需工程投入只是一次漫长事故耗去的时间里的一小部分。

未来某个凌晨三点的自己,会感谢现在的自己。

速查卡

五条告警,一句话版本:

  • PHP-FPM 进程池饱和——active/total > 70% 持续 2 分钟;抓慢请求堆积、赶在连接全部耗尽之前。
  • 数据库连接利用率——connected/max > 60% 持续 5 分钟;抓连接泄漏或慢查询命中,赶在 "Too many connections" 之前。
  • 队列深度与最旧任务时长——最旧任务年龄大于 5 分钟;抓 worker 饥饿或崩溃,赶在用户可见之前。
  • 错误率对比基线——当前 5 分钟比 1 小时前基线高 3 倍,且绝对值大于 2%;抓"有问题发布"的前几分钟。
  • 外部 API 成功率与时延——单 host 失败率超过 5% 或 p95 时延超过 2 秒持续 3 分钟;抓合作方劣化,赶在级联超时之前。

应降级的滞后指标:

  • 应用服务器 CPU 与内存
  • 脱离速率上下文的 5xx 绝对数
  • 数据库 CPU
  • 磁盘使用率
  • 外部拨测

通用原则:

  • 告警用比率和速率,不用绝对值
  • 拿当前窗口与基线窗口对比,抓退化而非正常波动
  • 每条告警都要配 runbook,没有 runbook 的告警迟早被忽略
  • for: 2m 这类"持续时长"消除单次尖峰噪声
  • 阈值按依赖区分,支付网关永远比日志 webhook 更严

黄金看板布局:

  • 第一行:告警状态灯(绿/黄/红)
  • 第二行:请求速率 + 错误率,叠加发布标记
  • 第三行:FPM 饱和度、数据库连接利用率、各队列深度
  • 第四行:外部 API 各 host 的 p95 时延与成功率
  • 第五行:Top 慢查询与 Top 错误消息(仅作上下文,不作告警)
本作品采用《CC 协议》,转载必须注明作者和本文链接