springmvc 数据并发推送三方系统

需求

项目中来了一个需求,需要通过调用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字段,这样可以记录 某张表的数据 上传到了哪个时间点

每个上传方法的具体执行流程如下:

  1. 先根据type 从上传记录表查询到当前type下 最新一条上传数据的时间
sql 复制代码
select max(update_time) from t_upload_log where type = #{type}
  1. 根据上一步查出的时间 去具体的数据表中 查询数据 查询条件为 update_time大于传入的时间,这样就是增量的去推送数据
sql 复制代码
select * from t_data where update_time > #{update_time}
  1. 取出来的数据 遍历处理之后 通过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() 方法

优化思路如下:

  1. 方法一开始的逻辑不变,如果token还未到期 直接返回token
  2. 如果token过期了 那下面的调用接口以及 token值、到期时间 这两个属性的更新,就必须保证原子性,这里采用 synchronized 关键字来处理
  3. 这里还采用了 双检锁(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;
}

以上代码 还有几个需要说明的地方 这里说明一下

  1. 第9-17行的双检锁,就是为了保证 t1线程进入代码块后,t2阻塞。t1释放后 t2进入代码块 会再次调用接口 赋值token,这里为了避免这种情况 在进入代码块后 再次判断token是否未过期 避免上述问题。
  2. long currentTimeMillis = System.currentTimeMillis(); 这行代码 没有在方法一开始声明 ,而是放在了 synchronized 代码块之内
    1. 这里是因为 如果放在一开始声明 如果线程阻塞 那么这个变量 记录的就是线程开始阻塞的时间,而不是成功获取token的时间。这样的话 多少会有点误差,所以 这里做了上述处理。

单线程优化

截止到这里 针对这个问题的解决方案已经比较高效了 但是又出现了新的问题

第三方用来接收数据的接口 对并发比较高的情况做了限制。通过线程池去推送数据,很多都返回 超出频率限制,后来和业务方沟通,他们表示 不要调用太快 要留给接口反应时间 -_-||

那还是只能改成单线程的方式 只不过在单线程的情况下 做一些小小的优化

  1. sql查询数据时 根据update_time进行升序排序 这样取出的数据 就是从远及近的这么一个顺序
  2. 取数据时 使用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);
    }
}
相关推荐
海绵波波1074 小时前
flask后端开发(10):问答平台项目结构搭建
后端·python·flask
网络风云5 小时前
【魅力golang】之-反射
开发语言·后端·golang
Q_19284999065 小时前
基于Spring Boot的电影售票系统
java·spring boot·后端
运维&陈同学6 小时前
【Kibana01】企业级日志分析系统ELK之Kibana的安装与介绍
运维·后端·elk·elasticsearch·云原生·自动化·kibana·日志收集
Javatutouhouduan9 小时前
如何系统全面地自学Java语言?
java·后端·程序员·编程·架构师·自学·java八股文
后端转全栈_小伵9 小时前
MySQL外键类型与应用场景总结:优缺点一目了然
数据库·后端·sql·mysql·学习方法
编码浪子10 小时前
Springboot高并发乐观锁
后端·restful
uccs10 小时前
go 第三方库源码解读---go-errorlint
后端·go
Mr.朱鹏10 小时前
操作002:HelloWorld
java·后端·spring·rabbitmq·maven·intellij-idea·java-rabbitmq
编程洪同学12 小时前
Spring Boot 中实现自定义注解记录接口日志功能
android·java·spring boot·后端