支付系统设计:幂等性、对账和退款

1. 概述

支付问题与常规CRUD业务的区别在于一点:出错一次就是真金白银的损失。客户端重试可能导致用户被扣两次。支付网关(PSP)即使交易成功也可能超时。两个并发的退款请求可能导致退款超过原收额。

本文通过一个完整的设计案例,涵盖需求、数据模型、API 和处理流程。然后解决三个难点:幂等性、对账(reconciliation) 和 退款(refund)。读完这篇文章,你可以用 Spring Boot 3 和 PostgreSQL 构建支付系统的核心部分,并附带测试证明系统在争议情况下不会超额退款。

2. 问题需求

2.1. 功能需求

  • 商户为一个 Order 创建支付。系统调用 PSP 扣款。
  • 商户可进行全额或部分退款。总退款额不超过已收款额。
  • 每日系统将内部数据与 PSP 的结算文件进行对账。
  • 系统发送事件(PaymentCaptured、RefundSucceeded 等)供 Order 和 Accounting 等其他服务消费。

2.2. 规模和约束

  • 平均每秒 200 笔交易。闪购高峰期几分钟内可达 5,000 笔/秒。
  • 绝不允许同一个支付意图被扣两次。这是硬性约束。
  • 优先保证一致性而非可用性:宁可向客户返回错误也不能记录错误的金额。
  • PSP 是外部系统。它可能很慢、超时,或发送延迟、重复的 webhook。

3. 数据模型

金额总是使用最小单位(VND 用元,USD 用分)以 BIGINT 类型存储。不能用 double。

CREATE TABLE payments (
    id              UUID PRIMARY KEY,
    merchant_id     TEXT        NOT NULL,
    order_id        TEXT        NOT NULL,
    amount          BIGINT      NOT NULL CHECK (amount > 0),
    currency        CHAR(3)     NOT NULL,
    status          TEXT        NOT NULL, -- CREATED, PROCESSING, CAPTURED, FAILED, UNKNOWN
    refunded_amount BIGINT      NOT NULL DEFAULT 0,
    psp_reference   TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (refunded_amount <= amount)
);

CREATE TABLE refunds (
    id          UUID PRIMARY KEY,
    payment_id  UUID   NOT NULL REFERENCES payments(id),
    amount      BIGINT NOT NULL CHECK (amount > 0),
    status      TEXT   NOT NULL, -- PENDING, SUCCEEDED, FAILED
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE idempotency_keys (
    merchant_id   TEXT NOT NULL,
    idem_key      TEXT NOT NULL,
    request_hash  TEXT NOT NULL,
    status        TEXT NOT NULL, -- IN_PROGRESS, COMPLETED
    response_code INT,
    response_body TEXT,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (merchant_id, idem_key)
);

CREATE TABLE outbox (
    id         BIGSERIAL PRIMARY KEY,
    topic      TEXT  NOT NULL,
    payload    JSONB NOT NULL,
    sent_at    TIMESTAMPTZ
);

几个值得注意的地方:

  • CHECK (refunded_amount <= amount) 是数据库层的最后一道安全网。即使代码有 bug,数据库也会阻止。
  • idempotency_keys 表的主键 (merchant_id, idem_key) 是防重复机制的核心。
  • outbox 表帮助我们将事件发送到 Kafka,而无需分布式事务(见第 6 部分)。

4. API 设计

POST /v1/payments
Headers: Idempotency-Key: 7f3c1e2a-...
Body:    { "orderId": "ORD-1001", "amount": 250000, "currency": "VND", "source": "tok_visa" }
→ 201  { "id": "...", "status": "CAPTURED" }

POST /v1/payments/{id}/refunds
Headers: Idempotency-Key: 9a8b...
Body:    { "amount": 100000 }
→ 202  { "refundId": "...", "status": "PENDING" }

GET  /v1/payments/{id}

所有改变状态的 API 都必须包含客户端生成的 Idempotency-Key。重试时客户端发送相同的 key。退款返回 202 Accepted,因为 PSP 异步处理退款。

5. 幂等性:防止扣款两次

5.1. 原理

收到请求时,在做任何事情之前,我们先将 key 写入数据库。有三种可能:

  1. Key 不存在:插入成功,继续处理。
  2. Key 已 COMPLETED:返回之前保存的响应,不再调用 PSP。
  3. Key 状态为 IN_PROGRESS:另一个同 key 的请求正在处理。返回 409 Conflict 让客户端稍后重试。

我们还保存请求体的 hash。如果客户端用同一个 key 但不同的请求体重复发送(比如金额不同),这是客户端错误,返回 422。

5.2. 实现

需要的依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
@Service
public class IdempotencyService {

    private final JdbcTemplate jdbc;

    public IdempotencyService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public record StoredResponse(int code, String body) {}

    public Optional<StoredResponse> begin(String merchantId, String key, String requestHash) {
        int inserted = jdbc.update("""
            INSERT INTO idempotency_keys (merchant_id, idem_key, request_hash, status)
            VALUES (?, ?, ?, 'IN_PROGRESS')
            ON CONFLICT (merchant_id, idem_key) DO NOTHING
            """, merchantId, key, requestHash);
        if (inserted == 1) {
            return Optional.empty(); // 新请求,允许处理
        }
        Map<String, Object> row = jdbc.queryForMap("""
            SELECT request_hash, status, response_code, response_body
            FROM idempotency_keys WHERE merchant_id = ? AND idem_key = ?
            """, merchantId, key);
        if (!requestHash.equals(row.get("request_hash"))) {
            throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Key reused with different body");
        }
        if ("IN_PROGRESS".equals(row.get("status"))) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "Request is being processed");
        }
        return Optional.of(new StoredResponse((Integer) row.get("response_code"), (String) row.get("response_body")));
    }

    public void complete(String merchantId, String key, int code, String body) {
        jdbc.update("""
            UPDATE idempotency_keys SET status = 'COMPLETED', response_code = ?, response_body = ?
            WHERE merchant_id = ? AND idem_key = ?
            """, code, body, merchantId, key);
    }
}

