# 需求:给「订单导出」加一个可恢复的后台任务 ## 背景与约束 现有 `POST /api/orders/export` 是同步接口:请求进来后直接查库、拼 CSV、一次性返回。 单次最多导出 5000 行,超过就超时。运营侧现在需要一次导出 30 天全量订单(约 40 万行)。 硬性约束(不可协商): 1. **不能改前端调用协议**:前端仍然调 `POST /api/orders/export`,仍然拿到 `{taskId}`; 2. **数据库只读**:这个服务没有写权限,任务状态只能存 Redis; 3. **可恢复**:进程重启后,未完成的任务要能接着跑,不能从头再来; 4. **幂等**:同一个 `requestId` 重复提交只能产生一个任务; 5. **可观测**:每个任务要有进度(已处理行数 / 总行数)、当前阶段、失败原因。 ## 期望的接口行为 ```http POST /api/orders/export Content-Type: application/json { "requestId": " 由前端生成的 uuid,用于幂等 ", "from": "2026-09-01", "to": "2026-09-30", "format": "csv" | "xlsx" } ``` 响应: ```json { "taskId": "exp_01J8Z4K2", "state": "queued", "progressUrl": "/api/orders/export/exp_01J8Z4K2" } ``` 进度查询: ```json { "taskId": "exp_01J8Z4K2", "state": "running", // queued | running | succeeded | failed | cancelled "phase": "fetch", // fetch | render | upload "processed": 128430, "total": 412000, "startedAt": "2026-10-17T08:12:03Z", "finishedAt": null, "error": null, "downloadUrl": null } ``` ## 实现要求 ### 1. 任务拆分 - 按 **时间窗口** 分片:每片 1 天,片内再按主键区间分页(每页 2000 行); - 分片之间**可以并行**,但单个分片内必须顺序推进(保证游标单调); - 每个分片的进度单独记,任务总进度 = 已完成分片 + 当前分片内偏移。 ### 2. 状态机 ``` queued ──start──> running ──┬──> succeeded ├──> failed (可重试,最多 3 次,指数退避) └──> cancelled (用户主动取消,正在写盘的片要写完再停) ``` ⚠️ 状态迁移必须是**原子的**:用 Redis 的 `WATCH/MULTI` 或 Lua 脚本,不要先读再写。 ### 3. 断点恢复 - 每个分片完成后,把 `{shardId, cursor, rowsWritten}` 写进 Redis Hash(TTL 7 天); - 进程启动时扫 `export:running:*`,把 `running` 且心跳超过 60s 的任务重新入队; - 重新入队时**从 cursor 继续**,已写出的行不能重复。CSV 用「追加写临时文件 + 最后统一 改后缀」;xlsx 不能追加,改为「每个分片写一个 sheet 片段,最后合并」。 ### 4. 失败与重试 | 失败类型 | 处理 | |---|---| | 数据库超时 | 重试 3 次,指数退避 1s / 4s / 16s | | 内存不足(单页过大) | 把页大小减半后重试,最小 200 行 | | 对象存储上传失败 | 换一个分片重传,不清空已上传部分 | | 其他未分类异常 | 标记 failed,保留现场(cursor 不清),允许人工重跑 | ### 5. 可观测性 - 指标:`export_task_total{state}`、`export_rows_processed_total`、 `export_task_duration_seconds{format}`(直方图); - 日志:每个分片开始/结束各一条,**必须带 `taskId` 与 `shardId`**,不要打整行数据; - 追踪:任务入队时生成 `traceId`,后续所有分片沿用(便于在链路系统里串起来)。 ## 验收标准 - [ ] 40 万行导出在 30 分钟内完成(单实例,4 个并行分片); - [ ] 导出过程中 `kill -9` 进程,重启后任务自动继续,最终文件行数与不含重复、不缺行; - [ ] 同一 `requestId` 并发提交 10 次,只产生 1 个任务、1 个文件; - [ ] `state=failed` 的任务,错误信息能直接看出是哪个阶段、哪一页失败; - [ ] 取消一个 running 任务,10 秒内状态变 `cancelled` 且不再产生新分片; - [ ] 以上每条都有对应的自动化测试(不需要真的导 40 万行,用小数据集 + 人为注入失败)。 ## 不要做的事 - ❌ 不要引入新的消息队列(Kafka / RabbitMQ)—— 单实例用 Redis List 足够; - ❌ 不要把状态存在进程内存里(重启即丢,违反约束 3); - ❌ 不要为了"简单"把 40 万行一次性读进内存(会 OOM,页大小上限 2000 行); - ❌ 不要改 `POST /api/orders/export` 的请求/响应结构(约束 1); - ❌ 不要在一次事务里读多页(长事务会锁表)。 ## 交付物 1. 设计说明(含状态机图、分片策略、Redis key 设计),一页以内; 2. 实现 PR(拆成 3 个提交:状态机与存储 / 分片与断点恢复 / 可观测性与测试); 3. 一份「如何人工重跑失败任务」的运维文档。 --- 请先给设计说明,我确认后再开始写代码。设计里请明确回答: - 分片并行的上限怎么定(CPU 核数?数据库连接池大小?); - 对象存储上传是每片一次还是最后合并一次,各自代价; - 若某一片反复失败,是整任务失败还是跳过该片继续(并说明理由)。