〇、【引子】

由于老婆经常拿着手机问我,“这件衣服好看吗” “这件衣服我能穿吗”…… 所以我萌生出了写一个 AI 换衣工具的想法。

虽然豆包已经有这个功能了,但是操作起来比较麻烦,每次还得输入提示词。为了最大程度减少老婆大人操作的复杂度,我决定还是自己写一个,让她可以:

  1. 上传自己的照片
  2. 上传衣服/模特图片
  3. 点击按钮生成图片

一、【开发】

借助 ChatGPT 和千问,差不多半天就写完了所有的代码(主要是调样式耗费时间)。图像大模型用的是:字节 Seedream4.5

本地运行的界面如下:

本地运行界面

为了方便用户测试,我在界面上放了几张预置图片,先把图片上传到腾讯云对象存储,然后把链接塞到 html 里。


踩坑:图片跨域

本地运行,点击示例图片,点击生成,卧槽,明明已经选择了图片,怎么还提示让我“上传两张图片”?

打开 console 一看,从 url 下载图片的时候跨域了……

原因是点击示例图片的时候,会下载图片并转换成 blob 以备后续请求,这一步被拦住了,图片并没有被加载到 post body 里,所以点击 “生成” 的时候,会提示 “请上传两张图片”


不对啊,既然跨域了,那为什么界面上还能显示出图片,它应该是裂掉的才对呀


为什么 HTML <img> 可以加载,而 JS 不能呢? ChatGPT 告诉我:

  1. HTML <img src="..."> 是浏览器直接渲染图片,不涉及读取图片内容到 JavaScript,所以不会被浏览器的同源策略阻止。
  2. 但如果在 js 里使用 fetch(url) 读取(下载)图片内容时,浏览器就会检测跨域请求,此时如果服务器没有返回 Access-Control-Allow-Origin,就会报错。

出于安全考虑,我不打算放宽限制,(在 cdn 上把图片的访问策略 access-control-allow-origin 设置为 *),所以这个问题在本地是无法解决的。

我在本地只能验证 上传图片 + 生成图片 的 case,无法验证 点击选用示例图片 + 生成图片 的 case。

倒也不影响,只要部署到服务器上能跑就行。


二、【部署】

代码部署完之后,配置 nginx:

1
2
3
4
5
6
7
8
9
10
location ^~ /tools/cloth_changer/ {

proxy_pass http://127.0.0.1:xxxxx;

send_timeout 200s;
proxy_connect_timeout 200s;
proxy_send_timeout 200s;
proxy_read_timeout 200s;

}

字节的图片生成接口耗时在 15-50 秒左右,所以我把各种超时时间都拉大了许多。

三、【异象】

部署完成,打开页面,选择示例图片,点击生成~

报错提示 err_connection_reset

我蒙了,怎么 20 秒就被 reset 了,连接还一直处于 initial connection 阶段?


Chrome 官方对 initial connection 的说明


查了下日志,发现:后台即时收到了请求,并且完整的执行完了,所以可以排除 request body 异常(或过大),导致传输失败,连接被掐断

而后台执行的整体耗时大约在 40~50 秒左右,还没有返回任何数据,请求就被被掐断了,所以可以排除是 response body 异常(或过大),导致传输失败,连接被掐断


这么看来,大概率还是中间 nginx 等待超时导致请求被掐断了。可我明明把 nginx 的超时时间设的很长呀,一水的 200s,怎么可能 20s 就被掐了呢。

为了验证这一点,我把端口直接暴露出来,从公网通过 ip + port 访问,这下果然正常了。

直连正常,过 nginx 会 reset,那基本可以确定问题出在 nginx 了。


四、【迷雾重重】


1. 检查 nginx 配置

于是我又细细捋了一遍 nginx 配置,看起来都没啥问题,甚至整个配置文件也搜不出来跟 20 相关的数字。

在 ChatGPT 的建议之下,我又分多次往 nginx 配置文件里塞了许多东西。

但不管怎么改,接口仍然是 20 秒超时。


nginx 的 error.log 里啥都没有,access.log 里倒是明明白白写着请求是 200

无论是浏览器、还是 curl 命令,后台实际上都成功了

看来这条路暂时是走不通了。


2. 真正的超时时间

