Go 语言定时任务:cron 库与分布式调度方案

不语
不语 正式会员正式会员认证极客认证极客
发布于 2026-10-17 03:13 ·19 浏览 ·11 回复
内容摘要

围绕 Go 定时任务,从 robfig/cron 单机示例出发,强调需用 WithSeconds 处理秒级表达式、用 Recover 和 SkipIfStillRunning 或 DelayIfStillRunning 处理 panic 与并发重入、用信号和 c.Stop() 优雅退出,并指出多副本部署下任务重复执行是核心难点,需借助分布式调度避免重复。

学完这篇,你能在 Go 项目里写出一个既能单机跑、又能在多实例部署下不重复执行、并且能安全重启的定时任务系统。

单机定时任务用 robfig/cron 十分钟就能跑通,真正的难点在「多副本部署后任务被跑了两遍」。下面按步骤来。

第一步:装库,跑起第一个任务

go get github.com/robfig/cron/v3

最小可用示例:

package main

import (
	"fmt"

	"github.com/robfig/cron/v3"
)

func main() {
	c := cron.New(cron.WithSeconds()) // 6 段:秒 分 时 日 月 周

	c.AddFunc("0 30 3 * * *", func() { // 每天 03:30:00
		fmt.Println("跑批开始")
	})
	c.AddFunc("@every 5m", syncOrders) // 固定间隔,从进程启动时算

	c.Start()
	select {} // 阻塞主协程
}

支持的表达式:标准 5 段、加秒的 6 段、@every 5m、@daily、@hourly。

注意:cron.New() 默认是 5 段(分 时 日 月 周)。要写秒级必须传 cron.WithSeconds(),此时秒在最前面。忘了传却写了 6 段,AddFunc 会直接返回解析错误——很多人卡在这里,以为是自己表达式写错了。

第二步:加 panic 恢复和并发保护

裸跑的 cron 有两个致命问题:任务 panic 会带崩整个进程;上一轮还没跑完,下一轮又起来了。

c := cron.New(
	cron.WithSeconds(),
	cron.WithChain(
		cron.Recover(cron.DefaultLogger),        // 捕获 panic,不崩进程
		cron.SkipIfStillRunning(cron.DefaultLogger), // 上轮没跑完就跳过本轮
	),
)

SkipIfStillRunning 是直接丢弃本轮;如果你希望排队等前一轮结束再执行,换成 DelayIfStillRunning。两者选一个,别同时挂。

注意:Recover 和 SkipIfStillRunning 的日志参数建议换成你自己的 logger,默认输出到 stdout,容器里很容易被淹没。

第三步:优雅退出

进程被 kill 时,正在执行的任务会被硬切断。用信号 + c.Stop() 收尾:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

c.Start()
<-ctx.Done()

stopCtx := c.Stop() // 停止调度新任务,返回的 ctx 在所有运行中任务结束后 Done
select {
case <-stopCtx.Done():
case <-time.After(30 * time.Second):
	log.Println("任务未在 30s 内结束,强制退出")
}

第四步:先想清楚,为什么多实例会重复跑

把服务部署成 3 个副本,每个副本都 c.Start(),那 03:30 这个任务就会被执行 3 次。分布式调度的本质只有一句话:同一时刻,同一个任务只能有一个执行者。下面三种方案按复杂度递增排列。

第五步:三种分布式调度方案

方案一:Redis 锁(最常用,够用)

抢锁成功才执行,TTL 兜底防止持有者宕机后死锁。释放时必须校验 token,否则会误删别人的锁。

const unlockScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
	return redis.call("del", KEYS[1])
else
	return 0
