## Context 当前 `cmd/worker/main.go` 的启动入口把两类职责放在同一进程中: - 可横向扩容的被动消费职责:Asynq Worker Server 和全部任务 Handler - 必须单例运行的主动发起职责:`PollingInitializer`、`PollingScheduler`、套餐激活/流量重置扫描、Asynq Scheduler 其中 Asynq Worker Server 天然适合多实例消费任务,但完整复制当前 worker 会同时复制主动调度模块。当前 Asynq v0.25.1 的 `Scheduler` 是进程内 cron,到点直接 `Enqueue`,不是跨进程单例;轮询初始化器也会启动后全量扫库并写 Redis 分片队列;流量重置节流状态是进程内字段。因此第一期必须先通过显式角色把部署语义收敛清楚。 本变更只处理“角色拆分和启动编排”,不改变已有 Handler、Service、Store、Model 的业务逻辑,不新增数据库表,也不改 Asynq 任务载荷。 ## Goals / Non-Goals **Goals:** - 新增 `all`、`leader`、`consumer` 三种 Worker 角色。 - 默认 `all`,旧部署不配置新环境变量时行为完全不变。 - `consumer` 只运行任务消费相关模块,不运行轮询初始化器、轮询调度器和 Asynq Scheduler。 - `leader` 与 `all` 的运行模块一致,但部署语义明确为单例主动调度实例。 - 启动日志可直接证明当前实例的角色和模块启停情况。 - 关闭流程按实际启动模块执行,避免 `consumer` 因未创建调度器而空指针或重复关闭。 **Non-Goals:** - 不实现 Redis Leader 锁或自动选主。 - 不为套餐激活、订单超时等任务补充唯一性去重。 - 不改 `PollingScheduler` 的调度算法和 Redis 分片队列实现。 - 不调整 `queue.concurrency`、队列权重或业务 Handler 幂等逻辑。 - 不新增 API、数据库迁移、DTO 或 OpenAPI 文档生成器改造。 - 不新增自动化测试文件;按项目根规范使用编译和人工日志验证。 ## Decisions ### Decision 1:用显式配置角色,而不是自动探测或自动选主 **选择**:新增 `worker.role`,支持 `all`、`leader`、`consumer`,通过环境变量 `JUNHONG_WORKER_ROLE` 覆盖。 **备选方案**:启动时所有 worker 抢 Redis 锁,抢到锁者自动成为 leader。 **理由**:第一期目标是最小、安全、可回滚。显式角色能立即阻止完整进程被误横向复制,改动集中在配置和启动编排;自动选主需要续期、失锁处理、调度器停止/重启语义和故障切换策略,适合第二期单独设计。 ### Decision 2:保留 `all` 作为默认兼容模式 **选择**:默认 `worker.role=all`。 **备选方案**:默认改为 `leader`。 **理由**:`all` 与当前运行行为完全一致,现有生产、本地开发和临时脚本不需要增加环境变量。`leader` 用于部署语义表达,等生产灰度确认后再从 `all` 切换到 `leader`。 ### Decision 3:`consumer` 初始化共享依赖,但不启动单例模块 **选择**:`consumer` 仍初始化 Redis、PostgreSQL、Storage、Gateway、`BootstrapWorker`、`PollingConfigManager`、`PollingQueueManager`、`PollingBase`、`PollingLifecycleService`、TaskHandler 和 Asynq Worker Server。 **理由**:轮询任务 Handler 执行后需要读取轮询配置、执行并发控制、更新卡缓存、重入队分片队列;导入任务也可能触发轮询生命周期回调。因此 `consumer` 不是“瘦到只连 Asynq”的进程,而是“只不主动调度”的完整任务消费进程。 **边界**:`consumer` 不创建或不启动 `PollingInitializer`、`PollingScheduler`、Asynq Scheduler;如需为构造依赖创建可选对象,也必须保证不会启动其后台 goroutine。 ### Decision 4:角色常量集中定义,启动逻辑用 helper 降低误用 **选择**:新增 Worker 角色常量,并提供类似 `RunsSingletonModules()` 的判断逻辑,避免在 `main.go` 多处硬编码字符串。 **位置约束**:遵循项目常量规范,角色字符串应放在 `pkg/constants/`,并添加中文注释;`pkg/config` 和 `cmd/worker` 通过常量判断角色。 **理由**:角色字符串会出现在配置校验、启动模块判断、日志输出和文档中,集中定义可以避免 `"consumer"` 这类 magic string 分散。 ### Decision 5:关闭流程记录已启动模块,而不是假设全部存在 **选择**:为 Asynq Scheduler、Polling Scheduler、Polling Initializer 使用可选变量或启动状态标记;关闭时只关闭已启动模块。 **理由**:`consumer` 不启动单例模块,关闭逻辑如果无条件 `Shutdown/Stop` 会产生 nil panic 或关闭未运行对象。按启动状态关闭也让 `all/leader/consumer` 三种角色的生命周期可读。 ### Decision 6:验证以编译和人工运行日志为准 **选择**:使用 `go build ./cmd/worker`、`go build ./...`、三种角色启动日志检查作为验收。 **理由**:项目根规范明确禁止自动化测试规划和 `*_test.go` 文件;本变更也主要是进程编排,人工日志能直接验证模块启停契约。 ## Risks / Trade-offs | 风险 | 缓解措施 | |------|----------| | 部署层误把两个实例都配置成 `leader` | 第一期通过日志暴露误配;第二期再补 Redis Leader 锁 | | `consumer` 漏初始化轮询依赖导致轮询任务无法重入队 | `consumer` 必须保留 `PollingConfigManager`、`PollingQueueManager`、`PollingBase`、`PollingLifecycleService` | | 关闭流程引用未启动模块 | 使用可选变量/启动状态判断,只关闭已启动模块 | | 默认值变更影响现有部署 | 默认保持 `all`,不配置环境变量时行为不变 | | Asynq Scheduler 多实例去重被误解 | 文档和启动日志明确只有 `all/leader` 启动 Scheduler;`consumer` 禁止启动 | | 角色字符串散落导致后续维护出错 | 角色值集中为常量,并在配置校验中拒绝非法值 | ## Migration Plan 1. 发布代码后,不修改现有环境变量,单实例继续以默认 `all` 运行。 2. 灰度将唯一 worker 设置为 `JUNHONG_WORKER_ROLE=leader`,确认日志中初始化器、轮询调度器和 Asynq Scheduler 均启动。 3. 新增第一个 `consumer` 实例,设置 `JUNHONG_WORKER_ROLE=consumer` 和唯一 `JUNHONG_WORKER_INSTANCE_NAME`。 4. 检查 `consumer` 日志,确认未出现“轮询调度器已启动”和“Asynq Scheduler 已启动”,且 Asynq Worker Server 正常启动。 5. 如发生异常,先停止所有 `consumer`,保留单个 `leader`;若仍异常,将唯一实例切回默认 `all` 或回滚二进制。 ## Open Questions 无。自动选主、Leader 分布式锁和任务唯一性去重作为第二期增强,不阻塞第一期实施。