Files
junhong_cmp_fiber/openspec/changes/refactor-export-datasource/design.md
Break 1f634eb465
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m3s
init
2026-06-16 15:15:50 +08:00

7.1 KiB
Raw Blame History

Context

现有导出系统在 2026-04-30 上线核心架构是三段式异步任务dispatch → shard → finalize+ 场景策略插件SceneStrategy 接口)。系统整体结构健全,但存在三个实现层面的缺陷:

  1. query_json 字段在 service.CreateTask 中被序列化存入数据库,但 DeviceSceneStrategyIotCardSceneStrategyNextShardBoundaryQueryRows 均不读取该字段,导致用户传入的所有筛选条件被静默丢弃。
  2. export_finalize.go 在 finalize 阶段重新调用各分片的 QueryRows 将数据全量加载到内存后再生成汇总文件shard 阶段上传的 OSS 分片文件未被复用,等同于数据查了两遍。
  3. SceneStrategy 接口要求开发者手动实现 NextShardBoundaryKeyset 游标分页),新增场景需写约 80 行模板代码,且当 SQL 中含有 JOIN 时游标基于主表 ID 的假设可能被破坏(一对多 JOIN 导致结果行数与主表行数不一致)。

Goals / Non-Goals

Goals:

  • 引入 DataSource 接口,将分片游标逻辑从场景实现中剥离,开发者只需实现 Count / Headers / Fetch
  • 支持动态列:Headers 接收运行时参数,可在执行时决定列数(解决设备绑多卡动态列问题)
  • query_json 中的筛选条件通过 ExportParams 传入每次 Fetch 调用,让过滤条件真正生效
  • finalize 阶段改为合并 shard OSS 分片文件,消除重查数据库
  • 迁移 deviceiot_card 两个现有场景到新接口,补全筛选条件支持
  • 对外 API4 个接口)签名不变,无数据库迁移

Non-Goals:

  • 不做用户侧自定义导出(用户在前端自由组合列/表)
  • 不支持跨租户数据合并导出
  • 不引入 YAML/DSL 配置驱动
  • 不删除旧 SceneStrategy 接口(保留作复杂场景逃生通道)

Decisions

决策 1分片改为 offset/limit 驱动,而非 Keyset 游标

选择:框架在 dispatch 阶段通过 Count 获取总行数,按 ExportDefaultShardSize 切分出 (offset, limit) 对,每个分片只记录 offsetlimitshard 阶段调用 Fetch(ctx, params, offset, limit)

放弃:保留 Keyset 游标(当前实现),要求 DataSource 实现 NextShardBoundary

原因Keyset 游标强依赖"主表有单调递增 ID 且查询不含破坏游标的 JOIN"这一假设。实际业务查询IoT 卡+套餐 LEFT JOIN、订单多表关联中这个假设很容易不成立。offset/limit 对任意 SQL 均有效,代价是在超大数据量下存在 offset 深翻页性能问题——但导出任务本身是后台异步作业,对延迟不敏感,这个代价可接受。

数据库侧应对Fetch 实现时在 SQL 末尾加 ORDER BY id ASC 保证结果稳定,避免不同分片间数据重复或遗漏。

决策 2动态列由 Headers 在运行时决定,框架在 dispatch 阶段一次性固定

选择dispatch 阶段调用 DataSource.Headers(ctx, params) 一次,将结果序列化存入主任务的 query_json 扩展字段(新增 resolved_headers 子键shard 和 finalize 阶段均从该字段读取,不再重新调用 Headers

原因:动态列场景(如设备绑多卡)的列数依赖全量数据扫描,如果每个 shard 独立调用 Headers 可能得到不同列数,导致各分片 CSV 列不对齐无法合并。在 dispatch 阶段固定一次,保证所有分片使用同一套表头。

代价CountHeaders 会在 dispatch 阶段各执行一次额外查询,对于需要扫全表才能确定列数的动态列场景有一定开销,但因为是异步任务可以接受。

决策 3finalize 合并分片 CSV 文件而非重查数据库

选择shard 阶段写 CSV 时不写表头仅数据行finalize 阶段按 shard_no 升序从 OSS 下载各分片文件,顺序追加到本地临时文件,最后在文件头部写入表头,上传合并后的完整文件。

放弃:当前 finalize 重新调用 QueryRows 全量重查后在内存中合并。

原因消除重复数据库查询shard 产物得到复用。内存侧也从"全量数据 in-memory"变为"流式追加写文件",对大数据量更友好。

注意点XLSX 格式的分片文件无法直接二进制拼接(每个 xlsx 是独立 zip 包),因此 XLSX 格式的 finalize 仍需重查数据库(或先把各分片作为 csv 合并后再转 xlsx。为简化实现XLSX 格式的 finalize 走降级路径:各分片 csv 合并后用 excelize 流式写出 xlsxshard 阶段对 XLSX 任务同样生成 csv 分片(不生成 xlsx 分片)。

决策 4ExportParams 结构统一传递query_json 在 Service 层解析

选择service.GetTaskDetailWorker 调用路径)负责将 task.QueryJSON 解析为 map[string]any,连同权限快照一起构造 ExportParams,作为参数传入所有 DataSource 方法。

原因解析逻辑集中在一处DataSource 实现无需关心 JSON 解析,直接消费类型化字段。

决策 5保留 SceneStrategy 接口作为逃生通道

对于无法用 DataSource 表达的场景(如多阶段聚合、需要跨库合并的报表),保留原 SceneStrategy 接口。Registry 同时支持两种注册方式,框架在执行时通过类型断言判断走哪条路径。

Risks / Trade-offs

风险 缓解措施
offset 深翻页在千万级数据下性能退化 导出任务为异步后台作业P99 无硬性要求;若实测超时可对 DataSource 实现添加索引提示
XLSX 格式降级路径导致 shard 文件与 CSV 格式行为不一致 在文档和注释中明确说明XLSX 的 shard 文件统一命名为 .csv.shard 加以区分
Headers 在 dispatch 阶段执行全表扫描(动态列场景) DataSource 实现者可在 Headers 中只做轻量采样(如 LIMIT 1而非全量扫描来决定动态列数
现有 device / iot_card 场景迁移引入回归 两个场景的 Fetch 输出与原 QueryRows 输出做 diff 验证MCP 手动对比数据样本

Migration Plan

  1. 新接口与旧接口并存,不删除 SceneStrategy
  2. dispatch/shard/finalize 三段 worker 修改为优先检测 DataSource 接口,降级检测 SceneStrategy
  3. deviceiot_card 场景实现新 DataSource,原 SceneStrategy 实现暂时保留,经线上验证后在下一个 change 中删除
  4. shard 写文件逻辑:新建任务走新路径(不含表头的 csv 分片);存量任务(已在执行中)因为 finalize 会检查 shard 文件是否可下载,若下载失败降级为重查数据库(加兜底逻辑)
  5. 无数据库迁移,无 API 变更,可随时回滚(回滚只需重新部署旧版 worker

Open Questions

  • 动态列场景中dispatch 阶段固定 resolved_headers 后,若同一批次中不同分片的数据导致"实际需要的列数"不同(如某分片的设备绑了 5 张卡,另一分片最多 3 张),当前方案以 dispatch 阶段的扫描结果为准。这个"扫全表确定最大列数"的代价是否可接受,还是应该约定上限(如最多 N 列动态列)?