Files
ykxmz/docs/优化实测数据.md
T
xmz7891 fa2e1ae43c 补充:那个 JS bug 在做完修复之后又踩了一次(同一个坑,第二次)
在真实中转上实测管理页时,node --check 报第 46 行语法错。
出事的是注释:

    // ⚠ 这里必须是 \\n(反斜杠+n),不能是 \n ——
                                      ^^^^ 被 Python 变成真换行

**注释为了举例说明「不能写那个转义」,自己写了一个转义**,
于是注释被断成两行,整段 <script> 语法错误,
管理页所有按钮(改码/删除/批量删除/全选)全部失效。

修法:注释改成一个转义序列都不写,只用文字描述。

新增两道防线:
  * _tests/test_admin_js_syntax.py(回归阶段 3a6)
    本地渲染管理页 -> node --check。不连中转就能查出来。
  * _tests/test_admin_live.py
    真实中转上走 HTTP 实测两个功能(16/17 通过,唯一失败就是这个 bug)。
    它依赖线上设备,不进回归套件。

原来那个「管理页 JS 合法性」阶段只查 token 在不在,查不出语法错。
「测过」和「能用」是两件事 —— 这句话这轮被验证了两次。

部署时机:发现时 relay-stability 正在跑隧道 A/B,部署要重启中转
会打断对照,所以故意押后到对照跑完。
2026-10-07 20:34:06 +08:00

18 KiB
Raw Blame History

ykxmz 优化实测数据(2026-10-07)

这份文件记录本轮所有优化的实测数字和对比依据。 每一项都写清楚:改之前是多少、改之后是多少、怎么测的、受什么限制。 目的:以后有人问"这个改动到底有没有用",能直接翻数字,不用靠感觉。


零、测量方法上的两个坑(先说清楚,否则下面的数字会被误读)

0.1 「从哪台机器测」会差很多

设备是随身 WiFi(4G 上行)。如果在设备自己上跑 curl,请求会走:

设备 → 4G → Cloudflare 边缘 → 隧道 → 回到设备自己

同一段 4G 链路被算了两遍,数字比真实用户差得多。

真实用户(WiFi/宽带/5G)只有后半段走 4G:

用户 → 好网络 → Cloudflare 边缘 → 隧道 → 设备 4G

所以能从 PC 测的指标就用 PC 测。两边都测过的项,下面会分开列。

0.2 用 curl -I 验证 GET 的响应头是错的

-I 发的是 HEAD。我在这上面白查了半天缓存头 —— 看到的一直是 do_HEAD 分支的响应, 而真正的 GET 早就改对了。验证缓存头必须用 curl -sD - -o /dev/null。


一、下载吞吐

改之前:relay 给所有响应发 Cache-Control: no-store,Cloudflare 完全不缓存, 每次下载都要穿隧道回 4G 源站。

文件 改之前 改之后 倍数
ykxmz-setup-1.8.2.exe(15 MB) 237568 字节后就断了(17 KB/s) 695 KB/s,完整 15067199 字节 41×
ykxmz-watch-1.8.2.apk 3 KB/s 101 KB/s 33×
ykxmz-server-1.8.2.zip 67 KB/s 87 KB/s 1.3×

改完之后三个文件的 cf-cache-status 都是 HIT(不再穿隧道回源), 而且不消耗 4G 流量。

