Przeglądaj źródła

网关探活提速: /healthz 6.5s → 冷探 1.5s / 命中缓存 ~10ms

发现: 收尾测页面时 /healthz 要 6.5 秒回来(20 MB 的门户只要 0.04 s)。三笔叠加:
  ① 6 个上游探活**串行**、单探针默认 3 s → 4.4 s (CMS 与 Ollama 两个掉线模块各卡满超时);
  ② Ollama 模型查询在探活之后**又串一次** → +2 s;
  ③ 门户 sha256 **每次**重读 20 MB 再哈希 → 0.1~数十秒(磁盘忙时)。

改法(返回结构一字未改):
  · 全部探针并发 (ThreadPoolExecutor) → 总耗时 = 最慢那个, 不再求和; 单探针 PROBE_TIMEOUT=1.5 s;
  · Ollama 探针与路由探针并发, 不再串在后面多等一个超时;
  · 门户 sha 抽成 _portal_sha() 并按 (mtime_ns, size) 缓存 —— /healthz 与 /api/version 共用,
    文件没变就不重读; version() 补了 OSError 兜底(门户缺失仍返回 None, 与原实现一致);
  · 整个 healthz 结果缓存 HEALTH_TTL=5 s。

实测: 冷探 6.5 s → 1.53 s; 命中缓存 15 ms; 重启后首探 8.8 ms (serve 自己探过一遍, 缓存已热)。
结构逐键比对无差异 —— 顶层键/模块集合/每模块键/local_ai 键/status/门户 file_sha256 全同;
/api/version 的 portal_sha256 与磁盘门户逐字节一致 (9b6aabeb…)。

★写进注释的实测结论: 这台机器连**任何**已关闭的 127.0.0.1 端口都不回 RST, 而是丢 SYN 等超时
(对照随机闭端口 49996/49997 同样 1.5 s)。所以"有模块掉线"时冷探下限就是 PROBE_TIMEOUT,
提速只能靠缓存; 别把超时压到健康模块也可能被误判的程度(最慢的健康探针 sim_sys 345 ms, 1.5 s 留 4 倍余量)。

指纹回归: 与出厂基线比 6 处变化, 全部由**产物状态**解释(原始重算后 CMS/契约/判级底座不在):
  gateway.healthz(CMS 掉线) · gateway.version(契约产物不在) · detail.maint_survey(实机参数表行) ·
  detail.query/turbine_problems(err=no_products) · cms.home(503);
不受影响的 4 项逐字节一致(portal.home 64b4b81158d09d9c · detail.v2 a0abff29c000f570 ·
sim_sys.page 72d5062d7b1d998a · viewer.index c04e5c29f8b55c55)。
zhouyang.xie 4 tygodni temu
rodzic
commit
067a2c6207
2 zmienionych plików z 82 dodań i 11 usunięć
  1. 23 0
      _修复记录_20260911/README.md
  2. 59 11
      scripts/guanlan_gateway.py

+ 23 - 0
_修复记录_20260911/README.md

@@ -273,6 +273,29 @@ L0 仓 16 件 1.78 MB (随包 118 件 —— 差额就是"包内没有生成端"
 重启服务 5/7 ok · 页面字节与改前逐项一致(`/` 20225828 · `/detail/` 229643 · `/sim/` 50998 ·
 `/sim/sys/` 123455 · `/viewer/` 4986) · 维护页数据层四个数字不变(报警 39211 · 工单 5876 · 油样 404 · 月表 3729)。
 
+## 探活页提速: /healthz 6.5 s → 冷探 1.5 s / 命中缓存 ~10 ms (2026-09-11)
+
+收尾测页面时发现 `/healthz` 要 **6.5 秒**才回来(而 `/` 那个 20 MB 门户只要 0.04 s)。拆开看是三笔叠加:
+
+| 项 | 原实现 | 代价 |
+|---|---|---|
+| 6 个上游探活 | **串行** `for` 循环, 单探针默认 3 s | 4.4 s(其中 CMS / Ollama 两个掉线模块各卡满超时) |
+| Ollama 模型查询 | 探活之后再串一次 | +2 s |
+| 门户 sha256 | **每次** `PORTAL.read_bytes()` 重读 20 MB 再哈希 | 0.1–数十秒(磁盘忙时) |
+
+改法(返回结构一字未改): ① 全部探针**并发**(`ThreadPoolExecutor`, 总耗时 = 最慢那个); ② 单探针 1.5 s;
+③ 门户 sha 按 `(mtime_ns, size)` 缓存(值不变就不重读); ④ 整个结果缓存 5 s —— 连续调用近乎零成本。
+
+实测: 冷探 **6.5 s → 1.53 s**, 命中缓存 **15 ms**; 重启后首探 8.8 ms(`guanlan.py serve` 自己探过一遍,
+缓存已热)。结构逐键比对无差异(顶层键/模块集合/每模块键/`local_ai` 键/`status`/门户 `file_sha256` 全同),
+`/api/version` 的 `portal_sha256` 与磁盘门户逐字节一致(`9b6aabeb…`)。
+
+**★一台机器上的实测结论**(写进代码注释了): 这台机器**连任何已关闭的 `127.0.0.1` 端口都不回 RST**,
+而是把 SYN 丢掉等超时 —— 对照用的随机闭端口 49996/49997 同样 1.5 s 超时, 不是我们端口或进程的问题,
+是系统防火墙/安全软件行为。所以"有模块掉线"时冷探下限就是 `PROBE_TIMEOUT`; 想更快只能靠缓存,
+**别为此把超时压到健康模块也可能被误判的程度**(实测最慢的健康探针 `sim_sys` 345 ms, 其余 <25 ms,
+1.5 s 留了 4 倍余量)。
+
 ## 仍然存在 (不是代码问题)
 
 - `/local-ai/` 仍是 DOWN: 本机 Ollama 没运行, 且 `%USERPROFILE%\.ollama` 下**没有任何模型**

+ 59 - 11
scripts/guanlan_gateway.py

@@ -14,6 +14,7 @@ _sys.path.insert(0, str(_plb.Path(__file__).resolve().parents[1]))
 from src import paths as P
 import argparse, datetime as dt, hashlib, http.client, json, os, pathlib, re, socket, subprocess, sys, time, urllib.parse
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from concurrent.futures import ThreadPoolExecutor
 
 ROOT = pathlib.Path(__file__).resolve().parents[1]
 PORTAL = pathlib.Path(os.environ.get("GUANLAN_PORTAL", str(ROOT / "release/portal.html")))  # v2: 门户 = 本仓 release/, 不再指桌面
@@ -69,15 +70,42 @@ def portal_bytes():
     return _PORTAL_CACHE["body"]
 
 
-def _probe(host, port, path, timeout=3.0):
+def _probe(host, port, path, timeout=None):
     t0 = time.time()
     try:
-        c = http.client.HTTPConnection(host, port, timeout=timeout); c.request("GET", _q(path)); r = c.getresponse(); r.read(65536); c.close()
+        c = http.client.HTTPConnection(host, port, timeout=timeout or PROBE_TIMEOUT); c.request("GET", _q(path)); r = c.getresponse(); r.read(65536); c.close()
         return dict(ok=r.status < 400, http=r.status, ms=int((time.time() - t0) * 1000))
     except OSError as e:
         return dict(ok=False, http=None, ms=int((time.time() - t0) * 1000), err=str(e)[:80])
 
 
+# 探活参数 (2026-09-11 调): 原先 6 个上游**串行**探、每个默认 3 s, 而两个掉线的模块
+# (CMS 无产物未起 / Ollama 无模型未起) 各卡满超时 → /healthz 实测 6.5 s; 再加上每次重读
+# 20 MB 门户算 sha256 (磁盘忙时这一项能到几十秒), 一个探活页慢得没法看。
+# 现在: ① 全部探针并发 → 总耗时 = 最慢那个; ② 单探针 1.5 s; ③ 门户 sha 按 (mtime,size) 缓存;
+# ④ 整个结果缓存 HEALTH_TTL 秒 → 连续调用近乎零成本。实测: 冷探 6.5 s → 1.53 s, 命中缓存 15 ms;
+# 全部模块健康时冷探 ≈ 最慢的健康探针 (实测 sim_sys 345 ms, 其余 <25 ms)。
+#
+# ★为什么"掉线模块"必须等满超时: 本机实测 **连任何已关闭的 127.0.0.1 端口都不回 RST**,
+#   而是把 SYN 丢掉等超时 (对照用的随机闭端口 49996/49997 同样 1.5 s 超时, 不是我们端口的问题,
+#   是这台机器的防火墙/安全软件行为)。所以"有模块掉线"时的冷探下限就是 PROBE_TIMEOUT,
+#   想更快只能靠缓存 —— 别为此把超时压到健康模块也可能被误判的程度。
+PROBE_TIMEOUT = 1.5
+HEALTH_TTL = 5.0
+_HEALTH_CACHE = {"at": 0.0, "payload": None}
+_PORTAL_SHA = {"key": None, "sha": None}
+
+
+def _portal_sha():
+    """门户文件 sha256, 按 (mtime_ns, size) 缓存。原实现每次 /healthz 与 /api/version 都
+    `PORTAL.read_bytes()` 重读 20 MB 再哈希 —— 值没变, 代价却按请求量重复付。"""
+    st = PORTAL.stat()
+    key = (st.st_mtime_ns, st.st_size)
+    if _PORTAL_SHA["key"] != key:
+        _PORTAL_SHA.update(key=key, sha=hashlib.sha256(PORTAL.read_bytes()).hexdigest())
+    return _PORTAL_SHA["sha"]
+
+
 def ollama_models(timeout=3.0):
     """返回 (running, [模型名]). 拒连 = 未启动, 不猜."""
     try:
@@ -88,16 +116,34 @@ def ollama_models(timeout=3.0):
 
 
 def healthz():
-    mods = []
-    for pre, port, _, hp, name in ROUTES:
-        mods.append(dict(path=pre + "/", name=name, upstream_port=port, **_probe("127.0.0.1", port, hp)))
-    try:  # 门户文件读失败 (如 Finder 启动的 app 无外接盘权限 EPERM) 必须报 ok=False 带原因, 不能让 /healthz 整个炸成空回复 (2026-09-07 app 冷启动实测)
-        mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=PORTAL.exists(), file_sha256=hashlib.sha256(PORTAL.read_bytes()).hexdigest() if PORTAL.exists() else None, portal=str(PORTAL)))
-    except OSError as e:
-        mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=False, file_sha256=None, portal=str(PORTAL), error=f"{type(e).__name__}: {e}"))
-    run, models = ollama_models()
+    """全部模块状态 (逐项探活)。结果缓存 HEALTH_TTL 秒 (并发探活 + 缓存见 PROBE_TIMEOUT 处注释);
+    返回结构一字未改 (path/name/upstream_port/ok/http/ms/err · 门户那项带 file_sha256/portal)。"""
+    if _HEALTH_CACHE["payload"] is not None and time.time() - _HEALTH_CACHE["at"] < HEALTH_TTL:
+        return _HEALTH_CACHE["payload"]
+    with ThreadPoolExecutor(max_workers=len(ROUTES) + 2) as ex:
+        f_mods = [ex.submit(_probe, "127.0.0.1", port, hp, PROBE_TIMEOUT) for _, port, _, hp, _ in ROUTES]
+        f_oll = ex.submit(ollama_models, PROBE_TIMEOUT)          # 与路由探针并发, 不再串在后面多等一个超时
+        f_sha = ex.submit(_portal_sha)
+        mods = [dict(path=pre + "/", name=name, upstream_port=port, **f.result())
+                for (pre, port, _, hp, name), f in zip(ROUTES, f_mods)]
+        try:
+            psha = f_sha.result()
+        except OSError:
+            psha = None
+        run, models = f_oll.result()
+    # 门户文件读失败 (如 Finder 启动的 app 无外接盘权限 EPERM) 必须报 ok=False 带原因, 不能让 /healthz 整个炸成空回复 (2026-09-07 app 冷启动实测)
+    if not PORTAL.exists():
+        mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=False,
+                            file_sha256=None, portal=str(PORTAL)))
+    elif psha is None:
+        mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=False,
+                            file_sha256=None, portal=str(PORTAL), error="OSError: 门户文件读失败 (权限/占用?)"))
+    else:
+        mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=True,
+                            file_sha256=psha, portal=str(PORTAL)))
     out = dict(status="ok" if all(m["ok"] for m in mods) else "degraded", mode="offline", checked=dt.datetime.now().isoformat(timespec="seconds"), modules=mods,
                local_ai=dict(running=run, models=models, note=None if run else "本机模型未启动 (Ollama %s:%d 拒连); 不会转云" % OLLAMA))
