Files

15 KiB
Raw Permalink Blame History

线上多人互相拖慢:源码排查与隔离复现

排查日期:2026-10-22。范围:当前业务源码、部署脚本和无网络的隔离探针。 未连接线上主机、模型或数据库,以下数值不是线上时延或容量结论。原始排查完成后,已按 本次授权落实下述源码优化;尚未部署到线上,未修改线上配置或数据。

已落实的优化

  1. 同步 IO 隔离:产物递归扫描、技能 ZIP 预览、内容探测和文本写入使用独立有界 executor(4 工作者、最多 8 个已提交作业),与登录密码校验的默认线程池隔离。 JSONL/本地事件 spool 使用另一组 2 工作者、4 个已提交作业;取消调用方不会提前归还 尚在执行的磁盘作业名额。权限检查仍在扫描/读写之前执行。
  2. 完成 run 内存回收:实际 task 完成后默认保留 300 秒,每 10 秒最多核对 200 项。 只有终态、错误和最终 metadata/kwargs 已在 RunStore 核对成功才移除实时记录;有未完成 task、存储失败或快照不一致则保留。不会删 SQL run 行、checkpoint、聊天记录或 SSE 缓冲。共享用户仍通过已授权的线程查询持久化 run;所有跨线程 run id 继续校验。 旧 run 即使比新 run 更晚收尾,也不能因新 run 已回收而重新覆盖线程状态。
  3. 事件转存磁盘:原默认 memory 后端工厂改用本地 SQLite spool,在 runtime_home()/run-events/events.sqlite3 保存完整内容,以事务分配递增 seq 并批量提交, 查询按页读取,不在内存永久堆积完成事件。没有自动 TTL 删除或消息截断;独立 MemoryRunEventStore 类保留供测试使用。显式 db 仍用业务 SQL/MySQL,jsonl 保留旧 目录/文件格式;没有迁移或删除这些历史存储。spool 及 WAL 需放在可写持久化本地卷。
  4. 公平调度:后台 subagent 默认全局 3、每用户 2;后台会商默认全局 4、每用户 2。 按用户轮转,排队不占执行工作者;队列有全局和每用户上限,满时明确报错。 subagent 超时包含排队时长。取消/超时后的实际协程必须结束才释放名额,避免旧任务 尚在写 checkpoint 时启动过多新任务。身份 context 继续传入执行线程和事件循环。
  5. 会商延迟 checkpoint:前台/后台内部建席位、总控线程只写业务元数据;init 不再 通过 HTTP 给空线程追加席位消息。清单随真实首轮输入一起保存;旧客户端未传 seat_meta 时仍从 thread 映射构造真实席位 ID。后续广播和历史 checkpoint 保留。

配置见 README 的 runtime_contention 示例,均为可选字段,重启生效。原 ROUNDTABLE_BACKGROUND_SLOTS_PER_USER 环境变量继续覆盖每用户会商额度(不超过全局额度)。 这些额度按 Gateway 进程计算;当前实时 run/bridge 仍在进程内,不能据此直接增 worker。

测试覆盖:真实 HTTP/SQL 权限与共享访问、run 回收后的历史和终态重连、Last-Event-ID 回放、 旧 run 收尾隔离、慢扫描期间另一用户创建线程、并发事件序号/分页/重启完整性、批次回滚与 磁盘失败后的 journal 重试、A 用户积压时 B 用户准入、排队超时/取消、真实子任务取消确认、 后台会商取消确认、会商 init 无 checkpoint 依赖和连续两轮消息完整性。使用本地 SQLite、 内存 saver 与受控协程,无线上 PG/MySQL/模型实测;不据此承诺线上时延或并发容量。

最终验证(2026-10-22):后端 747 passed / 1 skipped;前端 pnpm test:thread-history 35 passed;35 个新增/改动 Python 文件 Ruff 全部通过,改动的 harness 模块未引入 app.* 依赖,git diff --check 通过。后端测试入口:

packages/harness/tests/
tests/test_position_roundtable_sessions.py
tests/test_roundtable_role_boundary.py
tests/test_roundtable_model_fallback.py
tests/test_roundtable_seat_modes.py
tests/test_roundtable_gather_phase.py
tests/test_model_concurrency.py
tests/test_account_authentication.py
tests/test_artifact_preview.py
tests/test_artifact_library.py

结论

2026-10-23 已在最新 master 上整合:保留任务深链独立卡片存储、task/agent/action 投影索引、长历史 slim_display 和前端检索进度分轮隔离。整合后扩大回归到后端 780 passed / 1 skipped(额外覆盖任务深链接口、详情分区、一键清空与索引迁移), 前端历史/恢复 37 passed,改动 Python 文件 Ruff 与 git diff --check 均通过。 前端全量 typecheck 仍报告既有的 81 项错误,本次修改的 hooks/background-history/ api-client 文件无类型错误;该检查未宣称全量通过。

