Contents

Build a Delay Queue with Redis

delay queue block diagram by YouZan

Job

type Job struct {
	Id string `json:"id"`
	Topic string `json:"topic"`
	Delay int64 `json:"delay"`
	TTR int64 `json:"ttr"`  // time to run, guarantee reliability
	Body string `json:"body"` // service data
}

Job Pool

Redis hash
job’s Id $ \rightarrow $ hash key
meta data of the job $ \rightarrow $ hash value

// poolAdd add the job hash
func poolAdd(jobId string, data []byte) error {
	return rdb.HSet(background, PoolKey, jobId, data).Err()
}

// poolRem remove the job hash by job's Id
func poolRem(jobId string) error {
	return rdb.HDel(background, PoolKey, jobId).Err()
}

// poolGet get job meta data by job's Id
// the meta data is transformed into Job struct with unmarshal
func poolGet(jobId string) *Job{
	jobStr, err := rdb.HGet(background, PoolKey, jobId).Result()
	if err != nil {
		return nil
	}
	job := new(Job)
	if err = json.Unmarshal([]byte(jobStr), job); err != nil {
		return nil
	}
	return job
}

Ready Queue

Redis list
job’s Id $ \rightarrow $ list member

BRPOP blocks up to 24h until a job id is available, or the caller cancels.

func queuePop(ctx context.Context, topic string) (string, error) {
	key := fmt.Sprintf(QueueKeyPrefix, topic)
	vals, err := rdb.BRPop(ctx, 24*time.Hour, key).Result()
	if err != nil {
		return "", err
	}
	return vals[1], nil
}

func queueAdd(topic, jobId string) error {
	queueKey := fmt.Sprintf(QueueKeyPrefix, topic)
	return rdb.LPush(background, queueKey, jobId).Err()
}

Delay Bucket

Redis sorted set (zset)
job’s Id $ \rightarrow $ zset member
delay of the job $ \rightarrow $ zset score

multiple buckets will improve the polling efficiency

func bucketAdd(jobId string, jobDelay int64) error {
	bucketNum := <- bucketNumChan
	key := fmt.Sprintf(BucketKeyPrefix, bucketNum)
	return rdb.ZAdd(background, key,
		&redis.Z{Score: float64(time.Now().Unix()+jobDelay), Member: jobId}).Err()
}

func bucketRem(bucketNum int, jobId string) error {
	key := fmt.Sprintf(BucketKeyPrefix, bucketNum)
	return rdb.ZRem(background, key, jobId).Err()
}

func bucketGetFirst(bucketNum int) *redis.Z {
	key := fmt.Sprintf(BucketKeyPrefix, bucketNum)
	bucketItems := rdb.ZRangeWithScores(background, key, 0, 0).Val()
	if len(bucketItems) == 0 {
		return nil
	}
	return &bucketItems[0]
}

Timer

func initTimers() {
	timers = make([]*time.Ticker, BucketSize)
	for i := 0; i < BucketSize; i++ {
		timers[i] = time.NewTicker(1 * time.Second)
		go poolingBucket(timers[i], i)
	}
}

func poolingBucket(ticker *time.Ticker, bucketNum int) {
	for t := range ticker.C {
		item := bucketGetFirst(bucketNum)
		if item == nil || int64(item.Score) > t.Unix() { // does not come the time to run
			continue
		}

		jobId := item.Member.(string)
		job := poolGet(jobId)
		if job == nil { // job is not found in the pool (usually is deleted)
			bucketRem(bucketNum, jobId)
			continue
		}
		if queueAdd(job.Topic, job.Id) != nil {
			continue
		}
		bucketRem(bucketNum, job.Id)
	}
}

Delay Queue Server

syntax = "proto3";

option go_package = "delayqueue/pb";

service DelayQueue {
  rpc Push(PushReq) returns (PushResp);
  rpc Pop(PopReq) returns (PopResp);
  rpc Finish(FinishReq) returns (FinishResp);
}

message PushReq {
  string topic = 1;
  int64 delay = 2;
  int64 ttr = 3;
  string body = 4;
}

message PushResp {
  string id = 1;
}

message PopReq {
  string topic = 1;
}

message PopResp {
  string body = 1;
}

message FinishReq {
  string job_id = 1;
}

message FinishResp {}
package main

import (
	"context"
	"encoding/json"
	"errors"
	"net"
	"time"

	"github.com/go-basic/uuid"
	"github.com/go-redis/redis/v8"
	"google.golang.org/grpc"

	pb "delayqueue/pb"
)

const (
	PoolKey         = "dq:pool"
	QueueKeyPrefix  = "dq:queue:%s"
	BucketSize      = 5
	BucketKeyPrefix = "dq:bucket:%d"
)

var rdb *redis.Client

var timers []*time.Ticker

var bucketNumChan <-chan int

var background = context.Background()

type server struct {
	pb.UnimplementedDelayQueueServer
}

func (s *server) Push(ctx context.Context, req *pb.PushReq) (*pb.PushResp, error) {
	if req.Topic == "" {
		return nil, errors.New("topic is empty")
	}
	if req.Delay < 0 {
		req.Delay = 0
	}
	if req.Ttr < 0 {
		req.Ttr = 0
	}

	job := &Job{
		Id:    uuid.New(),
		Topic: req.Topic,
		Delay: req.Delay,
		TTR:   req.Ttr,
		Body:  req.Body,
	}
	jobBytes, err := json.Marshal(job)
	if err != nil {
		return nil, err
	}

	// 用 lua 脚本写成一个命令,保证操作的原子性
	if err = poolAdd(job.Id, jobBytes); err != nil {
		return nil, err
	}

	if err = bucketAdd(job.Id, job.Delay); err != nil {
		return nil, err
	}
	return &pb.PushResp{Id: job.Id}, nil
}

func (s *server) Finish(ctx context.Context, req *pb.FinishReq) (*pb.FinishResp, error) {
	poolRem(req.JobId)
	return &pb.FinishResp{}, nil
}

func (s *server) Pop(ctx context.Context, req *pb.PopReq) (*pb.PopResp, error) {
	jobId, err := queuePop(ctx, req.Topic)
	if err != nil {
		return nil, err
	}

	job := poolGet(jobId)
	if job == nil {
		return nil, errors.New("no job at present")
	}

	if job.TTR > 0 {
		if err = bucketAdd(job.Id, job.TTR); err != nil {
			return nil, err
		}
	}

	return &pb.PopResp{Body: job.Body}, nil
}

func initBucketNumChan() {
	c := make(chan int)
	go func() {
		i := 1
		for {
			c <- i
			i++
			if i > BucketSize {
				i = 1
			}
		}
	}()
	bucketNumChan = c
}

func main() {
	// connect to redis
	rdb = redis.NewClient(&redis.Options{
		Addr:     "localhost:6379",
		Password: "",
		DB:       0,
	})

	// pooling the buckets
	initTimers()
	initBucketNumChan()

	lis, err := net.Listen("tcp", ":8080")
	if err != nil {
		panic(err)
	}
	s := grpc.NewServer()
	pb.RegisterDelayQueueServer(s, &server{})
	if err = s.Serve(lis); err != nil {
		panic(err)
	}
}