背景
在业务数据没达到一定量又不想引入分布式事务框架增加复杂性,基于Job框架实现的补偿方案不失为一种简单优雅的方案。
微服务环境下虽然使用了retry框架,对一些幂等的接口一次失败多次尝试,但有些场景比如下单后无库存,要保证库存在一定时间内一定能扣成功,也只能使用Job框架以一定的频率发起补偿。
业界常用的分布式Job框架有 Saturn与 xxl-job
Job并发
像Saturn跟xxl-job都有控制台页面把Job配置为单机执行,单机发生故障进行故障转移。
数据量少的场景只需要单机执行,不需要考虑并发。
但单机执行场景下仍然要注意并发问题。
一般来说Job框架会保证下一个Job等待上一个Job执行完毕才开始。
如果业务逻辑使用了线程异步执行,如下
void executeJob() {
new Thread(()->{
//耗时的业务处理
}).start();
}
由于是在线程里面异步执行,Job就当做上一个Job已经处理完毕了,毫不犹豫地就开启下一轮轮询,就有可能发生两个线程并发处理同一段数据。
为了避免耗时业务处理长时间阻塞Job,启用线程异步处理也是必要的,同时要控制两个Job的轮询间隔,避免当前Job轮询启动了,上一个Job异步线程里面还有未完成业务处理逻辑。
Job查询必须有结束条件
使用Job自然是要查询那些需要补偿的任务。
通常使用分页查询,把待补偿的业务数据查询出来处理,直到处理完毕结束循环。
查询分页避免了一次性查询出过量数据造成OutOfMemoryError。
//第一种场景:分页查询不知道数据总数
public void executeJob() {
int beginPage = 0;
int pageSize = 500;
while (true) {
//根据beginPage进行分页查询
List<Object> orders = getOrders(beginPage, pageSize);
//处理业务数据 orders
if (orders.size() < pageSize) {
break;
}
beginPage = beginPage + 1;
}
}
//第二种场景:分页查询知道数据总数
public void executeJob() {
int beginPage = 0;
int pageSize = 10;
int total = 0;
int allTotal = 0;
while (total<allTotal) {
//根据beginPage进行分页查询
PageInfo<Object> orders = getOrders(beginPage, pageSize);
//处理业务数据 orders
allTotal = orders.getTotal();
total = total + orders.size();
if (orders.size() < pageSize) {
break;
}
beginPage = beginPage + 1;
}
}
while要有一种业务无关的结束条件
虽然分页查询有结束条件,但查询逻辑是会随着需求更新的,当查询条件不再满足结束条件就会陷入死循环。
由于Job是纯后台服务,测试人员一时也不易发现Job逻辑出了问题。
例如 getOrders(beginPage, pageSize) 随着分页插件的升级或Sql的更新每次总是查询出相同的500条数据。
500条相同的数据不多也不少,也不易发现查询结果出错,如下判断条件永远不满足,第一种场景:分页查询不知道数据总数就没有结束条件。
if (orders.size() < pageSize) {
break;
}
来看第二种场景:分页查询知道数据总数 ,多了一个结束条件 total<allTotal ,那多了一个结束条件就靠谱吗?
虽然把每次查询出的总数累加 了total = total + orders.size() ,但分页插件或Sql通过 select count(*) from table where object = xxxx 统计出的 allTotal 也可能在变化,每次都变大,正所谓你长我也长。可能半天过去了 total<allTotal 条件仍然没有满足,while也陷入死循环。
由于根据sql查询条件去结束while有一定的不确定性,通过限制最大循环是一种简单有效的方法。
例如根据业务量,while循环不应该超过1千次,数据总量不应该超过50万。
那么保留根据分页查询结果判断结束条件的同时,应该增加最大循环次数、最大业务量控制,避免由于查询失效造成的死循环。
public void executeJob() {
int beginPage = 0;
while (true) {
//分页查询
if (orders.size() < pageSize) {
break;
}
beginPage = beginPage + 1;
if(beginPage>1000){
break;
}
}
}
public void executeJob() {
int beginPage = 0;
while (total<500000) {
//分页查询
total = total + orders.size();
if (orders.size() < pageSize) {
break;
}
beginPage = beginPage + 1;
if(beginPage>1000){
break;
}
}
}
Job也应该像微服务一样优雅关闭
在微服务发布过程中,通常把旧服务从Eureka等注册中心以及负载平衡里面摘掉,不再接受新的请求,旧服务把已有请求处理完毕下线。由于Job是纯后台服务,有可能被忽略掉。例如旧服务已经不接受新的Http请求,但后台的Job轮询还没处理完毕,Job里面同时开启了数据库事务,这时候强杀进程会造成数据回滚。
Job也应该同理,像微服务一样,通过Http接口向分布式Job注册中心发送优雅下线的命令,待当前微服务已经没有未完成的Job任务再停机。