## Context 现有导出系统在 2026-04-30 上线,核心架构是三段式异步任务(dispatch → shard → finalize)+ 场景策略插件(SceneStrategy 接口)。系统整体结构健全,但存在三个实现层面的缺陷: 1. `query_json` 字段在 `service.CreateTask` 中被序列化存入数据库,但 `DeviceSceneStrategy` 和 `IotCardSceneStrategy` 的 `NextShardBoundary` 与 `QueryRows` 均不读取该字段,导致用户传入的所有筛选条件被静默丢弃。 2. `export_finalize.go` 在 finalize 阶段重新调用各分片的 `QueryRows` 将数据全量加载到内存后再生成汇总文件,shard 阶段上传的 OSS 分片文件未被复用,等同于数据查了两遍。 3. `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 1. 新接口与旧接口并存,不删除 `SceneStrategy` 2. dispatch/shard/finalize 三段 worker 修改为优先检测 `DataSource` 接口,降级检测 `SceneStrategy` 3. `device` 和 `iot_card` 场景实现新 `DataSource`,原 `SceneStrategy` 实现暂时保留,经线上验证后在下一个 change 中删除 4. shard 写文件逻辑:新建任务走新路径(不含表头的 csv 分片);存量任务(已在执行中)因为 finalize 会检查 shard 文件是否可下载,若下载失败降级为重查数据库(加兜底逻辑) 5. 无数据库迁移,无 API 变更,可随时回滚(回滚只需重新部署旧版 worker) ## Open Questions - 动态列场景中,dispatch 阶段固定 `resolved_headers` 后,若同一批次中不同分片的数据导致"实际需要的列数"不同(如某分片的设备绑了 5 张卡,另一分片最多 3 张),当前方案以 dispatch 阶段的扫描结果为准。这个"扫全表确定最大列数"的代价是否可接受,还是应该约定上限(如最多 N 列动态列)?