7.1 KiB
Context
现有导出系统在 2026-04-30 上线,核心架构是三段式异步任务(dispatch → shard → finalize)+ 场景策略插件(SceneStrategy 接口)。系统整体结构健全,但存在三个实现层面的缺陷:
query_json字段在service.CreateTask中被序列化存入数据库,但DeviceSceneStrategy和IotCardSceneStrategy的NextShardBoundary与QueryRows均不读取该字段,导致用户传入的所有筛选条件被静默丢弃。export_finalize.go在 finalize 阶段重新调用各分片的QueryRows将数据全量加载到内存后再生成汇总文件,shard 阶段上传的 OSS 分片文件未被复用,等同于数据查了两遍。SceneStrategy接口要求开发者手动实现NextShardBoundary(Keyset 游标分页),新增场景需写约 80 行模板代码,且当 SQL 中含有 JOIN 时游标基于主表 ID 的假设可能被破坏(一对多 JOIN 导致结果行数与主表行数不一致)。
Goals / Non-Goals
Goals:
- 引入
DataSource接口,将分片游标逻辑从场景实现中剥离,开发者只需实现Count / Headers / Fetch - 支持动态列:
Headers接收运行时参数,可在执行时决定列数(解决设备绑多卡动态列问题) query_json中的筛选条件通过ExportParams传入每次Fetch调用,让过滤条件真正生效- finalize 阶段改为合并 shard OSS 分片文件,消除重查数据库
- 迁移
device和iot_card两个现有场景到新接口,补全筛选条件支持 - 对外 API(4 个接口)签名不变,无数据库迁移
Non-Goals:
- 不做用户侧自定义导出(用户在前端自由组合列/表)
- 不支持跨租户数据合并导出
- 不引入 YAML/DSL 配置驱动
- 不删除旧
SceneStrategy接口(保留作复杂场景逃生通道)
Decisions
决策 1:分片改为 offset/limit 驱动,而非 Keyset 游标
选择:框架在 dispatch 阶段通过 Count 获取总行数,按 ExportDefaultShardSize 切分出 (offset, limit) 对,每个分片只记录 offset 和 limit,shard 阶段调用 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 阶段固定一次,保证所有分片使用同一套表头。
代价:Count 和 Headers 会在 dispatch 阶段各执行一次额外查询,对于需要扫全表才能确定列数的动态列场景有一定开销,但因为是异步任务可以接受。
决策 3:finalize 合并分片 CSV 文件而非重查数据库
选择:shard 阶段写 CSV 时不写表头(仅数据行),finalize 阶段按 shard_no 升序从 OSS 下载各分片文件,顺序追加到本地临时文件,最后在文件头部写入表头,上传合并后的完整文件。
放弃:当前 finalize 重新调用 QueryRows 全量重查后在内存中合并。
原因:消除重复数据库查询,shard 产物得到复用。内存侧也从"全量数据 in-memory"变为"流式追加写文件",对大数据量更友好。
注意点:XLSX 格式的分片文件无法直接二进制拼接(每个 xlsx 是独立 zip 包),因此 XLSX 格式的 finalize 仍需重查数据库(或先把各分片作为 csv 合并后再转 xlsx)。为简化实现,XLSX 格式的 finalize 走降级路径:各分片 csv 合并后用 excelize 流式写出 xlsx,shard 阶段对 XLSX 任务同样生成 csv 分片(不生成 xlsx 分片)。
决策 4:ExportParams 结构统一传递,query_json 在 Service 层解析
选择:service.GetTaskDetail(Worker 调用路径)负责将 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
- 新接口与旧接口并存,不删除
SceneStrategy - dispatch/shard/finalize 三段 worker 修改为优先检测
DataSource接口,降级检测SceneStrategy device和iot_card场景实现新DataSource,原SceneStrategy实现暂时保留,经线上验证后在下一个 change 中删除- shard 写文件逻辑:新建任务走新路径(不含表头的 csv 分片);存量任务(已在执行中)因为 finalize 会检查 shard 文件是否可下载,若下载失败降级为重查数据库(加兜底逻辑)
- 无数据库迁移,无 API 变更,可随时回滚(回滚只需重新部署旧版 worker)
Open Questions
- 动态列场景中,dispatch 阶段固定
resolved_headers后,若同一批次中不同分片的数据导致"实际需要的列数"不同(如某分片的设备绑了 5 张卡,另一分片最多 3 张),当前方案以 dispatch 阶段的扫描结果为准。这个"扫全表确定最大列数"的代价是否可接受,还是应该约定上限(如最多 N 列动态列)?