Files
huang e661b59bb9
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m58s
feat: 实现订单超时自动取消功能,支持钱包余额解冻和 Asynq Scheduler 统一调度
- 新增 expires_at 字段和复合索引,待支付订单 30 分钟超时自动取消
- 实现 cancelOrder/unfreezeWalletForCancel 钱包余额解冻逻辑
- 创建 Asynq 定时任务(order_expire/alert_check/data_cleanup)
- 将原有 time.Ticker 轮询迁移至 Asynq Scheduler 统一调度
- 同步 delta specs 到 main specs 并归档变更
2026-02-28 17:16:15 +08:00

23 KiB
Raw Permalink Blame History

Context

当前系统中待支付订单创建后不会自动失效,虽然 iot-orderorder-payment 规格文档中提到了超时取消机制,但实际代码中完全未实现。这导致:

  1. 数据库膨胀:大量"僵尸订单"(待支付但永不支付)占用存储空间
  2. 用户体验差:无法明确订单是否有效,用户可能尝试支付已过期订单
  3. 资源浪费:钱包余额被冻结但订单永不完成(混合支付场景)
  4. 数据质量低:订单统计数据不准确(包含大量永不完成的订单)

现有实现

  • tb_order 表缺少 expires_at 字段
  • 无超时相关的 Asynq 定时任务
  • OrderService.Cancel() 方法不支持钱包解冻
  • 无超时相关常量定义

技术栈

  • Asynq v0.24.x 任务队列(已用于佣金计算、轮询等异步任务)
  • GORM v1.25.x ORM
  • PostgreSQL 14+(已有索引优化经验)
  • Redis 6.0+(已用于分布式锁、缓存)

Goals / Non-Goals

