使用 Redis 构建一个可靠的延迟队列

本文内容来自开源项目:github.com/hdt3213/delayqueue
在现代软件开发中,我们经常会遇到需要在特定时间后执行任务的场景。
这些场景包括但不限于订单超时关闭、定时提醒、以及失败后重试机制等。
为了满足这些需求,我们需要一个既可靠又灵活的延迟队列系统。
本文将介绍如何使用 Redis 来构建这样一个系统,并提供一个 Go 语言的实现方案。

背景#

在生产环境中,一个可靠的延迟队列需要满足以下几个条件:

  • 数据持久化:服务崩溃或重启后,任务不会丢失。
  • 支持重试:面对超时或处理失败,需要有重试机制来保证任务的成功率。
  • 定时精确:用户设定的执行时间需要被严格遵守。
  • 高任务量处理:能够应对大量的任务请求。

然而,传统的实现方案,如定时扫表、Redis 过期监听、非持久化的时间轮等,都存在一定的局限性。因此,我们寻求一个更加可靠的解决方案。

实现原理#

使用 Redis 实现延迟队列的核心思想是将待执行的任务放入一个有序集合(SortedSet)中,其中消息 ID 作为成员(member),执行时间作为分数(score)。
通过定时扫描,将到达执行时间的任务转移到另一个数据结构中,如 List 或 Stream,然后由消费者进行处理。

我们的方案中,消息首先被放入名为 pending 的 SortedSet 中,等待到达执行时间。
然后,我们定义了一个名为 ready 的 List 数据结构,用于存放已到达投递时间的消息。
每秒扫描一次 pending,并将其中已到投递时间的消息转移到 ready 中。

数据结构#

在我们的实现中,一共涉及 7 个 Redis 数据结构:

  • pending:存储未到投递时间的消息,使用Redis zset数据结构。
  • ready:存储已到投递时间的消息,使用Redis list数据结构。
  • unack:存储已投递但未确认成功消费的消息 ID,使用Redis zset数据结构。
  • retry:存储处理超时后等待重试的消息 ID,使用Redis list数据结构。
  • garbage:暂存已达重试上限的消息 ID,使用Redis zset数据结构。
  • msgKey:存储消息内容的字符串键,使用Redis set数据结构。
  • retryCountKey:存储消息剩余重试次数的哈希表,使用Redis hash数据结构。

操作流程#

整个消息队列的操作流程包括:

  • send:发送新消息,存储消息内容和重试次数,并将消息 ID 放入 pending 中。
  • pending2ready:将已到投递时间的消息从 pending 移动到 ready 中。
  • ready2unack:将等待投递的消息从 ready 移动到 unack 中,并发送给消费者。
  • unack2retry:将 unack 中未到重试次数上限的消息转移到 retry 中,已到重试次数上限的转移到 garbage 中。
  • ack:从 unack 中删除处理成功的消息并清理其 msgKeyretryCount 数据。
  • garbageCollect:清理已到最大重试次数的消息。

消息流转过程#

在延迟队列中,消息的流转过程如下:
1.发送消息(send)

  • 首先将消息内容存储到 msgKey 中,并设置相应的 TTL。
  • 将消息 ID 和执行时间作为分数存储到 pending 队列中。
  • 同时在 retryCountKey 中记录消息的初始重试次数。
func (q *DelayQueue) SendScheduleMsg(payload string, t time.Time, opts ...interface{}) error {
	// parse options 用于自定义重试次数和过期时间
	retryCount := q.defaultRetryCount
	for _, opt := range opts {
		switch o := opt.(type) {
		case retryCountOpt:
			retryCount = uint(o)
		case msgTTLOpt:
			q.msgTTL = time.Duration(o)
		}
	}
	// generate id,生成一个uuid
	idStr := uuid.Must(uuid.NewRandom()).String()
	now := time.Now()
	// store msg 消息的过期时间应该是投递时间加上消息的ttl,记录消息的内容
	msgTTL := t.Sub(now) + q.msgTTL // delivery + q.msgTTL
	err := q.redisCli.Set(q.genMsgKey(idStr), payload, msgTTL)
	if err != nil {
		return fmt.Errorf("store msg failed: %v", err)
	}
	// store retry count,将所有的需要retry的id存到一个hash结构里面,记录其需要重试的次数
	err = q.redisCli.HSet(q.retryCountKey, idStr, strconv.Itoa(int(retryCount)))
	if err != nil {
		return fmt.Errorf("store retry count failed: %v", err)
	}
	// put to pending,写到pending的zset里面
	err = q.redisCli.ZAdd(q.pendingKey, map[string]float64{idStr: float64(t.Unix())})
	if err != nil {
		return fmt.Errorf("push to pending failed: %v", err)
	}
	q.reportEvent(NewMessageEvent, 1)
	return nil
}

