打开列表没问题,导出年度报表等了很久却返回504。有人把Nginx超时改成十分钟,错误暂时消失,随后发现更多用户开始排队。
504表示网关没能及时获得所需上游响应。先找时间耗在哪个阶段,再决定优化查询、限制任务规模或调整超时。延长等待不会让数据库自动变快,也不能保证已经超时的写操作没有执行。
本文以Nginx、Spring Boot和MySQL为例,围绕"年度导出约30秒后失败"的场景,从耗时日志走到任务化改造与回归验收。所有耗时数字都是诊断示例,代码为局部片段,阈值应按业务目标和实际容量制定,不代表已经在你的系统完成压力测试。
目录
- 先区分502与504
- 在入口日志里分开记录耗时
- 慢SQL先看执行计划
- 超时配置不是任务总时长开关
- 大报表适合改成任务
- 确定性能验收的范围
- 案例拆解:报表导出为什么总在30秒左右失败
- 异步导出最小设计:不仅是加一个线程
- 用同一组样本验收,避免"优化后感觉快了"
一、先区分502与504
502强调上游响应无效;504强调等待超时。连接阶段超时也可能返回504,所以不能把504简化成"已经成功连上数据库但SQL慢"。

图1:502侧重无效上游响应,504侧重等待超时,具体超时阶段仍需通过代理日志确定。
记录Nginx错误日志是在连接、发送还是读取上游时超时。同时查看请求是否经过CDN、负载均衡和另一个网关,它们可能拥有更短的等待限制。
二、在入口日志里分开记录耗时
下面的日志格式定义放在http上下文,再由站点引用:
nginx
log_format timing '$request_id $request_method $uri status=$status '
'upstream=$upstream_status rt=$request_time '
'connect=$upstream_connect_time '
'header=$upstream_header_time '
'response=$upstream_response_time';
它不记录查询参数、Cookie和Authorization。应用若要关联请求ID,需要由可信入口传递并接入应用日志。发生重试时,上游变量可能包含多个值,不能当作单个数字分析。

图2:分别观察连接、等待响应头和接收响应的耗时,才能区分连接慢与后台处理慢。
连接耗时高时先查上游可达性和连接队列;连接快但响应头迟迟不来时,查应用执行、依赖等待和线程排队。总响应耗时大但持续返回数据,又与完全没有数据返回的情况不同。
三、慢SQL先看执行计划
sql
EXPLAIN
SELECT id, name, created_at
FROM project
WHERE company_id = 1001
AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 50;
这是普通EXPLAIN,不执行结果查询;表与字段须按项目替换。先确认是否扫描过多行、排序方式和已有索引。不能只凭一个查询片段就决定新增索引,还要看数据分布、写入成本和其他SQL。
EXPLAIN ANALYZE会执行语句,不应对未知大查询直接在生产尝试。慢查询记录也要结合采样窗口和数据脱敏,避免把业务参数直接公开。

图3:SQL执行、连接池排队、锁等待和外部调用需要分开分析,接口慢不一定是SQL本身慢。
连接池等待、数据库锁等待和SQL执行时间要分开。有时SQL本身很快,但线程排队拿不到连接;扩大超时只会让队伍更长。
四、超时配置不是任务总时长开关
示例片段放在目标代理location中:
nginx
proxy_connect_timeout 3s;
proxy_send_timeout 15s;
proxy_read_timeout 30s;
以上是说明用途的样例值。proxy_read_timeout限制相邻两次读取上游数据之间的等待,并不是整个请求的总时长。应用HTTP客户端、数据库查询和连接池也有各自的超时,应该结合整体响应预算设计。
若网关30秒返回504,后台任务却继续执行两分钟,客户端立刻重试可能造成重复处理。应核对取消传播、幂等机制与结果查询能力,而不是把错误页面当成事务回滚证据。
五、大报表适合改成任务

图4:长任务将提交、可靠受理、后台执行和结果查询分开,返回任务标识不代表任务已经完成。
较大的导出可以采用"提交任务---返回任务ID---查询进度---下载结果"的方式。接口可以返回202与任务标识,但必须有实际持久化任务和后续查询机制支撑。
任务至少记录提交人、参数摘要、状态、进度、失败原因和结果有效期。下载仍要验证用户权限;任务列表和结果链接不能仅凭ID访问。重复提交还需明确复用还是新建,避免异步队列继续堆积。
六、确定性能验收的范围

