文档覆盖的 7 种方案
| 方案 | 核心技术 | 适用场景 |
|---|---|---|
| 一、DB 定时轮询 | FOR UPDATE SKIP LOCKED + 游标分页 |
小项目,分钟级可接受 |
| 二、Redis 延迟队列 | Keyspace 通知 / ZSET 轮询 | 中型项目,秒级 |
| 三、RocketMQ/RabbitMQ | 延迟消息 / 死信队列 | 大型项目,高可靠 |
| 四、时间轮 | 内存环形数组 | 心跳/连接超时(不适合订单) |
| 五、MySQL Event | CREATE EVENT SQL 定时任务 |
原型/极简场景 |
| 六、状态机+优化扫表 | 分布式锁 + 游标分页 | 不想引入中间件 |
| 七、分布式调度 | XXL-JOB 分片广播 | 公司已有调度平台 |
核心要点
- 每个方案都给了完整的 Go 代码实现
- 附带决策树帮你根据量级选方案
- 生产环境推荐:RocketMQ 延迟消息(主)+ 定时扫表(兜底)双重保障
- 重点覆盖了幂等性、库存并发安全、监控告警、乐观锁等生产必备细节
后端 Go 语言工程师:订单超时自动取消方案详解
场景:用户下单后 30 分钟内未支付,系统自动取消订单并释放库存。
目录
- 一、问题定义与核心需求
- 二、方案总览与选型矩阵
- 三、方案一:数据库定时轮询(最简)
- [四、方案二:Redis 过期回调 + 延迟队列](#四、方案二:Redis 过期回调 + 延迟队列 "#%E5%9B%9B%E6%96%B9%E6%A1%88%E4%BA%8Credis-%E8%BF%87%E6%9C%9F%E5%9B%9E%E8%B0%83--%E5%BB%B6%E8%BF%9F%E9%98%9F%E5%88%97")
- [五、方案三:RocketMQ / RabbitMQ 延迟消息](#五、方案三:RocketMQ / RabbitMQ 延迟消息 "#%E4%BA%94%E6%96%B9%E6%A1%88%E4%B8%89rocketmq--rabbitmq-%E5%BB%B6%E8%BF%9F%E6%B6%88%E6%81%AF")
- [六、方案四:时间轮算法(Time Wheel)](#六、方案四:时间轮算法(Time Wheel) "#%E5%85%AD%E6%96%B9%E6%A1%88%E5%9B%9B%E6%97%B6%E9%97%B4%E8%BD%AE%E7%AE%97%E6%B3%95time-wheel")
- [七、方案五:MySQL Event Scheduler(MySQL 原生定时任务)](#七、方案五:MySQL Event Scheduler(MySQL 原生定时任务) "#%E4%B8%83%E6%96%B9%E6%A1%88%E4%BA%94mysql-event-scheduler")
- [八、方案六:状态机 + 定时扫表优化](#八、方案六:状态机 + 定时扫表优化 "#%E5%85%AB%E6%96%B9%E6%A1%88%E5%85%AD%E7%8A%B6%E6%80%81%E6%9C%BA--%E5%AE%9A%E6%97%B6%E6%89%AB%E8%A1%A8%E4%BC%98%E5%8C%96")
- [九、方案七:分布式定时任务框架(XXL-JOB / 羚羊)](#九、方案七:分布式定时任务框架(XXL-JOB / 羚羊) "#%E4%B9%9D%E6%96%B9%E6%A1%88%E4%B8%83%E5%88%86%E5%B8%83%E5%BC%8F%E5%AE%9A%E6%97%B6%E4%BB%BB%E5%8A%A1%E6%A1%86%E6%9E%B6")
- 十、各方案横向对比与选型建议
- 十一、生产环境进阶与最佳实践
一、问题定义与核心需求
1.1 业务流程
markdown
用户下单 → 订单状态=PENDING → 倒计时30分钟
├─ 用户支付 → 状态=PAID → 正常流程
└─ 超时未付 → 状态=CANCELLED → 释放库存/优惠券/锁定资源
1.2 核心需求
| 需求 | 说明 |
|---|---|
| 精准性 | 尽量在到期时刻准时触发,误差可控 |
| 可靠性 | 不丢单、不重复取消(幂等) |
| 可扩展 | 支撑百万级并发订单量 |
| 可观测 | 运维可监控、可追溯、可手动干预 |
| 资源友好 | 不过度耗费数据库/内存/CPU |
1.3 数据库模型(通用)
sql
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号',
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0:待支付 1:已支付 2:已取消 3:已退款',
expire_time DATETIME NOT NULL COMMENT '支付截止时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status_expire (status, expire_time),
INDEX idx_order_no (order_no)
);
二、方案总览与选型矩阵
scss
┌─────────────────────────────────────────────────────────────┐
│ 订单超时取消方案 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ DB 轮询 │ │ Redis │ │ MQ 延迟 │ │ 时间轮 │ │
│ │ (扫表) │ │ 延迟队列 │ │ 消息 │ │ (in-memory) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │ │
│ ┌────┴─────┐ ┌────┴─────────────┴─────┐ ┌────┴──────┐ │
│ │ MySQL │ │ XXL-JOB / 羚羊 │ │ 状态机 │ │
│ │ Event │ │ 分布式调度 │ │ 优化扫表 │ │
│ └──────────┘ └────────────────────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────┘
| 方案 | 实时性 | 可靠性 | 复杂度 | 适合量级 | DB压力 |
|---|---|---|---|---|---|
| DB 定时轮询 | 分钟级 | 高 | 低 | 中小 | 中 |
| Redis 过期回调 | 秒级 | 中(回调可能丢失) | 中 | 中大 | 低 |
| Redis 延迟队列 (ZSET) | 秒级 | 高 | 中 | 中大 | 低 |
| RocketMQ 延迟消息 | 秒级 | 高 | 中 | 大 | 低 |
| RabbitMQ 死信队列 | 秒级 | 高 | 中 | 大 | 低 |
| 时间轮 | 毫秒级 | 低(内存) | 高 | 中 | 低 |
| MySQL Event | 分钟级 | 中 | 低 | 小 | 高 |
| 状态机+优化扫表 | 分钟级 | 高 | 中 | 中大 | 低 |
| 分布式调度 | 秒级 | 高 | 中 | 大 | 中 |
三、方案一:数据库定时轮询(最简)
3.1 原理
定时任务每隔 N 秒扫描 status=0 AND expire_time < NOW() 的订单,批量更新为已取消。
go
// scanner/order_scanner.go
package scanner
import (
"database/sql"
"log"
"time"
)
type OrderScanner struct {
db *sql.DB
interval time.Duration // 扫描间隔,如 10s
batch int // 每批处理数量
}
func NewOrderScanner(db *sql.DB, interval time.Duration, batch int) *OrderScanner {
return &OrderScanner{db: db, interval: interval, batch: batch}
}
func (s *OrderScanner) Run() {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for range ticker.C {
s.scanAndCancel()
}
}
func (s *OrderScanner) scanAndCancel() {
// ★ 关键 SQL:利用索引 idx_status_expire 高效扫描
query := `
SELECT id, order_no, user_id
FROM orders
WHERE status = 0
AND expire_time <= NOW()
LIMIT ?
FOR UPDATE SKIP LOCKED -- MySQL 8.0+ 跳过已锁行,避免等待
`
rows, err := s.db.Query(query, s.batch)
if err != nil {
log.Printf("scan orders error: %v", err)
return
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
var orderNo string
var userId int64
if err := rows.Scan(&id, &orderNo, &userId); err != nil {
continue
}
ids = append(ids, id)
}
if len(ids) > 0 {
s.batchCancel(ids)
}
}
func (s *OrderScanner) batchCancel(ids []int64) {
tx, _ := s.db.Begin()
defer tx.Rollback()
// 幂等更新:只取消还是待支付的订单
stmt, _ := tx.Prepare(`
UPDATE orders
SET status = 2, updated_at = NOW()
WHERE id = ? AND status = 0
`)
defer stmt.Close()
var cancelled int
for _, id := range ids {
result, _ := stmt.Exec(id)
n, _ := result.RowsAffected()
if n > 0 {
cancelled++
// 异步释放库存(避免阻塞扫描流程)
go s.releaseInventory(id)
}
}
tx.Commit()
log.Printf("batch cancel: %d/%d orders cancelled", cancelled, len(ids))
}
func (s *OrderScanner) releaseInventory(orderID int64) {
// 库存释放逻辑,需保证幂等
}
3.2 SQL 优化要点
sql
-- 1. 覆盖索引(避免回表,只查 id)
CREATE INDEX idx_status_expire_id ON orders (status, expire_time, id);
-- 2. 使用 FOR UPDATE SKIP LOCKED(MySQL 8.0+)
-- 多实例部署时避免行锁竞争
SELECT id FROM orders
WHERE status = 0 AND expire_time <= NOW()
LIMIT 200
FOR UPDATE SKIP LOCKED;
-- 3. 分页避免深分页性能问题
-- 记录上次处理的最大 id,用游标方式
SELECT id FROM orders
WHERE status = 0 AND expire_time <= NOW() AND id > @last_cursor
ORDER BY id LIMIT 200;
-- 4. 分区表(大表场景)
ALTER TABLE orders PARTITION BY RANGE (TO_DAYS(expire_time)) (
PARTITION p202601 VALUES LESS THAN (TO_DAYS('2026-02-01')),
PARTITION p202602 VALUES LESS THAN (TO_DAYS('2026-03-01')),
-- ...
);
3.3 优缺点
| 优点 | 缺点 |
|---|---|
| 实现简单,无需额外中间件 | 时效性差(取决于扫描间隔) |
| 数据库天然持久化,不丢数据 | 订单量巨大时扫描压力大 |
| 幂等控制简单(WHERE status=0) | 多实例需处理并发竞争 |
| 便于运维排查 | CPU 峰值可能集中在扫描时刻 |
四、方案二:Redis 过期回调 + 延迟队列
4.1 Redis Keyspace Notifications(过期回调)
原理:利用 Redis 的 key 过期事件通知,当订单关联的 key 过期时触发取消逻辑。
go
// redis_worker/expire_listener.go
package redis_worker
import (
"context"
"encoding/json"
"github.com/redis/go-redis/v9"
"log"
"time"
)
type ExpireListener struct {
rdb *redis.Client
db *sql.DB // 数据库连接,用于回查确认
}
func NewExpireListener(rdb *redis.Client, db *sql.DB) *ExpireListener {
return &ExpireListener{rdb: rdb, db: db}
}
func (l *ExpireListener) Start(ctx context.Context) {
// ★ 配置 Redis 开启键空间通知
// redis-cli> CONFIG SET notify-keyspace-events Ex
pubsub := l.rdb.PSubscribe(ctx, "__keyevent@0__:expired")
defer pubsub.Close()
ch := pubsub.Channel()
for msg := range ch {
// msg.Payload 就是过期的 key
l.handleExpiredKey(msg.Payload)
}
}
func (l *ExpireListener) handleExpiredKey(key string) {
// key 格式:order:expire:{orderId}
var orderID int64
_, err := fmt.Sscanf(key, "order:expire:%d", &orderID)
if err != nil {
return
}
// ★ 必须回查数据库确认订单状态(Redis 通知不可靠,可能丢失)
l.cancelOrder(orderID)
}
// 创建订单时设置 Redis key
func (l *ExpireListener) SetOrderExpire(orderID int64, expireAt time.Time) error {
key := fmt.Sprintf("order:expire:%d", orderID)
ttl := time.Until(expireAt)
return l.rdb.Set(context.Background(), key, "1", ttl).Err()
}
4.2 Redis ZSET 延迟队列(更可靠)
原理:使用 Redis Sorted Set,score 为过期时间戳,定时轮询到期的订单。
go
// redis_worker/delay_queue.go
package redis_worker
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"log"
"time"
)
const delayQueueKey = "order:delay_queue"
type DelayQueue struct {
rdb *redis.Client
}
// 下单时将订单加入延迟队列
func (q *DelayQueue) AddOrder(ctx context.Context, orderID int64, expireAt time.Time) error {
score := float64(expireAt.UnixMilli())
member := fmt.Sprintf("%d", orderID)
return q.rdb.ZAdd(ctx, delayQueueKey, redis.Z{
Score: score,
Member: member,
}).Err()
}
// 支付成功时从延迟队列移除
func (q *DelayQueue) RemoveOrder(ctx context.Context, orderID int64) error {
return q.rdb.ZRem(ctx, delayQueueKey, fmt.Sprintf("%d", orderID)).Err()
}
// 定时扫描到期的订单
func (q *DelayQueue) Poll(ctx context.Context) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for range ticker.C {
now := float64(time.Now().UnixMilli())
// ★ ZRANGEBYSCORE 获取到期的订单
members, err := q.rdb.ZRangeByScore(ctx, delayQueueKey, &redis.ZRangeBy{
Min: "0",
Max: fmt.Sprintf("%.0f", now),
}).Result()
if err != nil || len(members) == 0 {
continue
}
// 批量删除已取出的订单(原子操作)
pipe := q.rdb.Pipeline()
for _, m := range members {
pipe.ZRem(ctx, delayQueueKey, m)
}
results, _ := pipe.Exec(ctx)
// 处理成功移除的订单
for i, result := range results {
if result.Err() == nil {
// 异步取消订单
go q.handleExpiredOrder(members[i])
}
}
}
}
func (q *DelayQueue) handleExpiredOrder(orderIDStr string) {
log.Printf("Processing expired order: %s", orderIDStr)
// 更新数据库订单状态 + 释放库存
}
4.3 优缺点
| 优点 | 缺点 |
|---|---|
| 延迟可控(秒级) | Keyspace 通知不可靠(可能丢失) |
| 解耦数据库,扫描压力小 | 需额外维护 Redis 集群 |
| ZSET 方案可靠性高 | 内存占用随订单量增长 |
| 适合中等规模 | Redis 宕机丢失队列数据(需持久化+补偿) |
五、方案三:RocketMQ / RabbitMQ 延迟消息
5.1 RocketMQ 延迟消息(推荐)
RocketMQ 原生支持 18 个延迟级别(1s/5s/10s/30s/1m/2m/3m/4m/5m/6m/7m/8m/9m/10m/20m/30m/1h/2h)。
注意 :如需自定义 30 分钟延迟,可在配置中增加
messageDelayLevel。
go
// mq/rocketmq_producer.go
package mq
import (
"context"
"encoding/json"
"github.com/apache/rocketmq-client-go/v2"
"github.com/apache/rocketmq-client-go/v2/primitive"
"github.com/apache/rocketmq-client-go/v2/producer"
)
type OrderDelayProducer struct {
producer rocketmq.Producer
}
func NewOrderDelayProducer(nameServers []string) (*OrderDelayProducer, error) {
p, err := rocketmq.NewProducer(
producer.WithNameServer(nameServers),
producer.WithGroupName("order_delay_group"),
producer.WithRetry(2),
)
if err != nil {
return nil, err
}
if err := p.Start(); err != nil {
return nil, err
}
return &OrderDelayProducer{producer: p}, nil
}
type DelayMessage struct {
OrderID int64 `json:"order_id"`
OrderNo string `json:"order_no"`
UserID int64 `json:"user_id"`
Version int64 `json:"version"` // 乐观锁版本号,防止重复消费
}
// 下单时发送延迟消息
func (p *OrderDelayProducer) SendOrderDelay(ctx context.Context, msg DelayMessage) error {
body, _ := json.Marshal(msg)
m := primitive.NewMessage("order-delay-topic", body)
// ★ 设置延迟级别
// Level 18 = 30分钟(需要自定义 messageDelayLevel)
m.WithDelayTimeLevel(18)
// 或者使用新版 API 设置任意延迟时间
// m.WithDeliverTimeMs(time.Now().Add(30 * time.Minute).UnixMilli())
// 设置 key 方便消息查询
m.WithKeys([]string{msg.OrderNo})
_, err := p.producer.SendSync(ctx, m)
return err
}
go
// mq/rocketmq_consumer.go
package mq
import (
"context"
"database/sql"
"encoding/json"
"github.com/apache/rocketmq-client-go/v2/consumer"
"github.com/apache/rocketmq-client-go/v2/primitive"
"log"
)
type OrderDelayConsumer struct {
db *sql.DB
}
func NewOrderDelayConsumer(db *sql.DB, nameServers []string) error {
c, err := rocketmq.NewPushConsumer(
consumer.WithNameServer(nameServers),
consumer.WithGroupName("order_delay_consumer"),
)
if err != nil {
return err
}
handler := &OrderDelayConsumer{db: db}
err = c.Subscribe("order-delay-topic", consumer.MessageSelector{}, handler.Handle)
if err != nil {
return err
}
return c.Start()
}
// Handle 消费延迟消息
func (h *OrderDelayConsumer) Handle(ctx context.Context, msgs ...*primitive.MessageExt) (
consumer.ConsumeResult, error,
) {
for _, msg := range msgs {
var dm DelayMessage
if err := json.Unmarshal(msg.Body, &dm); err != nil {
log.Printf("unmarshal delay msg error: %v", err)
continue
}
// ★ 幂等处理:检查并取消订单
if err := h.cancelIfUnpaid(ctx, dm); err != nil {
log.Printf("cancel order %d failed: %v", dm.OrderID, err)
// 根据错误类型决定是否重试
return consumer.ConsumeRetryLater, nil
}
}
return consumer.ConsumeSuccess, nil
}
func (h *OrderDelayConsumer) cancelIfUnpaid(ctx context.Context, dm DelayMessage) error {
tx, err := h.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// ★ 乐观锁 + 状态判断保证幂等
result, err := tx.ExecContext(ctx, `
UPDATE orders
SET status = 2, updated_at = NOW()
WHERE id = ?
AND status = 0
AND version = ?
`, dm.OrderID, dm.Version)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
// 订单可能已支付或版本号不匹配(已被处理),直接消费成功
return tx.Commit()
}
// 释放库存
_, err = tx.ExecContext(ctx, `
UPDATE inventory i
JOIN order_items oi ON i.sku_id = oi.sku_id
SET i.stock = i.stock + oi.quantity,
i.locked_stock = i.locked_stock - oi.quantity
WHERE oi.order_id = ?
`, dm.OrderID)
if err != nil {
return err
}
return tx.Commit()
}
5.2 RabbitMQ 死信队列方案
scss
┌──────────────────────────────────────────────────────┐
│ RabbitMQ 延迟消息流程 │
│ │
│ Producer ──► Normal Exchange ──► Normal Queue │
│ (无消费者, 消息会过期) │
│ │ │
│ │ x-message-ttl │
│ │ x-dead-letter- │
│ │ exchange │
│ ▼ │
│ DLX Exchange │
│ │ │
│ ▼ │
│ DLX Queue (有消费者) │
│ │ │
│ ▼ │
│ Consumer 处理取消 │
└──────────────────────────────────────────────────────┘
go
// mq/rabbitmq_setup.go
package mq
import (
"github.com/streadway/amqp"
)
func SetupDelayQueues(ch *amqp.Channel) error {
// 1. 声明死信交换机(延迟消息的最终投递目标)
dlxExchange := "order.dlx.exchange"
ch.ExchangeDeclare(dlxExchange, "direct", true, false, false, false, nil)
// 2. 声明死信队列(消费者实际监听的队列)
dlxQueue := "order.dlx.queue"
ch.QueueDeclare(dlxQueue, true, false, false, false, nil)
ch.QueueBind(dlxQueue, "order.cancel", dlxExchange, false, nil)
// 3. 声明延迟交换机(消息先发到这里)
delayExchange := "order.delay.exchange"
ch.ExchangeDeclare(delayExchange, "direct", true, false, false, false, nil)
// 4. 声明延迟队列(设置 TTL 和死信交换机,无消费者)
args := amqp.Table{
"x-message-ttl": int32(30 * 60 * 1000), // 30 分钟
"x-dead-letter-exchange": dlxExchange,
"x-dead-letter-routing-key": "order.cancel",
}
delayQueue := "order.delay.queue.30m"
ch.QueueDeclare(delayQueue, true, false, false, false, args)
ch.QueueBind(delayQueue, "order.delay.30m", delayExchange, false, nil)
return nil
}
// 发送延迟取消消息
func PublishDelayOrder(ch *amqp.Channel, orderID int64) error {
return ch.Publish(
"order.delay.exchange",
"order.delay.30m",
false, false,
amqp.Publishing{
ContentType: "application/json",
Body: []byte(fmt.Sprintf(`{"order_id":%d}`, orderID)),
DeliveryMode: amqp.Persistent,
},
)
}
// 消费者(监听死信队列)
func ConsumeCancelOrder(ch *amqp.Channel) error {
msgs, err := ch.Consume(
"order.dlx.queue",
"order-cancel-consumer",
false, // autoAck = false,手动确认
false, false, false, nil,
)
if err != nil {
return err
}
go func() {
for msg := range msgs {
// 处理取消逻辑
if handleCancel(msg.Body) {
msg.Ack(false)
} else {
msg.Nack(false, true) // 重试
}
}
}()
return nil
}
5.3 优缺点
| 优点 | 缺点 |
|---|---|
| RocketMQ: 精准延迟,可靠性极高 | 需部署维护 MQ 集群 |
| 解耦数据库,异步处理 | RocketMQ 延迟级别有限(18个),需自定义 |
| 天然支持重试和死信 | RabbitMQ 队列固定 TTL,不同延迟需不同队列 |
| 适合大规模、高可靠性场景 | 消息堆积时影响延迟精度 |
六、方案四:时间轮算法(Time Wheel)
6.1 原理
在内存中维护一个环形数组(时间槽),每个槽指向到期任务链表,定时指针转动触发到期任务。
markdown
┌───┐
│ 0 │──► TaskA ──► TaskB
│ 1 │
│ 2 │──► TaskC
tick │...│
│59 │
│60 │──► TaskD ──► TaskE ← 当前指针
│...│
└───┘
一圈 = 60 个槽, 每槽 1s
go
// timewheel/timewheel.go
package timewheel
import (
"container/list"
"sync"
"time"
)
type Task struct {
ID int64
Circle int // 还剩几圈
ExpireFunc func() // 到期执行函数
}
type TimeWheel struct {
mu sync.Mutex
slots []*list.List // 时间槽
slotNum int // 槽数量
tick time.Duration
curSlot int // 当前指针位置
ticker *time.Ticker
stopCh chan struct{}
}
func New(slotNum int, tick time.Duration) *TimeWheel {
tw := &TimeWheel{
slots: make([]*list.List, slotNum),
slotNum: slotNum,
tick: tick,
stopCh: make(chan struct{}),
}
for i := 0; i < slotNum; i++ {
tw.slots[i] = list.New()
}
return tw
}
func (tw *TimeWheel) Start() {
tw.ticker = time.NewTicker(tw.tick)
go func() {
for {
select {
case <-tw.ticker.C:
tw.executeSlot()
case <-tw.stopCh:
tw.ticker.Stop()
return
}
}
}()
}
func (tw *TimeWheel) Stop() {
close(tw.stopCh)
}
// AddTask 添加延迟任务
// delay: 延迟时长
func (tw *TimeWheel) AddTask(task *Task, delay time.Duration) {
tw.mu.Lock()
defer tw.mu.Unlock()
// 计算位置:index 和 圈数
slotDelta := int(delay / tw.tick)
task.Circle = slotDelta / tw.slotNum
slotIndex := (tw.curSlot + slotDelta) % tw.slotNum
tw.slots[slotIndex].PushBack(task)
}
func (tw *TimeWheel) executeSlot() {
tw.mu.Lock()
defer tw.mu.Unlock()
slot := tw.slots[tw.curSlot]
if slot.Len() > 0 {
var next *list.Element
for e := slot.Front(); e != nil; e = next {
next = e.Next()
task := e.Value.(*Task)
if task.Circle > 0 {
task.Circle--
continue
}
// 执行到期任务
slot.Remove(e)
go task.ExpireFunc()
}
}
tw.curSlot = (tw.curSlot + 1) % tw.slotNum
}
6.2 使用示例
go
// 在订单服务中使用时间轮
func main() {
tw := timewheel.New(3600, time.Second) // 3600 槽,每槽 1s = 1h 一圈
// 启动时间轮
tw.Start()
defer tw.Stop()
// 下单时添加延迟取消任务
task := &timewheel.Task{
ID: orderID,
ExpireFunc: func() {
// ★ 必须回查数据库确认状态
cancelOrder(orderID)
},
}
tw.AddTask(task, 30*time.Minute)
}
6.3 优缺点
| 优点 | 缺点 |
|---|---|
| 毫秒级精度 | 纯内存,进程重启丢失所有任务 |
| 零数据库压力 | 需自行实现持久化和故障恢复 |
| 高性能,适合大量短时任务 | 实现复杂度高 |
| 无外部中间件依赖 | 不适用于需要严格可靠性的订单场景 |
结论 :时间轮适合"丢了也可以"的场景(如心跳检测、连接超时),不适合订单取消(丢失=资金损失)。订单场景下需配合数据库持久化补偿。
七、方案五:MySQL Event Scheduler(原生定时任务)
7.1 原理
使用 MySQL 内置的事件调度器,定时执行 SQL 取消过期订单。
sql
-- 开启事件调度器
SET GLOBAL event_scheduler = ON;
-- 创建定时事件(每 10 秒执行)
CREATE EVENT IF NOT EXISTS auto_cancel_expired_orders
ON SCHEDULE EVERY 10 SECOND
DO
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE order_id BIGINT;
DECLARE cur CURSOR FOR
SELECT id FROM orders
WHERE status = 0 AND expire_time <= NOW()
LIMIT 100;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur;
read_loop: LOOP
FETCH cur INTO order_id;
IF done THEN
LEAVE read_loop;
END IF;
-- 取消订单
UPDATE orders SET status = 2, updated_at = NOW()
WHERE id = order_id AND status = 0;
-- 释放库存(可写存储过程或触发应用层回调)
UPDATE inventory i
INNER JOIN order_items oi ON i.sku_id = oi.sku_id
SET i.stock = i.stock + oi.quantity,
i.locked_stock = i.locked_stock - oi.quantity
WHERE oi.order_id = order_id;
END LOOP;
CLOSE cur;
END;
7.2 优缺点
| 优点 | 缺点 |
|---|---|
| 零代码,SQL 即可实现 | 业务逻辑复杂时难以维护 |
| 数据库级别执行,不依赖应用 | 无法触发应用层逻辑(如发通知) |
| 无需额外部署 | 单点,主库压力大 |
| 适合极简场景 | 难以监控和Debug |
结论:仅适合小项目原型阶段,生产环境不推荐。
八、方案六:状态机 + 定时扫表优化
8.1 原理
将订单状态流转建模为状态机,结合索引优化 + 分批次 + 多实例分布式锁,使传统的 DB 轮询方案也能支撑较大规模。
objectivec
┌─────────┐
│ PENDING │ (待支付)
└────┬────┘
│
┌──────────┼──────────┐
│ 用户支付 │ 超时取消 │ 手动取消
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ PAID │ │CANCELLED│ │CANCELLED│
└────────┘ └────────┘ └────────┘
go
// scanner/distributed_scanner.go
package scanner
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/go-redsync/redsync/v4"
)
type DistributedOrderScanner struct {
db *sql.DB
rs *redsync.Redsync
nodeID string
}
func (s *DistributedOrderScanner) Run(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.safeScan(ctx)
case <-ctx.Done():
return
}
}
}
// safeScan 用分布式锁保证同一时刻只有一个实例扫描
func (s *DistributedOrderScanner) safeScan(ctx context.Context) {
mutex := s.rs.NewMutex(
"lock:order:cancel:scanner",
redsync.WithExpiry(30*time.Second),
)
// ★ 获取分布式锁
if err := mutex.Lock(); err != nil {
// 其他实例正在扫描,跳过
return
}
defer mutex.Unlock()
s.scanAndCancel(ctx)
}
func (s *DistributedOrderScanner) scanAndCancel(ctx context.Context) {
// 使用游标分页避免深分页问题
var lastID int64 = 0
batchSize := 200
for {
rows, err := s.db.QueryContext(ctx, `
SELECT id, order_no
FROM orders
WHERE status = 0
AND expire_time <= NOW()
AND id > ?
ORDER BY id
LIMIT ?
`, lastID, batchSize)
if err != nil {
return
}
count := 0
for rows.Next() {
var id int64
var orderNo string
rows.Scan(&id, &orderNo)
// 单条更新,避免长事务
s.cancelOne(ctx, id, orderNo)
lastID = id
count++
}
rows.Close()
if count < batchSize {
break // 没有更多数据
}
}
}
func (s *DistributedOrderScanner) cancelOne(ctx context.Context, id int64, orderNo string) {
result, err := s.db.ExecContext(ctx, `
UPDATE orders
SET status = 2, updated_at = NOW()
WHERE id = ? AND status = 0
`, id)
if err != nil {
return
}
if n, _ := result.RowsAffected(); n > 0 {
s.releaseInventory(ctx, id)
s.sendNotification(ctx, orderNo)
}
}
func (s *DistributedOrderScanner) releaseInventory(ctx context.Context, orderID int64) {
// 库存释放,需要幂等
s.db.ExecContext(ctx, `
UPDATE inventory i
INNER JOIN order_items oi ON i.sku_id = oi.sku_id AND oi.order_id = ?
SET i.stock = i.stock + oi.quantity,
i.locked_stock = i.locked_stock - oi.quantity
`, orderID)
}
func (s *DistributedOrderScanner) sendNotification(ctx context.Context, orderNo string) {
// 发站内信 / 推送通知
}
8.2 优缺点
| 优点 | 缺点 |
|---|---|
| 无需额外中间件,纯 DB + 代码 | 仍有扫描间隔延迟 |
| 分布式锁解决多实例竞争 | 订单量千万级时性能下降 |
| 游标分页避免深分页问题 | 需维护分布式锁 |
| 实现可控,易于调试 | - |
九、方案七:分布式定时任务框架
9.1 XXL-JOB / 羚羊(自研调度平台)
go
// xxl_job/order_cancel_handler.go
package xxl_job
import (
"context"
"database/sql"
"log"
"time"
)
// XXL-JOB Go 客户端 handler 示例
// 如果公司用羚羊(Antelope),API 类似,大同小异
type OrderCancelJob struct {
db *sql.DB
}
// Execute 每 10 秒由 XXL-JOB 调度中心触发
func (j *OrderCancelJob) Execute(ctx context.Context) error {
log.Println("[XXL-JOB] order_cancel_job start")
// 使用上面定义的分布式扫描逻辑
scanner := &DistributedOrderScanner{
db: j.db,
nodeID: GetWorkerID(), // XXL-JOB 的 worker ID
}
scanner.scanAndCancel(ctx)
log.Println("[XXL-JOB] order_cancel_job finish")
return nil
}
// 注册到 XXL-JOB
func InitXXLJob(db *sql.DB) {
job := &OrderCancelJob{db: db}
executor := xxl.NewExecutor(
xxl.ServerAddr("http://xxl-job-admin:8080/xxl-job-admin"),
xxl.AccessToken("your_token"),
xxl.ExecutorPort(9999),
xxl.RegistryKey("order-service"),
)
executor.RegTask("orderCancelJob", job.Execute)
executor.Run()
}
9.2 调度平台优势
css
┌─────────────────────────────────────────────────────┐
│ XXL-JOB / 羚羊 调度中心 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 任务管理 │ │ 调度策略 │ │ 报警监控 │ │
│ │ CRON/固定 │ │ 分片广播 │ │ 失败重试 │ │
│ │ 频率/API │ │ 故障转移 │ │ 邮件通知 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ 执行器集群 (order-service) │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │Worker 1 │ │Worker 2 │ │Worker 3 │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
9.3 分片广播(处理海量订单)
go
// 利用 XXL-JOB 分片广播,将数据按 id 取模分给不同 worker
func (j *OrderCancelJob) ExecuteSharding(ctx context.Context) error {
shardIndex := xxl.GetShardIndex() // 当前分片索引,如 0/1/2
shardTotal := xxl.GetShardTotal() // 总分片数,如 3
// 方案 A:按订单 id 取模
rows, err := j.db.QueryContext(ctx, `
SELECT id, order_no FROM orders
WHERE status = 0
AND expire_time <= NOW()
AND id % ? = ?
LIMIT 200
`, shardTotal, shardIndex)
// 方案 B:按 user_id 取模(分散更均匀)
// WHERE user_id % ? = ?
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int64
var orderNo string
rows.Scan(&id, &orderNo)
j.cancelOne(ctx, id, orderNo)
}
return nil
}
9.4 优缺点
| 优点 | 缺点 |
|---|---|
| 自带调度中心、监控、报警 | 引入框架依赖 |
| 分片广播解决大表扫描 | 需要部署调度中心 |
| 失败重试、任务日志完善 | 短间隔扫描可能对调度中心造成压力 |
| 适合公司已有调度平台的场景 | - |
十、各方案横向对比与选型建议
10.1 横向对比
vbnet
方案 实时性 可靠性 实现复杂度 运维成本 适用量级
────────────────────────────────────────────────────────────────
DB 定时轮询 ★★☆☆☆ ★★★★☆ ★☆☆☆☆ ★★★☆☆ < 10万/天
DB 轮询 + 优化 ★★☆☆☆ ★★★★☆ ★★☆☆☆ ★★★☆☆ < 100万/天
Redis 过期回调 ★★★★☆ ★★★☆☆ ★★★☆☆ ★★★★☆ < 50万/天
Redis ZSET 延迟队列 ★★★★☆ ★★★★☆ ★★★☆☆ ★★★★☆ < 100万/天
RocketMQ 延迟消息 ★★★★★ ★★★★★ ★★★★☆ ★★★★☆ > 100万/天
RabbitMQ 死信队列 ★★★★★ ★★★★★ ★★★★☆ ★★★★☆ > 100万/天
时间轮 ★★★★★ ★☆☆☆☆ ★★★★★ ★★☆☆☆ < 10万/天
MySQL Event ★★☆☆☆ ★★★☆☆ ★☆☆☆☆ ★★☆☆☆ < 1万/天
分布式调度 + DB 扫描 ★★★☆☆ ★★★★★ ★★★☆☆ ★★★☆☆ < 200万/天
10.2 选型决策树
markdown
是否需要秒级精度?
├─ 是 ──► 公司已有 RocketMQ/RabbitMQ?
│ ├─ 是 ──► 方案三:MQ 延迟消息 ★推荐
│ └─ 否 ──► 已部署 Redis 集群?
│ ├─ 是 ──► 方案二:Redis ZSET 延迟队列
│ └─ 否 ──► 方案九:分布式调度 + 小间隔扫描
│
└─ 否(分钟级可接受)──► 订单量 > 100万/天?
├─ 是 ──► 方案六:分布式调度 + DB 扫描优化
└─ 否 ──► 方案一:简单 DB 扫描
10.3 推荐组合(生产环境最佳实践)
推荐:RocketMQ 延迟消息(主) + 定时扫表(兜底)
arduino
┌─────────────────────────────────────────────────────┐
│ 双重保障架构 │
│ │
│ 下单 ──► 发送 RocketMQ 延迟消息 30min │
│ │ │
│ └──► 同时写入 expire_time 字段到 DB │
│ │
│ 正常路径:RocketMQ 到点投递 → Consumer 取消订单 │
│ 兜底路径:定时任务每 1min 扫表 → 处理漏网之鱼 │
│ │
│ 支付路径:支付回调 → 更新状态 + 从 MQ 消息不算消费 │
│ 或 Redis ZRem 删除 │
└─────────────────────────────────────────────────────┘
十一、生产环境进阶与最佳实践
11.1 幂等性保障
go
// 1. 数据库唯一约束 / 乐观锁
UPDATE orders SET status = 2, version = version + 1
WHERE id = ? AND status = 0 AND version = ?
// 2. 取消记录表(防重)
CREATE TABLE order_cancel_log (
order_id BIGINT PRIMARY KEY,
reason VARCHAR(32),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
// 3. 分布式锁
func cancelWithLock(orderID int64) error {
key := fmt.Sprintf("cancel:order:%d", orderID)
ok, _ := rdb.SetNX(ctx, key, "1", 10*time.Second).Result()
if !ok {
return nil // 其他实例/线程正在处理
}
defer rdb.Del(ctx, key)
return doCancel(orderID)
}
11.2 消息可靠性保证
go
// RocketMQ 消费端手动确认 + 死信队列
// 消费失败重试 16 次后进入死信队列,人工介入
// 定死信队列监控告警
func monitorDLQ() {
// 死信队列中有消息 → 钉钉/飞书告警
}
// 补偿任务:每天凌晨全量扫一次昨天应该取消但未取消的订单
func dailyCompensationTask() {
query := `
SELECT id FROM orders
WHERE status = 0
AND expire_time < DATE_SUB(NOW(), INTERVAL 1 HOUR)
`
// ... 批量处理
}
11.3 库存释放的并发安全
go
// 使用数据库行锁 + 条件更新,避免超卖
func releaseInventory(ctx context.Context, db *sql.DB, orderID int64) error {
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
// 1. 查询订单已锁定的库存(快照在 order_items 表)
rows, _ := tx.QueryContext(ctx, `
SELECT sku_id, quantity FROM order_items WHERE order_id = ?
`, orderID)
defer rows.Close()
type Item struct {
SkuID int64
Quantity int
}
var items []Item
for rows.Next() {
var item Item
rows.Scan(&item.SkuID, &item.Quantity)
items = append(items, item)
}
// 2. 逐条释放库存
for _, item := range items {
result, err := tx.ExecContext(ctx, `
UPDATE inventory
SET stock = stock + ?,
locked_stock = GREATEST(locked_stock - ?, 0)
WHERE sku_id = ? AND locked_stock >= ?
`, item.Quantity, item.Quantity, item.SkuID, item.Quantity)
if err != nil {
return err
}
if n, _ := result.RowsAffected(); n == 0 {
// 库存状态异常,记录日志告警
log.Printf("inventory release failed: sku=%d qty=%d",
item.SkuID, item.Quantity)
}
}
return tx.Commit()
}
11.4 监控与告警
go
// Prometheus 指标
var (
orderCancelTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "order_cancel_total",
Help: "Total cancelled orders",
},
[]string{"reason"}, // timeout, manual, etc.
)
orderCancelLatency = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "order_cancel_latency_seconds",
Help: "Cancel order latency",
Buckets: []float64{0.01, 0.05, 0.1, 0.5, 1, 5},
},
[]string{"method"}, // mq, scanner, etc.
)
orderExpiredNotCancelled = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "order_expired_not_cancelled_count",
Help: "Expired orders not yet cancelled",
},
)
)
// 定期上报积压量
func reportBacklog() {
var count int
db.QueryRow(`
SELECT COUNT(*) FROM orders
WHERE status = 0 AND expire_time <= NOW()
`).Scan(&count)
orderExpiredNotCancelled.Set(float64(count))
}
11.5 最终架构总结
ini
┌──────────────────┐
│ 订单创建 │
└────────┬─────────┘
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌────────┐ ┌──────────┐
│写入 DB │ │RocketMQ│ │Redis ZSET│
│(主存储) │ │延迟消息 │ │延迟队列 │
└──────────┘ └────┬───┘ └────┬─────┘
│ │
30分钟后 │ │ 轮询
▼ ▼
┌────────────────────────────┐
│ 消费者 / 取消处理器 │
│ 1. 幂等检查 (status=0?) │
│ 2. 乐观锁更新 │
│ 3. 释放库存 │
│ 4. 发通知/记录日志 │
└────────────────────────────┘
│
失败 / 漏处理
│
▼
┌────────────────────────────┐
│ 兜底扫表任务 (每分钟) │
│ SELECT ... WHERE │
│ status=0 AND expired │
└────────────────────────────┘
│
▼
┌────────────────────────────┐
│ 每日全量补偿 (凌晨) │
│ 全量扫昨天未取消订单 │
└────────────────────────────┘
最终建议:
- 小项目 / 初创期:方案一(DB 定时轮询),10 分钟上手,后续平滑升级。
- 中型项目(10万~100万单/天):方案二(Redis ZSET 延迟队列),实时性秒级,实现简单。
- 大型项目(100万+单/天,要求高可靠):方案三(RocketMQ 延迟消息) + 方案一兜底扫表,双重保障。
- 关键原则:永远不要只用内存方案(时间轮、纯 Redis),必须配合数据库持久化兜底。订单是钱,钱不能丢。