init
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m3s

This commit is contained in:
Break
2026-06-16 15:15:50 +08:00
parent 84ab3bad99
commit 1f634eb465
21 changed files with 2776 additions and 41 deletions

View File

@@ -0,0 +1,84 @@
## 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` 两个现有场景到新接口,补全筛选条件支持
- 对外 API4 个接口)签名不变,无数据库迁移
**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 阶段各执行一次额外查询,对于需要扫全表才能确定列数的动态列场景有一定开销,但因为是异步任务可以接受。
### 决策 3finalize 合并分片 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 分片)。
### 决策 4ExportParams 结构统一传递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 列动态列)?