cover_image

高效资金路由决策引擎的设计与实践

Haiyan Shopee技术团队
2022年10月27日 10:03

点击关注公众号👆,探索更多Shopee技术实践

目录

1. 背景
2. 模块设计
   2.1 规则决策模块
   2.2 控量决策模块
3. 应用与监控
4. 总结与展望

1. 背景

随着 Shopee 在海外市场的发展,其在信贷场景的应用也有所增长。在电商分期、现金贷等信贷金融场景中,出于进一步扩大规模、满足当地金融监管、控制业务风险等原因,须以联合贷款、资产证券化(ABS)等方式接入外部的资金方。

资金对接平台作为连接外部金融机构和内部信贷金融产品的桥梁,扮演着连接器的角色,而出于风险、成本等方面的考虑,不同的外部资金方不仅对贷款用户、贷款单等信息有一定的要求,而且对不同维度的放款规模有一定的限制。在此背景下,资金路由决策引擎在资金对接平台中起到最核心的作用:为贷款单匹配一个当前合适的资金方。

设计扩展性强、使用优雅的规则决策模块以及高性能、计算精准且不超卖的交易控量决策模块是本文的关键所在。

2. 模块设计

资金路由决策引擎作为在资金对接系统中独立的单元,其具体功能是根据贷款单、用户等相关信息基于资金方的规则做决策,以及基于资金方的放贷规模来限制或准入,最后输出一个当前匹配的资金方。概要流程如下图所示。

图片

资金路由决策模块供服务内交易模块调用,有着明确的输入输出信息,输入项为 loan 相关的信息,输出项为决策出的资金方。

其主要包括规则决策和控量决策,围绕这两大核心模块,我们合理设计开发了相关的功能:

  • 资方顺序配置:可灵活配置调整资方决策匹配的顺序;
  • 规则配置:对基本规则、源数据、资方规则做到灵活配置;
  • 可路由时间配置:配置某些资方在特定时间周期范围内不可用;
  • 源数据懒加载:特定资方决策需要的源数据按需加载;
  • 阈值维护:阈值数据结构复杂,灵活配置储存不同规则的阈值;
  • 特殊校验函数:对复制的规则做到定制化开发,灵活配置;
  • 规则引擎:一种嵌入在应用程序中的组件,实现了将业务决策从应用程序代码中分离出来,并使用预定义的语义模块编写业务决策;
  • 用额管控与计算:实时精确计算统计并管控不同维度(日、期数、贷款类型等)的所有交易金额和笔数,绝不超卖;
  • 指标上报:规则决策、控量决策结果上报时序数据库,配置可视化面板、监控告警,实时了解相关情况。

2.1 规则决策模块

在资金方接入过程中,每个资金方对贷款资产往往有一定的要求,比如期望贷款人职业范畴不能属于某些行业、单笔贷款金额在固定金额区间范围内、年龄大于多少岁、授信额度大于多少。

此外,对于资金平台来说,希望分配给资金方的单的利率要小于用户的利率才能盈利,对带某些信息的单必须匹配给特定的资金方。

针对资金方、资金平台的各种决策逻辑校验需求,如果单纯靠硬编码的方式来实现,可扩展性会比较差,这不符合优秀软件开发架构的定义。因此有必要来设计一个可灵活配置的规则决策引擎。

2.1.1 规则分析

一个规则校验往往包含几部分:一是运算符,常用的有大于、小于、等于,以及范围校验、特殊逻辑校验等;二是数据源,比如用户年龄、贷款金额等等;三是阈值信息,比如年龄范围、职业范围、可贷省市区等。这些信息都需要配置在数据库中做到灵活配置使用。

首先,常用运算表达式归纳如下所示,可以建一张规则基础定义表来维护相关的运算。

对于比较复杂、特殊的逻辑可用自定义的函数来配置使用;而对于源数据,同样也需要一张表来定义数据名、数据源、数据类型、加载方式等相关信息,某些源数据不是所有的资金方都需要校验,可定义成懒加载的方式使用。

图片

2.1.2 规则 ER 设计

资方规则的 ER 图如下所示,以此来维护资方相关的规则,并且按需开放一些简单的规则供运营人员修改。其中:

  • rule_define_tab 存储基本的规则信息,包含操作码、前置处理、描述等信息;
  • rule_item_tab 存储基本的属性元数据信息,包含属性名、数据来源等;
  • funder_rule_info_tab 记录不同资方的校验规则,通过这个记录关联基本规则、属性信息、阈值信息,供规则引擎使用。
图片

2.1.3 规则引擎调研与应用

大多数金融系统都用 Java 技术栈来打造,Java 领域的规则引擎有 Drools、Esper、Activiti、Flowable 等,由于我们以 Go 语言来构建相关系统,因此对 Go 领域的规则引擎做了预研。

