Bug —— 首次加载页面,向后端发起请求时返回502,再次发起请求时成功

2025年08月26日 Tags: Server


这个问题在第一次搭个人博客的时候就有了,虽然网站刷一下也能用,但是后来看着这个问题越看越不顺眼……

必须解决!安排!

代码环境:前端发送请求 --> nginx --(反向代理)--> 后端(Node),使用 pm2 管理进程。

系统环境:阿里云 ---- Centos 8(cat /etc/os-release

这里先说明一下我解决这个问题的流程:

(1)首先问了一下AI,AI给我的回答是服务冷启动,当我发送请求到后端的时候,后端服务还没有启动起来。将信将疑吧。给的方案没一个能解决的,说的都不太清楚。

当时也查看了nginx错误日志,没看出个所以然。

(2)API 监控

这里用的是一个监控平台——onlineornot(看博主个人网站时发现的),拿来用用。结果是真心不管用,监控不出来什么问题,怎么看都正常……

Description

(3)抓包

因为服务在阿里云上,使用 tcpdump (Linux 默认已安装)抓包,然后将文件拉到本地,用 Wireshark 工具进行分析。

a. 抓包:

sudo tcpdump -i any -s 0 -w load.pcap port 3001
# 参数:
#	-i any 监听所有网络接口(包括本地回环 lo)
#	-s 0 抓取完整数据包(不截断)
#	-w load.pcap 将抓包结果写入文件 load.pcap
#	port 3001 只抓取目标或源为 3001 端口的流量(你的服务端口)

b. 等页面发送请求之后,停止抓包:Ctrl + C

这里也可以使用 curl -X POST http://127.0.0.1:3001/login -H "Content-Type: application/json" -d '{"loginId": "xxx", "loginPwd": "xxx"}' 直接发送请求查看是否能请求到后端。(绕过了nginx,不能排查 nginx 和 后端 之间是否有问题)。请求了,没问题,那就还是 nginx --> 后端 这步出了问题。

ls 查看抓包文件

c. 在本地打开终端,切到 Desktop 目录,使用 scp 拉取文件:

# 使用私钥(需要在阿里云提前生成)
scp -i /path/to/阿里云私钥.pem root@阿里云公网IP:/root/load.pcap .

# 使用密码(enter 后提示用户输入密码)
scp root@seeyoutomorrow.cyou:/root/load.pcap .

d. 桌面双击文件,使用 Wireshark 打开,进行查看。

抓了一次错误请求,抓了一次正确请求做对比。

长得一毛一样,就是没有响应!!!

(4)重新再问 AI

感觉 AI 最近升级了吧,回答更靠谱了一些…… 根据前述请求,查看 nginx 错误日志:

tail -f /var/log/nginx/error.log

终于抓到了一丝痕迹:

Description

到这里大概有了个思路:nginx 反向代理默认使用的是 HTTP/1.0 协议,看看nginx到node请求有没有问题

HTTP/1.0 和 HTTP/1.1 区别

HTTP/1.0 连接模型: (1)短连接:每发送一次请求都要先建立 TCP 连接,在收到响应后立即关闭 TCP 连接。

HTTP/1.1 连接模型:

(1)长连接:发送请求后不会立即关闭 TCP 连接,会保持连接完成多次连续请求。但是在空闲状态也会消耗服务器资源。(设置 ConnectionKeep-Alive

(2)HTTP 流水线:在不等待响应的情况下可以连续发送多个请求。但服务器必须按顺序处理并返回响应,如果第一个请求处理很慢,后面的响应也无法返回(队头阻塞 HOL),因此浏览器不启用该特性。已被 HTTP/2 的多路复用(multiplexing)取代。

引用 MDN 的一个图:

Description

参考:

MDN HTTP/1.x 的连接管理

Nginx 默认使用 HTTP/1.0 协议: 默认不开启 upstream 连接池,每个请求后会关闭到后端的连接(Connection: close)

Description

解决方式:

# 使用 HTTP/1.1
proxy_http_version 1.1;
proxy_set_header Connection "";  # 可选:禁用 keepalive

# 启用重试机制
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 3;
proxy_next_upstream_timeout 2s;

引用 MDN 中关于 HTTP/1.x 中的一句话:‼️重要‼️

要注意的一个重点是 HTTP 的连接管理适用于两个连续节点之间的连接,它是逐跳(Hop-by-hop)标头,而不是端到端(End-to-end)标头。当模型用于从客户端到第一个代理服务器的连接和从代理服务器到目标服务器之间的连接时(或者任意中间代理)效果可能是不一样的。HTTP 协议头受不同连接模型的影响,比如 Connection 和 Keep-Alive,就是逐跳标头标头,它们的值是可以被中间节点修改的。

要理解上述这句话,还需要理解:逐跳标头和端到端表头。

逐跳标头(Hop-by-hop):标头仅对单次传输连接有意义(逐段传输),并且不得由代理重传或者缓存。包括:Keep-AliveTransfer-EncodingTEConnectionTrailerUpgradeProxy-AuthorizationProxy-Authenticate

端到端标头(End-to-end):标头必须被传输到最终的消息接收者。中间代理必须重新转发这些未经修改的标头,并且必须缓存它们。

这两段内容结合着来理解,就是对于 前端 --请求--> nginx --代理转发--> 后端 这个过程中,如果请求/响应中携带了端到端标头,那么前后端是一定会接收到这个标头的。而如果是逐跳标头,只会在请求中的其中一段过程传递,例如前端传给nginx,但是如果要nginx再传给后端,就需要另行配置了。

感觉不管用,给数据库也加了连接池,好像管用了。。。

const mysql = require("mysql2/promise");
const connection = mysql.createPool({
  host: "localhost",
  user: "",
  password: "",
  database: "",
  waitForConnections: true,
  connectionLimit: 10,
  maxIdle: 10,
  idleTimeout: 60000,
  queueLimit: 0,
  enableKeepAlive: true,
  keepAliveInitialDelay: 0,
});

try {
  const [rows, fields] = await connection.query(query); // query 为 sql 查询语句
  return rows;
} catch (err) {
  throw err;
}

添加 warmup 代码:

app.listen(port, () => {
  const startMsg = `[${new Date().toISOString()}] 服务启动,监听端口: ${port}`;
  logger.info(startMsg);
  console.log(startMsg);
  // 服务预热,主动请求一次/article接口
  axios
    .get(`http://localhost:${port}/article`)
    .then(() => {
      logger.info(`[${new Date().toISOString()}] 预热请求 /article 成功`);
      console.log(`[${new Date().toISOString()}] 预热请求 /article 成功`);
    })
    .catch((err) => {
      logger.error(
        `[${new Date().toISOString()}] 预热请求 /article 失败: ${err}`
      );
      console.error(
        `[${new Date().toISOString()}] 预热请求 /article 失败`,
        err
      );
    });
});

今天,又给后端加了日志功能:

// 集成 winston 和 morgan 日志库
const winston = require("winston");
const morgan = require("morgan");
const fs = require("fs");

// 日志
if (!fs.existsSync("logs")) fs.mkdirSync("logs");
// 直接将打印日志生成到文件中
const logger = winston.createLogger({
  transports: [new winston.transports.File({ filename: "logs/app.log" })],
});
const accessStream = fs.createWriteStream("logs/access.log", { flags: "a" });
app.use(morgan("combined", { stream: accessStream }));

// 全局请求日志中间件
app.use((req, res, next) => {
  const logMsg = `[${new Date().toISOString()}] ${req.method} ${req.url}`;
  logger.info(logMsg);
  console.log(logMsg);
  next();
});
← 上一篇: 面试题:500条数据渲染加载卡顿(分页),不能使用懒加载、虚拟滚动,如何解决?

下一篇: → 狂神 —— Docker学习笔记(一)