一、引言
在现代的微服务架构中,业务系统通常由多个独立的系统组成,需要频繁地交换数据和事件。 为了保证服务的高可用性、可拓展性、低耦合,异步消息通信 成为微服务间传递消息的必要手段。 但实际应用中,异步消息通信也带了许多挑战,
- 事件可靠性问题 :由于网络波动或发送失败,导致的
业务数据与消息状态的不一致。 - 延迟任务调度问题:某些任务需要在特定时间点或延迟执行,而传统轮询效率低下,且时间精度有限。
- 系统可观测性与可维护性:在复杂的业务逻辑中,任务的状态、事件的流转、以及对异常的处理,都是系统设计的关键问题。
为了解决这些痛点,本博客将介绍几种关键技术方案:
- 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 扫描器设计
- 定时查询到期任务(带行锁防并发)
- 通过转换器将任务转为Outbox事件
- 写入Outbox表(含幂等性保护)
- 更新任务状态(成功/失败)
一个将延迟任务 可靠地桥接到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 应用场景
延时队列在实际应用中有很多应用场景,例如:
- 定时任务: 使用延时队列可以实现定时任务,例如每隔一段时间执行某个操作,或者在特定的时间点执行某个操作。
注:定时任务 一般是有固定任务周期的,而延时任务是由事件触发的。
- 消息重试 :在分布式系统中,消息可能因为网络原因或其他原因无法成功送达,此时可以使用延时队列实现消息的重试机制。消息发送失败后,将消息存入延迟队列,设置一个合适的延时时间,当时间到期后,重新发送消息。加粗样式
- 缓解并发压力:在高并发场景下,将大量请求先存入延时队列,然后由消费者逐一处理,从而避免瞬间请求对系统造成压力。
- 订单超时自动取消:电商系统中,用户下单后需要在一定时间内付款,否则订单会被自动取消。这种场景下,可以使用延时队列实现订单的超时监控。(类似于上面说的定时任务)
- 消息通知:在很多业务场景中,需要给用户发送消息通知,但是由于某些原因,这些消息不能及时发送。例如:当用户购买一件商品时,需要在3天内发货,如果超时未发货,需要给用户发送一条消息通知。这时候就可以通过延时队列来实现。(服务端主动向客户端发送消息)
4.3 传统定时器的弊端
以上场景都有一个特点,需要在某个事件发生之后 或者之前 的指定时间点完成某一项任务。
- 之后的指定时间点:如,下订单后10分钟,若未付款自动取消。
- 之前得指定时间点:如,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事件 → 消息中间件
实现目标
- Redis通道:实现毫秒级精度,主要出发路径
- 数据库通道:兜底,并且Redis故障时,也可实现优雅降级
- 双写策略:任务同时写入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 它负责:
- 订阅指定事件的消息
- 调用业务逻辑处理
- 成功处理后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 重要概念总结
- 消息ID:时间戳-序列号,保证唯一和有序
- 消费者组:多个消费者共同消费,每条消息只被一个消费者处理
- PENDING:已读取但未确认的消息
- ACK:确认消息处理完成,从PENDING中移除
- CLAIM:将超时未处理的消息转移给其他消费者
借鉴: 1、Outbox模式 2、微服务架构-Outbox发件箱模式 3、延时队列的三种实现方案 4、Redis Stream 菜鸟文档