支付系统设计:幂等性、对账和退款
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 写入数据库。有三种可能:
- Key 不存在:插入成功,继续处理。
- Key 已
COMPLETED:返回之前保存的响应,不再调用 PSP。 - 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 因争议自动退款。对账是最后一道防线。
每日流程:
- 下载 PSP 的结算文件并加载到临时表
psp_settlements(psp_reference, amount, type)。 - 用
FULL OUTER JOIN与内部数据匹配。 - 将差异写入
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 和你的代码都可能出错,并准备好检测和修复故障的机制。