图片

由于当前系统中只需要规则引擎动态加载,执行简单的规则,输出 true/false 即可,后续的决策由系统模块处理,从可用性、性能等方面考虑选取 govaluate 来执行配置的规则表达式。一条规则在 govaluate 的使用如下:

func Compare(left interface{}, operator string, right interface{}) (bool, error) {
 var params = make(map[string]interface{})
 params["left"] = left
 params["right"] = right
 var expr *govaluate.EvaluableExpression
 expr, _ = govaluate.NewEvaluableExpression(fmt.Sprintf("left %s right", operator))
 eval, err := expr.Evaluate(params)
 log.Infof("expr=%v,params=%+v,result=%+v", expr.String(), params, eval)
 if err != nil {
  return false, err
 }
 if result, ok := eval.(bool); ok {
  return result, nil
 }
 return false, errors.New("convert error")
}

代码中 left 和 right 为属性值和阈值,operator 为操作比较符,这些参数通过配置化的方式配置在 DB,执行函数最终输出一个校验的结果,以此做到灵活配置使用,可扩展性强。

2.2 控量决策模块

系统通常需要在某些维度控制接入资金方的放贷规模,比如:总贷款余额不能超过多少、某期数在一天内不能超过多少等等。

在转账等动帐支付类场景中,往往需要对某一特定的账户进行扣款、收款等操作,如果并发量较高,假设我们数据库对数据进行一次更新需要使用 10 毫秒的时间,业务量小的话还能处理,对于一秒钟要处理几千甚至上万次的话,数据库就会来不及响应。

对于此类热点账户的处理,业界常用的做法是通过汇总记账的方式来处理,即通过先 insert db 再汇总入账,这样就把多次 db update 操作通过多次 insert + 一次 update 来替换,数据库的 insert 操作能支持的并发量是很大的,以此手段来解决热点账户的问题,虽然会导致延迟入账,但这在多数场景是可以容忍的。

在我们的场景中,对于放款类正向交易,往往需要校验并扣减某个相应的额度,而对于还款等负向交易则不需要校验,直接入账便可,对此类需求,有不同的处理方式。

2.2.1 基于 MySQL 悲观锁同步更新记录

最直接的实现方式是对于所有交易基于 MySQL 数据库同步计数更新数据,抛开数据库可能造成锁等待、锁超时等问题不说,下面我们先基于 MySQL 的悲观锁方式计数,验证在不同并发情况下的吞吐量如何。

图片

此次测试在特定配置的机器上,基于 MySQL 5.6 版本执行。

图片

由测试结果可以得知,加锁更新 MySQL 某条记录的 TPS 在 200 左右,每次 MySQL 的 update 操作大概耗时 5ms,这个速度显然不能处理海量金融交易的场景。

2.2.2 基于队列落地请求+异步消费的方式

图片

如上图所示,对交易进行缓存校验扣减成功后,用消息队列存储,用消息队列多副本、落盘机制来保证交易可靠性,异步入账任务批量消费消息计算并更新账户余额信息。

这种方式虽然能支持高并发流量,但是也有一定的弊端:

  • 首先,刷新缓存可用额的时候需要基于阈值和当前已用值做计算,这种情况下,应用系统不方便捞取所有消息队列未处理的交易做计算;
  • 其次,消息队列消费时,应用系统还需要加一张存储所有交易的防重表来防止多次消费。

因此,这种方式也不可取。

2.2.3 基于 Redis 校验扣减+缓冲入账方式

数据库插入数据的并发支持是非常大的,一般不会造成数据库性能问题,只是更新账户余额需要进行锁处理,会造成性能问题,所以解决账户的关键是解决账户余额更新问题。

因为需要对正向类交易进行校验并扣减用额,如果校验额度不够则需要拒绝交易,因此把不同维度的用额同步到缓存中,对交易进行实时校验扣减,校验扣减成功则往 DB 流水表插入一条记录,然后由入账任务异步汇总入账到用额表。

图片

对于放款类正向交易需进行缓存校验扣减,由于涉及到多个维度的操作,采取 Redis 单线程工作模式 + Lua 脚本的方式来保证校验扣减操作的原子性。

而对于还款类的负向类交易,由于不需要校验直接入账,所以无需通过缓存校验扣减,直接写流水异步汇总入账便可,因此会出现缓存可用额比 DB 实际可用额少的情况,当缓存中可用额不足的时候,异步去刷新 DB 可用额覆盖到缓存便可。容忍对于少量放款交易,某个资方在 DB 实际有额,但是没及时同步到缓存而拒绝这些交易,但不允许超卖发生。

