网络传言验证-请求合并优化真的有效吗?

前言

最近看了篇文章,提到了请求合并优化,但是没有测试数据对比,让我产生了一些好奇,决心自己来做一次彻底的对比实验,会给出切实可行的代码以及注释,欢迎来看!

环境配置

所有优化都是基于业务场景和开发环境动态调整的,因此我会详细列出影响该优化相关的配置,没有特别提到的就是默认配置

  • Spring Boot 2.1.18.RELEASE,Oracle OpenJDK version 1.8.0_381
  • MySQL 5.7,配置优化
    • innodb_buffer_pool_size=16G innodb_buffer_pool_instances=8 innodb_buffer_pool_chunk_size=512M,buffer pool优化提高并发度和命中率
    • max_connections=1000,提升最大连接数
  • Druid 1.2.16 需要调大maxActive

本地版测试

网上还有Hystrix框架带的请求合并写法,这里暂不考虑,就用纯Java方式实现,同时为了方便开发和本地测试,采用了本地版和网络版两次验证方式,抽丝剥茧般剖析请求合并的优化。

实现原理

java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.PostConstruct;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;

@RestController
@RequestMapping("/merge")
@Slf4j
public class MergeController {
    private final MergeService mergeService;

    public MergeController(MergeService mergeService) {
        this.mergeService = mergeService;
    }

    private final static int REQ_MAX = 800;
    private final static ThreadPoolExecutor executor = new ThreadPoolExecutor(2000, 3000,
            3, TimeUnit.MINUTES, new LinkedBlockingQueue<>(500));
    private List<String> bomCodeList = new ArrayList<>();

    @GetMapping("/merge")
    public Integer merge() {
        String bomCode = bomCodeList.get(new Random().nextInt(10));
        return mergeService.listDeCaOriginal(bomCode).size();
    }

    @GetMapping("/mergeBatch")
    public Integer mergeBatch() {
        String bomCode = bomCodeList.get(new Random().nextInt(10));
        return mergeService.listDeCa(bomCode).size();
    }

    /**
     * 初始化条件数据
     */
    @PostConstruct
    public void initData() {
        bomCodeList = mergeService.listBomCode();
    }

