利用并发编程提升接口效率

895 阅读8分钟

前言

为什么我每次查看订单详情这么慢?为什么别的APP查看订单详情这么快?没有对比就没有伤害,作为程序员的你们平时工作中会不会有这种声音在你耳边响起呢 我想这... 大概、也许会有的吧.....

为什么会慢呢?其实这个要从多个维度来说,比如说数据库单表数据量大了,表没有建索引,表设计的不合理导致SQL写的太复杂,或者是表索引失效了,查询逻辑有待优化等等诸多原因都有可能导致查询效率变慢。

本文主要是从Java代码查询逻辑层面来做优化处理,通过模拟一个查询订单详情接口来分析为什么要使用并发编程?使用并发编程是如何提升接口效率(应用性能)的?使用并发编程有哪些优缺点?

需求来的猝不及防

太慢了,太慢了,太慢了!能不能给我优化优化,也许这就是程序员头发日渐稀少的原因之一吧,哈哈哈!

优化前的代码

//来自 OrderController 的 details() 方法,用来查询订单详情的

public OrderDetailsDTO detailsV1(String orderNo) {
    
    StopWatch watch = new StopWatch();
    watch.start();

    //这里为了演示查询场景模拟的根据订单先查询到订单的信息,根据订单号查询详情、根据订单号查询地址、根据订单号查询物流信息
    String orderInfo = orderInfo(orderNo);
    log.info("查询订单 {} 的订单信息", orderNo);

    String orderGoodsInfo = orderGoodsInfo(orderNo);
    log.info("查询订单 {} 的商品信息", orderNo);

    String orderAddressInfo = orderAddressInfo(orderNo);
    log.info("查询订单 {} 的商品信息", orderNo);

    String orderLogisticsInfo = orderLogisticsInfo(orderNo);
    log.info("查询订单 {} 的物流信息", orderNo);

    //合并查询结果
    OrderDetailsDTO dto = new OrderDetailsDTO(orderInfo, orderGoodsInfo, orderAddressInfo, orderLogisticsInfo);

    watch.stop();
    log.info("查询详情耗时:{}s", watch.getTotalTimeSeconds());

    return dto;
}

从上面的代码分析来看,订单的基础信息、商品信息、地址信息、物流信息都是跟订单号关联的,使用订单号就可以查询出来各自的信息,并没有很强的依赖关系【这里的依赖指的是在查询商品信息并没有依赖订单信息的返回结果,而是利用订单号就能查询出关联的商品信息】

那么在代码中一个一个去执行查询肯定是会影响整体效率,比如订单信息查询消耗了2秒,商品信息1秒,地址信息1秒,物流信息2~3秒,此时一个查询请求线程进来,代码按照串行顺序挨个查询总计耗费个6~7秒钟。

思考 既然都各自没有相互依赖是不是可以采取并行呢?利用多个线程来同时查询最终将各自查询的结果汇总到一起

优化后的代码