在和 ChatGPT 进行了多轮博弈之后,它提示我在网站上 curl 的时候加上 time,看一看耗时到底是多少。我一试,立刻发现了端倪!

curl 的时候,超时时间是 10 秒!!!

浏览器访问的时候,超时时间是 20 秒!!!

再结合我之前查日志的时候,发现了、却没有重视的一个现象:我在浏览器发起一个请求的时候,后端会相差 10s 收到两条请求,两条请求最终都执行完了。

我突然反应过来:真正的超时时间应该是 10 秒,而不是 20 秒!

很可能是 nginx 检测到后端服务 10s 没有了响应,重发了请求


于是,我又开始了反复修改 nginx 配置的地狱轮回。

但问题始终没有解决。


3. 超时的根因

万般无奈之下,我在百度上输入了:腾讯云 超时 10秒。谁知道居然真让我找到了答案:

我幡然醒悟,网站整个都藏在 cdn 后面,而cdn 超时 10 秒,所以不论怎么改大 nginx 的配置都没用,重发 & 超时跟 nginx 没有半毛钱关系!!!


我打开腾讯云控制台,果然找到了这个选项:

CDN 回源超时的说明

在把超时时间设为 60 秒之后,服务就正常了,没有超时、没有重发!


五、【最后的问题】

到这里,看似问题已经完美解决了,但其实还有一个疑问悬而未决:

为什么直接 curl,后台只会收到一个请求(不管加不加 --retry )。通过浏览器访问时,后台大概率收到 2 个请求,小概率收到 3 个请求,极小概率收到 1 个请求。


答案显而易见:是浏览器做了重发

经过检索以及询问 ChatGPT ,我发现网络上、包括 Chrome 官方都没有对浏览器重发行为的明确描述,只有一些零零散散的讨论,也都没法解释我的现象。


既然求人不行,那就求己!

wireshark,启动!


1. Wireshark 抓包

由于服务在 cdn 后面,所以 ip 一直会变,为了方便根据 ip 抓包,我首先在 hosts 文件中强制指定了 dns 结果:

1
111.26.166.62  mofish.zone

对于重试一次的场景(20秒超时),抓包如下:

wireshark 抓包:请求重试一次的场景

我们可以看到:

  1. 浏览器发起首次请求,传输了所有的数据(后端已经完整的收到了请求,并且开始执行了)
  2. cdn 随即发送了 fin-ack 挥手,这时候浏览器只是 ack 了,并没有继续挥手。
  3. cdn 等待 10 秒后超时,发送 rst
  4. 浏览器向 cdn 发送 fin-ack,挥完剩下的手。
  5. 再之后,浏览器重新发起三次握手,并且在这个连接上,重发了一遍请求
  6. cdn 又等待了 10 秒,发送 rst

这个重发的请求,在浏览器 F12 的 DevTools 里是看不到的,所以最开始我误以为超时时间是 “20秒” 。


对于未重试的场景(10秒超时),抓包如下:

wireshark 抓包:未重试

没有挥手,达到 10 秒时,cdn 直接发送了 rst


这个问题跟到这里,我已经快被 ChatGPT 的长篇大论冲昏了头脑,把现象简单总结为:cdn 的不确定行为(有时候会主动挥手(fin)再 rst,有时候直接 rst),导致浏览器时而重发,时而不重发。


为了真正的石锤,我打算自己写一个 server,先挥手,再发送 rst,看看浏览器会不会重发请求。

在 ChatGPT 的忽悠下,先后用 python、c 写了一整天的 server,都没有达到想要的效果。

无奈,硬着头皮又回来看抓包了。


2. 再看抓包

这一次,一个被我忽略的问题又重新进入了我的视线,在这张重试一次的图里,最开头并没有 3 次握手,而是直接开始发送数据

wireshark 抓包:请求重试一次的场景

为了抓到纯净的数据包,我总是先 F5 刷新网页,选择两张示例图片,然后再运行 wireshark,然后再点击 “生成” 按钮

理论上来讲,此时应该会有三次握手的数据包在最开头,那为什么没有呢?!

TCP 连接重用!!!

我浑身一个机灵,卧槽,真相应该就在眼前了!


一定是浏览器重用了我打开页面、加载 css、js 时候的连接,所以我点击 “生成” 按钮的时候,浏览器没有再去三次握手,而是直接传输数据。


3. 大胆假设