图5:用实际数据规模和目标并发验证优化效果,同时核对超时后的写入结果与重试保护。
| 检查项 | 合格证据 |
|---|---|
| 耗时阶段 | 确认连接、排队或执行瓶颈 |
| 真实规模 | 按目标数据量验证,非空表测试 |
| 并发 | 正常峰值下延迟与错误率可接受 |
| 超时后果 | 写操作结果可以核对 |
| 任务模式 | 状态、失败和下载权限完整 |
记录优化前后的样本规模、并发、P95延迟和错误率,才能判断变化是否有效。没有做容量测试时,就说明是配置审查与小样本验证,不把它描述成压力测试结论。
下面以导出为例,把入口超时、后台执行和用户取得结果三个时间点分开检查。
七、案例拆解:报表导出为什么总在30秒左右失败
设想一个教学场景:项目列表很快,年度导出却在约30秒后504,后台过了一会儿还打印"导出完成"。这意味着用户等待结束与后台任务结束可能不是同一时刻。
先记录失败发生在哪层入口。如果前方CDN先超时,单独调整源站Nginx并不能让浏览器多等一分钟。时间"恰好30秒"是查配置的线索,不是数据库慢的证明。
1. 让耗时日志真正用于该接口
前文的log_format仅声明格式,还需在实际处理路径中启用:
nginx
# 合并至已有server;示例假设后台保留/api前缀
location /api/ {
access_log /var/log/nginx/api_timing.log timing;
proxy_set_header X-Request-ID $request_id;
proxy_pass http://127.0.0.1:8080;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
}
这些数值只是教学边界,不是通用生产建议。应用日志需接入同一请求ID;否则入口有ID、后台没有,仍无法可靠关联并发请求。
示意记录:
text
rid=demo-export status=504 connect=0.002 header=- response=30.004
error: upstream timed out (...) while reading response header from upstream
示意字段名可按前文格式增加rid,实际输出格式与平台版本有关。这里最关键的证据是"连接很快,但等待响应头超时",所以继续看应用排队、生成报表和依赖调用。
header=-不是"耗时为零"。它表示没有可用的该项时间,不能按0加入平均值。多个上游尝试也可能产生多组值,分析时必须保留尝试顺序。
2. 在应用里拆分导出耗时
至少区分以下几个阶段,而不是只记录方法总耗时:
| 阶段 | 要测量的内容 | 常见误判 |
|---|---|---|
| 获取数据库连接 | 等待连接的时间 | 误认为SQL执行慢 |
| 查询并取回数据 | 执行、网络和结果读取 | 只看SQL服务端执行时间 |
| 组装与写文件 | 内存处理、压缩、磁盘写入 | 忽略文件生成成本 |
| 上传结果 | 对象存储或远端调用 | 把外部等待算作数据库慢 |
| 返回响应 | 是否已提交响应头或断开 | 误以为后台完成等于用户收到 |
下面是Java 17中的局部计时片段,放入原有导出方法,已有对象和方法按工程替换:
java
long started = System.nanoTime();
try {
return exportService.export(query);
} finally {
long elapsedMs =
java.util.concurrent.TimeUnit.NANOSECONDS
.toMillis(System.nanoTime() - started);
log.info("export finished requestId={} elapsedMs={}",
requestId, elapsedMs);
}
它只测同步调用结束时间;如果export内部只是把任务交给线程池,测到的是提交耗时,不是实际任务耗时。细分阶段应在真正执行的位置计时,并避免记录报表明细和敏感查询参数。
3. 根据耗时选择修复,而不是统一调大超时
如果SQL扫描范围过大,结合前文EXPLAIN检查条件与已有索引;如果获取连接就等待很久,检查长事务和连接释放;如果数据库已经返回而生成文件耗时很长,应控制数据规模或改任务模式。
在隔离测试环境,可用代表性数据对比两个实现,但不要把空表或几十条数据的结果当成大报表性能结论。索引变化还会影响写入、存储与其他查询,需要一起评估。
八、异步导出最小设计:不仅是加一个线程
直接new Thread启动任务后返回"成功",会引入新的问题:进程重启任务丢失、异常不可见、重复提交无限排队,以及用户无法确认结果。
一个最小任务模型可以包含:
| 字段 | 用途 |
|---|---|
| task_id | 查询任务的稳定标识 |
| tenant_id、created_by | 数据隔离和归属 |
| idempotency_key | 同一提交的去重依据 |
| request_hash | 检查同一幂等键是否对应相同参数 |
| status | PENDING、RUNNING、SUCCEEDED、FAILED |
| result_key、expires_at | 结果位置与保留期限 |
| error_code | 脱敏的失败分类 |
| created_at、updated_at | 排查与超时恢复 |
幂等键唯一约束的作用域应包含业务归属,例如租户和提交人;同一键传入不同参数应明确拒绝,不能悄悄返回另一份报表。参数本身还要有数量和时间范围限制。
接口可以这样约定:
text
POST /api/export-tasks
-> 202 Accepted
-> Location: /api/export-tasks/{taskId}
-> 返回任务ID和PENDING状态
GET /api/export-tasks/{taskId}
-> 返回当前任务状态,校验租户和提交人权限
GET /api/export-tasks/{taskId}/file
-> 任务成功且未过期才允许下载,仍然校验权限
202只表示已经受理,不能代替数据库中的任务记录。任务表加消息队列时,还要处理"任务落库成功但消息发送失败"的一致性问题,可以评估事务事件记录或可靠扫描投递机制。
后台工作者应通过原子认领避免重复执行,配合租约、超时回收或明确失败策略。对于可能重复执行的任务,结果保存也要有幂等规则。以上是设计边界,不是一张任务表就自动具备的能力。
九、用同一组样本验收,避免"优化后感觉快了"
不要只比较两次手动点击。固定数据范围、并发数和缓存条件,分别记录:
- 普通查询的P95延迟和错误率;
- 导出受理接口的响应时间;
- 任务排队时间、实际执行时间和成功率;
- 文件条数、权限、下载有效期与内容完整性;
- 工作进程重启后任务如何恢复。
可以先把以下表格填实,再决定是否发布:
| 用例 | 检查结果 |
|---|---|
| 原来超时的数据范围 | 能完成;结果正确 |
| 多人同时提交 | 队列有界;普通业务没有被拖垮 |
| 重复点击提交 | 按设计复用或拒绝,不无限新增任务 |
| 工作进程中断 | 任务可恢复或明确失败,不永久RUNNING |
| 越权查询任务ID | 无法看到或下载他人结果 |
| 文件已过期 | 明确提示,不返回失效或错误文件 |
504修复后的目标不是"所有请求永远不超时",而是合理范围内及时完成,超限任务有明确处理路径,失败之后结果可确认。