GoRangeTask 是一个可嵌入 Go 服务的 MySQL 持久化任务队列,面向由本服务 Worker 执行的后台任务。
项目状态:v0.1.0 已发布(早期阶段)。
已可用:任务与配置类型、Handler 注册表与 payload 解码、公开错误、MySQL migration(版本管理 + 脏状态检测)、完整的 MySQL Client(
Enqueue/EnqueueTx/Get/Cancel,含幂等键),以及 Worker 侧的领取与执行闭环(Claim/Succeed/Retry/MarkDead、并发Run轮询、租约续期与失租取消、过期租约回收、失败退避与永久错误分流、Handler panic 兜底、终态任务清理、优雅停机、逐任务超时)。单元测试与真库集成测试覆盖上述实现,CI 已接入 MySQL。另有一个可独立运行的示例服务example/basic(业务行与任务行同事务创建、幂等 Handler、cmd/demo入口),见下文「安装与快速开始」。结构化日志(Config.Logger)与指标(Config.Metrics/ClientConfig.Metrics)已接上,见下文「可观测性」;官方可选 GORM adapter 见下文「在 GORM 事务里入队」。性能与恢复已实测(2026-10-18,Ryzen 7 8745H;另有 2026-10-17 的 4 核机基线):数字、命令与原始 JSON 见性能基线(Ryzen 7 8745H)。最要紧的几条——10,000 笔「业务行 + 任务行同一事务」中事务完整率 100.00%(回滚零残留、零孤儿任务);Worker 停止续租后 2.05–3.68 s 任务被另一个 Worker 接手(租约 2 s 配置,上界 4.29 s);陈旧 epoch 的写回被 fencing 拒绝;256 B payload、单机 8 核 16 线程(MySQL 与压测同机)下 W=1/4/16 为 66–714 task/s(W=32 到 1000),每任务固定 6 条 SQL、行锁等待接近 0。这些数字只用来说明量级与瓶颈位置:同一格两次运行最多差 13.6%(旧 4 核机上是 45%),且语义是"至少一次尝试"而非"保证业务处理成功"。
已知边界:清理是物理删除。任务行被删掉之后它的幂等键就不再占用唯一索引,同一个键可以被重新接受——库保证的是"保留期内不重复入队",不是"永不重复"。需要更强语义时要在业务侧自行去重。终态清理的保留期默认 7 天(
succeeded/canceled)与 30 天(dead);把CleanupInterval设成负值会关闭清理器,终态表将随任务吞吐单调增长。下文的能力清单与语义说明描述的是已实现并验收的行为;未实现或未实测的部分会在原处显式标注。逐项证据见项目进度跟踪。
业务服务可以在自己的数据库事务中写入业务数据和 GoRangeTask 任务行。事务提交后,GoRangeTask Worker 从 MySQL 领取任务并调用本地注册的 Go Handler。
同一事务插入业务数据和任务行,直接 SQL 就可以实现。GoRangeTask 不会比 MySQL 事务提供更强的原子性保证。它的目标是把任务行写入之后的执行生命周期封装为可复用库:并发领取、租约续期、Worker 故障恢复、失败重试、旧租约写回保护和死信管理。
flowchart LR
A[业务 Producer] -->|同一事务:业务数据 + 任务行| DB[(MySQL)]
DB -->|领取任务 / 租约| W[GoRangeTask Worker]
W --> H[本地 Go Handler]
H -->|成功确认或失败重试| DB
GoRangeTask 适用于生产者与 Handler 共用数据库、任务由本服务执行且需要可靠恢复的场景。简单任务也可以直接用 SQL 自行实现;使用 GoRangeTask 的理由是复用并验证完整的并发和故障处理机制。
GoRangeTask 目标是让已提交任务在数据库和 Worker 可用、重试策略允许的情况下至少交给 Handler 尝试执行一次。Handler 可能重复运行,也可能因永久错误或达到最大尝试次数进入死信。
- 不保证 Handler 必定成功。 长期故障、永久错误或重试次数耗尽时,需要告警或人工处理。
- 不保证 exactly-once(恰好一次)外部副作用。 Handler 应实现幂等;fencing token 保护的是 GoRangeTask 任务状态,不会撤销已经发生的外部操作。
- 恢复只在 Worker 进程内进行。 租约过期的任务由运行中的 Worker 内置的回收器捡回(
Config.ReapInterval,负值可关闭该职责),所以"至少有一个 Worker 在运行"是进程崩溃、停机超时之后任务能被重新执行的前提;全部 Worker 下线期间,任务只是停在running等着,不会自己回到队列。 - 不替代 RabbitMQ。 需要跨服务/跨语言分发、广播、路由、Broker 缓冲或多个独立订阅者时,继续使用 Outbox + RabbitMQ 等事件架构。GoRangeTask 不充当 Outbox 转发器。
- 不追求默认高于 Broker 的吞吐。 MySQL 查询、锁等待、连接池和轮询成本需要在目标负载下实测。
以下每一条都已实现(逐项的实现口径与测试证据见进度跟踪):
EnqueueTx:在调用方现有数据库事务中写入任务,不接管事务的提交或回滚。- 立即执行和延迟执行。
- MySQL InnoDB 多 Worker 并发领取,使用短事务和
FOR UPDATE SKIP LOCKED。 - 数据库时间基准的租约、续期、过期恢复。
- 递增
lease_epoch(fencing token),拒绝旧租约持有者写回任务状态。 - 可配置重试次数、有上限的指数退避 + 抖动、永久失败和死信。
- 可选幂等键、任务状态查询、取消尚未领取的任务。
- 优雅停机、结构化日志、基础指标和终态任务清理。
- 官方可选 GORM adapter(
adapter/gorm)。 - 可独立运行的示例,演示事务入队、Worker 执行和故障恢复。
性能与恢复已有实测基线(硬件、MySQL 参数、Worker 配置、命令与原始 JSON 齐全):见性能基线(Ryzen 7 8745H),以及一台 4 核笔记本的旧基线。不要把这些数字当成容量承诺——它们来自与 MySQL 同机的开发机(还有 14–32% 的背景负载,MySQL 跑在 WSL2 虚拟盘上),同一格两次运行最多差 13.6%,换机器后结论会变;文档里也明确标出了"测不出来"与"没测到"的项。
| 项目 | 选择 |
|---|---|
| 公开项目名 | GoRangeTask |
| 仓库名 | gorangetask |
| Go module | github.com/Tidehyc/gorangetask |
| Go 版本 | 1.24+ |
| 数据库 | MySQL 8.0+ / InnoDB;MVP 只验证 MySQL |
| SQL 层 | 核心包使用 database/sql,不依赖 GORM;GORM adapter 是可选子包 adapter/gorm(已实现) |
| 许可证 | MIT |
数据库任务表承担本地任务持久化与 Worker 领取,不经过 RabbitMQ。未来支持其他数据库时,需要单独实现并验证该数据库的 migration、领取 SQL、租约 SQL 和并发语义。
仓库提供 scripts/dev-mysql.sh,用容器化 MySQL 8.0 作为本地开发库:
./scripts/dev-mysql.sh up # 启动 MySQL 容器
./scripts/dev-mysql.sh status # 查看状态
./scripts/dev-mysql.sh shell # 打开 mysql 客户端
./scripts/dev-mysql.sh down # 停止容器,保留数据卷
./scripts/dev-mysql.sh reset # 删除容器和数据卷默认连接信息:127.0.0.1:3306,数据库 gotask,用户 root,密码 rootpw,UTC 时区,READ-COMMITTED 隔离级别。该环境用于本地开发;不要将示例密码用于共享或生产环境。
go get github.com/Tidehyc/gorangetask核心包(根包与 store/mysql)只用标准库的 database/sql,唯一的直接依赖是 MySQL 驱动(由使用方自己引入并注册)。最低 Go 1.24。
仓库里还有一个可选子包 adapter/gorm,它依赖 gorm.io/gorm。核心包不 import 它,因此不使用 GORM 的项目不会把它编译进产物;但 go.mod 里会列出这个依赖——Go 的模块图是全仓库共享的。这与"核心不依赖 GORM"并不矛盾:那条说的是 import 层面的隔离。
接入的步骤顺序不能换:
- 迁移任务表:
mysqlmigrations.Migrate(ctx, db)建出gotask_tasks并管理版本; - 建业务表:业务表属于使用方,库对它一无所知;
- 构造 store:
mysqlstore.New(db, clientCfg)——它只读表、不建表,所以必须排在两步建表之后。
ctx := context.Background()
db, err := sql.Open("mysql", dsn) // dsn 必须带库名;库不替调用方创建数据库
// ... ping、错误处理 ...
if err := mysqlmigrations.Migrate(ctx, db); err != nil {
return err
}
client, err := mysqlstore.New(db, gorangetask.ClientConfig{})
// ... 错误处理 ...这是整个库存在的理由。业务行与任务行写在同一个 *sql.Tx 上——EnqueueTx 不开、不提交、不回滚这个事务,它只是借用你的连接插入一行:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }() // 提交成功后返回 sql.ErrTxDone,可忽略
res, err := tx.ExecContext(ctx,
"INSERT INTO orders (customer, amount_cents) VALUES (?, ?)", customer, amountCents)
if err != nil {
return err
}
orderID, err := res.LastInsertId()
if err != nil {
return err
}
payload, err := json.Marshal(map[string]int64{"order_id": orderID})
if err != nil {
return err
}
// 同一个 tx:提交则订单与任务一起可见,回滚则两者都不存在。
taskID, err := client.EnqueueTx(ctx, tx, gorangetask.Task{
Queue: "orders",
Type: "order.export",
Payload: payload,
})
if err != nil {
return err // 注意:此时 tx 还没提交,taskID 不具备"任务已存在"的含义
}
if err := tx.Commit(); err != nil {
return err
}Worker 侧注册 Handler 并运行。Handler 必须幂等——库的语义是「至少一次」,进程崩溃、租约过期、停机超时都会让同一条任务被重新领取:
registry := gorangetask.NewRegistry()
if err := registry.Register("order.export", func(ctx context.Context, raw json.RawMessage) error {
var p struct {
OrderID int64 `json:"order_id"`
}
if err := json.Unmarshal(raw, &p); err != nil {
// payload 是入队时写死的,重试不会让它变得可解析 ⇒ 带永久标记,直接进死信,
// 而不是白试到次数耗尽。
return gorangetask.Permanent(fmt.Errorf("decode payload %s: %w", raw, err))
}
// 幂等的来源:条件更新。第一次影响 1 行,第二次影响 0 行——0 行不是错误。
res, err := db.ExecContext(ctx, `UPDATE orders SET status = 'exported'
WHERE id = ? AND status = 'pending'`, p.OrderID)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
// 简化版:把 0 行当幂等命中。完整写法还要回读业务状态,把「已经导出过」
// (不是错误)与「订单根本不存在」(应当返回 gorangetask.Permanent)
// 分开——见 example/basic/handler.go。
return nil
}
return nil
}); err != nil {
return err
}
worker, err := gorangetask.NewWorker(client, registry, gorangetask.Config{Queues: []string{"orders"}})
if err != nil {
return err
}
// 阻塞运行直到 ctx 被取消;取消触发优雅停机(Handler 在 ShutdownTimeout 内没退出
// 会返回 ErrShutdownTimeout,被放弃的任务保持 running,交给回收器)。
if err := worker.Run(ctx); err != nil {
return err
}一份完整、可直接运行的版本在 example/basic(表结构、幂等 Handler 的完整写法、可运行入口都在里面):
./scripts/dev-mysql.sh up
go run ./example/basic/cmd/demo # 造 5 笔订单,起一个 Worker,打印进度
go run ./example/basic/cmd/demo -orders 0 # 不建订单,只起 Worker 观察业务表,按 Ctrl-C 结束
go run ./example/basic/cmd/demo -h # 连接串、订单数、Handler 延迟、打印间隔「至少一次」不是口号。进程在 Handler 跑到一半时消失,那条任务会原样留在 running,
直到租约过期、并且有一个活着的 Worker 在跑回收器把它捡回来。
example/basic 的入口带了 -lease / -reap 两个开关,专门把这条路径压进几秒。下面每一步都是实测输出:进程输出照抄,查库结果都附了对应的 SQL。
第一幕:起一个 Worker,让 Handler 慢一点,然后在它跑完之前按 Ctrl-C。
go run ./example/basic/cmd/demo -orders 2 -handler-delay 30s -lease 5s -reap 2screated order 1 (task 1) in one transaction
created order 2 (task 2) in one transaction
worker started; press Ctrl-C to stop (watching every 1s)
order status task status attempt error
1 pending 1 running 1
2 pending 2 running 1
...
按 Ctrl-C,收尾是这样的:
shutting down: interrupted
task 1 (order 1) is still running
task 2 (order 2) is still running
2 task(s) left unfinished. They stay as-is until the lease expires,
then the reaper hands them back to the queue. Restart with `-orders 0` to
watch a fresh worker pick them up (see README.md).
业务行还是 pending、任务行是 running、attempt 停在 1:Handler 一收到取消就收手,没有写回任何业务结果。查库能看到租约还剩多久:
-- 下面三段查库结果都出自这条查询(表名固定为 gotask_tasks,库不自带管理界面)
SELECT id, status, attempt, lease_until, UTC_TIMESTAMP(6) AS now_utc
FROM gotask_tasks ORDER BY id;id status attempt lease_until now_utc
1 running 1 2026-10-16 08:15:49.200823 2026-10-16 08:15:44.394506
2 running 1 2026-10-16 08:15:49.202704 2026-10-16 08:15:44.394506
等几秒让租约到期——-lease 5s 且心跳是租约的 1/3,所以租约最晚在停机后 5 秒内到期——再查一次:
id status attempt lease_until now_utc
1 running 1 2026-10-16 08:15:49.200823 2026-10-16 08:15:50.551951
2 running 1 2026-10-16 08:15:49.202704 2026-10-16 08:15:50.551951
lease_until 已经落在 now_utc 后面了,但状态还是 running——租约到期本身什么都不改变。
此刻集群里没有任何 Worker,也就没有人在回收。这正是回收器存在的理由,也是"光有租约不够"的地方。
第二幕:换一个 Worker 起来,这次不建订单,只观察业务表。
go run ./example/basic/cmd/demo -orders 0 -lease 5s -reap 2sorder customer amount_cents status exported_at
1 customer-01 1999 pending -
2 customer-02 3998 pending -
...
order customer amount_cents status exported_at
1 customer-01 1999 exported 2026-10-16T08:15:52.928231Z
2 customer-02 3998 exported 2026-10-16T08:15:52.967856Z
起来约 2 秒(一个 -reap 2s 周期)后,回收器把两笔过期租约放回 pending,同一个 Worker 随即领取、
跑完 Handler,业务行翻成 exported。收尾查库:
id status attempt lease_until now_utc
1 succeeded 2 NULL 2026-10-16 08:16:00.733618
2 succeeded 2 NULL 2026-10-16 08:16:00.733618
attempt 从 1 变成 2,就是"同一条任务被领过两次"的直接证据——所以 Handler 必须幂等。
两个开关只服务于演示。默认是
LeaseDuration 30s/ReapInterval 30s,照默认跑这条路径要等几十秒; 生产里这个量级才合理——租约太短,一次 GC 停顿或网络抖动就会被误判成"进程崩了"。
两者都是可选的:不配就是静默,库不替你决定往哪写。日志在 Config.Logger,
Worker 指标在 Config.Metrics,入队与取消的计数在 ClientConfig.Metrics。
type Logger interface {
Log(ctx context.Context, level gorangetask.Level, msg string, kv ...any)
}它只有一个方法,接任何日志库都是十几行。接 log/slog:
type slogAdapter struct{ l *slog.Logger }
func (a slogAdapter) Log(ctx context.Context, level gorangetask.Level, msg string, kv ...any) {
var lvl slog.Level
switch level {
case gorangetask.LevelDebug:
lvl = slog.LevelDebug
case gorangetask.LevelInfo:
lvl = slog.LevelInfo
case gorangetask.LevelWarn:
lvl = slog.LevelWarn
default:
lvl = slog.LevelError
}
a.l.Log(ctx, lvl, msg, kv...)
}
cfg.Logger = slogAdapter{l: slog.Default()}字段名是稳定的 snake_case:task_id / task_type / queue / attempt / max_attempts /
lease_epoch / worker_id / duration / delay / error。payload 永远不进日志
(有一条用哨兵值守着这件事的用例)。
级别分配:info 启动、停机、任务成功;warn 失败待重试、进入死信;error 基础设施错误、
停机超时;debug 每轮领取与清理。回收器只在真的捡回任务时记一条——它每轮都在跑,
每轮都记会把"确实有进程崩过"那一次埋在噪音里。
type Metrics interface {
TaskEnqueued(queue string)
TaskCanceled()
TaskClaimed(queue, taskType string)
TaskFinished(queue, taskType string, outcome gorangetask.Outcome, d time.Duration)
TaskWaited(queue, taskType string, d time.Duration)
LeaseLost(queue, taskType string)
HandlerPanicked(queue, taskType string)
HandlerTimedOut(queue, taskType string)
TasksReaped(toPending, toDead int)
TasksCleaned(status gorangetask.Status, deleted int64)
ClaimLatency(d time.Duration)
ConcurrencyInUse(n int)
}它刻意是具名事件,而不是通用的 Counter(name, labels) 原语:指标名与标签集合由库固定,
你无法(也无法不小心)把 task ID 塞进标签。接 Prometheus 时嵌入 NopMetrics、只覆盖关心的
几个即可:
type promMetrics struct {
gorangetask.NopMetrics // 其余方法自动成为空实现
tasks *prometheus.CounterVec
duration *prometheus.HistogramVec
}
func (m *promMetrics) TaskFinished(queue, taskType string, outcome gorangetask.Outcome, d time.Duration) {
m.tasks.WithLabelValues(queue, taskType, string(outcome)).Inc()
m.duration.WithLabelValues(queue, taskType).Observe(d.Seconds())
}两条需求条目刻意不在库里:按队列与状态的积压量(要库主动轮询,改由宿主自己查
SELECT queue_name, status, COUNT(*) FROM gotask_tasks GROUP BY 1, 2),以及数据库连接池占用
(属于宿主,用 db.Stats())。
TaskWaited 的数值由数据库时钟算出(从任务"最早可被领取"到实际被领取),因此不受各机器
钟差影响。TaskEnqueued 有一条已知局限:EnqueueTx 也会计数,包括事务最终回滚的那一次
——库无从知道调用方的事务是否提交。
日志面向人:带 task ID 与错误原文,用来追溯"这一条任务发生了什么"。指标面向聚合: 不带任何高基数维度。把 task ID 加进指标标签会让监控系统的基数爆掉。
adapter/gorm 是官方可选包,把 *gorm.DB 事务接到同一个 EnqueueTx 上:
import (
"gorm.io/gorm"
gormadapter "github.com/Tidehyc/gorangetask/adapter/gorm"
)
adapter, err := gormadapter.New(client) // client 就是上文那个 *mysqlstore.Client
if err != nil {
return err
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&order).Error; err != nil {
return err
}
_, err := adapter.EnqueueTx(ctx, tx, gorangetask.Task{
Queue: "orders", Type: "order.export", Payload: payload,
})
return err
})必须传事务句柄:db.Transaction 的回调参数,或 db.Begin() 的返回值。传一个非事务的
*gorm.DB 会让任务行走自己的连接(autocommit),与业务写入不在同一个事务里——那种写法
编译通过、运行也不报错,只在回滚时才暴露(业务行没了、任务行还在),所以 adapter 会直接
拒绝它。
为什么不能直接把 *gorm.DB 当作 gorangetask.Tx:它没有 ExecContext 方法,而且它的 Exec
会丢掉 sql.Result——GORM 只在内部的 Create 流程里读 LastInsertId,裸 SQL 拿不到新插入
行的 ID,而 EnqueueTx 必须返回任务 ID。adapter 因此从 *gorm.DB 上取出它正在使用的连接
(Statement.ConnPool),交给 store/mysql 执行与普通入队完全相同的那条 INSERT:校验、
幂等键冲突识别、时间口径全部复用,没有第二份实现。
- 需求文档:项目目标、MVP 范围、状态机、API 草案、可观测性和验收标准。
- 技术设计:任务表 DDL、领取/续租/确认/回收 SQL、API 与验证计划;附录 A 是一次实测事故的完整留档——
SELECT ... FOR UPDATE LIMIT n的加锁范围由执行计划决定,计划一离开预期索引就会锁住整个积压、SKIP LOCKED失效,附EXPLAIN/data_locks对照与固定手段。 - 迁移机制:MySQL DDL、dirty 状态、advisory lock、多语句处理及实测验证。
- 进度跟踪:阶段状态、完成标准和可核验的实现证据。
- 性能基线(Ryzen 7 8745H):当前开发机上的硬件/MySQL/Worker 配置、入队延迟、吞吐与伸缩、排队延迟、事务完整率、崩溃恢复耗时,以及这些数字不能用来声称什么。
- 性能基线(i5-8200Y,历史):同一套测法在一台 4 逻辑核笔记本上的结果,用于对照"哪些结论随机器变、哪些不变"。
- STAR 说明:项目定位、与手写 SQL 及 Outbox/RabbitMQ 的区别、简历证据框架(R 段为实测值)。
- 下一步计划:还没做的事按"证据缺口 / 能力缺口 / 工程化"分优先级,每项都写清验收判据;同时列出明确不做的边界。
- 面试复习指南:按 19 个 Feat 拆解源码与设计取舍,用于面试前复盘。
本项目使用 MIT License。