再看一看没有重试的场景,开头就是三次握手:

wireshark 抓包:未重试


此时,一个大胆的猜测出现在我的脑海中:

在一个复用的 TCP 连接上,如果浏览器收到了 rst,就会重试请求。

在一个新建的 TCP 连接上,如果浏览器收到了 rst,就会直接终止。

这个解释,可比上面 “cdn 随机发癫” 令人信服的多!也合理的多!


4. 小心求证(一)

既然可能是 TCP 复用,那么 TCP keepalive 的时间是多少呢?

ChatGPT 告诉我:

主流浏览器(Chrome / Edge)HTTP/1.1 keep-alive 空闲时间一般是 60 秒

那好办,我先打开页面,选择好图片,等待 60+ 秒,再发送请求,果然浏览器没再重发,10s 结束

随后我又多试了几次,确认这个时间是 60s 没错!

60s 以内的请求,都会重发!

60s 以后的请求,一定不会重发!


到了这一步,我们基本就可以确认,TCP 连接重用是核心原因之一!

下一步需要验证的,就是:在重用的 TCP 连接上,收到 rst,会导致浏览器重发请求


5. 小心求证(二)

在 ChatGPT 的帮助下,我构造了下面的代码,其核心是:

  1. 在访问页面及资源(html、css、favicon.ico)的时候,正常返回,且保持连接不断(不能 conn.close()
  2. 当发送 direct_rst 请求的时候,不返回任何数据(请求就会处于 initial connection 阶段),先 sleep 10 秒,然后直接发 rst
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
import socket
import threading
import time
import struct
from pathlib import Path

HOST = "0.0.0.0"
PORT = 39181

html = Path("index.html").read_bytes()
js = Path("fake_scripts.js").read_bytes()

def hang_then_rst(conn, timeout_sec=10):
print(f"[CDN] hang_then_rst: hanging for {timeout_sec}s...")

def worker():
# 先挂起,不发送任何字节
time.sleep(timeout_sec)
print("[CDN] timeout reached → sending RST")

try:
# 配置 socket 将要以发送 rst 的方式关闭连接
l_onoff = 1
l_linger = 0
conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
struct.pack("ii", l_onoff, l_linger))

# 关闭连接,发送 RST
conn.close()
except Exception:
pass

threading.Thread(target=worker, daemon=True).start()

def handle_client(conn):
conn.settimeout(60)

while True:
data = conn.recv(4096)
if not data:
break

request = data.decode("utf-8", errors="ignore")
print("Request:\n", request)

# ========= 页面 =========
if "GET /index.html" in request:
print("Receive GET /index.html")
resp = (
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: text/html\r\n"
b"Content-Length: " + str(len(html)).encode() + b"\r\n"
b"Connection: keep-alive\r\n"
b"\r\n" + html
)
conn.sendall(resp)
print(" GET /index.html end")

elif "GET /fake_scripts.js" in request:
print("Receive GET /fake_scripts.js")

resp = (
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: application/javascript\r\n"
b"Content-Length: " + str(len(js)).encode() + b"\r\n"
b"Connection: keep-alive\r\n"
b"\r\n" + js
)
conn.sendall(resp)
print(" GET /fake_scripts.js end")

elif "GET /favicon.ico" in request:
print("Receive GET /favicon.ico")
resp = (
b"HTTP/1.1 204 No Content\r\n"
b"Content-Length: 0\r\n"
b"Connection: keep-alive\r\n"
b"\r\n"
)
conn.sendall(resp)
print(" GET /favicon.ico end")

# ========= 核心请求 =========
elif "POST /direct_rst" in request:
print("Receive GET /direct_rst")
recv_full_request(conn, data)
hang_then_rst(conn)
print(" GET /direct_rst end")
return

else:
# 正常响应
print("Receive OK")
send_keepalive_ok(conn)
print(" OK end")

conn.close()

def start_server():
print(f"Mock CDN (POST simulation) listening on {HOST}:{PORT}")
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen(5)

while True:
conn, addr = server.accept()
print("Client:", addr)
threading.Thread(target=handle_client, args=(conn,), daemon=True).start()

if __name__ == "__main__":
start_server()

def recv_full_request(conn, data):
"""
读取完整的 POST body
"""
headers = data.split(b"\r\n\r\n")[0].decode("utf-8", errors="ignore")
content_length = 0

