订单超时自动取消方案详解

文档覆盖的 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),必须配合数据库持久化兜底。订单是钱,钱不能丢。
相关推荐
java1234_小锋1 小时前
SpringBoot可以同时处理多少请求?
java·spring boot·后端
12344521 小时前
网络IO模型
后端·操作系统
用户467245132231 小时前
synchronized的"双重人格":静态与非静态方法锁的惊天差异
后端
胡志辉2 小时前
Nginx CVE‑2026‑42945:隐藏18年高危漏洞被曝光(附解决方案)
前端·后端·nginx
BestHeaker2 小时前
CC Switch 全能使用教程
后端·职场和发展·跳槽·学习方法
折哥的程序人生 · 物流技术专研2 小时前
Java面试85题图解版 · 全系列总目录
java·开发语言·后端·面试·职场和发展
海棠Flower未眠2 小时前
Spring Boot 3 + JPA多模块系统对MySQL和DORIS进行多数据源集成实战(荣耀典藏版)
spring boot·后端·mysql
武子康2 小时前
Java-01 深入浅出 MyBatis 入门与核心原理:半自动 ORM 框架详解
java·后端·mybatis
木易 士心2 小时前
Java 跳出多层循环
java·开发语言·后端