ON CONFLICT DO NOTHING 将“检查然后写入”变成一条原子操作。两个相同 key 的请求同时到达时,只有一个会得到 inserted == 1。另一个会进入读取分支并收到 409。不存在“两个请求都认为 key 不存在”的竞态条件。

注意: 不要用单独的 Redis SETNX 作为支付幂等性的事实来源。Redis 故障转移或 key 被逐出会导致你失去幂等性记录,进而重复扣款。Redis 只应作为 database 前的缓存层,database 才是最终仲裁者。

5.3. PSP 端的幂等性

你的请求被保护了,但从你到 PSP 的调用也可能被重试。大多数 PSP 都支持自己的幂等性 key。请用 payments.id 本身作为那个 key:

PspResult result = pspClient.charge(new ChargeRequest(
        payment.id().toString(), // 发给PSP的幂等性key
        payment.amount(),
        payment.currency(),
        sourceToken));

当调用 PSP 超时时,你不知道款是否已扣。不要标记为 FAILED。改为设置为 UNKNOWN 状态,然后启动一个 job 用 payments.id 向 PSP 查询。标记 FAILED 然后让客户端用新 key 重试正是导致重复扣款的经典路径。

6. Database 和 Kafka 的一致性:事务性 Outbox

收款后,需要同时更新 payments 和发布 PaymentCaptured 事件。数据库更新成功但 Kafka 发送失败(或反之)会导致 Order 服务状态不同步。解决方案是在同一个事务中将事件写入 outbox 表:

@Transactional
public void markCaptured(UUID paymentId, String pspReference) {
    jdbc.update("UPDATE payments SET status = 'CAPTURED', psp_reference = ? WHERE id = ? AND status IN ('PROCESSING','UNKNOWN')",
            pspReference, paymentId);
    jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('payment-events', ?::jsonb)",
            """
            {"type":"PaymentCaptured","paymentId":"%s"}""".formatted(paymentId));
}

一个 relay(定期 job 或 Debezium CDC)读取所有 sent_at IS NULL 的行,发送到 Kafka 后更新 sent_at。Relay 可能发送重复的事件,所以消费者必须幂等,比如记录已处理的 paymentId。UPDATE 语句中的 status IN (...) 条件确保来自 PSP 的重复 webhook 不会覆盖不同的最终状态。