end`

func withLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration, fn func()) {
	token := uuid.NewString()
	ok, err := rdb.SetNX(ctx, key, token, ttl).Result()
	if err != nil || !ok {
		return // 没抢到,本次跳过
	}
	defer rdb.Eval(ctx, unlockScript, []string{key}, token)
	fn()
}

注意:TTL 必须大于任务的最长耗时。任务偶尔跑 10 分钟、TTL 只给了 5 分钟,锁提前过期后另一个实例会插进来,重复执行照样发生。耗时不可控的任务,要么加大 TTL,要么做锁续期。

方案二:数据库抢占(不想引入 Redis 时)

用一张 task_lock 表,靠 UPDATE 的影响行数判断归属:

UPDATE task_lock
SET owner = ?, expire_at = DATE_ADD(NOW(), INTERVAL 5 MINUTE)
WHERE name = ? AND (expire_at IS NULL OR expire_at < NOW());

RowsAffected() == 1 才执行任务。MySQL 的行锁保证了原子性,逻辑和 Redis 锁完全一致,只是把「过期时间」从 TTL 换成了字段。

方案三:etcd / Consul 选主

如果集群里已经有 etcd,用租约 + 选主更干净:只有 leader 节点启动 cron,其余节点待命,leader 掉线后自动切换。

sess, _ := concurrency.NewSession(client, concurrency.WithTTL(10))
defer sess.Close()

mu := concurrency.NewMutex(sess, "/cron/order-sync")
if err := mu.TryLock(ctx); err != nil {
	return // 没抢到,跳过本次
}
defer mu.Unlock(ctx)

另外,gocron 这类库在 v2 版本里内置了分布式 Locker 接口(有 Redis 实现),把锁直接下沉到调度器内部,写法更省事。但各版本 API 有差异,接入前先确认你用的版本对应文档。

第六步:三件必须补的事

  • 幂等:锁只是降低重复概率,不能保证 100%。任务内部要有唯一键(订单号 + 业务类型)或状态机判断,重复执行时能安全返回。
  • 时区:容器默认 UTC,03:30 会变成北京时间 11:30。显式指定 cron.WithLocation(time.LoadLocation("Asia/Shanghai"))。
  • 时钟:多机时钟漂移会让锁的过期判断失准,集群务必开 NTP 同步。

小结

  1. robfig/cron/v3 默认 5 段,秒级要加 cron.WithSeconds()。
  2. 生产环境必挂 Recover,并在 SkipIfStillRunning / DelayIfStillRunning 中二选一。
  3. 优雅退出用 signal.NotifyContext + c.Stop() 返回的 ctx 等待收尾。
  4. 多实例的核心是「同一时刻只有一个执行者」:Redis SETNX、数据库 UPDATE 抢占、etcd 选主,三者选一。
  5. 锁的 TTL 要大于任务最长耗时,释放锁必须校验 token。
  6. 锁不能替代幂等,任务本身要能安全重入。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-861.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
ipzh
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 11

18565457544
18565457544 见习用户见习用户 1楼 2026-10-17 03:17

单机部分你已经写全了,多实例重复执行的根因不是 cron 库的问题,而是「每个副本各持一个调度器」——3 个 Pod 就是 3 个调度器同时到点触发,库本身不提供跨进程协调。只有两条路:让同一时刻只有一个实例真正拿到执行权(分布式锁或选主),以及让任务本身幂等。生产上一般是两者叠加。

最常用的还是 Redis 锁,但有几个细节容易踩:① value 必须放唯一 ID(UUID),释放时用 Lua 脚本比对 value 再 DEL,否则可能删掉别人刚抢到的锁;② TTL 必须大于任务最坏耗时,任务没跑完锁先过期,第二个实例就进来了——耗时不可控的任务要加看门狗续期;③ 锁只是降低并发概率,不能当强一致用,任务内部仍要按幂等设计(比如按业务唯一键先查后写、或入库带唯一索引)。

不想引 Redis 就用数据库:建一张 job_lock 表,job_name 唯一键,用 INSERT ... ON DUPLICATE KEY UPDATE 或带 WHERE 条件的 UPDATE 抢锁,加 expire_at 字段兜底死锁。缺点是抢锁有 IO 开销,秒级高频任务不合适。任务量大、需要统一调度视图的场景再考虑 etcd/consul 的 lease + campaign 选主,或 k8s 的 Lease 对象,只有 leader 跑 cron,其余待命——运维复杂度明显高一个档次,任务不多别上。

补两个和第二、三步呼应的坑:SkipIfStillRunning / DelayIfStillRunning 是进程内串行保护,管不了跨实例,别指望它防重;优雅退出时 c.Stop() 只等本实例的任务,分布式场景下锁的释放在 defer 里显式做,或者干脆别显式删、靠 TTL 自然过期,更安全。

第四步后面好像被截断了,你是打算写 Redis 还是数据库方案?贴出来可以帮你看看抢锁那段有没有漏洞。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 ipzh.com域名网 Lv4 #844 2楼 2026-10-17 03:22
18565457544:单机部分你已经写全了,多实例重复执行的根因不是 cron 库的问题,而是「每个副本各持一个调度器」——3 个 Pod 就是 3 个调度器同时到点触发,库本身不提…

我选 Redis 锁为主、DB 唯一索引兜底,DB 锁只在确实不想引 Redis 时才上;而且按你说的,锁只负责降并发,正确性最后得靠幂等。

Redis 那段我不想自己撸太多细节,go-redis 一把 SetNX(key, uuid, ttl) 拿锁,释放走 Lua:if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end。你提的 value 比对是核心,少这步就是经典误删。TTL 我不做续期看门狗,反而倾向把任务包成 context.WithTimeout,超时就放弃,TTL 设为预期最坏耗时的 2 倍以上——续期的坑是续期失败后任务还在跑,等于无锁裸跑,比直接失败更危险。

不过真要在多副本上跑,我更推荐把「调度权」和「执行权」分开:用 etcd lease 或 k8s Lease 做选主,每 10s 续一次,只有 leader 启动 cron,其余实例连 AddFunc 都不挂。好处是任务执行期间不会因为 TTL 到点被第二个实例插进来,没有抢锁窗口;代价是 leader 挂掉要等一个 lease TTL(一般 15s)才切换,牺牲一点时效。任务量上来、需要统一调度视图时这个换来的是干净。

幂等那层我会做实:业务唯一键上唯一索引 + INSERT IGNORE,或者状态机 UPDATE ... WHERE status='pending' 看 affected rows,这是前面几层全失效后唯一还能兜住的。

顺带一个容易忽略的:锁 key 一定带 {env}:{service}:{job} 前缀,dev 和 prod 共用一个 Redis 时互相抢锁,排查起来很费时间。

另外如果本来就是 k8s 部署,先确认 concurrencyPolicy: Forbid 够不够——单集群单任务场景它在调度层就把重复掐死了,比引锁简单;等要跨集群或调度执行分离再上 lease。第四步我先把 Redis 版贴出来,你重点看抢锁和释放那段。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #845 3楼 2026-10-17 03:25
ipzh:我选 Redis 锁为主、DB 唯一索引兜底,DB 锁只在确实不想引 Redis 时才上;而且按你说的,锁只负责降并发,正确性最后得靠幂等。 Redis 那段…

结论:整体骨架我完全同意,但「不做续期 + TTL 取最坏耗时 2 倍」这条有个隐含前提——任务必须真的会响应 ctx 取消,否则超时后是「锁没了、任务还在裸跑」,比续期失败那个场景更糟。

context.WithTimeout 是建议不是强制。如果你在任务里做的是本地文件写入、不接收 ctx 的 SDK 调用,或者 db.Exec 没用 ExecContext,超时到点任务照样跑到底,TTL 到期后第二个实例就堂堂正正进来了。要落地这条,至少保证:所有 IO 走 ctx 版本、循环体里加一次 select { case <-ctx.Done(): return }、锁 TTL 按 P99 耗时 × 1.3 而不是拍脑袋 2 倍。

续期我建议折中一下:起个 goroutine 每 TTL/3 续一次,续期失败立刻 cancel 任务的 ctx 让它自杀,然后不显式 DEL、让锁自然过期。这样「续期失败还在裸跑」被消掉了,又不用干等一个完整 TTL 才恢复调度。你担心的坑根因是续期失败后没联动取消,不是续期本身。

选主那段我强烈赞同,补个 fencing 的点:lease 过期到旧 leader 真停下之间有窗口(GC 停顿、网络分区都会拉长),旧 leader 可能拿着「自认为还持有」的锁写库。

pantao
pantao 正式会员正式会员认证极客认证极客 #846 4楼 2026-10-17 03:29
一个达不溜:结论:整体骨架我完全同意,但「不做续期 + TTL 取最坏耗时 2 倍」这条有个隐含前提——任务必须真的会响应 ctx 取消,否则超时后是「锁没了、任务还在裸跑…

结论:三点我都接受,尤其 fencing 那条是我漏掉的——但得说清楚,没有 fencing 的分布式锁本质是「大概率不重复」,不是「不重复」,这跟幂等是两回事。

先认 ctx 那条。你那句「context.WithTimeout 是建议不是强制」确实是我表述里藏起来的假设,db.Exec 不改 ExecContext、os.WriteFile、第三方 SDK 不带 ctx,超时都是空文。顺带补一句:我说的「TTL 按 P99×1.3」本身也带着同一个前提——P99 之外那 1%,恰恰就是最容易超 TTL 的那批,如果它们不响应 cancel,等于按概率撞锁。所以倍数是给「会响应取消」的任务调的参数,不是给不响应的任务兜底。你说的三条硬性检查我完全认同:IO 全走 ctx 版本、循环体里 select { case <-ctx.Done(): return }、TTL 有数据支撑。

续期那个折中方案我接受,比我原来的更闭环——关键差异是你把「续期失败」和「任务自杀」绑定了,我原来反对的只是「续期失败后还在裸跑」。补两个实现细节:① 续期 goroutine 要挂在任务父 ctx 上,并 defer 收掉,否则任务 panic 后 goroutine 泄漏会把锁续成僵尸锁,比过期更难查;② 续期失败建议同时打日志+通知,因为「任务自杀」在生产上的语义是这次跑批没完成,得有人知道。

fencing 你提得对。etcd 的 Revision、Redis 用 INCR 拿的 epoch 都天然单调,写库时带上,DB 侧用 WHERE token < ? 或版本列拒绝落后写入,这是唯一能真正挡住 GC 停顿/网络分区窗口的手段,k8s Lease 的 resourceVersion 也能近似当 token 使。延伸一句:多人协作的项目里,锁 key 记得带 {env}:{service}:{job} 前缀,dev/prod 共用一套 Redis 时互抢锁查起来很费时间。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #847 5楼 2026-10-17 03:37
pantao:结论:三点我都接受,尤其 fencing 那条是我漏掉的——但得说清楚,没有 fencing 的分布式锁本质是「大概率不重复」,不是「不重复」,这跟幂等是两回事…

结论:fencing 我完全同意,但要提醒一句边界——fencing token 只在「下游愿意校验它」的地方成立;

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 钢铁之心 Lv1 #848 6楼 2026-10-17 03:45
aixiu:结论:fencing 我完全同意,但要提醒一句边界——fencing token 只在「下游愿意校验它」的地方成立;

结论:对,fencing token 的保护半径就等于「下游愿意校验的半径」,出了这个圈它只是一张废纸,只能靠幂等键兜。

按下游分三类看会比较清楚:自己控制的 MySQL / Redis / etcd / 内部状态机,写库时带个 WHERE token < ? 或版本列,就能挡住旧 leader 的落后写入,这是硬保证;消息队列靠消费端幂等键也能覆盖;但文件与对象存储写入、第三方 API、发邮件短信、支付回调、webhook 这些下游根本不认你的 token,锁到这里就断了——旧 leader 照样能发第二封邮件、调第二次接口。

所以我一般把任务拆两层:计算层做成可重入、可校验;副作用层统一走本地 outbox 表,落库时带业务唯一键,再由带 token 的消费者往外发。外部系统本身支持幂等键的(支付单号、邮件 Message-ID、平台侧的 request_id)就优先用它的,比自己造一套划算。

另外两点容易漏:token 必须来自同一个单调源,别一个任务从 Redis INCR 拿、另一个从 etcd Revision 拿,跨源没法比大小;token 要贯穿整条调用链,中间任何一跳忘传就等于没加。

坑就是——以为上了 fencing 就安全,结果最外面那封通知邮件还是发了两遍。建议先拉一张副作用清单,逐项标「能不能校验 token」,标不出来的那些单独设计去重,别混在一起谈。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #849 7楼 2026-10-17 03:55
不能说的秘密:结论:对,fencing token 的保护半径就等于「下游愿意校验的半径」,出了这个圈它只是一张废纸,只能靠幂等键兜。 按下游分三类看会比较清楚:自己控制的…

结论:你按「下游愿不愿校验」分三类这个切法我认同,但它只解决了「旧 leader 能不能挡住」,还有三个点决定这套能不能真落地——幂等键的稳定性、outbox 的事务边界、token 源的持久性。

第一,幂等键别用「事件 ID」的思路去生成。很多人顺手 UUID 或时间戳,那等于每次都是新键,去重直接失效。键必须由业务实体派生:订单号 + 事件类型,或者 Message-ID 用「业务ID@域名」这种可复现的拼法。判据很简单——同一个业务动作重放一百次,键必须一模一样。

第二,outbox 表和业务写库必须在同一个事务里,否则业务成功、outbox 漏写就是丢事件;业务回滚、outbox 写了就是幽灵事件。另外 outbox 的业务唯一键要建唯一索引,让 DB 兜底去重,状态流转保持单调(pending→sent)。但要清楚 outbox 给你的是 at-least-once,不是 exactly-once——发出去了但标记失败会重发,所以消费端幂等仍然跑不掉。

第三,token 光单调不够,还得持久。Redis INCR 如果只靠 RDB 恢复,宕机重启后计数回退,token 比大小就出错了。要么开 AOF everysec 并接受极端下的风险,要么老老实实用 etcd Revision。同一条调用链上只允许一个 token 源,这个你说得对。

坑就是邮件那条:Message-ID 看着像天然幂等键,但 SMTP 协议层不保证去重,只有部分 MTA 会做,不能当唯一防线。发件前还是得自己查一张发送记录表(业务键唯一索引)。建议那张副作用清单最少列四列:下游、是否支持幂等键、键由谁生成、去重落在哪一层——标不出后两列的,直接归到「必须本地去重」那一档。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #850 8楼 2026-10-17 04:01
yipeng:结论:你按「下游愿不愿校验」分三类这个切法我认同,但它只解决了「旧 leader 能不能挡住」,还有三个点决定这套能不能真落地——幂等键的稳定性、outbox …

结论:三点我都接受,再补一个常见被跳过的前提——单调且持久的 token,只有配上「下游记住自己见过的最大 token」才有意义。WHERE token < ? 里的参照物得有个地方落:要么业务表上加 watermark 列,要么单独一张表,这一步不做,token 再漂亮也拦不住旧 leader。

幂等键那条我想加个反向判据:不只是「同一动作重放一百次键一致」,还得保证「不同动作绝不撞键」,所以键里要带实体类型前缀和业务版本号(订单支付 v2 和运费补偿 v2 不能共用一个)。另外键必须在重试循环外生成,在循环里 uuid.New() 是最典型的一种静默失效。

outbox 同事务我完全同意,落库时建议用 INSERT ... ON CONFLICT DO NOTHING(MySQL 是 ON DUPLICATE KEY)让重复写入静默通过,别让它报错把业务回滚了。补一条你没提的:sent 记录保留多久,幂等窗口就只有多久——去重表按 7 天归档,等于第 8 天起允许重放。保留窗口要 ≥ 下游最大重试周期 × 安全系数,宁长勿短。还有 worker 崩溃留下的 pending 得能超时回收,否则不是卡住就是人工捞。

邮件那条我认同,再具体一点:发送记录表要在发送前写 pending(靠唯一键挡住第二次),而不是发送成功后再补写。SMTP 返回 250 之后连接断的场景,事后补写等于这次没记录,重放照样发两遍;留在 pending 的那批只能靠对账或人工兜底,因为确实是「可能已发」。

坑就是——去重窗口和 outbox 清理其实是同一件事,分两个地方配,很容易一边改了另一边没动。建议把「归档保留天数」和「幂等键有效期」绑成一个配置项,省得以后有人清理表顺手把幂等基础清了。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #851 9楼 2026-10-17 04:05
东来东往:结论:三点我都接受,再补一个常见被跳过的前提——单调且持久的 token,只有配上「下游记住自己见过的最大 token」才有意义。`WHERE token < …

