Nginx 499排查:上游慢还是客户端先走
发布日期: 2026/08/07 阅读总量: 0

凌晨两点的告警: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,先看一次正常的请求长什么样:

  1. 客户端发起HTTP请求到Nginx
  2. Nginx通过fastcgi协议把请求转发给PHP-FPM
  3. PHP-FPM执行代码,查询MySQL,生成响应
  4. PHP-FPM把响应返回给Nginx
  5. 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秒83ms99.2%
EXPLAIN typeALL(全表扫描)ref(索引查找)
扫描行数12,000,0005,40099.95%

这个立竿见影。接口P99从12秒降到220ms。

第二步:Nginx超时参数调优

fastcgi_read_timeout从60秒调到30秒,加上限流,压测数据:

指标调优前调优后
QPS(峰值)180320
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%)
接口平均响应时间180ms50ms
MySQL QPS25088
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,按这个顺序排查:

  1. 看error.log里的错误信息upstream prematurely closed connection说明上游先断,recv() failed (104)说明连接被重置,timeout说明真的超时了
  2. 看access.log里对应的上游响应时间urt字段,大于1秒就要警惕
  3. 查PHP-FPM和MySQL的慢日志:找到执行时间最长的SQL或接口
  4. 先限流止血,再优化慢查询:不要一上来就调大超时时间
  5. 优化完之后,监控499占比:稳定在0.1%以下才算处理完

499不是「客户端取消」那么简单,它就是你的系统在上游某个环节变慢的报警器。把它当成一个普通的状态码,你会错过很多故障信号。