Files
group_fqcd_jr/docs/演示用/多Worker接入方案-2026-09-14.md
T
lzf_0626 36c7a9d8d2 文档:审查报告入库 + 全量校对补注
## 新入库(`docs/演示用/`)

- `代码库全面审查报告-2026-09-14.md`
- `代码修改方案-2026-09-14.md`
- `记忆系统排查报告-2026-09-14.md`
- `记忆系统修复文档-2026-09-14.md`
- `文档一致性审计报告-2026-09-14.md`
- `多Worker接入方案-2026-09-14.md`

## 全量校对(32 个既有文档 + `AGENTS.md`)

跨 39 个文件、**1125 insertions / 148 deletions**。

⚠️ **这批改动同样不是本次会话写的**。我抽样核对过性质:是**实质内容补充**而不是
格式/换行转换。例如 `docs/44-演示流程.md` 新增两条"2026-09-14 补注":

- `启动金融Agent平台.bat` 只在**桌面**上,仓库里只有 `启动平台.bat` 这一份
  (两份由同一个 `tools/make_launcher_bat.py` 产出,改完 `start.ps1` 重跑它一起更新);
- `advisor_t`(9020) 与 `offsite_t`(9006) **不在 `tools/seed_test_rbac.py` 的演示用户里**
  (那里只有 `cust_t`/`risk_t`/`admin_t`/`review_t` 四个),由 `grant_*.py` 系列创建,
  **重跑种子不会重建它们** —— 换机器时这两个账号登录失败,要先查 `sys_user` 有没有这两行,
  而不是查密码。

这两条都是对的地方,与我这一路踩到的现象一致(我确实用到了 `advisor_t`/`offsite_t`)。

**我没有逐字审阅全部 39 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
2026-09-14 20:36:00 +08:00

13 KiB
Raw Blame History

运营侧额外 Worker 接入方案

日期:2026-09-14 性质:方案文档,未修改任何代码 结论先行:"worker 只能有一个"这个前提不成立,但你的担忧(主 worker 不能被拖垮)是真实且已经存在的——只是原因和"有几个 worker"无关,而在主循环的串行 await。



〇、决策结论(2026-09-14 晚,已定)

确认信息:运营业务独立 / 邮件量很少 / 要求异步 / 采用 IMAP IDLE 长连接模式(有邮件立即拉取,无邮件进入 IDLE 等待 120 秒)。

状态:✅ 前置条件已全部确认,可开工。

结论:方案 C —— 独立常驻进程。

确认记录:

确认项 结论 对方案的影响
业务性质 运营自己的独立业务 → 排除 A(扩多邮箱)
邮件量 很少 → 独立进程开销可忽略,无需为省资源合并
执行方式 异步 / IDLE 长连接(120s) → 排除 B+D(同进程轮询),节奏不匹配
是否写 domain_event_outbox 否,只写运营自己的表 → 独立进程方案成立(红线 1 不触发)

为什么 IDLE 推翻了原推荐的 B+D:

主 Worker IMAP IDLE Worker
模型 短轮询(run_once() 快速返回,无活 sleep(1)) 长连接阻塞(一次 IDLE 阻塞 120 秒等推送)
节奏 1 秒 120 秒
返回时机 每轮都返回 只有收到推送或超时才返回

把"阻塞 120 秒的 IDLE"塞进"每轮 1 秒的轮询循环",即使用 gather 也会被拖住(那个 task 不返回,gather 就不完成)。两者节奏差两个数量级,混合在一起是给自己找麻烦。

选 C 的额外理由:

  • 独立进程 = 主 Worker 必然不受影响,正好满足"主 worker 要保证运行"
  • 邮件量很少 → 独立进程的资源开销可忽略,没有"为了省资源而合并"的理由
  • IDLE 天然是"独立服务"形态,与 risk_scan_scheduler 的独立入口同款

一、先纠正三个事实

1.1 项目里已经有两种 Worker 形态,不是"只能有一个"

入口 启动方式 形态
主 Worker python -m app.worker 单进程内串行跑 WorkerRuntime + OffsiteMailWorker
风控扫描 python -m app.worker.risk_scan_scheduler 独立常驻进程(risk_scan_scheduler.py:155-169,自带 while True + serve() + main())

独立进程入口是有先例的,不是新东西。

1.2 底座在设计上就支持多副本

已有完整的跨进程互斥机制:

机制 位置 作用
MySQL GET_LOCK 跨进程咨询锁 db.py:39-68(SCAN_LOCK_NAME) 规则扫描跨进程互斥
FOR UPDATE SKIP LOCKED Outbox 领取 事件不会被两个进程重复领
行锁 + locked_until 租约 AgentRun run 不会被重复执行
行锁 + lease_until/lease_id OffsiteMailCursor 邮件不会被重复收

结论:多开一个进程不会导致重复消费,架构是留了口的。

1.3 场外邮件 Worker 早就在主 Worker 里了

__main__.py:47-52:

