Yujun's Blog

逆向流程架构:交易系统的必要性与设计原则

July 27, 2025 (1d ago)SD

逆向流程架构:交易系统的必要性与设计原则

在构建任何交易系统时,我们总是把精力放在设计着“用户下单 -> 支付成功 -> 发货 -> 确认收货”这条正向流程。它是系统的价值主干,承载着我们的KPI。可能我们平时会花费大量时间想着如何去优惠这套流程。

但在这条光明大道的背后,潜藏着一个复杂、晦暗却至关重要的方向——逆向流程。

逆向流程的众生相:不止是退款那么简单

逆向流程并不是一个单一的动作,而是覆盖交易全生命周期的很多状态。根据触发的时间点,我们可以暂时分为以下几种情况:

  • 支付前取消

比如,用户已下单,但尚未支付(如“待支付”状态)。这是最简单的逆向流程,不涉及资金。主要工作是关闭订单、释放被锁定的库存或优惠名额。只涉及状态变更。我们要做的包括,释放库存,释放优惠,订单关闭。

  • 支付后,履约前退款

用户已支付,但服务/商品尚未履约(如拼团“未成团”、电商“未发货”、外卖“商家未接单”)。这是最常见的退款场景,难度陡增,因为它首次引入了与外部支付系统的交互。不仅涉及状态的一个变更,还涉及资金的逆转,我们要做的是原路退款,更新订单状态,回滚优惠等等。

  • 履约后退款退货

比如,用户已收货,但对商品不满意(如“7天无理由退货”)。这是最复杂的逆向流程,因为它不仅涉及线上系统,还涉及线下的仓储和物流。我们要做的不仅是状态变更和资金逆转了,还涉及线下的仓储和物流。这里就涉及到了售后工单,退单入库,质检,生成退款单等需要进行的操作。

  • 系统发起的逆向操作

这代表着一些并非由用户主动发起,而是由系统规则触发的行为。如“拼团超时未成团,系统自动退款”、“风控系统识别为欺诈交易,系统自动关单退款”。通常是批量的、自动的逆向操作,对系统的吞吐量和稳定性要求很高。比如,超时检测,批量退款,凤控关单等操作。

核心挑战,技术难点

那么了解完有哪些逆向流程之后,我们首先要知道的就是,这其中有哪些难点亟须我们解决?

这里总结了下,设计逆向流程时,我们必须直面的三大核心挑战:

  • 状态一致性

逆向操作涉及多个独立系统(业务、支付、仓储等)的状态变更。必须保证这些变更在最终结果上是原子性的。任何中间状态的失败都可能导致数据不一致(例如,退款成功但订单状态未更新)。

  • 幂等性

分布式环境中的网络延迟和重试机制,使得逆向操作的请求可能被多次接收。系统必须确保同一请求被执行一次和执行N次的效果完全相同,以防止重复退款等严重问题。

  • 可靠性

对外部系统(特别是支付网关)的依赖是逆向流程中最脆弱的环节。系统必须具备容错能力,能够处理网络超时、下游服务瞬时不可用等异常,保证关键操作(如退款指令)最终能够成功传达。

架构设计原则

为了应对上述挑战,一套健壮的逆向流程架构应遵循以下几个设计原则。

状态机建模

正如之前博文提到的,由实体的状态梳理出整个流程之后,可以回溯出整个业务流程。

逆向流程本质上其实也就是复杂的状态转移。使用形式化的状态机对订单、退款单等核心实体进行建模,可以从根本上杜绝非法状态转换。

比如,我们可以为每个核心实体定义一组有限的状态集(ENUM)、触发状态转移的事件集,以及一个状态转移函数。任何状态变更都必须通过这个函数进行,而不是在业务代码中随意设置状态。

单据分离

原始订单的核心价值在于其作为交易完成时刻的不可变快照。在其上直接进行逆向修改,会污染数据,破坏其审计价值。

所以我们可以分离一下,为每种逆向流程创建独立的、有完整生命周期的单据实体,如退款单 (Refund Order)、售后单 (After-Sale Order)。原始订单通过外键与这些逆向单据关联。这符合单一职责原则,并极大地提升了系统的可追溯性和模块化程度。

这里可能有些抽象,以一个简单的例子说明一下:

比如,我们在订单表orders上直接增加字段来处理退款:

-- 不推荐的设计
CREATE TABLE `orders` (
    `order_id` BIGINT PRIMARY KEY,
    `user_id` BIGINT,
    `amount` DECIMAL(10, 2),
    `status` VARCHAR(20), -- 'PAID', 'REFUNDING', 'REFUNDED'
    `refund_amount` DECIMAL(10, 2),
    `refund_reason` VARCHAR(255),
    `refund_time` DATETIME
);