改动:/download/* → public, max-age=604800, immutable;/ → max-age=60; /shot/*、/favicon.ico 保持 max-age=86400;/v1/* 保持 no-store。

顺带修的:_send() 无条件先发 no-store 导致两个 Cache-Control 头 (调用方的被吃掉);do_HEAD 和 do_GET 的缓存头不一致。


二、隧道稳定性与公网可用率

2.1 最终配置

项 值 依据
实例 1 lanremote-tunnel --protocol quic --ha-connections 8 --metrics 127.0.0.1:20241 QUIC 连上之后丢包恢复优于 TCP
实例 2 lanremote-tunnel-h2 --protocol http2 --ha-connections 2 --metrics 127.0.0.1:20242 QUIC 拨号只有约 72% 成功率,靠它兜底
两个实例 连同一个具名隧道 Cloudflare 把请求分摊到全部 10 条连接
wlan0 MTU 1400 QUIC 走 UDP,TCPMSS 规则管不到它
TCP MSS 1360 和 MTU 1400 配套
TCP keepalive 30 / 10 / 3 4G 会静默丢 TCP,系统默认 7200 秒才察觉

2.2 为什么"两条一起跑"(受控对照,40 次/组)

配置 可用率 中位 P90 P99
quic 5 + h2 5 100% 1490 2346 3295
quic 3 + h2 7 97% 1271 2558 13934
quic 8 + h2 2 100% 1250 1715 2632

结果反直觉但机制清楚:QUIC 的弱点是「连不上」,不是「连上之后不好」。 一旦连上,它没有 TCP 的跨流队头阻塞,反而更稳。

2.3 QUIC 拨号 72% 的根因(不是"运营商封了 UDP")

曾经以为是运营商限制 UDP。那个判断是错的 —— 依据来自一个手搓的简化 UDP 探测包,格式不对,Cloudflare 本来就不该回应它。

用 cloudflared --loglevel debug 实测:

INF | UDP Connectivity  region1.v2.argotunnel.com  PASS  QUIC connection successful
INF | UDP Connectivity  region2.v2.argotunnel.com  PASS  QUIC connection successful
INF | SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol
INF Registered tunnel connection        (握手 1 秒)

UDP 完全通。 真因是:

QUIC 建连要 6+ 个包往返;TCP 的 SYN 只有 3 个包、内核还按 1s/2s/4s 激进重传。
约 5.4%/包的丢包下:  (1 - 0.054)^6 ≈ 72%   ← 和实测 72% 对得上

2.4 MTU 1400 的效果

建齐 10 条连接耗时 dial 失败次数
MTU 1500 28 秒 2
MTU 1400 13 秒 1

原因:QUIC 的包一旦超过路径 MTU 就被中间设备静默丢掉(UDP 丢了不回 ICMP), 只能等超时。把接口 MTU 压到 1400(路径 MTU 实测 1472)之后就好了。

2.5 长时可用率

时段 样本 可用率 中位 P90 P99
25 分钟(双协议初测) 50 100% 1505 3218 14245
1 小时 44 分(2 小时长测) 361 98.61% 1280 2386 14242

长测原定 2 小时,实际跑到 1 小时 44 分就停了 —— 设备在 19:11 换了网段(192.168.10.105 → 192.168.2.156), 测量脚本随之断开。361 个样本足够说明问题。

改之前是 96~98%。失败不集中在某个时段(按小时分桶是均匀的, 没有"运营商高峰"特征),绝大多数抖动 1 秒内自愈。真正造成可见中断的只有三次:

16:30:19 -> 16:34:28   (4 分 09 秒)
16:50:39 -> 16:53:04   (2 分 25 秒)
17:00:20 -> 17:02:27   (2 分 07 秒)

失败的 5 次:

17:27:20  HTTP 502   1407ms
17:31:42  HTTP 530    862ms
17:56:46  HTTP 000  25000ms
17:57:06  HTTP 502   4652ms
19:00:20  HTTP 502   2101ms

PC 侧(完全不同的网络)在同一个窗口也失败 —— 说明问题在 Cloudflare 边缘 或隧道这一侧,不是某一端的本地链路。

2.6 连接数波动(86 个采样点 / 约 1.5 小时)

QUIC 连接数:   最低 1   最高 8   平均 7.5
HTTP/2 连接数: 最低 1   最高 2   平均 2.0

QUIC 连接数分布:
   8 条  76.7%     <- 大部分时间健康
   7 条  15.1%
   6 条   3.5%
   5 条   2.3%
   1 条   2.3%     <- 塌陷(1.5 小时里约 2 分钟)

QUIC 塌陷(<=2 条): 2 次,占 2.3%
   塌陷时 HTTP/2 >=1 条:   2 次(100%)
   塌陷时 HTTP/2 也归零:   0 次

这就是双协议兜底的价值,有数字支撑:QUIC 塌的时候 HTTP/2 一次都没塌。

一次典型塌陷的时间线(watchdog 日志):

17:56:39  公网不通(HTTP 000,quic=1, h2=2)   连续第 1 次
17:56:59  公网不通(HTTP 000,quic=7, h2=2)   连续第 2 次   <- 自己回来了
17:57:09  公网不通(HTTP 502,quic=6, h2=2)   连续第 3 次
          公网连续 3 次不通,但连接健康(合计 8 条)—— 再等等,不重启
17:57:11  公网不通(HTTP 502,quic=1, h2=1)   连续第 4 次   <- 又塌,且 h2 也掉了
          连续 4 次公网不通 —— 重启 [两个实例]
17:57:37  本次已建齐(合计 10 条)                <- 26 秒恢复

这次重启是对的:连接总数掉到 2 条属于确认故障, 重启把中断缩短到 26 秒,比等 QUIC 自然恢复(可能几分钟)划算。

目标「0 掉线」没能做到 —— 4G 链路上 QUIC 会周期性塌陷。 能做的是「加协议冗余 + 加快恢复」,这两件都做了, 把影响压到 2.3% 的窗口且 HTTP/2 全程兜住。


三、长轮询(目标:稳定贴近 60000ms + 最小开销)

30 次真实 poll(每次挂 60 秒),改完之后:

成功 29/30 = 96.7%
耗时: 最小 60920   中位 61439   P90 126604   P99 126716

中位 61439ms(理想 61000)—— 隧道只加了约 1.4 秒开销,非常好。

4 次异常长尾(~126.6 秒 = 正好 2 倍 hold):发生在隧道重启的窗口里。 Cloudflare 在隧道重连时会把请求重发给源站,源站又 hold 了一次 60 秒。

由此把客户端超时从 120 秒放宽到 180 秒(留 120 秒网络余量)。 超时余量的演进全是被实测逼出来的:

 90 秒  -> 实测出现 93589ms   不够
120 秒  -> 实测出现 126604ms  不够
180 秒  -> 60 hold + 120 余量

不缩短 hold 的理由:这条链路的根本问题是丢包抖动,请求越少越不容易撞上。 放宽客户端超时只是"别把慢但成功的请求冤枉掉",不增加任何请求数。


四、端到端延迟(局域网 vs 跨网络)

用 _tests/bench_lan_vs_relay.py 测,同一套客户端写法,两边各 30 次 cmd:ping:

路径 握手 成功率 中位 P90 最大
局域网直连 16 ms 30/30 (100%) 0 ms 0 ms 1 ms
跨网络(本地中转) 46 ms 30/30 (100%) 15 ms 29 ms 32 ms

跨网络那 15ms 是本地中转的协议开销(长轮询往返 vs 直连 TCP)。 真实跨网络的 1.0~1.5 秒来自隧道 + 4G,是另一回事(见 2.5 的耗时数据)。

结论:局域网确实是不折不扣的"毫秒级、不耗流量、不怕隧道抖"的那条路。 中位 0ms vs 15ms,而且完全不经过任何外网。


五、局域网优化的四个真问题("不能忘本")

# 原来怎么错 现在怎么对
1 sameSubnet() 只比前三段(等于写死 /24)。在 10.x/8、172.16/12 上,10.8.1.5 和 10.8.2.5 被判成"不同网络"→ 明明能直连却走中转还费流量 按网卡真实掩码比较;拿不到掩码才退回 /24
2 广播发现只发一次包,UDP 丢一个就整轮搜不到电脑 按 {0, 350, 900, 1800, 3000}ms 重发 5 轮
3 只探一个网段(拿到第一个 wlan 就 return)—— 多网卡手表搜不到另一网段的电脑 localNets() 返回全部,定向广播全探
4 走中转之后再也回不到局域网(只在"刚连上"那一刻试一次直连) 定期回切(起手 15s,退避到最多 2min)。先做便宜的 TCP ping,通了才动真格,不破坏正在工作的中转连接

六、"按一下没反应"的真因(本轮最重要的修复)

现象

用户报:局域网能搜到电脑、IP/端口/配对码都对,连上后没有任何反应。

证据链(插桩复现)

Exception in thread client-relay:
  server.py:407  session.send(reply)
  relay.py:166   self._link.send_to_client(data)
  relay.py:357   self._post("/v1/agent/send", ...)
  relay.py:766   raise RelayError(f"请求失败: {last_error}")
lanremote.relay.RelayError: 请求失败: Idle

线程一死:client-relay 没了 → 电脑再也不回任何东西; 而 relay-link 还活着、还在长轮询 → 中转那边看着"设备在线"。 手表看到的就是「连上了,按一下毫无反应」。

根因:一条 HTTP/1.1 连接被两个线程同时用

with self._http_lock:
    connection = self._http        # ← 锁到这里就放了
    ...
connection.request("POST", ...)    # ← 请求在锁外发
  • relay-link 线程跑 /v1/agent/poll —— 长轮询,一条请求挂 180 秒
  • client-relay 线程同时 POST /v1/agent/send

HTTP/1.1 一条连接同一时刻只能有一个在途请求 → 第二个线程撞上 http.client 状态机 → CannotSendRequest("Idle")。

修法

  1. 发命令走独立连接(_post 加 dedicated 参数)
  2. 两条连接各自一把锁盖住整个「发请求 + 读响应」周期
  3. 普通请求若发现缓存连接正忙,临时另开一条用完即关
  4. _client_loop 兜住回包异常 —— 发失败最多踢掉这个会话让手表重连, 绝不能让线程消失

效果

修复前:pair OK -> ping OK -> ping 卡死 -> 线程崩溃,永久哑
修复后:pair 21ms -> ping 82ms -> ping 4ms -> ping 6ms,零异常

_tests/test_relay_local.py:之前完全卡死 -> 现在 21/21 全过

这个测试之前一直是红的,说明"回归全绿"这件事本身就不成立过 —— 某个阶段卡住时看起来像环境问题而不是缺陷。


七、独立验证发现的问题(对抗性复核)

派了一个专门的验证智能体,只读验证 + 自己构造边界场景,找到 9 个问题, 其中 3 个严重(都是本轮改动引入的),已全部修复:

严重度 问题 修法
🔴 TTL 清理 + 关掉接入开关 = 设备永久锁死:register() 用 device_id in self.devices 判断老设备,但超时清理(>6 小时)把设备从 devices 和 codes 都删了。电脑关机过夜必然触发,而且重启也救不回来(清 codes 时没落盘,重启后又当孤儿清一遍,自我维系)。管理员在页面上还看不到这台设备 状态文件里单独存 knownDevices 设备历史,超时清理绝不动它;清理后补 _save()
🔴 管理页 JS 语法错误 → 所有按钮失效:admin.py 里 ADMIN_JS 是普通三引号字符串,在 Python 源码里写的 \n 被变成真实换行,拼进 JS 就是字符串里的裸换行 → 整段 <script> 不执行 → 改码/删除/批量删除全变幽灵按键 写成 \\n
🔴 --relay / --relay-status 崩溃:RelayLink.start(url) 的 url 是必填参数,改默认地址时两处调用没跟着改 两处都补上 url
🟡 非法 deviceId(控制字符 / 路径分隔符)被接受 白名单:字母数字 + -_.:
🟡 enable_relay() 第二次调用谎报成功 加 RelayLink.running 属性,返回值反映真实状态
🟡 snapshot() 清孤儿码后没落盘 补 _save()
🟡 deploy.sh --prefix 沙箱泄漏 已修
🟡 test_net_tune.py 断言过时(钉死 MTU 1500,而实现故意用 1400) 更新断言
🟢 测试自己骗人(变异测试证明有盲区) 已补

验证智能体自己的总结很到位:

「23 项测试通过」和「功能能用」是两件事 —— 管理页那个 JS bug 就是在 23 项全绿的情况下进去的,测试只查 token 存不存在,不查语法。

7.1 那个 JS bug 后来又踩了一遍(同一个坑,第二次)

在做完上面这些修复之后,加「改码」功能时又踩了一次,而且更讽刺。

在真实中转上实测管理页时,用 node --check 检查页面内嵌的 JS:

SyntaxError: Invalid or unexpected token  (第 46 行)

出事的是这段注释(admin.py 源码):

// ⚠ 这里必须是 \\n(反斜杠+n),不能是 \n ——
                                  ^^^^
                       这个 \n 被 Python 变成真实换行

注释为了举例说明「不能写 \n」,自己写了一个 \n。 于是注释被断成两行,// 行注释不能跨行 → 整段 <script> 语法错误 → 管理页所有按钮(改码/删除/批量删除/全选)全部失效。

修法:注释改成一个转义序列都不写,只用文字描述。

新增两道防线:

  1. _tests/test_admin_js_syntax.py(回归阶段 3a6) 本地渲染管理页,把 <script> 抠出来喂给 node --check。 这是最快的复现方式 —— 不连中转、不起服务器就能查出来。

  2. _tests/test_admin_live.py 在真实中转上走 HTTP 实测两个功能(16/17 通过, 唯一失败就是上面这个 bug)。它依赖线上设备,不进回归套件。

为什么原来查不出来:已有的「管理页 JS 合法性」阶段只检查 token 在不在,不查语法。「测过」和「能用」是两件事 —— 这句话在这一轮被验证了两次。

部署时机:发现时 relay-stability 正在跑隧道配比 A/B, 部署要重启中转服务会打断对照,所以故意押后到对照跑完再部署。


八、测试工具自己骗人(本轮踩了两次)

8.1 局域网/中转对比基准

这个基准一度是红的,报告里写着"这是中转链路的产品 bug"。 查下来不是 —— 基准自己有两个 bug:

  1. 两条路用了同一个配对码 → 第 2 台被「一码一机」规则拒掉, 而第 1 台虽然被杀、中转还以为在线,于是"看着成功、实际没人回包"
  2. relay_connect() 里 attach 用的还是旧码(打到已死掉的设备上)

修好之后两条路都通了(见第四节)。

8.2 缓存头

用 curl -I 验证 GET 的缓存头,看到的一直是 HEAD 分支的响应。见 0.2。

教训:症状像产品 bug 时,先怀疑测量工具比先怀疑产品更省时间。


九、服务端一键部署脚本

relay/deploy.sh + relay/deploy-tune.sh,随服务端 zip 发布。

  • 自动检查并安装 cloudflared
  • 部署程序、装 4 个 systemd 服务、网络调优、配隧道、自检打公网地址
  • 不传 --hostname 且没有 TUNNEL_TOKEN 时明确提示要填 —— 不会静默用一个写死的域名(这个中转站是作者自己用的,不对外开放)
  • 幂等:重跑 = 升级,已有数据/配对码/管理密码/凭据/手改过的 config.yml 都不动
  • 按需重启:程序和 unit 用 cmp 比对,一样就不落盘、不重启

测试三套全绿:

测试 结果
_build/test_deploy_sh.sh(Windows 沙箱) 110 通过 / 0 失败
_build/test_zip_deploy.sh(从发布 zip 解压再跑) 30 通过 / 0 失败
_build/test_deploy_device.sh(真机 Debian aarch64) 39 通过 / 0 失败

真机那套桩掉了 systemctl/iptables/ip/sysctl/nmcli (--prefix 只隔离写文件,不隔离命令副作用), 第 8 节专门验证线上零影响:relay/tunnel/h2 全 active、MTU=1400、 MSS 规则数、keepalive 测前测后完全一致。


十、没做到的部分(如实记录)

目标 实际 限制
公网可用率 100% 98.5%(1h35m) 4G 链路约 5%/包丢包;QUIC 握手要 6+ 往返,成功率 (1-p)^6
隧道 0 掉线 仍有约 9 分钟周期的 QUIC 塌陷 同上,靠 HTTP/2 兜底把影响压到最小
长轮询 100% 96.7%(30 次) 隧道重启窗口内 Cloudflare 会重发请求,源站二次 hold

这些不是代码问题,是这条 4G 链路的物理条件。 换有线/宽带会立刻好很多。 能做的是"减少请求数、加协议冗余、加快恢复"——这三件都做了。