订单半小时未支付,如何自动取消?

下单后「30 分钟未支付自动取消、释放库存」是电商标配。难点在于:不能每个订单起个定时器(百万订单起百万定时器),要「统一的延迟触发机制」准确在 30 分钟点取消,且取消要幂等(已支付的不取消、取消失败能重试)。本文给出三套相互隔离的方案,最推荐方案一:RabbitMQ 死信队列(DLX)——订单创建时发一条带 TTL=30min 的消息到「普通队列」,30 分钟到点消息变死信进死信队列被消费取消,天然延迟触发、精准准时。

01PART为什么这是个难题为什么这是个难题

  • 不能每单起定时器:百万订单起百万 sleep/cron,资源爆炸。
  • 要精准触发:到点(30min)才取消,提前/滞后都不合适。
  • 幂等:已支付的订单不能被取消(用户刚付完,取消消息才到,要识别已支付跳过)。
  • 取消失败要重试:取消订单涉及释放库存、退优惠券,任一步失败要能重试。

02PART方案一(最推荐):RabbitMQ 死信队列(TTL + DLX)方案一(最推荐):RabbitMQ 死信队列(TTL + DLX)

1.1 技术选型

关注点 选型 说明
MQ RabbitMQ 3 死信队列做延迟
延迟 消息 TTL + DLX 到点进死信队列
消费 普通消费者取消订单 幂等
框架 PHP 8.5 / Laravel 13 主链路

1.2 依赖安装(docker run)

docker run -d --name rabbitmq --restart unless-stopped --network haveyb \
  -p 5672:5672 -p 15672:15672 rabbitmq:3-management
docker run -d --name mysql --restart unless-stopped --network haveyb \
  -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 mysql:9.4
docker exec -w /data/new_blog php composer require php-amqplib/php-amqplib

1.3 方案分析

订单创建时,发一条消息到「延迟队列」(设置了 x-message-ttl=30min + x-dead-letter-exchange)。消息在延迟队列里静静躺 30 分钟(不消费),TTL 一到自动变成死信,被路由到「死信队列」,消费者拿到消息执行取消订单。优点:延迟精准(到点触发)、不需要扫表、天然支持重试(消费失败重投);缺点:RabbitMQ TTL 是「队列级」或「消息级」,消息级 TTL 在队列里是 FIFO 顺序触发(前一条没到期,后一条即使到期也不急着投——实际 RabbitMQ 3.8+ 已优化为各自按 TTL 触发)。

1.4 完整代码

声明延迟队列 + 死信交换机 app/Console/Commands/DeclareDelayQueue.php:

下单时发延迟消息 app/Services/CreateOrderService.php:

消费取消(幂等) app/Console/Commands/OrderCancelConsume.php:

保活 run_cancel.sh:

docker exec -d -w /data/new_blog php bash run_cancel.sh

1.5 运行验证

docker exec -w /data/new_blog php php artisan amqp:declare-delay
docker exec -w /data/new_blog php php artisan order:cancel-consume &
# 下单(不支付)
docker exec -w /data/new_blog php php artisan tinker
>>> (new \App\Services\CreateOrderService())->create(['user_id'=>1,'sku_id'=>10,'qty'=>2]);
# 30 分钟后,order.cancel 消费,订单 status=2,库存释放
# 若中途支付:status 变 1,30 分钟后消费幂等跳过

1.6 必踩的坑

  • 幂等必做:用户恰在 30 分钟点支付,取消消息后到,必须查 status!=0 跳过,否则取消已付订单。
  • 消息级 TTL 顺序:RabbitMQ 队列里消息按入队顺序检查 TTL,前一条未到期会阻塞后一条触发(3.8+ 已改善,但大批不同 TTL 要评估);统一 30min TTL 不受影响。
  • 队列级 TTL 局限:x-message-ttl 是队列固定值,所有消息同延迟;不同订单要不同延迟得建多个队列或用消息级 TTL。
  • 消费失败重试:用 basic_nack(requeue=true) 重投,但要带重试上限防死循环。

03PART方案二:Redis ZSet 延时 + 定时扫描(与方案一隔离)方案二:Redis ZSet 延时 + 定时扫描(与方案一隔离)

2.1 技术选型

订单创建时 ZADD delay_orders <到期时间戳> <orderId>,后台定时任务每秒 ZRANGEBYSCORE delay_orders 0 now 取到期订单取消。适合不想依赖 MQ 死信、或订单取消逻辑简单(纯 Redis 操作)的场景。

2.2 依赖安装(docker run)

docker run -d --name redis --restart unless-stopped --network haveyb \
  -p 6379:6379 -e REDIS_PASSWORD=123456 redis:8.2 redis-server --requirepass 123456
docker run -d --name mysql --restart unless-stopped --network haveyb \
  -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 mysql:9.4
docker exec -w /data/new_blog php composer require predis/predis

2.3 方案分析

ZSet 的 score 存「到期时间戳」,value 存 orderId。定时任务每秒扫 score <= now 的订单,取出取消。ZRANGEBYSCORE 是 O(log N),扫描高效,不扫全表。优点:不依赖 MQ、逻辑直观;缺点:定时任务有秒级延迟(可接受)、要自己保证任务唯一执行(多实例用分布式锁)、取消失败要处理。