不能只归因于 PG 数据量。优化前还存在事件循环上的同步文件扫描、未回收的运行记录和 内存事件、跨用户共享的子智能体队列、业务连接池与线程池竞争等路径。 业务表使用 MySQL 只隔离了数据库来源,没有隔离 Gateway 的 CPU、内存、线程池与业务连接池。

单 worker 本身不意味着不能并发;这里的风险是慢同步工作在唯一事件循环上执行。 现有 RunManager/MemoryStreamBridge 的实时控制状态在进程内,直接增加 worker 又会导致 重连、取消和 join 请求找不到原进程中的运行,不能当作当前架构的安全扩容方案。

优化前已确认的代码路径

优先级 位置与现象 对其他人的影响 适用边界
P0 routers/artifacts.py::list_artifacts 在 async 路由内直接调用同步 collect_outputs_listing,递归扫描、resolve/stat、排序 扫描期间同一事件循环无法及时处理其他请求和 SSE 同步调用已复现;线上时长取决于文件量、磁盘和挂载
P0 runtime/runs/manager.py 定义了 cleanup,但当前应用调用链没有调用;_runs 保留完成记录、kwargs 和 task 引用 内存随历史运行累积;创建/限流/列表仍遍历全部记录,在事件循环上消耗 CPU 当前代码路径确定;线上累计数/RSS 未取得
P0 默认 run_events.backend=memory 的 MemoryRunEventStore 仅 append,没有自动 TTL/总量预算 工具输出、消息和 trace 长期积累,增加内存压力及扫描/GC 成本 线上若已使用 db/jsonl,此项不按默认内存后端判断
P0 subagents/executor.py 全进程 _scheduler_pool(max_workers=3),调度线程等待执行 future 完成才释放 一名用户占用 3 个后台子任务时,另一人的子任务排队;排队尚未进入执行 timeout 普通后台 subagent 路径;不能推广为所有模型调用最多 3 路
P1 登录与 run、权限、菜单、任务等仓储注入同一个业务 SQLAlchemy session factory 慢查询/锁等待占满 MySQL 池后,登录和菜单也要等连接;移走 PG 不能避免 连接池共享确定,线上是否已耗尽要观测 checkout/慢查询
P1 密码校验、文档转换和多个文件/扫描任务都用默认 asyncio.to_thread executor 长转换/扫描占满工作线程时,另一个人的密码验证或工具 IO 排队 YAML 中 100 是同一总池上限,不是每人 100;AnyIO 另有自己的池
P1 当前安装的 PG saver 实例在 _cursor 取连接前持有实例锁;SQLite saver 同样串行 某条慢 checkpoint 查询/写入使其他会话状态读写排队 上一轮无 PG 网络测试已验证;线上必须核对实际 saver/wheel 版本
P1 会商内部 _create_thread_direct 和后台 _create_thread 仍直接写 empty_checkpoint 批量建席位仍争用 saver;上一轮普通 POST 的延迟初始化没有覆盖这两个入口 见 routers/multi_agent.py 与 roundtable_inprocess_gateway.py
P1 worker 同步递归 serialize,services.format_sse 在事件循环中同步 JSON 编码 大聊天状态/工具输出形成 CPU 和带宽尖峰,同进程其他请求延迟 不等于每个 token 都重发全量历史,取决于实际 stream mode 与事件大小
P1 模型总量限流默认关闭、每用户额度默认 0;后台会商只有每用户 2 槽的局部限制 多人/多席位同时压向同一模型服务,没有为前台保留容量,上游排队和重试会放大等待 线上管理员设置可能覆盖默认;每用户会商限制不是全站后台上限

RunManager 的锁内没有等待数据库 IO,不能把它描述成“持全局锁等待 MySQL”;其已确认问题是 完成记录没有接入回收,以及锁内同步全表扫描随累计运行数增长。

MemoryStreamBridge 每个 run 的事件条数有 256 默认上限,结束 60 秒后也会 cleanup, 不能与无限累计的 MemoryRunEventStore 混淆。但它没有按字节限制,包含大 values 的活动运行 仍可能占用大量内存;连接复用/replay 也会再次编码事件。

