1. 故障背景与影响:支付接口超时率飙升、用户投诉、业务损失复盘

1.1 故障现象与时间线

2024年6月18日大促期间,核心支付网关接口在14:32分开始出现大面积超时,持续时长约47分钟。监控系统显示,支付确认接口(/v1/pay/confirm)的P99延迟从基线45ms飙升至12.8s,超时率从0.02%骤升至23.7%。

指标项 故障前(基线) 故障峰值 恢复后
P99 延迟 45 ms 12,800 ms 52 ms
超时率 0.02% 23.7% 0.03%
QPS 2,300 3,100 2,450
错误数/分钟 0.5 734 0.7
关键发现: 故障期间数据库连接池活跃连接数达到上限(200),大量goroutine阻塞在获取数据库连接上,形成级联雪崩。

1.2 用户投诉与业务影响

故障期间共收到用户投诉2,847条,主要集中为三类:

  • 支付成功但订单状态未更新(占比62%):用户已扣款,但页面显示“支付中”,导致重复支付尝试。
  • 支付超时提示(占比28%):接口返回504,用户无法确认是否扣款成功。
  • 退款异常(占比10%):部分用户发起退款后,退款状态卡在“处理中”超过2小时。

直接业务损失估算:

损失类型 金额(万元) 说明
订单流失 ≈ 320 超时导致用户放弃支付,转化率下降18%
客诉赔付 ≈ 45 重复扣款、退款延迟的补偿
技术应急人力 ≈ 12 6名工程师紧急排查+数据库扩容
合计 ≈ 377 未计入品牌信誉损失

1.3 故障根因全景图

下图展示了故障发生的完整链路与根因关系:

支付接口超时故障根因全景图 大促流量洪峰 QPS 3,100 → 超限35% Go 支付服务 goroutine 池耗尽 MySQL 慢查询 连接池200满 根因1:SQL 无索引 order_no 字段未建索引 全表扫描 320万行 根因2:行锁竞争 同一用户并发支付 行锁等待超时 根因3:连接池耗尽 max_open=200 不足 goroutine 排队阻塞 级联雪崩效应 支付超时 → 用户重试 → 更多慢查询 → 连接池彻底阻塞 💡 复盘要点: 三个根因同时爆发,单一优化无法恢复。需要从 SQL 索引、连接池配置、限流降级三个维度联合治理。

1.4 故障复盘关键数据

通过事后日志与APM追踪,我们提取了故障期间的核心性能数据:

维度 具体指标 故障值 健康阈值
数据库 慢查询数/分钟 1,240 < 10
行锁等待平均时长 4.7 s < 100 ms
应用 goroutine 阻塞数 8,600 < 200
DB 连接获取等待 3.2 s < 10 ms
业务 支付成功率 76.3% > 99.9%
重复支付订单数 1,247 0
⚠️ 重要警示: 本次故障中,order_no 字段的 SELECT ... FOR UPDATE 语句在无索引情况下触发了全表扫描,每次扫描耗时约 2.3s。并发 200 个请求时,数据库立即进入“锁风暴”状态,这是本次超时飙升的直接导火索。

1.5 故障链路的代码级还原

以下是故障发生时支付确认接口的核心代码片段,问题集中在 无索引查询 + 行锁 的组合上:

// 故障版本:支付确认接口
func (s *PayService) ConfirmPay(ctx context.Context, req *PayConfirmReq) error {
    // 问题1:order_no 字段无索引,触发全表扫描
    // 问题2:FOR UPDATE 锁住所有匹配行,高并发下互相等待
    query := `SELECT id, status, amount FROM orders 
              WHERE order_no = ? FOR UPDATE`

    tx, _ := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
    row := tx.QueryRowContext(ctx, query, req.OrderNo)

    var order Order
    err := row.Scan(&order.ID, &order.Status, &order.Amount)
    if err != nil {
        tx.Rollback()
        return err
    }

    // 模拟第三方支付网关调用(耗时约 200ms)
    if err := s.thirdPartyPay(ctx, order); err != nil {
        tx.Rollback()
        return err
    }

    // 更新订单状态
    _, err = tx.ExecContext(ctx, `UPDATE orders SET status = 'paid' WHERE id = ?`, order.ID)
    if err != nil {
        tx.Rollback()
        return err
    }

    return tx.Commit()
}