找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4870

积分

0

好友

632

主题
发表于 昨天 17:50 | 查看: 15| 回复: 0

支付回调到了两次,第一条说成功,第二条因为渠道重试而晚到,却带着失败结果。系统该信哪一条?

如果支付单只是数据库里一行任意可改的 status ,答案往往很糟:后到的失败把已经成功的支付单覆盖掉;订单没有履约,用户却已经被扣款。再加上同步调用超时、消息重复投递、进程在提交后宕机,这类问题不是多写几层 Service 能解决的。

这篇文章讨论的不是“怎样把 Java 项目改成 DDD 目录结构”,而是一个日交易量千万级、峰值 TPS 5000+ 的支付系统在业务扩张后,如何回答四个更实际的问题:

  • 什么是支付单,什么是渠道流水,谁拥有支付最终状态;
  • 哪些规则必须在一个本地事务内成立,哪些只能接受最终一致;
  • 第三方调用超时、回调重复或乱序时,如何不误判、更不重复执行;
  • 数据库状态与异步事件如何一起可靠地向后推进。

文中的业务规模与既有技术背景来自这次重构案例;表名、代码和时间参数则是为说明设计而简化的示例,不应被当作某个支付渠道的接口规范。

从一段“大 Service”开始失控

系统早期只有少量支付方式时,下面的实现并不离谱:

支付成功
  ├─ 更新业务订单
  ├─ 更新支付记录
  ├─ 核销优惠券
  ├─ 发送积分
  ├─ 通知用户
  └─ 写审计日志

但业务增加退款、余额、分账、多币种和对账之后,同一个方法同时承担了支付确认、履约编排、资金处理和通知。最危险的变化不是方法变成几百行,而是团队已经无法回答:哪一步失败必须回滚扣款,哪一步晚几分钟完成仍然正确?

当产品说“订单支付成功”,财务说“渠道已扣款”,开发说“支付流水成功”时,双方甚至可能都在描述真实现象,却不是同一件事。代码里于是并存 order_statuspayment_statustrade_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 、外部流水号、金额、币种、渠道状态和确认时间。差异不应直接由脚本把状态改掉,而应进入与正常回调同一条确认通道:校验事实、执行状态机、写审计记录、必要时生成事件。这样人工补单不会绕开不变性。

恢复任务也需要边界:只扫描 PAYINGUNKNOWN 且超过确认窗口的支付单;每次查询使用渠道幂等标识;达到渠道规定的最大确认期限后,升级为人工核对而不是自动宣判失败。扫描索引、批次大小与退避策略要按实际积压量和渠道限流确定,不能凭空承诺吞吐数据。

运营时应看到“哪一段没有收敛”

支付链路最有价值的监控不是笼统的接口 QPS,而是能定位不一致窗口的指标。至少应按渠道、支付方式和错误类型观察:

信号 它暴露的问题 典型动作
PAYING/UNKNOWN 的年龄分布与数量 查询或回调链路没有收敛 检查渠道可用性、恢复任务与限流
支付成功到 Outbox 已发布的延迟 双写后的异步出口被阻塞 检查 Relay 领取、broker、失败重试
消费滞后和重复命中率 下游处理能力或重放异常 区分扩容、慢 SQL、毒消息
对账差异数量及存活时间 本地事实与外部事实不一致 进入同一确认通道,追踪人工闭环
状态迁移拒绝次数 回调乱序、请求错误或代码回归 按来源记录状态对和渠道响应码

日志中用 paymentIdmerchantRequestId 、渠道流水号和 eventId 作为关联键,但绝不记录完整回调报文、签名密钥、银行卡信息或用户敏感标识。对外请求与回调接收还应分别记录耗时和结果类别;把 timeout 都打成 ERROR 并不能告诉值班人员“渠道是否已扣款”。

测试要攻击不变性,而不是只测 Controller

这类模型最值得自动化的不是接口 200,而是状态与效果在失败条件下仍成立。以下测试比堆砌大量 CRUD 测试更接近真实风险:

  • 同一 merchantRequestId 并发创建 100 次,最终只能有一张支付单;参数不同的重放必须拒绝。
  • PAYINGUNKNOWN 重复确认同一成功流水,成功事件只落库一次;不同成功流水必须被隔离并告警。
  • 成功之后再注入失败回调,状态仍为 SUCCEEDED
  • 模拟“渠道成功、响应超时”,断言本地先进入 UNKNOWN ,查询确认后才变为成功。
  • 模拟“支付单和 Outbox 已提交、Relay 发送成功、标记已发布前宕机”,断言事件可能重发但下游效果不重复。
  • 两个确认线程同时读取同一版本,断言只有一个条件更新成功,另一个重读后得到幂等结果。

这些测试不依赖真实渠道账号。渠道适配器可用契约测试验证签名、金额编码和状态映射;状态机可做参数化测试或属性测试,枚举所有合法与非法迁移。真正接入渠道的沙箱验证则应覆盖回调验签、重复通知和查询结果,而不能以一次 Happy Path 成功作为上线依据。

不要把 DDD 误解成一套技术采购清单

这次重构没有因为使用 DDD 就自动上 CQRS、Event Sourcing、分库分表或分布式锁。

  • 查询只是按支付单和流水检索时,普通读模型已经足够;报表与写模型在查询压力或模型复杂度明确冲突时,再评估读模型分离。
  • 资金流水和审计日志可以提供可追溯性;只有确实需要从全部历史事件重建业务状态时,Event Sourcing 的复杂度才值得承担。
  • 限界上下文不是微服务数量。一个部署单元内可以有多个清晰模块;只有团队协作、数据所有权和独立演进的收益超过远程调用与运维成本,才拆服务。
  • Redis 可以是缓存或限流能力,但不是支付事实的最终裁判。先有唯一键、条件更新和事务边界,才谈它是否必要。

DDD 的价值也不在于让 DomainService 取代旧的万能 Service 。能够自然归属支付单的规则,就由支付单表达;只有无法归属于单一聚合、且确实是领域规则的计算,才值得有领域服务。应用服务只负责一个用例的编排、事务和端口调用。

一套可复用的判断顺序

面对下一条复杂业务需求,不必先问“这里要不要上 DDD、Kafka 或 Redis”,可以按这个顺序拆解:

  1. 把触发条件、状态和结果说成唯一的业务语言;先区分支付事实、订单事实和渠道事实。
  2. 写下不能被破坏的不变性,再决定谁拥有它。能在一个聚合和本地事务解决的,不扩成跨服务问题。
  3. 对远程调用区分成功、明确失败与结果未知;为未知状态准备回调、查询和对账,而不是把超时当失败。
  4. 只要数据库状态需要驱动异步下游,就检查双写窗口;确有窗口时再使用 Outbox,并接受至少一次投递。
  5. 让数据库唯一键、条件更新和消费幂等共同保护效果;消息顺序、锁和重试都只是补充,不是业务正确性的替身。

真正成熟的 DDD 不是把所有东西放入领域层,而是知道什么必须一起保持一致,什么应该以事件交接,什么根本不该被放进同一个事务。对于支付系统,最可靠的架构也不是一串组件名,而是:支付事实有所有者,状态迁移有约束,未知结果可以恢复,跨边界的事件不丢失且可重复处理,外部世界最终能被对账校验。




上一篇:Liquid Network 3.2 亿美元被盗:cache key 碰撞漏洞与共识分裂分析
下一篇:Mac 硬盘清理实测:写了个 Skill 一次释放 100 多 G,开源 Disk Raccoon
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-10 16:59 , Processed in 1.473151 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表