6.6 KiB
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
- 发布代码后,不修改现有环境变量,单实例继续以默认
all运行。 - 灰度将唯一 worker 设置为
JUNHONG_WORKER_ROLE=leader,确认日志中初始化器、轮询调度器和 Asynq Scheduler 均启动。 - 新增第一个
consumer实例,设置JUNHONG_WORKER_ROLE=consumer和唯一JUNHONG_WORKER_INSTANCE_NAME。 - 检查
consumer日志,确认未出现“轮询调度器已启动”和“Asynq Scheduler 已启动”,且 Asynq Worker Server 正常启动。 - 如发生异常,先停止所有
consumer,保留单个leader;若仍异常,将唯一实例切回默认all或回滚二进制。
Open Questions
无。自动选主、Leader 分布式锁和任务唯一性去重作为第二期增强,不阻塞第一期实施。