1 需求背景
- 用户抢单成功之后,如果一定时间后没有完成任务,任务自动取消
- 用户提交任务审核后,如果商家一定时间后没有审核,任务自动通过
类似的场景比较多 简单的处理方式就是使用定时任务 假如数据比较多的时候 有的数据可能延迟比较严重,而且越来越多的定时业务导致任务调度很繁琐不好管理。
2 技术支撑
Redis 有序集合和集合一样也是string类型元素的集合,且不允许重复的成员。
不同的是每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。
有序集合的成员是唯一的,但分数(score)却可以重复。
集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。 集合中最大的成员数为 232 - 1 (4294967295, 每个集合可存储40多亿个成员)。
示例
redis 127.0.0.1:6379> ZADD runoobkey 1 redis
(integer) 1
redis 127.0.0.1:6379> ZADD runoobkey 2 mongodb
(integer) 1
redis 127.0.0.1:6379> ZADD runoobkey 3 mysql
(integer) 1
redis 127.0.0.1:6379> ZADD runoobkey 3 mysql
(integer) 0
redis 127.0.0.1:6379> ZADD runoobkey 4 mysql
(integer) 0
redis 127.0.0.1:6379> ZRANGE runoobkey 0 10 WITHSCORES
1) "redis"
2) "1"
3) "mongodb"
4) "2"
5) "mysql"
6) "4"
3 队列设计
3.1 任务结构
// Job 使用msgpack序列化后保存到Redis,减少内存占用
type Job struct {
Topic string `json:"topic" msgpack:"1"` //主题(任务类型)
No string `json:"no" msgpack:"2"` // job唯一标识ID
Delay int64 `json:"delay" msgpack:"3"` // 执行时间, unix时间戳
TTR int64 `json:"ttr" msgpack:"4"` //超时时间
XXX string `json:"xxx" msgpack:"5"` //其他附加字段
}
Topic:主题,每个任务所属类型,不通类型任务可以分开调度
No :主题标识,标识唯一
Delay:执行时间戳
TTR:超时时间,执行时间已过,再进过TTR时间,任务将被丢弃
XXX:根据需要添加其他字段
3.2 任务桶Bucket(redis有序集合)
根据定时任务量的大小,设置Bucket的数量,Bucket的数量指定同时轮询的队列数。添加的任务将平均放置到不同的任务桶中。
3.3 就绪队列(list)
达到执行时间的任务,将从任务桶中取出,根据主题分类,放置到就绪队列中。
3.4 持久化
需要借助数据库等额外实现持久化
3.5 运行
1 初始化:根据配置连接redis服务,初始化相应数量的任务桶和轮询协程
2 添加任务:初始化任务结构,经过msgpack序列化后,添加到redis集合和其中一个任务桶中
3 轮询队列:各个任务桶的轮询协程轮询时间戳最小的任务是否达到执行时间,如果达到,则取出放到就绪队列。
4 执行任务:从就绪队列中取出任务,检查任务是否被删除,如果被删除,则直接返回;否则返回任务给执行体执行。
5 删除任务:执行完任务,删除redis集合中的任务;不想任务执行,直接提前删除任务。
4 代码实现(包含商品和订单任务例子)
延时队列初始化
package delayqueue
import (
"errors"
"fmt"
"github.com/astaxie/beego"
"ranbbService/models"
"time"
"github.com/astaxie/beego/orm"
"ranbbService/util/cache"
)
var (
// 每个定时器对应一个bucket
timers []*time.Ticker
// bucket名称chan
bucketNameChan <-chan string
session_redis *cache.RedisCache
)
// Init 初始化延时队列
func InitDelayQueue() error{
var err error
session_redis, err = cache.NewRedisCacheFromCfg("session_redis")
if err!=nil{
beego.Error("InitDelayQueue,err=",err)
return err
}
//初始化配置,如果配置文件中没有,则使用默认配置
Setting = &Config{}
Setting.configParse()
RedisPool = initRedisPool()
//初始化各个任务桶的轮询定时器
initTimers()
bucketNameChan = generateBucketName()
//初始化时添加Jobs
err = InitJobsTable()
if err!=nil{
beego.Error("InitDelayQueue,err=",err)
return err
}
//处理任务定时
go handleGoods()
//处理订单定时
go handleOrders()
return nil
}
func InitJobsTable() error {
//处理任务
goods,count,err := models.DelayQueueGoods(0)
if err !=nil{
beego.Error("AddJobsTable,err=",err)
return err
}
beego.Debug("Goods Jobs counts:",count)
for _,goods:=range goods{
job := Job{
Topic:"GOODS",
No:goods.No,
Delay:goods.ExecTime,
TTR:10,//十秒超时
UUID:goods.UUID,
}
beego.Debug("Push goods:",job.No)
err = Push(job)
if err !=nil{
beego.Error("AddJobsTable,err=",err)
return err
}
}
//处理订单
orders,count,err := models.DelayQueueOrders(0)
if err !=nil{
beego.Error("AddJobsTable,err=",err)
return err
}
beego.Debug("Orders Jobs counts:",count)
for _,order:=range orders{
job := Job{
Topic:"ORDERS",
No:order.No,
Delay:order.ExecTime,
TTR:10,//十秒超时
UUID:order.UUID,
}
beego.Debug("Push orders:",job.No)
err = Push(job)
if err !=nil{
beego.Error("AddJobsTable,err=",err)
return err
}
}
return nil
}
添加任务:
// Push 添加一个Job到队列中
func Push(job Job) error {
if job.No == "" || job.Topic == "" || job.Delay < 0 || job.TTR <= 0 {
return errors.New("invalid job")
}
//将任务添加到任务池(redis集合)
err := putJob(job.No, job)
if err != nil {
beego.Error("添加job到job pool失败#job-%+v#%s", job, err.Error())
return err
}
//将任务添加到任务桶(redis有序集合)
err = pushToBucket(<-bucketNameChan, job.Delay, job.No)
if err != nil {
beego.Error("添加job到bucket失败#job-%+v#%s", job, err.Error())
return err
}
return nil
}
轮询任务桶,将达到执行时间的任务放到就绪队列中
// 初始化定时器
func initTimers() {
timers = make([]*time.Ticker, Setting.BucketSize)
var bucketName string
for i := 0; i < Setting.BucketSize; i++ {
timers[i] = time.NewTicker(1 * time.Second)
bucketName = fmt.Sprintf(Setting.BucketName, i+1)
beego.Debug("Init delay queue bucket:",bucketName)
go waitTicker(timers[i], bucketName)
}
}
func waitTicker(timer *time.Ticker, bucketName string) {
for {
select {
case t := <-timer.C:
tickHandler(t, bucketName)
}
}
}
// 扫描bucket, 取出延迟时间小于当前时间的Job
func tickHandler(t time.Time, bucketName string) {
for {
bucketItem, err := getFromBucket(bucketName)
if err != nil {
beego.Error("扫描bucket错误#bucket-%s#%s", bucketName, err.Error())
return
}
// 集合为空
if bucketItem == nil {
//beego.Debug("Got bucketItem is nil")
return
}
// 延迟时间未到
if bucketItem.timestamp > t.Unix() {
if (bucketItem.timestamp - t.Unix()) < 10{
beego.Debug("Not the time for executing job:",bucketItem.jobNo,"need time:",bucketItem.timestamp - t.Unix(),"s")
}
return
}
// 延迟时间小于等于当前时间, 取出Job元信息并放入ready queue
job, err := getJob(bucketItem.jobNo)
if err != nil {
beego.Error("获取Job元信息失败#bucket-%s#%s", bucketName, err.Error())
continue
}
// job元信息不存在, 从bucket中删除
if job == nil {
removeFromBucket(bucketName, bucketItem.jobNo)
continue
}
// 再次确认元信息中delay是否小于等于当前时间
if job.Delay > t.Unix() {
// 从bucket中删除旧的jobNo
removeFromBucket(bucketName, bucketItem.jobNo)
// 重新计算delay时间并放入bucket中
pushToBucket(<-bucketNameChan, job.Delay, bucketItem.jobNo)
continue
}
//beego.Debug("pushToReadyQueue:",job.Topic,",No:",bucketItem.jobNo)
err = pushToReadyQueue(job.Topic, bucketItem.jobNo)
if err != nil {
beego.Error("JobNo放入ready queue失败#bucket-%s#job-%+v#%s",
bucketName, job, err.Error())
continue
}
// 从bucket中删除
removeFromBucket(bucketName, bucketItem.jobNo)
}
}