准备阶段
区分job和curl
curl和job进行区分:curl是一次性的
;job是定时的
。所以在写这两种类型的代码时,方案考虑会有差异。
业务场景归类
补偿类job
。补偿类job典型的特点是带有‘status’状态,比如:正常业务status应该从‘init’–>‘processing’–>‘sucess’,但是如果数据库中一直是init,说明该数据一直未被处理(原因可能之前处理的时候失败了,所以一直是init)。此时,补偿类会把init状态的数据查询到并进行处理:- 如果处理成功则状态从‘init’–>‘processing’–>‘sucess’,则job下次则不会查询已经处理成功的数据;如果处理失败,那么状态还是init,则job下次还会把该数据查询出来进行处理。
- 可以在SQL条件中添加‘时间限制’,只查询一段时间内的处理失败的数据,超过这个时间的数据不再进行处理。
数据迁移类curl
。数据迁移curl不需要‘status’状态,是完全的把A表数据复制到B表。 值得注意的是,如果只是做数据迁移且不涉及到数据的处理和聚合,那么迁移这个操作可以交给数仓来处理。
是否需要使用redis记录游标
redis记录失败游标的目的是:下一次执行时可以从redis中读取开始游标,然后继续执行。偏向于的场景是【定时重试】,job类型可根据自身场景考虑是否使用。
数据迁移是一次性代码,curl命令只执行一次,不存在【定时】场景。如果发生错误,log打印错误id,然后再提交一次curl,传入开始游标即可。
补偿类job
考虑因素
此类job的特点是:耗时短、数据量较少、带有status状态。
考虑因素如下:
- 并发考虑。添加分布式锁,保证同一时刻只有一个线程在处理数据。使用
redisson方式的分布式锁
,可以保证:自动续期、避免死锁。 - 考虑是否线程休眠。如果需要处理的数据量较大,那么每批次处理完后需要让线程休眠。但是一般而言,补偿类job作为兜底方案,数据量一般都不大。可配置到阿波罗中。
- 幂等考虑。
status转换
问题,可能造成job重复拉取
执行。当我把状态是init的查询出来后,在handle中做了处理,那么处理成功、失败后,该init状态是否需要转换为sucess、fialed,还是说依旧保持init。因为这关系到我下一次job执行是否还会再次处理该条数据。 - 时间条件限制。
时间限制
,对一直处理失败的数据进行放弃
。因为确实存在这种情况:某几条状态是init的数据始终是处理失败。对于这种情况,我们需要添加时间限制,比如限制只查询前60分钟~10分钟的、状态是init的数据。 - 查询数量限制。避免查询大量数据到内存导致OOM。可配置到阿波罗中。
- Switch开关。在while循环里面配置一个开关,一方面当出现死循环的时候可以快速下线功能,另一方面当发生未知代码错误的时候及时下线功能。
代码模板
@PostMapping("/schedule/job")
public void scheduleJob() {
/* 规范:job均异步运行 */
CompletableFuture.runAsync(() -> {
log.info( begin...");
schedule();
log.info("end...");
});
}
public void schedule() {
log.info("[start.");
/* 分布式锁解决并发执行问题 */
RLock lock = redissonClient.getLock("keyLock");
if (!lock.tryLock()) {
log.info("lock conflict");
return;
}
log.info("distribute lock success.");
try {
/* 开关。是否开启 */
if (switch) {
handler();
}
}catch (Exception exception){
log.error("fail.", exception);
}finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
log.info("end.");
}
private void handler() {
log.info("handler start.");
long amountCheckStart = 60 * 60 * 1000;
long amountCheckEnd = 10 * 60 * 1000;
AssertUtil.isTrue(amountCheckStart > amountCheckEnd, "时间区间设置错误");
Date curDate = new Date();
// 示例: 60分钟前的时间
Date startTime = new Date(curDate.getTime() - amountCheckStart);
// 示例: 10分钟前的时间
Date endTime = new Date(curDate.getTime() - amountCheckEnd);
Long requestQueryId = 0L;
// 滚动查询查询数据
List<RequestDO> requestList =
aigcRequestRepository.queryRolledRequestList(requestQueryId, startTime, endTime);
log.info(" requestList.size:[{}]", requestList.size());
/* 扫表 */
while (!CollectionUtils.isEmpty(requestList)) {
for(int i = 0 ; i < requestList.size(); i++) {
RequestDO request = requestList.get(i);
Long id = request.getId();
String requestId = request.getRequestId();
String requestStatus = request.getRequestStatus();
String statusExplain = request.getStatusExplain();
try {
log.info("[check amount] 开始校对. id:[{}], requestId:[{}], status:[{}], statusExplain:[{}]",
id, requestId, requestStatus, statusExplain);
/* 核心业务处理 */
doHandler(request.getUserId(), requestId, requestStatus, statusExplain);
log.info("[check amount] 结束校对. id:[{}], requestId:[{}], status:[{}], statusExplain:[{}]",
id, requestId, requestStatus, statusExplain);
}catch (Exception exception) {
log.error("[check amount] 权益校对失败. id:[{}], requestId:[{}], status:[{}], statusExplain:[{}]",
id, requestId, requestStatus, statusExplain, exception);
}
}
// 获取下次查询的游标
requestQueryId = requestList.get(requestList.size() - 1).getId();
requestList = aigcRequestRepository.queryRolledRequestList(requestQueryId, startTime, endTime);
log.info("[check amount] requestList.size:[{}]", requestList.size());
}
log.info("[check amount] handler end");
}
}
SQL语句
/**
* SQL示例:
* select * from request where id > 6 and
created_time between '2023-03-24 00:00:00' and '2023-03-24 23:00:00' and status in () order by id asc limit 100 ;
* @param id
* @param startTime
* @param endTime
* @return
*/
public List<RequestDO> queryRolledRequestList (Long id, Date startTime, Date endTime){
LambdaQueryWrapper<RequestDO> queryCondition = new QueryWrapper().lambda();
queryCondition.eq(RequestDO::getDeleted, 0);
queryCondition.eq(RequestDO::getRequestStatus, RequestRecordStatus.PROCESSING.getCode());
queryCondition.or().in(RequestDO::getStatusExplain,
Lists.newArrayList(RequestStatusExplain.OCCUPY_UN_KNOW_ERROR.getCode(),
RequestStatusExplain.RELEASE_UN_KNOW_ERROR.getCode()));
queryCondition.between(RequestDO::getCreatedTime, startTime, endTime);
queryCondition.gt(RequestDO::getId, id);
queryCondition.orderByAsc(RequestDO::getId);
queryCondition.last("limit 100");
return aigcRequestDAO.selectList(queryCondition);
}
数据迁移类curl
考虑因素
此类job的特点是:耗时长、可能会中断所以需要续跑、全表无差别复制。
耗时计算:
- 需要根据老表数据量,做一次迁移时长预估,且这段时间机器不能停机,否则得重新提交curl。机器配置xxx,使用batchinsert,100条数据差不多1s。(听说batchinsert比for循环insert性能高4倍,待验证)
代码相关因素:
- 并发考虑。添加分布式锁,
保证同一时刻只有一个线程在处理数据
。使用redisson方式的分布式锁,可以保证:自动续期、避免死锁。 线程休眠
。一般而言,迁移类工作处理的数据量都比较大,那么每批次处理完后需要让线程休眠。可配置到阿波罗中。查询数量限制
。避免查询大量数据到内存导致OOM。可配置到阿波罗中。- 单条插入或者批量插入。如果是
停机迁移
或者不停机迁移对存量数据迁移(存量数据很多,超过千万)
,可考虑批量插入batchInsert(需要仔细考虑是否有唯一冲突),优点是迁移工作会更快些;如果需要对每一条数据做额外的处理
,那么可考虑单条插入insert,缺点是迁移工作会慢些。 - 是否redis记录游标。 数据迁移是一次性代码,curl命令只执行一次,不存在【定时】场景。如果发生错误,log打印错误id,然后再提交一次curl,传入开始游标即可。
- Switch开关。在while循环里面配置一个开关,一方面当出现死循环的时候可以快速下线功能,另一方面当发生未知代码错误的时候及时下线功能。
SQL相关因素:
- 新表是否有唯一约束。新表是否建立了唯一约束(不强制建立,但是建议建立),
如果建立,代码中应该对唯一键异常进行捕获
。 谨防滚动查询SQL慢查询
。SQL的条件需要谨慎,谨防慢查询。对于语句select * from table where id > 1000 and created_time between ‘2022-01-01 00:00:00’ and “2023-10-10 00:00:00” limit 100,id是主键索引,而createTime没有添加索引,那么如果数据量超过两千万条,这个SQL很有可能是慢查询。谨防滚动查询SQL大分页
。对于select * from table where id > 1000 and accountId = ‘001’ limt 100;那么accountId=‘001’的数据在数据库中的分布很有可能是:第一条数据id=1,第二条id=10w,第三条id=20w…. 分散不均匀,导致慢查询。---- 待验证。
代码模板
https://gitee.com/MyFirstMyYun/spring-boot-project-case/blob/master/job-scan-table-demo/src/main/java/com/xiaolyuh/controller/HistoryEventTrackingTask.java
https://gitee.com/MyFirstMyYun/spring-boot-project-case/blob/master/job-scan-table-demo/src/main/java/com/xiaolyuh/controller/SingleTableScanByNoAutoFieldJob.java
总结
略。