凌晨两点的告警:499不是「客户端取消」那么简单
先交代背景。我们的服务是Nginx 1.24.0 + PHP-FPM 8.3 + Laravel 11,MySQL 8.0.35,跑在K8s里。某个周四凌晨2点,告警群炸了:Nginx error.log每分钟刷出几百条499,用户端反馈App页面「转圈转不出来」,部分用户直接白屏。
第一反应是看error.log,满屏都是:
2024/03/14 02:13:47 [error] 12345#0: *89102 upstream prematurely closed connection while reading response header from upstream, client: 10.0.3.28, server: api.example.com, upstream: "fastcgi://unix:/var/run/php-fpm.sock", host: "api.example.com"
2024/03/14 02:13:47 [error] 12345#0: *89103 recv() failed (104: Connection reset by peer) while reading response header from upstream, client: 10.0.3.28, server: api.example.com, upstream: "fastcgi://unix:/var/run/php-fpm.sock"
注意这两行日志:错误码是499,但Nginx记录的错误信息是upstream prematurely closed connection。也就是说,不是客户端先断开,而是上游PHP-FPM先关了连接。499这个状态码,很容易让人误以为「是用户等不及取消了」,但真实情况往往不是这样。
当天深夜排查,最后定位到一个接口:POST /api/v1/orders/batch-detail。这个接口从MySQL里查订单详情,单次查询涉及7张表JOIN,数据量300万级。平时QPS只有300左右,但P99响应时间从800ms一路涨到12秒。PHP-FPM进程全部卡在等待MySQL返回,新的请求进不来,Nginx等不到响应,就开始给客户端报499。
这篇文章就围绕这次故障,讲清楚499的完整链路、三种解决方案的对比,以及可落地的配置和代码。
499到底怎么产生的:一条请求的三方时间线
要理解499,先看一次正常的请求长什么样:
- 客户端发起HTTP请求到Nginx
- Nginx通过fastcgi协议把请求转发给PHP-FPM
- PHP-FPM执行代码,查询MySQL,生成响应
- PHP-FPM把响应返回给Nginx
- Nginx把响应返回给客户端
499的发生点就在第5步之前。关键在于:谁先断开了连接。有三种可能:
情况一:客户端先断开(最经典的499)
客户端请求发了,但等不及了,主动关闭了连接。Nginx发现客户端已经走了,就会在日志里记录499。这种情况说明客户端设了超时时间,而且比Nginx的proxy_read_timeout短。比如客户端设了10秒超时,Nginx设了60秒,那上游10秒返回和30秒返回,结果都是499。
情况二:PHP-FPM先断开(我们这次遇到的)
PHP-FPM因为执行超时被杀掉,或者进程崩溃,连接被重置。Nginx还在等响应,结果收到一个EOF或者RST,然后往客户端写数据时发现客户端也没了,就记录499。这次故障就是这么发生的:PHP-FPM的request_terminate_timeout设了30秒,但慢查询把连接池占满,PHP-FPM的accept队列溢出,部分请求直接被拒,Nginx收到的是connection reset。
情况三:网络中间设备断连
K8s里常见。Pod被驱逐、Service后端摘除、负载均衡空闲超时(比如SLB默认60秒空闲断连),都可能导致连接被中间设备掐掉。
所以你看,499不是「上游超时」的专属状态码。它是Nginx的「客户端断开」标记,但断开的原因可能是上游太慢,也可能是上游崩了,也可能是网络问题。排查的时候不能只看status=499,要结合error.log里的错误信息判断。
怎么定位:三步找到真凶
第一步:打开upstream响应时间日志
Nginx默认的日志格式没有上游响应时间,你根本不知道这个499对应的请求在上游花了多久。先改log_format:
http {
log_format upstream_info '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct=$upstream_connect_time '
'uht=$upstream_header_time urt=$upstream_response_time '
'host=$host';
access_log /var/log/nginx/access.log upstream_info;
}
关键字段就三个:rt(总请求时间)、uct(连接上游耗时)、urt(上游处理耗时)。改完配置reload(不用重启):
nginx -t && nginx -s reload
第二步:日志分析,锁定慢接口
把这个日志接入到ES或者用awk直接分析。凌晨故障时我直接用awk拉的:
# 找出响应时间超过5秒的请求,按接口聚合
awk '$NF ~ /^urt=/ {sub("urt=", "", $NF); if ($NF+0 > 5) print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
结果很扎眼:/api/v1/orders/batch-detail占了87%的慢请求,平均urt在8-12秒之间。普通接口的urt都小于200ms。
第三步:追到MySQL慢查询
接口慢,无非是代码慢、MySQL慢、Redis慢。先查MySQL慢查询日志:
# 查看当前慢查询日志状态
mysql -uroot -p -e "SHOW VARIABLES LIKE 'slow_query_log%'; SHOW VARIABLES LIKE 'long_query_time';"
# 临时开启慢查询日志(生产慎用,这里只是应急)
mysql -uroot -p -e "SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 2;"
跑了5分钟,抓到一个执行时间9.8秒的查询,SQL长这样:
SELECT o.id, o.order_no, u.nickname, g.goods_name, g.price, ...
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN order_items oi ON oi.order_id = o.id
LEFT JOIN goods g ON oi.goods_id = g.id
LEFT JOIN order_status_log osl ON osl.order_id = o.id
LEFT JOIN shop s ON o.shop_id = s.id
WHERE o.id IN (... 50个ID ...)
ORDER BY o.created_at DESC;
这SQL看着不复杂,问题出在order_status_log这张表:数据量1200万,order_id字段没有索引,每次JOIN都是全表扫描。EXPLAIN一看,type=ALL,rows=1200万,extra里写着Using where; Using temporary; Using filesort。
到这里根因清晰了:接口并发一上来,SQL跑9秒,PHP-FPM进程全被占住,新的请求排队,Nginx等不到响应,客户端失去耐心,499开始刷屏。
方案对比:三条路,效果天差地别
针对「上游慢导致499」,有几种常见处理方式。我按推荐程度从低到高排:
方案A:调大proxy_read_timeout / fastcgi_read_timeout
这是大部分人第一反应。把超时时间从60秒调到300秒,让Nginx多等一会儿,客户端就不容易收到499了。但仔细想想,这是最偷懒的做法。
副作用很明显:客户端自己的超时时间你控制不了(移动端通常15-20秒),你调到300秒,客户端10秒就关了,还是499。而且调大超时会让PHP-FPM的进程被慢请求占住更久,拖垮整个服务。相当于把故障面扩大了。
我们试过把fastcgi_read_timeout从60秒调到300秒,499数量短暂下降,但PHP-FPM的CPU和内存暴涨,其他接口的响应时间也跟着飙到3秒以上。这个方案只适合「上游就是偶尔慢一次,但确实需要长时间处理」的场景,不适合我们这种慢SQL问题。
方案B:优化SQL + 加索引(治本)
这次故障的根因就是order_status_log缺索引。给order_id加上普通索引,把JOIN的顺序调整一下,这条SQL从9.8秒降到80ms。这是真正的解法。
代价是:需要DBA或后端开发去分析慢查询,改代码,发布上线。整个流程走完大概需要几小时。在凌晨2点的故障现场,等不了那么久。
方案C:限流 + 熔断 + 异步化改造(架构级)
对于「批量查询」这种重接口,最彻底的做法是异步化。把请求拆成两步:第一步提交任务,返回任务ID;第二步轮询结果。再加上Nginx层的限流,防止慢请求把PHP-FPM打满。
这个方案实施周期最长,但对整个系统的稳定性提升是质的。
代码实现:一套可落地的组合拳
理想状态是三管齐下:先用方案A缓解(虽然不推荐,但紧急时刻能救火),然后立刻做方案B(加索引),最后在代码层做方案C(异步化改造)。下面给出每一步可运行的代码。
5.1 Nginx调优配置
先看紧急处理时怎么改Nginx。以下配置解决了我们「PHP-FPM被慢请求占满」的问题:
# /etc/nginx/conf.d/upstream.conf
# Nginx 1.24.0 需要这个配置来限制每个上游的并发请求数
upstream php_backend {
server unix:/var/run/php-fpm.sock;
keepalive 32; # 复用连接,减少TCP握手
keepalive_requests 1000; # 单连接最多处理1000个请求
keepalive_timeout 60s;
}
# /etc/nginx/conf.d/api.example.com.conf
server {
listen 80;
server_name api.example.com;
# 限制单IP并发连接数,防刷也防雪崩
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn perip 20;
# 请求频率限制:每秒5个,突发10个
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=5r/s;
limit_req zone=api_limit burst=10 nodelay;
location ~ \.php$ {
fastcgi_pass php_backend;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 关键超时参数:本地开发可以调大,生产必须严格控制
fastcgi_connect_timeout 3s; # 连接PHP-FPM超时
fastcgi_send_timeout 10s; # 发送请求到PHP-FPM超时
fastcgi_read_timeout 30s; # 等PHP-FPM返回数据超时
# 上游返回504/502时,不要自动重试(避免重复提交订单)
fastcgi_next_upstream off;
}
}
这里有个细节:fastcgi_next_upstream off很重要。开着重试的话,PHP-FPM已经执行了SQL但响应丢了,Nginx会重试一次,导致SQL执行两次。对于写接口这是灾难。
5.2 PHP-FPM调优
PHP-FPM也需要配合调整。我们当时的配置是这样的:
; /etc/php/8.3/fpm/pool.d/www.conf
; PHP 8.3.0 + php-fpm
; 动态进程管理
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
; 核心:每个请求最大执行时间,超过就被杀掉
request_terminate_timeout = 30s
; 慢请求日志,帮你定位哪个接口慢
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 2s
关键参数是request_terminate_timeout。这个值要小于Nginx的fastcgi_read_timeout,否则Nginx还没超时,PHP-FPM先杀进程,会触发「上游提前关闭连接」的499。
5.3 慢查询自动告警脚本
故障结束后,我们写了一个脚本,每5分钟扫一次MySQL慢查询日志,发现有超过3秒的SQL就报警。这样可以提前发现问题,不用等499刷屏才反应:
#!/bin/bash
# /usr/local/bin/slow_query_alert.sh
# 依赖:MySQL 8.0.35 + cron
SLOW_LOG="/var/lib/mysql/slow.log"
MAIL_TO="backend@example.com"
THRESHOLD=3
# 检查慢查询日志文件是否存在
if [ ! -f "$SLOW_LOG" ]; then
echo "慢查询日志文件不存在: $SLOW_LOG"
exit 1
fi
# 查找最近5分钟内执行时间超过阈值的SQL
RECENT_SLOW=$(awk -v threshold="$THRESHOLD" '
/^# Query_time:/ {
query_time=$3
if (query_time+0 > threshold+0) {
getline
getline
sql_line=$0
print "执行时间: " query_time "s | SQL: " sql_line
}
}' "$SLOW_LOG" | tail -20)
if [ -n "$RECENT_SLOW" ]; then
echo "$RECENT_SLOW" | mail -s "[ALERT] MySQL慢查询告警 $(date +%F_%T)" "$MAIL_TO"
# 也可以发到钉钉/企微,用webhook
fi
这个脚本虽然简单,但能救命。加了之后,我们平均每两周能提前发现一个慢查询,都是几百毫秒的,还没到影响用户的程度就处理了。
5.4 接口级缓存:降低慢SQL的触发频率
优化SQL之后,再把热点数据缓存到Redis。下面是Laravel里的一个简单实现:
// PHP 8.3 + Laravel 11
// app/Http/Controllers/OrderController.php
public function batchDetail(Request $request)
{
$orderIds = $request->input('order_ids', []);
if (empty($orderIds)) {
return response()->json(['code' => 400, 'msg' => 'order_ids不能为空']);
}
// Redis缓存,key按订单ID集合生成
sort($orderIds);
$cacheKey = 'order:batch:' . md5(implode(',', $orderIds));
// 从Redis读
$cached = Redis::get($cacheKey);
if ($cached) {
return response()->json(json_decode($cached, true));
}
// 从MySQL查(优化后的SQL)
$orders = Order::with(['user:id,nickname', 'items.goods:id,goods_name,price', 'shop:id,name'])
->whereIn('id', $orderIds)
->get();
$data = ['code' => 0, 'data' => $orders];
// 写入Redis,过期时间600秒
Redis::setex($cacheKey, 600, json_encode($data));
return response()->json($data);
}
5.5 异步化改造思路
对于重接口,同步阻塞的方式本质上是把MySQL的压力转移到PHP-FPM上。异步化的核心思路:
// 1. 接收请求,生成任务ID
// 2. 把任务ID和参数放入消息队列(如Redis List或RabbitMQ)
// 3. 立即返回「任务已接收」
public function submitBatchDetail(Request $request)
{
$taskId = Str::uuid()->toString();
$orderIds = $request->input('order_ids');
// 推入队列
Redis::rpush('queue:batch_detail', json_encode([
'task_id' => $taskId,
'order_ids' => $orderIds,
'created_at' => time(),
]));
return response()->json(['code' => 0, 'data' => ['task_id' => $taskId]]);
}
// 4. 客户端拿到task_id后轮询结果接口
public function getBatchResult(Request $request)
{
$taskId = $request->input('task_id');
$result = Redis::get("task_result:$taskId");
if (!$result) {
return response()->json(['code' => 0, 'data' => ['status' => 'pending']]);
}
return response()->json(['code' => 0, 'data' => ['status' => 'done', 'result' => json_decode($result)]]);
}
// 5. 后台Worker处理队列中的任务
// worker.php 可以用命令行跑:php artisan queue:work
效果数据:优化前后对比
我们的处理分三步:凌晨紧急加索引 → 第二天做Nginx/PHP-FPM参数调优 → 两周后完成接口缓存和限流。每一步都有实测数据。
压测环境说明
- 测试工具:wrk 4.2.0(
wrk -t4 -c100 -d60s) - 接口:
POST /api/v1/orders/batch-detail,请求体包含50个订单ID - 压测机与服务器同一内网,千兆带宽
- 服务器:8C16G,Nginx 1.24.0,PHP-FPM 8.3,MySQL 8.0.35
第一步:给order_status_log表加索引
ALTER TABLE order_status_log ADD INDEX idx_order_id (order_id);
加索引后单条SQL执行时间对比:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| SQL执行时间 | 9.8秒 | 83ms | 99.2% |
| EXPLAIN type | ALL(全表扫描) | ref(索引查找) | — |
| 扫描行数 | 12,000,000 | 5,400 | 99.95% |
这个立竿见影。接口P99从12秒降到220ms。
第二步:Nginx超时参数调优
把fastcgi_read_timeout从60秒调到30秒,加上限流,压测数据:
| 指标 | 调优前 | 调优后 |
|---|---|---|
| QPS(峰值) | 180 | 320 |
| P95响应时间 | 8.2秒 | 180ms |
| P99响应时间 | 12秒 | 220ms |
| 499错误率 | 23.5% | 0.02% |
| 502/504错误率 | 0.8% | 0% |
| PHP-FPM进程占用率 | 100%(持续满) | 40% |
第三步:Redis缓存命中率
接口加了Redis缓存后,从压测结果看:
| 指标 | 无缓存 | Redis缓存(命中率65%) |
|---|---|---|
| 接口平均响应时间 | 180ms | 50ms |
| MySQL QPS | 250 | 88 |
| PHP-FPM CPU使用率 | 65% | 30% |
最终效果
优化全部落地后,同一压测场景(4线程100连接持续60秒)下:
# 优化前
wrk -t4 -c100 -d60s -s post.lua http://api.example.com/api/v1/orders/batch-detail
Running 1m test @ http://api.example.com/api/v1/orders/batch-detail
4 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 7.82s 3.25s 12.01s 75.00%
Req/Sec 45.37 18.21 78.00 60.00%
Latency Distribution
50% 7.51s
75% 9.83s
90% 11.20s
99% 12.00s
10828 requests in 1.00m, 2.51MB read
Socket errors: connect 0, read 0, write 0, timeout 1024
Requests/sec: 180.47
# 优化后
wrk -t4 -c100 -d60s -s post.lua http://api.example.com/api/v1/orders/batch-detail
Running 1m test @ http://api.example.com/api/v1/orders/batch-detail
4 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 104.22ms 121.23ms 982.45ms 88.75%
Req/Sec 320.13 40.56 410.00 70.00%
Latency Distribution
50% 50.15ms
75% 120.20ms
90% 240.28ms
99% 510.94ms
19208 requests in 1.00m, 4.46MB read
Requests/sec: 320.13
QPS提升77%,P99从12秒降到510ms,499几乎消失。
避坑指南:这五个坑,我全踩过
写这篇的时候我翻了下自己的排障记录,在499和上游超时这件事上,踩过的坑不少,挑最典型的五个说。
坑1:把499当成「客户端主动取消」,不当回事
早期我们日志里见到499,第一反应是「用户等不及退出了,正常现象」。直到某次499伴随大量用户投诉,才发现是上游全挂了。现在我们的监控规则是:499占比超过1%就报警,不管什么原因先看上游是不是还活着。
坑2:开了upstream重试,导致订单重复创建
有次我们给Nginx配了proxy_next_upstream error timeout,本意是提升可用性。结果上游PHP-FPM处理完请求后,在返回响应那一刻连接断开(比如K8s滚动发布),Nginx以为请求没处理,又重试了一次。对写接口来说,这是致命的。后来所有写接口全部关掉重试:
location ~ \.php$ {
# ... 其他配置
fastcgi_next_upstream off;
}
读接口可以开,写接口必须关。这个教训来自一次真实的生产事故:用户重复下单,财务对账花了三天。
坑3:日志格式没带upstream_response_time,事后无从查起
第一次遇到499刷屏时,我们用的还是Nginx默认日志格式,只有status、request_time、upstream_addr。结果是499的请求分布在全天的日志里,无法判断到底是哪个接口、哪个上游导致的。后来把所有环境都换成了带upstream_response_time的自定义格式。
坑4:调大fastcgi_read_timeout掩盖问题
这个前面说过了。调大超时时间在短期能让499数量下降,但它把问题从「Nginx报错」变成了「PHP-FPM被占满」。我们调大到300秒的当晚,PHP-FPM的max_children从50被吃到满,其他正常接口响应从200ms变成3秒,整体事故影响面反而扩大了。正确的思路是先限流保护PHP-FPM,再去优化慢查询。
坑5:K8s里压测数据失真
如果你在K8s里做压测,注意Service的负载均衡策略和Pod的优雅终止。我们刚开始压测时数据忽高忽低,后来发现是Pod的terminationGracePeriodSeconds设的太短,压测过程中Pod被滚动更新杀掉了,导致大量连接被重置。推荐压测前先确认:
# k8s deployment.yaml 关键配置
spec:
template:
spec:
terminationGracePeriodSeconds: 60 # 给优雅终止留足时间
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
坑6(额外):PHP-FPM的request_terminate_timeout别设太大
这个参数是杀掉执行时间过长的PHP进程,防止「雪崩」的关键。我们曾把它设为0(不限制),结果一个死循环接口把整个FPM池拖垮。现在统一设30秒,并且配合request_slowlog_timeout把慢请求都记录下来,每周复盘一次。
总结:499排查的思维框架
以后再见到499,按这个顺序排查:
- 看error.log里的错误信息:
upstream prematurely closed connection说明上游先断,recv() failed (104)说明连接被重置,timeout说明真的超时了 - 看access.log里对应的上游响应时间:
urt字段,大于1秒就要警惕 - 查PHP-FPM和MySQL的慢日志:找到执行时间最长的SQL或接口
- 先限流止血,再优化慢查询:不要一上来就调大超时时间
- 优化完之后,监控499占比:稳定在0.1%以下才算处理完
499不是「客户端取消」那么简单,它就是你的系统在上游某个环节变慢的报警器。把它当成一个普通的状态码,你会错过很多故障信号。