topfans/docs/superpowers/specs/2026-07-01-message-queue-design.md

49 KiB
Raw Blame History

后端消息队列改造设计

日期: 2026-07-01
状态: 设计完成,待实施
分支: feat/actvity


一、背景与目标

1.1 现状问题

问题 具体表现 影响
同步 RPC 紧耦合 socialService 点赞后同步调 notificationServicenotificationService 挂了影响点赞 故障传播
fire-and-forget 裸 goroutine notificationService.push + moderationService 举报通知均用 go func() 发推送/通知,无重试无持久化 推送丢失、通知丢失
内存 channel statisticService 用 Go channel 传事件,服务重启清空 埋点数据丢失
轮询替代事件驱动 galleryService 每分钟 ticker 查过期展览 收益结算延迟
手动下架不计累计时长 exhibitionService.RemoveFromSlot 只调 RemoveExhibitionTx不调 userService.AddExhibitionHours 主动下架的展示时长、收益、点赞押注全部丢失
cron worker 自管理 taskService/assetService 各自 sleep loop 实现定时任务 不可靠、难监控

1.2 目标

  1. 解耦 — 跨服务异步通信替换同步 RPC
  2. 可靠 — 所有异步操作有重试/死信/持久化
  3. 实时 — 事件驱动替代轮询
  4. 不引入新中间件 — 基于现有 Redis

二、技术选型

2.1 Asynq — 任务队列

  • 选型理由:基于 Redis已有Go 原生,支持重试/超时/死信/延时/定时
  • 适用场景:点对点任务,需要重试保证

2.2 Redis Streams — 事件流

  • 选型理由:基于 Redis已有activityService 已有使用经验,支持消费者组 ACK
  • 适用场景:一对多事件,多消费者、高吞吐

2.3 决策原则

场景
点对点、需要重试/死信 Asynq
一对多、多消费者、高吞吐 Redis Streams
读操作、强一致性写 保留同步 RPC

三、架构与调用关系

3.1 改造前(现状)

┌─────────────────────────────────────────────────────────────────────────┐
│                           Gateway (Gin HTTP)                              │
│         Dubbo 客户端,全部走同步 RPC 调用下游                                │
└────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────────┘
     │    │    │    │    │    │    │    │    │    │    │    │    │
     ▼    ▼    ▼    ▼    ▼    ▼    ▼    ▼    ▼    ▼    ▼    ▼    ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────────┐ ┌────────┐ ┌─────────┐ ┌──────────┐ ┌────────┐ ┌──────────┐ ┌──────────┐
│  user  │ │ asset  │ │gallery │ │  task  │ │notification│ │ social │ │moderation│ │statistic │ │starbook│ │aiChat   │ │activity  │
│Service │ │Service │ │Service │ │Service │ │  Service   │ │Service │ │ Service  │ │ Service  │ │Service │ │Service  │ │Service   │
└───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ └─────┬──────┘ └───┬────┘ └────┬─────┘ └────┬─────┘ └────────┘ └────────┘ └────┬─────┘
    │          │          │          │             │            │          │           │                              │
    │          │  cron    │  ticker  │  sleep      │ go func() │          │           │ channel                      │
    │          │  worker  │  1 min   │  loop       │ push      │          │           │ sink                         │
    │          │          │          │             │            │          │           │                              │
    └──────────┴──────────┴──────────┴─────────────┴────────────┴──────────┴───────────┴──────────────────────────────┘
                    全部通过 Dubbo 同步 RPC 直连,失败只打日志,无重试

核心问题标注:

标注 位置 问题
cron worker assetService season_reset_worker 只靠 cron 定时
ticker 1 min galleryService CleanupWorker 每分钟轮询过期展览 → 同步 RPC taskService
sleep loop taskService DailyResetWorker goroutine 计算到 05:00 的等待时间
go func() push notificationService 废弃 goroutine 发推送,无重试
channel sink statisticService 内存 chan *Event → 重启丢数据

3.2 改造后(目标架构)

                              ┌─────────────────────────┐
                              │      Gateway (Gin)       │
                              │  ┌───────────────────┐   │
                              │  │ WebSocket Hub     │◄──┼── Redis Streams
                              │  │ (stream:activity) │   │   Consumer Group
                              │  └───────────────────┘   │
                              └────┬────────────────────┘
                                   │ Dubbo RPC读/强一致性写保持同步)
                                   │
         ┌─────────────────────────┼─────────────────────────┐
         │                         │                         │
         ▼                         ▼                         ▼
┌─────────────────┐     ┌──────────────────┐     ┌──────────────────┐
│   Asynq Broker   │     │  Redis Streams    │     │   Dubbo RPC       │
│   (Redis DB 2)   │     │  (Redis DB 3)     │     │   (读/同步写)      │
│                  │     │                   │     │                   │
│ ▪ 任务队列        │     │ ▪ 事件流           │     │ userService       │
│ ▪ 重试+死信       │     │ ▪ 消费者组 ACK     │     │ assetService      │
│ ▪ 延时/定时       │     │ ▪ 多消费者         │     │ galleryService    │
│ ▪ 优先级          │     │ ▪ 消息持久化       │     │ socialService     │
└───┬─────────────┘     └───┬───────────────┘     │ ... 等读接口      │
    │                       │                     └──────────────────┘
    │                       │
    │  ┌──────────────────────────────────────────────────────────┐
    │  │                  Asynq 生产者 / 消费者                      │
    ├──┤                                                          │
    │  │ 生产者:                                                    │
    │  │   galleryService  → notification:create                    │
    │  │                    → revenue:exhibition                   │
    │  │                    → revenue:like-bet                     │
    │  │                    → gallery:exhibition-expire (delay)     │
    │  │   socialService   → notification:create                    │
    │  │   moderationService → notification:create                  │
    │  │                    → moderation:auto-hide                 │
    │  │   aiChatService   → aichat:chat                           │
    │  │   starbookService → starbook:collection-create            │
    │  │                                                            │
    │  │ 消费者 + Scheduler:                                         │
    │  │   notificationService ← notification:create                │
    │  │                       ← notification:push (self)           │
    │  │   taskService        ← revenue:exhibition                  │
    │  │                       ← revenue:like-bet                   │
    │  │                       ← task:daily-reset (scheduler)       │
    │  │   galleryService     ← gallery:exhibition-expire (self)    │
    │  │   assetService       ← asset:season-reset (scheduler)      │
    │  │   statisticService   ← statistic:materialize (scheduler)   │
    │  │                       ← statistic:weekly-income (scheduler)│
    │  │                       ← statistic:level-up (scheduler)     │
    │  │   moderationService  ← moderation:auto-hide (self)         │
    │  │   aiChatService      ← aichat:chat (self)                  │
    │  │   starbookService    ← starbook:collection-create (self)   │
    │  └──────────────────────────────────────────────────────────┘
    │
    │  ┌──────────────────────────────────────────────────────────┐
    │  │              Redis Streams 生产者 / 消费者                  │
    ├──┤                                                          │
    │  │ 生产者 → XADD:                                              │
    │  │   userService    → stream:user                             │
    │  │   assetService   → stream:asset                            │
    │  │   socialService  → stream:social                           │
    │  │   galleryService → stream:exhibition                       │
    │  │   activityService→ stream:activity                         │
    │  │   moderationService → stream:moderation                    │
    │  │                                                            │
    │  │ 消费者组 (Consumer Group: topfans-service):                  │
    │  │   statisticService ← stream:user                           │
    │  │                    ← stream:asset                          │
    │  │                    ← stream:social                         │
    │  │                    ← stream:exhibition                     │
    │  │                    ← stream:moderation                     │
    │  │   taskService     ← stream:user (用户注册 → 初始化任务)     │
    │  │                    ← stream:asset (铸造统计)                │
    │  │                    ← stream:exhibition (展览统计)           │
    │  │   notificationService ← stream:moderation (举报通知)        │
    │  └──────────────────────────────────────────────────────────┘

3.3 关键调用链

展示收益结算链(改造后):

展览上架
  galleryService
    │
    ├─ 写 ZSET保留兼容
    └─ mq.EnqueueExhibitionExpire(exhibition_id, expireAt)             ← 走 adapter
         │
         ▼  ┌─ 到期时刻触发 ─────────────────────────────┐
         │  galleryService (handler)                      │
         │    ├─ 查询点赞数                                │
         │    ├─ adapter.Get().EventProducer()            │
         │    │     .Publish("stream:exhibition", e)      │
         │    ├─ mq.EnqueueRevenueExhibition(payload)     │
         │    ├─ mq.EnqueueRevenueLikeBet(payload)        │
         │    ├─ 标记 exhibition.processed=true            │
         │    └─ ZSET.Remove                               │
         │                                                │
         ▼  ┌─ revenue:exhibition ────────────────────────┐
         │  taskService (handler)                          │
         │    ├─ assetLevelService.CalculateRevenue() RPC  │
         │    ├─ CreateRevenueRecord() → DB                │
         │    ├─ assetLevelService.AddExhibitionHours() RPC│
         │    ├─ userService.AddExhibitionHours() RPC      │
         │    └─ 失败 → 重试3次 → 死信队列                   │
         │                                                │
         ▼  ┌─ revenue:like-bet ──────────────────────────┐
         │  taskService (handler)                          │
         │    ├─ 查询 exhibition 下所有 asset_likes         │
         │    ├─ 按 bet_order 计算每笔金额                  │
         │    ├─ BatchCreate like_bet_revenue_records      │
         │    └─ DB 唯一约束保证幂等                        │
         └────────────────────────────────────────────────┘

通知推送链(改造后):

点赞操作
  socialService
    ├─ assetClient.LikeAsset() RPC ✅ 同步
    └─ mq.EnqueueLikeNotification(...)                               ← 走 adapter
         │
         ▼  notificationService (handler)
              ├─ 事务内 INSERT notification + UPSERT stats
              └─ mq.EnqueuePushPayload(notif)                          ← 走 adapter
                   │
                   ▼  notificationService (handler)
                        ├─ 拉取用户活跃 cids
                        ├─ UniPush.Send()
                        ├─ 失败 → 重试5次指数退避
                        └─ 最终失败 → 死信队列 → 告警

3.4 服务依赖关系(改造后)

                    ┌──────────┐
                    │  Redis   │  ← 唯一的中间件依赖
                    └────┬─────┘
           ┌─────────────┼─────────────┐
           │             │             │
      ┌────▼────┐  ┌─────▼──────┐  ┌──▼──────────┐
      │  Asynq  │  │  Streams   │  │  Dubbo RPC  │
      │  (DB 2) │  │  (DB 3)    │  │  (同步读/写) │
      └────┬────┘  └─────┬──────┘  └──┬──────────┘
           │             │             │
    ┌──────▼──────┐      │      ┌──────▼──────┐
    │ 任务型服务   │      │      │ 查询型服务   │
    │ notif/task/ │      │      │ user/asset/ │
    │ gallery等   │      │      │ social等    │
    └─────────────┘      │      └─────────────┘
                  ┌──────▼──────┐
                  │ 事件消费型   │
                  │ statistic/  │
                  │ gateway(WS) │
                  └─────────────┘
  • AsynqnotificationService、taskService、galleryService 依赖(既是生产者也是消费者)
  • Redis StreamsstatisticService 重度依赖消费者组userService/assetService/socialService 轻量依赖(仅生产)
  • Dubbo RPC:保留用于读操作和强一致性写(如领取收益时调 userService.UpdateCrystalBalance
  • 不新增中间件Asynq 和 Streams 都跑在现有 Redis 上

四、新增包结构(适配器模式 — broker 可插拔)

4.0 设计原则

业务代码只跟 pkg/mq/adapter/ 接口层打交道,不直接依赖 Asynq / Redis Streams / RabbitMQ 的具体类型。 后续切换 broker如从 Redis 迁到 RabbitMQ只需要新增一个 adapter 实现,业务侧 producer/consumer 一行不改。

backend/pkg/mq/                          # 共享broker 无关的通用契约
├── adapter/                             # ★ 适配器抽象层 — 业务代码面向这些接口编程
│   ├── adapter.go                       # Adapter 接口 + Manager 单例
│   ├── task.go                          # Task / TaskInfo 公共数据结构
│   ├── event.go                         # Event / MessageAck 公共数据结构
│   ├── task_producer.go                 # TaskProducer 接口Enqueue / EnqueueAt
│   ├── task_consumer.go                 # TaskConsumer 接口RegisterTask / Run
│   ├── event_producer.go                # EventProducer 接口Publish
│   ├── event_consumer.go                # EventConsumer 接口Subscribe / Ack
│   └── options.go                       # 通用配置选项(重试次数、退避、延迟等)
│
├── asynq/                               # Asynq adapter 实现(当前默认)
│   ├── client.go                        # Asynq Client 单例
│   ├── server.go                        # Asynq Server 单例
│   ├── task_producer.go                 # 实现 adapter.TaskProducer
│   ├── task_consumer.go                 # 实现 adapter.TaskConsumer
│   ├── task_marshal.go                  # Task payload 序列化/反序列化JSON
│   └── middleware.go                    # 日志/重试中间件
│
├── streams/                             # Redis Streams adapter 实现
│   ├── event_producer.go                # 实现 adapter.EventProducer
│   ├── event_consumer.go                # 实现 adapter.EventConsumer
│   ├── keys.go                          # 所有 Stream Key 常量
│   └── consumer_group.go                # 消费者组管理
│
├── rabbitmq/                            # ★ 预留(后续接入用,本期不实现)
│   ├── task_producer.go                 # 实现 adapter.TaskProducer占位
│   ├── task_consumer.go                 # 实现 adapter.TaskConsumer占位
│   ├── event_producer.go                # 实现 adapter.EventProducer占位
│   └── event_consumer.go                # 实现 adapter.EventConsumer占位
│
├── tasks/                               # 业务侧 Task Type 注册中心broker 无关)
│   └── registry.go                      # 所有 TaskType 常量、Payload 结构定义
│
└── config.go                            # MQ 配置 + adapter 选择

backend/services/xxxService/
└── mq/                                  # 各服务 MQ 适配:业务侧
    ├── producer.go                      # 通过 adapter.TaskProducer / EventProducer 发送
    └── consumer.go                      # 通过 adapter.TaskConsumer / EventConsumer 注册 handler

4.1 Adapter 接口设计(关键代码骨架)

adapter/adapter.go — 抽象工厂 + 选择器

package adapter

import "context"

// Adapter 顶层抽象 — 一个进程内只有一个 adapter 实例
type Adapter interface {
    // 业务模型划分:两类原语
    //   - Task点对点 + 重试 + 死信(→ Asynq / RabbitMQ-RabbitMQ
    //   - Event发布订阅 + 多消费者(→ Redis Streams / RabbitMQ-Topic
    TaskProducer() TaskProducer
    TaskConsumer() TaskConsumer
    EventProducer() EventProducer
    EventConsumer() EventConsumer
    Close() error
}

// Manager 单例,启动时根据 config 选择 adapter 实现
type Manager struct { impl Adapter }

func Init(cfg Config) error { /* 选择 adapter 实现 */ }
func Get() Adapter { return mgr.impl }

// broker 选择由 config.MQDriver 决定
// 未来切到 RabbitMQmgr.impl = rabbitmq.NewAdapter(cfg)
// 业务代码 0 改动

adapter/task.go — 业务侧 Task 数据结构

package adapter

import "time"

// Task 业务侧定义(屏蔽 Asynq 的 asynq.Task
// 切 broker 时业务代码不感知
type Task struct {
    Type     string                 // 任务类型 e.g. "notification:create"
    Payload  map[string]any         // JSON 业务参数
    Queue    string                 // 可选,默认为 "default"
    MaxRetry int                    // 重试上限
    Timeout  time.Duration          // 单次执行超时
    Delay    time.Duration          // 延时ProcessAt 也能设)
    Priority int                    // 优先级
}

type TaskInfo struct {
    ID       string
    Queue    string
    State    string  // pending / active / completed / failed
    NextRunAt time.Time
    Retry    int
}

adapter/task_producer.go — 生产端接口

package adapter

import "context"

type TaskProducer interface {
    // Enqueue 立即入队
    Enqueue(ctx context.Context, t Task) (string, error)
    // EnqueueAt 延时入队ProcessAt 时刻才执行(替代 ticker
    EnqueueAt(ctx context.Context, t Task, processAt time.Time) (string, error)
    // EnqueueUnique 仅一个未执行任务存在(按 Type+UniqueKey 去重)
    EnqueueUnique(ctx context.Context, t Task, uniqueTTL time.Duration) (string, error)
}

adapter/task_consumer.go — 消费端接口

package adapter

type TaskHandler func(ctx context.Context, t *Task) error

type TaskConsumer interface {
    RegisterTask(taskType string, handler TaskHandler, opts TaskRegisterOptions) error
    RegisterCron(spec string, taskType string, payload map[string]any) error  // cron 定时
    Run(ctx context.Context) error                                              // 启动 worker
    Stop() error
}

type TaskRegisterOptions struct {
    MaxRetry int
    Queue    string
    Timeout  time.Duration
}

adapter/event.go — 事件数据结构

package adapter

// Event 业务侧事件结构(屏蔽 Redis Streams 的 XMessage
// 切 broker 时业务代码不感知
type Event struct {
    Type       string                 // 事件类型 e.g. "asset.mint"
    Source     string                 // 来源服务
    OccurredAt time.Time
    Payload    map[string]any         // JSON
}

adapter/event_producer.go + event_consumer.go

type EventProducer interface {
    Publish(ctx context.Context, topic string, e Event) error
}

type EventConsumer interface {
    Subscribe(ctx context.Context, topics []string, group string, handler EventHandler) error
    Ack(ctx context.Context, topic string, msgID string) error
}

type EventHandler func(ctx context.Context, topic string, e Event, msgID string) error

4.2 各服务的 mq/ 目录业务侧broker 无关)

每个服务只 import pkg/mq/adapter不直接 import asynq/streams 包

backend/services/galleryService/mq/
├── producer.go
│   import "github.com/topfans/backend/pkg/mq/adapter"
│
│   func EnqueueExhibitSettled(ctx context.Context, e ExhibitionEvent) error {
│       return adapter.Get().TaskProducer().EnqueueAt(
│           ctx,
│           adapter.Task{
│               Type:    "gallery:exhibition-settled",
│               Payload: map[string]any{...e...},
│               MaxRetry: 3,
│           },
│           e.SettledAt,
│       )
│   }
│
└── consumer.go
    import "github.com/topfans/backend/pkg/mq/adapter"

    func RegisterHandlers() error {
        consumer := adapter.Get().TaskConsumer()
        consumer.RegisterTask("gallery:exhibition-settled", handleSettled, adapter.TaskRegisterOptions{MaxRetry: 3})
        consumer.RegisterTask("gallery:exhibition-expire", handleExpire, adapter.TaskRegisterOptions{MaxRetry: 3})
        consumer.RegisterCron("0 */1 * * *", "gallery:cleanup-display-status", nil)
        return nil
    }

4.3 Asynq Adapter 实现关键点asynq/ 目录)

文件 职责
client.go asynq.Client 单例,封装 Redis 连接配置
server.go asynq.Server 单例,封装 concurrency / queues
task_producer.go adapter.Taskasynq.Task,调 asynq.Client.EnqueueEnqueueAtProcessAt 实现
task_consumer.go 业务侧 TaskHandlerasynq.HandlerFunc,注册到 asynq.Mux
task_marshal.go adapter.Task.Payload (map) ↔ JSON 序列化,确保反序列化两端兼容

4.4 Redis Streams Adapter 实现关键点streams/ 目录)

文件 职责
event_producer.go adapter.EventXADD stream:user ...Payload map → fields
event_consumer.go 包装 XReadGroup,收到消息后回调业务 EventHandler
keys.go 所有 stream key 常量(stream:user 等)
consumer_group.go XGroupCreateMkStream自动重试pending list 清理

4.5 RabbitMQ 接入路径(本期不实现,仅留位)

未来切到 RabbitMQ 时:

  1. pkg/mq/rabbitmq/ 下实现 4 个文件
  2. pkg/mq/config.goDriver = "rabbitmq"
  3. adapter.Manager 自动切换
  4. 业务代码(所有服务的 mq/producer.gomq/consumer.go0 改动

4.6 完整调用链示意(接入透明)

业务方:
  mq.PublishExhibitSettled(ctx, exhibitionData)
                ↓
  adapter.Get().TaskProducer().EnqueueAt(...)      ← 业务只跟 adapter 打交道
                ↓
  [manager impl = asynq] asynq.TaskProducer.EnqueueAt()
                ↓
  asynq.Client.Enqueue()                            ← 真正的 MQ 调用

切换到 RabbitMQ:
  config.MQDriver = "rabbitmq"
                ↓
  [manager impl = rabbitmq] rabbitmq.TaskProducer.EnqueueAt()
                ↓
  amqp091-go.Channel.Publish(...)

业务方代码:不变

五、Redis Streams 定义

Stream Key 生产者 消费者 事件描述
stream:user userService statisticService, taskService 注册、资料变更
stream:asset assetService statisticService, taskService 铸造、等级变更
stream:social socialService statisticService 资产点赞
stream:exhibition galleryService taskService, statisticService 上架开始/到期/完成
stream:activity activityService gateway(WebSocket) 活动贡献(已有,统一命名)
stream:moderation moderationService notificationService 举报/处理结果

stream:activity 对应 activityService 已有的 combo:stream:contributions,逻辑不动,统一 key 命名。


六、Asynq Task 定义

6.1 通知类

Task Type 生产者 消费者 重试 说明
notification:create socialService, moderationService, galleryService, gateway(admin) notificationService 3次 创建通知(替换同步 RPC
notification:push notificationService(self) notificationService 5次 UniPush 推送(替换裸 goroutine

6.2 收益类

Task Type 生产者 消费者 重试 说明
revenue:exhibition galleryService taskService 3次 展示收益结算
revenue:like-bet galleryService taskService 3次 点赞押注收益计算

6.3 定时任务类Asynq Scheduler

Task Type 调度表达式 消费者 说明
task:daily-reset 0 5 * * * taskService 每日 05:00 重置任务
asset:season-reset 按赛季配置 assetService 赛季重置
statistic:materialize */5 * * * * statisticService 物化视图刷新
statistic:weekly-income 0 2 * * * statisticService 周收入更新
statistic:level-up */30 * * * * statisticService 等级提升更新
statistic:partition-create 5 0 * * * statisticService events 表每日分区创建(替代 partitioner goroutine
statistic:partition-drop 30 0 * * * statisticService 过期分区清理(替代 partitioner goroutine

6.4 业务类

Task Type 生产者 消费者 重试 说明
moderation:auto-hide moderationService moderationService 3次 自动隐藏内容
gallery:exhibition-expire galleryService galleryService 1次 展览自然到期Asynq ProcessAt 精确时刻触发ZSET + 每日兜底扫描作为补偿
gallery:exhibition-settled galleryService galleryService 3次 统一结算任务(自然到期/手动下架/踢走等任意触发场景handler 内做幂等 + 路由到收益计算/累计时长任务
user:accumulate-hours galleryService userService 3次 增加用户累计上架时长含手动下架补回此前遗漏的handler 内幂等检查 exhibition_id
asset:accumulate-hours galleryService assetService 3次 增加资产累计展出时长(推动资产等级升级;幂等键 exhibition_id
gallery:cleanup-display-status (scheduler) galleryService 0次 display_status 不一致修复(每小时)
gallery:expired-exhibition-fallback (scheduler) galleryService 0次 DB 兜底扫描过期展览(每天 04:00补偿 Asynq delay task 遗漏)
starbook:collection-create starbookService starbookService 3次 收藏集创建异步处理
aichat:chat aiChatService aiChatService 3次 AI 对话异步处理
cache:invalidate socialService socialService 1次 缓存失效

七、各服务改造方案

7.1 notificationService — 核心消费者 🔴

改造内容:

改动 文件 说明
新增 mq/consumer.go 注册 notification:createnotification:push handler通过 adapter.TaskConsumer.RegisterTask
新增 mq/producer.go 封装 EnqueuePushPayload(),只 import adapter
修改 main.go mq.RegisterHandlers() + mq.StartConsumers()
修改 service/notification_service.go 删除 go func() (L231),改为 mq.EnqueuePushPayload(notif)

Handler 逻辑:

notification:create  (通过 adapter.TaskConsumer 注册)
  → 参数校验 → 事务内写 notifications + stats
  → mq.EnqueuePushPayload(notif)  ← 走 adapter不直接调 asynq

notification:push  (通过 adapter.TaskConsumer 注册)
  → 拉取用户活跃 cids
  → UniPush.Send()
  → 失败自动重试最多5次
  → 最终失败进死信队列

CreateNotification gRPC 接口保留,供 admin 面板通过 gateway 调用。

7.2 galleryService — 核心生产者 🔴

改造内容:

改动 文件 说明
新增 mq/consumer.go 注册 gallery:exhibition-expire(自然到期)+ gallery:exhibition-settled通用结算handler通过 adapter.TaskConsumer.RegisterTask
新增 mq/producer.go 封装事件发布 + task 入队 + EnqueueExhibitSettled,只 import pkg/mq/adapter,不直接接触 asynq/streams
修改 service/exhibition_service.go RemoveFromSlot()RemoveExhibitionByAsset() 末尾改为 mq.EnqueueExhibitSettled(exhibition_id, source),不再直接调 RPC
修改 main.go mq.RegisterHandlers() + mq.StartConsumers()
删除 service/cleanup_worker.go 整个文件:轮询 → Asynq delay task + scheduler
新增 Asynq scheduler 注册 gallery:cleanup-display-status(每小时),替代 cleanupInvalidDisplayStatus()
新增 Asynq scheduler 注册 gallery:expired-exhibition-fallback(每天 04:00DB 兜底查询过期展览,补偿 Asynq delay task 可能遗漏的

展览生命周期(统一结算模型,所有 MQ 调用走 adapter 层):

[展览上架 — 通过 adapter]
  → 写 ZSET保留兼容
  → mq.EnqueueExhibitionExpire(exhibition_id, expireAt)
       └→ adapter.Get().TaskProducer().EnqueueAt(
            adapter.Task{Type: "gallery:exhibition-expire", MaxRetry: 3, ...},
            expireAt)

[展品结算触发 — 任何路径统一入口]                              ★ 核心改进
  所有下架 / 清扫代码路径都调:
  mq.EnqueueExhibitSettled(exhibition_id, source)  source ∈ {"natural","manual","kick"}
       └→ adapter.Get().TaskProducer().Enqueue(
            adapter.Task{Type: "gallery:exhibition-settled", MaxRetry: 3, Payload: ...})

[gallery:exhibition-settled Handler — 三场景统一处理]
  → 幂等检查: 查 exhibition.settled → 已结算则 return
  → 标记 settled=true不再仅看 processed覆盖全场景
  → mq.PublishExhibitionSettled(e) → stream:exhibition XADD
  → EnqueueRevenueExhibition(payload)
  → EnqueueRevenueLikeBet(payload)
  → EnqueueAccumulateUserHours(exhibition_id, hours)   ← ★ 补回手动下架时长
  → EnqueueAccumulateAssetHours(exhibition_id, hours)

[gallery:exhibition-expire Handler — 自然到期精确触发]
  → 调 assetClient.GetAssetLikeCount(assetID) RPC 查点赞数
  → 调 repo 计算 actual_hours = (expireAt - startTime) / 3600000
  → mq.EnqueueExhibitSettled(exhibition_id, source="natural")

> 幂等entrance 查 settled 标志 + revenue/累计时长 handler 内各自去重exhibition_id 幂等键)

7.3 taskService — 收益计算 + 定时任务 🔴

改造内容:

改动 文件 说明
新增 mq/consumer.go 注册 revenue:exhibitionrevenue:like-bet handler + task:daily-reset scheduler通过 adapter 接口注册)
新增 mq/producer.go 封装 Streams 发布(统计埋点),只 import adapter
修改 main.go mq.RegisterHandlers() + 启动 Asynq Server + Streams Consumer
删除 worker/daily_reset_worker.go 整个文件
修改 service/revenue_service.go OnExhibitionCompletedRecordLikeBetRevenue 改为 handler 内部函数,逻辑不变

Handler 逻辑:

revenue:exhibition (payload: {exhibition_id, asset_id, slot_id, occupier_uid,
                               occupier_star_id, slot_owner_uid, start_time, expire_at, like_count})
  → 幂等检查: SELECT 是否已有同 exhibition_id 的 revenue record → 已存在则 return
  → 调 assetLevelService.CalculateRevenue(assetID, likeCount, startTime, expireAt, 0)
  → CreateRevenueRecord → 写 DB
  → 调 assetLevelService.AddExhibitionHours → 失败仅日志,不重试全任务
  → 调 userRPCClient.AddExhibitionHours → 失败仅日志,不重试全任务
  → 失败重试3次幂等检查保证重复调用安全

revenue:like-bet (payload: {exhibition_id, asset_id, start_time, expire_at})
  → 查 exhibition 下所有 asset_likes
  → 按 bet_order 计算每笔金额 → 批量写 like_bet_revenue_records
  → DB 唯一约束 uk_like_bet_unique(exhibition_id, like_id) 保证幂等
  → 失败重试3次 → 死信

7.4 socialService — 替换同步 RPC 🟡

改造内容:

改动 文件 说明
新增 mq/producer.go 封装 notification:create 入队 + stream:social 发布
修改 service/asset_like_service.go L142 fireLikeNotification 改为 mq.EnqueueLikeNotification(...)(走 adapter
可选删除 client/notification_client.go 不再需要同步 RPC 调 notificationService

点赞流程改造后:

LikeAsset()
  → assetClient.LikeAsset() RPC ✅ 保持同步(返回 likeCount
  → XADD stream:social {type: "asset.like", ...}     ← 替代 statistic.TrackEvent()
  → mq.EnqueueLikeNotification(...)  ← 替代同步 RPC 调 notificationService走 adapter

statistic.TrackEvent() 调用改为 XADD stream:social由 statisticService 的 Streams consumer 消费。channel_sink 被删除后 TrackEvent() 不再可用。

7.5 moderationService — 替换同步 RPC 🟡

改造内容:

改动 文件 说明
新增 mq/producer.go 封装 notification:create 入队
新增 mq/consumer.go 注册 moderation:auto-hide handler
修改 client/notification_client.go 改为 mq.EnqueueReportNotice(...)(删除 go func() fire-and-forget goroutine
修改 service/report_service.go L185 go func() { s.notifClient.SendXxx() }() 改为 mq.EnqueueReportNotice(...)

7.6 statisticService — channel → Streams 🟡

改造内容:

改动 文件 说明
新增 mq/consumer.go Streams 消费者组 + Asynq scheduler
修改 main.go 启动 Streams Consumer + Asynq Server
删除 sink/event_sink.go EventSink 接口,随 channel_sink 一起废弃
删除 sink/channel_sink.go 内存 channel → 生产者直接 XADD Stream
保留迁移 worker/partitioner.go → Asynq schedulerstatistic:partition-create 00:05 + statistic:partition-drop 00:30events 表每日分区管理不能停
删除 worker/event_flusher.go goroutine 批量写入 → Streams consumer
删除 worker/materializer.go ticker → Asynq scheduler
删除 worker/metric_weekly_user_income_updater.go ticker → Asynq scheduler
删除 worker/metric_upcoming_level_ups_updater.go ticker → Asynq scheduler

改造后架构:

[消费] Redis Streams Consumer Group
  stream:user / stream:asset / stream:social / stream:exhibition / stream:moderation
    → 批量写入 event_partitions
    → 触发增量更新

[消费] Asynq Scheduler
  statistic:materialize (每5分钟) → REFRESH MATERIALIZED VIEW
  statistic:weekly-income (每天) → 周收入更新
  statistic:level-up (每30分钟) → 等级提升更新

7.7 userService — 事件发布 🟢

改造内容:

改动 文件 说明
新增 mq/producer.go 封装 stream:user 发布
新增 mq/consumer.go 注册 user:accumulate-hours handler接收 galleryService 派发的累计时长任务,含手动下架补漏)
修改 service 层 Register/UpdateProfile 末尾追加 XADD

7.8 assetService — 定时任务 + 事件 🟢

改造内容:

改动 文件 说明
新增 mq/producer.go 封装 stream:asset 发布
新增 mq/consumer.go 注册 asset:season-reset scheduler + asset:accumulate-hours handler接收 galleryService 派发的资产累计时长)
删除 worker/season_reset_worker.go cron → scheduler
修改 service/mint_service.go Mint 完成后 XADD stream:asset

7.9 aiChatService — 异步对话 🟢

改造内容:

改动 文件 说明
新增 mq/producer.go Chat 时 Enqueue aichat:chat
新增 mq/consumer.go Handler 调 LLM → 写 DB

7.10 starbookService — 异步收藏集 🟢

改造内容:

改动 文件 说明
新增 mq/producer.go CreateCollection 时 Enqueue
新增 mq/consumer.go Handler 处理异步逻辑

7.11 activityService — 不改

已有 Redis Streams combo worker只把 key 统一命名为 stream:activity

7.12 gateway — 微调

  • 新增 stream:activity 消费者组连接(已有 WebSocket hub整合
  • admin 发系统通知从同步 RPC 改为 mq.EnqueueAdminNotification(...)(走 adapter

八、数据流对比

改造前(展示收益 + 点赞押注)

galleryService.CleanupWorker (每分钟 ticker 轮询)
  → RPC assetService.GetAssetLikeCount()
  → 本地计算收益(硬编码 R0=5
  → RPC taskService.OnExhibitionCompleted()     ← 任务服务挂了收益就丢了
  → RPC taskService.RecordLikeBetRevenue()      ← 同上
  → 失败只打 warn 日志,无重试

改造后

[展览上架时 — 业务侧 mq.Xxxbroker 无关]
  mq.EnqueueExhibitionExpire(exhibition_id, expireAt)
       └→ adapter.Get().TaskProducer().EnqueueAt(...)

[到期触发 — handler 也走 adapter.TaskConsumer]
  gallery:exhibition-expire handler
    → mq.PublishExhibitionExpired(e) → adapter.Get().EventProducer().Publish("stream:exhibition", e)
    → mq.EnqueueRevenueExhibition(payload)   ← 重试 + 死信broker 无关)
    → mq.EnqueueRevenueLikeBet(payload)      ← 重试 + 死信
    → 标记 exhibition.settled=true

[taskService 消费 — 也通过 adapter.TaskConsumer 路由]
  revenue:exhibition handler → 计算收益 → 写DB
  revenue:like-bet handler   → 批量写入

关键改进: taskService 挂了不影响事件产生,恢复后自动从上次 ACK 位置继续处理。


九、错误处理

9.1 Asynq 重试策略

场景 重试次数 退避策略 最终失败
通知创建 3 指数退避 10s/30s/90s 死信队列 → 告警
推送 5 指数退避 10s/30s/90s/270s/810s 死信队列 → 告警
收益计算 3 指数退避 10s/30s/90s 死信队列 → 告警
缓存失效 1 丢弃(缓存有 TTL 兜底)
定时任务 0 N/A 下次调度自动重试

9.2 Redis Streams 错误处理

  • 消费者组 ACK 机制:消息处理后显式 ACK未 ACK 的消息在 PEL 中
  • 消费者崩溃重启:从上次 ACK 位置继续,消息不丢
  • Stream 有最大长度限制100000防止内存无限增长

9.3 死信处理

[Asynq Dead Letter Queue]
  → 定时巡检(每小时)
  → 输出 ERROR 日志(含 task_type + payload + 错误原因)
  → 关键 task收益类触发钉钉/飞书告警
  → 人工介入:通过 admin 接口重放或跳过

十、配置

10.1 .env 新增配置

# Asynq
ASYNQ_REDIS_ADDR=localhost:6379
ASYNQ_REDIS_DB=2
ASYNQ_REDIS_PASSWORD=
ASYNQ_CONCURRENCY=10              # 并发处理数

# Redis Streams
STREAMS_REDIS_ADDR=localhost:6379
STREAMS_REDIS_DB=3
STREAMS_CONSUMER_GROUP=topfans-service
STREAMS_MAX_LEN=100000

10.2 go.mod 新增依赖

github.com/hibiken/asynq  # Asynq 任务队列

Redis Streams 使用已有的 github.com/redis/go-redis/v9,不需要新包。


十一、迁移计划

阶段一基础设施1-2天

  1. 实现 pkg/mq/asynq/ — client、server、tasks 常量、middleware
  2. 实现 pkg/mq/streams/ — producer、consumer、keys 常量
  3. 实现 pkg/mq/config.go
  4. 单元测试

阶段二核心服务改造3-5天

  1. notificationService — Asynq handler通知创建 + 推送)
  2. galleryService — 替换 CleanupWorker
  3. taskService — 替换 DailyResetWorker + 接收 revenue task

阶段三次级服务改造2-3天

  1. socialService — 异步通知
  2. moderationService — 异步通知 + auto-hide
  3. statisticService — channel → Streams + ticker → scheduler

阶段四新增事件能力1-2天

  1. userService — 事件发布
  2. assetService — 事件发布 + scheduler
  3. aiChatService — 异步对话
  4. starbookService — 异步收藏集

阶段五收尾1天

  1. gateway — Streams consumer + admin 异步通知
  2. activityService — 统一 key 命名
  3. 全链路压力测试
  4. 清理删除的旧代码

十二、函数/接口迁移结构

12.1 收益计算相关函数去向

函数 当前位置 改造后 说明
cleanup_worker.calculateExhibitionRevenue galleryService 删除 R0 硬编码 5 作为兜底值传给 taskService改造后 handler 直接调 assetLevelService.CalculateRevenue()R0 按等级从 DB 读),无需兜底
cleanup_worker.calculateExhibitionRevenue galleryService 删除 R0 硬编码 5 作为兜底值传给 taskService改造后 handler 直接调 assetLevelService.CalculateRevenue()R0 按等级从 DB 读),无需兜底
cleanup_worker.cleanup galleryService 删除 ticker 轮询 → Asynq delay task
cleanup_worker.cleanupExpiredExhibitions galleryService 删除 ZSET+DB 轮询 → Asynq delay task
cleanup_worker.cleanupAssetsFromZSET galleryService 删除 ZSET 按 asset 逐个清理 → Asynq delay task
cleanup_worker.cleanupExpiredExhibitionsFromDB galleryService 删除 DB 兜底轮询 → Asynq delay task
cleanup_worker.cleanupInvalidDisplayStatus galleryService → Asynq scheduler gallery:cleanup-display-status display_status 不一致修复,独立定时任务(每小时)
revenueService.OnExhibitionCompleted taskService mq/consumer.go handler 内部函数 改为 revenue:exhibition handler逻辑复用
revenueService.RecordLikeBetRevenue taskService mq/consumer.go handler 内部函数 改为 revenue:like-bet handler逻辑复用
revenueService.CalculateExhibitionRevenue taskService 保留 参考实现未被调用R0 硬编码 5保留供测试对比
revenueService.CalculateBuff taskService 保留 纯函数taskService 包内使用
CalculateLikeBetRevenue taskService 保留不动 纯计算函数handler 内部调用
assetLevelService.CalculateRevenue assetService 保留不动 唯一正式版本R0 从 levelConfig.HourlyRevenueDB读取
assetLevelService.CalculateBuff assetService 保留不动 纯函数assetService 包内使用(与 taskService.CalculateBuff 逻辑相同但各自独立)

12.2 AssetLevelService 接口

项目 改造前 改造后
接口定义 taskService revenue_service.go 保持不变taskService 仍需调用
实现方 assetService asset_level_service.go 保持不变
调用方式 galleryService RPC → taskService → assetService RPC Asynq handler → assetService RPC
CalculateRevenue OnExhibitionCompleted 内调用 revenue:exhibition handler 内调用,逻辑不变
AddExhibitionHours OnExhibitionCompleted 内调用 revenue:exhibition handler 内调用,逻辑不变

12.3 定时任务 Worker 迁移

Worker 当前位置 改造后
CleanupWorker galleryService 删除文件gallery:exhibition-expire delay task + gallery:cleanup-display-status scheduler
DailyResetWorker taskService 删除文件task:daily-reset Asynq scheduler
SeasonResetWorker assetService 删除文件asset:season-reset Asynq scheduler
Materializer statisticService 删除文件statistic:materialize Asynq scheduler
MetricWeeklyUserIncomeUpdater statisticService 删除文件statistic:weekly-income Asynq scheduler
MetricUpcomingLevelUpsUpdater statisticService 删除文件statistic:level-up Asynq scheduler
EventFlusher statisticService 删除文件 → Redis Streams consumer
Partitioner statisticService 保留文件,触发方式迁移statistic:partition-create + statistic:partition-drop Asynq scheduler

12.4 缓存/状态层迁移

组件 当前位置 改造后
sink.EventSink 接口 statisticService 删除文件 → 统一用 streams.Producer
sink.ChannelEventSink statisticService 删除文件 → 生产者直接 XADD Stream
worker.Partitioner statisticService 保留文件 → Asynq scheduler 触发events 表每日分区管理不能停)
database.GetExpiredAssets (ZSET) pkg/database 保留 — 兼容Asynq delay task 是主路径
database.RemoveExpiringAsset pkg/database 保留 — Asynq handler 内调用

十三、风险与缓解

风险 严重度 缓解
Asynq 依赖 RedisRedis 挂了全挂 Redis 已有主从,增加哨兵保活;关键同步路径保留 RPC 兜底
gallery:exhibition-expire handler 失败导致收益漏算 重试 1 次 + 保留 ZSET 每日兜底扫描(原 cleanupExpiredExhibitionsFromDB 逻辑改为 Asynq scheduler 每日跑一次)
revenue:exhibition 重试导致重复创建收益记录 handler 入口先按 exhibition_id 查已有记录,已存在直接返回 + 后续可加 DB 唯一约束
Streams 消费者组 rebalance 导致短暂不可用 消费者组启动时有 5s 的 claim pending 消息逻辑
异步化导致前端感知延迟 通知/收益创建本身就有 DB 写入延迟,异步化不增加用户可见延迟
收益计算逻辑迁移出错 revenue:exhibition handler 内部完全复用现有 OnExhibitionCompleted 逻辑,只改调用方式