Skip to content

设计一个支付系统

支付系统为什么难做

支付系统是绝对不能出错的系统。一笔重复扣款、一笔金额错误,造成的不仅是资损,更是信任崩塌。它和我们之前聊的 Feed 流、排行榜不同——那些系统允许偶尔丢数据或延迟,但支付系统必须做到"不出错,出错了能发现,发现了能追回"。

设计核心可以概括为三轮防线:第一轮防重(幂等 + 唯一索引),第二轮防错(状态机 + 流水记账),第三轮对账(T+1 自动对账 + 告警)。这三轮防线层层递进,确保即使某层失效,下一层也能兜住。

支付链路概览

一次完整的支付请求从用户点击"确认支付"到最终资金到账,经历以下链路:

用户 → 下单服务 → 支付网关 → 风控系统 → 账户系统 → 外部渠道(微信/支付宝/银行卡)

每个环节都可能失败,但失败不等于终止——需要重试、补偿或回滚。这也是支付系统设计最困难的地方。

幂等性设计:防重的第一道闸

支付接口必须支持幂等重试。客户端发起支付时带一个唯一 idempotent_key(通常是 order_id + payment_id 或业务方生成的 UUID),服务端收到请求后先查这个 key 是否处理过。

sql
-- 幂等表设计
CREATE TABLE idempotent_record (
    idempotent_key  VARCHAR(64) PRIMARY KEY,  -- 唯一键,天然防重
    status          TINYINT NOT NULL,          -- 0=处理中, 1=成功, 2=失败
    response        TEXT,                      -- 上次处理的结果,重复请求直接返回
    created_at      DATETIME NOT NULL,
    updated_at      DATETIME NOT NULL,
    UNIQUE KEY uk_idempotent (idempotent_key)
);

流程:请求到达 → 插入 idempotent_recordINSERT ... ON DUPLICATE KEY)→ 已存在则直接返回上次结果 → 不存在则执行业务逻辑 → 更新记录状态。

核心原则:幂等判断必须在业务逻辑之前,否则重复请求已经穿透到下游。这也是为什么很多支付系统在网关层就做幂等拦截,而不是下沉到业务层。

资金安全:流水记账,不直接改值

资金安全最重要的一条铁律:账户余额只能通过流水累计出来,不能直接 UPDATE 余额

错误的做法:

sql
-- ❌ 直接改余额,丢了流水,无法回滚
UPDATE account SET balance = balance - 100 WHERE account_id = 1;

正确的做法是两步走——先冻结,再确认

sql
-- 第一步:创建流水 + 冻结余额(同一个事务)
BEGIN;
INSERT INTO account_journal (account_id, order_id, amount, type, status)
VALUES (1, 'ORDER123', -100, 'FREEZE', 'PENDING');
UPDATE account SET frozen_balance = frozen_balance + 100 WHERE account_id = 1;
COMMIT;

-- 第二步:确认扣款
BEGIN;
UPDATE account_journal SET status = 'SUCCESS' WHERE order_id = 'ORDER123';
UPDATE account SET balance = balance - 100, frozen_balance = frozen_balance - 100
WHERE account_id = 1;
COMMIT;

冻结 + 确认的机制保证了:如果扣款流程中途失败(如外部渠道超时),冻结资金可以解冻,不会出现账不平。流水表是支付系统的"黑匣子",任何时候以流水为准重建余额,结果应该一致。

分布式事务:最终一致性是唯一的出路

支付链路很长——下单 → 扣款 → 发货 → 结算,涉及多个微服务。2PC(两阶段提交)在分布式环境下不可行(性能差、协调者单点、阻塞问题),业界共识是放弃强一致,拥抱最终一致

落地常用的方案是本地消息表 + 定时对账

java
// 伪代码:扣款成功后发送发货消息
@Transactional
public void completePayment(String orderId) {
    // 1. 扣款
    accountService.deduct(orderId, amount);
    // 2. 写入本地消息表(同一个事务!)
    messageDao.insert(new LocalMessage(orderId, "delivery", "PENDING"));
    // 3. 事务提交后,后台线程轮询消息表发送 MQ
}