offsite_worker = OffsiteMailWorker(settings)
while True:
    worked = await runtime.run_once()
    worked = await offsite_worker.run_once() or worked

并且注释里有一段很重要的历史:

"场外收件 Worker 必须与底座 Worker 同进程同入口:2026-09-11 01:45 的一次批量文件覆盖把这处接线删掉了,导致邮件 Worker 完全不再运行、邮箱无人收取。"

所以"拉邮箱"这件事本来就属于主 Worker 的职责范围。 组员要加的如果是同类需求,第一选择应该是接进来,而不是另起炉灶。


二、真正的隐患:主循环是串行 await(已经存在)

worked = await runtime.run_once()
worked = await offsite_worker.run_once() or worked   # ← 串行

offsite_worker.run_once() 内部要做 IMAP 连接 → 拉邮件 → 存附件 → OCR/大模型识别 → 写业务表,一次批次耗时可能几十秒。 在此期间主循环完全停摆:agent_run 排队、domain_event_outbox 事件堆积、记忆抽取停滞。

这才是"主 worker 要保证运行"的真正威胁——它跟新加几个 worker 无关,现在就存在。


三、先问清一个问题(决定走哪条路)

组员要拉的邮箱,是"另一个邮箱的同类场外基金邮件",还是"运营自己的、和场外基金完全无关的业务邮件"?

这两者的答案完全不同。


四、方案 A:同类邮件、另一个邮箱 —— 扩成多邮箱(推荐)

事实支撑(表结构天生就支持,是代码没用上)

OffsiteMailCursor 有:

UniqueConstraint("mailbox", "folder", name="uk_offsite_mail_cursor")

但 _claim_cursor()(offsite_mail_worker.py:208)写死了单邮箱:

.where(OffsiteMailCursor.mailbox == self.settings.offsite_mailbox, ...)

唯一约束是按 (mailbox, folder) 建的,说明设计时就是打算支持多邮箱的,只是配置层收成了一个。

做法

  1. 配置从 offsite_mailbox: str 扩成多邮箱列表(或加一张邮箱配置表)
  2. OffsiteMailWorker 持有多份配置,或主循环按邮箱逐个构造/调用
  3. _claim_cursor 按当前 mailbox 取游标(现有逻辑不用改,只要 mailbox 是可变的)

评价

项 结论
新增进程 ❌ 不需要
复用现成能力 ✅ 租约、退避、死信、附件存储、识别全复用
部署改动 ✅ 无
残留风险 ⚠️ 仍在同一进程 → 必须配合方案 D

五、方案 B:独立业务 —— 挂到主入口,但改成并发(推荐)

做法

在 __main__.py 里新增一个 worker 实例,但不要用 await 串行调,改为并发:

  • 轻量的:两个 worker 各自 asyncio.create_task,主循环按 worker_poll_seconds 轮询
  • 或:主循环每轮用 asyncio.gather(*(w.run_once() for w in workers)),并给每个 worker 加超时

评价

项 结论
新增进程 ❌ 不需要,部署形态不变
隔离性 ⚠️ 同进程,一个 worker 崩溃会连累(但可用 try/except 兜住)
改动量 小(约 20 行)
附带收益 ✅ 顺带修掉 §2 的串行阻塞隐患

必须遵守

每个 worker 的 run_once() 外面都要 try/except 吞异常并记日志,绝不能让一个 worker 的异常跳出主循环。项目已有这个模式(__main__.py:53-62),照抄即可。


六、方案 C:独立业务 —— 独立进程

事实支撑

risk_scan_scheduler.py 已经是这个形态,直接照抄 serve() / main() 结构。

做法

新建入口(如 python -m app.worker.ops_mail),独立部署、独立守护。

评价

项 结论
隔离性 ✅ 最好:互不影响,一个挂了另一个照跑
运维成本 ⚠️ 多一个进程要守护/重启/监控
主 Worker 安全 ✅ 完全不受影响

硬前提(最重要)

新进程绝不能消费底座的队列(domain_event_outbox / memory_sync_outbox)。

项目真实踩过这个坑,__main__.py:31-44 有完整记录:

本入口曾有一个 MemorySyncOutboxWorker 与 runtime 内那套同时读同一个队列,而两套的 handler 并不相同……同一事件被哪套领到结果不定,等于同一事实在图里有两种说法。

判断标准:新 worker 只拉自己的邮箱、写自己的业务表(或投递到自己的事件类型),与底座队列无交集 → 安全。 一旦它要消费 domain_event_outbox,就必须回到方案 B。


七、方案 D:无论选 A/B/C 都建议做 —— 主循环解耦

把"串行 await 多个 worker"改成"并发 / 独立 task",让慢的 worker 不拖住快的。

这是独立收益项:就算最后决定不加任何新 worker,当前 offsite_worker.run_once() 串行阻塞主循环的问题也值得单独修。


八、决策矩阵

