把跨越时间的流程
写成一段普通的 Go 代码
订单半小时后自动关闭、注册三天后发召回邮件、十万条数据跑到一半发版,这些流程的生命周期比进程长,gotick 可以让你直接把它们写下来,重启、崩溃、扩缩容都不会让它从头再来。这一切,只依赖 Redis。
// 订单创建后 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 没有把流程编译成状态机,而是每次调度都从头重新执行你的函数。下面这个演示把三件事放在一起看:代码走到哪一行、每个任务现在是什么状态、你的函数已经从头跑了第几遍。
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,它才会被第一次执行。
每一个会写状态的断点都要占一次调度,函数正常返回本身也要占一次。所以四个 task 的流程,最少也要从头跑五遍。
最难写对的那部分,交给 asynq
「按时投递、失败重试、worker 中途死了任务不丢」,这些是分布式任务队列里最容易写错、也最难自测的部分。gotick 没有重新造,而是直接站在 asynq(13k+ star)上:一个已经被大量 Go 项目用在生产上的任务队列。它负责把任务准时、至少一次地送到某个进程手里;gotick 负责的是「送到之后怎么接着往下走」。
入队、延时投递、指数退避重试、把死掉的 worker 手上的任务回收,这些都是它的既有能力,不是我们新写的代码。默认重试 25 次,重试耗尽的进归档,保留 90 天。
asynq 保证的是至少一次,也就是同一个任务可能被送到两次。让这件事变安全的是 gotick 的状态层:每一步完成即落盘,重复投递会被直接跳过,而不是重跑一遍。
状态写入走 Redis 的 Lua 脚本做原子比较更新,再配合 epoch 和心跳判断执行权归谁。一个假死之后又活过来的进程,没法把别人已经做完的任务改回去。
换句话说:可靠性上 gotick 押的是 asynq 已经验证过的部分,自己只承担「重放」这一层的正确性,而那一层是有测试的。
支持信号触发、用信号随时唤醒任务
对于没有固定时间的任务(比如用户支付),使用事件可以唤醒等待中的任务来提交处理或者关闭。
Sleep 30 秒查一次库,没付就再睡。用户第 3 分钟付的钱,订单要到第 3 分半才发货。而这期间你查了 60 次库,只为确认什么都没发生。
一个负责定时关单,一个负责收支付,两边必须互相取消。关单已经把订单改成 closed,支付回调这时才到,钱收了、单也关了。更麻烦的是它不报错:偶尔有个订单收了钱没发货,只能等对账才发现。
超时那一刻,用一个哨兵去抢信号位。抢到了就是真超时,之后迟到的支付一律拒掉;抢不到,说明支付在同一瞬间落了地,那就认它。两边不可能都以为自己赢了,而且之后每一遍重放读到的都是同一个答案。
用你自己的订单号寻址,不必存 callId
触发时带上业务本来就有的标识,取消和发信号都用它,不必为了 gotick 在订单表上多加一列。同一个 key 再触发一次,前一个还没结束的会自动取消,只有最后一次继续跑。
顶替不是把旧的删掉,走的是正常取消:旧流程落进 canceled 终态,原因里写着被谁顶掉的,界面上看得到。这个形状很常见。用户连改十次配置,只有最后一次的预览有用;订单状态又变了,那个基于旧状态还在跑的就该停下。
取消也是这套机制的一部分:不设超时的等待是真的无限等,一个 Cancel 就能把它停掉。完整可运行的例子在仓库的 example/payment_race。
在 Go 里写长流程,以前两条路都不好走
这两条路我们都走过。一条越走越像在造一个很烂的工作流引擎,另一条为了「订单半小时后关闭」要先养一套基建。gotick 想占的是中间那一档。
| 手搓延时 MQ + 状态字段 | Temporal | gotick | |
|---|---|---|---|
| 新增基础设施 | 无,用现有 MQ 和数据库 | server + 存储 + 可视化 | 无,用现有 Redis |
| 流程写在哪 | 散在状态表和补偿任务里 | Workflow + Activity + Worker | 一个普通的 Go 函数 |
| 中间加一步要动什么 | 改状态表,多半要迁数据 | 改代码,还要顾及版本兼容 | 改代码 |
| 幂等和重试 | 每一处自己写 | 引擎负责 | 引擎负责 |
| 要先学多少概念 | 零,但会越滚越复杂 | 一整套 | 六个函数 |
| 本地跑起来 | 直接跑 | 先起一整套服务 | 直接跑 |
能干什么,干不了什么
右边那一列不是免责声明,是选型依据。如果其中任何一条对你是硬伤,说明你该选别的。这比用错了再迁移便宜得多。
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 还没有。
没有跨语言 SDK。它也没有经过 Temporal 那种规模的生产验证,如果你的流程一旦出错就会赔钱,先想清楚这一点。
合不合身,一张表说清楚
这个领域不缺好东西,缺的是刚好合身的那个。gotick 的位置很具体:你手里已经有一台 Redis,要写的流程是几步到几十步,而你不想为它再多养一个服务。下面这张表也写了什么时候别用它。选型选错的代价,比花十分钟看完这张表大得多。
| 方案 | 要部署服务端 | 存储 | 语言 | 什么时候选它 |
|---|---|---|---|---|
| gotick | 不需要 | Redis | Go | 已经有一个 Redis,想用几十行普通 Go 代码把一个跨越时间的流程写对 |
| Temporal | 需要 server + 存储 + UI | Cassandra / PG / MySQL | 多语言 | 流程本身是核心资产,需要审计、多语言、长期演进,团队愿意养一套基建 |
| DBOS Transact | 不需要 | 仅 Postgres | Go / Python / TS / Java | 主库就是 Postgres,且能接受生产级观测能力绑定在付费控制台上 |
| go-workflows | 不需要 | SQLite / MySQL / PG / Redis | Go | 想要 Temporal 式的严格确定性模型,并接受它对代码写法更强的约束 |
| Restate | 需要(单二进制) | 内置 | 多语言 | 想要比 Temporal 轻的服务端,且不介意 server 是 BUSL 许可 |
| Inngest | 需要(云或自托管) | 服务端管理 | TypeScript 优先 | 主力语言是 TypeScript,希望有托管服务和现成的控制台 |
| River / asynq | 不需要 | Postgres / Redis | Go | 只需要可靠的后台任务队列,不需要多步骤流程,别为了用不上的能力付成本 |
一句话版本:需要跨语言、审计、超长历史,选 Temporal;主库是 Postgres,选 DBOS;只需要任务队列,选 asynq 或 River,别为用不上的能力付成本。
可视化面板,你能看见任务进展到哪一步
流程跨越几分钟到几天,光看日志是拼不出全貌的。gotick 自带一个界面:哪些实例在跑、每一步花了多久、失败在哪、正在睡的还有多久醒。它是一个普通的http.Handler,数据直接读 Redis,所以不需要连上正在运行的 worker,也不需要为它部署任何服务。
在哪儿看,三种方式挑一种
因为它只是一个 handler,界面跑在哪里由你决定:嵌进已有的服务、单独起一个端口,或者一行代码都不改,本机敲条命令临时看一眼。三种都不需要新部署服务。
嵌进你现在的服务,跟着它一起上线,最省事。
mux.Handle("/_gotick/", h)和业务端口分开。监听非本机地址时必须配密码,否则直接拒绝启动。
ui.ListenAndServe(addr, opt)线上出了事,不改代码不发版,命令行连上 Redis 就能看现场。
gotick ui -redis ...界面放在独立子包里。不 import 它的二进制一个字节都不会变大——实测过。
三步就跑起来,什么都不用部署
go get github.com/zbysir/goticktick.Flow("order/close", ...)tick.StartServer(ctx)第三步之后流程就开始跑了。它和你的业务代码在同一个进程里,跟着你原有的部署方式一起上线——没有第四步。
读完整文档你觉得它还缺什么
如果你看完想用但又觉得它缺点什么,告诉我们,我们会优先处理。