在此方案下,从系统高可用性考虑,当 Redis 宕机发生时,还款类负向交易仍能正常插入 DB 并异步入账,放款类正向交易会由于 Redis 故障没法校验扣减而拒绝,系统会退而求其次地选择兜底的资金方放款,不阻塞业务进行,待 Redis 恢复后,系统根据流量来赖加载刷新缓存便可。

在灾备环境下,只需要所有的 DB 数据同步到了灾备环境就可以正常使用,缓存数据由于懒加载刷新而不需要同步,而且系统可以以资方维度来检查勾销资方的数据是否同步,从而决定接入流量,更加细粒度来管控。

在多活以及单元化架构下,一般按用户把各自数据拆进行了划分,对于资方额度相关的全局数据则没法拆分使用。因此,可以把这个模块做成一个单独的服务,在存储层面针对一致性敏感数据做到强同步,供各数据中心使用,当然也需要做好数据正确性的控制,以及保证系统高性能、高可用、高可靠。

图片

Redis 缓存热点资方的相关信息,用一个 khash 的数据结构来存储计算相关的数据,key 为 funder_hotspot_limit_khash_{funderId}

图片

缓存刷新时的计算逻辑如下图所示。假设配置的阈值为 100,DB 已用额 60,DB 流水表中有 20 未入账,因此实际用额是 80,这个时候刷新到缓存中的可用额度为 100-80=20,当然刷新的时候还需要先校验是否有正在插入的流水、以及锁住缓存,才能把可用额 20 刷新到缓存中生效使用。

图片

控量决策逻辑如下所示,深色代表应用的逻辑,浅色代表 Redis Lua 脚本的执行逻辑。

图片

在尽量保证缓存与 DB 一致,不允许超卖的前提下,需采取合适的缓存更新策略:

  • 缓存是基于资方的维度更新,缓存有记录上次更新的时间,控制更新频率;同时缓存上还有状态标识来控制并发刷新,缓存上有请求计数器来做在途管控;
  • 应用启动的时候缓存无记录,须更新资方缓存;
  • 正向交易来了,缓存无记录,需异步更新缓存;
  • 正向交易来了,当日放款量、笔数不足,由于这些维度不统计负向交易,不需更新缓存;
  • 正向交易来了,在贷余额不够,需异步更新缓存
  • 每天零点更新缓存;
  • Admin 后台修改了缓存阈值,需强制刷新 DB 可用额数据到缓存;
  • 缓存数据丢失,有交易发生时触发刷缓存。

同样,对此方案进行测试分析,测试一个资源在缓存校验扣减 + insert DB 在不同并发场景下的性能耗时,同样在特定配置的机器上,基于 Redis 6.2.2、MySQL 5.6 版本执行。

图片

由测试数据分析可知,在高并发场景下,TPS 能达到万级,因此本方案能应对高并发交易的处理。

3. 应用与监控

此方案设计开发后经过功能测试、性能测试,达到使用要求,上线以来对规则配置的频繁修改扩展性强,对放款额度能够精准控制。

为防止规则的错配影响业务,需对规则的拒绝量、单的匹配量等统计与告警。经调研,我们选择采取开源的 Promethus 来做时序数据存储、监控、告警。同时,运营人员可基于这些数据来统计决策,反向调节规则参数,形成一个闭环,更好地支撑业务。

图片

4. 总结与展望

该方案在上线以来,对不同市场地区的贷款单进行路由处理,能满足规则的灵活配置,可扩展性强,在高并发场景下对交易的处理以及入账性能高,对平台要求的单量、金额等不同维度的管控能做到精准控制,从无超卖现象。

规则决策方案在风控决策、以及各类互联网应用场景比如某些特殊场景发短信、通知等决策可应用,热点账户解决方案则可在账户支付转账、互联网营销管控等场景可供使用。

目前,规则的上线需要开发配合运营人员处理,后续可把规则做成规则包的方式供业务下发生效使用。

本文作者

Haiyan,后端开发工程师,来自 Shopee Financial Products 团队。

技术编辑

Yang,Shopee 技术委员会后端通道委员;

Liping、Chuanliang,来自 Shopee Financial Products 团队。

团队简介

基于 Shopee 服务的市场,Financial Products 致力于打造信贷、保险和投资理财等金融服务。信贷为客户和商户提供消费贷和现金贷服务。在诸多消费场景,提供各种类型的保险购买服务;以及提供即时的基金购买和赎回服务,满足用户的投资理财需求。

我们围绕零售金融持续打造资金、资产、核算、交易、风控、用户、承保、理赔等金融核心服务;另外,在金融方面的资金账务严要求情形下,我们的业务还呈现出场景多、金额小、交易并发高的特点,我们的团队需解决业务扩展性、数据一致性、高并发等多维度的挑战。

图片
图片
图片
图片

图片

继续滑动看下一个
Shopee技术团队
向上滑动看下一个