EDA方案说明 - 以还款流程举例

2022-07-25,,,,

目录

    • 还款流程概述
      • 业务流程介绍
        • 还款流水初始化
        • 代扣
        • 外部核销
        • 内部核销
        • 后续处理
    • 重构前的设计
      • 存在的问题
        • 逻辑节点过多,接口耗时与可靠性难以预估,无法保证用户体验
        • 保证数据一致性的成本高,逻辑重
        • 发生异常时难以自动恢复,无法实现服务自治
        • 流程不易维护,变更成本和风险大
        • 非关联流程间存在不应该存在的耦合
    • 重构后的设计
      • 子流程
          • 子流程1:还款提交
          • 子流程2 & 子流程3:代扣提交 & 代扣成功
          • 子流程4:外部核销
          • 子流程5:内部核销
          • 后续处理
      • 如何解决问题
          • 通过异步消息解耦流程
          • 消费逻辑可重入的前提下,使用消息重试机制
          • 流程变更简单
          • 不相关操作彻底解耦
      • 事件消息设计
    • 总结

笔者认为,EDA(事件驱动架构)不应该简单地被理解为使用异步或同步的消息来串联流程,它是一种架构设计的思想。

按照这种思想来设计系统架构所带来的的好处是巨大的。它能帮助我们定义服务边界,完成服务自治,更好地实现服务间的松耦合;它能更好地应对业务需求的变更,充分降低业务流程调整带来的成本与风险。

在架构设计中应用EDA思想的前提,是确保所有参与搭建或重构的设计与开发人员充分理解该思想,但这往往不那么容易,这篇文章提到了以下两点原因:

  • 事件驱动可能是客观世界的运作方式,但不是人的自然思考问题的方式

  • 事件驱动架构会增加额外的复杂性,比如调试的困难性,又比如并不直观的最终一致性

为了帮助理解,通过某产品还款流程的设计与实现,来举例说明事件驱动架构在实际业务场景中的应用方案。该实践并不能称之为EDA应用的最佳实践,只是笔者在重构过程中向EDA迈出的一小步。

还款流程概述

还款业务的正向流程如下(省略了一些非核心的逻辑与异常分支):

业务流程介绍

还款流水初始化

同步操作。

订单中心在一个事务中执行以下两个操作:

  • 带状态修改原借款订单还款状态为还款中,更新条数不为1将会触发事务回滚
  • 入库一条还款流水,还款流水中将来自上游的订单号作为唯一索引,插入失败将会触发事务回滚

这样可以保证:重复的还款请求不会触发两次还款逻辑,同一订单不同的还款请求强制串行。

代扣

同步操作,支付平台对接第三方支付渠道从用户银行卡中扣款至指定银行账户。

外部核销

将还款行为对应的信息流同步至资金方,根据资金方接口性质决定是同步或异步操作。

内部核销

外部核销完成后,内部系统相应订单流水状态与数据的变更,以及还款计划与用户账单的变更。

后续处理

还款设计到的订单数据处理完成后,进行的其它操作,如通知用户、回调上游调用方、账务系统记账等操作。

重构前的设计

被动的异步设计——能采用同步流程即采用同步流程,当某个流程遇到外部的异步依赖时才进行异步操作。

由于外部核销流程中,部分资金方接口设计为异步操作——同步接口会返回中间状态,需要等待处理完成后的回调或主动查询处理结果来异步获取核销结果——所以还款流程整体被分为两个步骤:

  1. 还款流水初始化 >> 代扣 >> 提交外部核销
  2. 外部核销结果回调 >> 内部核销 >> 后续处理

其中第一步骤交互如下:

存在的问题

逻辑节点过多,接口耗时与可靠性难以预估,无法保证用户体验

在用户提交还款请求后,进行了太多的同步操作,这大大增加了接口执行过程中的不确定性,任何一个步骤的耗时或异常都会影响整个请求。我们能够保证的仅是在局域网中的交互可靠性,例如数据库的读写,内部服务间的RPC调用,但当引入了外部系统时不确定性将大大增加,例如支付渠道的代扣接口发生波动时,甚至不可用时,将直接影响还款接口的可用性。

保证数据一致性的成本高,逻辑重

在较长的同步流程中,一个流程发生异常时,为了保证数据一致性,必须进行一些操作来处理已经完成的操作。而在涉及多个服务的分布式系统中,这样的代价往往是很大的。

发生异常时难以自动恢复,无法实现服务自治

流程完全由上游系统触发,当发生异常时当次流程作废,只能通过上游甚至客户本人重新发起请求来触发执行。

流程不易维护,变更成本和风险大

当外部环境发生变化,导致需要调整现有业务流程时,难以评估本次变更带来的影响,尤其是对于尚不熟悉完整业务流程的开发人员和测试人员。以至于任何一个微小的改动都需要进行全流程的测试,极大地增加了迭代成本。

非关联流程间存在不应该存在的耦合

例如还款的后续处理中,账务系统记账、短信通知用户、回调上游调用方这三个操作,组织在一个同步流程中,必然存在执行的先后,但客观上这三个操作并没有顺序性的要求。非关联操作耦合在一起,其中某一项操作失败后会影响其它操作的执行,失败后的重试也不便于处理,部分操作必要性高,必须保证至少执行一次(例如记账),部分操作必要性低,失败可以不重试(例如短信通知和回调上游)。