Goals:

  1. 实现订单 30 分钟超时自动取消机制
  2. 支持钱包余额自动解冻(混合支付/H5 钱包支付场景)
  3. 提供过期状态查询和筛选功能
  4. 性能符合要求(定时任务查询 < 50ms单批处理 < 5s
  5. 支持数据库迁移和回滚
  6. 不影响现有订单业务逻辑

Non-Goals:

  1. 不支持可配置的超时时间(固定 30 分钟)
  2. 不支持订单续期(延长过期时间)
  3. 不发送超时提醒通知(后续可扩展)
  4. 不处理已支付订单的退款超时(不在本次范围)
  5. 不修改第三方支付回调逻辑(已有幂等保证)

Decisions

Decision 1: 数据库字段设计

选择: 新增 expires_at TIMESTAMP NULL 字段到 tb_order

理由:

  • NULL 语义:已支付/已取消/已退款订单无需过期时间,设为 NULL 节省存储
  • TIMESTAMP 类型:支持时区,精度到秒(超时 30 分钟,秒级精度足够)
  • 索引设计:复合索引 idx_order_expires(expires_at, payment_status) 优化定时任务查询

替代方案:

  • 使用 expired_at 字段名不符合业务语义expires_at 表示"何时过期"expired_at 表示"何时已过期"
  • 使用 INT 存储 Unix 时间戳:可读性差,不利于 SQL 调试
  • 使用单列索引 idx_expires_at性能不如复合索引WHERE 条件包含 payment_status

数据迁移策略:

  • 迁移时已存在的订单 expires_at 初始化为 NULL
  • 不对历史待支付订单设置过期时间(避免批量取消历史订单)
  • 新创建的待支付订单才设置过期时间

Decision 2: 定时任务实现方式

选择: 使用 Asynq 的 Scheduler周期任务调度器每分钟执行一次

理由:

  • 架构统一性:项目已使用 Asynq 作为任务队列基础设施,定时任务也应统一使用 Asynq Scheduler而非 time.Ticker
  • 分布式支持:多 Worker 部署时,通过 Redis 分布式锁确保任务只执行一次,避免重复处理超时订单
  • 任务持久化:任务记录在 Redis支持查询执行历史、监控失败率
  • 自动重试:支持任务失败自动重试(可配置重试次数和延迟)
  • 无额外依赖:复用现有 Redis 基础设施
  • 未来扩展性:为项目中现有的 time.Ticker 定时任务(告警检查器、数据清理)迁移到 Asynq 提供范例

替代方案:

  • 使用 time.Ticker/time.Timer:虽然简单,但多 Worker 部署时会重复执行,且无任务持久化和执行历史
  • 使用 PostgreSQL pg_cron 扩展:增加数据库负载,不符合项目架构(业务逻辑在应用层)
  • 使用独立的 Cron 服务:增加运维复杂度,技术栈碎片化

实现步骤:

  1. 创建 Asynq Scheduler 实例cmd/worker/main.go

    // 创建 Asynq Scheduler
    asynqScheduler := asynq.NewScheduler(
        asynq.RedisClientOpt{
            Addr:     redisAddr,
            Password: cfg.Redis.Password,
            DB:       cfg.Redis.DB,
        },
        &asynq.SchedulerOpts{
            Location: time.Local, // 使用本地时区
        },
    )
    
  2. 注册周期任务

    // 注册订单超时检查任务(每分钟执行)
    _, err := asynqScheduler.Register(
        "@every 1m",  // cron 表达式:每分钟
        asynq.NewTask(constants.TaskTypeOrderExpire, nil),
        asynq.Queue(constants.QueueDefault),
    )
    if err != nil {
        appLogger.Fatal("注册订单超时任务失败", zap.Error(err))
    }
    
  3. 启动 Scheduler

    if err := asynqScheduler.Start(); err != nil {
        appLogger.Fatal("启动 Asynq Scheduler 失败", zap.Error(err))
    }
    defer asynqScheduler.Shutdown()
    
  4. 创建 Task Handlerinternal/task/order_expire.go

    type OrderExpireHandler struct {
        orderService *order.Service
        logger       *zap.Logger
    }
    
    func (h *OrderExpireHandler) HandleOrderExpire(ctx context.Context, task *asynq.Task) error {
        count, err := h.orderService.CancelExpiredOrders(ctx)
        if err != nil {
            h.logger.Error("取消超时订单失败", zap.Error(err))
            return err // 返回错误Asynq 自动重试
        }
    
        if count > 0 {
            h.logger.Info("成功取消超时订单", zap.Int("count", count))
        }
        return nil
    }
    
  5. 注册 Handlerpkg/queue/handler.go

    func (h *Handler) registerOrderExpireHandler() {
        orderExpireHandler := task.NewOrderExpireHandler(
            h.workerResult.Services.OrderService,
            h.logger,
        )
        h.mux.HandleFunc(constants.TaskTypeOrderExpire, orderExpireHandler.HandleOrderExpire)
        h.logger.Info("注册订单超时检查任务处理器", zap.String("task_type", constants.TaskTypeOrderExpire))
    }
    

Decision 3: 批量处理策略

选择: 单次最多处理 100 条订单,使用事务批量更新

理由:

  • 避免单次处理时间过长(单批 < 5s
  • 事务保证订单状态更新和钱包解冻的原子性
  • 超过 100 条的订单在下次任务执行时处理(每分钟执行,延迟可接受)

替代方案:

  • 使用 LIMIT 1000:单批处理时间可能超过 5s影响任务调度
  • 使用分页循环处理:复杂度高,事务范围难控制
  • 不使用事务:订单状态更新和钱包解冻可能不一致

实现细节:

// 单批处理逻辑
func (s *Service) CancelExpiredOrders(ctx context.Context) (int, error) {
    // 1. 查询超时订单(最多 100 条)
    orders, err := s.orderStore.FindExpiredOrders(ctx, 100)

    // 2. 开启事务
    return len(orders), s.db.Transaction(func(tx *gorm.DB) error {
        // 3. 批量更新订单状态
        // 4. 批量解冻钱包余额(如需)
    })
}

Decision 4: 钱包余额解冻逻辑

选择: 在 OrderService.Cancel() 方法中统一处理解冻逻辑,支持手动取消和自动取消两种场景

理由:

  • 代码复用:手动取消和自动取消共用同一解冻逻辑
  • 事务保证:订单状态更新和钱包解冻在同一事务中
  • 支持多种支付方式:钱包支付、混合支付

解冻规则:

支付方式 是否解冻 解冻金额
钱包支付H5 端待支付) total_amount
混合支付 wallet_payment_amount
纯在线支付wechat/alipay -
后台钱包一步支付 - (订单创建时已完成支付)

替代方案:

  • 在定时任务中直接解冻钱包:代码重复,手动取消时需重复实现
  • 不在事务中解冻:可能导致订单已取消但钱包未解冻

实现细节:

func (s *Service) Cancel(ctx context.Context, orderID uint) error {
    return s.db.Transaction(func(tx *gorm.DB) error {
        // 1. 查询订单
        order, err := s.orderStore.GetByID(ctx, orderID)

        // 2. 校验状态(只能取消待支付订单)
        if order.PaymentStatus != model.PaymentStatusPending {
            return errors.New(errors.CodeInvalidParam, "只能取消待支付订单")
        }

        // 3. 更新订单状态
        order.PaymentStatus = model.PaymentStatusCancelled
        order.ExpiresAt = nil

        // 4. 解冻钱包余额(如需)
        if needUnfreeze(order) {
            amount := getUnfreezeAmount(order)
            err := s.walletService.Unfreeze(ctx, tx, order.BuyerType, order.BuyerID, amount)
        }

        return s.orderStore.Update(ctx, tx, order)
    })
}

Decision 5: 订单创建流程修改

选择: 在 OrderService.Create() 方法中,仅对待支付订单设置 expires_at

理由:

  • 后台钱包一步支付订单创建时立即完成支付(payment_status = 2),无需过期时间
  • 线下支付订单offline创建时立即标记为已支付无需过期时间
  • 只有 H5 端或后台创建的待支付订单需要设置过期时间

设置规则:

场景 订单状态 是否设置 expires_at
H5 端创建钱包支付订单 payment_status = 1 now + 30min
H5 端创建在线支付订单wechat/alipay payment_status = 1 now + 30min
H5 端创建混合支付订单 payment_status = 1 now + 30min
后台创建钱包支付订单 payment_status = 2 NULL
后台创建线下支付订单 payment_status = 2 NULL

实现细节:

func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest) (*model.Order, error) {
    order := &model.Order{
        // ... 其他字段
        PaymentStatus: model.PaymentStatusPending,
    }

    // 仅待支付订单设置过期时间
    if order.PaymentStatus == model.PaymentStatusPending {
        expiresAt := time.Now().Add(constants.OrderExpireTimeout)
        order.ExpiresAt = &expiresAt
    }

    // 后台钱包一步支付逻辑
    if req.PaymentMethod == "wallet" && isAdminContext(ctx) {
        // 立即扣款并支付
        order.PaymentStatus = model.PaymentStatusPaid
        order.ExpiresAt = nil // 已支付订单无需过期时间
    }
}

