14 KiB
Worker 多实例升级方案
1. 背景
当前 cmd/worker/main.go 不是一个纯粹的“任务消费进程”,而是把以下几类职责放在了同一个入口里:
- Asynq 任务消费器
- 轮询配置管理器
- 轮询全量初始化器
- 轮询调度器
- Asynq 定时任务调度器
这导致一个关键事实:
- Asynq 消费器天然适合多实例横向扩容
- 调度器、初始化器这类“主动发起工作”的职责不适合直接整进程复制
因此,当前阶段不能简单把整个 worker 容器横向扩成多个副本,否则会出现重复调度、重复扫库、重复初始化、重复入队等问题。
本方案的目标不是否定多实例,而是把当前 worker 升级成:
- 1 个单例 Leader Worker
- N 个可横向扩展的 Consumer Worker
这样既保留未来扩容能力,又能控制当前风险。
2. 当前现状
2.1 当前 worker 进程承担的职责
当前入口集中在 cmd/worker/main.go:140-261,关键行为如下:
| 模块 | 位置 | 当前行为 | 是否适合多开 |
|---|---|---|---|
| Asynq Worker Server | cmd/worker/main.go:140-141、240-244 |
消费 Asynq 队列任务 | 适合 |
| PollingConfigManager | cmd/worker/main.go:143-149 |
加载轮询配置,定时刷新,订阅变更 | 基本可多开 |
| PollingInitializer | cmd/worker/main.go:156-165 |
启动后从 DB 分批加载全量卡到 Redis 分片队列 | 不适合直接多开 |
| Polling Scheduler | cmd/worker/main.go:173-188 |
每秒扫描轮询分片队列并投递 Asynq 任务 | 不建议直接多开 |
| Polling activation/data reset | internal/polling/scheduler.go:225-237 |
每 10 秒做套餐激活检查和流量重置调度 | 不适合直接多开 |
| Asynq Scheduler | cmd/worker/main.go:200-233 |
周期性注册 order_expire、alert_check、data_cleanup、daily_traffic_flush |
不适合直接多开 |
2.2 当前为什么不能直接多开整个 worker
1. Asynq Scheduler 会重复入队
cmd/worker/main.go:200-233 中每个 worker 实例都会启动一个 Asynq Scheduler,并注册:
order_expirealert_checkdata_cleanupdaily_traffic_flush
如果启动 3 个完整 worker 实例,就会出现:
- 每分钟不是入队 1 次
order_expire - 而是入队 3 次
order_expire
虽然某些下游任务具备一定幂等性,但会额外增加:
- 数据库扫描压力
- Redis 队列压力
- 日志噪音
- 故障排查复杂度
2. PollingInitializer 会重复全量扫库
cmd/worker/main.go:156-158 会在启动时执行:
pollingInitializer.StartBackground(ctx)
初始化逻辑在 internal/polling/initializer.go:124-203,会:
- 分批读取全量卡数据
- 写入 Redis 分片轮询队列
- 写入卡缓存
如果多开整个 worker,每个实例都会做一遍全量初始化,结果是:
- 重复扫库
- 重复写 Redis
- 启动期压力被放大
- 配置变化时多个实例可能同时重新初始化
3. 套餐激活检查会重复扫库和重复入队
轮询调度器中的 processActivationTasks 位于 internal/polling/scheduler.go:225-237,每 10 秒会调用:
HandlePackageActivationCheck(ctx)HandleDataReset(ctx)
其中 HandlePackageActivationCheck 在 internal/polling/package_activation_handler.go:63-109 中,会主动:
- 查找过期主套餐
- 查找孤儿套餐
- 直接向 Asynq 提交激活任务
问题在于这不是“消费已有任务”,而是每个实例自己扫库、自己决定要不要再入队。
更重要的是,enqueueActivationTask 在 internal/polling/package_activation_handler.go:469-502 中并没有使用 Asynq 唯一键控制,因此多实例下会出现:
- 重复扫描同一批数据
- 重复提交相同激活任务
- 下游虽然有 Redis 锁兜底,但前置流量已经放大
4. 流量重置调度使用进程内状态,不是分布式单例
internal/polling/data_reset_handler.go:18-20 中:
lastDailyResetlastMonthlyResetlastYearlyReset
这三个值是进程内内存变量。
这意味着:
- 启动 1 个
worker,每分钟只会有 1 份调度检查 - 启动 3 个
worker,每分钟会有 3 份独立调度检查
它们会分别调用:
ResetDailyUsageResetMonthlyUsageResetYearlyUsage
相关实现位于 internal/service/package/reset_service.go:43-240。当前这里没有单独的分布式 Leader 保护,因此多实例时至少会造成重复扫描,严重时会产生并发处理窗口。
5. 轮询分片出队本身虽然相对安全,但不代表整个调度器适合多开
轮询分片出队在 internal/polling/queue_manager.go:24-34 和 66-88 使用 Lua 原子出队:
ZRANGEBYSCOREZREM
因此就“分片队列抢活”这件事本身,多实例并不是最危险的问题。
真正的问题是:
- 调度器不只做分片出队
- 还混合了主动扫库、主动定时、主动初始化职责
所以风险不是“出队会乱”,而是“整进程多开后单例职责被复制了”。
3. 升级目标
3.1 目标架构
升级后的 worker 角色拆分如下:
| 角色 | 职责 |
|---|---|
leader |
运行 Asynq 消费器 + PollingInitializer + PollingScheduler + Asynq Scheduler |
consumer |
只运行 Asynq 消费器,不运行任何调度器和初始化器 |
all |
保持当前兼容模式,单实例部署时继续使用 |
3.2 设计目标
- 保持当前单实例部署兼容
- 支持未来多实例扩容
- 先做最小改动,不大规模改业务逻辑
- 后续可继续升级为“自动主从选主”模式
4. 推荐实施方案
4.1 第一期:先做角色拆分
推荐新增配置:
| 配置项 | 说明 | 默认值 |
|---|---|---|
JUNHONG_WORKER_ROLE |
all / leader / consumer |
all |
JUNHONG_WORKER_INSTANCE_NAME |
实例名称,便于日志区分 | 空 |
角色语义定义如下:
all
用于兼容当前行为:
- 启动 Asynq Worker Server
- 启动 PollingInitializer
- 启动 PollingScheduler
- 启动 Asynq Scheduler
适用场景:
- 当前生产单实例
- 本地开发
- 首次升级的灰度阶段
leader
用于未来的单例主 Worker:
- 启动 Asynq Worker Server
- 启动 PollingInitializer
- 启动 PollingScheduler
- 启动 Asynq Scheduler
与 all 的差别不是运行行为,而是部署语义:
all是兼容模式leader是明确表示“该实例承担单例职责”
consumer
用于未来横向扩容的消费实例:
- 启动 Asynq Worker Server
- 不启动 PollingInitializer
- 不启动 PollingScheduler
- 不启动 Asynq Scheduler
这样一来:
- 只有
leader负责“主动调度” - 所有
consumer只负责“被动消费”
这就是当前阶段最稳的多实例架构。
5. 代码改造点
5.1 配置层改造
需要修改:
pkg/config/config.gopkg/config/defaults/config.yamlpkg/config/loader.godocs/environment-variables.md
建议新增:
worker:
role: "all"
instance_name: ""
并绑定环境变量:
JUNHONG_WORKER_ROLEJUNHONG_WORKER_INSTANCE_NAME
5.2 cmd/worker/main.go 入口改造
将当前入口逻辑改成“共享依赖初始化”和“按角色启停模块”两段。
建议改造原则如下:
永远初始化的部分
这些模块无论 leader 还是 consumer 都需要:
- Redis
- PostgreSQL
- Storage
- Gateway
- BootstrapWorker
- TaskHandler
- Asynq Worker Server
- PollingConfigManager
- PollingQueueManager
- PollingBase
- LifecycleService
原因:
- 消费轮询任务时,
consumer仍然需要轮询相关依赖来做重入队、配置读取、停复机处理
只在 all/leader 启动的部分
pollingInitializer.StartBackground(ctx)scheduler.Start(ctx)asynqScheduler.Run()
只在 all/leader/consumer 都启动的部分
workerServer.Run(taskHandler.GetMux())
5.3 启停日志改造
建议在启动时打印:
- 当前角色
- 当前实例名
- 启动了哪些模块
- 没启动哪些模块
例如:
Worker 角色: consumer
实例名称: worker-consumer-2
已启用模块: queue_server
已禁用模块: polling_initializer, polling_scheduler, asynq_scheduler
这样后续排查非常直接。
6. 第二期增强方案
第一期做完后,已经足够支持“1 主 + N 从”的稳定部署。
如果后续希望进一步增强容错,可以继续做以下升级。
6.1 增加 Leader 分布式锁
即使采用 leader/consumer 角色,仍然存在人为误配置的风险,例如:
- 错把两个实例都配置成
leader - 自动扩容时错误复制了 Leader 配置
因此第二期建议为以下单例职责加 Redis Leader 锁:
- Asynq Scheduler 锁
- Polling Scheduler 锁
- Polling Initializer 锁
推荐思路:
- Leader 启动时用
SET NX EX争抢锁 - 成功后定时续期
- 失去锁时主动停止调度器或直接退出进程
这样即使部署层误配,运行时也能收敛到单活。
6.2 为重复入队场景增加唯一性保护
重点场景:
enqueueActivationTask- 其他未来可能由调度器主动生成的任务
建议做法:
- 使用
asynq.Unique(...)控制短时间内同一业务键只允许入队一次 - 或者先用 Redis 锁做业务级去重,再入队
这样可以进一步降低重复调度带来的噪音。
6.3 为流量重置调度加分布式互斥
DataResetHandler 当前是进程内时钟,不是分布式单例。
建议第二期增加:
- 日重置锁
- 月重置锁
- 年重置锁
保证无论部署多少个 Leader 候选实例,实际同一时间只有一个实例执行重置扫描。
7. 分阶段实施路径
阶段 1:代码兼容改造
目标:
- 引入
JUNHONG_WORKER_ROLE - 默认保持
all - 单实例行为完全不变
验收标准:
- 不改现有部署配置时,运行行为与当前一致
- 日志能明确打印当前角色和启用模块
阶段 2:灰度切换为 leader
目标:
- 当前生产单实例从
all切成leader - 验证角色切换不影响现有功能
验收标准:
- 轮询调度器正常启动
- Asynq Scheduler 正常启动
- 队列消费正常
阶段 3:增加第一个 consumer
目标:
- 新增 1 个
consumer - 验证只有 Leader 运行调度器
- 验证消费者可共同分担任务处理
验收标准:
consumer日志中不出现轮询调度器已启动consumer日志中不出现Asynq Scheduler 已启动- 队列堆积下降
- Redis 心跳键仍只有 Leader 负责刷新
阶段 4:按需扩容 consumer
目标:
- 根据任务积压和资源情况逐步增加消费者数量
扩容原则:
- 优先横向扩
consumer - 不随意增加
leader - 先观察队列滞留和 Redis/DB 压力,再决定并发和实例数
8. 部署建议
8.1 当前两台服务器阶段
当前建议仍然是:
- 应用机:
1 个 API + 1 个 worker leader - Redis 机:
1 个 Redis
此阶段不建议在同一台应用机上同时起多个 consumer,因为:
- 资源本身有限
- 当前最主要瓶颈还不是 Asynq 消费,而是整体调度职责未拆分
8.2 未来进入多实例阶段
推荐部署形态:
| 节点 | 服务 |
|---|---|
| 应用机 A | api-1 + worker-leader |
| 应用机 B | api-2 + worker-consumer-1 |
| 应用机 C | worker-consumer-2(可选) |
如果只有两台应用机,也可以是:
| 节点 | 服务 |
|---|---|
| 应用机 A | api-1 + worker-leader |
| 应用机 B | api-2 + worker-consumer-1 |
核心原则只有一个:
- Leader 固定单例
- Consumer 负责横向扩容
9. 回滚方案
9.1 角色切换回滚
如果 leader/consumer 方案上线后发现问题,回滚顺序建议如下:
- 先停止所有
consumer - 保留一个
leader - 如果问题仍存在,再将唯一实例切回
all
9.2 代码版本回滚
如果新版本本身存在问题:
- 先停
consumer - 再停
leader - 回滚到旧版本单实例
worker - 只启动 1 个旧版本实例
注意:
- 回滚时不要保留多个旧版本全功能
worker同时运行 - 否则会重新引入当前这份方案要解决的问题
10. 最终建议
10.1 本次升级推荐做法
本次升级建议只做 第一期最小改动:
- 新增
worker角色概念 - 拆出
leader / consumer / all - 用角色控制模块启停
- 暂不一次性引入复杂的自动选主
原因:
- 改动最小
- 风险最低
- 能立刻为未来多实例铺路
- 当前生产部署也不会被迫大改
10.2 升级后的长期方向
长期建议目标是:
worker-leader只负责单例调度职责worker-consumer做水平扩容- 第二期再补 Redis Leader 锁和任务唯一性保护
这条路径与当前仓库文档里的方向一致,即:
- Worker 支持多实例
- 但调度器只保留单例运行
相关参考:
specs/002-gorm-postgres-asynq/spec.md:100-103docs/polling-system/performance-tuning.md:45-66
11. 本方案覆盖的直接代码位置
为了后续实施时快速定位,当前最关键的修改位置如下:
| 文件 | 重点修改内容 |
|---|---|
cmd/worker/main.go |
按角色拆分模块启停 |
pkg/config/config.go |
新增 Worker 角色配置 |
pkg/config/defaults/config.yaml |
新增默认 Worker 配置 |
pkg/config/loader.go |
绑定 Worker 环境变量 |
docs/environment-variables.md |
补充新环境变量说明 |
internal/polling/data_reset_handler.go |
第二期可补分布式互斥 |
internal/polling/package_activation_handler.go |
第二期可补任务唯一性/去重 |
internal/polling/initializer.go |
第二期可补初始化单例锁 |
12. 一句话结论
当前不是“Worker 永远不能多开”,而是:
当前整个 cmd/worker 进程里同时混合了可横向扩展的消费职责和必须收敛成单例的调度职责,所以不能直接整进程复制。
正确升级路径是:
先拆角色,再扩 Consumer,最后补自动单例保护。