Files
baiyue-firstleg/docs/公共导出中心RocketMQ架构设计.md

1395 lines
102 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 公共导出中心 RocketMQ 架构设计(第四轮快速评审定稿)
> 状态:设计已实施并通过本地自动化回归,待完成 §19.2/§19.3 测试环境真实验收
> 日期:2026-08-03
> 修订说明:第一版曾标记“定稿”,经评审发现 3 个阻断级可靠性问题(Reconciler 导致排队任务饥饿、DEAD 策略自相矛盾且可无限循环、`brokerIP1` 环境变量不会被替换)及 7 项边界缺陷,均已在本稿修正。核心修正是确立 §3 原则 5:**消息补投不产生新版本**。
> 第四轮快速评审:确认 8C32G 环境以并发 4 作为压测起始值;补齐并发环境变量透传,并将三个调度开关改为默认关闭,避免首次部署绕过分阶段启用顺序。
> 适用范围:`baiyue-firstleg` 内需要异步导出和下载中心能力的业务服务
> 容量基准:平台总用户约 1000,导出为低频后台操作。本文所有定量参数按该基准取值,并在 §22 说明放大路径。
## 1. 结论摘要
将现有 File Service 的“数据库任务表 + 定时扫描并直接执行”升级为:
```text
任务表 + 事务 Outbox + RocketMQ 触发 + 幂等 Consumer + Reconciler 兜底 + 统一数据 Provider 契约
```
采用至少一次投递,不追求跨数据库与 MQ 的伪“恰好一次”。**数据库是唯一事实来源与唯一可靠性保证;RocketMQ 是加速通道,不是可靠性来源。** 这一条是本文全部设计取舍的根据:
- 任何“此刻不该执行”的消息一律确认消费,**绝不使用 `RECONSUME_LATER` 做业务重排**。
- 所有重试、延迟、恢复由数据库的 `next_retry_at`、`lease_until` 与 Reconciler 承担。
- 因此 DLQ 只可能收到无法反序列化的毒消息,“DLQ 出现任意新消息即告警”这条规则才成立。
目标是让 IAM、Order、Warehouse、Finance 等业务服务只提供导出数据,不再分别维护任务表、MQ Consumer、Excel 生成、文件存储、重试、过期清理或下载中心。
### 1.1 实施前代码基线(评审时已核实)
以下表格记录设计启动时用于制定迁移方案的历史基线,不代表当前代码状态;当前已实现能力以 [公共导出与下载中心接入说明](./公共导出与下载中心接入说明.md) 和实际源码为准。
| 事项 | 实际状态 |
|---|---|
| 任务执行 | `file/.../service/ExportTaskWorker.java` 定时 2 秒轮询,`processNext()` 每轮只执行一个任务 |
| 原子领取 | `ExportTaskRepository.claim()` 按 `status = PENDING` 条件更新,多实例安全 |
| 失联恢复 | `requeueStale()` 按 `updated_at` 早于阈值粗粒度回队,无租约、无心跳 |
| 自动重试 | 不存在。失败即 `FAILED`,只能人工重试 |
| 导出类型注册 | Java Bean 注册(`ExportTaskHandler` 实现类),列定义硬编码在 `CustomerExportTaskHandler.HEADERS` |
| Provider 调用 | `IamExportClient` 为 IAM 专用 Feign 接口,每增一个类型需复制一个 Client |
| RocketMQ | **仓库无任何可用基础**:`spring-cloud-starter-stream-rocketmq` 只出现在 `firstleg/pom.xml` 且 firstleg 无一行 Stream 代码(死依赖);`file/pom.xml` 无 MQ 依赖;全仓 `*.yml` 无 rocketmq 配置;`docker-compose-infra.yml` 只有 nacos/mysql/redis,无 NameServer 与 Broker |
| 既有 Outbox | 已有两套且**均无发布器**:`ord_outbox_event`(`order/src/main/resources/db/order.sql:153`,`OrderApplicationService.writeOutbox()` 只写不发)与 `bridge_outbox`(`event-bridge-service`)。全仓 `@Scheduled` 仅 `ExportTaskWorker` 一处 |
| 指标 | 只有 `micrometer-core`(`common-web/pom.xml`),**无 `micrometer-registry-prometheus`**,actuator 的 `prometheus` 端点当前不可用;无 Prometheus 服务端 |
| 测试基建 | 全仓无 Testcontainers 依赖,无容器化测试先例 |
| 存储 | `LocalExportStorage` 为具体类直接注入,对象键 `exports/{taskId}/{uuid}.xlsx`;部署已挂载 `FILE_UPLOADS_HOST_DIR:/data/files` |
本文的实施清单以上述历史基线为迁移起点,不假设当时已有任何基础设施就绪。
## 2. 目标与非目标
### 2.1 目标
- 导出请求快速返回任务编号,后台异步执行。
- 数据库提交成功的任务最终一定被调度,不因 MQ 短暂不可用而丢失。
- MQ 重复投递、Consumer 重启、网络超时不会产生重复有效文件。
- 失败自动重试、人工重试、租约超时恢复全部可追踪,且不产生 DLQ 噪声。
- 下载中心继续使用统一任务表,不依赖 MQ 查询状态。
- 新业务服务只实现公共 Provider 契约并注册导出类型,不写任务、Excel、存储、下载代码。
- File Service 统一负责 XLSX、存储、下载鉴权、过期清理、可观测性。
- 代码层面支持 File Service 多实例(租约、条件抢占、`lease_owner` 齐备);**部署层面订单与客户按单节点运行**,因为存储是宿主机目录(§11.1)。滚动发布期间的短暂双实例由租约保证安全。
### 2.2 非目标
- 不支持 CSV、PDF 或同步大文件下载。
- 不允许业务服务提交任意 URL 让 File Service 回调。
- 不允许 File Service 跨库读取业务数据。
- 不把完整导出数据写入 MQ。
- 不以 Redis 作为任务、重试或幂等的事实来源。
- 不实现用户自定义列编排器。
- 不实现导出断点续传:每次执行从零重写整个文件(见 §9.5)。
- 订单与客户不引入 MinIO/OSS(见 §21 决策 6),但存储访问必须走接口抽象。
- 订单与客户不引入 Testcontainers(见 §21 决策 8)。
## 3. 核心原则
1. **数据库是事实来源**:任务状态、申请人、租户、筛选条件、文件信息、尝试次数、租约保存在 MySQL。(**注意**:任务表**不**保存权限快照——权限只在创建与人工重试时校验,执行阶段不再校验,理由见 §10.4。)
2. **MQ 只传命令**:消息只含 `eventId`、`taskId`、`dispatchVersion` 等标识,不携带业务数据。
3. **事务 Outbox 保证不丢任务**:任务与待发送消息在 File 数据库同一本地事务提交。
4. **至少一次 + 幂等**:允许重复消息,不允许重复有效执行。
5. **版本只代表执行尝试,不代表投递尝试**:`dispatch_version` 递增**仅**发生在三种情形——执行失败后的自动重试(§9.2)、租约超时恢复(§8.5-2)、人工重试(§9.3)。**消息补投、重发、重复投递一律不递增版本**,只产生同一版本的重复消息,由 Consumer 幂等吸收。
6. **MQ 不承担重排与延迟**:不使用延迟消息、不使用 `RECONSUME_LATER` 做业务重排;Reconciler 是唯一重派通道。
7. **业务服务只提供数据**:不生成文件,不维护下载状态,不消费公共导出 Topic。
8. **注册信息必须受控**:服务名、Provider 路径、权限编码来自受控配置且经代码白名单二次校验,不接受浏览器传入。
9. **租户维度贯穿全链路**:任务、Provider 请求、Provider 查询、下载鉴权都必须带租户与访问域(AGENTS.md 强制要求)。
10. **时间以数据库为准**:所有租约领取、续租、过期判断使用数据库 `CURRENT_TIMESTAMP(3)`,不使用各实例的 `LocalDateTime.now()`(见 §8.6)。
### 3.1 原则 5 为何是本文的核心
第一版设计在“Reconciler 发现命令可能丢失”时递增 `dispatch_version` 并重发。这看似安全,实则制造了一个**活性错误**:
并发为 4、单任务最长 30 分钟,则第五个任务在 Broker 队列中正常等待 30 分钟期间,任务状态一直是 `PENDING`。若 Reconciler 在第 10 分钟判定“发出太久未执行 = 消息丢失”并递增版本,原消息到达时会因版本过期被丢弃,新消息又排到队尾——**持续负载下这个任务可能永远无法开始执行**。这不是“产生一些垃圾 Outbox 行”,而是任务饥饿。
同一个错误还有第二个后果:Broker 长期不可用时,Outbox 发送次数耗尽 → 换新版本重建 → 新行继续耗尽 → 无限循环。因为任务的 `attempt_count` 在这个过程中从不增长,`max_attempts` 这道闸门根本拦不住。
根因是把**投递失败**(消息没送到)和**执行失败**(送到了但跑挂了)当成了同一件事。两者的正确处理完全相反:
| | 语义 | 正确处理 | 是否递增版本 |
|---|---|---|---|
| 投递失败 | 命令没送到 Consumer | 重发**同一条**命令 | **否** |
| 执行失败 | 命令送到了,执行出错 | 作废旧尝试,开始新尝试 | **是** |
确立原则 5 后,三个问题一次性消解:排队任务不再被作废(补投不动版本,原消息仍有效)、Broker 丢消息可安全补投(重发同版本,Consumer 幂等)、DEAD 无限循环消失(不再有“换版本重建”这条路径)。
## 4. 总体架构
```mermaid
flowchart LR
UI["Admin / Client"] -->|"创建任务"| API["File Export API"]
API -->|"同一事务"| TASK["file_export_task"]
API -->|"同一事务"| OUTBOX["file_export_outbox"]
PUB["Outbox Publisher"] -->|"到点领取"| OUTBOX
PUB -->|"发送 taskId"| MQ["RocketMQ export command topic"]
MQ --> CONSUMER["File Export Consumer"]
CONSUMER -->|"原子抢占 + 租约"| TASK
CONSUMER -->|"内部令牌 + 游标 + 快照"| PROVIDER["业务服务 Export Provider"]
PROVIDER --> CONSUMER
CONSUMER --> XLSX["通用 XLSX Writer"]
XLSX --> STORAGE["ExportStorage 共享目录"]
CONSUMER --> TASK
REC["Reconciler"] -->|"补发 / 租约恢复 / 过期清理"| TASK
REC --> OUTBOX
REC --> STORAGE
UI -->|"任务列表 / 下载"| API
API --> STORAGE
```
### 4.1 服务职责
| 组件 | 职责 |
|---|---|
| File Export API | 创建、查询、下载、人工重试;校验登录身份、租户、访问域与业务权限 |
| `file_export_task` | 任务状态与下载中心事实来源,承载调度字段与租约 |
| `file_export_outbox` | 可靠记录需要发送的 MQ 命令,承载发送重试与租约 |
| Outbox Publisher | 到点原子领取 Outbox、发送 MQ、记录发送结果与退避 |
| RocketMQ | 触发 Consumer、吸收突发、按 Consumer 并发天然排队 |
| File Export Consumer | 幂等抢占、watchdog 续租(§8.3)、调用 Provider、生成文件、提交结果 |
| Business Export Provider | 校验租户与筛选条件,按稳定快照游标返回列与行数据 |
| Reconciler | **唯一重派通道**:补发漏发命令、恢复超时租约、释放僵死 Outbox、清理过期文件与孤立文件 |
### 4.2 为什么 MQ 存在(诚实说明)
Outbox Publisher 与 Reconciler 仍然轮询数据库,MQ **不消除轮询**。MQ 在本架构中的实际价值是:
1. 把“任务执行触发”从秒级轮询变为毫秒级推送,Reconciler 因此可以降到 30 秒一轮,数据库压力低于现状。
2. Consumer 并发即天然的执行队列与背压,突发提交不会压垮 Provider。
3. 与 `order` / `event-bridge-service` 已规划的事件化方向统一,`file_export_outbox` 的表结构与 Publisher 组件将成为全仓 Outbox 的公共基线(§21 决策 3)。
不宣称 MQ 提升了可靠性——可靠性来自数据库与 Reconciler。
## 5. 公共接口保持兼容
现有浏览器接口不变:
- `POST /api/platform/v1/files/export-tasks`
- `GET /api/platform/v1/files/export-tasks`
- `GET /api/platform/v1/files/export-tasks/{id}`
- `GET /api/platform/v1/files/export-tasks/{id}/download`
- `POST /api/platform/v1/files/export-tasks/{id}/retry`
创建任务请求仍为:
```json
{
"exportType": "CUSTOMER_LIST",
"format": "XLSX",
"taskName": "客户列表",
"parameters": {
"keyword": "柏悦",
"status": "ACTIVE"
}
}
```
浏览器不得提交 Provider 服务名、URL、Topic、消费组或所需权限。File Service 依据 `exportType` 查询受控注册表。响应中所有 `Long` 仍按仓库约定输出为字符串,业务成功与失败统一 HTTP 200 并以响应体 `code == "200"` 判断。
任务状态机(新增自动重试路径):
```text
PENDING -> RUNNING -> SUCCEEDED -> EXPIRED
-> PENDING(自动重试,dispatch_version + 1)
-> FAILED -> PENDING(人工重试,dispatch_version + 1)
```
## 6. RocketMQ 设计
### 6.1 客户端选型
使用 `org.apache.rocketmq:rocketmq-spring-boot-starter:2.3.3`,**不使用** `spring-cloud-starter-stream-rocketmq`。理由:
- 本场景需要显式控制“抢不到就确认消费”的返回语义(`ConsumeConcurrentlyStatus.CONSUME_SUCCESS`),Stream 的函数式模型表达这一点绕。
- 仓库中那个 Stream 依赖是无人使用的死依赖,实施时**一并从 `firstleg/pom.xml` 删除**,避免两套客户端并存。
**版本选 2.3.3 而非 2.3.1,且 Spring Boot 3.2.9 兼容性已实证核验**(该 starter 的 parent pom 声明 `spring.boot.version` 为 2.7.18,容易误判为不兼容,故记录证据):
| 核验项 | 结果 |
|---|---|
| 自动配置注册方式 | jar 内**同时**包含 `META-INF/spring.factories`(Boot 2)与 `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`(Boot 3),Boot 3 下 `RocketMQAutoConfiguration` 能正常加载 |
| `javax.*` 残留 | 全 jar 扫描**无任何** `javax/annotation`、`javax/validation`、`javax/servlet` 引用,不受 javax→jakarta 断裂影响 |
| 字节码版本 | major 52(Java 8),JDK 17 可运行 |
| 传递 `rocketmq-client` | 2.3.3 → **5.3.1**(2.3.1 → 5.3.0)。5.3.1 与 §16.1 的 Broker 镜像 `apache/rocketmq:5.3.1` 版本一致,也与仓库中 `spring-cloud-alibaba 2023.0.3.2` 已解析出的 `rocketmq-client 5.3.1` 相同 |
starter 的 `spring-boot-starter` / `spring-boot-starter-validation` 依赖未锁版本,在本仓由 Spring Boot 3.2.9 的 `dependencyManagement` 接管,解析为 3.2.9。
协议使用 Remoting(4.x 兼容模式)直连 NameServer + Broker,不引入 RocketMQ 5.x 的 Proxy/gRPC 组件——当前量级不需要,且少一个可失效组件。
实施第一步仍须编译期验证:加入依赖后跑 `mvn -pl file -am test`,并在本地启动 File Service 确认 `RocketMQAutoConfiguration` 已生效(日志出现 Producer 启动记录)。
### 6.2 Topic 与消费组
| 项目 | 取值 |
|---|---|
| Topic | `${MQ_TOPIC_PREFIX}baiyue-file-export-command-v1` |
| Tag | `EXPORT_TASK` |
| Consumer Group | `${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1` |
| Producer Group | `${MQ_TOPIC_PREFIX}baiyue-file-export-publisher-v1` |
| Message Key | `taskId`(便于按任务在控制台检索) |
| 队列数 | 4 |
| 顺序要求 | 无。同一任务的串行由数据库状态与 `dispatch_version` 保证 |
| 投递语义 | At-least-once |
| 延迟消息 | **禁用**。延迟一律由 `file_export_outbox.next_retry_at` 承担(见 §6.3) |
环境隔离用 `MQ_TOPIC_PREFIX` 前缀(测试 `test-`,生产空)而非 RocketMQ namespace,前缀在 4.x/5.x Broker 上行为一致。部署 Compose 必须显式传入该变量,并使用 `${MQ_TOPIC_PREFIX?...}` 校验“是否设置”而不是 `${MQ_TOPIC_PREFIX:-test-}`;后者会把生产显式空值错误替换为 `test-`。**禁止测试与生产共用 Topic 或消费组。**
### 6.3 为什么禁用 MQ 延迟消息
RocketMQ 4.x 只支持 18 个固定延迟级别(1s/5s/10s/30s/1m/2m/3m/4m/5m/6m/7m/8m/9m/10m/20m/30m/1h/2h)。本文的退避序列(§9.2)含 15 分钟,不在级别表内;未来调整退避也会再次受限。
正确做法:Outbox 行自带 `next_retry_at`,Publisher 只领取 `next_retry_at <= now` 的行。延迟精度等于 Publisher 轮询周期(5 秒),任意间隔都可表达,且延迟状态在数据库里可查、可人工干预。
### 6.4 消息契约
契约类放入 `common-core` 的新包 `com.baiyue.common.core.export`(见 §21 决策 4,**不新建模块**):
```json
{
"schemaVersion": 1,
"eventId": "3f2a7c18-5b41-4d0e-9a6b-2c8d5e1f7a90",
"taskId": "190000000000000001",
"exportType": "CUSTOMER_LIST",
"dispatchVersion": 1,
"occurredAt": "2026-08-03T18:00:00+08:00",
"traceId": "..."
}
```
约束:
- `eventId` 由导出侧直接使用 `UUID.randomUUID().toString()` 生成;不复用 `common-event-contract` 中的 `EventIdentifiers`,避免 `common-core` 反向依赖跨区域事件契约模块。
- `dispatchVersion` 仅在自动重试、人工重试或 Reconciler 恢复超时执行租约时递增;消息补投不递增。
- Consumer 只处理与任务当前 `dispatch_version` 相同的消息;不一致即确认消费。
- 消息不含筛选明细、客户数据、Token、文件内容、租户敏感信息。
- 反序列化失败或 `schemaVersion` 不支持的消息**不确认**,令其走 MQ 原生重投直至 DLQ——这是 DLQ 的唯一合法来源。落 DLQ 后必须走 §9.4.2 的隔离流程,否则 Reconciler 会重发同一条坏 payload 形成无限循环。
- Producer 必须同时把 `eventId`、`taskId`、`dispatchVersion`、`schemaVersion` 写入 RocketMQ User Properties。DLQ Handler 从属性定位任务与 Outbox,**不得依赖已经损坏的 JSON body**;属性值必须与 Outbox 列及 payload 中的同名字段一致。
## 7. 事务 Outbox
### 7.1 为什么不能直接发送 MQ
以下写法不可接受:
```text
INSERT task -> COMMIT -> SEND MQ
```
数据库提交后进程退出,消息不会发送,任务永久停留。反过来先发 MQ 再提交数据库,Consumer 可能收到不存在的任务。
### 7.2 表结构
```sql
CREATE TABLE IF NOT EXISTS file_export_outbox (
id BIGINT UNSIGNED NOT NULL COMMENT 'Outbox 记录ID(雪花ID)',
event_id VARCHAR(64) NOT NULL COMMENT '投递意图唯一标识(UUID)',
task_id BIGINT UNSIGNED NOT NULL COMMENT '关联导出任务ID',
dispatch_version INT NOT NULL COMMENT '任务派发版本,与任务表同名字段对应',
topic VARCHAR(128) NOT NULL COMMENT '目标Topic(含环境前缀)',
tag VARCHAR(64) NOT NULL COMMENT '消息Tag',
message_key VARCHAR(64) NOT NULL COMMENT '消息Key,取taskId',
payload_json JSON NOT NULL COMMENT '待发送命令消息体',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/CLAIMED/SENT/QUARANTINED;无发送失败终态,QUARANTINED仅用于毒消息隔离,见7.4与9.4',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '累计发送尝试次数',
next_retry_at DATETIME(3) NOT NULL COMMENT '允许发送的最早时间,承担全部延迟语义',
lease_owner VARCHAR(128) NULL COMMENT '领取实例标识',
lease_until DATETIME(3) NULL COMMENT '领取租约截止时间,超时可被其他实例重新领取',
sent_at DATETIME(3) NULL COMMENT '发送成功时间',
last_error VARCHAR(1000) NULL COMMENT '最后一次发送错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_file_export_outbox_event (event_id),
UNIQUE KEY uk_file_export_outbox_task_version (task_id, dispatch_version),
KEY idx_file_export_outbox_publish (status, next_retry_at, id),
KEY idx_file_export_outbox_task (task_id, dispatch_version, status, sent_at)
) ENGINE=InnoDB COMMENT='导出命令事务Outbox表:与任务变更同一本地事务写入,由Publisher可靠投递RocketMQ;不是操作日志';
```
设计说明:
- **`lease_owner` / `lease_until` 是必需的**,与既有 `bridge_outbox` 对齐。只有 `claimed_at` 时,Publisher 领取后崩溃会让该行永久停留在 `CLAIMED`,没有任何条件能重新领取它。
- **本表不使用逻辑删除,不声明 `deleted` / `version` 字段。** 仓库 MyBatis-Plus 全局配置了 `logic-delete-field: deleted`,该配置只对声明了同名字段的实体生效,本表实体不声明即不受影响;清理走物理删除(§15)。
- `uk_file_export_outbox_task_version` 是关键约束:它使“同一任务同一版本只会存在一条待发命令”成为数据库级事实,Reconciler 的补发因此天然幂等(重复插入报唯一键冲突并忽略)。
- `idx_file_export_outbox_task` 专供 §8.5(1) 的 `NOT EXISTS` 子查询:该子查询按 `(task_id, dispatch_version)` 定位并读取 `status` 与 `sent_at`,走这个覆盖索引可避免回表。虽然唯一键已能定位,但补上 `status`/`sent_at` 让子查询完全走索引。
任务创建还需增加只有一行 `GLOBAL_CREATE` 的 `file_export_task_guard` 表,并由初始化 SQL 预置该行;File Service 启动时必须验证它存在,缺失则启动失败。创建事务只对该行执行一次 `SELECT ... FOR UPDATE`(禁止在请求内先 `INSERT IGNORE` 再升级锁,避免并发锁升级死锁),随后在同一事务内完成 60 秒重复提交检查、单用户活动任务上限、单类型活动任务上限以及 Task + Outbox 写入。当前创建量级极低(日任务 < 50),全局单行锁的等待成本可以忽略,却能避免多个实例并发请求同时通过“先 COUNT、后 INSERT”而突破 3/5 上限;该锁只串行化任务创建,不影响 Publisher、Consumer 或下载。
### 7.3 任务表字段调整(直接改初始化 SQL)
**当前处于开发阶段,不产出增量迁移脚本。** 做法:直接在 `file/src/main/resources/db/file.sql` 的 `file_export_task` 建表语句中补齐新字段与索引,测试环境先执行新增 SQL 使其生效,本模块的初始化 SQL 即为唯一事实来源。
这样选择的前提与代价:
- 前提:导出功能尚未有生产数据,测试环境的任务表可以直接重建,不存在必须保留的存量任务。
- 代价:已经跑过旧版 `file.sql` 的环境不会自动升级。因此测试环境在只读确认任务数为 0 后,按依赖顺序执行 **`DROP TABLE file_export_outbox; DROP TABLE file_export_task_guard; DROP TABLE file_export_task;`,再完整执行 `file.sql`**,而不是 `ALTER TABLE`。具体核验 SQL 见 `deploy/sql/README.md`。
- 一旦进入生产、表内有真实数据,后续任何字段变更就必须回到 AGENTS.md 要求的可重复执行增量脚本模式(`information_schema` 判断 + `ALTER TABLE`),本节的简化做法**仅适用于当前开发阶段**。
`file_export_task` 在现有字段基础上补入以下 7 个字段(位置放在 `failure_message` 之后、`requested_by` 之前,保持读表时调度字段聚集):
```sql
dispatch_version INT NOT NULL DEFAULT 1 COMMENT '派发版本,仅自动重试、人工重试或超时执行租约恢复时递增;Consumer只处理版本一致的消息',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '已执行尝试次数',
max_attempts INT NOT NULL DEFAULT 3 COMMENT '自动重试上限,创建任务时按注册表写入',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '允许执行的最早时间,承担全部退避语义',
lease_owner VARCHAR(128) NULL COMMENT '当前执行实例标识',
lease_until DATETIME(3) NULL COMMENT '执行租约截止时间,超时由Reconciler回收',
last_heartbeat_at DATETIME(3) NULL COMMENT '最后心跳时间',
```
并在该表的索引区补入两个索引:
```sql
KEY idx_file_export_dispatch (status, next_retry_at, id),
KEY idx_file_export_lease (status, lease_until)
```
同时把 §7.2 的 `file_export_outbox` 建表语句一并加入同一个 `file.sql`,与任务表相邻放置,便于阅读时看到两者的对应关系。
**存量 PENDING 任务不做数据迁移**:表是重建的,没有存量。若测试环境选择保留数据,则这些任务的 `next_retry_at` 会取默认当前时间、`dispatch_version` 取 1,Reconciler 会在 30 秒内为它们补发 Outbox 命令并正常执行——无需任何一次性 SQL。
### 7.4 Publisher 可靠性
轮询周期 5 秒,每轮最多领取 50 条。领取分两步,避免 `UPDATE ... ORDER BY ... LIMIT` 的锁范围问题:
```text
1) SELECT id FROM file_export_outbox
WHERE status = 'PENDING' AND next_retry_at <= CURRENT_TIMESTAMP(3)
ORDER BY next_retry_at, id LIMIT 50
2) 逐条条件更新:
UPDATE file_export_outbox
SET status = 'CLAIMED', lease_owner = :instance,
lease_until = CURRENT_TIMESTAMP(3) + INTERVAL :leaseSeconds SECOND,
attempt_count = attempt_count + 1
WHERE id = :id AND status = 'PENDING'
-- 影响行数 0 表示已被其他实例领取,跳过
```
发送与结果处理:
- 发送成功:`status = SENT`,`sent_at = CURRENT_TIMESTAMP(3)`,清空 `lease_owner` / `lease_until`。
- 发送失败:`status = PENDING`,`next_retry_at = CURRENT_TIMESTAMP(3) + backoff`,记录 `last_error`。退避 5s / 15s / 1m / 5m / 15m,之后**固定 15m 无限重试**。**不得无间隔死循环,也不得放弃。**
- 发送成功但更新数据库前进程退出:租约到期后另一实例重新领取并重发,产生重复消息,由 Consumer 幂等吸收。
**不设发送失败终态,Outbox 行永不因发送失败而放弃。** 第一版设计曾规定“`attempt_count > 20` 置 `DEAD`,仅人工恢复”,这与 §8.5 的自动补投逻辑直接冲突,且会导致:Broker 长期不可用 → 所有 Outbox 行耗尽次数变 `DEAD` → 若允许自动换版本重建则无限循环(任务 `attempt_count` 从不增长,`max_attempts` 拦不住);若不允许则**所有任务静默停滞**,违背“数据库提交成功的任务最终一定被调度”这一核心目标(§2.1)。
取而代之:发送失败无限退避重试,靠**告警**而非状态机来驱动人工介入。`export_outbox_oldest_age_seconds > 120` 即告警(§13),Broker 故障期间告警持续存在,运维必然介入;Broker 恢复后所有积压命令自动发出,无需任何人工操作。
`status` 共四个值,其中只有 `QUARANTINED` 是终态,且**只用于毒消息隔离,绝不用于发送失败**(§9.4):
| 值 | 含义 | 谁写入 | Reconciler 是否补投 |
|---|---|---|---|
| `PENDING` | 待发送 | 创建 / 发送失败退避 / Reconciler 恢复 | — |
| `CLAIMED` | 已被某实例领取,发送中 | Publisher | 租约超时后释放回 `PENDING` |
| `SENT` | 已成功投递到 Broker | Publisher | 超时未执行则改回 `PENDING` 重发 |
| `QUARANTINED` | **消息内容损坏,永久隔离** | DLQ Handler 自动处置 | **否,永久排除** |
`attempt_count` 仅用于计算退避档位与观测,不再作为放弃条件。
**`attempt_count` 的语义是“本 Outbox 行的发送尝试次数”,与任务的 `attempt_count`(执行尝试次数)是两个独立计数器,不可混用。** 发送重试不消耗任务的执行次数(§3 原则 5)。任务重试会写入**新的** Outbox 行,其 `attempt_count` 从 0 开始。
退避时长由 `attempt_count` 查表得出,超出表长即取末位(15m)。退避计算基于领取时已递增的值,避免首次失败就跳过 5s 档。
Publisher 只负责投递几十字节的小消息,不执行导出,不受导出耗时影响。
**Publisher 与 Consumer 必须使用不同的 Spring Bean,且 Producer 的 `sendMessageTimeout`(3 秒)远小于 Outbox 租约(60 秒)**,保证发送调用不会跨越租约边界造成重复领取。
## 8. Consumer 幂等、租约与背压
### 8.1 执行模型:消费线程同步执行
Consumer 采用 `MessageModel.CLUSTERING`、`consumeMessageBatchMaxSize = 1`、`consumeThreadMin = consumeThreadMax = 4`,**消费线程直接同步执行导出**,不再投递到额外线程池。
这样选择的原因与代价:
- 消费线程数即单个 File Service 实例的导出并发上限(4);超出的任务在 Broker 队列中等待——这正是 MQ 的正当用途,不需要自己实现背压和“线程池满了怎么办”。
- 并发消费模式下,一个队列可以向同一实例的多个消费线程提交不同批次,**Topic 的 4 个队列不是“最多 4 个线程”的硬上限**。队列数主要决定消费组内实例间的分配粒度;当前单实例部署使用 4 个队列足够,未来扩 File 实例时应保证队列数不少于需要获得分配的实例数,并通过压测决定是否继续扩队列。
- 长任务会占住消费线程。因此 `consumeTimeout` 必须大于最大任务时长:**最大任务时长 30 分钟,`consumeTimeout` 设 90 分钟**。否则 Broker 判定消费超时并重投,虽有幂等兜底但会产生无意义的抢占尝试。
- `maxReconsumeTimes = 2`。**仅对毒消息生效**:§8.2 的全部抢占失败分支都返回 `CONSUME_SUCCESS`,业务上永不触发重投;唯一不确认消费的路径是消息反序列化失败(§6.4),该路径重投 2 次后落 DLQ。此参数与抢占竞争无关——第一版文案称其"吸收抢占竞争"是错的,抢占竞争根本不会走到重投。
- 不使用独立线程池,因此不存在“已抢占但无线程执行、任务卡到租约过期”这个失败模式。
导出执行不占用 Servlet 请求线程(MQ 消费线程独立),满足资源隔离要求。
### 8.2 原子抢占
Consumer 收到命令后执行条件更新(时间一律取自数据库,见 §8.6):
```sql
UPDATE file_export_task
SET status = 'RUNNING',
attempt_count = attempt_count + 1,
started_at = CURRENT_TIMESTAMP(3),
lease_owner = :instance,
lease_until = CURRENT_TIMESTAMP(3) + INTERVAL :leaseSeconds SECOND,
last_heartbeat_at = CURRENT_TIMESTAMP(3),
processed_rows = 0,
total_rows = NULL,
failure_message = NULL
WHERE id = :taskId
AND dispatch_version = :dispatchVersion
AND status = 'PENDING'
AND next_retry_at <= CURRENT_TIMESTAMP(3)
```
`started_at` 每次抢占都覆盖为当前时间(不用 `COALESCE` 保留首次值),因为 §9.5 规定文件内容对应"最后一次执行开始时刻"的快照,`started_at` 必须与之一致。任务创建时间由 `requested_at` 保留。
`processed_rows` / `total_rows` / `failure_message` 在抢占时一并重置,保证下载中心不会显示上一轮的残留进度。
**影响行数为 0 时,必须按下表穷尽处理。所有分支一律 `CONSUME_SUCCESS`,无一例外:**
| 重新读取任务后的状态 | 判定 | 处理 |
|---|---|---|
| 任务不存在 | 极旧消息或数据已清理 | 确认消费,记 WARN |
| `dispatch_version` 大于消息中的值 | 旧版本消息 | 确认消费 |
| `SUCCEEDED` / `FAILED` / `EXPIRED` | 已终结 | 确认消费 |
| `RUNNING` 且 `lease_until > now` | 另一实例正在处理 | 确认消费。**不重投**,由该实例继续;若其崩溃则 Reconciler 恢复 |
| `RUNNING` 且 `lease_until <= now` | 租约已过期 | 确认消费。**不在此处抢占**,交由 Reconciler 统一恢复(见 §8.5) |
| `PENDING` 且 `next_retry_at > now` | 处于退避窗口,命令提前到达 | 确认消费。**不做任何补救**:Publisher 会在到点后发出该版本的命令;即使那条也丢了,Reconciler 的 `SENT` 超时窗口会兜底 |
| `PENDING` 且 `next_retry_at <= now` 但更新仍为 0 | 与其他实例竞争失败 | 确认消费 |
最后两行是原设计缺失的分支。尤其“`PENDING` 且退避未到”——自动重试会把任务置为 `PENDING` 且 `next_retry_at = CURRENT_TIMESTAMP(3) + 15min`,此时若有重复消息导致命令提前到达,就会落入这个分支。正常流程下不会发生:§9.2 保证新 Outbox 行的 `next_retry_at` 等于任务的 `next_retry_at`,Publisher 到点才发。
**这两个分支都只确认消费、不做补救,是刻意的。** 任何“抢不到就重投/就自己补发”的补救逻辑都会与 Reconciler 的重派职责重叠,形成两条并行的重派通道,进而产生版本竞争。§3 原则 5 的“Reconciler 是唯一重派通道”正是这个意思。
### 8.3 租约续期:独立 watchdog,覆盖全执行期
**续租必须由独立的 watchdog 线程按固定周期执行,不能挂在 Provider 批次循环里。**
第一版设计把续租放在“每完成一个批次”之后,存在一个致命盲区:**最后一批数据拉完之后,还有 `SXSSFWorkbook.write()`、ZIP 压缩、大文件落盘这些耗时操作,期间一次批次都不会完成,续租因此完全停止。** 20 万行的 XLSX 压缩落盘在慢磁盘上完全可能超过 300 秒租约,导致任务被 Reconciler 判为失联、剥夺租约并重派——而此时它其实马上就要成功了。结果是任务被反复重跑,每次都在最后一步被杀。
正确做法:Consumer 抢占成功后启动一个 watchdog,**在整个执行期间**(含 Provider 拉取、XLSX 写入、压缩、落盘、结果提交前的全部阶段)每 60 秒续租一次:
```sql
UPDATE file_export_task
SET lease_until = CURRENT_TIMESTAMP(3) + INTERVAL :leaseSeconds SECOND,
last_heartbeat_at = CURRENT_TIMESTAMP(3)
WHERE id = :taskId AND status = 'RUNNING'
AND dispatch_version = :dispatchVersion AND lease_owner = :instance
```
进度上报(`processed_rows`)仍在批次之间单独更新,与续租解耦——**进度是展示用的,租约是正确性用的,两者不能共用一次写入**。
watchdog 的生命周期与约束:
- 抢占成功后启动,`finally` 块中确保关闭(无论成功、失败、异常)。
- 续租周期 60 秒,租约 300 秒 → 为 watchdog 发现异常、通知执行线程并安全中止预留约 240 秒租约余量;续租异常仍按下一条立即触发中止,不能据此忽略连续失败。
- **续租返回 0 行时立即置中止标志**,执行主线程在每个可中断点(批次边界、写入循环内)检查该标志,一旦置位就中止导出、删除半成品文件、放弃提交。
- watchdog 自身异常不得静默吞掉,必须记 ERROR 并置中止标志——续租线程死了却继续执行,等于失去租约保护。
### 8.3.1 执行截止时间:watchdog 不能让任务无限跑
**watchdog 会无条件续租,因此它同时消除了"任务卡死自然超时"这一兜底。** §14 声明的"最大任务时长 30 分钟"若没有独立的检查机制,就只是一句无法生效的声明:一个卡在慢 Provider 上的任务会被 watchdog 一直续租下去,占着消费线程直到 `consumeTimeout`(90 分钟)才被 Broker 判超时——而那时它已白占了一个并发位一个半小时。
因此抢占成功时必须计算并持久化执行截止时间。在 §8.2 的抢占 SQL 中增加一列:
```sql
execution_deadline_at = CURRENT_TIMESTAMP(3) + INTERVAL :maxDurationMinutes MINUTE,
```
对应 `file_export_task` 增加字段(与 §7.3 的 7 个字段一并落库,共 8 个):
```sql
execution_deadline_at DATETIME(3) NULL COMMENT '本次执行的截止时间,超过即判超时失败;抢占时按最大任务时长计算',
```
**deadline 必须在三处检查**,缺一不可:
| 检查点 | 原因 |
|---|---|
| 每个 Provider 批次开始前 | 最常见的超时来源是数据量过大、批次过多 |
| XLSX 写入循环内(每 N 行,如每 10000 行) | 单批数据量大时,写入本身可能耗时很久 |
| 结果提交前(§8.4) | 防止刚好在 deadline 边界完成却仍提交,导致时长统计失真 |
超过 deadline 按**临时依赖错误**处理(走 §9.2 自动重试),而非永久失败——首次超时可能是 Provider 偶发慢,重试有意义。但 `failure_message` 必须写明"执行超过最大时长 30 分钟",便于区分真正的超时与其他临时错误。若重试到 `max_attempts` 仍超时,说明数据量确实超出能力,转 `FAILED`,此时应考虑调大 `max-duration-minutes` 或调小 `max-rows`。
**deadline 用数据库时间计算与比较**(§8.6)。检查时不要在 Java 端拿 `LocalDateTime.now()` 和它比——用 watchdog 每次续租的返回值携带"是否已超 deadline",或在续租 SQL 中顺带判断:
```sql
UPDATE file_export_task
SET lease_until = CURRENT_TIMESTAMP(3) + INTERVAL :leaseSeconds SECOND,
last_heartbeat_at = CURRENT_TIMESTAMP(3)
WHERE id = :taskId AND status = 'RUNNING'
AND dispatch_version = :dispatchVersion AND lease_owner = :instance
AND execution_deadline_at > CURRENT_TIMESTAMP(3) -- 超时则续租失败
```
这样 deadline 与租约共用一次写入:**超过 deadline 后续租自然失败 → 中止标志置位 → 执行主线程在下一个可中断点退出**。不需要额外的时间比较逻辑,也不会因实例时钟偏差而误判。
注意这个复用有一个后果:超 deadline 后租约不再续期,300 秒后 Reconciler 会按"租约超时"路径恢复它(§8.5-2,递增版本重试)。这与"超时按临时错误重试"的结论一致,因此**即使 Consumer 侧的中止逻辑因故没跑,Reconciler 也会兜底得到相同结果**——两条路径殊途同归,这是刻意的冗余。
租约、续租与超时的约束关系(实施时必须同时满足):
```text
续租周期(60s) << 租约(300s) << consumeTimeout(90min)
Provider 读超时(15s) << 租约(300s)
最大任务时长(30min) < consumeTimeout(90min)
```
有了 watchdog,Provider 读超时不再是租约正确性的**唯一**保障(续租独立于批次),但仍必须按 §14 配置——否则单批可以无限挂起,任务会一直占着消费线程直到 `consumeTimeout`。
### 8.4 结果提交
文件写入完成后:
```sql
UPDATE file_export_task
SET status = 'SUCCEEDED', file_name = ..., object_key = ..., content_type = ...,
size_bytes = ..., total_rows = ..., processed_rows = ...,
completed_at = CURRENT_TIMESTAMP(3),
expires_at = CURRENT_TIMESTAMP(3) + INTERVAL :retentionDays DAY,
lease_owner = NULL, lease_until = NULL, failure_message = NULL
WHERE id = :taskId AND status = 'RUNNING'
AND dispatch_version = :dispatchVersion AND lease_owner = :instance
AND execution_deadline_at > CURRENT_TIMESTAMP(3)
```
影响行数为 0 时(版本已变、租约被剥夺或提交时已越过 deadline),**必须删除本次生成的孤立文件**;若仍是本实例持有的同版本 `RUNNING` 且 deadline 已过,则走 §9.2 超时重试,否则只记 WARN 并确认消息。deadline 条件必须进入最终 SQL,不能只在 Java 中预检查,否则检查后跨越截止时间仍可能错误提交成功。
### 8.5 Reconciler:唯一重派通道
轮询周期 30 秒。四项职责,每项都是幂等的条件更新:
**(1) 补投疑似丢失的命令** —— **恢复原 Outbox 行重发,绝不递增任务版本**(§3 原则 5)。
分两种情形,分别处理:
**情形 A:当前版本没有任何 Outbox 行**(写入丢失或被误删)。补写一条**同版本**的 Outbox 行:
```sql
SELECT t.id, t.dispatch_version
FROM file_export_task t
WHERE t.status = 'PENDING'
AND t.next_retry_at <= CURRENT_TIMESTAMP(3) - INTERVAL :redispatchGraceSeconds SECOND
AND NOT EXISTS (SELECT 1 FROM file_export_outbox o
WHERE o.task_id = t.id AND o.dispatch_version = t.dispatch_version)
LIMIT 50
```
插入时 `dispatch_version` 取任务当前值,`event_id` 新生成。唯一键 `uk_file_export_outbox_task_version` 保证并发下只有一条插入成功,其余报冲突并忽略——天然幂等。
注意此处 `NOT EXISTS` 不带 `status` 过滤,因此 `QUARANTINED` 行也会阻止补写——这正是需要的:毒消息隔离后不允许再产生同版本命令。
**情形 B:Outbox 行已 `SENT` 很久,任务仍是 `PENDING`**(消息大概率已被 Broker 丢弃)。把该行**条件性恢复为 `PENDING` 重发同一版本**:
```sql
UPDATE file_export_outbox o
JOIN file_export_task t
ON t.id = o.task_id AND t.dispatch_version = o.dispatch_version
SET o.status = 'PENDING',
o.next_retry_at = CURRENT_TIMESTAMP(3),
o.lease_owner = NULL,
o.lease_until = NULL
WHERE o.status = 'SENT'
AND o.sent_at <= CURRENT_TIMESTAMP(3) - INTERVAL :sentStaleSeconds SECOND
AND t.status = 'PENDING'
AND t.next_retry_at <= CURRENT_TIMESTAMP(3) - INTERVAL :redispatchGraceSeconds SECOND
LIMIT 20
```
`WHERE o.status = 'SENT'` 天然排除了 `QUARANTINED` 行——**这是阻断毒消息循环的关键**,见 §9.4。
**关键:任务版本不动,Outbox 行也不换版本。** Publisher 下一轮会重发同一条命令,Consumer 收到后正常抢占执行。
**为什么这样就不会饿死排队任务**:假设任务因并发已满在 Broker 队列中正常等待,Reconciler 在第 10 分钟把 Outbox 行恢复为 `PENDING` 并重发。此时队列里有**两条同版本命令**:
- 若原命令先被消费 → 正常抢占执行;随后重复命令看到 `RUNNING` → 确认消费(§8.2 第 4 行)。
- 若重复命令先被消费 → 同样正常抢占执行;原命令看到 `RUNNING` → 确认消费。
**两条命令都是有效的,谁先到谁执行。** 对比第一版“递增版本”的做法:原命令会因版本过期被丢弃,新命令排到队尾,持续负载下任务永远轮不上——这正是被修掉的活性错误。
代价:正常排队场景下会产生一条无用的重复消息。这是刻意的取舍——重复消息由幂等吸收,成本仅是一次条件更新;而漏补一条真丢的消息会让任务永久停滞。**宁可多投,不可不投。**
`sentStaleSeconds` 默认 600 秒。由于重复投递现在是无害的,这个值不再需要精确避开正常排队时长,可以取得比较激进;但也不宜过小,否则积压时会放大 Broker 队列长度。
两种情形都**不判断 `attempt_count`**:补投不是新的执行尝试,不受 `max_attempts` 约束。执行次数的闸门在 §8.2 抢占成功时递增、在 §9.2 与 §8.5(2) 判定,与补投无关。
**(2) 恢复超时租约**:
```sql
SELECT id, dispatch_version, attempt_count, max_attempts, object_key
FROM file_export_task
WHERE status = 'RUNNING' AND lease_until <= CURRENT_TIMESTAMP(3) LIMIT 20
```
同一事务内:若 `attempt_count < max_attempts` 则置 `PENDING`、`next_retry_at = CURRENT_TIMESTAMP(3)`、**`dispatch_version + 1`**、清空租约、写新 Outbox;否则置 `FAILED` 并写明“执行实例失联且已达最大尝试次数”。半成品文件按 §9.5 清理。
**这里递增版本是正确的**,与 §8.5(1) 的补投不同:租约超时意味着上一次**执行**确实失败了(实例已死或卡死),需要作废那次尝试并开始新尝试——正好符合 §3 原则 5 中“执行失败才递增”的定义。递增后旧实例若“复活”也无法再提交(§8.4 的条件带版本号)。
**(3) 释放僵死 Outbox**:`status = 'CLAIMED' AND lease_until <= CURRENT_TIMESTAMP(3)` → 置回 `PENDING`,`next_retry_at = CURRENT_TIMESTAMP(3)`。这是投递侧恢复,**不动任务版本**。
**(4) 过期与孤立文件清理**:沿用现有 `expiredArtifacts` + `markExpired` 逻辑(先删文件、删成功才置 `EXPIRED`、失败保持原状并告警),另扫描 `exports/` 下无对应 `SUCCEEDED` 任务且创建超过 24 小时的文件作为孤立文件删除。
Reconciler 的所有分支都只做“把任务重新变成可派发的 PENDING + 写 Outbox”或“恢复 Outbox 行重发”,**从不直接执行导出,也从不因投递问题递增任务版本**。
### 8.6 时间基准:一律使用数据库时钟
**所有与租约、退避、超时判断相关的时间,必须在 SQL 中使用 `CURRENT_TIMESTAMP(3)`,不得由 Java 端传入 `LocalDateTime.now()`。**
这一条覆盖:抢占的 `next_retry_at <= ?`、租约的 `lease_until` 写入与比较、Outbox 的 `next_retry_at` 与 `sent_at`、Reconciler 的全部时间条件。
原因:多实例部署时各容器的系统时钟存在偏差(NTP 同步误差、容器时钟漂移,秒级到分钟级都可能)。若用本地时钟:
- 时钟快的实例会**提前抢占**:它认为 `lease_until` 已过期,实际上持有者还在正常执行 → 两个实例同时写同一任务。
- 时钟慢的实例会**延迟恢复**:真正失联的任务迟迟不被回收。
- 退避窗口在不同实例上长短不一,`next_retry_at` 失去意义。
数据库是全局唯一的时间基准,所有实例读到的 `CURRENT_TIMESTAMP(3)` 一致,上述问题全部消失。
例外:仅用于日志输出、指标打点、文件名生成的时间可以用本地时钟——它们不参与正确性判断。`file_name` 中的时间戳即属此类。
现有代码中 `ExportTaskRepository` 的 `claim(id, LocalDateTime.now())`、`requeueStale(cutoff)` 等签名都是 Java 端传时间,**改造时必须一并改为 SQL 内取时间**,这是从当前实现迁移时容易遗漏的一处。
## 9. 重试与 DLQ
### 9.1 错误分类
| 类型 | 示例 | 行为 |
|---|---|---|
| 永久业务错误 | 参数无效、导出类型已停用、列定义不合法、`schemaHash` 批次间不一致、超出 `max-rows` | 立即 `FAILED`,不自动重试 |
| 临时依赖错误 | Provider 503 / 连接超时 / 读超时、存储短暂不可用、游标未推进 | 走 §9.2 自动重试 |
| Consumer 非预期退出 | JVM 退出、实例被杀、心跳失败 | 租约超时 → Reconciler 恢复(§8.5-2) |
| MQ 投递失败 | NameServer / Broker 不可用 | Outbox 退避重发(§7.4) |
| 消息本身损坏 | 反序列化失败、`schemaVersion` 不支持 | 不确认消费 → MQ 原生重投 → DLQ → **§9.4.2 隔离(Outbox 置 `QUARANTINED` + 任务置 `FAILED`)** |
**只有最后一类会进入 DLQ。** 这是“DLQ 出现任意新消息即告警”能作为有效告警规则的前提。
### 9.2 自动重试
`max_attempts = 3`(即最多 2 次自动重试)。退避 1 分钟、15 分钟,含 ±20% 随机抖动。退避由数据库承担,任意间隔均可表达。
临时失败时,Consumer 在**同一数据库事务**内:
1. 删除本次半成品文件(见 §9.5),置 `object_key = NULL`;
2. 若 `attempt_count >= max_attempts` → `status = FAILED`,写 `failure_message`,事务结束;
3. 否则 `status = PENDING`、`next_retry_at = CURRENT_TIMESTAMP(3) + backoff`、`dispatch_version + 1`、`processed_rows = 0`、`total_rows = NULL`、清空 `lease_owner` / `lease_until`、保留 `failure_message` 供下载中心展示上次原因;
4. 写入新 Outbox 行,其 `next_retry_at` **等于任务的 `next_retry_at`**(这是 Publisher 不提前发送的保证)。
事务提交后确认 MQ 消息。所有重试状态在数据库中可查,不依赖客户端内存,不引入 Redis 重试队列。
条件更新同样要带 `dispatch_version = :v AND lease_owner = :instance`;失败则说明租约已被剥夺,放弃写入。
### 9.3 人工重试
下载中心人工重试只允许 `FAILED`(保持现有 `ExportTaskService.retry()` 的约束),在同一事务内:
- 重新校验登录身份、租户、访问域与注册表中的 `required-permission`;
- 校验导出类型当前 `enabled = true`(停用类型不得重试);
- 清空 `failure_message`,`attempt_count = 0`,`processed_rows = 0`,`total_rows = NULL`,`started_at = NULL`,`completed_at = NULL`;
- 删除上次遗留文件并置 `object_key = NULL`;
- `status = PENDING`,`next_retry_at = CURRENT_TIMESTAMP(3)`,`dispatch_version + 1`;
- 写入新 Outbox 行。
### 9.4 DLQ 与毒消息隔离
#### 9.4.1 为什么必须隔离:否则形成无限毒消息循环
确立 §3 原则 5(补投重发同版本)后,出现了一个新的循环路径,**必须显式阻断**:
```text
消息 payload 损坏(反序列化失败)
-> Consumer 不确认消费
-> MQ 重投 2 次仍失败 -> 落 DLQ
-> 任务仍是 PENDING,Outbox 行仍是 SENT
-> 超过 sentStaleSeconds 后 Reconciler 判定"消息丢失"
-> 把同一条损坏 payload 改回 PENDING 重发 ← 循环点
-> 再次落 DLQ ...
```
每 10 分钟循环一次,DLQ 持续增长,告警持续触发,且**永远不会自愈**——因为 payload 存在数据库里,重发多少次都是同样的坏数据。Reconciler 的"重发同版本"对 Broker 丢消息是正确的,对内容损坏则是灾难。
根因:§8.5 的补投无法区分"消息丢了"和"消息到了但读不懂"。两者在数据库侧的表征完全相同(任务 `PENDING` + Outbox `SENT`),必须由 DLQ 处置流程显式打标。
#### 9.4.2 隔离机制
新增 Outbox 终态 `QUARANTINED`,**只用于消息内容损坏,绝不用于 Broker 发送失败**(后者永不放弃,§7.4)。
File Service 增加独立 DLQ Handler,使用专用消费组订阅 `%DLQ%<workerConsumerGroup>`。Handler 接收原始 `MessageExt`,从 User Properties 读取 `eventId` / `taskId` / `dispatchVersion` / `schemaVersion`,在下列事务成功提交后才确认 DLQ 消息:
```sql
-- 1) 隔离该版本的 Outbox 行,阻断 Reconciler 补投
UPDATE file_export_outbox
SET status = 'QUARANTINED',
last_error = CONCAT('DLQ隔离: ', :reason)
WHERE event_id = :eventId
AND task_id = :taskId AND dispatch_version = :dispatchVersion
AND status IN ('SENT', 'PENDING', 'CLAIMED');
-- 2) 同一事务内把对应任务置为 FAILED,让用户在下载中心看到明确失败
UPDATE file_export_task
SET status = 'FAILED',
failure_message = '导出命令消息损坏,请重新发起导出',
completed_at = CURRENT_TIMESTAMP(3),
lease_owner = NULL, lease_until = NULL
WHERE id = :taskId AND dispatch_version = :dispatchVersion
AND status = 'PENDING';
```
两步必须在**同一事务**内完成,并断言两条更新各影响 1 行。只做第 1 步会让任务永久停在 `PENDING`(Reconciler 已被阻断,无人再驱动它),对用户表现为"一直排队"——比循环更难排查。若任务版本已经变化或 Outbox `event_id` 不匹配,说明是旧消息或外部误投,记录审计后直接确认,不得隔离当前版本。若新协议消息缺少任一定位属性,DLQ Handler 记 CRITICAL、保存 Broker messageId 与原始属性供人工排查并确认消息,避免 DLQ Handler 自身形成二级死信循环。
隔离后的恢复路径:**修复消息契约或代码后,由用户或运维走人工重试**(§9.3)。人工重试递增 `dispatch_version` 并写入**新版本**的 Outbox 行,其 payload 由新代码生成,与被隔离的旧版本无关。旧的 `QUARANTINED` 行永久保留作为审计痕迹,因唯一键是 `(task_id, dispatch_version)` 而版本已变,不会冲突。
#### 9.4.3 运维要求
- Consumer Group 的 DLQ Topic 为 `%DLQ%<consumerGroup>`,需在部署脚本中显式创建并授予读写权限(§16.2)。
- DLQ 出现任意消息即告警,因为按 §9.1 它只承载损坏消息。
- DLQ Handler 必须自动执行 §9.4.2 的隔离;运维收到告警后只负责分析原因与决定是否人工重试。禁止依赖值班人员在 10 分钟补投窗口内手工运行 SQL。
- DLQ Handler 的数据库处置失败必须持续重试(`maxReconsumeTimes = Integer.MAX_VALUE`),不能在有限次数后进入无人消费的 `%DLQ%<dlq-handler-group>` 二级 DLQ;缺少或非法定位属性的消息仍按 §9.4.2 记录 CRITICAL 后确认。
- 重投前核对任务当前 `dispatch_version`;版本已变说明任务已通过新版本重试,旧消息只允许丢弃。
- 不允许静默丢弃:每次处置需在运维记录中写明 `taskId`、`eventId`、`dispatchVersion`、损坏原因、处置方式。
- **任务不会因为消息进 DLQ 而丢失**:要么被隔离流程显式置为 `FAILED`(用户可见、可人工重试),要么在未隔离期间由 Reconciler 补投。两种结局都不是静默停滞。
#### 9.4.4 毒消息的真实来源与预防
按 §6.4,消息只含 `eventId` / `taskId` / `exportType` / `dispatchVersion` / `occurredAt` / `traceId`,全是定长标识,**正常情况下不可能损坏**。真实来源只有两类:
1. **消息契约不兼容变更**:如把 `taskId` 从字符串改成数字、删字段、改 `schemaVersion` 语义,而新旧版本 File Service 在滚动发布期间并存。这是最可能的来源。
2. 手工向 Topic 投递了格式错误的消息(测试误操作)。
预防措施:`schemaVersion` 只增不改语义;新增字段必须可选;删除字段需跨两个发布周期(先停用后删除)。滚动发布期间新旧 Consumer 并存是常态,契约必须双向兼容。
### 9.5 半成品文件与快照语义
- 每次执行从零重写整个文件,不做断点续传。`SXSSFWorkbook` 的临时文件在 `try-with-resources` 中释放。
- 任何失败路径(永久失败、临时失败、租约丢失、提交竞争失败)都必须删除本次 `object_key` 指向的文件。删除失败只记 WARN,由 Reconciler 的孤立文件扫描兜底。
- **`snapshotToken` 不跨 `dispatch_version` 复用。** 每一轮执行重新向 Provider 申请首批并获得新 Token。原因:退避 15 分钟后旧 Token 很可能已失效;且新 Token 语义清晰——文件内容对应“最后一次执行开始时刻”的数据快照。
- 因此下载中心对重试成功的任务应理解为:数据以最后一次执行时刻为准,而非任务创建时刻。任务详情返回的 `startedAt` 即该时刻。
## 10. 统一 Business Export Provider 契约
### 10.1 接入形态
新业务服务不接公共导出 MQ,只提供一个标准内部 HTTP Provider。契约类型放入 `common-core` 的 `com.baiyue.common.core.export` 包,避免每个服务复制 DTO。
```http
POST /internal/exports/v1/{exportType}/batches
X-Internal-Service-Token: ...
Content-Type: application/json
```
请求:
```json
{
"taskId": "190000000000000001",
"tenantId": "0",
"appCode": "PLATFORM",
"requestedBy": "1900000000000000009",
"dispatchVersion": 1,
"parameters": {
"keyword": "柏悦",
"status": "ACTIVE"
},
"cursor": null,
"snapshotToken": null,
"limit": 500
}
```
**`tenantId`、`appCode`、`requestedBy` 是必填的,这是原设计的严重缺口。** 现有 `file_export_task` 已保存这三项,但原契约未下传,Provider 无法按租户过滤。当前只有平台侧 `CUSTOMER_LIST` 所以未暴露,一旦出现 `app_code = MERCHANT` 的导出类型就会越权导出。AGENTS.md 明确要求“商户数据必须保留租户维度,并由服务端执行租户、访问域、数据范围和按钮权限校验”。
Provider 侧强制要求:
- 必须校验 `X-Internal-Service-Token`,与 `INTERNAL_SERVICE_TOKEN` 一致才响应;
- 必须用 `tenantId` + `appCode` 约束查询,**不得信任 `parameters` 中的任何租户字段**;
- `appCode = MERCHANT` 时必须额外按 `requestedBy` 的数据范围过滤;
- 只读,不得产生副作用。
首批响应:
```json
{
"code": "200",
"message": "success",
"data": {
"schemaVersion": 1,
"schemaHash": "sha256:...",
"snapshotToken": "provider-defined-opaque-token",
"columns": [
{ "key": "tenantCode", "title": "客户编码", "type": "STRING", "width": 18 },
{ "key": "tenantName", "title": "公司名称", "type": "STRING", "width": 28 }
],
"rows": [
{ "tenantCode": "TEN101", "tenantName": "柏悦物流" }
],
"nextCursor": "101",
"hasMore": false,
"totalRows": 1
}
}
```
响应遵循仓库统一约定:HTTP 200 + `code == "200"` 才成功,`Long` 输出为字符串。后续批次必须返回相同 `schemaHash`,可省略 `columns`;`schemaHash` 变化即视为永久业务错误并 `FAILED`(说明 Provider 在导出期间变更了列定义)。
`hasMore = true` 但 `nextCursor` 为空或未推进时,File Service 判定为临时依赖错误并终止本轮(沿用现有 `CustomerExportTaskHandler` 的游标推进校验)。
### 10.2 稳定快照
仅用递增 ID 游标会把导出期间新增的数据混入结果。Provider 首批必须返回不透明 `snapshotToken`,后续批次带回同一 Token。
Provider 可将 Token 实现为签名后的最大 ID、截止时间或数据库快照标识。File Service 不解析 Token,只限制长度(≤512 字符)并原样回传。Token 失效时 Provider 返回永久业务错误码,File Service 判为 `FAILED`——不重试,因为重试只会拿到同一个失效 Token(本轮内)。
**"稳定快照"的保证范围必须说准,不要过度承诺。** 不同 Token 实现的保证强度差别很大:
| Token 实现 | 排除导出期间新增 | 冻结导出期间的修改 | 冻结导出期间的删除 |
|---|---|---|---|
| 签名后的最大 ID(最简单,推荐起步) | ✅ | ❌ 后续批次会读到改后的值 | ❌ 已删除的行不再返回,结果行数可能少于 `totalRows` |
| 截止时间戳 + 按 `updated_at` 过滤 | ✅ | ⚠️ 取决于是否有历史版本 | ❌ |
| 数据库快照(如 MySQL 一致性读快照、或导出前落临时表) | ✅ | ✅ | ✅ |
**订单与客户 `CUSTOMER_LIST` 采用"签名后的最大 ID",即只保证不混入新增数据,不保证修改与删除的一致性。** 这对客户列表导出是可接受的——导出期间恰好有客户资料被改动的概率极低,且影响仅是个别字段取值偏新。
因此对外表述应为“**不会混入导出开始后新增的数据**”,而非笼统的“数据快照一致”。若某个导出类型确实需要完整快照(如财务对账),该 Provider 需自行实现临时表或一致性读方案,本契约不阻止,但也不代为实现。
单批响应中的 `totalRows` 为**估算值**,仅用于展示进度百分比,不得用于校验最终行数是否正确——删除会使实际行数偏少,这是上表的直接后果。
### 10.3 注册表
导出类型通过受控 Nacos 配置注册(`file-service.yml`):
```yaml
baiyue:
file:
export:
definitions:
CUSTOMER_LIST:
provider-service: iam-service
provider-path: /internal/exports/v1/CUSTOMER_LIST/batches
required-permission: platform:customer:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
```
限制与启动校验(任一不通过则**启动失败**,不允许降级运行):
- `provider-service` 必须在**代码内置白名单**中(`iam-service`、`order-service`、`warehouse-service`、`firstleg-service`)。只靠 Nacos 校验不够——Nacos 被误改或被入侵即可指向任意服务造成 SSRF;白名单在代码里,配置只能从白名单中选。
- `provider-path` 必须匹配 `^/internal/exports/v1/[A-Z0-9_]+/batches$`,且路径中的类型名必须等于 map 的 key。
- `required-permission` 非空且匹配 `^[a-z]+:[a-z-]+:[a-z-]+$`。
- `app-scope` ∈ `{PLATFORM, MERCHANT}`,用于创建任务时校验请求方访问域。
- `max-rows` ∈ [1, 500000],`batch-size` ∈ [50, 1000],`max-attempts` ∈ [1, 5]。
- key 不重复、不为空(YAML map 天然去重,但需校验非法字符)。
- 注册配置由平台发布流程维护,不能由浏览器动态注册。
配置支持 Nacos 热刷新,但**刷新时同样执行全量校验,校验失败保留旧配置并告警**,不接受非法配置生效。
通过配置注册后,新业务类型不需要修改 File Controller、Consumer、Publisher 或 XLSX Writer。
### 10.4 权限与定义在热更新下的语义(必须明确,否则行为不确定)
任务从创建到执行之间可能间隔数十分钟(排队 + 退避重试),期间 Nacos 注册表可能被改动,用户权限也可能被回收。**必须明确每一项按哪个时点取值**,否则同一个任务在不同时刻执行会得到不同结果。
本文的取值规则:
| 项目 | 取值时点 | 理由 |
|---|---|---|
| `required-permission` 校验 | **创建时** + **人工重试时**,执行阶段不再校验 | 执行发生在后台,此时没有用户会话,无法也不应重新鉴权。任务一旦受理即视为已授权 |
| `enabled` | **创建时** + **人工重试时** | 同上。已受理的任务不因类型临时停用而失败——停用是为了阻止新任务,不是撤销在途任务 |
| `app-scope` | **创建时** | 与权限同批校验 |
| `max_attempts` | **创建时快照进任务表** | 已在 §7.3 落表,重试上限在任务生命周期内恒定,不受配置改动影响 |
| `provider-service` / `provider-path` | **执行时取当前值** | Provider 地址变更(如服务迁移)应当立即对在途任务生效,否则任务会打到已下线的地址 |
| `batch-size` / `max-rows` | **执行时取当前值** | 属于资源保护参数,调小应立即生效以保护下游 |
因此**任务表不保存权限快照,也不保存定义版本号**——需要冻结的只有 `max_attempts`,已单独落表;其余要么只在创建时校验一次,要么刻意读取最新值。这比引入 `definition_version` 字段更简单,且语义明确。
两个必须接受的后果,写明以免日后被当作 bug:
1. **权限被回收后,用户已提交的排队任务仍会执行完成**,文件可下载(下载时会再校验 `ownedTask`,但不校验导出权限)。若需要立即阻断,应由管理员显式取消任务——**本期不提供取消接口**,属已知缺口,后续需要时再加。
2. **导出类型被 `enabled: false` 后,在途任务仍会跑完。** 若要强制停止,运维手段是关 `consumer.enabled` 或直接改任务状态为 `FAILED`。
如果未来出现"必须严格按执行时权限"的合规要求,则需要在任务表增加权限快照字段并在执行前比对——那是一次有意的设计变更,不是本期的疏漏。
## 11. 通用 XLSX Writer
统一 Writer 替换现有 `CustomerExportTaskHandler` 中硬编码的 `HEADERS` 与 `writeRow`。至少支持:
- 类型 `STRING`、`INTEGER`、`DECIMAL`、`BOOLEAN`、`DATE`、`DATETIME`,按类型写入对应单元格类型(当前实现把所有值写成字符串,日期与金额在 Excel 中无法排序计算,本次修正)。
- 空值、超长单元格(截断至 32767 字符并记 WARN)、非法数字、`columns` 中未声明的字段(忽略并记 WARN,不报错)。
- 冻结表头、列宽上限(≤255 字符宽)、稳定 Sheet 名。
- 单 Sheet 行数上限 1048575 行;接近上限自动创建下一 Sheet 并重复表头。按 `max-rows: 200000` 的默认值,正常不会触发,但必须实现。
- `SXSSFWorkbook(100)` 流式写入、`setCompressTempFiles(true)`、临时文件确定性清理。
- **所有 `STRING` 值以字符串单元格写入并对首字符为 `= + - @ Tab CR` 的值加前导单引号**,防止 CSV/Excel 公式注入。
- 上限:最大列数 100、最大行数取注册表 `max-rows`、最大任务时长 30 分钟、最大文件 50MB。任一超限即 `FAILED` 并写明具体上限。
**文件大小上限必须在写入过程中实时检查并提前中止,不能等文件完整落盘后再判断。** 事后检查有两个问题:磁盘已经被写满(50MB 上限形同虚设,实际可能写出几个 GB)、白白消耗了完整的导出时间才失败。
实现方式:在 `SXSSFWorkbook` 的输出流外包一层计数流,累计字节数超过上限立即抛出永久业务错误:
```java
// 写入路径:SXSSFWorkbook.write(countingStream)
// countingStream 内部累加 written,超限即 throw ExportSizeExceededException
```
注意 `SXSSFWorkbook` 的临时文件也会占磁盘(`setCompressTempFiles(true)` 后是压缩的,但仍需空间),行数上限 `max-rows` 是这一侧的主要保护。两道闸门都要有:`max-rows` 防临时文件膨胀,计数流防最终文件膨胀。
同理,Provider 单批响应体 ≤4MB 的限制也应在读取时按 `Content-Length` 或计数流判断,而非完整读入后再检查。
业务服务不得返回样式、公式、宏或任意文件路径。
### 11.1 存储抽象与多实例约束
将现有 `LocalExportStorage` 提取为接口 `ExportStorage`(`create` / `size` / `load` / `delete`),实现类 `SharedDirectoryExportStorage` 保持当前行为与对象键格式 `exports/{taskId}/{uuid}.xlsx`。Consumer、API、Reconciler 只依赖接口。
订单与客户不引入对象存储(§21 决策 6),但接口就位后未来替换为 MinIO/OSS 不需要改动调用方。**对象键始终由 File Service 生成与校验,业务服务不得提供。**
**普通宿主机目录挂载不构成"共享存储",它只在同一宿主机的容器之间共享。** 这是第一版的一处不实表述:部署已有的 `FILE_UPLOADS_HOST_DIR:/data/files` 是宿主机 bind mount,若 File Service 的两个实例被调度到**不同物理机**,各自看到的是各自宿主机上的独立目录——A 实例生成的文件,B 实例既读不到也删不掉,表现为:下载随机 404(请求打到没有该文件的实例)、过期清理留下永久孤儿文件。
因此订单与客户存储的明确边界,**二选一,必须在部署前确认**:
| 方案 | 约束 | 适用 |
|---|---|---|
| **A. 单节点部署**(订单与客户默认) | File Service 只允许 1 个实例;若用编排系统需固定节点亲和,禁止自动扩容 | 1000 用户量级,导出低频,单实例足够 |
| **B. 跨主机共享卷** | 挂载 NFS / CephFS / 云盘共享卷到同一路径 | 需要多实例时 |
选 A 时必须注意:**代码不得因此引入任何单实例假设**——租约、`lease_owner`、条件更新全部保留,因为滚动发布期间会短暂存在两个实例,且方案 B 随时可能启用。单实例是部署约束,不是设计约束。
选 B 时需额外验证:NFS 的 `Files.deleteIfExists` 与 `newOutputStream(CREATE_NEW)` 语义、以及多实例并发写同一目录的表现。
`ExportStorage` 接口的价值正在于此:从 A 迁到 B 只是换挂载,从 A/B 迁到 MinIO 只是换实现类,调用方一行不改。
## 12. 权限与安全
- 创建任务:校验登录身份、`app_code` 与注册表 `app-scope` 匹配、`required-permission` 命中。
- 列表、详情、下载:沿用现有 `ownedTask()` 的租户 + 访问域 + 申请人 + `platform:download:all` 校验。
- Provider 调用使用 `X-Internal-Service-Token`,**不转发用户 Bearer Token**;令牌只从配置中心密文注入。
- File Service 不记录完整行数据、Token 或敏感筛选条件;日志中的 `parameters` 需脱敏。
- 任务参数限制:键数 ≤20、JSON ≤4KB、单字符串 ≤256 字符、嵌套深度 ≤2。
- Provider 响应限制:单批 ≤`batch-size` 行、列数 ≤100、单元格 ≤32767 字符、单批响应体 ≤4MB。
- Provider 服务名与路径必须来自注册表且过代码白名单,防止 SSRF(§10.3)。
- MQ 消息不含任何敏感数据(§6.4)。当前 Compose 仅支持 File Service 与 Broker 位于同一 `infra-network`,Broker 不映射宿主机端口。
## 13. 可观测性
**前置依赖**:必须先在 `common-web/pom.xml` 增加 `micrometer-registry-prometheus`,否则 actuator 的 `prometheus` 端点不可用,本节指标全部无处可采。同时需要落地 Prometheus 服务端(§21 决策 5)。
指标(1000 用户量级的实用集合):
- `export_task_created_total{type}`
- `export_task_completed_total{type,status}`
- `export_task_duration_seconds{type}`(直方图)
- `export_task_queue_age_seconds`(最老 PENDING 任务的等待秒数,Gauge)
- `export_outbox_pending`、`export_outbox_oldest_age_seconds`、`export_outbox_max_attempt`(当前所有待发行中最大的 `attempt_count`,替代原 `export_outbox_dead`)
- `export_provider_request_seconds{service,type}`、`export_provider_failure_total{service,type}`
- `export_reconciler_action_total{action}`(`redispatch` / `lease_recover` / `outbox_release` / `expire` / `orphan_clean`)
- `export_dlq_messages_total`(File Service 的 DLQ Handler 每收到一条消息即递增;无论定位属性是否合法都计数)
- `export_storage_bytes`
日志统一携带 `taskId`、`eventId`、`dispatchVersion`、`exportType`、`tenantId`、`requestId`、`traceId`,禁止打印完整导出数据。
告警:
| 告警 | 阈值 | 含义 |
|---|---|---|
| Outbox 积压 | `export_outbox_oldest_age_seconds > 120` | Publisher 或 Broker 异常 |
| Outbox 反复发送失败 | `export_outbox_max_attempt > 10` | 单条命令连续失败超 10 次,Broker 或网络异常,需人工排查(行本身会继续重试,不会丢) |
| 任务排队过久 | `export_task_queue_age_seconds > 600` | Consumer 未消费或全部忙 |
| DLQ | 任意新消息 | 出现损坏消息 |
| 失败率 | 连续 10 分钟失败率超阈值 | Provider 或存储异常 |
| Reconciler 频繁恢复 | `export_reconciler_action_total{action="lease_recover"}` 10 分钟内 > 3 | 实例不稳定或租约过短 |
| 存储 | 不可写或剩余空间不足 | 需扩容 |
## 14. 限流与资源隔离
| 项 | 取值 | 说明 |
|---|---|---|
| Consumer 并发(= 导出并发) | 4 | `consumeThreadMin = consumeThreadMax = 4`;队列数不构成并发线程硬上限 |
| 单用户同时活动任务 | 3 | 超出返回业务错误 |
| 单 `exportType` 同时活动任务 | 5 | 保护 Provider |
| 重复提交 | 同一用户 60 秒内相同 `exportType` + 相同参数 JSON → 返回已有活动任务 | 参数 JSON 需规范化后比较 |
| Provider 单批 | ≤500 行 | 注册表 `batch-size` |
| Provider 超时 | 连接 3 秒 / 读 **15 秒** | 见下方说明,**必须按导出 Client 单独配置** |
| 最大任务时长 | 30 分钟 | 超时判临时失败 |
| `consumeTimeout` | 90 分钟 | 必须 > 最大任务时长 |
| 优雅停机 | `server.shutdown: graceful`(已配置)+ Consumer `shutdown()` 先停拉取,等待当前批次到安全点,最长 60 秒 | 停机时不完成的任务由租约超时恢复 |
默认并发 4 以测试、生产均约 8C32G 为容量基准,但这 8 核由 File、IAM、MySQL 及其他服务共享,不能按 File Service 独占 8 核估算。当前单实例即使不扩 Topic 队列也能把消费线程调到 4 以上,但不得仅凭 CPU/内存规格直接升到 6 或 8;必须复核 Provider、数据库、磁盘和普通接口延迟。若改为多实例部署,还要同时复核队列分配与共享存储。
上表前三项创建约束必须在 `file_export_task_guard` 行锁保护的同一数据库事务内复核并写入,Controller/Service 中事务外的预检查只能用于快速失败,不能作为并发正确性保证。
File Service 必须显式限制 JVM 堆,不能继续只依赖镜像的 `-XX:MaxRAMPercentage=75.0` 按整机 32GB 计算。测试与生产起步配置为 `-Xms1g -Xmx2g`;若 4 个 20 万行任务并发压测时 GC 后堆占用持续超过 65%,再依据实测调大,不能用扩大堆掩盖未释放的 Workbook、临时文件或响应对象。
**Provider 读超时必须按导出 Client 单独配置,不能沿用全局默认值。** 仓库 `config/nacos/*/baiyue-common.yml` 中 `spring.cloud.openfeign.client.config.default.read-timeout` 当前为 **5000ms**,这对普通接口合适,但导出单批要拉 500 行业务数据(含多表关联),5 秒极易超时,会把正常导出误判为临时依赖错误并触发无谓重试。
因此在 `file-service.yml` 中按 `contextId` 单独覆盖:
```yaml
spring:
cloud:
openfeign:
client:
config:
iamExportProviderClient: &export-provider-timeout
connect-timeout: 3000
read-timeout: 15000
orderExportProviderClient: *export-provider-timeout
warehouseExportProviderClient: *export-provider-timeout
firstlegExportProviderClient: *export-provider-timeout
```
15 秒的取值依据:单批 500 行的正常耗时应在 1~3 秒,15 秒留 5 倍余量吸收抖动。**注意读超时不再是租约的保障**——§8.3 的 watchdog 独立于批次续租,即使单批阻塞 15 秒租约也不会丢;读超时在这里的作用是防止单批无限挂起把消费线程占死到 `consumeTimeout`。
## 15. 数据保留与存储
- `SUCCEEDED` 文件默认保留 7 天(沿用 `FILE_EXPORT_RETENTION_DAYS`)。
- 文件删除成功或确认不存在后,任务才能标记 `EXPIRED`;删除失败保持原状并重试 + 告警(沿用现有实现)。
- `file_export_outbox` 的 `SENT` 行保留 7 天后**物理删除**(每日一次,每次 ≤5000 行分批删除)。清理必须限定 `status = 'SENT'`,绝不能删除 `PENDING` / `CLAIMED` 行——它们是尚未送达的命令。
- `file_export_task` 终态行长期保留(下载中心需要历史),当前量级无需归档。
- 存储:订单与客户按 §11.1 方案 A(单节点)或方案 B(跨主机共享卷)二选一,部署前必须确认。宿主机 bind mount 只在同一主机内共享,**不足以支撑跨主机多实例**。
- 对象键由 File Service 生成和校验,业务服务不得提供。
## 16. 基础设施落地
### 16.1 测试环境 RocketMQ
实际部署入口为 `deploy/docker-compose.rocketmq.yml`,包含单 NameServer、单 Broker 和一次性 Topic 初始化容器(1000 用户量级,无主从):
```yaml
rocketmq-namesrv:
image: apache/rocketmq:5.3.1
command: sh mqnamesrv
restart: unless-stopped
networks: [infra-network]
rocketmq-broker:
image: apache/rocketmq:5.3.1
depends_on: [rocketmq-namesrv]
environment:
ROCKETMQ_BROKER_IP: ${ROCKETMQ_BROKER_IP:?ROCKETMQ_BROKER_IP is required}
volumes:
- ./rocketmq:/config:ro
- ${ROCKETMQ_DATA_DIR:-/data/firstleg/rocketmq/store}:/home/rocketmq/store
command: sh /config/render-broker-config.sh
restart: unless-stopped
networks: [infra-network]
rocketmq-topic-init:
image: apache/rocketmq:5.3.1
depends_on: [rocketmq-broker]
environment:
MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX?MQ_TOPIC_PREFIX must be set}
# 完整的四条幂等 mqadmin 命令见实际 Compose;任一失败由 on-failure 重试
command: sh -eu -c 'until sh mqadmin clusterList -n rocketmq-namesrv:9876 2>/dev/null | grep -q "BaiyueCluster"; do sleep 2; done; ...'
restart: on-failure
networks: [infra-network]
```
**`brokerIP1` 必须在启动时生成,不能在 properties 文件中直接写 `${ROCKETMQ_BROKER_IP}`。** RocketMQ 不会替换该占位符,会把字面量注册到 NameServer,客户端随后表现为发送超时。
因此 `deploy/rocketmq/broker.conf.template` 保存模板:
```properties
brokerClusterName=BaiyueCluster
brokerName=baiyue-file-export-broker
brokerId=0
brokerIP1=__BROKER_IP__
listenPort=10911
deleteWhen=04
fileReservedTime=72
brokerRole=ASYNC_MASTER
flushDiskType=ASYNC_FLUSH
autoCreateTopicEnable=false
autoCreateSubscriptionGroup=false
```
`deploy/rocketmq/render-broker-config.sh` 在启动前使用 `sed` 生成 `/tmp/broker.conf`,并验证地址与残留占位符:
```bash
#!/bin/sh
set -eu
case "${ROCKETMQ_BROKER_IP:-}" in
""|localhost|127.0.0.1|__BROKER_IP__|*'${'*)
echo "ROCKETMQ_BROKER_IP must be an explicit client-reachable address" >&2
exit 1
;;
esac
sed "s/__BROKER_IP__/${ROCKETMQ_BROKER_IP}/g" /config/broker.conf.template > /tmp/broker.conf
if grep -Eq '__BROKER_IP__|\$\{' /tmp/broker.conf; then
echo "broker.conf still contains an unresolved placeholder" >&2
grep -nE '__BROKER_IP__|\$\{' /tmp/broker.conf >&2
exit 1
fi
exec sh mqbroker -n rocketmq-namesrv:9876 -c /tmp/broker.conf
```
脚本生成临时运行配置而不覆盖只读模板,并显式拒绝空值、`127.0.0.1`、`localhost` 和未展开占位符。
当前部署限定客户端与 Broker 同在 `infra-network`,因此 `ROCKETMQ_BROKER_IP` 固定填 `rocketmq-broker`(服务名)。当前 Compose 不映射 `10911/10909`,不得填写宿主机 IP;如未来确需跨主机客户端,必须先增加受防火墙限制的端口映射并重新完成网络验收,不能只改 `brokerIP1`。
`autoCreateTopicEnable=false` 是刻意的:Topic 必须由部署脚本显式创建,避免拼错 Topic 名时静默创建出一个无人消费的新 Topic。
**验收项**:Broker 起来后在 `infra-network` 内的容器中执行 `mqadmin clusterList -n <namesrv>`,确认输出的 Broker 地址是 `rocketmq-broker` 而非字面量或 `127.0.0.1`;再从 File Service 容器内确认 `rocketmq-broker:10911` 可达。这一步不通过,后面所有 MQ 功能都无从验证。
### 16.2 Topic 初始化容器
`deploy/docker-compose.rocketmq.yml` 的 `rocketmq-topic-init` 内联幂等执行:
```bash
mqadmin updateTopic -n "$NAMESRV" -c BaiyueCluster -t "${PREFIX}baiyue-file-export-command-v1" -r 4 -w 4
mqadmin updateSubGroup -n "$NAMESRV" -c BaiyueCluster -g "${PREFIX}baiyue-file-export-worker-v1"
mqadmin updateSubGroup -n "$NAMESRV" -c BaiyueCluster -g "${PREFIX}baiyue-file-export-dlq-handler-v1"
mqadmin updateTopic -n "$NAMESRV" -c BaiyueCluster -t "%DLQ%${PREFIX}baiyue-file-export-worker-v1" -r 1 -w 1 -p 6
```
DLQ Topic 默认权限为只写(2),必须显式改为读写(6),否则无法消费 DLQ 排查问题。
初始化容器必须使用 `sh -eu`,并等待 `clusterList` 输出中明确出现 `BaiyueCluster` 后才执行上述命令;仅以 `clusterList` 退出码为 0 不能证明 Broker 已注册。任一初始化命令失败时容器按 `restart: on-failure` 重试,避免 NameServer 先就绪、Broker 后注册的首次部署竞态。
### 16.3 配置项
`file/src/main/resources/application.yml` 新增:
```yaml
rocketmq:
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
producer:
# 源码默认 test- 仅服务本地/测试;Compose 部署必须显式设置 MQ_TOPIC_PREFIX,生产传空字符串
group: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-publisher-v1
send-message-timeout: 3000
retry-times-when-send-failed: 2
retry-times-when-send-async-failed: 2
spring:
cloud:
openfeign:
client:
config:
iamExportProviderClient: &export-provider-timeout # 覆盖全局 read-timeout:5000,见 §14
connect-timeout: 3000
read-timeout: 15000
orderExportProviderClient: *export-provider-timeout
warehouseExportProviderClient: *export-provider-timeout
firstlegExportProviderClient: *export-provider-timeout
baiyue:
file:
export:
retention-days: ${FILE_EXPORT_RETENTION_DAYS:7}
instance-id: ${HOSTNAME:file-local}
consumer:
# 首次部署必须按 §17/§18 分阶段显式开启;缺少环境变量时保持关闭
enabled: ${FILE_EXPORT_CONSUMER_ENABLED:false}
concurrency: ${FILE_EXPORT_CONSUMER_CONCURRENCY:4}
consume-timeout-minutes: ${FILE_EXPORT_CONSUME_TIMEOUT_MINUTES:90}
max-reconsume-times: 2
publisher:
enabled: ${FILE_EXPORT_PUBLISHER_ENABLED:false}
poll-interval-ms: ${FILE_EXPORT_PUBLISHER_POLL_MS:5000}
batch-size: 50
lease-seconds: 60
reconciler:
enabled: ${FILE_EXPORT_RECONCILER_ENABLED:false}
poll-interval-ms: ${FILE_EXPORT_RECONCILER_POLL_MS:30000}
redispatch-grace-seconds: 60
# 命令已发出多久仍未被执行才判定为丢失并补发,见 §8.5(1)
# 必须大于常态排队时长,且与 §13 的 export_task_queue_age_seconds 告警阈值(600s)一致
sent-stale-seconds: ${FILE_EXPORT_SENT_STALE_SECONDS:600}
task:
lease-seconds: ${FILE_EXPORT_TASK_LEASE_SECONDS:300}
heartbeat-seconds: ${FILE_EXPORT_TASK_HEARTBEAT_SECONDS:60}
max-duration-minutes: ${FILE_EXPORT_MAX_DURATION_MINUTES:30}
max-file-size-mb: ${FILE_EXPORT_MAX_FILE_SIZE_MB:50}
rocketmq:
topic: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-command-v1
consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-worker-v1
dlq-topic: "%DLQ%${MQ_TOPIC_PREFIX:test-}baiyue-file-export-worker-v1"
dlq-consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-dlq-handler-v1
```
`consumer.enabled` / `publisher.enabled` / `reconciler.enabled` 三个独立开关是**部署与回滚的关键**(§17、§18)。`instance-id` 用于 `lease_owner`,容器环境取 `HOSTNAME`。
Publisher 的退避档位由实现固定为 5s / 15s / 1m / 5m / 15m,超过后固定 15m 持续重试;当前没有 `max-backoff-minutes` 配置项,避免文档出现不会被代码读取的伪配置。源码中的 `MQ_TOPIC_PREFIX:test-` 是本地安全默认值,部署环境仍必须由 Compose 的必填校验显式传入,见 §6.2 与 §18。
移除旧配置 `worker-delay-ms`、`stale-running-minutes`(由 Reconciler 参数取代)。
### 16.4 依赖变更
| 模块 | 变更 |
|---|---|
| `platform-bom` | 新增 `rocketmq-spring-boot-starter:2.3.3` 的版本管理(属性名建议 `rocketmq-spring.version`) |
| `common-core` | 新增包 `com.baiyue.common.core.export`:Provider 请求/响应 DTO、列定义与类型枚举、MQ 命令消息、注册表配置模型。均为 record,不引入新依赖(jackson 由 `spring-web` 传递已可用) |
| `file` | 新增 `rocketmq-spring-boot-starter` |
| `iam` | **无需改 pom**(已通过 `common-web` 传递依赖到 `common-core`),直接实现 Provider 接口 |
| `common-web` | 新增 `micrometer-registry-prometheus` |
| `firstleg` | **删除** 未使用的 `spring-cloud-starter-stream-rocketmq` |
| 根 `pom.xml` | 不变(不新增模块,仍为 18 个) |
**不新建 `common-export-contract` 模块。** 关键约束:Provider 契约是 file 与 iam 双方共用的 HTTP 结构,若只放在 `file` 模块内,iam 就必须依赖 `file-service`,会把 POI、MyBatis、任务表实体全部拖入 iam 的 classpath,并形成业务服务反向依赖 file 的结构,违反 AGENTS.md 的“服务间调用先找调用方 `client/`、不共享领域 Entity”。放入 `common-core` 既满足双方共用,又不新增模块——iam 与 file 均已通过 `common-web` 传递依赖到 `common-core`,两边 pom 都不用动。
## 17. 实施与切换
### 阶段 1:基础设施与契约(无行为变更)
1. 测试环境起 NameServer + Broker,执行 Topic 创建脚本,`mqadmin topicList` 核验。
2. `common-core` 新增 `com.baiyue.common.core.export` 包(契约 record),`platform-bom` 登记 rocketmq starter 版本。
3. `common-web` 加 Prometheus registry,核验 `/actuator/prometheus` 返回指标。
4. 按 §7.3 与 §8.3.1 完善 `file/src/main/resources/db/file.sql`:补齐 `file_export_task` 的 8 个字段与 2 个索引,加入 `file_export_outbox` 与 `file_export_task_guard` 建表语句。测试环境确认任务数为 0 后删除这三张导出表并重新执行,核验方式见 `deploy/sql/README.md`。
5. 删除 `firstleg` 的死依赖。
6. 现有 `ExportTaskWorker` 保持运行,行为不变。
### 阶段 2:Provider 与 Writer(仍走旧调度)
1. IAM 实现 `/internal/exports/v1/CUSTOMER_LIST/batches`,含租户校验与 `snapshotToken`。
2. File 实现通用 `ExportProviderClient` 与通用 XLSX Writer、`ExportStorage` 接口、Nacos 注册表与启动校验。
3. `CustomerExportTaskHandler` 改为走通用链路,删除硬编码列定义与 `IamExportClient`。
4. 仍由 `ExportTaskWorker` 调度执行,**先验证数据正确性**:导出结果与旧版逐列比对一致。
这一步把“数据链路正确性”与“调度机制正确性”分开验证,任一环节出问题都能独立定位。
### 阶段 3:MQ 调度上线
1. 部署带 Publisher + Consumer + Reconciler 的 File Service,三个开关**全部关闭**,确认服务健康。
2. **停用 `ExportTaskWorker.processNext()`**(保留 `expire` 逻辑迁入 Reconciler)。这里不做“新旧双轨执行”——旧 `claim()` 既不检查 `next_retry_at` 也不检查 `dispatch_version`(`ExportTaskRepository.java:56`),双轨期间它会提前执行退避窗口内的任务并绕过版本校验。双轨的复杂度换不来任何安全性。
3. 开启 `reconciler.enabled` → 观察 `export_reconciler_action_total{action="redispatch"}`,确认存量 PENDING 被补发 Outbox。
4. 开启 `publisher.enabled` → 观察 Outbox `SENT` 增长、Broker 消息堆积。
5. 开启 `consumer.enabled` → 任务开始执行,观察端到端。
6. 逐项跑完 §19 故障注入清单。
分步开启的价值:每一步只引入一个变量。若第 4 步后消息发不出去,问题必定在 Publisher 或 Broker,与 Consumer 无关。
### 阶段 4:清理
1. 删除 `ExportTaskWorker`、`firstPendingId`、`requeueStale` 及其测试。
2. 更新 `docs/公共导出与下载中心接入说明.md` 与 `AGENTS.md` 的 `file/` 模块条目。
3. 补充新业务类型接入清单(Provider 实现 + Nacos 注册两步)。
### 回滚
MQ 调度启用后的安全回退方式是**关配置开关暂停调度**,不需要回滚数据库结构;这不是恢复旧调度器:
- 关 `consumer.enabled`:停止领取新任务;尚未领取的任务保持 `PENDING`,在途 `RUNNING` 任务按优雅停机完成,未完成的由租约超时后 Reconciler 恢复。
- 同时关闭 `publisher.enabled` 与 `consumer.enabled`:停止发送新命令并停止执行;Outbox 与任务状态保留,重新开启后自动追平。
- **禁止恢复旧 `ExportTaskWorker`。** 它不检查 `next_retry_at` 与 `dispatch_version`,会绕过退避和版本保护;阶段 4 删除后也不再具备该回滚路径。如需回滚应用版本,必须先确认旧版本兼容当前表结构与任务状态,并按独立发布回滚方案执行,不能仅靠开关假定回到阶段 2。
- 数据库中的任务与状态始终完整,Outbox 行不会丢。
## 18. 部署顺序
```text
RocketMQ NameServer/Broker 就绪
-> Topic / ConsumerGroup / DLQ 创建
-> 测试环境执行 file.sql(任务表 + Outbox 表)
-> Nacos 发布导出类型注册表
-> 业务 Provider(IAM)上线
-> File Service 上线(三开关全关)
-> 开 Reconciler -> 开 Publisher -> 开 Consumer
-> Admin 验收
```
环境变量需在 `deploy/docker-compose.yml` 与 Jenkins 中透传:`ROCKETMQ_NAME_SERVER`、`MQ_TOPIC_PREFIX`、`ROCKETMQ_BROKER_IP`、`FILE_EXPORT_CONSUMER_ENABLED`、`FILE_EXPORT_CONSUMER_CONCURRENCY`、`FILE_EXPORT_PUBLISHER_ENABLED`、`FILE_EXPORT_RECONCILER_ENABLED`。Compose 会在单服务部署前插值整份文件,因此不能在 File 服务块使用 `${MQ_TOPIC_PREFIX?...}` 阻断 Auth/IAM 等无关服务;`deploy/scripts/deploy-services.sh` 只在部署 File 时执行环境级强校验。`MQ_TOPIC_PREFIX` 对 File 不得省略:测试必须显式设为 `test-`,生产必须显式设为空字符串。测试与生产的 `FILE_EXPORT_CONSUMER_CONCURRENCY` 初始值均显式设为 `4`,不只依赖应用默认值;三个启用开关首次上线均显式设为 `false`,再按本节顺序逐项改为 `true`。File 容器需要 `HOSTNAME` 可用(Docker 默认提供)。
## 19. 测试与验收门槛
### 19.1 单元测试(必须全绿)
- 任务与 Outbox 同事务创建;任务插入失败时 Outbox 不落库。
- 并发创建:两请求同时命中重复提交或活动任务上限时,由 `file_export_task_guard` 串行复核,最终只创建允许数量的任务与 Outbox。
- Publisher:到点领取、未到点不领取、并发领取只有一个成功、发送成功标记 `SENT`、发送失败退避、**连续失败 30 次后仍保持 `PENDING` 继续重试(无终态放弃)**、租约超时可被重新领取。
- Consumer 抢占:**§8.2 表格的七个分支逐一覆盖**,每个分支都必须断言返回 `CONSUME_SUCCESS`。
- Consumer:执行成功、永久失败直接 `FAILED`、临时失败递增版本并写新 Outbox、达到 `max_attempts` 转 `FAILED`。
- **watchdog 续租覆盖写盘阶段**(§8.3 的核心):模拟 Provider 拉取结束后 `write()`/压缩耗时超过租约时长,断言 watchdog 期间持续续租、任务未被 Reconciler 剥夺、最终 `SUCCEEDED`。这是第一版按批次续租时会失败的场景。
- watchdog:租约被剥夺时置中止标志,执行线程在下一个可中断点中止、删除半成品文件、不写任务表;watchdog 自身异常也置中止标志。
- deadline:抢占时按数据库时钟写入;超过 deadline 后 watchdog 不再续租并触发中止;Java 预检查通过但最终提交 SQL 执行时已超时的竞态必须提交失败、删除文件并进入超时重试。
- **时间基准**:所有租约与退避判断走数据库 `CURRENT_TIMESTAMP(3)`;构造实例本地时钟快 5 分钟的场景,断言不会提前抢占他人租约。
- 文件大小:写入超过 50MB 时在**写入过程中**抛错中止,断言磁盘上不存在超限的完整文件。
- 结果提交:版本已变时提交失败并删除孤立文件。
- Reconciler:四项职责各自幂等;`redispatch` 只恢复或补写同版本 Outbox,`lease_recover` 才递增版本且达上限转 `FAILED`。
- **Reconciler 不得跳过退避窗口**:构造一个刚自动重试、`next_retry_at` 在 15 分钟后的 `PENDING` 任务,其 Outbox 行已为 `SENT`;断言 Reconciler 连续多轮都不动它,直到 `next_retry_at` 到点后 60 秒才补投。
- **Reconciler 补投不递增任务版本**(§3 原则 5,最关键的一条):构造一个 `PENDING` 任务、其 Outbox 行为 `SENT` 且 `sent_at` 已超窗口;断言 Reconciler 把该行改回 `PENDING` 且 `dispatch_version` **保持不变**。
- **排队任务不被饿死**(阻断项 1 的回归测试):模拟并发已满、任务在队列中等待超过 `sentStaleSeconds`;断言补投后原命令仍能成功抢占执行(版本未变故未失效),任务最终 `SUCCEEDED`。
- **补投两种情形**:无 Outbox 行 → 补写同版本新行;`SENT` 超窗口 → 原行改回 `PENDING`。两者都断言版本不变。
- **补投不受 `max_attempts` 约束**:`attempt_count` 已达上限的 `PENDING` 任务,其丢失的命令仍应被补投(补投不是执行尝试)。
- **重复命令幂等**:同版本两条命令并发消费,只有一个抢占成功执行,另一个见 `RUNNING` 后确认消费;最终只有一个有效文件。
- **执行恢复受重试上限约束**:`RUNNING` 租约超时且 `attempt_count >= max_attempts` 时任务应置 `FAILED`;不得把这条约束误用于 `PENDING` 任务的同版本消息补投。
- Outbox `attempt_count` 与任务 `attempt_count` 相互独立:任务重试写入的新 Outbox 行,其 `attempt_count` 从 0 开始。
- 人工重试:只允许 `FAILED`、停用类型拒绝、递增版本、清空上次现场。
- 注册表:白名单外服务名启动失败、路径格式非法启动失败、路径与 key 不一致启动失败、热刷新非法配置保留旧值。
- Provider 客户端:租户字段必填、`schemaHash` 变化判永久失败、游标未推进判临时失败、超时判临时失败。
- XLSX Writer:六种类型写入正确单元格类型、公式注入前缀转义、超长截断、多 Sheet 切换、**真实打开生成的 xlsx 并逐格校验**。
- Producer:四个定位字段同时写入 payload 与 RocketMQ User Properties,且与 Outbox 列一致。
- 主 Consumer 反序列化失败时不确认消费;DLQ Handler 使用原始 `MessageExt` 属性自动执行 `QUARANTINED + FAILED` 同事务更新,任一步失败则回滚且不确认;旧版本、eventId 不匹配或缺属性消息不得误伤当前任务。
- Reconciler 永不补投 `QUARANTINED`;隔离任务人工重试生成新版本 Outbox,旧隔离行保持不变。
### 19.2 集成测试(连测试环境真实 RocketMQ)
不引入 Testcontainers(§21 决策 8)。集成测试通过 profile 连接测试环境 Broker,默认在 CI 中跳过,由人工或独立 Job 触发:
真实任务的创建、终态轮询、下载和 XLSX 签名校验统一由 `scripts/export-center-test-env-acceptance.ps1` 执行;使用 `-CreateOnly` 与 `-TaskIds` 可把故障期间创建和基础设施恢复后的追平验证拆成两段。脚本不拥有部署或停服权限,Broker/File/IAM/MySQL 的故障注入仍由有权限的运维或独立 Job 执行。
- 数据库提交后停 Broker,恢复后任务仍被执行(Outbox 退避生效)。
- Publisher 发送成功但未标记 `SENT`(注入异常),重复投递只产生一个有效文件。
- Consumer 执行中 `kill -9`,租约超时后 Reconciler 恢复并最终成功,且不留孤立文件。
- Provider 503 两次后成功,`attempt_count` 与退避符合预期。
- Provider 持续 503 至 `max_attempts`,任务 `FAILED`,`export_task_completed_total{status="FAILED"}` 增长,**DLQ 无消息**。
- 手工投递一条无法反序列化但携带完整 User Properties 的消息,确认进入 DLQ 后被 DLQ Handler 自动隔离:Outbox 为 `QUARANTINED`、任务为 `FAILED`、告警触发,等待超过 `sentStaleSeconds` 后也不会再次进入 DLQ。
- 投递缺少定位属性的畸形 DLQ 消息,确认 Handler 记录 CRITICAL 与 Broker messageId 后确认,不形成二级 DLQ;不得误伤当前任务版本。
- 双实例并发消费同一消息,只生成一个有效文件、只有一个 `SUCCEEDED`。
### 19.3 测试环境验收
- Admin 客户列表创建真实任务,下载中心显示排队 / 执行中(进度递增)/ 重试 / 完成 / 过期。
- 下载 XLSX 与筛选条件一致,日期与金额列在 Excel 中可排序计算。
- 导出过程中新增一条客户数据,确认不混入本次结果(快照生效)。
- 重启 File、停 Broker、停 IAM 各一次,恢复后任务无丢失。
- 普通用户无法访问他人任务;无 `platform:customer:export` 权限无法创建。
- 20 万行导出跑通,观察内存、文件大小、耗时在 §14 上限内。
- 连续提交 10 个任务,确认按并发 4 排队执行,Provider 未被打满。
- 同时执行 4 个 20 万行导出,确认整机 CPU 持续低于 70%、File Service GC 后堆占用低于 65%、磁盘无持续满负载、Provider P95 < 5 秒且 P99 < 10 秒,IAM 普通接口 P95 相比无导出基线恶化不超过 20%。任一项不满足则生产回退并发 2,不以机器总内存充足替代链路压测。
## 20. 通过标准
- 不采用“数据库提交后直接发送 MQ”的非可靠方案。
- **`dispatch_version` 只因执行尝试递增,消息补投不递增版本**(§3 原则 5)——这是本轮评审的核心修正,防止排队任务饥饿与 DEAD 无限循环。
- Outbox 无终态失败:发送永不放弃,靠告警而非状态机驱动人工介入。
- 租约续期由独立 watchdog 覆盖全执行期,包含最终写盘与压缩阶段。
- 所有租约与退避判断使用数据库时钟,不使用实例本地时钟。
- 任务状态不放入 Redis,Redis 不承担重试队列。
- 业务服务不自行实现任务、Excel、存储和下载中心。
- File Service 不跨库读取业务数据。
- Provider 契约包含 `tenantId` / `appCode` / `requestedBy`,且 Provider 侧强制按其过滤。
- 权限与定义在热更新下的取值时点已逐项明确(§10.4),无未定义行为。
- 存储的多实例边界已明确(§11.1),不把宿主机 bind mount 当作跨主机共享存储。
- MQ 不承担延迟与业务重排;Reconciler 是唯一重派通道;DLQ 只承载损坏消息。
- Outbox 与任务均有租约,无“领取后崩溃即永久卡死”的路径。
- 至少一次投递、幂等、租约、Outbox、Reconciler、DLQ、指标与告警全部纳入订单与客户实现。
- 新业务类型能通过 Provider 契约 + 受控注册接入,不复制客户导出代码。
## 21. 已定型决策
以下决策已在本文中定型,不再作为评审开放项。列出理由以便后续维护者理解取舍。
| # | 决策 | 取值 | 理由 |
|---|---|---|---|
| 1 | 订单与客户是否含 MQ | 含 | 业务方已定。可靠性由数据库承担,MQ 引入不降低稳定性 |
| 2 | MQ 客户端 | `rocketmq-spring-boot-starter` 2.3.3,Remoting 协议 | 需要显式控制消费返回语义;不引入 Proxy 少一个可失效组件;同时删除 `firstleg` 的 Stream 死依赖。Boot 3.2.9 兼容性与 `rocketmq-client 5.3.1` 传递版本已实证核验,见 §6.1 |
| 3 | Outbox 是否抽公共模块 | 订单与客户不抽,`file_export_outbox` 独立建表并作为全仓基线 | 订单与客户只有一个真实使用者,过早抽象会把接口定错。`order` 的 Outbox 目前**根本没有发布器**,是独立待修问题;后续收敛时以本表结构为准(含 `lease_owner`/`lease_until`/`next_retry_at`),命名统一为 `<模块>_outbox` |
| 4 | 契约放哪 | 放入 `common-core` 的 `com.baiyue.common.core.export` 包,**不新建模块**,模块总数仍为 18 | 契约是 file 与 iam 双方共用的 HTTP 结构。放 `file` 内会迫使 iam 依赖 `file-service`(拖入 POI/MyBatis/任务表实体,且业务服务反向依赖 file);新建模块则多一个模块。放 `common-core` 两全:双方已通过 `common-web` 传递依赖到它,pom 都不用改。与 `common-event-contract` 不合并——后者是跨区域事件信封(含 `sourceRegion`/`payloadHash` 等无关字段),强行复用会互相牵制 |
| 5 | Prometheus | 订单与客户补 `micrometer-registry-prometheus`;服务端由运维落地 | 无 registry 则 §13 全部指标无处可采,等于没有可观测性 |
| 6 | 文件存储 | 订单与客户共享目录挂载 + `ExportStorage` 接口抽象 | 1000 用户、7 天保留、单文件 ≤50MB,总量在 GB 级,共享目录足够。引入 MinIO 多一个有状态组件与备份负担。接口就位后替换不改调用方 |
| 7 | 导出类型注册 | Nacos 受控配置 + 代码内置服务白名单 + 启动强校验 | 纯 Nacos 有 SSRF 面;纯代码注册每加类型都要发版。两者结合:配置只能从白名单里选 |
| 8 | Testcontainers | 不引入,集成测试连测试环境 Broker | 全仓无 Testcontainers 先例,RocketMQ 容器需同时起 NameServer+Broker 并处理 `brokerIP1`,投入产出比低于直接用测试环境 |
| 9 | 新旧双轨 | 不双轨,阶段 3 直接停用旧 Worker | 旧 `claim()` 不识别 `next_retry_at` 与 `dispatch_version`,双轨会提前执行退避中的任务。回滚靠配置开关,不靠双轨 |
| 10 | Consumer 执行模型 | 消费线程同步执行,并发 4,`consumeTimeout` 90 分钟 | 8C32G 共享部署环境下以 4 作为压测起始值;避免自建线程池的“队列满怎么办”与“已抢占无线程执行”两个失败模式;MQ 队列本身就是背压 |
| 11 | 断点续传 | 不做,每次从零重写 | 20 万行 XLSX 生成在分钟级,续传的状态管理复杂度远超收益 |
| 12 | 默认参数 | `max-rows` 20 万、单文件 50MB、并发 4、租约 300 秒、`max_attempts` 3、退避 1m/15m | 见 §14、§22;生产上线前必须通过 4 个最大任务并发压测 |
| 13 | 数据库变更方式 | 开发阶段直接完善 `file/src/main/resources/db/file.sql`,测试环境 `DROP TABLE` 后重建,**不写增量迁移脚本** | 导出功能尚无生产数据,增量脚本的复杂度换不来价值。**限定条件**:一旦上生产、表内有真实数据,后续变更必须回到 AGENTS.md 的可重复执行增量模式 |
| 14 | 版本递增的唯一来源 | 仅执行失败重试、租约恢复、人工重试三处;**补投不递增** | 第二轮评审的核心修正。混淆投递失败与执行失败会造成排队任务饥饿(活性错误)与 DEAD 无限循环,见 §3.1 |
| 15 | Outbox 失败终态 | 取消 `DEAD`,发送无限退避重试 + 告警 | 设终态会让 Broker 长期故障时任务静默停滞,违背 §2.1 的“提交成功的任务最终一定被调度”。靠告警驱动人工介入,Broker 恢复后自动追平 |
| 16 | 续租方式 | 独立 watchdog 线程按 60 秒周期,覆盖全执行期 | 挂在批次循环里会漏掉最终 `write()`/压缩/落盘阶段,慢磁盘下大文件会在临成功时被误判失联并反复重跑 |
| 17 | 时间基准 | 全部走数据库 `CURRENT_TIMESTAMP(3)` | 实例时钟偏差会导致提前抢占(两实例同写一任务)或延迟恢复 |
| 18 | 多实例部署 | 代码支持,**订单与客户部署按单节点**;需多实例时先上跨主机共享卷 | 宿主机 bind mount 只在同主机内共享,跨主机会导致下载随机 404 与永久孤儿文件(§11.1) |
| 19 | 快照强度 | 订单与客户只保证“不混入导出开始后新增的数据”,不冻结修改与删除 | 最大 ID 游标的固有限制。对客户列表可接受;需要完整快照的类型由该 Provider 自行实现临时表或一致性读(§10.2) |
| 20 | 权限取值时点 | 创建与人工重试时校验,执行阶段不校验;不存权限快照 | 执行在后台无用户会话,无法重新鉴权。已受理即视为已授权,代价与已知缺口写明于 §10.4 |
| 21 | 毒消息处置 | 独立 DLQ Handler 自动将匹配的 Outbox 置 `QUARANTINED`、任务置 `FAILED` | 人工隔离无法保证在 Reconciler 10 分钟补投窗口内完成;定位字段放 User Properties,避免依赖损坏 payload |
仍需运维/管理侧确认(不影响设计定型):
- 测试与生产环境 Broker 的机器资源与 NameServer 地址。
- Prometheus 服务端与告警通道的落地责任人。
- DLQ 的值班责任人(Outbox 不再有终态失败,靠积压告警驱动,无需单独值班)。
## 22. 容量基准与放大路径
本文参数按平台总用户约 1000、导出为低频后台操作取值。估算:日导出任务 < 50,峰值并发提交 < 10,单任务行数通常 < 5 万。
| 维度 | 当前取值 | 何时需要调整 | 调整方式(不需改架构) |
|---|---|---|---|
| Consumer 并发 | 4 | 排队时长告警持续触发,且 §14 的 CPU、堆、磁盘、Provider 与 IAM 延迟门槛均有余量 | 单实例可直接压测更高 `concurrency`;增加 File 实例前同时复核 Topic 队列分配并启用共享存储 |
| Broker | 单节点 ASYNC_MASTER | 对消息可靠性要求提高 | 加 Slave 或 DLedger;Outbox 保证不丢任务,Broker 丢消息由 Reconciler 补发 |
| Topic 队列数 | 4 | File 实例数接近/超过队列数,或出现队列分配不均、单队列热点 | 用 `mqadmin updateTopic -r/-w` 扩容;并发消费下队列数不需要等于线程总数 |
| 存储 | 共享目录 | 总量超过单机磁盘或需异地备份 | 实现 `ExportStorage` 的 MinIO/OSS 版本,替换 Bean |
| `max-rows` | 20 万 | 出现更大导出需求 | 调注册表;超过 100 万需考虑分文件打包(新需求,非本文范围) |
| Reconciler 周期 | 30 秒 | 任务量增长使单轮扫描变慢 | 缩小 `LIMIT` 并提高频率,或加分片条件 |
**关键点:以上任何调整都只涉及配置或单个实现类,不触及 Outbox、幂等、租约、Reconciler 这套核心机制。** 这是本文追求“不需要改第二次”的具体含义——不是参数永不变,而是机制不用重做。
## 23. 关联文档
- 当前已实现版本的接入说明:[公共导出与下载中心接入说明](./公共导出与下载中心接入说明.md)(阶段 4 完成后需同步更新)
- 统一响应与 HTTP 200 约定:[统一响应约定改造方案-HTTP200](./统一响应约定改造方案-HTTP200.md)
- 目录与服务边界:[头程项目目录结构与服务说明](./头程项目目录结构与服务说明.md)
- 本模块表结构事实来源:`file/src/main/resources/db/file.sql`(开发阶段直接维护建表语句,不走增量脚本,见 §7.3)
- 生产化后的增量执行边界:`deploy/sql/README.md`