+    _HEALTH_CACHE.update(at=time.time(), payload=out)
     return out
 
 
@@ -107,9 +153,11 @@ def version():
     try: head = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"], capture_output=True, text=True, timeout=5).stdout.strip()
     except Exception: head = None
     con = json.loads(CONTRACT.read_text(encoding="utf-8")) if CONTRACT.exists() else {}
+    try: psha = _portal_sha()                       # 缓存版: 同一份门户不重复读 20 MB
+    except OSError: psha = None                     # 门户缺失/不可读 → 与原实现一致返回 None, 不抛
     return dict(mode="offline", entry="http://127.0.0.1:28084/", repo_head=head, detail_style_baseline=dict(commit=b.get("git_commit"), built=b.get("built"), fingerprint=b.get("fingerprint"), artifact_sha256=b.get("artifact_sha256")),
                 content_contract=dict(version=con.get("contract_version"), sha256=con.get("contract_sha256"), n_claims=con.get("n_claims"), consumers=c.get("consumers_wired")),
-                portal_sha256=hashlib.sha256(PORTAL.read_bytes()).hexdigest() if PORTAL.exists() else None, routes=[dict(path=p + "/", name=n) for p, _, _, _, n in ROUTES], gateway=pathlib.Path(__file__).name)
+                portal_sha256=psha, routes=[dict(path=p + "/", name=n) for p, _, _, _, n in ROUTES], gateway=pathlib.Path(__file__).name)
 
 
 def down_page(name, prefix, info):