2.到期检测和转移(pending2ready)

  • 定时任务(通常每秒执行一次)使用 Lua 脚本扫描 pending 队列,找出所有已到达执行时间的消息。
  • 将这些消息从 pending 队列中移除,并将其消息 ID 添加到 ready 队列中。
func (q *DelayQueue) pending2Ready() error {
	now := time.Now().Unix()
	keys := []string{q.pendingKey, q.readyKey}
	raw, err := q.eval(pending2ReadyScript, keys, []interface{}{now})
	if err != nil && err != NilErr {
		return fmt.Errorf("pending2ReadyScript failed: %v", err)
	}
	count, ok := raw.(int64)
	if ok {
		q.reportEvent(ReadyEvent, int(count))
	}
	return nil
}

const pending2ReadyScript = `
local msgs = redis.call('ZRangeByScore', KEYS[1], '0', ARGV[1])  -- get ready msg
if (#msgs == 0) then return end
local args2 = {} -- keys to push into ready
for _,v in ipairs(msgs) do
	table.insert(args2, v) 
    if (#args2 == 4000) then
		redis.call('LPush', KEYS[2], unpack(args2))
		args2 = {}
	end
end
if (#args2 > 0) then 
	redis.call('LPush', KEYS[2], unpack(args2))
end
redis.call('ZRemRangeByScore', KEYS[1], '0', ARGV[1])  -- remove msgs from pending
return #msgs
`

3.消息投递(ready2unack)

  • 消费者从 ready 队列中拉取消息 ID,然后从 msgKey 中获取消息内容进行处理。
  • 消息 ID 被移除 ready 队列,并添加到 unack 队列中,同时设置一个重试时间。
func (q *DelayQueue) ready2Unack() (string, error) {
	retryTime := time.Now().Add(q.maxConsumeDuration).Unix()
	keys := []string{q.readyKey, q.unAckKey}
	ret, err := q.eval(ready2UnackScript, keys, []interface{}{retryTime})
	if err == NilErr {
		return "", err
	}
	if err != nil {
		return "", fmt.Errorf("ready2UnackScript failed: %v", err)
	}
	str, ok := ret.(string)
	if !ok {
		return "", fmt.Errorf("illegal result: %#v", ret)
	}
	q.reportEvent(DeliveredEvent, 1)
	return str, nil
}
const ready2UnackScript = `
local msg = redis.call('RPop', KEYS[1])
if (not msg) then return end
redis.call('ZAdd', KEYS[2], ARGV[1], msg)
return msg
`

4.消息确认和重试(ack 和 unack2retry)

  • 如果消息处理成功,消费者调用 ack 方法,从 unack 队列中移除消息 ID,并删除对应的 msgKeyretryCountKey 中的记录。
  • 如果消息处理失败或超时,消息 ID 会在 unack2retry 过程中被重新调度。如果剩余重试次数大于 0,则将消息 ID 移动到 retry 队列中等待重试;如果重试次数耗尽,则将消息 ID 移动到 garbage 集合中等待清理。
