业务背景
跑批通常指代的是咱们应用程序针对某一批数据进行特定的解决
在金融业务中个别跑批的场景有分户日结、账务计提、欠款批扣、不良资产解决等等
具体举一个例子 🌰
客户在我司进行借款,并约定每月 10 号码还款,在客户自主受权银行卡签约后
在每月 10 号(通常是凌晨)咱们会在客户签约的银行卡上进行扣款
而后可能会有一个客户、两个客户、三个客户、四个客户、好多个客户都须要进行扣款,所以这一“批”所有数据,咱们都要对立地进行扣款解决,即为咱们“跑批”的意思
跑批工作是要通过定时地去解决这些数据,不能因为其中一条数据出现异常从而导致整批数据无奈持续进行操作,所以它必须是强壮的;并且针对于异样数据咱们后续能够进行弥补解决,所以它必须是牢靠的;并且通常跑批工作要解决的数据量较大,咱们不能让它解决的工夫过于久,所以咱们必须思考其性能解决;总结一下,咱们跑批处理的应用程序须要做到的要求如下
- 健壮性
- 针对于异样数据,不可能导致程序解体
- 可靠性
- 针对于异样数据,咱们后续能够跟踪
- 大数据量
- 针对于大数据量,可在规定的工夫内进行处理完毕
- 性能方面
- 必须在规定的工夫内解决完从而防止烦扰任何其余应用程序的失常运行
跑批危险
一些未接触过跑批业务的同学,可能会犯一些谬误·
- 「查问跑批数据,未进行分片解决」
- 这种状况具体有两种状况
- 一种是同学有意识进行分片解决,间接依据查问条件将全量数据查出;
- 第二种状况呢,不单是在跑批的时候可能呈现的状况,在平时的业务开发过程中也可能发现,针对于查问条件未进行判空解决。比方 select id from t_user_account weher account_id = “12”; 然而在业务处理过程中,account_id 为空,却间接进行查问,数据量一旦上来,就容易导致 OOM 喜剧
- 「未对数据进行批量解决」
- 这种状况也是同学们容易犯的一个谬误,通常咱们跑批可能会波及到数据筹备的过程,那么有的同学就会间接梭哈,边循环跑批数据边去查找所需的数据,一方面 for 嵌套的循环解决,工夫复杂度通常是随着你的 for 个数回升的,在我的项目中一个同学在保费代扣的跑批工作中,进行了五次 for 循环,这个工夫复杂度就是 O(n ^ 5)了,并且如果你的办法未进行事务管理的话,数据库的连贯开释也是一个十分耗费资源的事件
- 上一个伪代码可能会比拟好了解
// 调用数据库查问需跑批数据
List<BizApplyDo> bizApplyDoList = this.listGetBizApply(businessDate);
// for 循环解决数据
for(BizApplyDo ba : bizApplyDoList) {
// 业务解决逻辑.. 省略
// 查问账户数据
List<BizAccountDo> bizAccountDoList = this.listGetBizAccount(ba.getbizApplyId());
for (BizAccountDo bic : bizAccountDoList){// 账户解决逻辑.. 省略}
... // 后续还会嵌套 for 循环
}
- 「事务应用的力度不失当」
咱们晓得 Spring 两头的事务可分为编程性事务和申明式事务,具体二者的区别咱们就不开展阐明了
在开发过程中,就有可能同学不管三七二一,爽了就行,间接 @Trancational 覆盖住咱们整个办法
一旦办法解决工夫过久,这个大事务就给咱们的代码埋下了雷
- 「未思考上游接口的承受能力」
- 咱们跑批工作除了在咱们本零碎进行的解决外,还有可能须要调用内部接口
- 比方代扣时,咱们须要调用领取公司侧的接口,那么咱们是否有思考上游接口的承受能力和响应工夫(这里有一个坑,下一个 part 咱们开展说一下)
- 「不同的跑批工作工夫设置不合理」
- 在咱们的我的项目中,有一个的业务玩法是,咱们必须在保费扣完之后,才可进行本息的代扣
- 小张同学想当然,我的保费代扣定时工作从凌晨 12 点开始,一个小时定时工作总该完结了吧,那么我的本息代扣的定时工作从凌晨 1 点开始吧,可是这样设置真的适合吗?
优化思路
定时框架的抉择
罕用的有 Spring 定时框架、Quartz、elastic-job、xxl-job 等,框架无谓好坏,适宜本人业务的才是最佳的
可针对本人业务进行技术选型,咱们常应用的技术为 xxl-job,针对于咱们上文中所说到的不同的跑批工作设置的工夫不合理,咱们即可利用 xxljob 的子工作个性进行嵌套的工作解决,在保费代扣工作实现后紧接着进行本息代扣工作
避免 OOM,切记分片解决
这一点其实没有什么好开展的,在对跑批工作进行开发的时候,肯定要记住分片解决
一次性加载所有数据到内存里,无疑是自取灭亡
那么,如何优雅分片呢?
这时候小张同学举手了:分片我会呀,比方像这种扣款的都是以工夫维度来的,我间接 select * from t_repay_plan where repay_time <= “2022-04-10” limit 0,1000
那么,当初咱们找个数据来看下这种深度分片的性能如何
我在数据库中插入了大略两百万条数据,把制作数据的过程也分享给你们
// 1、创立表
CREATE TABLE `t_repay_plan` (`id` int(11) NOT NULL AUTO_INCREMENT,
`repay_time` datetime DEFAULT NULL COMMENT '还款工夫',
`str1` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3099998 DEFAULT CHARSET=utf8mb4
// 2、创立存储过程
delimiter $$
create procedure insert_repayPlan()
begin
declare n int default 1;
while n< 3000000
do
insert into t_repay_plan(repay_time,str1) values(concat( CONCAT(FLOOR(2015 + (RAND() * 1)),'-',LPAD(FLOOR(10 + (RAND() * 2)),2,0),'-',LPAD(FLOOR(1 + (RAND() * 25)),2,0))),n);
set n = n+1;
end while;
end
// 3、执行存储过程
call insert_repayPlan();
随着逐步的数据偏移,数据耗时逐步减少。因为这种深度分页是将数据全副查问进去,并且摈弃掉,成果天然不是那么尽如人意
其实咱们分片还有一种办法,那就是利用到咱们的 id 来进行分页解决(当然是你的 id 是须要保障业务增长,并且联合具体的业务场景来进行剖析)
咱们同样来试一下怎么利用 id,进行分片的耗时状况
咱们能够看到成果很显著,利用 id 进行分片,成果是优于咱们的这个还款工夫字段的
当然对于跑批过程中 「笼罩索引的应用、尽量不去 select * 等、批量进行插入」 等 sql 常见点不和大家一一开展阐明了
针对所需数据进行 map 的结构
咱们再写一个简略的反例
// 调用数据库查问需跑批数据
List<BizApplyDo> bizApplyDoList = this.listGetBizApply(businessDate);
// for 循环解决数据
for(BizApplyDo ba : bizApplyDoList) {
// 查问账户数据
BizAccountDo bizAccountDo = this.getBizAccount(ba.getbizApplyId());
// 账户解决逻辑.. 省略
// 查问扣款人数据
CustDo custDo = this.getCust(ba.getUserId);
// 扣款人解决逻辑.. 省略
}
咱们能够这样进行革新(伪代码、疏忽判空解决)
// 调用数据库查问需跑批数据
List<BizApplyDo> bizApplyDoList = this.listGetBizApply(businessDate);
// 构建业务申请编号汇合
List<String> bizApplyIdList = bizApplyDoList.parallelStream().map(BizApplyDo::getbizApplyId()).collect(Collectors.toList());
// 批量进行账户查问
List<BizAccountDo> bizAccountDoList = this.listGetBizAccount(bizApplyIdList);
// 构建账户 Map
Map<String, BizAccountDo> accountMap = bizAccountDoList.parallelStream().collect(Collectors.toMap(BizAccountDo::getBizApplyId(), Function.identity()))
// 扣款人数据同样解决
for(BizApplyDo ba : bizApplyDoList) {account = accountMap.get(ba.getbizApplyId())
// 账户解决逻辑.. 省略
}
尽可能减少 for 循环的嵌套,缩小数据库频繁连贯和销毁
事务管制长点心
一旦咱们应用了 @Trancation 进行治理事务,那么就要求组内开发人员在开发过程中须要瞪大眼睛去留神事务的管制范畴
因为 @Trancation 是在第一个 sql 办法执行的时候就开启了事务,在办法未完结之前都不会进行提交,有些同学接手革新这个办法的时候,没有留神到这个办法是被 @Trancation 笼罩,那么在这个办法里退出一些 RPC 的近程调用、音讯发送、文件写入、缓存更新等操作
1、这些操作本身是无奈回滚的,这就会导致数据的不统一。可能 RPC 调用胜利了,然而本地事务回滚了,可是 PRC 调用无奈回滚了。
2、在事务中有近程调用,就会拉长整个事务。那么久会导致本事务的数据库连贯始终被占用,那么如果相似操作过多,就会导致数据库连接池耗尽或者单个链接超时
我已经就见过一个办法,通过多人之手后,从而因为大事务导致数据库连贯被强行销毁的喜剧
所以 「咱们能够有选择性的去应用编程性事务去解决」 咱们的业务逻辑,让接手的同学能够明确看到什么时候开启了事务,什么时候提交了事务,也尽可能将咱们的事务粒度的范畴放大
上游接口 hold 住么
分享此条优化之前,先大抵介绍一下咱们的业务背景
保费代扣的跑批工作中,咱们是会借助流程编排这个框架,去异步发动咱们的代扣,你能够了解为一笔代扣申请就是一个异步线程,代扣的数据全副在流程编排中进行传递
在咱们进行优化结束的时候,筹备在 UAT 环境进行优化测试的时候,发现仅 20w 条保费数据,解决工夫就十分的不尽入人意
监控零碎环境,发现零碎频频在进行 GC,我的第一反馈,不会是产生内存泄露了吧,在筹备 dump 文件的时候
我意外的发现,大部分申请都是卡在了对外扣费的这个节点,通过日志察看,发现上游接口给的响应工夫过久,甚至局部呈现了超时状况
那么这个 GC 就正当了,因为咱们的代扣申请生成的速度十分快,并且是异步的线程调度,线程还未死亡,始终在尝试对外申请扣费,就导致所有的数据都堆在内存里,就导致了频繁 GC
在和上游接口方进行核实之后,确实针对于该接口没有进行限流解决(太坑了)
优化的思路也很简略了,在业务可承受的状况,咱们采取的是去发送 mq 申请后,就挂起流程编排(该线程会死亡),而后让消费者进行解决调用胜利后唤醒流程进行后续解决即可,当然应用固定的线程池间接调外也是能够的,目标都是避免过多的线程处于 RUNNING,从而导致内存始终的沉积
还有一种对外调用的万金油解决形式,就是在业务可承受的状况下,采取一种 fast success 形式,举个例子,在进行保费扣费的时候,咱们调用领取公司的接口之前,间接将咱们的扣费状态更改为扣费中,而后间接挂起咱们的业务,而后用定时工作去查证咱们的扣费后果,收到扣费后果后,在持续咱们扣费后的操作
机器利用方面给我打满
针对于生产下面的机器咱们通常不会是单机部署,那么如何能够尽可能去压迫咱们服务器的资源呢
那就是利用 xxl-job 的 「分片播送」 和 「动静分片」 性能
[外链图片转存失败, 源站可能有防盗链机制, 倡议将图片保留下来间接上传(img-rhWVcYx9-1652096017012)(https://upload-images.jianshu…)]
执行器集群部署时,工作路由策略抉择”分片播送”状况下,一次任务调度将会播送触发对应集群中所有执行器执行一次工作,同时零碎主动传递分片参数;可依据分片参数开发分片工作;
“分片播送”以执行器为维度进行分片,反对动静扩容执行器集群从而动静减少分片数量,协同进行业务解决;在进行大数据量业务操作时可显著晋升工作解决能力和速度。
“分片播送”和一般工作开发流程统一,不同之处在于能够获取分片参数,获取分片参数进行分片业务解决。
// 可参考 Sample 示例执行器中的示例工作 "ShardingJobHandler" 理解试用
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
具体举个例子,比方咱们做分户日结的时候,能够依据商户的编号对机器进行取模解决,而后每台机器只执行某些特定商户的数据
那么这边留一个问题给大家:如果产生数据歪斜,你会如何解决,即某个商户的数据量特地大,导致这台机器执行的工作十分的重,要是你,你会如何解决这种场景?
总结
明天针对于大数据量的跑批,在我的项目中实际思考就到此结束了。
文章介绍了咱们常见跑批工作中可能呈现的危险和比拟罕用通用的一些优化思路进行了分享
对于线程池和缓存的使用我未在文章中提及,这两点也对咱们的高效跑批具备极大的帮忙,小伙伴们能够加以利用
当然文章只是引起大家针对于跑批工作的思考,更多的优化还需联合工作具体情况和我的项目自身环境进行解决