需求
项目中来了一个需求,需要通过调用http接口 进行大批量数据的上传 下面将项目信息脱敏 详细介绍一下
我们的项目部署在公安网,需要通过定时任务 将 指定的多个表存储的数据 通过第三方给的http接口文档 上传到第三方系统 第三方再汇聚到类似数据中心的地方。定时任务要求是每5分钟执行一次
这些需要上传的表里面 都有一个update_time字段,是该条数据的最后更新时间。上传时就是根据这个update_time来增量的推送数据
接口鉴权机制
第三方提供的接口 存在一个鉴权机制,就是在调用上传数据接口之前,需要先调用一个getToken接口 获取到token 然后在调用上传数据接口时 将token放到请求头中 才能通过鉴权 token到期时间为120分钟
当然 调用getToken接口时 也需要传入业务方提供的APPID 来证明身份 在此不再赘述
按照单线程的思路 实现了下面的方法
java
//token值
private static String token = "";
//token获取成功的时间 用来统计token是否过期
//因为第三方接口文档 没有说明 token过期时 上传接口 会返回什么错误 所以在这里自己进行一下记录
private static long tokenLastUpdateTime;
private String getToken() throws Exception {
long currentTimeMillis = System.currentTimeMillis();
//这里为了考虑临界情况 将过期时间判断设置为了100分钟
if (currentTimeMillis - tokenLastUpdateTime < 100 * 60 * 1000) {
return token;
}
//调用接口获取token 并处理返回结果
String result = HttpClientUtils.get(getTokenUrl);
if (StringUtils.isNotBlank(result)) {
JSONObject jsonObject = JSONObject.parseObject(result);
if ("success".equals(jsonObject.getString("code"))) {
//接口返回成功 更新token值以及token更新的时间
token = jsonObject.getString("data");
tokenLastUpdateTime = currentTimeMillis;
}
}
return token;
}
以上方法 在单线程的环境下 是没有问题的 可以正常的去获取token
解决方案
文章中的代码 都是业务脱敏后的伪代码 主要为了展示思路
单线程
一开始的思路 就是单线程的去执行,每张表的上传工作 定义一个方法,定义一个主方法 挨个调用每张表的上传方法 依次上传
在这里需要定义一个上传记录表,里面的主要字段有type、update_time 其它字段在此省略
- type字段:是自定义的每张表的标识 用来在上传记录表中 标识该条数据是属于哪张表的
- update_time字段:该条数据修改时间,这里是取的原表里面的update_time字段,这样可以记录 某张表的数据 上传到了哪个时间点
每个上传方法的具体执行流程如下:
- 先根据type 从上传记录表查询到当前type下 最新一条上传数据的时间
sql
select max(update_time) from t_upload_log where type = #{type}
- 根据上一步查出的时间 去具体的数据表中 查询数据 查询条件为 update_time大于传入的时间,这样就是增量的去推送数据
sql
select * from t_data where update_time > #{update_time}
- 取出来的数据 遍历处理之后 通过post请求方式 推送到第三方 代码如下
java
public void upload() {
String type = "Tabaaa";
//select max(update_time) from t_upload_log where type = #{type}
String lastUploadTime = getLastUploadTime(type);
//select * from t_data where update_time > #{update_time}
List<Object> list = getData(lastUploadTime);
for(Object obj : list) {
try{
//调用上传数据接口 进行上传。。。
sendPost(type, obj);
} catch (Exception e) {
//如果捕获异常 这里会进行日志记录 方便问题排查 同时不影响下一条记录的上传
}
}
}
以上思路 功能上是没有问题的,但是存在一个问题 就是上传需要的时间太长了。本身每个表的数据都比较多 再进行单线程依次上传,花费的时间太多,定时任务每5分钟执行一次的话 根本执行不完。
所以 下面引入了多线程的解决方案
多线程
多线程处理的思路在于 定义一个全局的线程池,然后上传方法在取到数据之后 遍历时将每一条数据 提交到线程池 由线程池来进行上传操作。但是目前在主方法中还是依次去调用每个上传方法,这里也是为了避免可读性差 以及 可能出现的线程安全问题等
即便如此 效率也比单线程的多了很多 经测试 单位时间内 单线程推送数据可能在200-300条,多线程方式已经达到了1万以上。
下面贴一下具体代码
单例模式定义线程池
这里自己实现了一个饿汉式单例模式,因为项目中配置文件众多,又比较杂乱 配置类还得配置扫描路径
时间有限 就自己实现了单例模式
java
public class ThreadPool {
private static ThreadPoolTaskExecutor taskExecutor = taskExecutor();
private ThreadPool() {
}
public static ThreadPoolTaskExecutor getTaskExecutor() {
return taskExecutor;
}
private static ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(20);//核心线程数
executor.setMaxPoolSize(50);//最大线程数
executor.setQueueCapacity(100);//等待队列长度
executor.setThreadNamePrefix("dataUploadThread-");//线程名称前缀
//线程池拒绝策略 选择了CallerRunsPolicy 提交者自己执行的策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();//线程池初始化
return executor;
}
}
定时任务执行类代码
java
public class DataUploadJob {
//引入线程池
ThreadPoolTaskExecutor executor = BaqDataUploadThreadPool.getTaskExecutor();
//统一上传方法
public void upload() {
//模拟调用多个表的上传方法
uploadA();
uploadB();
uploadC();
uploadD();
}
//这里用uploadA做个说明
public void uploadA(){
final String type = "Tabaaa";
String lastUploadTime = getLastUploadTime(type);
List<Object> list = getData(lastUploadTime);
for(final Object obj : list) {
executor.execute(()->{
try{
//调用上传数据接口 进行上传。。。
sendPost(type, obj);
} catch (Exception e) {
//如果捕获异常 这里会进行日志记录 方便问题排查 同时不影响下一条记录的上传
}
});
}
}
}
getToken方法线程安全优化
上面每个线程在调用 sendPost 上传数据时 还是存在问题的,sendPost 伪代码如下
java
private void sendPost(String type, Object obj) throws Exception {
//组装请求头
Map<String, String> header = new HashMap<>();
header.put("", getToken());
//上传数据
String resultJson = HttpClientUtils.postJson("请求地址", header, obj);
if (StringUtils.isNotBlank(resultJson)) {
JSONObject jsonObject = JSONObject.parseObject(resultJson);
if ("success".equals(jsonObject.getString("code"))) {
//将上传成功的记录存入记录表
}
}
}
上面可以看出 每条数据上传前 都会去调用 getToken() 方法 获取token 封装到请求头中
那么这里 getToken就可能存在一个线程安全的问题,需要去修改 getToken() 方法
优化思路如下:
- 方法一开始的逻辑不变,如果token还未到期 直接返回token
- 如果token过期了 那下面的调用接口以及 token值、到期时间 这两个属性的更新,就必须保证原子性,这里采用 synchronized 关键字来处理
- 这里还采用了 双检锁(DCL) 来进一步保证了线程安全
优化后的代码如下
java
//token值
private static String token = "";
//token获取成功的时间 用来统计token是否过期
//因为第三方接口文档 没有说明 token过期时 上传接口 会返回什么错误 所以在这里自己进行一下记录
private static long tokenLastUpdateTime;
private String getToken() throws Exception {
if (System.currentTimeMillis() - tokenLastUpdateTime < 100 * 60 * 1000) {
return token;
}
synchronized (token) {
//双检锁 保证线程安全
long currentTimeMillis = System.currentTimeMillis();
if (currentTimeMillis - tokenLastUpdateTime < 100 * 60 * 1000) {
return token;
}
//调用接口 并更新token值以及更新时间
String result = HttpClientUtils.get(getTokenUrl);
if (StringUtils.isNotBlank(result)) {
JSONObject jsonObject = JSONObject.parseObject(result);
if ("success".equals(jsonObject.getString("code"))) {
token = jsonObject.getString("data");
tokenLastUpdateTime = currentTimeMillis;
}
}
}
return token;
}
以上代码 还有几个需要说明的地方 这里说明一下
- 第9-17行的双检锁,就是为了保证 t1线程进入代码块后,t2阻塞。t1释放后 t2进入代码块 会再次调用接口 赋值token,这里为了避免这种情况 在进入代码块后 再次判断token是否未过期 避免上述问题。
- long currentTimeMillis = System.currentTimeMillis(); 这行代码 没有在方法一开始声明 ,而是放在了 synchronized 代码块之内
-
- 这里是因为 如果放在一开始声明 如果线程阻塞 那么这个变量 记录的就是线程开始阻塞的时间,而不是成功获取token的时间。这样的话 多少会有点误差,所以 这里做了上述处理。
单线程优化
截止到这里 针对这个问题的解决方案已经比较高效了 但是又出现了新的问题
第三方用来接收数据的接口 对并发比较高的情况做了限制。通过线程池去推送数据,很多都返回 超出频率限制,后来和业务方沟通,他们表示 不要调用太快 要留给接口反应时间 -_-||
那还是只能改成单线程的方式 只不过在单线程的情况下 做一些小小的优化
- sql查询数据时 根据update_time进行升序排序 这样取出的数据 就是从远及近的这么一个顺序
- 取数据时 使用Limit字段 限制取出的条数 这样每次推送的条数都有限 5分钟时间也勉强可以处理完
以上两点,就可以保证 每次推的数据 都是先取时间靠前的数据 也就是说 数据时按照时间的先后顺序 增量推送 并且取出的条数还进行了限制 这样定时任务的执行时间 不会过长
实现方式就是在sql最后面添加 order by update_time limit 100 这样,具体每张表取多少条数据 后续也可以按照业务来进行调整。
拓展
如果需要我去设计一个接收数据的接口 既要满足能接受并发调用 又能准确的返回错误信息,可以将接口如下设计
使用FutureTask配合线程池 可以在调用get方法时 获取到对应异常 或者 正确出参。
java
@PostMapping("/doUpload")
public Object doUpload() {
FutureTask<String> task = new FutureTask<String>(() -> {
int i = 1 / 0;
return "success";
});
threadPoolTaskExecutor.submit(task);
try {
return task.get();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (ExecutionException e) {
throw new RuntimeException(e);
}
}