golang 异步任务队列 machinery 的使用

964 阅读8分钟

1. 前言

machinery 是使用 go 语言开发的一个异步任务框架,开源项目地址:
GitHub - RichardKnop/machinery: Machinery is an asynchronous task queue/job queue based on distributed message passing.

2. 初衷

  什么场景会用到异步任务队列?
  举个例子,用 12306 买票,付款后,12306 和银行都会发送一条通知短信,告知用户买票成功、银行账户扣款成功。但如果短信发送失败呢?我们的车票一样能够买到,车票钱还会从银行账户扣除,这个逻辑很合理。如果让我实现这样一段逻辑,很可能会把发送短信通知这段逻辑放在买票的主流程中顺序执行。可发短信一旦阻塞或者失败,会影响主逻辑的后续流程或返回结果,如果不处理短信的发送结果,就无法实现错误重试。那好,我单开一个协程专门处理短信通知逻辑,增加错误重试。可如果发生意外,程序崩溃,发短信的任务就会丢失,为此我还要增加一个持久化的逻辑,把未发送的和正在重试中的短信保存在文件中。
  如此一来,为了保证程序的健壮性,我们额外的任务量增加了很多。那有没有一个现成的代码库,既能支持异步任务,又能支持错误重试,还能支持任务持久化呢?没错你猜对了,接下来就轮到 machinery 登场了。

3. 基本架构

  先不讨论具体的底层实现,从一个使用者的视角,machinery 主要由三部分组成,一个发送任务的生产者 server,一个执行任务的消费者 worker,一个保存任务和执行结果的消息代理 broker。
  发送任务的数据流:server --> broker --> worker
  获取结果的数据流:server <-- broker <-- worker

4. 实际案例

4.1 发送短信通知...给显示器

项目目录结构

/my_workspace/my_machinery
    config.yaml # 配置文件,配置 redis 连接信息
    go.mod
    go.sum
    server.go   # 生产者
    worker.go   # 消费者

创建消费者

  流程:加载配置文件 --> 注册任务 --> 绑定消息队列 --> 接收、执行任务
  首先,调用 config.NewFromYaml 加载咱们的配置文件 config.yaml,得到一个配置对象实例,然后调用 machinery.NewServer 绑定配置项,生成一个 server 实例,通过调用 server 的 RegisterTask 方法来注册我们事先定义好的任务 SayHello 函数,然后调用 server.NewWorker 生成一个消费者实例 worker,并绑定名为 sms 的消息队列,最后调用 worker.Launch 启动消费者程序,并一直监听 sms 消息队列。
  具体代码如下所示:

package main

import (
	"fmt"
	"log"

	"github.com/RichardKnop/machinery/v1"
	"github.com/RichardKnop/machinery/v1/config"
)

// 定义任务
func SayHello(args []string) (string, error) {
	for _, arg := range args {
		fmt.Println(arg)
	}
	return "ok", nil
}

func main() {
	// 将配置文件实例化
	cnf, err := config.NewFromYaml("./config.yaml", false)
	if err != nil {
		log.Println("config.NewFromYaml failed, err:", err)
		return
	}

	// 根据实例化的配置文件创建 server 实例
	server, err := machinery.NewServer(cnf)
	if err != nil {
		log.Println("machinery.NewServer failed, err:", err)
		return
	}

	// 为消费者程序注册任务
	err = server.RegisterTask("SayHello", SayHello)
	if err != nil {
		log.Println("server.RegisterTask failed, err:", err)
		return
	}

	// 创建 worker 实例并绑定任务队列名
	worker := server.NewWorker("sms", 1)
	// 运行 worker 监听逻辑,监听消息队列中的任务
	err = worker.Launch()
	if err != nil {
		log.Println("start worker error", err)
		return
	}
}

创建生产者

  生产者程序的流程比消费者简单,具体的不同之处在于生产者程序不需要创建消费者,不需要绑定任务函数。只在需要的时候新建任务,再将任务下发给消费者即可。
  创建任务通过签名结构体 tasks.Signature 来完成,指定任务名 Name,指定任务参数 Args,最后调用 SendTask 下发这个任务签名就可以了。

