支付回调到了两次,第一条说成功,第二条因为渠道重试而晚到,却带着失败结果。系统该信哪一条?
如果支付单只是数据库里一行任意可改的 status ,答案往往很糟:后到的失败把已经成功的支付单覆盖掉;订单没有履约,用户却已经被扣款。再加上同步调用超时、消息重复投递、进程在提交后宕机,这类问题不是多写几层 Service 能解决的。
这篇文章讨论的不是“怎样把 Java 项目改成 DDD 目录结构”,而是一个日交易量千万级、峰值 TPS 5000+ 的支付系统在业务扩张后,如何回答四个更实际的问题:
- 什么是支付单,什么是渠道流水,谁拥有支付最终状态;
- 哪些规则必须在一个本地事务内成立,哪些只能接受最终一致;
- 第三方调用超时、回调重复或乱序时,如何不误判、更不重复执行;
- 数据库状态与异步事件如何一起可靠地向后推进。
文中的业务规模与既有技术背景来自这次重构案例;表名、代码和时间参数则是为说明设计而简化的示例,不应被当作某个支付渠道的接口规范。
从一段“大 Service”开始失控
系统早期只有少量支付方式时,下面的实现并不离谱:
支付成功
├─ 更新业务订单
├─ 更新支付记录
├─ 核销优惠券
├─ 发送积分
├─ 通知用户
└─ 写审计日志
但业务增加退款、余额、分账、多币种和对账之后,同一个方法同时承担了支付确认、履约编排、资金处理和通知。最危险的变化不是方法变成几百行,而是团队已经无法回答:哪一步失败必须回滚扣款,哪一步晚几分钟完成仍然正确?
当产品说“订单支付成功”,财务说“渠道已扣款”,开发说“支付流水成功”时,双方甚至可能都在描述真实现象,却不是同一件事。代码里于是并存 order_status 、 payment_status 、 trade_status ,以及含义不同的 SUCCESS 。
这不是命名洁癖,而是状态所有权丢失。重构的第一步不是创建 Aggregate 类,而是把语言还给业务。
先把三个容易混淆的对象拆开
在支付上下文中,我们把下面三个概念固定下来:
| 概念 |
它回答的问题 |
不负责什么 |
BusinessOrder |
用户买了什么,是否可履约 |
渠道是否扣款 |
PaymentOrder |
一次应收款的支付生命周期是什么 |
发货、积分、优惠券 |
PaymentTransaction |
一次渠道交互及其外部流水是什么 |
业务订单生命周期 |
例如,支付单 P10001 金额为 100 元;它可以有一次微信渠道流水 T20001 ,其外部流水号为 WX30001 。三者不能互相替代。若把渠道响应、回调时间、退款累计额、重试次数统统塞进 payment_order ,支付单就会逐渐成为整个系统的总账本,边界也随之消失。
统一语言必须进入 API、数据库字段、事件名和代码,而不只是会议纪要。尤其是状态的含义必须唯一:
public enum PaymentStatus {
CREATED, // 已创建,尚未请求渠道
PAYING, // 已向渠道发起请求,结果尚未确认
UNKNOWN, // 请求结果不确定,必须继续确认
SUCCEEDED, // 支付事实已被确认
FAILED, // 渠道已明确拒绝或确认失败
CLOSED // 已过期或主动关闭,不再接受新的支付尝试
}
UNKNOWN 是支付模型中最容易被省掉、却最重要的状态。读超时、连接重置或响应丢失都只说明“本地没有收到结果”,不说明渠道没有执行扣款。把 timeout 直接写成 FAILED ,再允许用户重试,就可能制造第二笔有效扣款。
创建支付单时,先保护“同一请求只有一个事实”
很多重复支付并非从回调开始,而是用户连续点击、网关重试或客户端重放创建接口时就已经发生。这里的幂等键应该来自业务侧稳定的请求标识,而不是由服务端为每次 HTTP 请求临时生成 UUID。
CREATE TABLE payment_order (
payment_id VARCHAR(64) NOT NULL PRIMARY KEY,
merchant_id BIGINT NOT NULL,
merchant_request_id VARCHAR(64) NOT NULL,
business_order_id VARCHAR(64) NOT NULL,
amount DECIMAL(20, 8) NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(16) NOT NULL,
channel_transaction_id VARCHAR(128) NULL,
version BIGINT NOT NULL DEFAULT 0,
expires_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
created_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_merchant_request (merchant_id, merchant_request_id),
UNIQUE KEY uk_channel_transaction (channel_transaction_id),
KEY idx_recovery (status, updated_at)
);
创建接口收到同一个 merchantRequestId 时,不能仅仅“返回已存在的支付单”。还必须核对商户、业务订单、金额与币种;若幂等键相同而关键参数不同,这是调用方错误或潜在攻击,应明确拒绝,而不是把一笔请求悄悄映射到另一笔支付。
public PaymentOrder create(CreatePaymentCommand command) {
return paymentQueries.findByMerchantRequestId(
command.merchantId(), command.merchantRequestId())
.map(existing -> assertSameRequest(existing, command))
.orElseGet(() -> createOrReadAfterConflict(command));
}
private PaymentOrder createOrReadAfterConflict(CreatePaymentCommand command) {
try {
return paymentCreationTx.insertNew(command); // 独立的本地事务
} catch (DuplicateKeyException duplicate) {
// 插入事务已经结束;用新的只读事务重新读取获胜者
return paymentQueries.findByMerchantRequestId(
command.merchantId(), command.merchantRequestId())
.map(existing -> assertSameRequest(existing, command))
.orElseThrow(() -> duplicate);
}
}
@Transactional
public PaymentOrder insertNew(CreatePaymentCommand command) {
PaymentOrder order = PaymentOrder.create(
paymentIdGenerator.next(), command.businessOrderId(), command.money(),
clock.instant().plus(command.expireAfter()));
paymentRepository.insert(order, command.merchantId(), command.merchantRequestId());
return order;
}
这里的 catch 不是把数据库异常当作正常控制流的偏好,而是对“先查询后插入”天然竞态的收口。需要注意,重复键后的查询必须使用新事务(通常也是新连接);不要在同一个已失败的写事务或旧的一致性读快照里查询。真正的幂等保证来自唯一键;应用层预查询只是为了给调用方更友好的返回结果。
聚合不是表的包装,而是不变性的边界
支付单、账户和退款单都可以有表,但“一张表一个聚合”是倒过来的设计。先要问的是:哪些业务规则必须在一次状态变更中同时成立?
对于 PaymentOrder ,我们选择维护这些不变性:
同一个支付请求只能创建一个 PaymentOrder。
已确认成功的 PaymentOrder 不能被失败、关闭或旧回调改写。
一笔 PaymentOrder 的已确认渠道流水必须唯一且金额、币种匹配。
PaymentSucceeded 事件只能随成功状态一起被记录一次。
这意味着支付状态、金额、币种、支付过期时间和已确认渠道流水属于支付聚合的职责。优惠券、通知、订单履约不属于它。
账户也有自己的不变性,例如余额不能扣成负数、同一资金指令不能重复记账。它不应因为“支付成功以后还要处理账户”而被塞进支付聚合。需要特别澄清的是:外部渠道支付确认成功,并不等价于“异步发一条消息给账户服务再扣一次钱”。余额支付的扣款应由账户领域在自己的资金指令中原子完成;支付领域记录的是该支付方式的确认结果。两者的编排取决于支付方式,不能用一条通用的 PaymentSucceeded -> debitAccount 事件覆盖。
因此,跨聚合的默认策略不是大事务,而是:每个聚合先在本地维护自己的事实,再用事件让其他上下文推进可延迟的动作。订单“已支付”、发货、积分和通知通常可以这样协作;如果某个业务规则确实要求付款与余额冻结同时不可分割,就应重新审视边界或采用同库本地事务,而不是用一句“最终一致”掩盖资金规则。
上图刻意没有把 Redis、分布式锁 或 CQRS 放进去。它们并不是支付正确性的前提;只有遇到明确的性能或跨资源约束时,才有理由引入。
让状态机替代 setStatus
“支付成功后更新状态”这句话隐含了太多条件:是谁确认的、是否是同一笔流水、当前是否还允许变化、是否已经处理过。让应用层直接执行 paymentOrder.setStatus(SUCCEEDED) ,等于把这些规则交给每一个调用方自觉遵守。
支付聚合应只暴露业务行为。下面的代码省略了 ORM 映射和访问器,但状态迁移与事件生成是完整的:
public final class PaymentOrder {
private final String paymentId;
private final Money amount;
private final Instant expiresAt;
private PaymentStatus status;
private String confirmedChannelTransactionId;
public PaymentOrder(String paymentId, Money amount, Instant expiresAt) {
this.paymentId = Objects.requireNonNull(paymentId);
this.amount = Objects.requireNonNull(amount);
this.expiresAt = Objects.requireNonNull(expiresAt);
this.status = PaymentStatus.CREATED;
}
public void beginPayment(Instant now) {
if (now.isAfter(expiresAt)) throw new IllegalStateException("payment is expired");
if (status != PaymentStatus.CREATED) throw new IllegalStateException("payment already started");
status = PaymentStatus.PAYING;
}
/** 渠道、回调或查询均可调用;同一成功事实重复到达时安全返回。 */
public Optional<PaymentSucceeded> confirmSuccess(
String channelTransactionId, Money paidAmount) {
requireSameMoney(paidAmount);
Objects.requireNonNull(channelTransactionId);
if (status == PaymentStatus.SUCCEEDED) {
if (!channelTransactionId.equals(confirmedChannelTransactionId)) {
throw new IllegalStateException("conflicting successful transaction");
}
return Optional.empty();
}
if (status != PaymentStatus.PAYING && status != PaymentStatus.UNKNOWN) {
throw new IllegalStateException("payment cannot become successful from " + status);
}
status = PaymentStatus.SUCCEEDED;
confirmedChannelTransactionId = channelTransactionId;
return Optional.of(new PaymentSucceeded(
UUID.randomUUID(), paymentId, channelTransactionId, amount));
}
public void markResultUnknown() {
if (status == PaymentStatus.PAYING || status == PaymentStatus.UNKNOWN) {
status = PaymentStatus.UNKNOWN;
return;
}
throw new IllegalStateException("payment cannot become unknown from " + status);
}
public void confirmFailure() {
if (status == PaymentStatus.PAYING || status == PaymentStatus.UNKNOWN) {
status = PaymentStatus.FAILED;
return;
}
if (status != PaymentStatus.FAILED) {
throw new IllegalStateException("payment cannot fail from " + status);
}
}
private void requireSameMoney(Money actual) {
if (!amount.sameAs(actual)) throw new IllegalArgumentException("amount or currency mismatch");
}
}
public record Money(BigDecimal value, Currency currency) {
public Money {
if (value == null || value.signum() < 0 || currency == null) {
throw new IllegalArgumentException("invalid money");
}
}
public boolean sameAs(Money other) {
return other != null
&& currency.equals(other.currency)
&& value.compareTo(other.value) == 0;
}
}
public record PaymentSucceeded(
UUID eventId,
String paymentId,
String channelTransactionId,
Money amount) {}
这段模型没有试图代表所有支付产品规则。例如“关闭后是否允许渠道的在途成功回调确认”必须根据渠道语义决定。关键在于:这是显式决策,不能由一个通用 setter 在无意间决定。
状态机还解决回调乱序。成功事实一旦被确认,后来到达的失败通知不能把它覆盖;同一成功通知重复到达也不会生成第二个业务事件。应用层之外仍要有数据库约束,因为并发请求可能加载到两个旧版本的聚合。
渠道适配层只翻译协议,不拥有状态
渠道 SDK 经常把“请求是否成功发出”“渠道是否受理”“用户是否完成支付”混在一个响应对象中。把它直接暴露给领域层,渠道枚举会很快渗入支付单状态机。应用层应该把渠道协议收敛为三类结果:
public sealed interface ChannelResult {
record ConfirmedSuccess(String channelTransactionId, Money paidMoney) implements ChannelResult {}
record ConfirmedFailure(String code, String message) implements ChannelResult {}
record Indeterminate(Throwable cause) implements ChannelResult {}
}
public interface PaymentChannel {
ChannelResult pay(PaymentRequest request, Duration timeout);
ChannelResult query(String merchantPaymentId, Duration timeout);
}
Indeterminate 不是异常吞掉后的“兜底成功”,而是明确要求进入恢复路径的业务结果。渠道请求必须携带稳定的商户支付单号或渠道要求的 idempotency key。对于已经可能写出请求的 read timeout,不能自动原样重发;应优先查询。只有在能够证明请求尚未发送,或渠道明确承诺该幂等键重复调用安全时,才考虑重试。
发起支付的流程也因此分成两个短事务,中间的网络调用不持有数据库事务:
事务 A:创建/读取 PaymentOrder,CREATED -> PAYING,提交
↓
调用渠道(带超时与稳定幂等键)
↓
事务 B:把明确结果或 UNKNOWN 通过状态机写回,提交
如果进程在事务 A 后、渠道调用前崩溃,恢复任务会发现长期停留在 PAYING 的记录。它不能直接假定已发起过渠道调用;应根据是否已持久化渠道请求尝试、渠道查询能力和产品的过期规则决定是补发、查询还是关闭。这也是为什么生产实现通常会单独记录 PaymentAttempt :它描述“本地是否已经准备或尝试与渠道交互”,而不是替代支付单。
回调、查询与恢复:超时不是失败
把一次渠道调用当作普通 RPC 是支付系统最昂贵的误解。对于读超时,安全的流程应是:
T0 支付单从 CREATED 变为 PAYING 并提交
T1 服务以 paymentId 作为渠道幂等键发起请求
T2 渠道完成扣款,但响应在网络中丢失
T3 本地收到 read timeout,支付单变为 UNKNOWN
T4 回调或恢复任务查询渠道
T5 渠道返回已成功的外部流水号与金额
T6 本地原子确认 SUCCEEDED,并写入 Outbox
T7 订单等下游收到事件后推进自己的状态
若 T4 查询到明确失败,才可以进入 FAILED 。若持续无法确认,则保留 UNKNOWN ,按渠道规则退避重试并纳入对账;不能为了让前台“有个结果”而伪造失败。同步响应、异步回调、主动查询和日终(或约定周期)对账不是四个彼此替代的功能,而是四条证据来源:越靠后,延迟越大、覆盖的故障窗口越广。
回调处理还要验证签名、商户号、金额、币种和外部流水号。HTTP 200 只是接收回执,不是“无需校验”的许可。
并发下,数据库约束才是最后的裁判
同一笔支付可能同时被同步响应、回调、查询任务和人工补单看到。用 Redis 锁包住“读—判断—写”看似能让代码串行,但锁租约过期、进程暂停、锁与数据库事务不同步都会增加新的故障面。对支付状态这种最终要写数据库的共享事实,先让数据库表达规则通常更直接。
前面表结构中的 uk_merchant_request 防止同一请求创建多张支付单; uk_channel_transaction 防止一条外部流水归属两张支付单。唯一索引不是业务校验的替代,却是并发场景最后不可绕过的保护。
确认成功时,应用服务只编排事务,领域对象决定是否合法,持久化层以条件更新避免丢失更新:
@Transactional
public void confirmChannelSuccess(ChannelCallback callback) {
callbackVerifier.verify(callback); // 签名、商户、金额、币种
PaymentOrder order = paymentRepository.findById(callback.paymentId())
.orElseThrow(() -> new IllegalArgumentException("payment not found"));
Optional<PaymentSucceeded> event = order.confirmSuccess(
callback.channelTransactionId(), callback.money());
if (event.isEmpty()) return; // 同一成功事实的重复回调
int changed = paymentRepository.updateIfVersionMatches(order);
if (changed != 1) throw new ConcurrentModificationException("payment updated concurrently");
outboxRepository.insert(OutboxEvent.of(event.get()));
}
UPDATE payment_order
SET status = 'SUCCEEDED',
channel_transaction_id = :channelTransactionId,
version = version + 1,
updated_at = CURRENT_TIMESTAMP(6)
WHERE payment_id = :paymentId
AND version = :expectedVersion
AND status IN ('PAYING', 'UNKNOWN');
这里的事务边界很窄:更新支付事实和记录待发布事件。它不调用订单服务、不发 Kafka、不做渠道查询。若条件更新失败,重新读取后由状态机判断:可能已经成功,也可能是冲突的流水,绝不能盲目重试更新。
热点余额等真正需要串行化的资金资源,可采用带余额条件的单行更新,或在冲突持续很高时评估短事务的 SELECT ... FOR UPDATE 。无论哪一种,事务内都不应包含渠道 HTTP 调用、跨服务 RPC 或同步发消息;数据库锁的成本主要是持有时间,而不是 SQL 写法。
Outbox 解决的是双写窗口,不是“恰好一次”
支付成功后,订单需要知道这件事。下面的直觉实现有一个无法靠 try/catch 补齐的窗口:
@Transactional
public void markSuccessAndPublish(...) {
paymentRepository.update(...);
kafkaTemplate.send("payment.succeeded", event); // 不可靠的双写
}
如果数据库提交成功后 JVM 宕机、消息发送失败,下游永远不会得知支付已成功;如果先发消息再回滚数据库,下游会看见一个从未发生的成功。
这正是 Outbox 必要的地方:当且仅当本地数据库事实与异步事件必须共同被保存时,在同一个本地事务写入两者。
CREATE TABLE outbox_event (
event_id CHAR(36) NOT NULL PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(16) NOT NULL,
attempts INT NOT NULL DEFAULT 0,
available_at DATETIME(6) NOT NULL,
lease_until DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL,
published_at DATETIME(6) NULL,
KEY idx_dispatch (status, available_at, lease_until)
);
Relay 从可用事件中领取一小批,以 event_id 作为消息 ID 和消费幂等键;成功后标记已发布。发送成功但更新 PUBLISHED 前进程崩溃时,租约过期的事件会再次发送。因此 Outbox 提供的是不丢事件的至少一次投递,不是端到端 exactly-once。
多实例 Relay 不能都用“查出 NEW 再发送”的方式扫描,否则会把同一批事件并发发出。MySQL 8 可用短事务领取记录;领取之后立刻提交,真正的 broker 调用放到事务外:
SELECT event_id
FROM outbox_event
WHERE status = 'NEW'
AND available_at <= CURRENT_TIMESTAMP(6)
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE outbox_event
SET status = 'PUBLISHING',
lease_until = DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL 60 SECOND),
attempts = attempts + 1
WHERE event_id IN (:eventIds);
这里的 60 秒只是示例。租约应覆盖正常发送时长并留有余量,同时不能长到进程故障后事件长期不可恢复。领取成功后,Relay 逐条或按 broker 能力批量投递:
public void dispatchOnce() {
List<OutboxEvent> events = transactionTemplate.execute(
ignored -> outboxRepository.claimAvailable(100, clock.instant().plusSeconds(60)));
for (OutboxEvent event : events) {
try {
messagePublisher.publish(event.eventType(), event.eventId(), event.payload());
transactionTemplate.executeWithoutResult(
ignored -> outboxRepository.markPublished(event.eventId(), clock.instant()));
} catch (TransientMessagingException e) {
transactionTemplate.executeWithoutResult(ignored ->
outboxRepository.reschedule(
event.eventId(), nextAttemptAt(event.attempts()), e.getClass().getSimpleName()));
} catch (Exception e) {
transactionTemplate.executeWithoutResult(ignored ->
outboxRepository.markForInvestigation(event.eventId(), redact(e)));
}
}
}
一个容易忽略的细节是:当发布成功后 markPublished 失败,事件必须保留并在租约到期后重发;不能为了避免重复而把它删除。重复由消费者幂等承担,丢失却无法由消费者补回。对“发布中且租约已过期”的记录,下一轮领取时需把它视为可恢复对象;这也是表中存在 lease_until 的原因。
事件顺序也要按真实需求设计。若订单消费者要求同一支付单的事件有序,消息 key 应为 paymentId ,使它们尽可能进入同一分区;若只按用户 ID 分区,热点用户会制造分区倾斜,却没有保护支付单这个真正需要顺序的粒度。即便在同一分区,消费者仍应能处理重复和旧事件,不能把 broker 顺序当作唯一正确性屏障。
消费者必须把“是否已经处理过”和“业务动作”放入同一事务:
CREATE TABLE consumed_event (
consumer_name VARCHAR(64) NOT NULL,
event_id CHAR(36) NOT NULL,
consumed_at DATETIME(6) NOT NULL,
PRIMARY KEY (consumer_name, event_id)
);
@Transactional
public void onPaymentSucceeded(PaymentSucceeded event) {
if (!consumedEventRepository.tryInsert("order-paid-projection", event.eventId())) return;
orderRepository.markPaidIfAwaitingPayment(event.paymentId());
}
tryInsert 依赖主键冲突返回 false。这样消息即使投递十次,订单领域的这项效果最多执行一次。下游业务仍应有自己的状态条件和业务唯一键,因为一次“处理记录已插入、业务写入失败”必须由同一事务回滚,不能靠消费者 offset 兜底。
对于永久失败,不能无限快速重试。按错误类型处理:参数或签名错误直接告警;临时基础设施错误采用退避和抖动;无法解释的业务冲突进入隔离队列或人工处理。无论哪种,事件状态、失败原因和最近一次尝试时间都要可查询。
对账不是报表,而是外部事实的最终校验
回调和主动查询都会失败:渠道可能延迟推送,查询接口也可能暂时不可用。对账的作用不是给财务补一张报表,而是把本地状态与渠道结算事实做最后一次可恢复的比较。
一条可定位的对账记录至少关联 paymentId 、外部流水号、金额、币种、渠道状态和确认时间。差异不应直接由脚本把状态改掉,而应进入与正常回调同一条确认通道:校验事实、执行状态机、写审计记录、必要时生成事件。这样人工补单不会绕开不变性。
恢复任务也需要边界:只扫描 PAYING 或 UNKNOWN 且超过确认窗口的支付单;每次查询使用渠道幂等标识;达到渠道规定的最大确认期限后,升级为人工核对而不是自动宣判失败。扫描索引、批次大小与退避策略要按实际积压量和渠道限流确定,不能凭空承诺吞吐数据。
运营时应看到“哪一段没有收敛”
支付链路最有价值的监控不是笼统的接口 QPS,而是能定位不一致窗口的指标。至少应按渠道、支付方式和错误类型观察:
| 信号 |
它暴露的问题 |
典型动作 |
PAYING/UNKNOWN 的年龄分布与数量 |
查询或回调链路没有收敛 |
检查渠道可用性、恢复任务与限流 |
| 支付成功到 Outbox 已发布的延迟 |
双写后的异步出口被阻塞 |
检查 Relay 领取、broker、失败重试 |
| 消费滞后和重复命中率 |
下游处理能力或重放异常 |
区分扩容、慢 SQL、毒消息 |
| 对账差异数量及存活时间 |
本地事实与外部事实不一致 |
进入同一确认通道,追踪人工闭环 |
| 状态迁移拒绝次数 |
回调乱序、请求错误或代码回归 |
按来源记录状态对和渠道响应码 |
日志中用 paymentId 、 merchantRequestId 、渠道流水号和 eventId 作为关联键,但绝不记录完整回调报文、签名密钥、银行卡信息或用户敏感标识。对外请求与回调接收还应分别记录耗时和结果类别;把 timeout 都打成 ERROR 并不能告诉值班人员“渠道是否已扣款”。
测试要攻击不变性,而不是只测 Controller
这类模型最值得自动化的不是接口 200,而是状态与效果在失败条件下仍成立。以下测试比堆砌大量 CRUD 测试更接近真实风险:
- 同一
merchantRequestId 并发创建 100 次,最终只能有一张支付单;参数不同的重放必须拒绝。
- 对
PAYING 或 UNKNOWN 重复确认同一成功流水,成功事件只落库一次;不同成功流水必须被隔离并告警。
- 成功之后再注入失败回调,状态仍为
SUCCEEDED 。
- 模拟“渠道成功、响应超时”,断言本地先进入
UNKNOWN ,查询确认后才变为成功。
- 模拟“支付单和 Outbox 已提交、Relay 发送成功、标记已发布前宕机”,断言事件可能重发但下游效果不重复。
- 两个确认线程同时读取同一版本,断言只有一个条件更新成功,另一个重读后得到幂等结果。
这些测试不依赖真实渠道账号。渠道适配器可用契约测试验证签名、金额编码和状态映射;状态机可做参数化测试或属性测试,枚举所有合法与非法迁移。真正接入渠道的沙箱验证则应覆盖回调验签、重复通知和查询结果,而不能以一次 Happy Path 成功作为上线依据。
不要把 DDD 误解成一套技术采购清单
这次重构没有因为使用 DDD 就自动上 CQRS、Event Sourcing、分库分表或分布式锁。
- 查询只是按支付单和流水检索时,普通读模型已经足够;报表与写模型在查询压力或模型复杂度明确冲突时,再评估读模型分离。
- 资金流水和审计日志可以提供可追溯性;只有确实需要从全部历史事件重建业务状态时,Event Sourcing 的复杂度才值得承担。
- 限界上下文不是微服务数量。一个部署单元内可以有多个清晰模块;只有团队协作、数据所有权和独立演进的收益超过远程调用与运维成本,才拆服务。
- Redis 可以是缓存或限流能力,但不是支付事实的最终裁判。先有唯一键、条件更新和事务边界,才谈它是否必要。
DDD 的价值也不在于让 DomainService 取代旧的万能 Service 。能够自然归属支付单的规则,就由支付单表达;只有无法归属于单一聚合、且确实是领域规则的计算,才值得有领域服务。应用服务只负责一个用例的编排、事务和端口调用。
一套可复用的判断顺序
面对下一条复杂业务需求,不必先问“这里要不要上 DDD、Kafka 或 Redis”,可以按这个顺序拆解:
- 把触发条件、状态和结果说成唯一的业务语言;先区分支付事实、订单事实和渠道事实。
- 写下不能被破坏的不变性,再决定谁拥有它。能在一个聚合和本地事务解决的,不扩成跨服务问题。
- 对远程调用区分成功、明确失败与结果未知;为未知状态准备回调、查询和对账,而不是把超时当失败。
- 只要数据库状态需要驱动异步下游,就检查双写窗口;确有窗口时再使用 Outbox,并接受至少一次投递。
- 让数据库唯一键、条件更新和消费幂等共同保护效果;消息顺序、锁和重试都只是补充,不是业务正确性的替身。
真正成熟的 DDD 不是把所有东西放入领域层,而是知道什么必须一起保持一致,什么应该以事件交接,什么根本不该被放进同一个事务。对于支付系统,最可靠的架构也不是一串组件名,而是:支付事实有所有者,状态迁移有约束,未知结果可以恢复,跨边界的事件不丢失且可重复处理,外部世界最终能被对账校验。