func (q *DelayQueue) ack(idStr string) error {
	atomic.AddInt32(&q.fetchCount, -1)
	err := q.redisCli.ZRem(q.unAckKey, []string{idStr})
	if err != nil {
		return fmt.Errorf("remove from unack failed: %v", err)
	}
	// msg key has ttl, ignore result of delete
	_ = q.redisCli.Del([]string{q.genMsgKey(idStr)})
	_ = q.redisCli.HDel(q.retryCountKey, []string{idStr})
	q.reportEvent(AckEvent, 1)
	return nil
}
func (q *DelayQueue) nack(idStr string) error {
	atomic.AddInt32(&q.fetchCount, -1)
	// update retry time as now, unack2Retry will move it to retry immediately
	err := q.redisCli.ZAdd(q.unAckKey, map[string]float64{
		idStr: float64(time.Now().Unix()),
	})
	if err != nil {
		return fmt.Errorf("negative ack failed: %v", err)
	}
	q.reportEvent(NackEvent, 1)
	return nil
}
func (q *DelayQueue) unack2Retry() error {
	keys := []string{q.unAckKey, q.retryCountKey, q.retryKey, q.garbageKey}
	now := time.Now()
	raw, err := q.eval(unack2RetryScript, keys, []interface{}{now.Unix()})
	if err != nil && err != NilErr {
		return fmt.Errorf("unack to retry script failed: %v", err)
	}
	infos, ok := raw.([]interface{})
	if ok && len(infos) == 2 {
		retryCount, ok := infos[0].(int64)
		if ok {
			q.reportEvent(RetryEvent, int(retryCount))
		}
		failCount, ok := infos[1].(int64)
		if ok {
			q.reportEvent(FinalFailedEvent, int(failCount))
		}
	}
	return nil
}
const unack2RetryScript = `
local unack2retry = function(msgs)
	local retryCounts = redis.call('HMGet', KEYS[2], unpack(msgs)) -- get retry count
	local retryMsgs = 0
	local failMsgs = 0
	for i,v in ipairs(retryCounts) do
		local k = msgs[i]
		if v ~= false and v ~= nil and v ~= '' and tonumber(v) > 0 then
			redis.call("HIncrBy", KEYS[2], k, -1) -- reduce retry count
			redis.call("LPush", KEYS[3], k) -- add to retry
			retryMsgs = retryMsgs + 1
		else
			redis.call("HDel", KEYS[2], k) -- del retry count
			redis.call("SAdd", KEYS[4], k) -- add to garbage
			failMsgs = failMsgs + 1
		end
	end
	return retryMsgs, failMsgs
end

local retryMsgs = 0
local failMsgs = 0
local msgs = redis.call('ZRangeByScore', KEYS[1], '0', ARGV[1])  -- get retry msg
if (#msgs == 0) then return end
if #msgs < 4000 then
	local d1, d2 = unack2retry(msgs)
	retryMsgs = retryMsgs + d1
	failMsgs = failMsgs + d2
else
	local buf = {}
	for _,v in ipairs(msgs) do
		table.insert(buf, v)
		if #buf == 4000 then
		    local d1, d2 = unack2retry(buf)
			retryMsgs = retryMsgs + d1
			failMsgs = failMsgs + d2
			buf = {}
		end
	end
	if (#buf > 0) then
		local d1, d2 = unack2retry(buf)
		retryMsgs = retryMsgs + d1
		failMsgs = failMsgs + d2
	end
end
redis.call('ZRemRangeByScore', KEYS[1], '0', ARGV[1])  -- remove msgs from unack
return {retryMsgs, failMsgs}
`

5.重试消息处理(retry2unack)

  • 定时任务会将 retry 队列中的消息 ID 移动到 unack 队列中,以便重新投递和处理。
func (q *DelayQueue) retry2Unack() (string, error) {
	retryTime := time.Now().Add(q.maxConsumeDuration).Unix()
	keys := []string{q.retryKey, q.unAckKey}
	ret, err := q.eval(ready2UnackScript, keys, []interface{}{retryTime, q.retryKey, q.unAckKey})
	if err == NilErr {
		return "", NilErr
	}
	if err != nil {
		return "", fmt.Errorf("ready2UnackScript failed: %v", err)
	}
	str, ok := ret.(string)
	if !ok {
		return "", fmt.Errorf("illegal result: %#v", ret)
	}
	return str, nil
}
const ready2UnackScript = `
local msg = redis.call('RPop', KEYS[1])
if (not msg) then return end
redis.call('ZAdd', KEYS[2], ARGV[1], msg)
return msg
`

6.垃圾回收(garbageCollect)

  • 定期清理 garbage 集合中的消息 ID,删除对应的 msgKeyretryCountKey 中的记录,以释放资源。
func (q *DelayQueue) garbageCollect() error {
	msgIds, err := q.redisCli.SMembers(q.garbageKey)
	if err != nil {
		return fmt.Errorf("smembers failed: %v", err)
	}
	if len(msgIds) == 0 {
		return nil
	}
	// allow concurrent clean
	msgKeys := make([]string, 0, len(msgIds))
	for _, idStr := range msgIds {
		msgKeys = append(msgKeys, q.genMsgKey(idStr))
	}
	err = q.redisCli.Del(msgKeys)
	if err != nil && err != NilErr {
		return fmt.Errorf("del msgs failed: %v", err)
	}
	err = q.redisCli.SRem(q.garbageKey, msgIds)
	if err != nil && err != NilErr {
		return fmt.Errorf("remove from garbage key failed: %v", err)
	}
	return nil
}

作者:Esofar

出处:https://www.cnblogs.com/wanber/p/18651686

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   技术漫游  阅读(29)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
more_horiz
keyboard_arrow_up light_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示