使用 Redis 构建一个可靠的延迟队列
本文内容来自开源项目:github.com/hdt3213/delayqueue
在现代软件开发中,我们经常会遇到需要在特定时间后执行任务的场景。
这些场景包括但不限于订单超时关闭、定时提醒、以及失败后重试机制等。
为了满足这些需求,我们需要一个既可靠又灵活的延迟队列系统。
本文将介绍如何使用 Redis 来构建这样一个系统,并提供一个 Go 语言的实现方案。
背景#
在生产环境中,一个可靠的延迟队列需要满足以下几个条件:
- 数据持久化:服务崩溃或重启后,任务不会丢失。
- 支持重试:面对超时或处理失败,需要有重试机制来保证任务的成功率。
- 定时精确:用户设定的执行时间需要被严格遵守。
- 高任务量处理:能够应对大量的任务请求。
然而,传统的实现方案,如定时扫表、Redis 过期监听、非持久化的时间轮等,都存在一定的局限性。因此,我们寻求一个更加可靠的解决方案。
实现原理#
使用 Redis 实现延迟队列的核心思想是将待执行的任务放入一个有序集合(SortedSet)中,其中消息 ID 作为成员(member),执行时间作为分数(score)。
通过定时扫描,将到达执行时间的任务转移到另一个数据结构中,如 List 或 Stream,然后由消费者进行处理。
我们的方案中,消息首先被放入名为 pending
的 SortedSet 中,等待到达执行时间。
然后,我们定义了一个名为 ready
的 List 数据结构,用于存放已到达投递时间的消息。
每秒扫描一次 pending
,并将其中已到投递时间的消息转移到 ready
中。
数据结构#
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
中删除处理成功的消息并清理其msgKey
和retryCount
数据。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,并删除对应的msgKey
和retryCountKey
中的记录。 - 如果消息处理失败或超时,消息 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,删除对应的msgKey
和retryCountKey
中的记录,以释放资源。
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
}
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战