public OrderDetailsDTO detailsV2(String orderNo) {
    StopWatch watch = new StopWatch();
    watch.start();

    //这里使用 Callable 来作为线程任务的执行类,因为Callable可以结合Future拿到线程任务的返回结果
    Callable<String> task1 = new Callable<String>() {
        @Override
        public String call() throws Exception {
            log.info("查询订单 {} 的订单信息", orderNo);
            return orderInfo(orderNo);
        }
    };

    Callable<String> task2 = new Callable<String>() {
        @Override
        public String call() throws Exception {
            log.info("查询订单 {} 的商品信息", orderNo);
            return orderGoodsInfo(orderNo);
        }
    };

    Callable<String> task3 = new Callable<String>() {
        @Override
        public String call() throws Exception {
            log.info("查询订单 {} 的地址信息", orderNo);
            return orderAddressInfo(orderNo);
        }
    };

    Callable<String> task4 = new Callable<String>() {
        @Override
        public String call() throws Exception {
            log.info("查询订单 {} 的物流信息", orderNo);
            return orderLogisticsInfo(orderNo);
        }
    };

    OrderDetailsDTO orderDetailsDTO = null;
    try {
        //创建一个核心大小为4的固定的线程池
        // invokeAll() : 执行给定的任务,返回一个 Futures 列表,在所有完成时保存它们的状态和结果
        List<Future<String>> futures = Executors.newFixedThreadPool(4).invokeAll(Arrays.asList(task1, task2, task3, task4));
        orderDetailsDTO = new OrderDetailsDTO();

        //遍历 Futures
        for (Future<String> future : futures) {
            //判断线程任务是否执行完成,如果完成则返回 true
            if (future.isDone()) {
                String str = future.get();

                //这里只是模拟根据各个任务的返回之后来判断进行数据汇总
                if ("订单信息获取成功".equals(str)) {
                    orderDetailsDTO.setOrderInfo(str);
                }
                if ("商品信息获取成功".equals(str)) {
                    orderDetailsDTO.setOrderGoodsInfo(str);
                }
                if ("地址信息获取成功".equals(str)) {
                    orderDetailsDTO.setOrderAddressInfo(str);
                }
                if ("物流信息获取成功".equals(str)) {
                    orderDetailsDTO.setOrderLogisticsInfo(str);
                }
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }

    watch.stop();
    log.info("查询详情耗时:{}s", watch.getTotalTimeSeconds());

    return orderDetailsDTO;
}

注意:这里只是为了演示案例,在真实生产环境场景不建议这么创建线程池,如果一旦并发上来了会导致服务器资源占有过高,影响系统稳定性。

模拟查询订单基础信息 orderInfo() 代码

private String orderInfo(String orderNo) {
    //假设需要2秒
    try {
        TimeUnit.SECONDS.sleep(2);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return "订单信息获取成功";
}

模拟查询订单商品信息 orderGoodsInfo() 代码

private String orderGoodsInfo(String orderNo) {
    //假设需要1秒
    try {
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return "商品信息获取成功";
}

模拟查询订单地址信息 orderAddressInfo() 代码

private String orderAddressInfo(String orderNo) {
    //假设需要1秒
    try {
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return "地址信息获取成功";
}

模拟查询订单物流信息 orderLogisticsInfo() 代码 [hutool工具包]

private String orderLogisticsInfo(String orderNo) {
    try {
        //一般查询物流可能是远程调用API,假设需要2~3秒
        long timeout = RandomUtil.randomLong(2, 4);//工具包使用的是hutool
        TimeUnit.SECONDS.sleep(timeout);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return "物流信息获取成功";
}

OrderDetailsDTO.java 响应给前端的订单详情包装类

/**
 * 汇总订单详情
 *
 * @author 云飞
 */
@NoArgsConstructor
@AllArgsConstructor
@Data
public class OrderDetailsDTO {
    /**
     * 订单基础信息
     */
    private String orderInfo;
    /**
     * 订单中的商品信息
     */
    private String orderGoodsInfo;
    /**
     * 订单中的地址信息
     */
    private String orderAddressInfo;
    /**
     * 订单中的物流信息
     */
    private String orderLogisticsInfo;
}

测试结果比较

    public static void main(String[] args) {
        //使用main方法来模拟前端调用接口
        OrderController controller = new OrderController();
        OrderDetailsDTO dto = controller.detailsV1("O123456");
//        OrderDetailsDTO dto = controller.detailsV2("O123456");
        log.info(JSON.toJSONString(dto));//@Slf4j注解
    }

优化前

优化后

总结

为什么要使用并发编程?

  1. 最主要是为了提升了应用的性能
  2. 复杂业务场景下,合理的进行业务拆分,并发比串行更适用
  3. 并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升

并发编程有哪些缺点?

多线程编程的好处显而易见,某种程度上提升了应用的性能,那么有没有缺点呢?是不是在任何场景下都使用呢?结论并不是

  1. 频繁切换上下文:CPU分配各个线程的时间片一般都是毫秒级,而每次切换时,都需要把当前状态保存起来,以便CPU下一次执行该线程时能够进行恢复先前状态,而这个切换时非常耗损性能,过于频繁反而无法发挥出多线程编程的优势。通常减少上下文切换可以采用无锁并发编程,CAS算法,使用最少的线程和使用协程(协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。)
  2. 线程安全问题:多线程编程中最难以把握的就是临界区线程安全的问题,如果需要对其进行加锁操作,稍微不注意就有可能会出现死锁的情况,一旦产生死锁就会造成系统功能不可用。

应该了解的概念

同步与异步

同步和异步关注的是 消息通信机制 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由调用者主动等待这个调用的结果。而异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

并发和并行

并发和并行是比较容易混淆的概念,并发指的是多个任务交替进行,而并行则是指真正意义上的 "同时进行" 。实际上,如果系统内只有一个CPU,而使用多线程时,那么真实系统环境下不能并行,只能通过切换时间片的方式交替进行,而成为并发执行任务。真正的并行也只能出现在拥有多个CPU的系统中。

阻塞和非阻塞

阻塞和非阻塞关注的是 程序在等待调用结果(消息,返回值)时的状态。阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

临界区

临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每个线程使用时,一旦临界区资源被一个线程占有,那么其他线程必须等待。