7. 退款和并发争议

7.1. 问题

支付金额 100,000元。客服代表和自动 job 同时发起 60,000元的退款请求。如果代码是“读 refunded_amount、检查、再写”这种模式,两个操作都会看到 0 + 60,000 <= 100,000 都通过检查。结果你退了 120,000元。

7.2. 解决方案:条件更新

把检查和更新合并到一条有条件的 UPDATE 语句中:

@Service
public class RefundService {

    private final JdbcTemplate jdbc;

    public RefundService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public UUID requestRefund(UUID paymentId, long amount) {
        int reserved = jdbc.update("""
            UPDATE payments SET refunded_amount = refunded_amount + ?
            WHERE id = ? AND status = 'CAPTURED' AND refunded_amount + ? <= amount
            """, amount, paymentId, amount);
        if (reserved == 0) {
            throw new IllegalStateException("Refund exceeds refundable amount or payment not captured");
        }
        UUID refundId = UUID.randomUUID();
        jdbc.update("INSERT INTO refunds (id, payment_id, amount, status) VALUES (?, ?, ?, 'PENDING')",
                refundId, paymentId, amount);
        jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('refund-commands', ?::jsonb)",
                """
                {"refundId":"%s","paymentId":"%s","amount":%d}""".formatted(refundId, paymentId, amount));
        return refundId;
    }
}

PostgreSQL 在 UPDATE 时对 payments 行加锁。第二个事务必须等第一个事务提交。然后它在新数据上重新评估 WHERE 条件,发现 60,000 + 60,000 > 100,000 不满足条件,得到 reserved == 0。这是 READ COMMITTED 隔离级别下的默认行为,无需改为 SERIALIZABLE。

我们先“预留”退款金额,然后异步通过 outbox 调用 PSP。如果 PSP 拒绝退款,消费者会取消预留:

UPDATE payments SET refunded_amount = refunded_amount - :amount WHERE id = :paymentId;
UPDATE refunds  SET status = 'FAILED' WHERE id = :refundId AND status = 'PENDING';

这两条语句必须在同一事务中。条件 status = 'PENDING' 确保一个失败的 webhook 如果重复发送,也不会取消预留两次。更严格的做法是,只在第二条语句恰好更新了一行时才执行第一条。

7.3. 并发测试

测试依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class RefundServiceTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired RefundService refundService;
    @Autowired JdbcTemplate jdbc;

    @Test
    void concurrentRefunds_neverExceedCapturedAmount() throws Exception {
        UUID paymentId = UUID.randomUUID();
        jdbc.update("""
            INSERT INTO payments (id, merchant_id, order_id, amount, currency, status)
            VALUES (?, 'shop-1', 'ORD-1001', 100000, 'VND', 'CAPTURED')
            """, paymentId);

        Callable<Boolean> refund60k = () -> {
            try {
                refundService.requestRefund(paymentId, 60_000);
                return true;
            } catch (IllegalStateException e) {
                return false;
            }
        };

        List<Boolean> results;
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            results = executor.invokeAll(List.of(refund60k, refund60k))
                    .stream().map(Future::resultNow).toList();
        }

        assertThat(results).containsExactlyInAnyOrder(true, false);
        Long refunded = jdbc.queryForObject(
                "SELECT refunded_amount FROM payments WHERE id = ?", Long.class, paymentId);
        assertThat(refunded).isEqualTo(60_000L);
    }
}

假设第 3 部分的 schema 通过 src/test/resources/schema.sql 加载(配合 spring.sql.init.mode=always)或通过 Flyway。两个虚拟线程同时发送退款请求。结果始终稳定:

results          = [true, false]   (任意顺序)
refunded_amount  = 60000

试试把 UPDATE 替换为 Java 中的“读取后比较”模式,你会看到测试间歇性失败。这正是本设计所消除的竞态条件。

8. 对账(Reconciliation)

