Golang实现基于Redis的可靠延迟队列

宋时安
宋时安
发布时间:2026-10-15 | 4人浏览过
原创

在Go语言生态中,相比Java的Redisson,原生的Redis延迟队列库较为稀缺。本文分享一种基于Redis数据结构实现的可靠延迟队列方案,利用Lua脚本保障操作原子性,配合ACK/NACK确认机制,完美解决分布式场景下消息重复消费或丢失的问题。

核心设计与架构目标

在设计一个可靠的分布式消息队列时,核心挑战在于如何在无锁环境下保证状态的一致性。本方案允许多个生产者和消费者实例分布式部署,通过定时执行Lua脚本来驱动消息流转。由于Redis执行Lua脚本具有原子性,整个过程无需依赖额外的分布式锁服务。

核心机制说明:

  • 拉模式消费:消费者主动拉取消息,便于控制流量和并发度。
  • 至少投递一次(At-least-once):若消费者返回肯定确认(ACK),消息被彻底删除;若返回否定确认(NACK)或超时未回复,消息将被重新投递。
  • 并发安全:一条消息在同一时刻只会被分配给一个消费者处理,除非消费时间超过了预设的 MaxConsumeDuration,此时系统判定为超时并重新投递,可能引发并发消费。

以下为完整的使用示例,仅需注册回调函数并调用 StartConsume() 即可启动队列:

package main

import (
	"github.com/go-redis/redis/v8"
	"github.com/hdt3213/delayqueue"
	"strconv"
	"time"
)

func main() {
	// 初始化 Redis 客户端
	redisCli := redis.NewClient(&redis.Options{
		Addr: "127.0.0.1:6379",
	})
	
	// 创建延迟队列实例
	queue := delayqueue.NewQueue("example-queue", redisCli, func(payload string) bool {
		// 业务逻辑处理
		// 返回 true 表示成功,返回 false 则触发重试机制
		return true
	})
	
	// 发送延时消息(1小时后投递,最多重试3次)
	for i := 0; i < 10; i++ {
		err := queue.SendDelayMsg(strconv.Itoa(i), time.Hour, delayqueue.WithRetryCount(3))
		if err != nil {
			panic(err)
		}
	}

	// 启动消费者协程
	done := queue.StartConsume()
	<-done
}

数据可靠性边界:由于数据存储在Redis中,只要Redis节点故障率极低且相关Key未被外部恶意篡改,该方案能保证消息不丢失。

底层数据结构设计

为了实现复杂的流转状态,我们组合使用多种Redis数据结构。每条消息在内部由唯一的 UUID 标识,消息体存储在独立的 String Key 中以利用 TTL 自动清理。

  1. msgKey (String): 存储实际的消息内容(Payload)。每个消息ID对应一个独立Key,便于利用Redis的过期策略。
  2. pendingKey (ZSet): 有序集合。Member为消息ID,Score为预期投递时间的Unix时间戳。用于存储尚未到期的延迟消息。
  3. readyKey (List): 列表。存储已达到投递时间、准备分发给消费者的消息ID。
  4. unAckKey (ZSet): 有序集合。Member为消息ID,Score为下次重试时间。存储正在被消费者处理但尚未确认的消息。
  5. retryKey (List): 列表。存储已准备好进行重试的消息ID。
  6. garbageKey (Set): 集合。暂存已达到最大重试次数、需要被彻底清理的消息ID。
  7. retryCountKey (Hash): 哈希表。键为消息ID,值为剩余的重试次数。

如上图所示,消息在不同Key之间流转。由于多实例并发运行,操作顺序不可预知,因此所有关键转换操作必须满足三个条件:原子性、幂等性(不重复处理)以及状态机始终正确。

Lua脚本驱动的状态流转

我们将复杂的CRUD操作封装为Lua脚本,由Redis原生执行以保证原子性。

第1步:Pending转Ready

目的:扫描已到期的延迟消息,将其移至Ready列表,等待消费者拉取。

工具:ZRangeByScore 扫描 ZRemRangeByScore 移除。

