问题
在微服务开发过程中,一定会遇到定时任务,分布式任务等场景。比如:对于某个微服务存在定时任务,而这个定时任务只能同时存在一个,即同一时刻只有一个正在运行。冲突点在于:
- 微服务一般为了横向扩展能力,需要是无状态的,通过k8s的deployment+HPA可以动态扩容。
- 定时脚本只能在微服务中运行一个。
所以,无法在微服务中使用协程/线程的启动。那么有哪些解决方式?
- 可以通过外置脚本来定时执行,比如使用crontab 定时执行一个脚本,如果k8s中使用cronjob定时执行一个脚本。
- 在微服务代码中,通过分布式锁的方式,确保同一时间只有一个定时任务正在执行。
这里有个坑,外置脚本的方式,脚本中只允许存在一些逻辑处理,不允许操作微服务的数据库。为了防止数据库在DDL过程中,而脚本没有进行修改带来的不兼容性错误。
对于go语言来说,可以通过cobra的方式,将脚本作为另一个启动命令来部署。这里可以确保脚本以及微服务的代码的版本一致性。
cronjob过多的使用+calico会触发一个BUG。可查看zhuanlan.zhihu.com/p/683530464。
看上去需求得到了满足,而对于整个项目来说,可能不仅仅只有一个定时任务,分布式任务,极端情况下,设计比较拉胯的每个微服务都有一个咋办? 那么有没有一个比较通用的工具,来帮助我们处理分布式任务/定时任务的可用性问题,让我们更加关注整个流程?
go生态中的 Temporal 可以满足我们的需求。
什么是Temporal
官网:docs.temporal.io/evaluate/wh…
取名来源:Temporal公司开源的Temporal。
Temporal 是一个开发人员优先的开源平台,可确保服务和应用程序的成功执行。
Temporal 和特定于语言的 SDK(在本例中为 Go SDK)为现代应用程序开发中出现的复杂性提供了全面的解决方案。您可以将 Temporal 视为一种“万能药”,可以作为开发人员在尝试构建可靠的应用程序时所经历的痛苦。
以上是官网说明文档。
对于开发人员来说,Temporal能够使开发人员更加关注于流程的编排。Temporal的话术是 每个任务都是一个Workflow,在Workflow中,开发人员通过定义一系列的Activity 来完成整个工作。
Temporal的整个架构如下:
Temporal Server包含一个前端服务,后端服务。所有这些服务都是水平可扩展的,生产环境通常会运行每个服务的多个实例,部署在多台机器上,以提高性能和可用性。
可以使用Client跟server进行交互,有三类client:
- Temporal 命令行工具
- Temporal web UI
- Temporal SDK
从以上介绍,需要理解的是:Temporal Cluster 不会执行您的代码。虽然该平台保证代码的持久执行,但它是通过编排实现的。应用程序代码的执行在集群外部。
快速入门
安装
下载二进制文件: learn.temporal.io/getting_sta…
启动测试服务:
temporal server start-dev --ip 0.0.0.0 --ui-port 8080
简单使用
temporal的文档以及示例非常多,这里可以通过下载 github中的 sample-go,来进行测试:
git clone org-56493103@github.com:temporalio/samples-go.git
sample-go的目录中,一般都分为三个目录,使用helloworld举例说明:
ubuntu@VM-20-12-ubuntu:~/code/samples-go$ tree -L 2 helloworld
helloworld
├── helloworld.go // 定义了workflow, activity
├── helloworld.json
├── helloworld_test.go
├── README.md
├── replay_test.go
├── starter // 相当于生产者
│ └── main.go
└── worker // 相当于消费者
└── main.go
执行两个main方法即可。
start/main.go 源码:
func main() {
// The client is a heavyweight object that should be created once per process.
c, err := client.Dial(client.Options{})
if err != nil {
log.Fatalln("Unable to create client", err)
}
defer c.Close()
workflowOptions := client.StartWorkflowOptions{
ID: "hello_world_workflowID",
TaskQueue: "hello-world",
}
we, err := c.ExecuteWorkflow(context.Background(), workflowOptions, helloworld.Workflow, "Temporal")
if err != nil {
log.Fatalln("Unable to execute workflow", err)
}
log.Println("Started workflow", "WorkflowID", we.GetID(), "RunID", we.GetRunID())
// Synchronously wait for the workflow completion.
var result string
err = we.Get(context.Background(), &result)
if err != nil {
log.Fatalln("Unable get workflow result", err)
}
log.Println("Workflow result:", result)
}
生产者需要以下三个步骤:
- 创建Temporal client。 默认连接本机 7233端口。
- 声明workflow相关配置。其中TaskQueue需要与Worker中保持一致。
- 像HTTP,GRPC服务一样调用WorkFlow。通过Get获取返回结果。
worker/main.go 源码:
func main() {
// The client and worker are heavyweight objects that should be created once per process.
c, err := client.Dial(client.Options{})
if err != nil {
log.Fatalln("Unable to create client", err)
}
defer c.Close()
w := worker.New(c, "hello-world", worker.Options{})
w.RegisterWorkflow(helloworld.Workflow) // 注册workflow
w.RegisterActivity(helloworld.Activity) // 注册activity
err = w.Run(worker.InterruptCh())
if err != nil {
log.Fatalln("Unable to start worker", err)
}
}
Worker 通常需要三件事:
- 创建Temporal 客户端;
- 任务队列名称。
- 工作流程定义函数的完全限定名称(函数签名)。
一旦你完成了Worker的配置,你就可以调用它的Run函数来启动它。然后,Worker 将开始对指定的任务队列进行“长轮询” 。如果您使用如上所示的程序从终端启动 Worker,如果您只看到几行输出,请不要感到惊讶。这是预期的行为,程序不会卡住,它只是忙于轮询任务队列并处理从Temporal集群接受的任务。
定义
worker
Worker 的生命周期和 Workflow Execution 的持续时间无关。用于启动此 Worker 的 Run 函数是一个阻塞函数,除非终止或遇到致命错误,否则不会停止。 Worker 的进程可能会持续数天、数周或更长时间。如果它处理的工作流相对较短,那么单个 Worker 在其生命周期内可能会执行数千甚至数百万个工作流。另一方面,工作流可以运行数年,而运行工作进程的服务器可能会在几个月后由管理员进行维护而重新启动。如果工作流类型已向其他工作人员注册,则其中一个或多个工作人员将自动从原始工作人员停止的地方继续。如果没有其他可用的 Worker,则一旦原始 Worker 重新启动,工作流执行就会从中断处继续执行。无论哪种情况,停机都不会导致工作流程执行失败。
Activity
正如Workflow定义是可导出的 Go 函数一样,Activity定义也是可导出的 Go 函数,并且对于允许作为输入参数和返回值的类型具有与Workflow定义相同的规则。该函数必须返回 Error 类型的值,但它也可以返回任何允许类型的另一个值。例如,任何转换为 JSON 的内容都可以,但通道或不安全指针之类的内容则不行。Temporal 不强制规定该函数的命名方式。您可以将Activity定义包含在与Workflow定义相同的源文件中,或者如果您愿意,也可以将其放在不同的源文件中。
尽管 Temporal 不需要为您的活动定义提供特定的第一个参数,但与工作流定义不同,我们建议将 context.Context 设置为您的活动定义中的第一个参数。这将使您能够利用一些附加功能,例如改进长时间运行活动的故障检测的检测信号。虽然您可能还不需要这些功能,但养成在Activity中包含 context.Context 作为第一个参数的习惯将使您在需要时更轻松地使用这些高级功能。
Temporal 的默认行为是自动重试 Activity,每次尝试之间有短暂的延迟,直到成功或被取消。 这意味着间歇性故障不需要您采取任何操作。当后续请求成功时,您的代码将恢复,就像故障从未发生过一样。但是,这种行为可能并不总是令人满意,因此 Temporal 允许您通过自定义重试策略对其进行自定义。
Activity 的参数代码层面没有限制,但是受限于grpc的payload大小。 单个参数最大2M , 整个消息体不能超过4M。
活动实现的单个实例在多个同时活动调用之间共享。活动实现代码应该是幂等的。
思考
文档中明确说明,worker重新启动后,工作流执行就会从中断处继续执行?
说明,workflow的每一个activity的执行状态不可能只保存在内存中,因为重启后丢失。那么在执行工程中,workflow一定会与temporal server进行状态的交互。
Activity在文档中也有约束 :Activity实现代码应该是幂等的。
在官方文档中,为什么突出这两点?具体是怎么实现的? 产生了比较多的疑问。 那么该如何去观测Temporal client与Temporal server端的实现方式?
答案是:tcpdump + wireshark。 在架构图中temporal client 与 temporal server是通过grpc进行通信的。
准备动手,准备动手。
抓包浅析
wireshark经常被使用来进行报文的分析,比较多的是TCP,HTTP。那么分析GRPC需要准备什么?
- protobuf;
git clone org-56493103@github.com:temporalio/api.git
- wireshark指定搜索
- 修改端口的报文解析策略;
hello过程发生什么
- tcpdump -i any tcp port 7233 -w /tmp/helloworld.pcap
- 执行worker/main.go
- 执行start/main.go。
抓包后,通过wireshark导出一下Flow Graph:
其中端口作用如下:
2142:worker
2143:start
7233:temporal server
流程分析如下:
| worker | start |
|---|---|
| 启动后,请求GetSystemInfo接口,获取temporal server的系统信息,返回: | 启动后,请求GetSystemInfo接口:获取temporal server的系统信息; |
| 请求DescribeNamespaceRequest:描述命名空间返回已注册命名空间的信息和配置; | |
| 调用StartWorkflowExecutionRequest:通知Temporal server开启一个工作流处理; | |
| 轮询获取PollWorkflowTaskQueue: | |
| 轮询获取PollActivityTaskQueue: | |
| 调用GetWorkflowExecutionHistory:获取执行结果。 - 阻塞 | |
| 调用RespondWorkflowTaskCompleted | |
| 调用RespondActivityTaskCompleted | |
| 调用RespondWorkflowTaskCompleted | |
| 获取到GetWorkflowExecutionHistory |
其中方法的定义:
- PollWorkflowTaskQueue: WorkflowTask 被分派给调用者,以执行带有待处理工作流任务的活动工作流。Worker在完成任务处理后应调用“RespondWorkflowTaskCompleted”。在将该任务交给Worker之前,该服务将在该任务的历史记录中创建一个“WorkflowTaskStarted”事件。
- PollActivityTaskQueue:PollActivityTaskQueue 由Worker调用来处理来自特定任务队列的活动任务。Worker在完成任务处理后应调用“RespondActivityTaskXXX”方法之一。只要在工作流程执行期间生成“SCHEDULE_ACTIVITY_TASK”命令,就会调度活动任务。在将任务分派给Worker之前,内存中的“ACTIVITY_TASK_STARTED”事件会被写入可变状态。当Activity完成时,开始事件和最终事件(
ACTIVITY_TASK_COMPLETED/ACTIVITY_TASK_FAILED/ACTIVITY_TASK_TIMED_OUT)都将被永久写入工作流执行历史记录中。这样做是为了避免在失败/重试循环的情况下写入许多事件。
从以上可以知道,worker程序在workflow中不是像我们理解的那样,从上向下执行,对于Activity还有一个Activit队列。所以在开发过程中有非常多的注意事项,可参考以下文章;
docs.temporal.io/workflows#d…
命令行测试
执行worker后,可通过命令行发送消息:
temporal workflow start \
--type GreetSomeone \
--task-queue greeting-tasks \
--workflow-id my-first-workflow \
--input '"Donna"'
请注意输入值的异常引号,它在单引号内有双引号。这是一个重要的细节。传递给 tctl 的输入必须以 JSON 格式提供,并且此处使用的引用对于通过 shell 传递值并以正确的格式传递到工作流是必要的。
查看历史记录-命令行
$ temporal workflow show --workflow-id my-first-workflow
$ temporal workflow show \
--workflow-id my-first-workflow \
--fields long
总结
temporal作为通用组件提供了非常多的文档,以及丰富的可调试性。目前,我也还在学习使用过程中,其余的细节后续有机会再进行分享。 感兴趣的话,建议看下官方的101课程,写的非常清楚。
参考文档
github.com:temporalio/samples-go.…
docs.temporal.io/dev-guide/g…
learn.temporal.io/courses/tem…