前言
前两篇文章讲了ForkJoin的基本原理和源码的简单讲解。也将ForkJoin做了简单的应用。今天这篇文章讲一讲在解决实际问题时对ForkJoin的应用。
实际中的问题
在做项目的时候,遇到了这样一个需求:需要把远端数据源中的数据全量导入到我们项目中的库中,也就是远端库数据同步更新到本地库。远端数据库中的数据表量比较庞大。由于是zf项目,对方只给我们提供这一种数据获取方式,而且对方的服务器也不给开放任何权限,只能链接他们的数据库。所以,在这些限制条件下,只能使用最笨的链接数据库的方式进行数据获取。
分析
经过远端数据库的数据梳理,发现虽然表的量比较庞大,但是表中的数据最多只达到了百万级,并不算多。而且有些表数据是以增量的形式变化的。这样,对于这些表,第一次数据同步可以看作全量同步外,以后的同步数量级就很小了。
既然是同步数据,那每次数据同步就包含有:新增|更新|删除
的数据。每次同步数据可以分批获取远端数据,再对分批数据进行解析,得到新增数据
、更新数据
、删除数据
。得到这些数据后,再针对不同数据对本地表做相应的操作。
思路
通过上面的分析,那数据同步思路大概就是:
- 每张表分批查询,获取分批数据。
- 对分批数据进行解析,得到新增、更新、删除数据。
- 对于新增的数据直接进行插表,对于更新的数据对其进行更新,对于删除的数据则进行删除。
- 重复以上3个步骤,扫描全部数据表。
数据分批获取
数据分批获取这一块,我直接用的是最简单粗暴的方法:分页查询。由于项目框架使用的是Hibernate,我这里使用的是@Query
加手写sql。得到的数据结构为:List<Map<String, Object>>
,再通过反射得到自己想要的数据。由于数据表数量庞大,这里用到了泛型。
样例代码贴在下面:
/**
* 分页查询
*
*/
public <T> Map getSrcDataFromRemote(Class<T> clazz, DataSrcSyncPj param, PgRepository pgRepository) throws Exception {
Map resMap = new HashMap();
Integer pageNum = param.getPageNum();
Integer pageSize = param.getPageSize();
FixedPageData pageData = new FixedPageData(pageNum, pageSize);
/**
* 数据同步时间
*/
Date syncDate = param.getSyncDatetime();
String syncDateStr = "";
if (syncDate != null) {
syncDateStr = DateUtils.getDateTime(syncDate);
}
Integer size = Math.toIntExact(pageData.getSize());
Integer offset = Math.toIntExact(pageData.getOffset());
Integer unSyncNum = param.getUnSyncNum();
//总数
Integer total = 0;
if (unSyncNum != null) {
total = unSyncNum;
} else {
PageNum pgN = pgRepository.findPageTotal(syncDateStr);
if (pgN != null) {
total = pgN.getTotal();
}
}
//查询数据
List<Map<String, Object>> pageObjs = pgRepository.findPageData(syncDateStr, offset, size);
//反射
List<T> remoteData = EntityUtils.reflectMap2PojoList(pageObjs, clazz);
resMap.put("total", total);
resMap.put("remoteData", remoteData);
log.info("{} getSrcDataFromRemote >>>> QUERY COMPLETED, UnSync Data Total: {}", this.getClass().getSimpleName(), total);
return resMap;
}
数据 增|删|改
项目中用到的框架是Hibernate,所以就涉及到entityManager,queryFactory
这些东西。
增
数据新增这里,我这块直接用到了entityManager
做的批量插入。先persist()
添加批量数据,然后再做flush()
刷新。
/**
* 批量新增
*
* @param var1
* @param <T>
*/
@Transactional(value = "transactionManagerMysql", rollbackFor = Exception.class)
public <T> void batchInsert(List<T> var1) {
Iterator<T> iterator = var1.iterator();
int index = 0;
while (iterator.hasNext()) {
T itD = iterator.next();
entityManager.persist(itD);
index++;
//过临界值或达到集合边界刷新缓存,数据入库
if (index % BATCH_SIZE == 0 || index == var1.size()) {
try {
entityManager.flush();
} catch (Exception e) {
printExceptionLog(this, "batchInsert", "Insert EXCEPTION", e);
e.printStackTrace();
} finally {
entityManager.clear();
}
}
}
}
改
数据的更新,直接可以用merge()
来处理,最后flush()
刷新。
/**
* 批量修改
*
* @param var1
* @param <T>
*/
@Transactional(value = "transactionManagerMysql", rollbackFor = Exception.class)
public <T> void batchUpdate(List<T> var1) {
Iterator<T> iterator = var1.iterator();
int index = 0;
while (iterator.hasNext()) {
T itD = iterator.next();
entityManager.merge(itD);
index++;
//过临界值或达到集合边界刷新缓存,数据入库
if (index % BATCH_SIZE == 0 || index == var1.size()) {
try {
entityManager.flush();
} catch (Exception e) {
printExceptionLog(this, "batchUpdate", "update EXCEPTION", e);
e.printStackTrace();
} finally {
entityManager.clear();
}
}
}
}
删
删除这里,我直接用的是临时表和本地表的leftjoin
来进行过滤。在数据进行分批解析和入库的过程中,将远端数据的id
存入本地的临时表中。在数据新增或更新完后,就可以得到远端表和本地表的所有最新数据。再通过leftjoin
的筛选,就可以得到需要删除的数据。
只不过在删除的时候,需要注意。在用到delete from table in (xxxx)
的时候,in
的数据不能过大,否则会有异常的风险。我这里将in
的数量控制在了1000
。
/**
* 删除数据
*
* @param rIds
* @param repos
* @return
*/
private Integer deleteData(List<String> rIds, BaseRepository repos) {
int total = 0;
if (!rIds.isEmpty()) {
List<List<String>> parts = Lists.partition(rIds, 1000);
int num = 0;
for (List<String> ids : parts) {
num = repos.deleteByIds(ids);
total += num;
}
}
return total;
}
ForkJoin应用
从上面的思路来看,可以引入ForkJoin的地方,是在对数据进行插入、更新、删除的时候。由于ForkJoin的基本原理是工作窃取和fork/join。
那么就可以在对分批数据进行解析的时候引入,将分批数据化整为零,各自处理。如下图:
线程安全
既然使用到了ForkJoin
,又是涉及数据的处理,必然涉及到线程安全的问题。对于这里的线程安全,其实我一开始是用synchronized
做的处理,但是在后续的测试中发现,被synchronized
的线程并不安全,还是会出现数据不一致的问题。后来,我用到了ReentrantLock
可重入锁,ReentrantLock
可以更有效的处理线程安全问题,而且比synchronized
使用更加灵活。
ReentrantLock
ReentrantLock
属于乐观锁。一般使用lock获取锁,unlock释放锁。每一个锁关联一个线程持有者和计数器,当计数器为 0 时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为 1;此时其它线程请求该锁,则必须等待;而持有该锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为 0,则释放该锁。
相比于synchronized
,ReentrantLock
在功能上更加丰富,它具有可重入、可中断、可限时、公平锁等特点。
ForkJoinTask compute()
compute()是ForkJoinTask的核心,需要复写来实现自己的业务功能。这里的主要逻辑就是数据的fork和结果的join。由于需要获取新增|更新
数据量,我这里用到了ConcurrentMap
来存放自己想要的数据。
样例代码:
protected ConcurrentMap<String, Object> compute() {
try {
//上锁
lock.lock();
ConcurrentMap<String, Object> map = new ConcurrentHashMap<>();
ConcurrentMap<String, Object> mapIds = new ConcurrentHashMap<>();
if (this.remoteList.size() > part) {
List<ForkJoinTask> tasks = new ArrayList<>();
List<List<OrclEntity>> partList = Lists.partition(this.remoteList, part);
Date date = this.syncDate;
boolean locate = this.locate;
BaseRepository repos = this.bRepos;
Class<T> clazz = this.clazzT;
//任务分割
partList.stream().forEach(it -> {
SyncOrclTaskN task = new SyncOrclTaskN(clazz,
Collections.synchronizedList(it), date, repos, locate);
tasks.add(task.fork());
});
AtomicInteger idsA = new AtomicInteger(0);
AtomicInteger addA = new AtomicInteger(0);
AtomicInteger updA = new AtomicInteger(0);
ConcurrentMap<String, Object> finalMap = map;
//结果合并
tasks.stream().forEach(it -> {
ConcurrentMap<String, Object> itMAP = (ConcurrentMap<String, Object>)it.join();
idsA.addAndGet((int) itMAP.get("ids"));
addA.addAndGet((int) itMAP.get("add"));
updA.addAndGet((int) itMAP.get("upd"));
finalMap.put("ids", idsA);
finalMap.put("add", addA);
finalMap.put("upd", updA);
});
return finalMap;
} else {
//数据解析
transformSrcData();
mapIds.put("ids", 0);
if (this.overAll) {
mapIds = locateIds();
}
map.put("add", 0);
map.put("upd", 0);
if (this.locate) {
map = addOrUpdate();
}
}
map.putAll(mapIds);
return map;
} catch (Exception e) {
log.error("{} compute >>>> TASK compute EXCEPTION: {}", this.getClass().getSimpleName(), e.toString());
log.error("{} compute >>>> TASK compute EXCEPTION StackTrace: {}", this.getClass().getSimpleName(), e.getStackTrace());
e.printStackTrace();
} finally {
//解锁
lock.unlock();
}
return null;
}
数据解析
数据解析这块,主要就是为了分离新增和更新数据。思路很简单,就是在本地表存在的数据,就是更新数据,不存在的就是新增数据。这里还是用到了泛型。
样例代码:
/**
* @Author zhou
* @Description 远端数据解析
* @Date 2021/7/12 16:19
* @Param []
* @return void
**/
public void transformSrcData() {
List<OrclEntity> unUpdData = new ArrayList<>();
List<OrclEntity> newData = new ArrayList<>();
if (remoteList.size() > 0 && remoteList != null) {
//获取id
List<String> remoteIds = this.remoteList.stream().map(OrclEntity::getId).collect(Collectors.toList());
if (this.overAll) {//全表扫描
this.idList = remoteIds;
}
//查询本地库存在的数据
List<ExistsId> existIds = new ArrayList<>();
existIds = bRepos.findExistIds(remoteIds);
//分离新增数据和更新数据
Date date = new Date();
List<ExistsId> finalExistIds = existIds;
remoteList.stream().forEach(item -> {
String pId = item.getId();
if (finalExistIds != null && finalExistIds.size() > 0) {
//新增标识
boolean flag = false;
for (ExistsId itId : finalExistIds) {
String localId = itId.getLocalId();
if (StringUtils.equals(pId, localId)) {
flag = true;
item.setId(localId);
item.setSyncDatetime(date);
unUpdData.add(item);
}
}
if (!flag) {
newData.add(item);
}
} else {
newData.add(item);
}
});
//转换为json存放
String jsonAdd = JSON.toJSONString(newData);
String jsonUpd = JSON.toJSONString(unUpdData);
//转换数据
this.addList = JSON.parseArray(jsonAdd, this.clazzT);
this.updList = JSON.parseArray(jsonUpd, this.clazzT);
this.existIds = finalExistIds;
}
}
多数据表 泛型处理
一张表的数据同步过程已经捋清楚,而远端数据表数量比较庞大,不可能每一张表的数据同步都重复写一遍。那么就可以将数据表向上提炼,提炼出一个父类,父类表包含有子类表的共有属性,比如 id, syncDateTime
。
/**
* @ProjectName xxxx
* @ClassName OrclEntity
* @Description 父类
* @Author zhou
* @Date 2021/6/17 14:02
* @Version 1.0
*/
public abstract class OrclEntity {
//提到父类的公共方法
public abstract String getId();
public abstract void setId(String id);
public abstract void setSyncDatetime(Date date);
}
这样在ForkJoinTask
的基础上,增加了泛型以后,对应的构造函数和Task类做了一些改变。
class SyncOrclTaskN<T> extends RecursiveTask<ConcurrentMap<String, Object>> {
//单个任务处理数据量
private static final int part = 10;
//远端数据
private List<OrclEntity> remoteList;
//新增数据
private List<T> addList;
//更新数据
private List<T> updList;
//ids
private List<String> idList;
//存在数据id
private List<ExistsId> existIds;
//同步时间
private Date syncDate;
//全表扫描
private Boolean overAll = false;
//是否开启数据同步
private Boolean locate;
//dao
private BaseRepository bRepos;
//泛型
private Class<T> clazzT;
//锁
final ReentrantLock lock = new ReentrantLock();
public SyncOrclTaskN(Class<T> clazz, List<OrclEntity> remotes, Date date, BaseRepository repository, boolean locate) {
/**
* 初始化参数
*/
this.syncDate = date;
this.locate = locate;
if (this.syncDate == null) {
//时间为空,全表扫描
this.overAll = true;
}
this.remoteList = Collections.synchronizedList(remotes);
this.bRepos = repository;
this.clazzT = clazz;
}
}
至此,整个数据同步的逻辑与过程就梳理完了,剩下的就是在此基础上,增加多数据源了。关于多数据源的引入就不在这里展开了。
参考与引用