高可靠微服务消息设计: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 菜鸟文档


相关推荐
架构师专栏16 分钟前
Spring Boot 4 概述与重大变化
spring boot·后端
武子康19 分钟前
大数据-162 Apache Kylin 增量 Cube 与 Segment 实战:按天分区增量构建指南
大数据·后端·apache kylin
SimonKing42 分钟前
IntelliJ IDEA 2025.2.x的小惊喜和小BUG
java·后端·程序员
青梅主码1 小时前
介绍一下我用AI开发的一款新工具:函数图像绘制工具(二)
后端
q***01771 小时前
Spring Boot 热部署
java·spring boot·后端
IT_陈寒2 小时前
JavaScript 闭包通关指南:从作用域链到内存管理的8个核心知识点
前端·人工智能·后端
ChineHe2 小时前
Golang并发编程篇002_Go并发基础
开发语言·后端·golang
g***72702 小时前
springBoot发布https服务及调用
spring boot·后端·https
风象南2 小时前
Spring Boot拦截器结合HMAC-SHA256实现API安全验证
后端