Durable workflows for Go · 只依赖 Redis

把跨越时间的流程
写成一段普通的 Go 代码

订单半小时后自动关闭、注册三天后发召回邮件、十万条数据跑到一半发版,这些流程的生命周期比进程长,gotick 可以让你直接把它们写下来,重启、崩溃、扩缩容都不会让它从头再来。这一切,只依赖 Redis。

GitHub 星标数MIT 许可
order.go
// 订单创建后 30 分钟自动关闭tick.Flow("order/close", func(ctx *gotick.Context) {gotick.Task(ctx, "create-order", createOrder)gotick.Sleep(ctx, "wait-payment", 30*time.Minute)gotick.Task(ctx, "close-order", closeOrder)})
它实际是怎么跑的

代码编排简单得像魔法,但它为你提供稳定的能力:可被打断、可重启、可重试

gotick 没有把流程编译成状态机,而是每次调度都从头重新执行你的函数。下面这个演示把三件事放在一起看:代码走到哪一行、每个任务现在是什么状态、你的函数已经从头跑了第几遍。

order/close未开始
你写的代码
1tick.Flow("order/close", func(ctx *gotick.Context) {
2 gotick.Task(ctx, "create-order", createOrder)
 
4 // 等半小时,期间不占用任何进程
5 gotick.Sleep(ctx, "wait-payment", 30*time.Minute)
 
7 gotick.Task(ctx, "charge", chargeCard)
8 gotick.Task(ctx, "send-receipt", sendReceipt)
9})

函数一遍都还没跑。要等有人调用 Trigger,它才会被第一次执行。

实际发生的事跑了 0 遍
有人调用了 Trigger("order/close"),流程从这里开始
create-order未开始
wait-payment未开始
charge未开始
send-receipt未开始
执行记录点一下跳到那一遍

每一个会写状态的断点都要占一次调度,函数正常返回本身也要占一次。所以四个 task 的流程,最少也要从头跑五遍。

播放这段流程的执行过程
13 秒。中途可以随时把进程杀掉,看它怎么接上。
靠什么可靠

最难写对的那部分,交给 asynq

「按时投递、失败重试、worker 中途死了任务不丢」,这些是分布式任务队列里最容易写错、也最难自测的部分。gotick 没有重新造,而是直接站在 asynq(13k+ star)上:一个已经被大量 Go 项目用在生产上的任务队列。它负责把任务准时、至少一次地送到某个进程手里;gotick 负责的是「送到之后怎么接着往下走」。

投递和重试是 asynq 的

入队、延时投递、指数退避重试、把死掉的 worker 手上的任务回收,这些都是它的既有能力,不是我们新写的代码。默认重试 25 次,重试耗尽的进归档,保留 90 天。

状态这一层是我们的

asynq 保证的是至少一次,也就是同一个任务可能被送到两次。让这件事变安全的是 gotick 的状态层:每一步完成即落盘,重复投递会被直接跳过,而不是重跑一遍。

执行权用 CAS 判定

状态写入走 Redis 的 Lua 脚本做原子比较更新,再配合 epoch 和心跳判断执行权归谁。一个假死之后又活过来的进程,没法把别人已经做完的任务改回去。

换句话说:可靠性上 gotick 押的是 asynq 已经验证过的部分,自己只承担「重放」这一层的正确性,而那一层是有测试的。

等外部事件

支持信号触发、用信号随时唤醒任务

对于没有固定时间的任务(比如用户支付),使用事件可以唤醒等待中的任务来提交处理或者关闭。

// 等支付,但最多等 30 分钟
paid, ok := gotick.WaitForSignal[Payment](ctx, "paid",
gotick.WithSignalTimeout(30*time.Minute))
if !ok {
gotick.Task(ctx, "close-order", closeOrder)
return
}
gotick.Task(ctx, "ship", shipOrder)
轮询能做,但很贵