其他需要对照线上负载的放大因素

  • MySQL metadata SQL 条件下推后仍可能没有适用索引。共享 taskId/agent_id/actionId 的 JSON 条件、标题 contains 和排序应检查真实 EXPLAIN;不能把“只发一条 SELECT”视为“查询恒快”。
  • services._maybe_sync_legacy_agent_records 的 TTL 为 3600 秒。过期请求需要等待同一个 同步任务,期间按智能体目录读文件并逐个调用业务库;冷启动/整点附近的突刺应核对 [start-run-timing] legacy-agent sync。正常 TTL 内已有快路径,不能说每次发送都会全扫。
  • load_system_settings() 没有读缓存,直接读 JSON 并校验;模型准入与多个中间件会调用它。 文件放在慢共享盘时会增加同步 IO;若文件小且本机缓存命中,通常不是首要瓶颈。
  • 产物预览仅在启用 API 加载、没有命中 write_file 流式路径且运行中时每秒刷新一次,loader 获取完整响应文本。它不是每秒全目录列表扫描。大报告多窗口预览可能放大磁盘/网络/前端 内存压力;通用文件下载已用 FileResponse,不应误报为所有下载都在路由内整文件读取。
  • 模型服务自身的并发槽、GPU 显存、长上下文、重试、知识检索/外部技能接口需要独立观测。 网关请求已经发出而首 token 很晚,不能自动归因于数据库。
  • 当前仓库没有足以代表该线上入口的 Nginx/Ingress 配置,不能判断线上 buffering、连接数、 超时、HTTP 版本或负载均衡是否正确。开发启动器含 reload,正式启动器默认单 worker; 应核对线上实际启动命令,不能从源码脚本推断线上一定在热重载模式。
  • 模板同时存在 SQLite 和 MySQL+PG 配置,其中一个 postgres 模板 use_postgres=false。 必须以线上配置路径、环境覆盖和启动日志确认实际后端,不能只看 type=postgres。

隔离探针证据

探针仅调用实际代码中的本地对象、临时目录和 SQLite 测试池,没有网络/模型调用。 机器、操作系统、文件缓存等都会影响绝对数值,仅用来验证串扰机制。

场景 观察
真实产物列表函数,临时目录 1,200 文件 路由耗时 1,640ms,同事件循环的无关 2ms timer 迟到约 1,638ms
同一函数/目录,作为对照放入 to_thread(未修改生产代码) 扫描约 1,344ms,timer 迟到约 14ms;工作没有消失,但释放了事件循环
实际 format_sse,构造约 6.27MiB values 编码约 47ms,timer 迟到约 45ms
RunManager 含 0 / 10,000 / 50,000 个已完成记录 全部仍被保留;启用两种准入计数时,新 run 准入中位约 0.016 / 3.195 / 15.376ms
MemoryRunEventStore 写入 1,000 个唯一 16KiB 假 trace 1,000 个全部保留;tracemalloc 约增加 16.13MiB,没有自动过期
真实 subagent execute_async + 受控 future 4 个独立任务只启动 3 个,第 4 个保持 pending;释放 1 个后才启动第 4 个
共享 SQLAlchemy 池测试,2 个连接均被占用 无关第三个连接等待超时;使用 SQLite QueuePool 验证池行为,不是线上 MySQL 测量

本次机器可复跑入口及原始结果(未提交到 Git,不包含凭证或用户消息):

  • 根目录 .runtime/concurrency_audit_20261022.py
  • 根目录 .runtime/concurrency_subagents_20261022.py
  • 根目录 .runtime/concurrency_audit_20261022.json

线上最小观测集合

用同一北京时间窗口关联以下指标,再确定哪个是此次线上事件的主因:

  1. Gateway 进程数、代码版本、实际数据库/checkpointer 类型,CPU/RSS/可用内存/容器重启数。
  2. 事件循环 lag 与登录、菜单、普通小查询、run 创建的 P50/P95/P99。
  3. MySQL 活跃连接、连接池借用等待/超时、慢查询和锁等待;PG 另记录 checkpoint 的锁等待、 借连接时间、SQL 时间、响应体大小。两层池分开,不只看数据库 CPU。
  4. 运行中的前台/后台/子智能体数量、每用户占用、排队时间、executor 的排队/执行时间。
  5. 模型请求发送时间、首 token 时间、完整生成时间、429/超时/重试和同一模型服务总请求数。
  6. SSE 帧大小/编码耗时、产物扫描文件数/耗时、索引/入库/会商后台作业与故障窗口的重合度。

若登录和菜单也慢,先查事件循环、RSS/CPU、共享 MySQL 池和密码线程池;若这些正常而只有 出字慢,优先区分模型上游、checkpoint 与子任务排队。服务运行越久越慢且重启缓解时, 优先核对完成 run/内存事件数量,但重启缓解本身不是泄漏的充分证据。

建议处理顺序

  1. 先切断已确认的串扰:把产物同步扫描和 ZIP 读取等重 IO 移离事件循环,并给扫描并发 单独预算;补完成 run 回收和事件存储/保留策略。回收前保证持久化查询、取消和断线恢复兼容, 不删除仍在运行的任务或未持久化的数据。
  2. 统一准入和公平调度:后台任务全站额度 + 每用户额度 + 可观测队列;子智能体名额可配、 公平取用,排队超时从入队计算;为前台保留资源。会商内部创建统一采用验证过的延迟初始化。
  3. 隔离资源:认证轻任务与文档转换/扫描分 executor,业务轻查询与后台重查询设独立预算, 保持 MySQL 总连接数有界;按实际 EXPLAIN 补索引,验证 saver 的并发实现。
  4. 最后做容量扩展:实时流传输和运行控制共享化后,再验证多 worker/多实例;结合真实 模型容量做分级负载测试。单纯扩大线程池、连接池或 worker 数,不能消除上述等待链。