一、起因
金融、支付类公司,易产生资损的业务当属代发、转账、卡券权益兑换类等出金交易。每一位致力于此的架构师、开发工程师最担心重复代发、重复兑换的问题,尤其对于批量的出金类业务,由于设计不当导致的大量的资金、资产损失后果惨重。因此批处理任务的防重设计极为重要。
二、定时任务演进
古代
以每5分钟执行一次批量代发交易为例,早期大部分系统都是单体应用,通常采用Spring+Cron表达式来实现定时任务:
spring-quartz.xml:
<bean id="issue" ...>
<property name="cronExpression" value="* 0/5 * * * ?"></property>
</bean>
为了防止定时任务重复启动,开发工程师们需要注意两点。
- 1.确保spring-quartz.xml只能被加载一次,如被多次加载,易造成定时任务重复执行。
- 2.等上一个任务执行完后再开启新的任务:
<!--concurrent : false表示等上一个任务执行完后再开启新的任务-->
<property name="concurrent" value="false"></property>
近代
随着集群部署的广泛使用,单体应用逐渐被替代。为了防止集群内多个实例的定时任务同时启动:
- 开发工程师通常会在工程内保留两份不同的spring-quartz.xml配置文件:一份是有该定时任务的,另一份则没有。
- 运维工程师根据不同配置文件产出的程序包,部署到不同的生产服务器上。
显而易见,由于加入了人为操作,较容易出现打包、部署、核验等操作失误,导致定时任务被重复启动。
为了解决一个项目两份程序包
的问题,可采用配置“白名单服务器”来实现:项目中保留该定时任务的spring-quartz.xml配置文件,所有的实例都启动该定时任务,在代发逻辑真正执行前,判断本机的IP地址是否是白名单地址,一致则执行后面的代发逻辑,不一致则终止。
现代
通过docker容器化部署时,应用实例的IP地址不固定,会散落部署在云平台内,采用配置“白名单服务器”的方式来防重已不可行。通过集中式的调度中心来发起调度任务是解决问题的一种思路。可使用elastic-job,xxl-job等开源框架,也可自研。
基本思路是:
- 各个业务系统只实现业务逻辑,并暴露标准化接口。
- 调度中心系统是所有任务的发起点,通过抢占锁资源等方式,确保每个任务只能发起一次,最终调用业务系统提供的接口。
潜在风险点
开发工程师在规避定时任务重复执行的同时,往往忽视了dubbo等组件,以及nginx、HAproxy等中间件带来的自动重试问题。想从根本上解决问题,开发工程师还需从系统设计与实现入手:即使定时任务重复启动也能确保交易不重复
。
三、定时任务实现
原始需求
产品经理:我要在代发工资系统内,实现每5分钟给200名员工发放工资,要求本系统不能重复发放工资。
需求分析
关键诉求:不能重复
。
数据结构
id | name | amount | status | batch_id |
---|---|---|---|---|
1 | zhang | 6110 | 0 | |
2 | zhou | 1280 | 0 | |
3 | liu | 16280 | 0 | |
4 | li | 8021 | 0 | |
5 | wang | 900 | 0 | |
6 | chen | 3280 | 0 | |
.. | ... | ... | ... | ... |
- 注:以下所有的实现,都默认有重复执行定时任务的可能。
思路1:一查二发
实现过程大致是一查二发的步骤,伪代码:
//1.查出最多200条
select * from t_order where status=‘0’ and rownum<=200;
//2.循环单笔发,并逐笔更新订单状态
foreach i++{
Send(i);
update t_order set status=#{status} where id=#{id} and status='0'
}
不难发现,当两个定时任务同时执行,步骤1的sql可能抓取到重复的数据,继而在步骤2造成重复代发工资。那么在执行send()前先校验下订单状态?不可行,可能会存在脏读。
思路2:幂等表
实现步骤:代发前,插入幂等表,如主键冲突记录异常,并终止本笔代发。
//1.查出最多200条
select * from t_order where status=‘0’ and rownum<=200;
//2.循环单笔发
foreach i++{
insert into mideng(…) values(…);
if 主键冲突 then 记录异常 return;
Send(i);
update t_order set status=#{status} where id=#{id} and status='0'
}
通过幂等表可以做到防重,但有洁癖的开发工程师会觉得:
- 1.每次交易会增加数据库交互的开销。
- 2.长时间后,幂等表的记录数会越来越多,不利于维护。
是否可以从根本上杜绝多个定时任务取同样的数据?
思路3:一锁二发
实现步骤:在每个定时任务的线程内生成唯一标识UUID,先把UUID更新至数据库中,再根据UUID做为查询条件取数进行后续的代发。
//1.锁定数据
String uuid=createUUID();//a73266fc0aa411eaae330221860e9b7e
upate t_order set batch_id=#{uuid} where status=‘0’ and batch_id is null and rownum<=200;
//2.取出本线程锁定的数据
select * from t_order where status=‘0’ and batch_id=#{uuid};
foreach i++{
Send(i);
update t_order set status=#{status} where id=#{id} and status='0' and batch_id=#{uuid}
}
数据结构
id | name | amount | status | batch_id |
---|---|---|---|---|
1 | zhang | 6110 | 0 | a73266fc0aa411eaae330221860e9b7e |
2 | zhou | 1280 | 0 | a73266fc0aa411eaae330221860e9b7e |
3 | liu | 16280 | 0 | a73266fc0aa411eaae330221860e9b7e |
4 | li | 8021 | 0 | a73266fc0aa411eaae330221860e9b7e |
5 | wang | 900 | 0 | a73266fc0aa411eaae330221860e9b7e |
6 | chen | 3280 | 0 | a73266fc0aa411eaae330221860e9b7e |
.. | ... | ... | ... | ... |
通过一锁二发的步骤可以保证每个定时任务只执行当前线程锁定的数据。开发工程师也可以根据实际的业务需求,同时使用一锁二发+幂等表。
网友评论