-- keys: pendingKey, readyKey
-- argv: currentTime
local msgs = redis.call('ZRangeByScore', KEYS[1], '0', ARGV[1])
if (#msgs == 0) then return end

-- 将消息推入 Ready List
local args2 = {'LPush', KEYS[2]}
for _,v in ipairs(msgs) do
	table.insert(args2, v) 
end
redis.call(unpack(args2))

-- 从 Pending ZSet 中移除已处理的消息
redis.call('ZRemRangeByScore', KEYS[1], '0', ARGV[1])

第2步:Ready转Unack

目的:从Ready或Retry列表中取出一条消息,标记为“正在处理”(放入Unack),防止其他实例重复拉取。

工具:RPop 弹出,ZAdd 加入有序集合。

-- keys: readyKey/retryKey, unackKey
-- argv: retryTime
local msg = redis.call('RPop', KEYS[1])
if (not msg) then return end

-- 记录当前时间为下一次重试时间(初始可能为立即重试或稍后)
redis.call('ZAdd', KEYS[2], ARGV[1], msg)
return msg

第3步:Unack转Retry

目的:检查长时间未确认(超时)的消息。根据剩余重试次数,决定是重新投入重试队列,还是移入垃圾回收区。

注意事项:由于Redis Lua限制,无法动态访问未知的 msgKey。因此,脚本仅更新状态,并将需要物理删除的消息ID记入 garbageKey。

-- keys: unackKey, retryCountKey, retryKey, garbageKey
-- argv: currentTime
local msgs = redis.call('ZRangeByScore', KEYS[1], '0', ARGV[1])
if (#msgs == 0) then return end

local retryCounts = redis.call('HMGet', KEYS[2], unpack(msgs))
for i,v in ipairs(retryCounts) do
	local k = msgs[i]
	if tonumber(v) > 0 then
		-- 还有重试机会:扣减次数,丢入 Retry List
		redis.call("HIncrBy", KEYS[2], k, -1)
		redis.call("LPush", KEYS[3], k)
	else
		-- 超过最大重试次数:清理计数,丢入 Garbage Set 待删除
		redis.call("HDel", KEYS[2], k)
		redis.call("SAdd", KEYS[4], k)
	end
end
-- 从未确认集合中移除这些消息
redis.call('ZRemRangeByScore', KEYS[1], '0', ARGV[1])

垃圾回收与确认机制

ACK 确认(成功)

当业务逻辑处理成功后,调用 ack 方法。这会从 unAckKey 移除记录,并从Redis中彻底删除 msgKey。

func (q *DelayQueue) ack(idStr string) error {
	ctx := context.Background()
	err := q.redisCli.ZRem(ctx, q.unAckKey, idStr).Err()
	if err != nil {
		return fmt.Errorf("remove from unack failed: %v", err)
	}
	// 删除实际消息数据,忽略非Nil错误
	_ = q.redisCli.Del(ctx, q.genMsgKey(idStr)).Err()
	q.redisCli.HDel(ctx, q.retryCountKey, idStr)
	return nil
}

NACK 否定确认(失败)

当业务处理失败时,调用 nack 方法。它将消息在 unAckKey 中的 Score 更新为当前时间,在下一次轮询时立即触发 unack2RetryScript 进行重试。

func (q *DelayQueue) nack(idStr string) error {
	ctx := context.Background()
	// 更新时间戳,使得下一次 consume 周期能立刻捕获并重投
	err := q.redisCli.ZAdd(ctx, q.unAckKey, &redis.Z{
		Member: idStr,
		Score:  float64(time.Now().Unix()),
	}).Err()
	if err != nil {
		return fmt.Errorf("negative ack failed: %v", err)
	}
	return nil
}

垃圾回收(Garbage Collect)

对于达到最大重试次数仍失败的消息,它们被标记在 garbageKey 中。我们需要定期清理这些无效数据和对应的 Payload。

func (q *DelayQueue) garbageCollect() error {
	ctx := context.Background()
	msgIds, err := q.redisCli.SMembers(ctx, q.garbageKey).Result()
	if err != nil || len(msgIds) == 0 {
		return err
	}
	
	// 收集对应的实际消息 Key
	msgKeys := make([]string, 0, len(msgIds))
	for _, idStr := range msgIds {
		msgKeys = append(msgKeys, q.genMsgKey(idStr))
	}
	
	// 批量删除消息体和ID记录
	err = q.redisCli.Del(ctx, msgKeys...).Err()
	if err != nil && err != redis.Nil {
		return fmt.Errorf("del msgs failed: %v", err)
	}
	
	return q.redisCli.SRem(ctx, q.garbageKey, msgIds).Err()
}

调度循环主流程

整个系统的核心是每秒执行的 consume 循环。它协调了上述所有脚本的执行:

func (q *DelayQueue) consume() error {
	// 1. 到期消息移入就绪队列
	err := q.pending2Ready()
	if err != nil { return err }
	
	// 2. 拉取新消息进行处理
	var fetchCount uint
	for {
		idStr, err := q.ready2Unack()
		if err == redis.Nil { break }
		if err != nil { return err }
		
		fetchCount++
		ack, callbackErr := q.callback(idStr)
		if callbackErr != nil { return callbackErr }
		
		// 根据结果确认或重投
		if ack { err = q.ack(idStr) } else { err = q.nack(idStr) }
		if err != nil { return err }
		
		if fetchCount >= q.fetchLimit { break }
	}
	
	// 3. 处理超时/失败消息的重试逻辑
	err = q.unack2Retry()
	if err != nil { return err }
	
	// 4. 清理彻底失败的消息
	err = q.garbageCollect()
	if err != nil { return err }
	
	// 5. (可选)处理专门的重试队列... 
	return nil
}

以上就是Golang实现基于Redis的可靠延迟队列的详细内容,更多关于Golang Redis可靠延迟队列的资料请关注本站其它相关文章!

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表