package main

import (
	"log"

	"github.com/RichardKnop/machinery/v1"
	"github.com/RichardKnop/machinery/v1/config"
	"github.com/RichardKnop/machinery/v1/tasks"
)

func NewSumTaskSignature(val []string) *tasks.Signature {
	// 新建任务签名,执行任务名Name,指定传递给任务的参数Args
	signature := &tasks.Signature{
		Name: "SayHello",
		Args: []tasks.Arg{
			{
				Type:  "[]string",
				Value: val,
			},
		},
	}
	return signature
}

func main() {
	// 加载配置文件
	cnf, err := config.NewFromYaml("./config.yaml", false)
	if err != nil {
		log.Println("config failed", err)
		return
	}

	// 新建 server 实例
	server, err := machinery.NewServer(cnf)
	if err != nil {
		log.Println("start server failed", err)
		return
	}

	// 下发任务给消费者
	signature := NewSumTaskSignature([]string{"1", "2", "3", "4", "5"})
	asyncResult, err := server.SendTask(signature)
	if err != nil {
		log.Fatal(err)
	}

	// 每秒获取一次消息队列中的结果
	res, err := asyncResult.Get(1)
	if err != nil {
		log.Fatal(err)
	}
	log.Printf("get res is %v\n", tasks.HumanReadableResults(res))
}

运行程序

  成功启动 redis 后,先运行 worker 程序,再运行 server 程序。worker 会阻塞在监听 redis 任务这一步,等 server 发送任务后,worker 收到任务,执行任务,通过消息队列返回执行结果给生产者。
  最终,控制台输出如下所示:

PS D:\my_workspace\golang\src\study_machinery\my_machinery> go run worker.go
INFO: 2023/10/22 11:12:49 file.go:19 Successfully loaded config from file ./config.yaml
INFO: 2023/10/22 11:12:49 worker.go:58 Launching a worker with the following settings:
INFO: 2023/10/22 11:12:49 worker.go:59 - Broker: redis://172.27.57.102:6379
INFO: 2023/10/22 11:12:49 worker.go:61 - DefaultQueue: sms
INFO: 2023/10/22 11:12:49 worker.go:65 - ResultBackend: redis://172.27.57.102:6379
INFO: 2023/10/22 11:12:49 redis.go:105 [*] Waiting for messages. To exit press CTRL+C
##### server 下发任务后 #####
DEBUG: 2023/10/22 11:14:20 redis.go:347 Received new message: {"UUID":"task_02196b06-25ed-475d-a134-14af5b9f6c28","Name":"SayHello","RoutingKey":"sms","ETA":null,"GroupUUID":"","GroupTaskCount":0,"Args":[{"Name":"","Type":"[]string","Value":["1","2","3","4","5"]}],"Headers":{},"Priority":0,"Immutable":false,"RetryCount":0,"RetryTimeout":0,"OnSuccess":null,"OnError":null,"ChordCallback":null,"BrokerMessageGroupId":"","SQSReceiptHandle":"","StopTaskDeletionOnError":false,"IgnoreWhenTaskNotRegistered":false}
DEBUG: 2023/10/22 11:14:20 worker.go:261 Processed task task_02196b06-25ed-475d-a134-14af5b9f6c28. Results = ok

##### server 下发任务,并获取到任务执行结果 #####
PS D:\my_workspace\golang\src\study_machinery\my_machinery> go run server.go
INFO: 2023/10/22 11:14:20 file.go:19 Successfully loaded config from file ./config.yaml
2023/10/22 11:14:20 get res is ok

4.2 给发送短信添加延迟时间

  修改上例生产者程序中的 NewSumTaskSignature 函数,为任务签名添加延迟时间。设置任务签名的 ETA 成员变量,ETA 用于控制任务执行的时间。该值为 time.Time 类型,表示未来的一个时间点。
  在下面这个例子中,我们让发送短信延迟三秒执行。

