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

102 KiB
Raw Permalink Blame History

公共导出中心 RocketMQ 架构设计(第四轮快速评审定稿)

状态:设计已实施并通过本地自动化回归,待完成 §19.2/§19.3 测试环境真实验收 日期:2026-08-03 修订说明:第一版曾标记“定稿”,经评审发现 3 个阻断级可靠性问题(Reconciler 导致排队任务饥饿、DEAD 策略自相矛盾且可无限循环、brokerIP1 环境变量不会被替换)及 7 项边界缺陷,均已在本稿修正。核心修正是确立 §3 原则 5:消息补投不产生新版本。 第四轮快速评审:确认 8C32G 环境以并发 4 作为压测起始值;补齐并发环境变量透传,并将三个调度开关改为默认关闭,避免首次部署绕过分阶段启用顺序。 适用范围:baiyue-firstleg 内需要异步导出和下载中心能力的业务服务 容量基准:平台总用户约 1000,导出为低频后台操作。本文所有定量参数按该基准取值,并在 §22 说明放大路径。

1. 结论摘要

将现有 File Service 的“数据库任务表 + 定时扫描并直接执行”升级为:

任务表 + 事务 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 实施前代码基线(评审时已核实)

以下表格记录设计启动时用于制定迁移方案的历史基线,不代表当前代码状态;当前已实现能力以 公共导出与下载中心接入说明 和实际源码为准。

事项 实际状态
任务执行 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. 总体架构

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

创建任务请求仍为:

{
  "exportType": "CUSTOMER_LIST",
  "format": "XLSX",
  "taskName": "客户列表",
  "parameters": {
    "keyword": "柏悦",
    "status": "ACTIVE"
  }
}

浏览器不得提交 Provider 服务名、URL、Topic、消费组或所需权限。File Service 依据 exportType 查询受控注册表。响应中所有 Long 仍按仓库约定输出为字符串,业务成功与失败统一 HTTP 200 并以响应体 code == "200" 判断。

任务状态机(新增自动重试路径):

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,不新建模块):

{
  "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

以下写法不可接受:

INSERT task -> COMMIT -> SEND MQ

数据库提交后进程退出,消息不会发送,任务永久停留。反过来先发 MQ 再提交数据库,Consumer 可能收到不存在的任务。

7.2 表结构

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 之前,保持读表时调度字段聚集):

  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 '最后心跳时间',

并在该表的索引区补入两个索引:

  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 的锁范围问题:

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):

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 秒续租一次:

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 中增加一列:

       execution_deadline_at = CURRENT_TIMESTAMP(3) + INTERVAL :maxDurationMinutes MINUTE,

对应 file_export_task 增加字段(与 §7.3 的 7 个字段一并落库,共 8 个):

  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 中顺带判断:

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 也会兜底得到相同结果——两条路径殊途同归,这是刻意的冗余。

租约、续租与超时的约束关系(实施时必须同时满足):

续租周期(60s) << 租约(300s) << consumeTimeout(90min)
Provider 读超时(15s) << 租约(300s)
最大任务时长(30min) < consumeTimeout(90min)

有了 watchdog,Provider 读超时不再是租约正确性的唯一保障(续租独立于批次),但仍必须按 §14 配置——否则单批可以无限挂起,任务会一直占着消费线程直到 consumeTimeout。

8.4 结果提交

文件写入完成后:

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 行:

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 重发同一版本:

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) 恢复超时租约:

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(补投重发同版本)后,出现了一个新的循环路径,必须显式阻断:

消息 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 消息:

-- 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。

POST /internal/exports/v1/{exportType}/batches
X-Internal-Service-Token: ...
Content-Type: application/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 的数据范围过滤;
  • 只读,不得产生副作用。

首批响应:

{
  "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):

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 的输出流外包一层计数流,累计字节数超过上限立即抛出永久业务错误:

// 写入路径: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 单独覆盖:

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 用户量级,无主从):

  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 保存模板:

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,并验证地址与残留占位符:

#!/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 内联幂等执行:

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 新增:

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. 部署顺序

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. 关联文档