// 定时任务:补偿未发送的消息
@Scheduled(fixedDelay = 5000)
public void compensate() {
    List<LocalMessage> pending = messageDao.selectByStatus("PENDING", 100);
    for (LocalMessage msg : pending) {
        boolean sent = mqTemplate.send(msg.getTopic(), msg.getPayload());
        if (sent) {
            messageDao.updateStatus(msg.getId(), "SENT");
        }
    }
}

关键:本地消息表和业务操作在同一个数据库事务中,保证业务操作成功则消息一定写入。如果业务操作失败,消息也不会写入,天然一致。

对于更复杂的跨服务 Saga,可以用 TCC(Try-Confirm-Cancel)模式,但 TCC 对业务侵入性大,除非链路中的每个服务都支持预留资源,否则不建议轻易上 TCC。

对账机制:兜底的第三条防线

即使幂等和流水都做好了,总会有意外——网络抖动导致重复回调、第三方渠道返回错误状态、甚至代码 bug。对账就是最后的兜底手段。

对账流程(T+1,即次日对前一天的数据):

  1. 内部对账:支付系统内部,流水表 vs 账户余额表,确保每笔流水都有对应的余额变动
  2. 外部对账:支付系统 vs 第三方渠道(微信/支付宝),逐笔匹配交易记录
  3. 差异处理:匹配状态不一致的标记为"长款"或"短款",触发告警人工介入
sql
-- 对账 SQL(简化版)
SELECT
    t.transaction_id,
    t.amount,
    t.status AS internal_status,
    c.status AS channel_status
FROM transaction t
LEFT JOIN channel_record c ON t.channel_trade_no = c.channel_trade_no
WHERE t.transaction_date = CURDATE() - INTERVAL 1 DAY
  AND (t.status != c.status OR c.channel_trade_no IS NULL);

对账频率:T+1 是底线,核心业务(如实时到账)可以缩短到小时级甚至分钟级,用流式对账(Kafka 实时比对)代替批量对账。

状态机设计:单向不可逆

支付状态必须严格编码,不允许随意跳转。标准的状态机设计:

INIT → PROCESSING → SUCCESS
                    → FAILED → REFUNDING → REFUNDED
java
public enum PaymentStatus {
    INIT(0),
    PROCESSING(1),
    SUCCESS(2),
    FAILED(3),
    REFUNDING(4),
    REFUNDED(5);

    private static final Map<PaymentStatus, Set<PaymentStatus>> TRANSITIONS = Map.of(
        INIT, Set.of(PROCESSING),
        PROCESSING, Set.of(SUCCESS, FAILED),
        SUCCESS, Set.of(REFUNDING),
        REFUNDING, Set.of(REFUNDED, FAILED)  // 退款失败也算一种结果
    );

    public boolean canTransitionTo(PaymentStatus target) {
        return TRANSITIONS.getOrDefault(this, Set.of()).contains(target);
    }
}

杜绝:从 SUCCESS 直接跳回 INIT、从 FAILED 直接跳 SUCCESS。状态机编码在编译期就能防止非法流转,比写在文档里可靠得多。

降级与兜底

支付渠道不可用时,不能简单报错。常见降级策略:

  • 降级队列:先记录支付请求,渠道恢复后异步处理(适合非实时场景)
  • 通道切换:微信不可用时切到支付宝,或走银联
  • 兜底人工打款:自动化链路全部失败,最终由财务手工操作

但降级必须设定触发条件——不是每次超时都降级,而是连续失败 N 次(如 5 次)或失败率超过阈值(如 10%)才触发,避免因偶发抖动误触降级。

总结

支付系统没有银弹,核心是"防、查、补"三层递进:

  • :幂等拦截、状态机编码、流水记账
  • :对账系统、监控告警
  • :补偿机制、降级策略、人工兜底

P7/P8 级别的面试官不会问"支付系统用什么技术栈",而是问"这笔钱重复扣了怎么追回来"、"凌晨 3 点对账发现长短款怎么处理"。能把这几个场景讲清楚,比背一百个组件名都有用。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。