func NewSumTaskSignature(val []string) *tasks.Signature {
	// 新建任务签名,执行任务名Name,指定传递给任务的参数Args
	signature := &tasks.Signature{
		Name: "SayHello",
		Args: []tasks.Arg{
			{
				Type:  "[]string",
				Value: val,
			},
		},
	}
	// ETA 为 Estimated Time of Arrival 的缩写,意指预计到达时间,
	// 在这里表示任务预计执行的时间。 
	eta := time.Now().Add(time.Second * time.Duration(3))
	signature.ETA = &eta
	return signature
}

再次执行 server 程序,观察程序打印,可以看到三秒后才得到返回值。

4.3 批量发送短信

  为避免调用短信接口过于频繁,我们可以先积累一定量的待发送的信息,比如3条,当待发送的短信数量达到三条后,一次性下发一个组任务,将这三条短信一次性发送出去。
  紧接前面的代码,在新建三个任务签名后,调用 task.NewGroup 将三个任务打包成一个任务组,然后调用 server.SendGroup 下发这个任务组,最后遍历任务组的结果对象,直到所有任务完成执行并返回结果。

// SendSayHelloGroupTask 下发任务组,该组的任务并发执行
func SendSayHelloGroupTask(server *machinery.Server) {
	// 创建三个任务
	sign1 := NewSumTaskSingature([]string{"01", "02", "03", "04", "05"})
	sign2 := NewSumTaskSingature([]string{"11", "12", "13", "14", "15"})
	sign3 := NewSumTaskSingature([]string{"21", "22", "23", "24", "25"})

	// 把上面创建的三个任务加入任务组
	group, err := tasks.NewGroup(sign1, sign2, sign3)
	if err != nil {
		log.Println("add group failed", err)
	}
	// 下发任务组,并设置任务执行的最大并发数为 10
	asyncResults, err := server.SendGroup(group, 10)
	if err != nil {
		log.Println(err)
	}
	// 接收组内所有任务的结果,直到所有任务都执行完毕返回成功或者失败
	for _, asyncResult := range asyncResults {
		results, err := asyncResult.Get(1)
		if err != nil {
			log.Println(err)
			continue
		}
		log.Printf(
			"args: %v, result: %v\n",
			asyncResult.Signature.Args[0].Value,
			tasks.HumanReadableResults(results),
		)
	}
}

  从server程序的控制台打印可以看到,组内每个任务的参数和结果如下所示: image.png
  worker程序的控制台打印如下,可以看到三个SayHello任务都得到了执行,而且最后任务组执行结果为 ok image.png

4.4 统计短信批量发送结果

  为避免调用短信接口过于频繁,我们可以先积累一定量的待发送的信息,比如3条,当待发送的短信数量达到三条后,一次性下发一个组任务,将这三条短信一次性发送出去。
  紧接前面的代码,在新建三个任务签名后,调用 task.NewGroup 将三个任务打包成一个任务组,然后调用 server.SendGroup 下发这个任务组,最后遍历任务组的结果对象,直到所有任务完成执行并返回结果。

5. 环境准备

必要组件

下载安装 go:All releases - The Go Programming Language (google.cn)
machinery 兼容多种消息代理,比如 redis、rabbitmq、memcache、mongodb 等。
本文使用 redis 作为消息代理。下载安装 redis:Download | Redis

项目配置文件

按照上面展示的项目路径,配置文件为:/my_workspace/my_machinery/config.yaml
在配置文件中配置消息代理,先复制到项目中,需要分别修改下面的四个变量:
${user}、${password}、${ip}、${user} 分别为 redis 的用户名、密码、IP 地址、端口

# 消息代理的地址,这里使用 redis 作为代理
broker: redis://${user}:${password}@${ip}:${port}
# 消息代理的默认队列名
default_queue: "sms" 
# 消费者处理的结果代理
result_backend: redis://${user}:${password}@${ip}:${port}
# redis的配置
redis:
  max_idle: 3
  max_active: 3
  max_idle_timeout: 240
  wait: true
  read_timeout: 15
  write_timeout: 15
  connect_timeout: 15
  normal_tasks_poll_period: 1000
  delayed_tasks_poll_period: 500
  delayed_tasks_key: "sms"