Sleep 30 秒查一次库,没付就再睡。用户第 3 分钟付的钱,订单要到第 3 分半才发货。而这期间你查了 60 次库,只为确认什么都没发生。

拆成两个流程,竞态得自己扛

一个负责定时关单,一个负责收支付,两边必须互相取消。关单已经把订单改成 closed,支付回调这时才到,钱收了、单也关了。更麻烦的是它不报错:偶尔有个订单收了钱没发货,只能等对账才发现。

信号把胜负收进一次原子操作

超时那一刻,用一个哨兵去抢信号位。抢到了就是真超时,之后迟到的支付一律拒掉;抢不到,说明支付在同一瞬间落了地,那就认它。两边不可能都以为自己赢了,而且之后每一遍重放读到的都是同一个答案。

用你自己的订单号寻址,不必存 callId

触发时带上业务本来就有的标识,取消和发信号都用它,不必为了 gotick 在订单表上多加一列。同一个 key 再触发一次,前一个还没结束的会自动取消,只有最后一次继续跑。

tick.Trigger(ctx, "order/close", meta, gotick.WithKey(orderId))
tick.CancelByKey(ctx, "order/close", orderId, reason)
tick.SendSignalByKey(ctx, "order/close", orderId, "paid", payment)

顶替不是把旧的删掉,走的是正常取消:旧流程落进 canceled 终态,原因里写着被谁顶掉的,界面上看得到。这个形状很常见。用户连改十次配置,只有最后一次的预览有用;订单状态又变了,那个基于旧状态还在跑的就该停下。

取消也是这套机制的一部分:不设超时的等待是真的无限等,一个 Cancel 就能把它停掉。完整可运行的例子在仓库的 example/payment_race。

它解决什么

在 Go 里写长流程,以前两条路都不好走

这两条路我们都走过。一条越走越像在造一个很烂的工作流引擎,另一条为了「订单半小时后关闭」要先养一套基建。gotick 想占的是中间那一档。

 手搓延时 MQ + 状态字段Temporalgotick
新增基础设施无,用现有 MQ 和数据库server + 存储 + 可视化无,用现有 Redis
流程写在哪散在状态表和补偿任务里Workflow + Activity + Worker一个普通的 Go 函数
中间加一步要动什么改状态表,多半要迁数据改代码,还要顾及版本兼容改代码
幂等和重试每一处自己写引擎负责引擎负责
要先学多少概念零,但会越滚越复杂一整套六个函数
本地跑起来直接跑先起一整套服务直接跑
诚实的说明书

能干什么,干不了什么

右边那一列不是免责声明,是选型依据。如果其中任何一条对你是硬伤,说明你该选别的。这比用错了再迁移便宜得多。

优势
API 只有六个函数

Task、Memo、Sleep、Array、Async+Wait、WaitForSignal。写在一个普通闭包里,用普通的 for 和 if 控制流程。没有 activity 注册,没有 worker 和 task queue 的概念要学。从外面往里操作只多两个:SendSignal 和 Cancel。

不需要部署任何东西

go get 完就能写。依赖的是你本来就有的那台 Redis,不是一套新的基础设施。本地开发不用先起一个服务端。

流程活得比进程久

重启、发版、崩溃、扩缩容都不会让它从头再来。每一步完成即落盘,恢复时跳过已完成的部分。

可视化是内嵌的

界面是一个普通的 http.Handler,挂到你已有的 mux 上就行,同样不需要新部署服务。它放在独立子包里,不 import 它的二进制一个字节都不会变大。

劣势
反复执行这件事是隐式的

你的 flow 函数会被从头跑很多遍,而语法上看不出来。随手一个 time.Now() 或一次数据库查询,就可能让两遍走上不同的分支。框架目前没有强制防护,只有文档提醒。

不适合高吞吐和超长历史

每次调度都把整个函数从头跑一遍。几十个步骤没问题,几千个步骤会明显吃力。

没有版本管理

