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

> GoTick 是一个嵌入式的 Go 工作流引擎，只依赖你已经有的 Redis，不需要部署任何服务。流程的生命周期比进程长：重启、发版、崩溃都不会让它从头再来。

[gotick](#top) [演示](#how) [痛点](#approaches) [对比](#rivals) [上手](#start) [文档](/docs.md)

[文档](/docs.md) [中文](/)/ [English](/en.md)

Durable workflows for Go · 只依赖 Redis

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

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

[开始上手](#start) [在 GitHub 上查看](https://github.com/zbysir/gotick)

[![GitHub 星标数](https://img.shields.io/github/stars/zbysir/gotick?style=flat-square&label=Stars&color=3ef08a&labelColor=0c110e)](https://github.com/zbysir/gotick) 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未开始

执行记录点一下跳到那一遍

触发0.0s#10.8s#21.9s#38.3s#49.3s#510.4s#611.4s#712.2s

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

播放这段流程的执行过程

13 秒。中途可以随时把进程杀掉，看它怎么接上。

靠什么可靠

## 最难写对的那部分，交给 asynq

「按时投递、失败重试、worker 中途死了任务不丢」，这些是分布式任务队列里最容易写错、也最难自测的部分。gotick 没有重新造，而是直接站在 [asynq](https://github.com/hibiken/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 + 状态字段 | Temporal | gotick |
| --- | --- | --- | --- |
| 新增基础设施 | 无，用现有 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 | 不需要 | 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，也不需要为它部署任何服务。

[![gotick 检查界面：一个正在睡眠的流程，显示还有多久唤醒和睡眠进度](https://fsu.creght.com/site/2088915489823657984/1787052241590__inspector_sleeping_v2.png?w=3072&fmt=webp)](https://fsu.creght.com/site/2088915489823657984/1787052241590__inspector_sleeping_v2.png "点开看原图") 正在等待的流程会告诉你还剩多久，倒计时在本地走，不靠轮询。[![gotick 检查界面：执行记录表，列出每一遍做了什么和距上一遍的间隔](https://fsu.creght.com/site/2088915489823657984/1787052242777__inspector_runs_v2.png?w=3072&fmt=webp)](https://fsu.creght.com/site/2088915489823657984/1787052242777__inspector_runs_v2.png "点开看原图") 每一遍做了什么、隔了多久，都摊开给你看。性能问题往往就藏在间隔里。

### 在哪儿看，三种方式挑一种

因为它只是一个 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)`

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

 [读完整文档→](/docs.md)

说句话

## 你觉得它还缺什么

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

邮箱（选填，将会通过这个邮箱回复你进度）你觉得它缺什么

提交

gotick [GitHub](https://github.com/zbysir/gotick) [中文](/)/ [English](/en.md)MIT · 对比数据取自 2026-08 公开信息

> 全站页面清单：[/llms.txt](/llms.txt)
