前言
”路由“ 这个词在我们工作技术栈中涉及领域广泛。例如 SpringCloud GateWay 、Nginx、SpringMvc,这些都可以针对一个 Http 请求,根据不同规则,把请求路由/转发到不同的目的地。又或者像 RabbitMQ 等消息队列,路由不同的消息到不同的目的队列。基于这个思路,我们小贷项目放款可用资金方的筛选也可以使用路由的思路来实现。
Github 源码
源码已分享到 Github。 li-choicerule
业务场景
在我们金融业务中放款资金简略场景如下
由于放款方是合作的一批资方,每个资方对用户是否能够符合放款条件有着一系列规则,比如某个资金方要求:
- 用户身份证户籍地址不能是偏远地区的
- 手机号前缀不能是 xx 开头的
- 身份证有效期不能是 3 个月内到期的
- 家庭住址不能是港澳台的
- 等等其他条件
做过小贷的都知道,资金方放款是一个耗时的操作,我们把出款请求发送过去,至于什么时候放款是资金方决定的,所以我们通常是先获取到所有资金方中,符合该用户放款条件的资金方集合,然后遍历这个集合去放款,直到任意一个放款成功。
那么假如我们有 100 个合作的资金方,怎么获取到这个用户符合哪些资金方条件呢?很简单,并且也是唯一的方法,那就是把每个资金方的规则都拿出来校验一遍,只要一个资金方任意一个规则用户不满足,这个资金方就要被剔除。
其实信贷项目中很多业务都可以用规则路由来实现,包括资金方路由、扣款商户号路由、签约商户号路由等等
规则路由的简介
这个场景下,我们就可以参考路由的概念,设计一个规则路由。把每个资金方作为一条大的规则链路,这个大规则链路里面有 N 个小的子规则,然后依照一定的顺序来排序这些子规则。每一条大的规则链路会获取一个结果
如果一条大的链路中所有非叶子结点的子规则都通过了校验,那就返回叶子结点的结果,通常是一个固定值。
当然我们也可以选择让其在任意一个叶子节点停止路由列表的继续执行,因为有些场景下,我们匹配到任意一个结果时,就不需要再往下执行剩余规则了。比如扣款商户号的规则路由,因为我们一笔扣款不可能选择多个商户号,根据路由请求,一直匹配到符合条件的扣款商户号就返回结果。
串行与并行的考量
怎样高效的执行完一个用户可路由到的所有资金方列表的规则路由呢,有两种方式。一种就是单线程从第一个资金方开始,一直遍历到最后一个。另一种就是开启多线程,所有资金方并行执行规则。两种规则都各有优缺点
串行方式
优点:
- 实现简单:逻辑直观,易于理解和调试
- 资源消耗低:没有线程开销,内存占用少
- 执行顺序可控:严格按照规则列表顺序执行
- 避免资源竞争:没有并发问题,不需要额外同步机制
- 调试方便:可以轻松跟踪执行路径
- 可预测性:执行时间和结果可预测
缺点:
- 性能可能较差:如果前面规则耗时很长,但没有拿到结果,即使后面的规则可以快速返回结果,也要等待
- 响应时间较长:一个线程遍历处理所有规则
- 阻塞式执行:非唯一结果场景下,一个慢规则会阻塞整个流程
并行方式
优点:
- 响应速度快:多线程并行执行,对于某用户来说,执行该用户的硬件资源更多
- 提高吞吐量:多个规则可以同时执行
- 更好的用户体验:减少等待时间
缺点:
- 实现复杂:需要处理并发、线程池、异常等
- 资源消耗大:每个规则都需要线程资源,用户量一大,线程池资源紧张
- 调试困难:并发问题难以复现和调试
- 可能造成资源浪费:未完成的规则也会占用资源直到被取消(尤其对于只需要一条规则结果的业务)
- 线程安全问题:规则执行可能有共享状态,需要考虑同步
- 超时控制复杂:需要合理设置超时时间
- 不确定性:结果可能因执行顺序不同而变化
以下是对比表格:
| 对比维度 | 串行方式 | 并行方式 |
|---|---|---|
| 实现复杂度 | 简单 | 复杂 |
| 性能 | 可能较慢(顺序等待) | 通常更快(并行处理) |
| 资源占用 | 低(单线程) | 高(多线程+线程池) |
| 响应时间 | O(∑所有规则时间) | O(最快规则时间) |
| 可调试性 | 优秀 | 困难 |
| 可预测性 | 高 | 低(受系统负载影响) |
| 错误处理 | 简单直接 | 需要额外机制 |
| 超时控制 | 容易 | 复杂 |
| CPU利用率 | 低 | 高 |
| 内存消耗 | 小 | 较大 |
| 适用场景 | 规则少、执行快、逻辑简单 | 规则多、执行慢、追求响应速度 |
其实对于并行的方式来说,是否有上述那些优点是需要实际压测的,因为一但用户量大起来,假设有 100 个资金方,一个用户就需要 100个线程去并行处理,如果同时有 100w 用户,可能导致的后果是线程池的线程反复用在某一部分用户的规则上,因为线程是远远不够用的,所有可能存在一部分用户的规则始终无法得到执行,对于这部分用户来说,体验感是极其差的。
综合各方面考量和评估,我们最终选择的是规则的串行执行。其实实测下来,由于这些都是内存里面的脚本引擎直接执行,执行速度还是很快的。
表结构设计
规则配置表
| 字段名 | 类型 | 允许NULL | 默认值 | 说明 |
|---|---|---|---|---|
| id | bigint | NO | AUTO_INCREMENT | 主键ID |
| rule_desc | varchar(500) | YES | NULL | 规则描述 |
| script_lang | varchar(50) | YES | NULL | 匹配规则脚本语言 groovy、spel 等 |
| script | text | YES | NULL | 匹配规则脚本 |
| result | text | YES | NULL | 结果 |
| result_type | varchar(20) | YES | 'json' | 结果类型,可以是json,xml,text 默认 json |
| status | varchar(20) | YES | NULL | 状态,是否可用 1:可用 0:禁用 |
| sort | int | NO | 0 | 排序 从小到大排序 |
| end | tinyint(1) | NO | 0 | 是否到这条规则结束,只有叶子节点该属性有用 |
| parent_id | bigint | YES | 0 | 上一级 第一级该值为 0 |
| rule_type | varchar(50) | YES | NULL | 业务类型 |
| test_data | text | YES | NULL | 规则测试数据结构体 |
| expect_value | tinyint | NO | 0 | 测试结构体期望的值 |
| crt_time | datetime | NO | CURRENT_TIMESTAMP | 创建时间 |
| upt_time | datetime | NO | CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间 |
| deleted | tinyint(1) | NO | 0 | 逻辑删除标志 |
规则运行轨迹表
| 字段名 | 类型 | 允许NULL | 默认值 | 说明 |
|---|---|---|---|---|
| id | int | NO | AUTO_INCREMENT | 主键ID |
| member_id | varchar(50) | YES | NULL | 会员编号 |
| rule_type | varchar(100) | YES | NULL | 业务规则类型 |
| params | text | YES | NULL | 执行参数 |
| result | text | YES | NULL | 执行结果 |
| rules_tree | text | YES | NULL | 规则执行路径结构 JSON 格式 |
| crt_time | datetime | NO | CURRENT_TIMESTAMP | 创建时间 |
| upt_time | datetime | NO | CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间 |
| deleted | tinyint(1) | YES | '0' | 逻辑删除标志(0-未删除,1-已删除) |
规则脚本策略
我们判断一条请求是否匹配某个子规则,需要执行这个子规则的内容脚本,如果返回 true 代表匹配,如果返回 false 代表不匹配,那这里脚本内容的类型有多种可选方式
groovySpring Expression Language (SpEL)ongl- ...等等
SpEL 确实是个很好用的东西,在 Mock 平台的设计 文章中也是运用这个来解析请求参数的 mock 条件表达式
这里可以根据业务实际场景开发脚本策略实现,我这里只需要 Groovy 和 SpEL 即可
顶层接口
public interface LanguageScriptStrategy {
String CONTEXT_KEY = "ctx";
boolean support(String language);
boolean match(Map<String,Object> params , String script);
}
groovy 策略实现
@Component
public class GroovyScriptStrategy implements LanguageScriptStrategy {
// 复用ScriptEngineManager
private static final ScriptEngineManager ENGINE_MANAGER = new ScriptEngineManager();
@Override
public boolean support(String language) {
return ScriptTypeEnum.GROOVY.name().toLowerCase().equals(language);
}
@Override
public boolean match(Map<String, Object> params, String script) {
ScriptEngine engine = ENGINE_MANAGER.getEngineByName(ScriptTypeEnum.GROOVY.name().toLowerCase());
// 设置上下文变量
Bindings bindings = engine.createBindings();
bindings.put(CONTEXT_KEY, params);
Object result = null;
try {
result = engine.eval(script, bindings);
} catch (ScriptException e) {
throw new RuntimeException("Groovy脚本执行错误: " + e.getMessage(), e);
}
if (result instanceof Boolean) {
return (Boolean) result;
}
return false;
}
}
SpEL 策略
@Component
public class SpelScriptStrategy implements LanguageScriptStrategy {
@Override
public boolean support(String language) {
return ScriptTypeEnum.SPEL.name().toLowerCase().equals(language);
}
@Override
public boolean match(Map<String, Object> params, String script) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariable(CONTEXT_KEY, params);
Boolean match = parser.parseExpression(script).getValue(context, Boolean.class);
if (match == null) {
return false;
}
return match;
}
public static void main(String[] args) {
SpelScriptStrategy strategy = new SpelScriptStrategy();
Map<String, Object> params = new HashMap<>();
params.put("name", "sunyuchao");
params.put("age", 18);
boolean match = strategy.match(params, "#ctx['name'] != null && #ctx['age'] != null");
System.out.println(match);
}
}
这两种脚本都比较简单,随便熟悉一下即可掌握,并且几乎能满足所有实际业务场景需求
核心代码实现
规则执行入口
/**
* @param ruleType 规则业务类型
* @param params 请求参数体,包含规则执行过程中用到的用户信息等参数
*/
public List<Map<String, Object>> choice(String ruleType, Map<String, Object> params) {
List<ChoiceRule> childrenList;
cacheLock.readLock().lock();
try {
//查询 ruleType 的根节点
childrenList = CHOICE_RULE_CACHE.get(new RuleTypeKey(ruleType, 0), this::loadRule);
} finally {
cacheLock.readLock().unlock();
}
if (CollectionUtils.isEmpty(childrenList)) {
return Collections.emptyList();
}
RuleExecResponse response = new RuleExecResponse();
ChoiceRuleRunRecord runRecord = new ChoiceRuleRunRecord();
runRecord.setMemberId(String.valueOf(params.get("memberId")));
runRecord.setRuleType(ruleType);
runRecord.setParams(JSON.toJSONString(params));
runRecord.setResult("{}");
runRecord.setRulesTree("{}");
runRecord.setCrtTime(LocalDateTime.now());
runRecord.setUptTime(LocalDateTime.now());
choiceRuleRunRecordMapper.insert(runRecord);
Map<String, Object> rulesTree = new LinkedHashMap<>();
rulesTree.put("id", 0);
List<Map<String,Object>> childrenNodes = new ArrayList<>();
for (ChoiceRule rule : childrenList) {
RuleExecResponse branchResponse = doChoice(params, rule);
childrenNodes.add(branchResponse.getRulesTree());
if (CollectionUtils.isEmpty(branchResponse.getResult())) {
continue;
}
response.getResult().addAll(branchResponse.getResult());
if (branchResponse.isEndFlag()) {
break;
}
}
rulesTree.put("children",childrenNodes);
runRecord.setResult(JSON.toJSONString(response.getResult()));
runRecord.setRulesTree(JSON.toJSONString(rulesTree));
choiceRuleRunRecordMapper.updateById(runRecord);
return response.getResult();
}
递归执行一条子规则链路
/**
* @param rule 当前规则实体
* @param params 请求参数体,包含规则执行过程中用到的用户信息等参数
*/
private RuleExecResponse doChoice(Map<String, Object> params, ChoiceRule rule) {
String script = rule.getScript();
//执行脚本内容
boolean match = strategyList.stream().filter(strategy -> strategy.support(rule.getScriptLang()))
.findFirst()
.orElseThrow()
.match(params, script);
RuleExecResponse response = new RuleExecResponse();
response.getRulesTree().put("id", rule.getId());
if (!match) {
// 不匹配直接返回空响应
response.getRulesTree().put("children", Collections.emptyList());
return response;
}
List<ChoiceRule> childrenList;
cacheLock.readLock().lock();
try {
childrenList = CHOICE_RULE_CACHE.get(new RuleTypeKey(rule.getRuleType(), rule.getId()), this::loadRule);
} finally {
cacheLock.readLock().unlock();
}
if (CollectionUtils.isEmpty(childrenList)) {
//说明是叶子结点,直接返回结果
response.getRulesTree().put("children", Collections.emptyList());
Map<String, Object> map = JSON.parseObject(rule.getResult(), new TypeReference<>() {
});
if (map != null && !map.isEmpty()) {
response.getResult().add(map);
}
//如果匹配就结束
response.setEndFlag(rule.isEnd());
return response;
}
List<Map<String, Object>> childrenNodes = new ArrayList<>();
for (ChoiceRule choiceRule : childrenList) {
//递归子规则
RuleExecResponse childResponse = doChoice(params, choiceRule);
response.getResult().addAll(childResponse.getResult());
childrenNodes.add(childResponse.getRulesTree());
if (childResponse.isEndFlag()) {
response.setEndFlag(true);
break;
}
}
response.getRulesTree().put("children", childrenNodes);
return response;
}
这里我启动项目的时候构造了当前业务类型的所有规则,以树结构打印到控制台,便于直观查看
规则运行轨迹
我们必须要记录一个用户在路由资金方的时候,这条路由的完整链路轨迹,这样在回溯问题的时候我们能清楚的看到是终止在了哪一个规则,并且到底什么原因没有通过规则。
回溯 这个词在我的文章中出现频率很高,包括在聊消息队列可靠性、幂等的时候也提到过。它非常重要,尽量让我们每一件事都有迹可循!
一个用户的一次路由是一条记录,choice_rule_run_record.rules_tree 字段记录了所有走过的规则树结构,其实就是一个嵌套的 JSON,然后我们用一个合适的前端组件显示出来一次路由的所有运行轨迹,例如下图
这里由于我不懂前端知识,所以随便选了一个组件来演示运行轨迹。上图的规则场景是匹配到任意一结果就结束,所以后面的同级规则都没有走到
验证规则脚本
我们在页面上编辑规则的时候,为了防止脚本内容有语法错误,可以在页面上新增一个校验按钮,当我们新增/编辑规则,保存提交前,后先点击校验按钮,通过之后才允许点击保存按钮,防止脚本内容错误直接投入生产环境造成事故。
/**
* 测试某个规则测试请求体是否能返回预期的值,验证脚本正确性
*
* @return true 验证通过:false 验证失败
*/
public boolean testRule(ChoiceRule rule) {
boolean match = strategyList.stream().filter(strategy -> strategy.support(rule.getScriptLang()))
.findFirst()
.orElseThrow()
.match(JSON.parseObject(rule.getTestData(), new TypeReference<>() {
}), rule.getScript());
return match == rule.isExpectValue();
}
类似下图的保存
规则脚本变更告警
规则变更是一个非常敏感的操作,很可能会引发严重的生产事故,因此在任何一条规则内容变更后都应该告警出来到钉钉群或者企业微信群,通知相关负责人知晓此事,如果没问题忽略即可。
我司之前就有过一次事故,那个规则路由是一个扣款商户号的场景。不同还款单根据一系列特定的条件走不同的扣款商户号去扣款,结果一个新来的同事改错了,也没有告警,导致一大批订单的扣款渠道走错。。。。