## Context 当前系统中待支付订单创建后不会自动失效,虽然 `iot-order` 和 `order-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`): ```go // 创建 Asynq Scheduler asynqScheduler := asynq.NewScheduler( asynq.RedisClientOpt{ Addr: redisAddr, Password: cfg.Redis.Password, DB: cfg.Redis.DB, }, &asynq.SchedulerOpts{ Location: time.Local, // 使用本地时区 }, ) ``` 2. **注册周期任务**: ```go // 注册订单超时检查任务(每分钟执行) _, 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**: ```go if err := asynqScheduler.Start(); err != nil { appLogger.Fatal("启动 Asynq Scheduler 失败", zap.Error(err)) } defer asynqScheduler.Shutdown() ``` 4. **创建 Task Handler**(`internal/task/order_expire.go`): ```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. **注册 Handler**(`pkg/queue/handler.go`): ```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,影响任务调度 - ~~使用分页循环处理~~:复杂度高,事务范围难控制 - ~~不使用事务~~:订单状态更新和钱包解冻可能不一致 **实现细节**: ```go // 单批处理逻辑 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) | ❌ | - | | 后台钱包一步支付 | ❌ | - (订单创建时已完成支付) | **替代方案**: - ~~在定时任务中直接解冻钱包~~:代码重复,手动取消时需重复实现 - ~~不在事务中解冻~~:可能导致订单已取消但钱包未解冻 **实现细节**: ```go 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 | **实现细节**: ```go 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` 变更为 2),将 `expires_at` 设置为 NULL **理由**: - 已支付订单不需要过期时间 - 避免查询混淆(`expires_at IS NOT NULL` 可快速筛选待支付订单) - 节省存储(NULL 值不占用索引空间) **实现位置**: - `OrderService.WalletPay()` - H5 端钱包支付成功 - `OrderService.HandlePaymentCallback()` - 第三方支付回调成功 **实现细节**: ```go 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` 布尔字段~~:需要定时更新,增加数据库负载 - ~~使用数据库视图~~:不符合项目架构(不使用视图) **实现细节**: ```go // 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 连接失败、钱包服务暂时不可用 - 不可重试错误:数据不一致(如钱包不存在)、业务逻辑错误 **实现细节**: ```go 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` 中定义超时相关常量 **常量列表**: ```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 分布式执行,避免重复执行 - 持久化和可靠性:任务存储在 Redis,Worker 重启不丢失任务 - 监控和管理:通过 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 | **实现细节**: ```go // 告警检查任务 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 Scheduler(cmd/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. **可靠性提升**: 任务持久化在 Redis,Worker 重启后自动恢复 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` ```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. 验证迁移成功: ```sql SHOW INDEX FROM tb_order WHERE Key_name = 'idx_order_expires'; ``` 3. 已存在的订单 `expires_at` 为 NULL(不影响现有业务) **回滚方案**: ```sql 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 端点和业务逻辑