Jelajahi Sumber

网关探活提速: /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 minggu lalu
induk
melakukan
067a2c6207
2 mengubah file dengan 82 tambahan dan 11 penghapusan
  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 ·
 重启服务 5/7 ok · 页面字节与改前逐项一致(`/` 20225828 · `/detail/` 229643 · `/sim/` 50998 ·
 `/sim/sys/` 123455 · `/viewer/` 4986) · 维护页数据层四个数字不变(报警 39211 · 工单 5876 · 油样 404 · 月表 3729)。
 `/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` 下**没有任何模型**
 - `/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
 from src import paths as P
 import argparse, datetime as dt, hashlib, http.client, json, os, pathlib, re, socket, subprocess, sys, time, urllib.parse
 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 http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from concurrent.futures import ThreadPoolExecutor
 
 
 ROOT = pathlib.Path(__file__).resolve().parents[1]
 ROOT = pathlib.Path(__file__).resolve().parents[1]
 PORTAL = pathlib.Path(os.environ.get("GUANLAN_PORTAL", str(ROOT / "release/portal.html")))  # v2: 门户 = 本仓 release/, 不再指桌面
 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"]
     return _PORTAL_CACHE["body"]
 
 
 
 
-def _probe(host, port, path, timeout=3.0):
+def _probe(host, port, path, timeout=None):
     t0 = time.time()
     t0 = time.time()
     try:
     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))
         return dict(ok=r.status < 400, http=r.status, ms=int((time.time() - t0) * 1000))
     except OSError as e:
     except OSError as e:
         return dict(ok=False, http=None, ms=int((time.time() - t0) * 1000), err=str(e)[:80])
         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):
 def ollama_models(timeout=3.0):
     """返回 (running, [模型名]). 拒连 = 未启动, 不猜."""
     """返回 (running, [模型名]). 拒连 = 未启动, 不猜."""
     try:
     try:
@@ -88,16 +116,34 @@ def ollama_models(timeout=3.0):
 
 
 
 
 def healthz():
 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,
     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))
                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
     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()
     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
     except Exception: head = None
     con = json.loads(CONTRACT.read_text(encoding="utf-8")) if CONTRACT.exists() else {}
     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")),
     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")),
                 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):
 def down_page(name, prefix, info):