perf(monitor_web): 主循环 hot path 不再触发 message DB 全量解密 (修 8-125s spike)

## 根因

用户反馈实时消息延迟从亚秒级 → 8-125 秒。

后端 log:
  [perf] decrypt=576页/47.8ms, query=46.6ms
  ... 总耗时=10381.0ms / 44964.4ms / 125398.8ms ...

解密+查询只 95ms, 但总耗时 8-125 秒。源头:

PR #106 (commit 1aa12c8, issue #42) 引入了 _lookup_latest_message,
在 check_updates 主循环里每个新消息都调:

  dec_path = self.db_cache.get(db_key)
            ^^^^^^^^^^^^^^^^^^^^^^^^
            mtime 变化时同步 full_decrypt 整个 message_N.db (~10s)

微信写消息时 message_N.db mtime 跟着变 → get() 触发全量解密 →
主循环阻塞 10 秒。多个 session 同时更新就叠加成几十秒。

之前 db_cache.get 主要在 _check_hidden_messages (走 _hidden_executor
后台线程) 调用, 不阻塞主线程。PR #106 把它带到了主线程 hot path。

证据 (log 里清晰可见):
  [cache] message\message_0.db 全量解密 10551ms
  [18:23:31 延迟=14.0s] [...] 莫名感触: ...   ← 实测消息延迟 14 秒

## 修复

MonitorDBCache 加 peek(rel_key) 方法, **不触发**重新解密, 只读
当前已解密文件路径 (可能 stale 1 个 mtime 周期):

  def peek(self, rel_key):
      out_path = os.path.join(self.tmp_dir, out_name)
      return out_path if os.path.exists(out_path) else None

_lookup_latest_message 把 self.db_cache.get(db_key) 改成 peek(db_key)。

## Trade-off

stale cache 可能让 _lookup_latest_message 查不到刚写入的 local_id,
返回 (None, None)。check_updates 会:
- 跳过加 _shown_keys (issue #79 的去重保险)
- 跳过用 full_content 替换 summary (issue #42 的扩展正文)

但**两个 fallback 路径都正常**:
- 1 秒后 _check_hidden_messages (异步线程) 会用 db_cache.get 等待
  解密完, 拿到 local_id 并 emit hidden 消息 (issue #79 不破坏)
- 第一次推送仍用 SessionTable.summary 的 80 字短截断, 后续如果 user
  开了详情自然加载完整 (issue #42 退化为"原始行为", 不影响主流程)

权衡: 用"偶发 80 字摘要" 换 "无 8-125 秒延迟"。

## 实测预期

修复后主循环总耗时应回到 < 200ms (跟解密+查询 95ms 同量级)。
SessionTable 跑 hidden_executor 仍然能补抓密集消息 (issue #79 保留)。

## 副作用 (无)

- get() 行为不变, hidden 路径仍同步等解密 (它在后台线程, 不影响主线程)
- peek() 是新加方法, 不影响现有调用方
- 测试 185/185 通过
This commit is contained in:
ylytdeng
2026-05-17 19:03:44 +08:00
parent dd9db0b25b
commit 273fe65a07

View File

@@ -255,6 +255,25 @@ class MonitorDBCache:
with lock:
self._state.pop(rel_key, None)
def peek(self, rel_key):
"""返回当前已解密文件路径,**不触发**重新解密 (即使源 mtime 变了)。
给主循环 hot path (check_updates → _lookup_latest_message) 用,
避免每次新消息都同步等待整个 message_N.db 重新全量解密 (10s+),
把主循环延迟从亚秒级飙到 8-125s。
返回的路径可能 stale (滞后 1 个 mtime 周期)。调用方应能容忍 stale
(比如查不到 latest_local_id 时跳过加 _shown_keys, 让 hidden 路径
异步兜底)。
get() 仍保留同步行为给真正需要最新的调用方 (hidden 路径异步线程)。
"""
if not get_key_info(self.keys, rel_key):
return None
out_name = rel_key.replace('\\', '_').replace('/', '_')
out_path = os.path.join(self.tmp_dir, out_name)
return out_path if os.path.exists(out_path) else None
def get(self, rel_key):
"""返回解密后的临时文件路径mtime 变化时自动重新解密"""
key_info = get_key_info(self.keys, rel_key)
@@ -956,7 +975,10 @@ class SessionMonitor:
return None, None
table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}"
for db_key in db_keys:
dec_path = self.db_cache.get(db_key)
# 用 peek 不触发同步解密 (主线程 hot path)。如果缓存还 stale
# 没 latest_local_id, 让 hidden 异步路径稍后兜底加 _shown_keys。
# 见 MonitorDBCache.peek 注释关于为什么这里不能用 .get。
dec_path = self.db_cache.peek(db_key)
if not dec_path:
continue
try: