高可靠微服务消息设计:Outbox模式、延迟队列与Watermill集成实践

一、引言

在现代的微服务架构中,业务系统通常由多个独立的系统组成,需要频繁地交换数据和事件。 为了保证服务的高可用性、可拓展性、低耦合,异步消息通信 成为微服务间传递消息的必要手段。 但实际应用中,异步消息通信也带了许多挑战,

  • 事件可靠性问题 :由于网络波动或发送失败,导致的业务数据消息状态的不一致。
  • 延迟任务调度问题:某些任务需要在特定时间点或延迟执行,而传统轮询效率低下,且时间精度有限。
  • 系统可观测性与可维护性:在复杂的业务逻辑中,任务的状态、事件的流转、以及对异常的处理,都是系统设计的关键问题。

为了解决这些痛点,本博客将介绍几种关键技术方案:

  • Outbox模式:通过 "先持久化事件,在异步转发" 的方式,事件发布,避免消息丢失。
  • 延迟任务调度系统:通过 数据库进行持久化任务,并结合扫描器和Outbox事件机制,实现延迟任务的可靠执行。
  • 延迟队列优化方案 :基于Redis Sorted Set实现高精度延迟触发,同时保留了原本数据库的持久化的可靠性。
  • 消息中间件整合 :通过Watermill与Redis Stream集成,构建了事件驱动框架,实现了高可靠的消息传递与处理。

二、Outbox模式:保证事件可靠发布,避免丢失事件

2.1 引言

2.1.1 背景

背景:在现代微服务架构中,服务之间常常需要通过异步消息传递来解耦 。例如,一个 订单服务 成功创建订单后,需要通知库存服务扣减库存。但若消息发送失败,此时就会造成写库成功,但消息发送失败 ,这时可能就需要回滚采用补偿机制

2.1.2 传统方法的缺陷

复制代码
1、事件发布成功但事务回滚:事件已发出,但业务数据未保存
2、事务提交但事件发布失败:业务数据已保存,但下游系统不知道

2.1.3 Outbox模式

但Outbox采用了一种全新的模式:把"发消息"变成"写日记",先把要通知别人的事情存入数据库、进行持久化 (记在小本本上),再推送消息 (找个靠谱的快递员去送信)。

2.2 具体实现

Outbox模式,通常是由两个部分组成。 分别为收集并持久化消息(仓储层实现),与转发消息(事件中继器)

2.2.1 核心数据结构

go 复制代码
// Outbox事件表结构
type OutboxEvent struct {
    EventID       string    `gorm:"primaryKey"`        // 事件唯一ID,UUID
    EventType     string    `gorm:"not null;index"`    // 事件类型,支付成功/失败
    // "发生了什么" - 描述具体的业务动作或状态变化
    
    AggregateID   string    `gorm:"not null;index"`    // 聚合根ID,订单ID
    AggregateType string    `gorm:"not null"`          // 聚合类型,订单
	// "发生在谁身上" - 描述被操作的业务实体或聚合根

    Payload       string    `gorm:"not null"`          // 事件数据(JSON)
    Status        string    `gorm:"not null;index"`    // 状态:pending/published/failed
    RetryCount    int       `gorm:"default:0"`         // 重试次数
    
    CreatedAt     time.Time `gorm:"not null;index"`    // 创建时间
    PublishedAt   *time.Time                           // 发布时间
}

2.2.2 仓储层实现

抽象出来的接口:

go 复制代码
// Repository Outbox 仓库接口
// 定义事件发件箱的所有数据访问操作
type Repository interface {
	// SaveEvent 在事务中保存事件到 outbox 表
	// aggregateID: 服务 ID(如订单 ID)
	// aggregateType: 服务类型(如 "Order")
	// payload: 事件数据(会被序列化为 JSON)
	SaveEvent(tx *gorm.DB, eventType, aggregateID, aggregateType string,
		payload any) error
		
	// GetPendingEvents 获取待发布的事件
	// limit: 最多返回多少条
	GetPendingEvents(ctx context.Context, limit int) ([]*models.OutboxEvent, error)

	// MarkAsPublished 标记事件为已发布
	// eventID: 事件的唯一 ID
	MarkAsPublished(ctx context.Context, eventID string) error
	
	// PublishInTransaction 在事务中发布事件
	PublishInTransaction(fn func(tx *gorm.DB) error) error
}

具体的实现逻辑:

go 复制代码
// 在事务中保存事件
func (r *repositoryImpl) SaveEvent(
    tx *gorm.DB,
    eventType, aggregateID, aggregateType string,
    payload any,
) error {
    // 序列化事件数据
    payloadJSON, err := json.Marshal(payload)
    if err != nil {
        return err
    }

    // 创建Outbox事件记录
    event := &OutboxEvent{
        EventID:       uuid.New().String(),
        EventType:     eventType,
        AggregateID:   aggregateID,
        AggregateType: aggregateType,
        Payload:       string(payloadJSON),
        Status:        OutboxEventStatusPending,
        CreatedAt:     time.Now(),
    }

    // 在同一个事务中保存
    return tx.Create(event).Error
}

// 事务性发布封装
func (r *repositoryImpl) PublishInTransaction(fn func(tx *gorm.DB) error) error {
    return r.db.Transaction(fn)
}

2.2.3 中继器

relay(中继器)的作用,就是转发,持久化在数据库中OutboxEvent表中的数据。 1、首先进行接口的抽象。

go 复制代码
// Relay Outbox 事件中继接口
type Relay interface {
	Start(ctx context.Context) error // 启动轮询循环
	Stop() error                     // 停止中继器
}

2、对接口进行具体的实现

go 复制代码
// Relay配置
type RelayConfig struct {
    PollInterval time.Duration // 轮询间隔
    BatchSize    int           // 批次大小
    MaxRetry     int           // 最大重试次数
}

// 事件中继器实现
type relayImpl struct {
    repo      Repository
    publisher message.Publisher
    config    RelayConfig
}
// Start 启动(阻塞运行)
func (r *relayImpl) Start(ctx context.Context) error {
	1、创建定时器
	2、定时调用processEvents函数,取出一批待发布事件
	2.1、将打包好的message,放入消息中间件
	2.2、同步将待发布的事件标记为已发布
}

// Stop 停止 Relay
func (r *relayImpl) Stop() error {
	close(r.stopCh)
	return nil
}

// 处理待发布事件
func (r *relayImpl) processEvents(ctx context.Context) error {
    // 1. 查询待发布事件
    events, err := r.repo.GetPendingEvents(ctx, r.config.BatchSize)
    if err != nil || len(events) == 0 {
        return err
    }

    // 2. 批量发布事件	
    for _, event := range events {
        if err := r.publishEvent(ctx, event); err != nil {
            // 发布失败,标记为失败状态
            r.repo.MarkAsFailed(ctx, event.EventID, err.Error())
            continue
        }
        
        // 发布成功,标记为已发布
        r.repo.MarkAsPublished(ctx, event.EventID)
    }
    
    return nil
}

// 发布单个事件
func (r *relayImpl) publishEvent(ctx context.Context, event *OutboxEvent) error {
    // 构建Watermill消息
    msg := message.NewMessage(event.EventID, []byte(event.Payload))
    
    // 设置元数据
    msg.Metadata.Set("event_id", event.EventID)
    msg.Metadata.Set("event_type", event.EventType)
    msg.Metadata.Set("aggregate_id", event.AggregateID)
    msg.Metadata.Set("aggregate_type", event.AggregateType)
    
    // 发布到消息中间件
    return r.publisher.Publish(event.EventType, msg)
}

为何在publishEvent能这般构造函数,是因为Watermill的底层如下:

go 复制代码
// Watermill的Message结构
type Message struct {
    UUID     string            // 消息唯一标识
    Payload  []byte            // 消息体(原始字节)
    Metadata map[string]string // 元数据键值对
    .....
    ackCh    chan error        // 确认通道(内部使用)
    nackCh   chan error        // 否定确认通道(内部使用)
    ctx      context.Context   // 上下文
}

当以上逻辑完成之后,启动服务器。

2.2.4 启动中继器

go 复制代码
// 启动Outbox Relay
func StartOutboxRelay(ctx context.Context) {
    // 初始化依赖
    repo := outbox.NewRepository(db)
    publisher := messaging.NewRedisStreamsPublisher(redisClient, logger)
    
    // 创建Relay实例
    relay := outbox.NewRelay(repo, publisher, outbox.RelayConfig{
        PollInterval: 100 * time.Millisecond,
        BatchSize:    100,
        MaxRetry:     3,
    })
    
    // 启动Relay(阻塞运行)
    go func(){
     if err := relay.Start(ctx); err != nil {
			globals.Log.Errorf("Outbox Relay stopped with error: %v", err)
		}
	}
}

2.3 总结:

Outbox模式通过 先持久化,后中继转发 的两阶段方式, 不仅将不稳定的网络通信原子性的数据库事务中剥离。 更是,为系统的可观测性可维护性提供了坚实基础。 它是在微服务架构中实现可靠异步通信的基石模式,被广泛应用于需要保证数据最终一致性的场景。

三、延迟任务调度系统

3.1 设计目标

基于数据库实现了延迟任务调度系统,延迟任务被存储到delayed_task表中,其中包含任务类型、执行时间、负载等。 一个扫描器(Scanner)定期检查到期任务,并将其转换为Outbox事件,进而由OutboxRelay发布。 从而可以在持久化的同时,精确的执行延迟任务。如支付超时等。

3.2 架构图

复制代码
业务服务 → 创建延迟任务 → delayed_tasks表
                          ↓
                   延迟队列扫描器
                          ↓  
                   转换为Outbox事件
                          ↓
                   Outbox Relay → 消息中间件 → 事件处理器

3.3 具体实现

3.3.1 核心数据结构

go 复制代码
// 延迟任务表结构
type DelayedTask struct {
    ID           uint           `gorm:"primaryKey"`
    TaskID       string         `gorm:"type:varchar(36);uniqueIndex;not null"` // UUID
    TaskType     string         `gorm:"type:varchar(36);not null;index"`       // 任务类型
    Payload      string         `gorm:"not null"`                              // 任务数据(JSON)
    ExecuteAt    time.Time      `gorm:"not null;index:idx_execute_at_status"`  // 执行时间
    Status       string         `gorm:"type:varchar(20);not null;index:idx_execute_at_status"` // 状态
    RetryCount   int            `gorm:"type:int;default:0"`                    // 重试次数
    ErrorMessage string         `gorm:"type:text"`                             // 错误信息
    CreatedAt    time.Time      `json:"created_at"`
    UpdatedAt    time.Time      `json:"updated_at"`
    DeletedAt    gorm.DeletedAt `gorm:"index"`                                 // 软删除
}

// 任务状态常量
const (
    DelayedTaskStatusPending    = "pending"    // 待执行
    DelayedTaskStatusProcessing = "processing" // 执行中  
    DelayedTaskStatusCompleted  = "completed"  // 已完成
    DelayedTaskStatusFailed     = "failed"     // 失败
)

// 任务类型常量
const (
	.....
	如:(支付超时)类型,(vip到期提醒)类型....
)

3.3.2 服务接口

go 复制代码
type Service interface {
    // ScheduleTask 调度延迟任务
    ScheduleTask(ctx context.Context, req *ScheduleRequest) (string, error)
    
    // CancelTask 取消延迟任务(软删除)
    CancelTask(ctx context.Context, taskID string) error
    
    // GetTask 查询任务状态
    GetTask(ctx context.Context, taskID string) (*DelayedTask, error)
}

// 调度请求构建器(Builder模式)
type ScheduleRequest struct {
    taskType  string         // 任务类型
    payload   map[string]any // 任务数据
    delay     time.Duration  // 延迟时长
    executeAt *time.Time     // 执行时间(可选)
}

// 创建调度请求
func NewScheduleRequest() *ScheduleRequest {
    return &ScheduleRequest{
        payload: make(map[string]any),
    }
}

// 链式调用设置参数
func (r *ScheduleRequest) WithTaskType(taskType string) *ScheduleRequest {
    r.taskType = taskType
    return r
}

func (r *ScheduleRequest) WithPayload(payload map[string]any) *ScheduleRequest {
    r.payload = payload
    return r
}

func (r *ScheduleRequest) WithDelay(delay time.Duration) *ScheduleRequest {
    r.delay = delay
    return r
}

func (r *ScheduleRequest) WithExecuteAt(executeAt time.Time) *ScheduleRequest {
    r.executeAt = &executeAt
    return r
}

其中构造实例,采用的是build模式,其中,有几大优点: 1、可读性:通过链式调用,设置参数的可读性,类似自然语言。 2、灵活好用:可以按任意顺序设置参数,并且可以只设置需要的参数,从达到不同目的。 3、易于拓展:若要增加新的参数,只需要新增一个字段与新的with方法就行。 4、可以隐藏构建细节。

3.3.3 服务实现

为了实现上述service服务接口。

go 复制代码
type serviceImpl struct {
    repo Repository
}

// ScheduleTask 任务调度
func (s *serviceImpl) ScheduleTask(ctx context.Context, req *ScheduleRequest) (string, error) {
    // 1、参数验证
  	...
    
    // 2、计算执行时间
    var executeAt time.Time
    if req.executeAt != nil {
        executeAt = *req.executeAt
    } else {
        executeAt = time.Now().Add(req.delay)
    }
    
    // 3、序列化payload
    payloadJSON, err := json.Marshal(req.payload)
    if err != nil {
        return "", fmt.Errorf("序列化payload失败: %w", err)
    }
    
    // 4、构建任务
    taskID := uuid.New().String()
    task := &DelayedTask{
        TaskID:    taskID,
        TaskType:  req.taskType,
        Payload:   string(payloadJSON),
        ExecuteAt: executeAt,
        Status:    DelayedTaskStatusPending,
    }
    
    // 5、持久化
    if err := s.repo.CreateTask(ctx, task); err != nil {
        return "", fmt.Errorf("创建任务失败: %w", err)
    }
    
    return taskID, nil
}

   // CancelTask 取消延迟任务(软删除)
func (s *serviceImpl) CancelTask(ctx context.Context, taskID string) error{ ... }
    
    // GetTask 查询任务状态
func (s *serviceImpl) GetTask(ctx context.Context, taskID string) (*DelayedTask, error){ ... }

3.3.4仓储层实现

go 复制代码
type Repository interface {
	// 创建任务
    CreateTask(ctx context.Context, task *DelayedTask) error
    // 获取临期任务
    GetExpiredTasks(ctx context.Context, limit int) ([]*DelayedTask, error)
    // 更新任务状态
    UpdateTaskStatus(ctx context.Context, taskID string, status string, errorMsg string) error
    // 根据 ID 查询任务
    GetTaskByID(ctx context.Context, taskID string) (*DelayedTask, error)
    // 删除任务(软删除) 使用场景:业务方主动取消任务
    DeleteTask(ctx context.Context, taskID string) error
}

3.3.5 扫描器设计

  1. 定时查询到期任务(带行锁防并发)
  2. 通过转换器将任务转为Outbox事件
  3. 写入Outbox表(含幂等性保护)
  4. 更新任务状态(成功/失败)

一个将延迟任务 可靠地桥接到Outbox模式桥梁处理器

扫描器实现

go 复制代码
type scannerImpl struct {
    repo       Repository
    outboxRepo outbox.Repository
    converter  TaskToEventConverter
    config     ScannerConfig
    stopCh     chan struct{}
}

// scanAndProcess 扫描并处理到期任务
func (s *scannerImpl) scanAndProcess(ctx context.Context) error {
    // 1、查询到期任务
    tasks, err := s.repo.GetExpiredTasks(ctx, s.config.BatchSize)
    if err != nil {
        return fmt.Errorf("查询到期任务失败: %w", err)
    }
    // 2、逐个处理到期任务
    // 成功-则标记成功
    // 失败-则标记失败,并自动跳过
    for _, task := range tasks {
        if err := s.processTask(ctx, task); err != nil {
            // 标记为失败
            s.repo.UpdateTaskStatus(ctx, task.TaskID, DelayedTaskStatusFailed, err.Error())
            continue
        }
        
        // 标记为已完成
        s.repo.UpdateTaskStatus(ctx, task.TaskID, DelayedTaskStatusCompleted, "")
    }
    
    return nil
}

// processTask 处理单个任务:转换为Outbox事件
// 先将 DelayedTask类型 转换为 outbox类型 ,最后存入outbox,等待被转发
func (s *scannerImpl) processTask(ctx context.Context, task *DelayedTask) error {
    // 使用转换器
    outboxEvent, err := s.converter.Convert(task)
    if err != nil {
        return fmt.Errorf("转换任务为事件失败: %w", err)
    }
    
    // 写入Outbox表
    if err := s.outboxRepo.Create(ctx, outboxEvent); err != nil {
        // 幂等性处理:如果是唯一索引冲突,说明已处理过
        if isDuplicateKeyError(err) {
            return nil // 返回成功,让任务标记为completed
        }
        return fmt.Errorf("创建Outbox事件失败: %w", err)
    }
    
    return nil
}

转换器实现 简而言之,就是将DelayedTask类型 转换为 outbox类型。

go 复制代码
type TaskToEventConverter interface {
    Convert(task *DelayedTask) (*OutboxEvent, error)
}

type defaultConverter struct{}

func (c *defaultConverter) Convert(task *DelayedTask) (*OutboxEvent, error) {
    // 验证payload是否为合法JSON
    var payloadMap map[string]any
    if err := json.Unmarshal([]byte(task.Payload), &payloadMap); err != nil {
        return nil, fmt.Errorf("无效的payload JSON: %w", err)
    }
    
    // 构建Outbox事件
    return &OutboxEvent{
        EventID:       task.TaskID,      // 任务ID作为事件ID
        EventType:     task.TaskType,    // 任务类型作为事件类型
        AggregateID:   task.TaskID,      // 任务ID作为聚合根ID
        AggregateType: "DelayedTask",    // 固定类型
        Payload:       task.Payload,     // 透传payload
        Status:        OutboxEventStatusPending,
    }, nil
}

其实上方一整个模块,只是为了实现: 让业务可以基于时间 触发复杂的业务流程,同时保证在分布式环境下的数据一致性执行可靠性。 但这些,其实引入延迟队列,会是一个更棒的选择

四、延迟队列

4.1 引言

在分布式系统中,延时队列(Delay Queue)是一个常见的工具,它 允许程序能够按照预定时间处理任务 (类似于定时任务)。 相比于普通的队列(先进先出),最大的区别,就体现在其延时属性上。这种机制使得延时队列可以用于实现定时任务、消息重试等功能。从而提高系统的可靠性和性能。

4.2 应用场景

延时队列在实际应用中有很多应用场景,例如:

  1. 定时任务: 使用延时队列可以实现定时任务,例如每隔一段时间执行某个操作,或者在特定的时间点执行某个操作。

注:定时任务 一般是有固定任务周期的,而延时任务是由事件触发的。

  1. 消息重试 :在分布式系统中,消息可能因为网络原因或其他原因无法成功送达,此时可以使用延时队列实现消息的重试机制。消息发送失败后,将消息存入延迟队列,设置一个合适的延时时间,当时间到期后,重新发送消息。加粗样式
  2. 缓解并发压力:在高并发场景下,将大量请求先存入延时队列,然后由消费者逐一处理,从而避免瞬间请求对系统造成压力。
  3. 订单超时自动取消:电商系统中,用户下单后需要在一定时间内付款,否则订单会被自动取消。这种场景下,可以使用延时队列实现订单的超时监控。(类似于上面说的定时任务)
  4. 消息通知:在很多业务场景中,需要给用户发送消息通知,但是由于某些原因,这些消息不能及时发送。例如:当用户购买一件商品时,需要在3天内发货,如果超时未发货,需要给用户发送一条消息通知。这时候就可以通过延时队列来实现。(服务端主动向客户端发送消息)

4.3 传统定时器的弊端

以上场景都有一个特点,需要在某个事件发生之后 或者之前指定时间点完成某一项任务。

  1. 之后的指定时间点:如,下订单后10分钟,若未付款自动取消。
  2. 之前得指定时间点:如,vip到期前的1天,自动提醒。

若用,定时器的轮询方式,需要定期扫描一遍数据库,数量少还能应付。如果有上百万甚至上千万的数据,此时用定时器轮询数据库,显然会给服务器带来极大的压力,且很可能在一秒内无法完成所有订单 的检查。造成既无法完成业务要求性能又低下

4.4 本项目中的遗憾,以及后续的升级目标

4.4.1 传统延迟队列

(如RabbitMQ死信队列、Redis Sorted Set)

text 复制代码
生产者 → 消息中间件(延迟队列) → 消费者
    ↓                          ↓
  立即入队                  时间到自动出队

4.4.2 而本项目实现的延迟任务调度系统

text 复制代码
生产者 → 数据库表 → 扫描器轮询 → 消息中间件 → 消费者
    ↓             ↓          ↓
  立即入库      主动查询     主动推送

4.4.3 差距

所以,本项目中使用的并不是延迟队列,而是一个延迟任务调度系统。 虽然这样持久化了数据、保证了原子性(在同一事物中处理),并使任务状态可视化。 但仍存在许多弊端的:

  • 需要主动轮询,而非由事件驱动。
  • 频繁的select查询会给数据库造成压力
  • 受到轮询的影响,可能会有几秒的误差 ...

4.4.4 改进方案:

可以选择引入Redis Sorted Set。 架构图

scss 复制代码
// 基于Redis Sorted Set

业务服务 → 双写策略
    ├─→ Redis Sorted Set (毫秒级触发)
    └─→ 数据库表 (持久化兜底)
        ↓
    Redis自动到期 → Outbox事件 → 消息中间件
        ↓
    数据库扫描器 (容错备份) → Outbox事件 → 消息中间件

实现目标

  1. Redis通道:实现毫秒级精度,主要出发路径
  2. 数据库通道:兜底,并且Redis故障时,也可实现优雅降级
  3. 双写策略:任务同时写入Redis和数据库

主代码

go 复制代码
// 可靠混合延迟队列
type ReliableHybridDelayQueue struct {
    taskRepo    Repository
    outboxRepo  outbox.Repository  
    redis       *redis.Client
    scanner     Scanner
    redisPoller *RedisDelayPoller
    ...
}

func (h *ReliableHybridDelayQueue) ScheduleTask(ctx context.Context, req *ScheduleRequest) (string, error) {
    taskID := uuid.New().String()
    executeAt := calculateExecuteAt(req)
    
    // 1. 数据库持久化(可靠性保证)
    task := &DelayedTask{
        TaskID:    taskID,
        TaskType:  req.taskType,
        Payload:   serializePayload(req.payload),
        ExecuteAt: executeAt,
        Status:    DelayedTaskStatusPending,
    }
    if err := h.taskRepo.CreateTask(ctx, task); err != nil {
        return "", fmt.Errorf("创建数据库任务失败: %w", err)
    }
    
    // 2. Redis Sorted Set写入(精准定时)
    redisKey := "delay_queue:" + req.taskType
    taskData, _ := json.Marshal(map[string]interface{}{
        "task_id": taskID, "task_type": req.taskType, "payload": req.payload, ...
    })
    
    // Redis写入失败不影响主流程,有数据库兜底
    if err := h.redis.ZAdd(ctx, redisKey, &redis.Z{
    	// 时间精度-毫秒级 
        Score: float64(executeAt.UnixMilli()), Member: string(taskData),
    }).Err(); err != nil {
        log.Warnf("Redis写入失败,任务 %s 将走数据库兜底", taskID)
    }
    
    return taskID, nil
}

// Redis轮询器(主通道)
type RedisDelayPoller struct {
    redis     *redis.Client
    outboxRepo outbox.Repository
    ...
}

func (p *RedisDelayPoller) pollAndProcess(ctx context.Context) error {
    now := time.Now().Unix()
    
    // 扫描所有延迟队列key
    keys, _ := p.redis.Keys(ctx, "delay_queue:*").Result()
    for _, key := range keys {
        // 获取到期任务
        tasks, _ := p.redis.ZRangeByScore(ctx, key, &redis.ZRangeBy{
            Min: "0", Max: strconv.FormatInt(now, 10), Count: 100,
        }).Result()
        
        for _, taskData := range tasks {
            // 移除并处理
            p.redis.ZRem(ctx, key, taskData)
            // 转换为Outbox事件...
            p.outboxRepo.Create(ctx, outboxEvent)
        }
    }
    return nil
}

// 数据库扫描器(兜底机制)
type scannerImpl struct {
    repo       Repository
    outboxRepo outbox.Repository
    ...
}

func (s *scannerImpl) scanAndProcess(ctx context.Context) error {
    // 查询到期任务(频率较低,如30秒一次)
    tasks, _ := s.repo.GetExpiredTasks(ctx, 100)
    for _, task := range tasks {
        // 转换为Outbox事件...
        s.outboxRepo.Create(ctx, outboxEvent)
        // 更新任务状态...
    }
    return nil
}

后期仍然需要改进: 需要新增:死信队列(DLQ, Dead Letter Queue) 死信队列(DLQ)的作用是:

  • 存放无法被正常消费或处理的消息
  • 例如重试 N 次失败、格式异常、超时等
  • 提供可追踪、人工干预、重投等能力

对此,可以新增,人工或后台服务查看 DLQ,如:GUI 面板(Web管理界面) 最后将解决后的问题重新入队。

延迟任务调度系统vs延迟队列 1、延迟任务调度系统可持久化并可视化,但存在轮询压力和延时误差; 2、延迟队列(如 Redis Sorted Set、RabbitMQ DLQ)可降低延迟、减少数据库压力。

五、消息中间件:Redis Streams和Watermill的集成

5.1 什么是Redis Stream

Redis Stream是Redis5.0版本新增的数据结构。 Redis Stream主要用于消息队列(MQ,Message Queue),Redis本身是有一个Redis发布订阅(pub/sub)来实现消息队列的功能。但缺点就是消息无法持久化。 而Redis Stream提供了消息持久化主备复制功能。可以让任何客户端,访问任何时刻的数据,并且能记住客户端的访问位置,还能保证消息不丢失。(借鉴了kafka的消费组的概念) 简而言之:他是一个有序、可持久化的日志数据结构,支持多个消费者组对消息进行消费,并通过ACK机制保证消息不丢失和可靠传输。 如下:

5.2 什么是Watermill

go Watermill 是一个用于处理消息流的高效 Go 语言库,主要用于构建事件驱动的应用程序。它支持事件溯源、基于消息的RPC、Sagas 等多种场景,并能与 Kafka、RabbitMQ、HTTP、Redsi等不同的消息中间件集成使用。其核心在于提供一套简单的接口和灵活的中间件(我们的manpao项目,就是自己通过redis Stream实现了一套适配Watermill的中间件),使得开发者可以专注于消息处理逻辑本身,而无需过多关注底层通信细节。

5.3 我为何要用Watermill+redis Stream

在现代微服务架构中,事件驱动正成为越来越常见的通信方式。 而,恰恰Go 生态中,就有一个非常优秀的事件/消息抽象库 ------ Watermill。 Watermill 本身不限制你使用 Kafka、NATS、Redis 等任意消息系统,只要你实现它的最核心接口即可。 而Redis Stream又满足

  • 可持久化
  • 严格有序
  • 支持consumer Group
  • 具备ACK机制
  • 并且维护Pending Entries List,确保消息不会丢失

所以非常适合用来做,轻量级事件系统/内网消息总线。

5.3 实现

5.3.1 架构

txt 复制代码
                     (你的实现代码)
Watermill Publisher  ---------------------------------→ RedisStreamsPublisher
                                |
                                |  XADD (写入 Stream)
                                ↓
                       Redis Stream (持久化队列)
                         ↑           │
                         │           │ XREADGROUP (读取)
                         │           ↓
Watermill Subscriber  ←------------------------ RedisStreamsSubscriber
                         |
                         | 将 redis.XMessage 转为 Watermill Message
                         |
                    Business Handler (处理并 Ack)

5.3.2 Watermill 事件系统的核心接口

go 复制代码
// Publisher side
type Publisher interface {
    Publish(topic string, messages ...*Message) error
    Close() error
}

// Subscriber side
type Subscriber interface {
    Subscribe(ctx context.Context, topic string) (<-chan *Message, error)
    Close() error
}

message是统一封装

go 复制代码
type Message struct {
    UUID     string
    Payload  []byte
    Metadata map[string]string

    Ack func() error  // 可选,由基础实现决定
}

5.3.3 Redis Streams 适配 Watermill 的 Publisher

发布事件 → 写入 Redis Stream → 自增 ID

go 复制代码
type RedisStreamsPublisher struct {
	client *redis.Client
	logger watermill.LoggerAdapter
}

func (p *RedisStreamsPublisher) Publish(topic string, msgs ...*message.Message) error {
	ctx := context.Background()

	for _, msg := range msgs {
		values := map[string]any{
			"uuid":    msg.UUID,
			"payload": string(msg.Payload),
		}
		// 写入 metadata
		for k, v := range msg.Metadata {
			values["metadata_"+k] = v
		}

		if _, err := p.client.XAdd(ctx, &redis.XAddArgs{
			Stream: topic,
			Values: values,
		}).Result(); err != nil {
			return fmt.Errorf("xadd failed: %w", err)
		}
	}

	return nil
}

func (p *RedisStreamsPublisher) Close() error { return nil }

5.3.4 Redis Streams 适配 Watermill 的 Subscriber

核心流程:

  • 创建 Consumer Group
  • 使用 XREADGROUP 阻塞拉取消息
  • 转换为 Watermill Message
  • 用户业务 Handle 后调用 AckMessage() → XACK

(1)构造与注册消费者组

go 复制代码
type RedisStreamsSubscriber struct {
	client        *redis.Client
	consumerGroup string
	consumerName  string
}

func NewRedisStreamsSubscriber(cfg SubscriberConfig) (message.Subscriber, error) {
	return &RedisStreamsSubscriber{
		client:        cfg.Client,
		consumerGroup: cfg.ConsumerGroup,
		consumerName:  cfg.ConsumerName,
	}, nil
}

func (s *RedisStreamsSubscriber) ensureGroup(ctx context.Context, stream string) error {
	err := s.client.XGroupCreateMkStream(ctx, stream, s.consumerGroup, "0").Err()
	if err != nil && !strings.Contains(err.Error(), "BUSYGROUP") {
		return err
	}
	return nil
}

(2)Subscribe:启动 goroutine,用 XREADGROUP 读取消息

go 复制代码
func (s *RedisStreamsSubscriber) Subscribe(
	ctx context.Context,
	topic string,
) (<-chan *message.Message, error) {

	if err := s.ensureGroup(ctx, topic); err != nil {
		return nil, err
	}

	ch := make(chan *message.Message)
	go s.readLoop(ctx, topic, ch)
	return ch, nil
}

func (s *RedisStreamsSubscriber) readLoop(
	ctx context.Context,
	topic string,
	out chan *message.Message,
) {
	defer close(out)

	for {
		select {
		case <-ctx.Done():
			return
		default:
			streams, err := s.client.XReadGroup(ctx, &redis.XReadGroupArgs{
				Group:    s.consumerGroup,
				Consumer: s.consumerName,
				Streams:  []string{topic, ">"},
				Count:    10,
				Block:    time.Second,
			}).Result()

			if err != nil && err != redis.Nil {
				time.Sleep(time.Second)
				continue
			}

			for _, st := range streams {
				for _, m := range st.Messages {
					msg := s.convert(topic, m)
					out <- msg
				}
			}
		}
	}
}

(3)消息转换 & ACK

go 复制代码
func (s *RedisStreamsSubscriber) convert(
	stream string, xm redis.XMessage,
) *message.Message {

	msg := message.NewMessage(xm.Values["uuid"].(string),
		[]byte(xm.Values["payload"].(string)))

	msg.Metadata = make(map[string]string)
	for k, v := range xm.Values {
		if strings.HasPrefix(k, "metadata_") {
			msg.Metadata[k[9:]] = v.(string)
		}
	}

	// 注册 ACK 行为
	streamName := stream
	msgID := xm.ID

	// 注意,这里只是注册了一个回调函数,等待后续去执行
	msg.Ack = func() error {
		return s.client.XAck(context.Background(),
			streamName, s.consumerGroup, msgID).Err()
	}

	return msg
}

(4)初始化 Watermill + Redis Streams + Handler

go 复制代码
func InitEventSubscribers(ctx context.Context) error {
	logger := logger.NewWatermillLogger(globals.Log)

	// 订单域消费者组
	orderSub, _ := messaging.NewRedisStreamsSubscriber(
		messaging.SubscriberConfig{
			Client:        globals.RDB,
			ConsumerGroup: "order-processing-group",
			ConsumerName:  "order-consumer",
			Logger:        logger,
		},
	)

	// 订阅 payment.succeeded
	payMsgs, _ := orderSub.Subscribe(ctx, "payment.succeeded")
	handler := eventhandlers.NewOrderPaymentHandler(
		orderSub.(*messaging.RedisStreamsSubscriber), logger)

	go func() {
		for msg := range payMsgs {
			if err := handler.Handle(msg); err != nil {
				globals.Log.Errorf("处理支付事件失败: %v", err)
			}
		}
	}()

	return nil
}

(5)架构

txt 复制代码
1. Watermill 事件抽象
   - Publisher / Subscriber / Message

2. Redis Streams 为什么适合作事件总线
   - 持久化、有序、消费组、ACK

3. Redis Streams 实现 Watermill Publisher

4. Redis Streams 实现 Watermill Subscriber
   - XGROUP
   - XREADGROUP
   - ACK

5. 系统初始化与事件处理绑定(可运行的示例)

六、事件处理器:订阅事件并执行业务逻辑

6.1 引言

在事件驱动框架中,Subscriber 只是把消息拉取到了本地,真正的业务逻辑由 **事件处理器(Event Handler)**来执行。 就上上方,咱们简略提到的:5.3.4中的:初始化 Watermill + Redis Streams + Handler 它负责:

  1. 订阅指定事件的消息
  2. 调用业务逻辑处理
  3. 成功处理后ACK消息,告诉Redis Stream消息已被消费

6.2 流程

scss 复制代码
Redis Stream (持久化队列)
       ↑
       | XREADGROUP
       ↓
Watermill Subscriber
       ↓
       ├─> Event Handler: 执行业务逻辑
       │
       └─> Ack() → XACK 消息

6.3 代码实例

go 复制代码
func InitEventSubscribers(ctx context.Context) error {
    // 创建 Watermill 日志适配器
    logger := logger.NewWatermillLogger(globals.Log)

    // 1、订单域消费者组
    orderSub, _ := messaging.NewRedisStreamsSubscriber(
        messaging.SubscriberConfig{
            Client:        globals.RDB,
            ConsumerGroup: "order-processing-group",
            ConsumerName:  "order-consumer",
            Logger:        logger,
        },
    )

    // 2、订阅 "payment.succeeded" 事件
    payMsgs, _ := orderSub.Subscribe(ctx, "payment.succeeded")
    handler := eventhandlers.NewOrderPaymentHandler(
        orderSub.(*messaging.RedisStreamsSubscriber), logger,
    )

    // 3、启动 goroutine 处理消息
    go func() {
        for msg := range payMsgs {
            if err := handler.Handle(msg); err != nil {
                globals.Log.Errorf("处理支付事件失败: %v", err)
            } else {
                // 成功处理后 ACK 消息
                _ = msg.Ack()
            }
        }
    }()

    // 4、可以订阅更多事件,例如退款
    refundMsgs, _ := orderSub.Subscribe(ctx, "payment.refunded")
    refundHandler := eventhandlers.NewOrderRefundHandler(
        orderSub.(*messaging.RedisStreamsSubscriber), logger,
    )

    go func() {
        for msg := range refundMsgs {
            if err := refundHandler.Handle(msg); err != nil {
                globals.Log.Errorf("处理退款事件失败: %v", err)
            } else {
                _ = msg.Ack()
            }
        }
    }()

    return nil
}

6.4 细节

这里能一直运行的核心原因就是 消息通道(channel) + goroutine 循环 构成了一个持续的消息消费管道。

七、拓展

7.1 常见redis Stream操作

7.1.1 Stream 消息 ID

基本格式

redis 复制代码
<毫秒时间戳>-<序列号>

示例:

redis 复制代码
1659430290156-0
  • 1659430290156:毫秒级时间戳
  • 0:同一毫秒内的序列号(从0开始)

7.1.2 特殊 ID 符号

* - 自动生成 ID

redis 复制代码
XADD mystream * field1 value1 field2 value2

Redis 会自动生成当前时间戳的 ID

$ - 最后一条消息

redis 复制代码
XREAD STREAMS mystream $

表示 Stream 中最后一条消息的 ID

0 - 第一条消息

redis 复制代码
XRANGE mystream 0 +

表示 Stream 的开始

-+ - 最小和最大 ID

redis 复制代码
XRANGE mystream - +
  • -:最小可能的 ID(0000000000000-0)
  • +:最大可能的 ID(18446744073709551615-18446744073709551615)

7.1.3 消费者组相关符号

> - 获取新消息

redis 复制代码
XREADGROUP GROUP mygroup consumer1 COUNT 1 STREAMS mystream >
  • >:只获取尚未分配给任何消费者的新消息

7.1.4 消费者组状态符号

PENDING 消息状态

在您之前的例子中:

redis 复制代码
XPENDING mystream groupC

输出解释:

bash 复制代码
1) (integer) 3          # 总共有3条待处理消息
2) "1659430290156-0"    # 最早的消息ID
3) "1659430750387-0"    # 最晚的消息ID
4)                      # 消费者列表
   1) 1) "consumer1"    # 消费者1
      2) "1"            # 有1条待处理消息
   2) 1) "consumer2"    # 消费者2
      2) "1"            # 有1条待处理消息
   3) 1) "consumer3"    # 消费者3
      2) "1"            # 有1条待处理消息

7.2 常用命令详解

7.2.1 XADD - 添加消息

redis 复制代码
XADD mystream * name "Alice" age 30
XADD mystream 1659430000000-0 name "Bob" age 25  # 自定义ID

7.2.2 XREAD - 读取消息

redis 复制代码
# 从ID 1659430000000-0 开始读取
XREAD STREAMS mystream 1659430000000-0

# 阻塞读取,最多等待5000毫秒
XREAD BLOCK 5000 STREAMS mystream $

7.2.3 XREADGROUP - 消费者组读取

redis 复制代码
XREADGROUP GROUP mygroup consumer1 COUNT 10 BLOCK 5000 STREAMS mystream >

7.2.4 XPENDING - 查看待处理消息

redis 复制代码
# 查看概要
XPENDING mystream mygroup

# 查看详细信息
XPENDING mystream mygroup - + 10

7.2.5 XACK - 确认消息处理完成

redis 复制代码
XACK mystream mygroup 1659430290156-0

7.2.6 XCLAIM - 消息转移

redis 复制代码
# 将闲置超过5000毫秒的消息转移给consumer2
XCLAIM mystream mygroup consumer2 5000 1659430290156-0

7.3 重要概念总结

  1. 消息ID:时间戳-序列号,保证唯一和有序
  2. 消费者组:多个消费者共同消费,每条消息只被一个消费者处理
  3. PENDING:已读取但未确认的消息
  4. ACK:确认消息处理完成,从PENDING中移除
  5. CLAIM:将超时未处理的消息转移给其他消费者

借鉴: 1、Outbox模式 2、微服务架构-Outbox发件箱模式 3、延时队列的三种实现方案 4、Redis Stream 菜鸟文档


相关推荐
Victor35621 小时前
Hibernate(29)什么是Hibernate的连接池?
后端
Victor35621 小时前
Hibernate(30)Hibernate的Named Query是什么?
后端
源代码•宸21 小时前
GoLang八股(Go语言基础)
开发语言·后端·golang·map·defer·recover·panic
czlczl2002092521 小时前
OAuth 2.0 解析:后端开发者视角的原理与流程讲解
java·spring boot·后端
颜淡慕潇1 天前
Spring Boot 3.3.x、3.4.x、3.5.x 深度对比与演进分析
java·后端·架构
布列瑟农的星空1 天前
WebAssembly入门(一)——Emscripten
前端·后端
小突突突1 天前
Spring框架中的单例bean是线程安全的吗?
java·后端·spring
iso少年1 天前
Go 语言并发编程核心与用法
开发语言·后端·golang
掘金码甲哥1 天前
云原生算力平台的架构解读
后端
码事漫谈1 天前
智谱AI从清华实验室到“全球大模型第一股”的六年征程
后端