无论你多么小心,内部数据和 PSP 还是可能出现不一致。Webhook 可能丢失,可能还有待解决的 UNKNOWN 交易,或 PSP 因争议自动退款。对账是最后一道防线。

每日流程:

  1. 下载 PSP 的结算文件并加载到临时表 psp_settlements(psp_reference, amount, type)。
  2. 用 FULL OUTER JOIN 与内部数据匹配。
  3. 将差异写入 recon_breaks 表供自动或人工处理。
INSERT INTO recon_breaks (psp_reference, internal_amount, psp_amount, reason)
SELECT COALESCE(p.psp_reference, s.psp_reference),
       p.amount,
       s.amount,
       CASE
           WHEN p.psp_reference IS NULL THEN 'MISSING_INTERNAL'
           WHEN s.psp_reference IS NULL THEN 'MISSING_AT_PSP'
           ELSE 'AMOUNT_MISMATCH'
       END
FROM (SELECT * FROM payments
      WHERE status = 'CAPTURED' AND created_at >= :day AND created_at < :day + INTERVAL '1 day') p
FULL OUTER JOIN (SELECT * FROM psp_settlements WHERE type = 'CHARGE') s
       ON p.psp_reference = s.psp_reference
WHERE p.psp_reference IS NULL
   OR s.psp_reference IS NULL
   OR p.amount <> s.amount;

各类差异的含义:

  • MISSING_INTERNAL:PSP 已收款但你方没有 CAPTURED 记录。通常是 UNKNOWN 交易或 webhook 丢失。处理方式是更新状态或在 order 被取消时退款。
  • MISSING_AT_PSP:你方记录已收款但 PSP 无此笔交易。这是严重情况,因为你可能已发货而没收到款。
  • AMOUNT_MISMATCH:通常由手续费、汇率或舍入 bug 导致。

注意: 留意时间窗口。23:59 的交易可能出现在第二天的结算文件中。对账应该有 T+1 或 T+2 的延迟,只在 break 连续出现两次后才升级给人工处理。

9. 突发流量

闪购时流量可能增加 25 倍。几个技术帮助系统承载:

  • 按商户限流,用 Redis(每秒窗口内 INCR + EXPIRE)。这保护系统免受单个“吵闹”商户的影响。这里 Redis 只用于限流,不决定交易正确性。
  • 分离同步和异步部分。 同步路径只包括写幂等性 key、创建 payment 和调用 PSP authorize。退款、发邮件、更新会计都通过 outbox → Kafka。按 paymentId 对 Kafka 分区以保证每个支付的事件顺序。
  • Circuit breaker 和短超时调用 PSP(比如 Resilience4j)。PSP 变慢时快速失败并返回 503 加 Retry-After,而不是耗尽线程池。客户端用相同 Idempotency-Key 重试会因为第 5 部分的保护而安全。
  • 定期清理 idempotency_keys 表(保留 24–72 小时)并按日期分区,避免表过大拖累 INSERT。

10. 常见错误

  • 用 double/float 存金额。应该用 long 乘以最小单位,或需要汇率时用 BigDecimal。
  • 把超时当作失败。超时意味着状态未知,应该设为 UNKNOWN 然后再查询 PSP。
  • 由服务器生成幂等性 key。Key 应该由客户端生成并在重试时保持不变。
  • 在 Java 中检查退款余额再 UPDATE。让 database 在一条语句中完成。
  • 在 @Transactional 内发送 Kafka 然后期望它随 database 回滚。它不会回滚。使用 outbox。
  • 因为“代码已正确”就跳过对账。外部系统不在你的控制范围内。

11. 结论

本文设计了支付系统的核心部分,基于三个原则。首先,幂等性通过 database 的唯一性约束保证,既适用于你的 API 也适用于到 PSP 的调用。其次,退款时的并发争议通过条件更新和 CHECK 约束解决,并用并发测试证明系统不会超额退款。第三,与外部系统的一致性基于事务性 outbox 和每日对账。

这三个方面的共同点是把重要的不变量下推到 database 层,在那里它们是原子性的。同时始终假设网络、PSP 和你的代码都可能出错,并准备好检测和修复故障的机制。

system-designpaymentidempotencyspring-bootpostgresql