Files
ykxmz/docs/优化实测数据.md
T
xmz7891 490adbdba3 补充 2 小时长测的最终数据(361 样本 / 1 小时 44 分)
可用率 356/361 = 98.61%
    耗时   最小 829  中位 1280  P90 2386  P99 14242

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

改之前是 96~98%,现在 98.61%,且失败不集中在某个时段。
2026-10-07 20:29:58 +08:00

380 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 连接被两个线程同时用
```python
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 存不存在,不查语法。
---
## 八、测试工具自己骗人(本轮踩了两次)
### 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 链路的物理条件。** 换有线/宽带会立刻好很多。
能做的是"减少请求数、加协议冗余、加快恢复"——这三件都做了。