五条监控告警,能覆盖大多数 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 配置里开启:
; /etc/php/8.3/fpm/pool.d/www.conf
pm.status_path = /status
ping.path = /ping再通过 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 部署在一起即可:
# 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_processes、phpfpm_total_processes、phpfpm_listen_queue、phpfpm_max_children_reached 等。
告警表达式:
# 70% 工作进程忙碌,持续 2 分钟触发
(phpfpm_active_processes / phpfpm_total_processes) > 0.7
# ... for 2mAlertmanager 格式:
- 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 配置中开启 slowlog 与 request_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 的底层值来自状态变量:
-- 当前连接数
SHOW GLOBAL STATUS LIKE 'Threads_connected';
-- 自服务器启动以来的历史峰值
SHOW GLOBAL STATUS LIKE 'Max_used_connections';
-- 配置上限
SHOW GLOBAL VARIABLES LIKE 'max_connections';Postgres:
-- 当前连接数
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告警表达式:
- 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 后端队列可以直接查:
# 队列深度
redis-cli LLEN queues:default
# 延迟队列(按时间戳排序的有序集合)
redis-cli ZCARD queues:default:delayed
# 最旧任务的入队时间(探查不出队)
redis-cli LINDEX queues:default 0 | jq '.pushedAt'数据库后端队列(jobs 表):
-- 深度
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 的最小实现:
// 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 和管理员访问,别公开暴露。
告警表达式:
- 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 分钟延迟的通知队列,和要求秒级一致的库存同步队列不可同日而语:
- 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 的这类指标通常从访问日志或插桩中间件获取。下面是一个简化版本的中间件:
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=...} 这样的计数器。
告警表达式:
- 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 推送:
# 发布成功后调用监控系统
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 挂一个中间件:
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 客户端上:
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
$stack = HandlerStack::create();
$stack->push(app(PrometheusHttpMiddleware::class));
$client = new Client(['handler' => $stack, 'timeout' => 10]);每一次对外请求都带上了 host 和状态码标签,可以清楚看到每个依赖的成功率和速度。
告警表达式:
- 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 应比营销邮件服务苛刻得多:
- 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 秒级别的分诊,就能把问题归到某一类:
告警名告诉他触发的是哪一类。打开黄金看板,依次看五行信号:
- FPM 是否饱和? 大概率是慢请求模式——查慢日志和 Top 接口。
- 数据库连接是否高? 跑
SHOW PROCESSLIST,找被反复命中的慢查询。 - 队列是否在堆积? 看 worker 状态,
strace附到卡死的 worker。 - 错误率是否突增? 看发布标记,若高度对齐某次发布,立即回滚。
- 外部 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 错误消息(仅作上下文,不作告警)