不能直接用time.afterfunc做订单超时取消,因其回调无fiber上下文、无法安全访问db/日志、不支持事务回滚与幂等校验,且服务重启后定时器丢失;可靠方案是cron轮询+状态机+select for update skip locked。

为什么不能直接用 time.AfterFunc 做订单超时取消
因为 Fiber 应用是多协程模型,time.AfterFunc 启动的回调在任意 goroutine 中执行,无法保证能安全访问应用上下文(比如数据库连接池、日志实例),更没法做事务回滚或幂等校验。一旦订单状态更新失败,就可能产生脏数据。
常见错误现象:panic: runtime error: invalid memory address or nil pointer dereference,尤其在回调里调用 db.QueryRow 或 c.Status() 时。
- 回调函数不持有
fiber.Ctx,不能直接响应 HTTP 请求 - 没有上下文超时控制,若数据库卡住,goroutine 可能永久泄漏
- 服务重启后,未触发的定时器全部丢失,无法恢复超时逻辑
用 github.com/robfig/cron/v3 驱动轮询 + 状态机判断
真正可靠的做法是:把“超时”从实时触发改为周期性扫描 + 状态跃迁。Fiber 本身不提供后台任务能力,必须外挂独立调度器。
使用场景:订单创建后 30 分钟未支付,自动置为 cancelled;需支持并发扫描、避免重复处理、兼容服务扩缩容。
- 扫描频率建议设为
*/2 * * * *(每 2 分钟一次),太密加重 DB 压力,太疏影响时效性 - SQL 必须加
FOR UPDATE SKIP LOCKED(PostgreSQL)或SELECT ... FOR UPDATE(MySQL),防止多实例同时处理同一订单 - 更新语句要带条件:
WHERE status = 'unpaid' AND created_at ,避免覆盖已支付订单
示例关键片段:
_, err := db.ExecContext(ctx, `
UPDATE orders
SET status = 'cancelled', updated_at = NOW()
WHERE id IN (
SELECT id FROM orders
WHERE status = 'unpaid'
AND created_at <h3>如何让取消逻辑可测试、可追溯</h3><p>不要把状态更新和通知耦合在一起。超时取消只是状态变更,后续动作(如退款、发短信、推送 WebSocket)应通过事件驱动解耦。</p><p>容易踩的坑:在 cron job 里直接调用微信模板消息接口,一旦网络抖动就失败且无重试,导致用户投诉。</p>
- 每次扫描后记录日志,包含影响行数、耗时、最早被取消的订单 ID,例如:
log.Info().Int("affected", rows).Str("oldest_id", id).Msg("cancelled expired orders") - 状态变更后,向消息队列(如 Redis Stream / Kafka)写入
order.cancelled事件,由独立消费者处理副作用 - 为每个订单生成唯一
cancel_reason = "timeout_after_30m",方便后续审计或用户端展示
Fiber 中注册定时任务的正确姿势
别在 app.Get("/order", ...) 路由里启动 cron,那会导致每次请求都新建一个调度器。必须在 main() 初始化阶段一次性注册。
性能影响:cron 实例是单例,但每个 job 执行时会新建 goroutine,所以 job 函数内部要自己控制并发(比如用 semaphore 限流)。
- 用
cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))包裹 panic 恢复,否则一个 job 崩溃会导致整个调度器停摆 - 启动前调用
c.Start(),并在app.Shutdown()时调用c.Stop(),确保进程退出前完成正在运行的 job - 如果用 Docker 部署,注意多个容器实例会同时扫描 DB —— 这不是 bug,是预期行为,靠 SQL 的
SKIP LOCKED和 WHERE 条件天然实现分布式锁
复杂点在于:超时规则可能因业务变化(比如大促期间延长到 60 分钟),硬编码在 SQL 里就很难维护。更好的方式是把阈值存在配置中心或数据库的 order_settings 表中,每次扫描前查一次。