2.4 完整代码

下单入 ZSet app/Services/OrderDelayService.php:

定时扫描取消 app/Console/Commands/ScanDelayOrders.php:

保活 run_scan.sh:

docker exec -d -w /data/new_blog php bash run_scan.sh

2.5 运行验证

docker exec -w /data/new_blog php php artisan order:scan-cancel &
docker exec -w /data/new_blog php php artisan tinker
>>> (new \App\Services\OrderDelayService())->create(['user_id'=>1,'sku_id'=>10,'qty'=>2]);
# 30 分钟后扫描任务取消订单,status=2

2.6 必踩的坑

  • 多实例重复扫:扫描任务多实例要分布式锁,否则重复取消(事务幂等已兜底,但浪费)。
  • 秒级延迟:定时扫描有 1 秒级延迟,业务要接受。
  • ZSet 膨胀:取消的要从 ZSet 删,否则集合越来越大,扫描变慢。

04PART方案三:Go 时间轮 / 定时轮询(与方案一/二隔离)方案三:Go 时间轮 / 定时轮询(与方案一/二隔离)

3.1 技术选型

用 Go 写延迟任务服务:订单创建时按 orderId 注册到时间轮(TimingWheel)或简单 time.AfterFunc(30min),到点触发取消。Go 的 timer 高效,百万订单用时间轮(O(1) 触发)扛得住。适合「已有 Go 栈、要高效管理海量延迟任务」。

3.2 依赖安装(docker run)

docker run -d --name mysql --restart unless-stopped --network haveyb \
  -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 mysql:9.4
docker run -d --name go --privileged --restart unless-stopped --network haveyb \
  -p 19503-19508:9503-9508 -v ~/Desktop/code:/data \
  -v ~/.goenv/shared/go-mod:/go/pkg/mod -v ~/.goenv/shared/go-build:/root/.cache/go-build \
  -w /data/delay-cancel -e TZ=Asia/Shanghai golang:1.26-bookworm sleep infinity

3.3 方案分析

Go 时间轮:订单创建时提交一个 delay=30min 的任务到时间轮,时间轮内部用环形数组 + 层级桶管理,到点精准触发回调(取消订单)。比「每秒扫表」高效(O(1) 触发),比 MQ 死信简单(不用建队列拓扑)。适合海量延迟任务的统一管理。本文用 time.AfterFunc 演示(简单,百万级用时间轮库如 rfwenn/timewheel)。

3.4 完整代码

Go 延迟取消 main.go:

go.mod + 编译运行 run_delay.sh:

docker cp main.go go:/data/delay-cancel/main.go
docker exec -w /data/delay-cancel go sh -c '
  go mod init delay-cancel
  go get github.com/go-sql-driver/mysql@v1.8.1
  go mod tidy
  go mod edit -go=1.25
  go build -o delay-cancel main.go
'
docker exec -d -w /data/delay-cancel go bash -c 'while true; do ./delay-cancel >> /var/log/delay-cancel.log 2>&1; sleep 3; done'

3.5 运行验证

curl -s "http://127.0.0.1:9503/order/create?user_id=1"
# 30 分钟后日志:order N cancelled

3.6 必踩的坑

  • 进程重启丢任务:time.AfterFunc 在内存,进程重启注册的延迟任务丢失,要用时间轮 + 持久化(落 DB/Redis)或重启后重建。
  • 海量 timer:百万 time.AfterFunc 占内存,生产用时间轮(rfwenn/timewheel)O(1)。
  • 幂等:取消前必须查 status,已支付跳过。

05PART方案对比方案对比

维度 方案一 RabbitMQ死信 方案二 Redis ZSet扫描 方案三 Go时间轮
延迟精准 ✅ 到点触发 ⚠️ 秒级延迟 ✅ 精准
依赖 RabbitMQ Redis Go服务
重试 ✅ nack重投 手动 手动
复杂度 中 低 中(Go)
推荐 ⭐⭐⭐ 最推荐 ⭐⭐ 简单 ⭐⭐ 海量

06PART小结小结

订单超时取消核心是「延迟触发 + 幂等」。最推荐方案一 RabbitMQ 死信队列:下单发带 TTL=30min 的延迟消息,到点变死信进消费队列取消,精准触发、天然重试、不扫表。简单场景用方案二 Redis ZSet 定时扫描(ZRANGEBYSCORE 高效取到期)。海量延迟任务用方案三 Go 时间轮(O(1) 触发)。通用铁律:① 取消必幂等(查 status,已支付跳过);② 延迟触发别用「每单定时器」(资源爆炸);③ 取消失败要重试(MQ nack / 重投);④ 取消要释放库存/退券,事务内完成;⑤ 进程重启要能恢复延迟任务(死信队列天然持久,ZSet/时间轮要持久化)。

全文 MQ 统一用 RabbitMQ,不混用 Kafka。方案三用 Go 1.25(golang:1.26-bookworm 容器),符合「Go 辅助常驻延迟任务场景」。