看似很好,但其实也有一些弊端:

  • 如果一笔订单包含多个商品,用户只想退其中一个,怎么记录?
  • 如果允许用户对一笔订单发起多次退款申请,如何记录每一次的申请状态和原因?
  • orders表既要记录交易事实,又要记录退款过程,职责不清,字段越来越多,表结构迅速腐化
  • status字段既要表示订单的履约状态(PAID, SHIPPED),又要表示退款状态(REFUNDING),容易导致逻辑混乱。

所以我们可以分离设计,为每一个逆向流程创建独立的单据表。

-- 订单表 (只记录交易事实)
CREATE TABLE `orders` (
    `order_id` BIGINT PRIMARY KEY,
    `user_id` BIGINT,
    `total_amount` DECIMAL(10, 2),
    `status` VARCHAR(20) -- PAID, SHIPPED, COMPLETED, CLOSED
    -- ... 其他交易快照信息
);

-- 退款单表 (专门记录退款过程)
CREATE TABLE `refund_orders` (
    `refund_id` BIGINT PRIMARY KEY,
    `order_id` BIGINT NOT NULL, -- 外键关联到 orders 表
    `user_id` BIGINT,
    `refund_request_id` VARCHAR(64) UNIQUE, -- 用于幂等性校验的唯一请求ID
    `refund_amount` DECIMAL(10, 2),
    `reason` VARCHAR(255),
    `status` VARCHAR(20), -- APPLIED, PROCESSING, SUCCESS, FAILED
    `create_time` DATETIME,
    `success_time` DATETIME
    -- ... 还可以有关联的退款商品明细等
);

这样,orders表的状态机和 refund_orders表的状态机可以独立演进,互不干扰。可以轻松支持一笔订单对应多笔退款单(部分退款、多次退款)。

  • 可追溯性:每一笔逆向操作都有一个独立的、完整的记录,包含其申请、处理、完成的全过程,非常便于审计和问题排查。
  • 模块化:退款逻辑可以封装成一个独立的RefundService,它只操作refund_orders表,与核心的OrderService解耦。

幂等性保障

不仅是逆向流程,正向流程梳理业务的时候,我们也要考虑幂等性问题,这个大家应该很熟悉了。

比如,用户在App上点击“申请退款”按钮,由于网络卡顿,App在短时间内发送了两次相同的退款请求。这时候,系统应该确保外部对同一逆向操作的重复请求,不会在系统内产生副作用。

一般幂等性保障我们都要以一种层次化保障的方式来实现,这里也不例外:

  • 入口层:唯一请求ID + 防重表
  1. 我们要强制要求调用方(App、其他微服务)在每次发起新的退款申请时,必须生成一个唯一的 refund_request_id(通常是UUID)。
  2. 设计防重表/即唯一索引:
-- refund_orders 表中的 UNIQUE 索引
ALTER TABLE `refund_orders` ADD UNIQUE INDEX `uk_refund_request_id` (`refund_request_id`);
// 在 Controller 或 Service 的入口处
public RefundResult applyRefund(RefundRequest request) {
    try {
        // 尝试插入退款单,利用数据库的唯一键约束来防重
        RefundOrder refundOrder = createRefundOrderFrom(request);
        refundOrderDao.insert(refundOrder); // 如果 request_id 已存在,这里会抛出 DuplicateKeyException
        
        // 插入成功,继续执行后续的异步任务触发等逻辑
        triggerRefundProcessing(refundOrder);
        return RefundResult.success(refundOrder.getRefundId());

    } catch (DuplicateKeyException e) {
        // 捕获唯一键冲突异常,说明是重复请求
        log.warn("重复的退款请求: {}", request.getRefundRequestId());
        
        // 查询已存在的退款单,返回其当前状态
        RefundOrder existingOrder = refundOrderDao.findByRequestId(request.getRefundRequestId());
        return RefundResult.fromExisting(existingOrder);
    }
}
  • 逻辑层:状态机校验

这里我们可能还不太熟练,平时可能只做第一层,即使请求通过了入口层的防重(比如创建了退款单),后续的异步处理任务也可能被重复触发。

这时候我们就可以借助状态机来进行校验,这是业务逻辑层面的最后一道防线。它确保了只有处于正确前置状态的单据,才会被执行后续的操作,防止了已完成或已失败的流程被再次错误地执行。上代码:

// 在处理退款的异步任务中
public void processRefundTask(long refundId) {
    RefundOrder refundOrder = refundOrderDao.findByIdForUpdate(refundId); // 加悲观锁,防止并发处理
    
    // 状态机前置校验
    if (!"APPLIED".equals(refundOrder.getStatus())) {
        log.info("退款单 {} 状态不为APPLIED,当前为 {},跳过处理。", refundId, refundOrder.getStatus());
        return; // 直接返回,不做任何操作
    }
    
    // ... 执行调用支付网关等核心逻辑 ...
    
    // 更新状态
    refundOrder.setStatus("PROCESSING");
    refundOrderDao.update(refundOrder);
}

异步化与补偿机制