Decision 6: 订单支付成功后清除过期时间

选择: 在订单支付成功时(payment_status 变更为 2expires_at 设置为 NULL

理由:

  • 已支付订单不需要过期时间
  • 避免查询混淆(expires_at IS NOT NULL 可快速筛选待支付订单)
  • 节省存储NULL 值不占用索引空间)

实现位置:

  • OrderService.WalletPay() - H5 端钱包支付成功
  • OrderService.HandlePaymentCallback() - 第三方支付回调成功

实现细节:

func (s *Service) WalletPay(ctx context.Context, orderID uint) error {
    return s.db.Transaction(func(tx *gorm.DB) error {
        // ... 扣款逻辑

        // 更新订单状态并清除过期时间
        err := s.orderStore.UpdatePaymentStatus(ctx, tx, orderID, model.PaymentStatusPaid, time.Now(), nil)
    })
}

Decision 7: 查询过期状态实现方式

选择: 在 DTO 响应中动态计算 is_expired 字段,不存储在数据库

理由:

  • 避免数据冗余(is_expired 可由 expires_at 和当前时间计算得出)
  • 避免定时任务更新 is_expired 字段(增加数据库写负载)
  • 支持按过期状态筛选(查询时使用 SQL 条件 expires_at <= NOW()

替代方案:

  • 在数据库中存储 is_expired 布尔字段:需要定时更新,增加数据库负载
  • 使用数据库视图:不符合项目架构(不使用视图)

实现细节:

// DTO 响应
type OrderResponse struct {
    // ... 其他字段
    ExpiresAt *time.Time `json:"expires_at"`
    IsExpired bool       `json:"is_expired"` // 动态计算
}

// 动态计算逻辑
func buildOrderResponse(order *model.Order) *dto.OrderResponse {
    resp := &dto.OrderResponse{
        ExpiresAt: order.ExpiresAt,
    }

    // 动态计算是否过期
    if order.ExpiresAt != nil && order.PaymentStatus == model.PaymentStatusPending {
        resp.IsExpired = time.Now().After(*order.ExpiresAt)
    }

    return resp
}

// 查询过期订单的 SQL 条件
// WHERE expires_at <= NOW() AND payment_status = 1

Decision 8: 性能优化策略

选择: 使用复合索引 + 批量操作 + 事务优化

优化措施:

  1. 索引优化: 复合索引 idx_order_expires(expires_at, payment_status) 覆盖查询条件
  2. 批量更新: 单 SQL 语句批量更新订单状态(避免 N 次数据库调用)
  3. 批量解冻: 钱包解冻支持批量操作(单事务中处理多个钱包)
  4. 限制批次大小: 单次最多处理 100 条,避免长事务

性能指标:

  • 定时任务查询耗时:< 50ms
  • 单批次处理耗时:< 5s
  • 数据库连接池无阻塞

监控指标:

  • 每次任务处理的订单数量
  • 任务执行耗时
  • 钱包解冻次数
  • 失败订单数量

Decision 9: 错误处理和重试策略

选择: 使用 Asynq 的重试机制,最多重试 3 次

重试策略:

  • 可重试错误数据库连接失败、Redis 连接失败、钱包服务暂时不可用
  • 不可重试错误:数据不一致(如钱包不存在)、业务逻辑错误

实现细节:

func (h *OrderExpireHandler) HandleOrderExpire(ctx context.Context, task *asynq.Task) error {
    count, err := h.service.CancelExpiredOrders(ctx)
    if err != nil {
        h.logger.Error("取消超时订单失败", zap.Error(err))

        // 判断是否可重试
        if isRetryableError(err) {
            return err // 返回错误Asynq 自动重试
        }

        return asynq.SkipRetry // 不可重试错误,跳过重试
    }

    h.logger.Info("取消超时订单成功", zap.Int("count", count))
    return nil
}

Decision 10: 常量定义

选择: 在 pkg/constants/constants.go 中定义超时相关常量

常量列表:

// 订单超时时间30 分钟)
const OrderExpireTimeout = 30 * time.Minute

// 订单超时取消任务类型
const TaskTypeOrderExpire = "order:expire"

// 单批处理订单数量上限
const OrderExpireBatchSize = 100

理由:

  • 统一管理常量,避免硬编码
  • 便于后续调整(如修改超时时间)
  • 符合项目规范(所有常量定义在 pkg/constants/

Decision 11: 重构现有定时任务为 Asynq Scheduler

选择: 将现有的 time.Ticker/time.Timer 定时任务迁移到 Asynq Scheduler

理由:

  • 统一任务调度机制:项目架构设计初衷就是用 Asynq 承载所有任务和定时功能
  • 分布式支持Asynq Scheduler 原生支持多 Worker 分布式执行,避免重复执行
  • 持久化和可靠性:任务存储在 RedisWorker 重启不丢失任务
  • 监控和管理:通过 Asynq Dashboard 统一监控所有定时任务执行状态
  • 代码一致性:避免混用多种定时任务实现方式

迁移范围:

定时任务 当前实现 迁移后
告警检查器 (startAlertChecker) time.NewTicker(1 * time.Minute) Asynq Scheduler @every 1m + TaskTypeAlertCheck
数据清理定时任务 (startCleanupScheduler) time.NewTimer (每天凌晨2点) Asynq Scheduler 0 2 * * * + TaskTypeDataCleanup

对比分析:

特性 time.Ticker/Timer Asynq Scheduler
分布式支持 多 Worker 重复执行 自动去重,单次执行
任务持久化 Worker 重启丢失 存储在 Redis
监控和管理 无统一界面 Asynq Dashboard
错误重试 需手动实现 内置重试机制
代码复杂度 中等(需手动管理 goroutine 低(声明式配置)
依赖 Go 标准库) Redis

实现细节:

// 告警检查任务 Handler
type AlertCheckHandler struct {
    service *pollingSvc.AlertService
    logger  *zap.Logger
}

func (h *AlertCheckHandler) HandleAlertCheck(ctx context.Context, task *asynq.Task) error {
    if err := h.service.CheckAlerts(ctx); err != nil {
        h.logger.Error("告警检查失败", zap.Error(err))
        return err // Asynq 自动重试
    }
    h.logger.Info("告警检查成功")
    return nil
}

// 数据清理任务 Handler
type DataCleanupHandler struct {
    service *pollingSvc.CleanupService
    logger  *zap.Logger
}

func (h *DataCleanupHandler) HandleDataCleanup(ctx context.Context, task *asynq.Task) error {
    if err := h.service.RunScheduledCleanup(ctx); err != nil {
        h.logger.Error("数据清理失败", zap.Error(err))
        return err
    }
    h.logger.Info("数据清理成功")
    return nil
}

// 注册到 Asynq Schedulercmd/worker/main.go
scheduler.Register("@every 1m", asynq.NewTask(constants.TaskTypeAlertCheck, nil))
scheduler.Register("0 2 * * *", asynq.NewTask(constants.TaskTypeDataCleanup, nil))

Cron 表达式说明:

  • @every 1m - 每分钟执行(告警检查)
  • 0 2 * * * - 每天凌晨 2:00 执行(数据清理)

迁移后的优势:

  1. 统一架构: 所有定时任务都使用 Asynq Scheduler代码风格一致
  2. 易于管理: 通过 Asynq Dashboard 查看所有定时任务的执行历史和状态
  3. 易于扩展: 新增定时任务只需注册 Cron 表达式,无需管理 goroutine
  4. 可靠性提升: 任务持久化在 RedisWorker 重启后自动恢复
  5. 分布式友好: 多 Worker 部署时自动避免重复执行

风险和缓解:

  • Redis 依赖: 如果 Redis 故障,定时任务无法执行
    • 缓解Redis 高可用部署(主从 + 哨兵)
  • 迁移风险: 迁移过程中可能遗漏某些任务
    • 缓解:保留旧代码注释,测试验证所有任务正常执行后再删除

Risks / Trade-offs

Risk 1: 定时任务延迟导致订单超时时间不精确

风险: 定时任务每分钟执行一次,订单实际取消时间可能晚于过期时间 1 分钟

影响: 低。30 分钟超时容忍 1 分钟误差(最多 3.3% 误差)

缓解措施:

  • 在用户支付时检查订单是否过期(前端 + 后端双重校验)
  • 在订单详情中显示过期时间,提示用户尽快支付

Risk 2: 批量处理可能导致部分订单取消失败

风险: 批量处理 100 条订单时,如果某个订单的钱包解冻失败,整个事务回滚

影响: 中。失败的订单会在下次任务执行时重新处理,但可能延迟 1 分钟

缓解措施:

  • 使用 Asynq 重试机制(最多重试 3 次)
  • 记录失败日志,便于排查问题
  • 后续优化:考虑单个订单失败不影响其他订单(分批事务)

Risk 3: 钱包余额解冻失败导致用户损失

风险: 订单取消成功但钱包解冻失败(如钱包不存在、冻结余额不足)

影响: 高。用户钱包余额永久冻结

缓解措施:

  • 在同一事务中处理订单取消和钱包解冻,任一失败则全部回滚
  • 记录详细日志,包含订单 ID、钱包 ID、解冻金额
  • 提供人工介入机制(运营后台手动解冻)

Risk 4: 数据库索引失效导致查询性能下降

风险: 随着订单数量增长,索引选择性下降,查询性能降低

影响: 中。定时任务查询耗时超过 50ms

缓解措施:

  • 定期监控查询耗时
  • 定期归档历史订单(如 6 个月前的已完成/已取消订单)
  • 必要时调整索引策略(如分区表)

Risk 5: Redis 故障导致定时任务无法执行

风险: Redis 故障导致 Asynq 任务调度失败,超时订单无法取消

影响: 高。订单堆积,数据库膨胀

缓解措施:

  • Redis 高可用部署(主从复制 + 哨兵)
  • 监控 Redis 可用性和 Asynq 任务执行状态
  • 提供手动触发取消超时订单的 API运营后台

Trade-off: 性能 vs 准确性

选择: 优先保证性能(每分钟执行,单批 100 条),牺牲部分准确性(延迟 1 分钟)

理由: 30 分钟超时场景下1 分钟延迟影响可接受;性能更重要(避免数据库负载过高)


Trade-off: 代码复用 vs 逻辑独立

选择: Cancel() 方法同时支持手动取消和自动取消,逻辑复用

理由: 避免代码重复,降低维护成本;风险是逻辑耦合,但通过参数区分场景(手动 vs 自动)可缓解

Migration Plan

Phase 1: 数据库迁移(不影响业务)

  1. 执行迁移脚本 migrations/000xxx_add_order_expiration.up.sql
    ALTER TABLE tb_order ADD COLUMN expires_at TIMESTAMP NULL COMMENT '订单过期时间';
    CREATE INDEX idx_order_expires ON tb_order(expires_at, payment_status);
    
  2. 验证迁移成功:
    SHOW INDEX FROM tb_order WHERE Key_name = 'idx_order_expires';
    
  3. 已存在的订单 expires_at 为 NULL不影响现有业务

回滚方案:

DROP INDEX idx_order_expires ON tb_order;
ALTER TABLE tb_order DROP COLUMN expires_at;

Phase 2: 代码部署API 服务)

  1. 部署修改后的 API 服务(包含 Create()Cancel() 逻辑)
  2. 验证新创建的订单 expires_at 字段正确设置
  3. 验证手动取消订单时钱包解冻正常

验证步骤:

  • 创建待支付订单,检查 expires_at 是否为 created_at + 30min
  • 手动取消混合支付订单,检查钱包余额是否解冻
  • 监控错误日志,确认无异常

Phase 3: 定时任务部署Worker 服务)

  1. 部署修改后的 Worker 服务(包含定时任务)
  2. cmd/worker/main.go 中注册周期任务
  3. 验证定时任务执行正常