    @GetMapping("/test1")
    public void test1() {
        Random random = new Random();
        Instant start1 = Instant.now();
        AtomicLong al = new AtomicLong(0L);
        CompletableFuture[] futures = new CompletableFuture[REQ_MAX];
        for (int i = 0; i < REQ_MAX; i++) {
            futures[i] = CompletableFuture.runAsync(() -> {
//                System.out.println(Thread.currentThread().getName() + "开始计算");
                String bomCode = bomCodeList.get(random.nextInt(bomCodeList.size() / 80));
//                String res = HttpClientUtil.sendHttpGet(
//                        "http://127.0.0.1:8081/order-compute-new/merge/mergeBatch?bomCode=" + bomCode);
                Instant start = Instant.now();
                int res = mergeService.listDeCa(bomCode).size();
                Instant end = Instant.now();
                long between = ChronoUnit.MILLIS.between(start, end);
                al.addAndGet(between);
            }, executor).exceptionally(e -> {
                log.error("多线程操作异常", e);
                return null;
            });
        }
        CompletableFuture<Void> allOf = CompletableFuture.allOf(futures);
        // 等待所有任务完成
        try {
            allOf.get();
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
        Instant end1 = Instant.now();
        System.out.println("合并优化总时间" + ChronoUnit.MILLIS.between(start1, end1) + " 秒");
        System.out.println("合并优化平均时间" + al.get() / REQ_MAX);
    }

    @GetMapping("/test2")
    public void test2() {
        Random random = new Random();
        Instant start2 = Instant.now();
        CompletableFuture[] futures2 = new CompletableFuture[REQ_MAX];
        AtomicLong al = new AtomicLong(0L);
        for (int i = 0; i < REQ_MAX; i++) {
            futures2[i] = CompletableFuture.runAsync(() -> {
                String bomCode = bomCodeList.get(random.nextInt(bomCodeList.size()));
//                String res = HttpClientUtil.sendHttpGet(
//                        "http://127.0.0.1:8081/order-compute-new/merge/mergeBatch?bomCode=" + bomCode);
                Instant start = Instant.now();
                int res = mergeService.listDeCaOriginal(bomCode).size();
                Instant end = Instant.now();
                long between = ChronoUnit.MILLIS.between(start, end);
                al.addAndGet(between);
//                System.out.println(Thread.currentThread().getName() + "参数 " + bomCode + " 返回值 " + res);
            }, executor).exceptionally(e -> {
                log.error("多线程操作异常", e);
                return null;
            });
        }
        CompletableFuture<Void> allOf2 = CompletableFuture.allOf(futures2);
        // 等待所有任务完成
        try {
            allOf2.get();
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
        Instant end2 = Instant.now();
        System.out.println("原始请求总时间" + ChronoUnit.SECONDS.between(start2, end2) + " 秒");
        System.out.println("原始请求平均时间" + al.get() / REQ_MAX);
    }
}

一共就两段,Controller层是接口和本地压测代码,Service层是批处理定时任务和具体的待测试接口

ini 复制代码
import com.alibaba.excel.util.StringUtils;
import com.ruijie.ordercompute.entity.OdDeliveryCalculation;
import com.ruijie.ordercompute.service.IOdDeliveryCalculationService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.stream.Collectors;

@Service
@Slf4j
@EnableScheduling
public class MergeService {
    /**
     * 请求类
     */
    @Data
    public class RequestMerge {
        private String uniqueId;
        private String bomCode;
        private LinkedBlockingQueue<List<OdDeliveryCalculation>> futureRes;
    }

    private final IOdDeliveryCalculationService deCaService;

    private MergeService(IOdDeliveryCalculationService deCaService) {
        this.deCaService = deCaService;
    }

    private final Queue<RequestMerge> queue = new LinkedBlockingQueue<>(800);
    private final static Integer MAX_NUMBER = 800;

    /**
     * 批处理定时任务
     */
    @Scheduled(fixedDelay = 1500L)
    public void handleMerge() {
        int size = queue.size();
        if (size != 0) {
            List<RequestMerge> userReqs = new ArrayList<>();
            for (int i = 0; i < size; i++) {
                // 后面的SQL语句是有长度限制的,所以还要做限制每次批量的数量,超过最大任务数,等下次执行
                if (i < MAX_NUMBER) {
                    userReqs.add(queue.poll());
                }
            }
            System.out.println("合并了" + userReqs.size() + "条请求");
            Instant start = Instant.now();
            Map<String, List<OdDeliveryCalculation>> responseMap = listDeCaBatch(userReqs);
            for (RequestMerge userReq : userReqs) {
                List<OdDeliveryCalculation> list = responseMap.get(userReq.getUniqueId());
                LinkedBlockingQueue<List<OdDeliveryCalculation>> futureRes = userReq.getFutureRes();
                futureRes.offer(list);
            }
            System.out.println("查询+分发用时"+ChronoUnit.MILLIS.between(start, Instant.now())+"毫秒");
        }
    }

    /**
     * 原始请求
     */
    public List<OdDeliveryCalculation> listDeCaOriginal(String bomCode) {
        return deCaService.lambdaQuery().eq(OdDeliveryCalculation::getBomCode, bomCode).list();
    }

    /**
     * 结合请求合并优化的改造后请求
     */
    public List<OdDeliveryCalculation> listDeCa(String bomCode) {
        RequestMerge request = new RequestMerge();
        // 这里用UUID做请求id
        request.setUniqueId(UUID.randomUUID().toString().replace("-", ""));
        request.setBomCode(bomCode);
        LinkedBlockingQueue<List<OdDeliveryCalculation>> itemQueue = new LinkedBlockingQueue<>();
        request.setFutureRes(itemQueue);
        //将对象传入队列
        queue.offer(request);
        try {
            return itemQueue.take();
        } catch (Exception e) {
            log.error("取值异常", e);
        }
        return null;
    }

    /**
     * 批量查询
     */
    public Map<String, List<OdDeliveryCalculation>> listDeCaBatch(List<RequestMerge> itemReqs) {
        List<String> bomCodeList = itemReqs.stream().map(RequestMerge::getBomCode).distinct().collect(Collectors.toList());
        Instant start = Instant.now();
        List<OdDeliveryCalculation> list = deCaService.lambdaQuery().in(OdDeliveryCalculation::getBomCode, bomCodeList).list();
        System.out.println("查询用时"+ChronoUnit.MILLIS.between(start, Instant.now())+"毫秒");
        Map<String, List<OdDeliveryCalculation>> itemResMap = list.stream()
                .collect(Collectors.groupingBy(OdDeliveryCalculation::getBomCode));
        Map<String, List<OdDeliveryCalculation>> reqIdMap = new HashMap<>(itemReqs.size());
        for (RequestMerge itemReq : itemReqs) {
            reqIdMap.put(itemReq.getUniqueId(), itemResMap.get(itemReq.getBomCode()));
        }
        return reqIdMap;
    }

    /**
     * 获取所有条件数据
     */
    public List<String> listBomCode() {
        List<OdDeliveryCalculation> list = deCaService.lambdaQuery()
                .select(OdDeliveryCalculation::getBomCode).list();
        return list.stream().filter(Objects::nonNull).map(OdDeliveryCalculation::getBomCode)
                .filter(StringUtils::isNotBlank).distinct().collect(Collectors.toList());
    }
}

答疑解惑

和网上其他人写的略有不同,这里针对上面一些代码行做一下注释,解释一下为啥这么写。

为什么用CompletableFuture?

我最开始看网上有用CountDownLatch来确保所有线程同时并发的,但是他没有写优化之后的时间对比。又因为这个原因,需要等待全部线程执行结束,我又特别喜欢用CompletableFuture,所以CompletableFuture.allOf是最方便的方案。而且追求开始时间一致也没啥必要,开异步的时间可以忽略不计,所以勉强也算是差不多时间开始。

因为普通的CompletableFuture如果没有特别封装的话,需要对异常进行收集处理,也就是exceptionally和handle,否则异常会被吞掉,这一点尤其需要注意。关于封装,有京东的AsyncToolCompletableFuture原理与实践-美团技术博客都有提到,可以去学习封装一下。

为什么自建线程池?

CompletableFuture默认的线程池ForkJoinPool.commonPool()偏向于计算密集型任务处理,核心线程数是逻辑CPU数少1,而且是共享线程池,这肯定不能用了。想更细致的了解CompletableFuture的优化案例和场景,可以看性能优化-如何爽玩多线程来开发

为什么设置random.nextInt(bomCodeList.size()/80?

首先来看一下如果是random.nextInt(bomCodeList.size())的情况下,原始请求和合并优化后的时间差异,是不是很意外,请求合并优化后反而比不过常规的并发请求。我一开始都懵了,因为和我的想象完全相反,于是我就加了些打印时间的代码观察。这里提一嘴啊,接口耗时统计也就是性能分析,可以用XRebel、Arthas、SkyWalking、PinPoint之类的工具统计,我这里因为是内网,这些工具不太方便就用的最原始的方法。

结合上面的代码,首先定时任务在1.5s内接收了队列中的800个循环异步创建的请求,验证了我上面提到的CompletableFuture开异步的时间可以忽略不计。然后结合代码打印了批处理的IN查询时间,因为这个SQL很简单,就是SELECT * from 表 WHERE 条件 in (XXX),但是却执行了21秒,同时可以发现推送队列和队列获取的总时间不超过2s,这个影响不大。

参数 druid.maxActive 请求总时间/秒 请求平均时间/毫秒
结果1 20 10 5294
结果2 20 16 4170
结果3 20 12 4480
结果4 20 16 5109
结果5 500 13 3222
结果6 500 9 2577
结果7 500 11 2176
结果8 500 13 3187

原始请求单条等值查询平均只用了3s,注意这里Druid的连接池最大数(spring.datasource.druid.maxActive)会影响请求时间,因为会阻塞在获取数据库连接这一步,会让平均请求时间拉长。总的请求时间有波动很正常,因为数据库里根据bom_code条件筛选的数据多少也会对SQL执行时间产生影响。

如果使用IN查询,随机不重样的bom_code大概会在600个左右,如上图所示,命中索引,还是要执行20s左右。我一开始想着要优化SQL,但是转念一想,单条件IN查询还优化什么,真实情况下本来就是存在冷热数据。那么我考虑热数据优化,减少条件多样性不就完了,于是设置random.nextInt(bomCodeList.size()/80,结果如下

参数 定时时间/毫秒 druid.maxActive SQL执行时间/毫秒 请求总时间/毫秒
结果1 1500 20 1617 2400
结果2 1500 20 1804 2178
结果3 1500 500 1678 1807
结果4 1500 500 1756 3128
结果5 1000 500 1777 2257
结果6 500 500 2225+1869 4769

这里对会造成的影响条件做了对照试验,首先可以确定的是,由于请求合并连接数并不造成影响。请求合并后大大降低了连接数,这是除了性能优化之外一个不可忽视的优化点。还可以看到定时任务设置过短没有意义,反而会因为队列被切割两份,查询两次从而增加总时长。当然定时任务设置过长也会有影响,过长会导致无意义的等待,这也是结果1234总时长波动的原因。

为什么使用LinkedBlockingQueue以及调用take()?

LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺,性能更好。

关于take,网上我看有写用poll定时拿的,咋说呢,我能理解这种选择是因为担心太久无法获取数据而导致请求阻塞出现异常。但是常规来说,最好能返回一个正确的值,那么自带的take方法,会在队列为空时阻塞等待非空时被唤醒。

注意这里take会有问题,如果批处理报错,无法往队列中塞入数据,那么将会阻塞至请求超时异常,这也是无法接受的。因此这里有两个手段处理,一是take设置定时器,超时返回null,二是批处理报错,往请求的队列中塞入null值,第二种比较好,第一种频繁构建定时器浪费资源。

为什么分为本地测试,直接通过http,feign之类的模拟更真实的请求不可以吗?

不是没试过,公司框架默认的这个HttpClientUtil里面配置有些小,主要是要调整连接池的大小,MaxTotal和DefaultMaxPerRoute,最大连接数和单路由最大并发,这俩调整下,不然也影响请求总时间。

这里我看了下Hutool的http包,关联有点多,本来想copy过来当个工具类,还是算了,不过可以学习一下。

接口压力测试

前置准备

还是使用经典的Jmeter来做压力测试,前期配置线程池参数为上图,1000并发同时启动一次,模拟千人并发,同时配置代码中队列大小以及线程池核心线程数为1000,定时还是保持1500毫秒

这里接口压力测试时出现了一个问题,合并上线卡在200,猜测是Spring Boot默认的线程池导致的问题,不太确认是哪个,但是默认的话,一般和Tomcat有关。果不其然server.tomcat.max-threads,默认值为200,为什么是200呢?这是考虑到过多线程的持有会占据更多的资源,在性能和资源消耗中取了200这个平衡值来保证中小系统的并发,同时也不会占据过多的资源。这里调整为1000。

对比测试

首先调用优化后的接口,结果如下图,顺利合并了1000条,SQL很快,这里最多还是用了2s,应该是定时任务间隔的问题。

接着对比调用了原始接口的

可以看到调用原始请求的最大时间来到了7s多,远远高于合并的2s,这里过长的时间应该是Druid连接数还是默认的500,导致部分请求阻塞在等待获取连接中。

这里不修改别的参数,改一下定时的时间,从1500毫秒到500毫秒,卡一个合并1000条请求的时机,可以看到如上结果,极大的提升。

测试总结

通过前面两种情况的对比,其实我们已经可以得出结论了,请求合并优化确实有效,而且我觉得其主要意义在于减少了连接数,对于MySQL这种本身不太支持高并发的数据库是非常友好。

写在最后

本篇落笔的灵感来源于我对于网上一篇请求合并优化的好奇,因为他的文章并没有写的很清楚,也缺乏数据测试,所以我决定自己来做这样一次网络传言验证,看是谣言还是真的有作用。我自己在之前接口优化总结的时候,其实也提到过这个优化,当时是我主观觉得有用,因为从理论上说得通,上面的实验结果也证明了这个理论的可行性。

接下来如果没有好的思路,会暂停一段时间写文章,不是因为过年嗷,过年还是要学习嘀,只是要忙别的事。最近日子过得很艰难,不过我好像一直都这样,哈哈,新年新气象,希望有个美好的结果。我自己建了个互相督促的学习群,目前就几个小伙伴,感兴趣的话,可以私信我呀!

相关推荐
欢乐少年19042 小时前
SpringBoot集成Sentry日志收集-3 (Spring Boot集成)
spring boot·后端·sentry
夏天的味道٥3 小时前
使用 Java 执行 SQL 语句和存储过程
java·开发语言·sql
冰糖码奇朵4 小时前
大数据表高效导入导出解决方案,mysql数据库LOAD DATA命令和INTO OUTFILE命令详解
java·数据库·sql·mysql
好教员好4 小时前
【Spring】整合【SpringMVC】
java·spring
浪九天5 小时前
Java直通车系列13【Spring MVC】(Spring MVC常用注解)
java·后端·spring
堕落年代6 小时前
Maven匹配机制和仓库库设置
java·maven
功德+n6 小时前
Maven 使用指南:基础 + 进阶 + 高级用法
java·开发语言·maven
uhakadotcom6 小时前
Apache CXF 中的拒绝服务漏洞 CVE-2025-23184 详解
后端·面试·github
uhakadotcom6 小时前
CVE-2025-25012:Kibana 原型污染漏洞解析与防护
后端·面试·github
uhakadotcom6 小时前
揭秘ESP32芯片的隐藏命令:潜在安全风险
后端·面试·github