核心理念是将内部状态变更外部依赖交互解耦,并通过补偿确保最终一致性。

​来过下具体流程:

  • 任务持久化 (解耦点)

    • applyRefund方法成功创建refund_orders记录(状态为APPLIED)并提交事务后,它的同步操作就结束了。

    • 紧接着,它会向一个任务表 (task_queue) 中插入一条记录,或者向一个消息队列中发送一条消息。

    • ​任务/消息内容可能为:{ "task_type": "PROCESS_REFUND", "business_id": refund_id }

    • ​目的:主流程(创建退款单)与耗时且不确定的外部调用(调用支付网关)彻底分离。用户可以立即得到“退款申请已受理”的响应。这里也是DDD架构中的事件分离点,是一种已完成的状态。

  • 异步执行器 (后台进程)

    • ​​​实现方式:可以是一个定时任务(如Spring @Scheduled),不断扫描task_queue表;也可以是一个MQ消费者,监听退款主题。 ​​​
    • ​主要职责是:获取任务 -> 调用processRefundTask(refundId)-> 根据执行结果删除或更新任务状态。 ​
  • 主动查询补偿 (应对回调丢失)

    • ​场景:调用支付网关退款后,支付网关返回“处理中”,并承诺稍后会通过HTTP回调通知我们结果。但这个回调请求可能因为网络问题而丢失。​
    • ​​实现:需要一个独立的、更高层级的定时任务,我们称之为状态对账/补偿任务
@Scheduled(fixedDelay = 5 * 60 * 1000) // 每5分钟执行一次
public void compensateRefundStatus() {
    // 1. 查询出所有长时间处于“处理中”状态的退款单
    List<RefundOrder> pendingOrders = refundOrderDao.findLongPendingOrders("PROCESSING", 10 minutes ago);
    
    for (RefundOrder order : pendingOrders) {
        try {
            // 2. 主动调用支付网关的“退款查询接口”
            PaymentGatewayQueryResult result = paymentGatewayClient.queryRefund(order.getRefundRequestId());
            
            // 3. 根据查询到的最终状态,更新本地数据库
            if (result.isSuccess()) {
                updateLocalRefundStatus(order.getRefundId(), "SUCCESS");
            } else if (result.isFailed()) {
                updateLocalRefundStatus(order.getRefundId(), "FAILED");
            }
            // 如果对方也返回“处理中”,则不做任何操作,等待下次轮询
        } catch (Exception e) {
            log.error("补偿任务查询退款单 {} 状态失败", order.getRefundId(), e);
        }
    }
}

这中通过主动拉取来弥补被动推送的不可靠性的方式,是保障与外部系统最终一致性的黄金法则,很多地方我们都会用到这样一种思想。

对账

对账是什么?

假设现在所有系统都会出错,我们必须通过独立的、权威的数据源进行交叉验证,发现并修复差异。

对账系统的运作模式:

  • ​数据源:

    • ​内部数据源:我方系统 refund_orders表中,所有在T-1日(昨天)终态为SUCCESS的退款记录。
    • ​​外部数据源:通过SFTP、API等方式,从第三方支付渠道获取的T-1日退款成功的渠道对账文件(通常是CSV或文本文件)。 ​​
  • ​对账过程(通常是一个独立的批量任务,通常在凌晨执行):

    • ​Step 1: 数据加载与标准化:分别加载内部记录和外部对账文件到内存或临时数据库表中,并统一格式。

    • ​Step 2: 以外部渠道为准,进行匹配:

      • 遍历渠道对账文件中的每一笔记录。
      • refund_request_id或渠道流水号,在我方记录中查找。
    • ​Step 3: 核对与勾销:

      • 能找到,且金额一致:平账。标记双方记录为“已对平”。
      • ​​能找到,但金额不一致:错账。记录差异,需要人工介入。​​
      • ​​渠道有,我方没有:我方短款/漏单。这是最严重的情况,说明钱退了,但我方系统没有记录。必须立即生成补单任务。 ​​
    • ​Step 4: 反向核对:

      • 遍历我方所有T-1日成功的记录中,那些在第3步未被勾销的。
      • ​​这些记录意味着我方认为成功了,但渠道方没有记录。这叫我方长款。需要人工核实,可能是退款失败但我方系统错误地记为成功。​​
  • ​产出与驱动:

    • 对账任务的最终产出是一份差异报告。​​
    • ​对于可自动修复的差异(如我方漏单),系统会自动创建差错处理任务。 ​
    • ​对于无法自动修复的差异,会生成人工工单,交由运营或财务人员处理。

对账是独立于业务流程之外的最终审计和保障机制。无论业务流程中存在多么隐蔽的Bug,最终都能在对账环节被发现和修正,确保了资金流的绝对安全。

​​

总结

能够理解这部分,必要之恶,的复杂性,并设计出处理方案,是我们系统成熟度的一个重大考量,一定不要忽略逆向流程的设计。

Comments