订单半小时未支付,如何自动取消?
下单后「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:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Wire\AMQPTable;
class DeclareDelayQueue extends Command
{
protected $signature = 'amqp:declare-delay';
public function handle(): int
{
$conn = new AMQPStreamConnection('rabbitmq', 5672, 'guest', 'guest');
$ch = $conn->channel();
// 死信交换机 + 死信队列(真正的消费队列)
$ch->exchange_declare('order.dlx', 'topic', false, true, false);
$ch->queue_declare('order.cancel', false, true, false, false);
$ch->queue_bind('order.cancel', 'order.dlx', 'order.cancel');
// 延迟队列:消息在这躺 30 分钟,TTL 到点变死信进 order.cancel
$ch->queue_declare('order.delay', false, true, false, false, false, new AMQPTable([
'x-message-ttl' => 30 * 60 * 1000, // 30 分钟
'x-dead-letter-exchange' => 'order.dlx',
'x-dead-letter-routing-key' => 'order.cancel',
]));
$ch->queue_bind('order.delay', 'order.delay.exchange', 'order.delay');
$this->info('延迟队列声明完成');
return self::SUCCESS;
}
}
下单时发延迟消息 app/Services/CreateOrderService.php:
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;
class CreateOrderService
{
public function create(array $data): int
{
$id = DB::table('orders')->insertGetId($data + ['status' => 0, 'created_at' => now()]);
// 发延迟消息:30 分钟后自动取消
$conn = new AMQPStreamConnection('rabbitmq', 5672, 'guest', 'guest');
$ch = $conn->channel();
$ch->basic_publish(
new AMQPMessage(json_encode(['order_id' => $id], JSON_UNESCAPED_UNICODE),
['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]),
'order.delay.exchange', 'order.delay'
);
$ch->close();
$conn->close();
return $id;
}
}
消费取消(幂等) app/Console/Commands/OrderCancelConsume.php:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use PhpAmqpLib\Connection\AMQPStreamConnection;
class OrderCancelConsume extends Command
{
protected $signature = 'order:cancel-consume';
public function handle(): int
{
$conn = new AMQPStreamConnection('rabbitmq', 5672, 'guest', 'guest');
$ch = $conn->channel();
$ch->basic_qos(0, 10, false);
$ch->basic_consume('order.cancel', '', false, false, false, false,
function ($msg) use ($ch) {
$p = json_decode($msg->body, true);
$orderId = $p['order_id'];
try {
DB::transaction(function () use ($orderId) {
$order = DB::table('orders')->where('id', $orderId)->lockForUpdate()->first();
if (!$order) return;
if ($order->status != 0) {
return; // 已支付/已取消,幂等跳过(关键!)
}
// 取消订单 + 释放库存
DB::table('orders')->where('id', $orderId)->update(['status' => 2, 'updated_at' => now()]);
DB::table('stock')->where('sku_id', $order->sku_id)
->update(['qty' => DB::raw("qty + {$order->qty}")]);
// 退优惠券等...
});
$ch->basic_ack($msg->delivery_tag);
} catch (\Throwable $e) {
// 失败重投死信(或重投主队),带重试计数
$ch->basic_nack($msg->delivery_tag, false, true);
}
});
while ($ch->is_consuming()) { $ch->wait(); }
return self::SUCCESS;
}
}
保活 run_cancel.sh:
#!/bin/bash
cd /data/new_blog
mkdir -p /var/log/newblog
while true; do
php artisan order:cancel-consume >> /var/log/newblog/cancel.log 2>&1 &
P=$!
wait -n
kill $P 2>/dev/null
echo "$(date '+%F %T') 退出,3s 重启" >> /var/log/newblog/cancel.log
sleep 3
done
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:
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class OrderDelayService
{
public function create(array $data): int
{
$id = DB::table('orders')->insertGetId($data + ['status' => 0, 'created_at' => now()]);
// score = 30 分钟后的时间戳
Redis::zadd('delay_orders', time() + 1800, $id);
return $id;
}
}
定时扫描取消 app/Console/Commands/ScanDelayOrders.php:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class ScanDelayOrders extends Command
{
protected $signature = 'order:scan-cancel';
public function handle(): int
{
while (true) {
$now = time();
// 取到期订单(score <= now),一次最多 100
$due = Redis::zrangebyscore('delay_orders', 0, $now, ['limit' => [0, 100]]);
foreach ($due as $orderId) {
$this->cancel((int) $orderId);
Redis::zrem('delay_orders', $orderId); // 取出即从集合删
}
if (empty($due)) sleep(1); // 无到期则歇 1 秒
}
return self::SUCCESS;
}
private function cancel(int $orderId): void
{
DB::transaction(function () use ($orderId) {
$order = DB::table('orders')->where('id', $orderId)->lockForUpdate()->first();
if (!$order || $order->status != 0) return; // 幂等:已支付/已取消跳过
DB::table('orders')->where('id', $orderId)->update(['status' => 2, 'updated_at' => now()]);
DB::table('stock')->where('sku_id', $order->sku_id)
->update(['qty' => DB::raw("qty + {$order->qty}")]);
});
}
}
保活 run_scan.sh:
#!/bin/bash
cd /data/new_blog
mkdir -p /var/log/newblog
while true; do
php artisan order:scan-cancel >> /var/log/newblog/scan.log 2>&1 &
P=$!
wait -n
kill $P 2>/dev/null
echo "$(date '+%F %T') 退出,3s 重启" >> /var/log/newblog/scan.log
sleep 3
done
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:
// main.go:Go 延迟取消,time.AfterFunc 演示(生产用时间轮)
package main
import (
"database/sql"
"log"
"net/http"
"time"
_ "github.com/go-sql-driver/mysql"
)
var db *sql.DB
func init() {
var err error
db, err = sql.Open("mysql", "root:123456@tcp(mysql:3306)/new_blog")
if err != nil {
log.Fatal(err)
}
}
// 提交延迟取消:30 分钟后触发
func scheduleCancel(orderId int) {
time.AfterFunc(30*time.Minute, func() {
cancelOrder(orderId)
})
}
func cancelOrder(orderId int) {
// 事务:幂等取消(已支付跳过)
tx, _ := db.Begin()
var status int
tx.QueryRow("SELECT status FROM orders WHERE id=? FOR UPDATE", orderId).Scan(&status)
if status != 0 {
tx.Rollback()
return // 已支付/已取消,幂等跳过
}
tx.Exec("UPDATE orders SET status=2, updated_at=NOW() WHERE id=?", orderId)
tx.Commit()
log.Printf("order %d cancelled", orderId)
}
func create(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
orderId := r.FormValue("order_id")
var id int
db.Exec("INSERT INTO orders(user_id,status,created_at) VALUES(?,0,NOW())", r.FormValue("user_id"))
db.QueryRow("SELECT LAST_INSERT_ID()").Scan(&id)
scheduleCancel(id) // 注册 30 分钟延迟取消
log.Printf("order %d created, cancel scheduled", id)
w.Write([]byte("ok"))
}
func main() {
http.HandleFunc("/order/create", create)
log.Println("延迟取消服务启动 :9503")
log.Fatal(http.ListenAndServe(":9503", nil))
}
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 辅助常驻延迟任务场景」。