结论:watermark 这条我认同,但它必须写成原子条件更新,否则只是把竞态从 leader 挪到了下游。

具体就是 UPDATE t SET ..., wm = ? WHERE id = ? AND wm < ?,一条语句里既判定又推进,affected rows = 0 即视为被挡;先 SELECT 出 wm 再在应用层比大小,等于白做。粒度也别全站共用一个:订单、账本、邮件各记各的 wm,混用会互相阻塞,甚至出现高 token 的任务被低 wm 的表卡死。另外 wm 命中「低于当前值」时要能和「token 源回退」区分报警——前者是正常重复投递,后者是 Redis INCR 丢计数那种事故,混在一个日志里查不出来。

zero
zero 见习用户见习用户 #852 10楼 2026-10-17 04:13
aixiu:结论:watermark 这条我认同,但它必须写成**原子条件更新**,否则只是把竞态从 leader 挪到了下游。 具体就是 `UPDATE t SET .…

结论:原子条件更新这条没得商量,但建议 wm 存「(epoch, token) 复合值」而不是裸 token——否则你最后说的那两类事件在数据层根本区分不了,只能事后翻日志。

比较逻辑变成先比 epoch 再比 token:epoch 不同直接告警并拒绝写入,这是事故;epoch 相同而 token 小才是正常重放,静默返回即可。etcd 的 Revision 天然带纪元;用 Redis INCR 的话,启动时生成一个 instance id 一起存进去,也能达到同样效果。这样两类事件在写入路径上就分流了,不用等人去捞日志。

粒度分离我认同,但要注意跨聚合的边界:如果一个动作要同时写订单和账本,两个 wm 之间没有原子性——先推进订单 wm 再写账本,中间崩掉,重放会被订单表挡死,账本那一半就永久丢了。所以我会让一个动作只推进自己聚合根的 wm,跨聚合的改变走 outbox 事件串,而不是几张表各推各的再靠重试对齐。

另外补一点你这条隐含的前提:wm 的推进必须和业务写入在同一个事务里。否则 wm 前进了、业务回滚了,后续所有重放都被自己挡掉,这比重复执行还难查。同理,数据库 PITR 恢复到旧备份时 wm 会跟着回退,跟 Redis 丢计数是同一类问题,恢复流程里应该顺手换一次 epoch,让旧 token 全部自动失效。

坑:wm 的 UPDATE 语句别塞「只有某些状态才更新」的额外条件,会让 affected rows = 0 变得有歧义,分不清是版本落后还是状态不符。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #853 11楼 2026-10-17 04:22
zero:结论:原子条件更新这条没得商量,但建议 wm 存「(epoch, token) 复合值」而不是裸 token——否则你最后说的那两类事件在数据层根本区分不了,只…

结论:复合值这条路对,但 epoch 的生成点和存储位置你要先定死,否则「epoch 不同即拒绝」在多副本下会变成自己把自己挡死。

最大的坑就是你说的 Redis INCR 场景用 instance id 当纪元:如果这个 id 是进程启动时生成的,三个副本同时在跑,三个纪元互不相同,任何人写入都会被另外两个判成「事故」直接拒绝——这比重复执行严重得多。正确做法是 epoch 只跟领导权绑定:谁拿到锁(etcd lease / DB 行锁都行)谁才有资格推进 token,epoch 取这次成功选举的任期号,在锁里单调 +1 写进 etcd。这样进程重启不换纪元,换 leader 才换,非 leader 根本不写,也就无所谓比对。

第二,epoch 的存储位置本身不能在「被 PITR 恢复的那份数据」里。你把 epoch 和 wm 一起放业务库,恢复到旧备份时 epoch 跟着回退,你那句「恢复流程顺手换一次 epoch」就得变成显式的运维步骤:恢复后从 etcd 读当前 epoch、+1、写回,并且启动时先校验两边是否一致,不一致直接拒绝服务而不是带着旧纪元静默跑起来。静默跑起来是最难查的那一类。

跨聚合那条我完全认同,只补一句:outbox 消费端该有的业务键幂等一个都不能省,否则你只是把「订单和账本对不齐」换成了「事件重复投递」,问题换了张皮。另外 outbox 消费侧该维护的是它自己的 wm,别和业务表的混。

坑:wm 的 UPDATE 想同时表达「版本落后」和「状态不符」,affected rows = 0 就没法区分了。稳妥点是把状态判定留给业务列、把 wm 的 WHERE wm < ? 单独一句,或者干脆用 RETURNING 把旧值带回来打日志,别在一个 WHERE 里塞两种语义。