重构后的设计

将流程按合适的粒度拆分为子流程,引入事件来驱动子流程的执行,将业务流程进一步解耦。
最常见的流程解耦方式即为同步转异步,采用主动的异步设计,简单地通过异步消息来串联流程即可解决几乎所有已知的问题,但EDA的应用应该远不止此。

子流程

子流程1:还款提交

子流程2 & 子流程3:代扣提交 & 代扣成功

子流程4:外部核销

子流程5:内部核销

后续处理

如何解决问题

通过异步消息解耦流程

用户提交还款请求时,同步处理逻辑只做必要性的校验与防并发操作,校验通过后视为还款提交成功,发送还款提交成功事件消息后立即返回(消息投递成功后由消息中间件保证至少消费一次),后续操作由还款提交成功事件驱动,这样该接口只包含一次查库、一次写库、一次与消息中间件的交互与若干内存运算,接口可靠性得以保证。

消费逻辑可重入的前提下,使用消息重试机制

笔者使用的消息中间件是RocketMQ,消费失败后会进入retry队列,按照所配置的指定时间间隔重复投递。基于这个特性,设置合理的时间间隔,能极大的节省异常处理的成本,实现流程级的自治。例如,上游重复提交,下游服务调用超时等问题,根据具体场景编写相应的异常处理逻辑后,均能通过重复消费的流程来驱动,能极大地节省生产问题处理的人力成本。

以代扣接口超时举例:

// 代扣提交事件消费逻辑伪代码
public void consumeDeductSubmitEvent(String deductFlowNo) {
    // 更新代扣流水状态:初始化-->代扣处理中
    int updateCount = deductFlowRepo.updateStatus(deductFlowNo, "INIT", "PROCESSING");
    if(updateCount != 1) {
        // 查询代扣流水状态
        String status = deductFlowRepo.selectStatus(deductFlowNo);
        // 通过数据库状态防止重复消费:打印warn日志,状态异常时直接丢弃消息
        if(!StringUtils.equalsAny(status, "INIT", "PROCESSING")) {
			log.warn("DeductFlow[{}] status do not support to deduct.", deductFlowNo);
        	return;
        }
        // 预期之外的错误,打印error日志,抛出异常,需要人工查看原因,问题修复后挂起的单会通过重试完成
        if(StringUtils.equalsAny("INIT")) {
			log.error("DeductFlow[{}] status update error.", deductFlowNo);
        	throw new RuntimeException();
        }
    }
    
    // 发送代扣请求
    DeductResponse deductResponse = paymentClient.deduct(deductRequest);
    // 检查异常是否为重复的流水号(当代扣接口不幂等时),如果是则查询指定代扣流水号的代扣结果
    if(deductResponse.isSuccess() ||
       deductResponse.isDuplicateFlowNo() && paymentClient.getDeductResult(deductFlowNo).isSuccess()) {
		// do nothing
    } else {
        // 代扣明确失败,流程结束
        handleDeductFail(reason);
        return;
    }
    
    // 更新代扣流水状态:代扣处理中-->代扣成功
    
    // 发送代扣成功事件消息
}

上述消费逻辑中每一个逻辑点都设计为可重入的,那么整体的消费逻辑就是可重入的,重复消费不会带来一致性问题。如果下游系统接口设计为幂等,上游处理代扣结果的逻辑将更加统一:

// 发送代扣请求
DeductResponse deductResponse = paymentClient.deduct(deductRequest);
if(deductResponse.isSuccess()) {
    // do nothing
} else {
    // 代扣失败,流程结束
    handleDeductFail(reason);
    return;
}
流程变更简单

当需要新增或下线子流程时,只需要调整相关子流程和所监听和发送的事件消息,不需要关注未发生变更的子流程逻辑。各个独立的子流程逻辑一目了然,维护简单。

不相关操作彻底解耦

通过注册独立的监听器来分别实现账务系统记账、短信通知用户、回调上游调用方等操作。

事件消息设计

RocketMQ消息发布和订阅是基于topic/tag的,同时其管理后台支持根据消息key过滤,这样,我们可以将一系列事件消息按topic组织起来,通过tag来标识,消息key指定为该事件的唯一标识,这样就可以很方便的根据key查找已保存的事件消息,并能方便地手动重发。

以还款场景举例,定义一个topic:topic_event_repay,定义一系列tag:submit/success,消息key即为还款流水号。

为避免消息泛滥,事件消息的消息体也存在相关的设计规范,但与本文相关性不大,这里不再赘述。

总结

如序中所言,本方案仅仅是将一个流程分解为各个子流程,并使用消息中间件通过异步化解耦,并不能称之为EDA应用的最佳实践,但这次重构带来的价值是毋庸置疑的。EDA思想以及DDD(领域驱动设计)能为我们的架构设计提供指导,我们在学习它们的同时,也需要时刻思考我们面临的问题与它们的关联,我们学习和应用的出发点始终是为了解决已面临的问题,避免陷入“为了应用而应用”的误区。

本文地址:https://blog.csdn.net/wyy51y/article/details/111990779

《EDA方案说明 - 以还款流程举例.doc》

下载本文的Word格式文档,以方便收藏与打印。