for line in headers.split("\r\n"):
if line.lower().startswith("content-length:"):
content_length = int(line.split(":")[1].strip())

body = data.split(b"\r\n\r\n", 1)[1]
to_read = content_length - len(body)

while to_read > 0:
chunk = conn.recv(min(4096, to_read))
if not chunk:
break
body += chunk
to_read -= len(chunk)

print(f"[CDN] Received POST body length = {len(body)}")
return headers, body

def send_keepalive_ok(conn, body=b"OK"):
headers = (
b"HTTP/1.1 200 OK\r\n"
b"Connection: keep-alive\r\n"
b"Keep-Alive: timeout=60\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Access-Control-Allow-Origin: *\r\n"
b"Access-Control-Allow-Methods: POST, GET, OPTIONS\r\n"
b"Access-Control-Allow-Headers: *\r\n"
b"\r\n"
)
conn.sendall(headers + body)

为了防止浏览器对 localhost 或者 127.0.0.1 有特殊处理,我把代码放到了服务器上,打开防火墙,直接 ip + port 访问。

终于!!我看到了梦寐以求的东西,浏览器真的背着我偷偷重试了

抓包一看,果然如我所料:

这是一个两次重试的场景(总耗时 30s),我们可以清楚地看到:

在重用的 TCP 连接上,如果浏览器收到了 rst,就会重发请求;

在一个全新的 TCP 连接上,如果浏览器收到了 rst,就会直接终止。


为什么大多数情况下,都重试一次,偶尔会两次,甚至三次四次呢?

大概率是取决于我初次点击的时间,距离 tcp keepalive = 60s 超时还有多久。


6. 源码

现在,我们带着实验结果猜想再去找源码,就很容易找到问题所在了。

/net/http/http_network_transaction.cc 可以找到:

是否需要重发,其实就是判断是否:重用 + 没有收到 header

1
2
3
4
5
6
7
8
9
bool HttpNetworkTransaction::ShouldResendRequest() const {
bool connection_is_proven = stream_->IsConnectionReused();
bool has_received_headers = GetResponseHeaders() != nullptr;

// NOTE: we resend a request only if we reused a keep-alive connection.
// This automatically prevents an infinite resend loop because we'll run
// out of the cached keep-alive connections eventually.
return connection_is_proven && !has_received_headers;
}

当出现 IO error 的时候,chromium 就会调用上面的 ShouldResendRequest() 进行判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int HttpNetworkTransaction::HandleIOError(int error) {
......
error = HandleSSLClientAuthError(error);

std::optional<HttpNetworkTransaction::RetryReason> retry_reason =
GetRetryReasonForIOError(error);
if (!retry_reason) {
return error;
}
......

switch (*retry_reason) {
case RetryReason::kConnectionReset:

// 判断是否需要重发请求
if (ShouldResendRequest()) {

// 默认门限值是50,即最多会重发 50 次
if (retry_attempts_on_connection_errors_ >=
kMaxRetryAttemptsOnConnectionErrors) {
return ERR_TOO_MANY_RETRIES;
}

// 计数器+1
retry_attempts_on_connection_errors_++;

// 关闭旧连接,然后把状态机切回 STATE_CREATE_STREAM,重新创建连接并发送请求
ResetConnectionAndRequestForResend(*retry_reason);

......
}

至此,我们终于破解了问题背后的隐秘!


六、改造

其实,不管是理论还是应用,都不应该使用这种长耗时的 post 接口;

相比于增加 cdn 超时时间,更科学的方法应该是:

异步请求,轮询结果

同时:

服务端做幂等处理,同一个请求只处理一次

七、总结

魔鬼藏在细节中,对于查问题过程中,遇到的任何异常状况,都不能忽略!

如果我早一点重视到日志中的重复请求,可以节省一天时间!

再比如我早一点重视抓包里没有三次握手的问题,可能更早的找到根因。

再比如 nginx 的 access.log 里已经写明了请求是 200,所以肯定不是 nginx 截断了请求,而是其他什么东西(cdn)

查问题的主要思路还是要掌握在自己脑中,不能一股脑顺着 ai 的思路走!

在上下文过长的情况下,ai 也容易变成一团浆糊,抓不住重点。尽量把问题拆分,每次启用新对话,答案的质量会更高!