Files
junhong_cmp_fiber/openspec/changes/archive/2026-05-06-split-worker-roles/design.md
huang 3a2e3f2571
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m8s
浅浅升级一下
2026-05-06 14:41:14 +08:00

6.6 KiB
Raw Blame History

Context

当前 cmd/worker/main.go 的启动入口把两类职责放在同一进程中:

  • 可横向扩容的被动消费职责Asynq Worker Server 和全部任务 Handler
  • 必须单例运行的主动发起职责:PollingInitializerPollingScheduler、套餐激活/流量重置扫描、Asynq Scheduler

其中 Asynq Worker Server 天然适合多实例消费任务,但完整复制当前 worker 会同时复制主动调度模块。当前 Asynq v0.25.1 的 Scheduler 是进程内 cron到点直接 Enqueue,不是跨进程单例;轮询初始化器也会启动后全量扫库并写 Redis 分片队列;流量重置节流状态是进程内字段。因此第一期必须先通过显式角色把部署语义收敛清楚。

本变更只处理“角色拆分和启动编排”,不改变已有 Handler、Service、Store、Model 的业务逻辑,不新增数据库表,也不改 Asynq 任务载荷。

Goals / Non-Goals

Goals:

  • 新增 allleaderconsumer 三种 Worker 角色。
  • 默认 all,旧部署不配置新环境变量时行为完全不变。
  • consumer 只运行任务消费相关模块,不运行轮询初始化器、轮询调度器和 Asynq Scheduler。
  • leaderall 的运行模块一致,但部署语义明确为单例主动调度实例。
  • 启动日志可直接证明当前实例的角色和模块启停情况。
  • 关闭流程按实际启动模块执行,避免 consumer 因未创建调度器而空指针或重复关闭。

Non-Goals:

  • 不实现 Redis Leader 锁或自动选主。
  • 不为套餐激活、订单超时等任务补充唯一性去重。
  • 不改 PollingScheduler 的调度算法和 Redis 分片队列实现。
  • 不调整 queue.concurrency、队列权重或业务 Handler 幂等逻辑。
  • 不新增 API、数据库迁移、DTO 或 OpenAPI 文档生成器改造。
  • 不新增自动化测试文件;按项目根规范使用编译和人工日志验证。

Decisions

Decision 1用显式配置角色而不是自动探测或自动选主

选择:新增 worker.role,支持 allleaderconsumer,通过环境变量 JUNHONG_WORKER_ROLE 覆盖。

备选方案:启动时所有 worker 抢 Redis 锁,抢到锁者自动成为 leader。

理由:第一期目标是最小、安全、可回滚。显式角色能立即阻止完整进程被误横向复制,改动集中在配置和启动编排;自动选主需要续期、失锁处理、调度器停止/重启语义和故障切换策略,适合第二期单独设计。

Decision 2保留 all 作为默认兼容模式

选择:默认 worker.role=all

备选方案:默认改为 leader

理由all 与当前运行行为完全一致,现有生产、本地开发和临时脚本不需要增加环境变量。leader 用于部署语义表达,等生产灰度确认后再从 all 切换到 leader

Decision 3consumer 初始化共享依赖,但不启动单例模块

选择consumer 仍初始化 Redis、PostgreSQL、Storage、Gateway、BootstrapWorkerPollingConfigManagerPollingQueueManagerPollingBasePollingLifecycleService、TaskHandler 和 Asynq Worker Server。

理由:轮询任务 Handler 执行后需要读取轮询配置、执行并发控制、更新卡缓存、重入队分片队列;导入任务也可能触发轮询生命周期回调。因此 consumer 不是“瘦到只连 Asynq”的进程而是“只不主动调度”的完整任务消费进程。

边界consumer 不创建或不启动 PollingInitializerPollingScheduler、Asynq Scheduler如需为构造依赖创建可选对象也必须保证不会启动其后台 goroutine。

Decision 4角色常量集中定义启动逻辑用 helper 降低误用

选择:新增 Worker 角色常量,并提供类似 RunsSingletonModules() 的判断逻辑,避免在 main.go 多处硬编码字符串。

位置约束:遵循项目常量规范,角色字符串应放在 pkg/constants/,并添加中文注释;pkg/configcmd/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/workergo build ./...、三种角色启动日志检查作为验收。

理由:项目根规范明确禁止自动化测试规划和 *_test.go 文件;本变更也主要是进程编排,人工日志能直接验证模块启停契约。

Risks / Trade-offs

风险 缓解措施
部署层误把两个实例都配置成 leader 第一期通过日志暴露误配;第二期再补 Redis Leader 锁
consumer 漏初始化轮询依赖导致轮询任务无法重入队 consumer 必须保留 PollingConfigManagerPollingQueueManagerPollingBasePollingLifecycleService
关闭流程引用未启动模块 使用可选变量/启动状态判断,只关闭已启动模块
默认值变更影响现有部署 默认保持 all,不配置环境变量时行为不变
Asynq Scheduler 多实例去重被误解 文档和启动日志明确只有 all/leader 启动 Schedulerconsumer 禁止启动
角色字符串散落导致后续维护出错 角色值集中为常量,并在配置校验中拒绝非法值

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 分布式锁和任务唯一性去重作为第二期增强,不阻塞第一期实施。