# 公共导出中心 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%`。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%`,需在部署脚本中显式创建并授予读写权限(§16.2)。 - DLQ 出现任意消息即告警,因为按 §9.1 它只承载损坏消息。 - DLQ Handler 必须自动执行 §9.4.2 的隔离;运维收到告警后只负责分析原因与决定是否人工重试。禁止依赖值班人员在 10 分钟补投窗口内手工运行 SQL。 - DLQ Handler 的数据库处置失败必须持续重试(`maxReconsumeTimes = Integer.MAX_VALUE`),不能在有限次数后进入无人消费的 `%DLQ%` 二级 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 `,确认输出的 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`