同类邮件、另一个邮箱 独立业务(运营自有)
首选 A + D B + D(同进程)或 C(独立进程)
何时选 C — 邮件量大 / 处理耗时长 / 要求故障隔离
新增进程 否 仅 C
隔离性 低 B 中 / C 高
改动量 小 B 小 / C 中
部署改动 无 C 需新增守护

九、四条红线(均来自项目真实事故)

  1. 不要两套 worker 消费同一个队列 —— MemorySyncOutboxWorker 事故,导致同一事实在图里有两种说法。
  2. 新 worker 必须自带租约或幂等 —— 不能假设"只会有一个实例"。参考 OffsiteMailCursor 的 lease_until + lease_id + 心跳续租。
  3. 异常必须被吞掉并记日志 —— 不能让单个 worker 的失败杀掉主循环(__main__.py:53-62 已是范例)。
  4. 独立进程必须独立可观测 —— 至少要有独立日志前缀 + 健康检查,否则"它挂了"这件事无人知晓(历史上场外 Worker 被误删接线后长期无人发现)。

十、补充:IDLE 模式的实现要点(给组员)

IMAP IDLE(RFC 2177)的标准流程:

连接 → 登录 → SELECT 收件箱
   └─ 发 IDLE 命令 → 阻塞等待(最长 120s)
        ├─ 收到服务器推送(如 `* N EXISTS`)→ 发 DONE → FETCH 拉新邮件 → 处理
        └─ 120s 超时无推送 → 发 DONE → 重新发 IDLE(保活)

四个必须注意的坑

1. 必须用 asyncio.to_thread 包住 IDLE(最严重)

IDLE 是同步阻塞的(阻塞 120 秒等推送)。如果在协程里直接调用,会冻结整个事件循环 120 秒——同进程的 Agent 执行、outbox 消费全部停摆。

项目里已有正确范例,照抄:offsite_mail_worker.py:290

messages = await asyncio.to_thread(...)

2. 120 秒要主动退出并重新 IDLE

不要指望一次 IDLE 长驻。服务器和中间网络设备(NAT/防火墙)都会掐断长时间静默的连接。每 120 秒退出重发是正确做法,也符合 RFC 2177 的建议(客户端应定期退出并重发 IDLE,规范建议上限 29 分钟)。

3. 断线重连 + 退避

IDLE 长连接必然会遇到:网络抖动、服务器重启、超时踢下线。需要:

  • 捕获连接异常 → 重新登录 → 重新 SELECT → 重新 IDLE
  • 重连间隔加指数退避(参考 runtime.py:901-904 的既有实现),避免服务器不可用时疯狂重连

4. 优雅退出与资源释放

独立进程必须处理 SIGTERM,否则重启部署时 IMAP 连接与数据库引擎泄漏。照抄 risk_scan_scheduler.py:168-169:

finally:
    await engine.dispose()

IDLE 模式下仍需遵守的红线

  • 单实例假设是危险的:IDLE 长连接看似"天然单实例",但一旦将来有人多开一个进程做高可用,两个实例同时 IDLE 同一邮箱会重复收件。建议现在就加一道互斥(直接复用 OffsiteMailCursor 的 lease_until + lease_id 思路,或用一个 MySQL GET_LOCK),成本很低。
  • 不碰 domain_event_outbox:这条不变。如果运营 Worker 处理完邮件需要触发底座流程,回来找我,改走同进程方案。

十一、确认记录与后续约束

11.1 四项前置全部已确认

# 问题 结论
1 同类还是独立业务 运营自己的独立业务
2 邮件量与耗时 很少
3 能否接受同进程 要求异步;且 IDLE 长连接后同进程轮询方案已不适用
4 是否写 domain_event_outbox 否,只写运营自己的表 ✅

11.2 后续若需求变更,必须回来重新评估

以下任一情况出现,独立进程方案不再成立,需要重新设计:

  • ⚠️ 改为需要触发底座流程(生成 Agent 任务 / 触发风控预警 / 触发记忆抽取)→ 必须写 domain_event_outbox → 回到同进程方案
  • ⚠️ 改为需要消费 domain_event_outbox / memory_sync_outbox 的事件 → 两套消费者抢同一队列,重演 MemorySyncOutboxWorker 事故
  • ⚠️ 邮件量增长到需要多实例 → 必须先补互斥(§10 红线),否则重复收件

11.3 验收清单(上线前逐条确认)

  • IDLE 调用已包 asyncio.to_thread(否则冻结事件循环)
  • 120 秒后主动 DONE 并重新 IDLE(保活)
  • 断线重连带指数退避(不疯狂重连)
  • finally: await engine.dispose()(优雅退出)
  • 单实例互斥已加(防将来多开重复收件)
  • 确认未引用 domain_event_outbox / memory_sync_outbox
  • 独立进程有独立日志前缀 + 健康检查(否则挂了无人知晓——场外 Worker 被误删接线的教训)
  • 主 Worker 与该进程同时运行时,主 Worker 的 outbox 消费速率无明显下降