一个跑三天的流程,中途你发了个版加了一个步骤,旧实例再跑的时候会走进新代码。Temporal 用 patch API 解决这件事,gotick 还没有。

只有 Go,而且还年轻

没有跨语言 SDK。它也没有经过 Temporal 那种规模的生产验证,如果你的流程一旦出错就会赔钱,先想清楚这一点。

和别的东西比

合不合身,一张表说清楚

这个领域不缺好东西,缺的是刚好合身的那个。gotick 的位置很具体:你手里已经有一台 Redis,要写的流程是几步到几十步,而你不想为它再多养一个服务。下面这张表也写了什么时候别用它。选型选错的代价,比花十分钟看完这张表大得多。

方案要部署服务端存储语言什么时候选它
gotick不需要RedisGo已经有一个 Redis,想用几十行普通 Go 代码把一个跨越时间的流程写对
Temporal需要 server + 存储 + UICassandra / PG / MySQL多语言流程本身是核心资产,需要审计、多语言、长期演进,团队愿意养一套基建
DBOS Transact不需要仅 PostgresGo / Python / TS / Java主库就是 Postgres,且能接受生产级观测能力绑定在付费控制台上
go-workflows不需要SQLite / MySQL / PG / RedisGo想要 Temporal 式的严格确定性模型,并接受它对代码写法更强的约束
Restate需要(单二进制)内置多语言想要比 Temporal 轻的服务端,且不介意 server 是 BUSL 许可
Inngest需要(云或自托管)服务端管理TypeScript 优先主力语言是 TypeScript,希望有托管服务和现成的控制台
River / asynq不需要Postgres / RedisGo只需要可靠的后台任务队列,不需要多步骤流程,别为了用不上的能力付成本

一句话版本:需要跨语言、审计、超长历史,选 Temporal;主库是 Postgres,选 DBOS;只需要任务队列,选 asynq 或 River,别为用不上的能力付成本。

看得见

可视化面板,你能看见任务进展到哪一步

流程跨越几分钟到几天,光看日志是拼不出全貌的。gotick 自带一个界面:哪些实例在跑、每一步花了多久、失败在哪、正在睡的还有多久醒。它是一个普通的http.Handler,数据直接读 Redis,所以不需要连上正在运行的 worker,也不需要为它部署任何服务。

gotick 检查界面:一个正在睡眠的流程,显示还有多久唤醒和睡眠进度
正在等待的流程会告诉你还剩多久,倒计时在本地走,不靠轮询。
gotick 检查界面:执行记录表,列出每一遍做了什么和距上一遍的间隔
每一遍做了什么、隔了多久,都摊开给你看。性能问题往往就藏在间隔里。

在哪儿看,三种方式挑一种

因为它只是一个 handler,界面跑在哪里由你决定:嵌进已有的服务、单独起一个端口,或者一行代码都不改,本机敲条命令临时看一眼。三种都不需要新部署服务。

01挂到已有的 mux

嵌进你现在的服务,跟着它一起上线,最省事。

mux.Handle("/_gotick/", h)
02自己起个端口

和业务端口分开。监听非本机地址时必须配密码,否则直接拒绝启动。

ui.ListenAndServe(addr, opt)
03本机临时看一眼

线上出了事,不改代码不发版,命令行连上 Redis 就能看现场。

gotick ui -redis ...

界面放在独立子包里。不 import 它的二进制一个字节都不会变大——实测过。

上手

三步就跑起来,什么都不用部署

01
装
go get github.com/zbysir/gotick
02
写
tick.Flow("order/close", ...)
03
运行
tick.StartServer(ctx)

第三步之后流程就开始跑了。它和你的业务代码在同一个进程里,跟着你原有的部署方式一起上线——没有第四步。

读完整文档
说句话

你觉得它还缺什么

如果你看完想用但又觉得它缺点什么,告诉我们,我们会优先处理。

Render diagnostics