验证步骤:

  • 检查 Asynq 日志,确认任务每分钟执行
  • 人工创建过期订单(修改 expires_at 为过去时间),等待 1 分钟后检查订单状态
  • 监控任务执行耗时和处理订单数量

Phase 4: 监控和告警

  1. 配置 Prometheus 监控指标(任务执行次数、耗时、处理订单数)
  2. 配置告警规则(任务执行失败、耗时超过 5s
  3. 定期检查定时任务执行日志

监控指标:

  • order_expire_task_duration_seconds - 任务执行耗时
  • order_expire_task_processed_total - 处理订单总数
  • order_expire_task_failed_total - 失败次数

Rollback Strategy

如果出现严重问题,按以下顺序回滚:

  1. 立即停止 Worker 服务(停止定时任务执行)
  2. 回滚 API 服务代码(恢复到未修改的版本)
  3. 回滚数据库(执行 migrations/000xxx_add_order_expiration.down.sql

触发回滚的条件:

  • 定时任务导致大量订单误取消
  • 钱包余额解冻失败率 > 5%
  • 数据库性能严重下降(查询耗时 > 500ms

Open Questions

  1. 是否需要发送订单超时通知?

    • 当前不发送通知Non-Goal
    • 后续可扩展(如微信模板消息、短信提醒)
  2. 是否支持可配置的超时时间?

    • 当前固定 30 分钟Non-Goal
    • 后续可考虑按订单类型配置不同超时时间(如大额订单 1 小时)
  3. 历史待支付订单如何处理?

    • 当前不处理(expires_at 为 NULL不会被定时任务取消
    • 建议:运营后台提供批量取消功能,人工清理历史订单
  4. 是否需要订单超时后自动重建订单?

    • 当前不支持Non-Goal
    • 用户需要手动重新创建订单
  5. 是否需要支持订单续期?

    • 当前不支持Non-Goal
    • 如需支持,需增加 API 端点和业务逻辑