一、引言
近期,组内做了一次让人印象深刻的组内代码review,针对一段异步代码,组内成员互相发表意见,开展头脑风暴,印象深刻,现特将这次组内的一次良好的技术探讨分享给感兴趣朋友们。 下面大家一起看代码,为代码"挑刺",各位读者都是这次代码review的评审人。
二、场景
背景:端侧传递多个经纬度信息向后端服务获取每个经纬度周边的POI信息(如酒店、充电站、加油站等),组件需要多次查询openSearch(阿里云的一个中间件,类似ElasticSearch)获取每个经纬度周边的POI信息点,但是本身OpenSearch不支持类似于es的msearch
的方式,所以只能一次请求一个经纬度多次查询数据,简图如下:
为了提升接口响应速度,自然而然的就想去用多线程并发请求openSearch数据,以加快查询速度。
三、代码Review开始
组内的一位同学代码实现大致如下(代码做了脱敏)为了说明问题,关注重点部分,简化了查询过程代码。
java
public static void main(String[] args) {
// 模拟一堆查询条件
List<String> queryConditionList = new ArrayList<>();
queryConditionList.add("query1");
queryConditionList.add("query2");
queryConditionList.add("query3");
queryConditionList.add("query4");
// 获取查询结果
List<String> searchResult = getSearchResult(queryConditionList);
System.out.println(searchResult);
}
// 为简化逻辑 简单创建了一个固定数量的线程池
private static ExecutorService executorService = Executors.newFixedThreadPool(10);
public static List<String> getSearchResult(List<String> queryConditionList) {
CountDownLatch countDownLatch = new CountDownLatch(queryConditionList.size());
List<String> result = new ArrayList<>();
for (String query : queryConditionList) {
executorService.submit(() -> {
try {
// 模拟查询耗时
if ("query1".equals(query) || "query3".equals(query)) {
Thread.sleep(1000);
} else {
Thread.sleep(2000);
}
// 获取查询结果
String searchRes = query + "_SearchResult";
result.add(searchRes);
countDownLatch.countDown();
} catch (InterruptedException e1) {
e1.printStackTrace();
}
});
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return result;
}
现在请你对上面的代码开展review,先不考虑代码规范问题,目前上面的代码是否有功能问题?
事实上上面的代码至少有下述两个问题
1、await方法没有指定超时时间
2、getSearchResult返回result丢掉了原始查询的顺序性
先说问题1,await方法如果不指定线程超时时间,将使得线程池被打爆的几率变大,当查询请求量很大时,假如每个线程执行时间都超长,很容易就打满了线程池,接口直接瘫痪。
再说问题2,虽然我们按照queryConditionList依次提交了任务,但是有的任务会提前完成,比如query1和query3,这样的话就会被先放到result集合中,导致丢掉了顺序性。
在组员发表完意见之后,代码评审人立马进行了代码修改,改正如下:
java
public static List<String> getSearchResult(List<String> queryConditionList) {
CountDownLatch countDownLatch = new CountDownLatch(queryConditionList.size());
List<String> result = new ArrayList<>();
List<Future<String>> futureList = new ArrayList<>();
for (String query : queryConditionList) {
Future<String> future = executorService.submit(() -> {
String searchRes = null;
try {
// 模拟查询耗时
if ("query1".equals(query) || "query3".equals(query)) {
Thread.sleep(1000);
} else {
Thread.sleep(2000);
}
// 获取查询结果
searchRes = query + "_SearchResult";
countDownLatch.countDown();
} catch (InterruptedException e1) {
e1.printStackTrace();
}
return searchRes;
});
futureList.add(future);
}
try {
countDownLatch.await(3, TimeUnit.SECONDS);
for (Future<String> stringFuture : futureList) {
result.add(stringFuture.get());
}
} catch (Exception e) {
throw new RuntimeException(e);
}
return result;
}
在上面的代码中,这位同学首先改掉了超时时间的问题,然后使用futureList去收集每个异步任务的结果,然后在for循环里面开始收集异步任务的返回,以保证顺序性。
此时组内的其他组员对上面的代码又再次提出了新的问题
1、await方法虽然指定超时时间了,但是stringFuture.get()方法没有指定超时时间还是存在问题
2、countDownLatch 等待三秒后,假定此时还有任务没执行完,后面的get方法还会继续阻塞,我们需要的是查询条件能完成几个就返回几个,这种写法相当于强制等到所有查询任务执行完之后才返回
3、作为一个已经升级到jdk17+springBoot3.X 的组件,异步任务编排使用 CompletableFuture 理论上更为简便。
于是这位同学改用CompletableFuture,重写了上面的代码,重写之后的代码如下:
java
public static List<String> getSearchResult(List<String> queryConditionList) {
List<String> result = new ArrayList<>();
List<CompletableFuture<String>> collect = queryConditionList.stream().map(query -> CompletableFuture.supplyAsync(() -> {
String searchRes = null;
try {
// 模拟查询耗时
if ("query1".equals(query) || "query3".equals(query)) {
Thread.sleep(1000);
} else {
Thread.sleep(2000);
}
// 获取查询结果
searchRes = query + "_SearchResult";
} catch (InterruptedException e1) {
e1.printStackTrace();
}
return searchRes;
})).toList();
try {
for (CompletableFuture<String> future : collect) {
try {
result.add(future.get(1, TimeUnit.SECONDS));
} catch (Exception e) {
// ignore 这里忽略,是为了保证查询能继续进行
}
}
} catch (Exception e) {
throw new RuntimeException(e);
}
return result;
}
这位同学首先在get方法里面指定了超时时间,通过遍历List<CompletableFuture<String>>
保证了返回结果的相对顺序性,此外为了保证一个任务超时不会影响其他,这里没有设置在子任务异常发生之后终止程序。
对于上面的代码,组内研究发现还是存在一定的问题,主要问题如下:
1、这是一段"不公平"的代码,因为对于第一个任务必须在1s返回,但是其他任务的执行时间事实上是可以超过1s的。假定第一个任务在1s之后超时,那么第二个任务都已经执行了一秒了,所以这是个不公平的代码。
2、这段代码的超时时间是针对子任务的,事实上超时时间指定到整个任务会更加合适。
3、CompletableFuture没有使用自定义线程池,而是使用默认的ForkJoin线程池,后期会丢失traceId,没有合适的线程名称也不方便追踪问题(这个问题可以参考这篇文章juejin.cn/post/728608...
于是这位同学又继续优化了一版:
java
public static List<String> getSearchResult(List<String> queryConditionList) {
List<String> result = new ArrayList<>();
List<CompletableFuture<String>> collect = queryConditionList.stream().map(query -> CompletableFuture.supplyAsync(() -> {
String searchRes = null;
try {
// 模拟查询OpenSearch
if ("query1".equals(query) || "query3".equals(query)) {
Thread.sleep(4000);
} else {
Thread.sleep(1000);
}
searchRes = query + "_SearchResult";
} catch (Exception e1) {
e1.printStackTrace();
}
return searchRes;
}, executorService)).toList();
try {
CompletableFuture.allOf(collect.toArray(new CompletableFuture[0])).get(3, TimeUnit.SECONDS);
} catch (TimeoutException e) {
// ignore or logging 这里忽略超时异常
} catch (ExecutionException e) {
throw new RuntimeException(e);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
for (CompletableFuture<String> future : collect) {
if (future.isDone()) {
// 只获取已完成的任务的结果
result.add(future.join());
}
}
return result;
}
上面的代码中,使用了CompletableFuture.allOf方法将所有子任务当做一个整体来设置超时时间,allof方法在这里最多等待所有任务完成最多3秒,然后如果超时就会抛出TimeoutException
(有兴趣的读者可以再去了解下anyOf的方法),等待3秒之后,可能存在任务没有执行完毕,但是在后面取异步执行结果的时候只选取了已经完成的任务的异步结果,事实上for循环那里也是不会阻塞的。
此时代码评审到时间了,粗看起来代码已经没问题了,但是事实上这样的写法还有没有问题交给读者来判断,也欢迎各位评审员继续在评论区发表意见。
评审结束后,有组员提出Jdk21 中的"虚拟线程"更适合解决这样的问题,虚拟线程更为轻量且不需要去考虑去使用"池化"技术,尤其适合拿来解决这种io密集型任务,可以像下面这样去编码:
java
// 虚拟线程解决方案,只需要把原先代码的线程池替换为这种声明方式即可
var executorService = Executors.newVirtualThreadPerTaskExecutor();
List<CompletableFuture<String>> collect = queryConditionList.stream().map(query -> CompletableFuture.supplyAsync(() -> {
String searchRes = null;
try {
// 模拟查询OpenSearch
if ("query1".equals(query) || "query3".equals(query)) {
Thread.sleep(4000);
} else {
Thread.sleep(1000);
}
searchRes = query + "_SearchResult";
} catch (Exception e1) {
e1.printStackTrace();
}
return searchRes;
}, executorService)).toList();
//....省略其他代码
可惜目前的组件只升级到了jdk17,暂时还不支持虚拟线程,针对虚拟线程,有组内同学又继续提出了对于使用了虚拟线程以后ThreadLocal该如何使用?,traceId的传递该如何进行?,不良代码导致虚拟线程过多的时候对系统有无不良影响等问题,这些问题就作为了下次技术分享的新议题了。
注:虚拟线程的知识可以参考这个juejin.cn/post/728748...
四、对于代码Review一点个人感想
对于代码评审这件事,在笔者曾经待过的所谓"大厂"团队里,其实一直都被忽视,大部分团队对于代码评审存在的意义就是仅限于这是质量团队要求的一项上线红线。很多情况下就是在aone(阿里内部的一个构建平台)上点一下""评审通过""按钮应付一下检查。
笔者在刚毕业校招入职某厂的那几年,曾在一个技术中台团队作为主力开发,彼时的团队,技术氛围非常好。高职级研发每次都会积极的参与项目的代码评审活动,在一次次的代码review活动中常常感觉收获颇丰,印象最为深刻的是一次mybatis的属性注入问题,全体组员直接当场debug了一遍mybtais源码。
然而,后来作为研发带头人时,却时常感觉代码评审力不从心,主要来说有下面几个因素导致代码评审无法很好的推进:
1、业务需求太多,来不及解释了,先上线再说
2、业务复杂,参与评审的人员不懂业务背景
3、程序员都有的自尊心,被人在代码review会议上指指点点,内心不舒服
4、代码评审费时费力,感觉没有收获
目前我所在的技术团队,对于代码评审则是小需求无需评审,代码行改动超过1000行则强制评审,为了避免集体评审代价较高的问题,则是在gitlab或者aone-code平台上直接留下代码评论信息,艾特对应人员进行代码改动 。
某次听说曾经的师兄所在的微软技术团队,提及他们团队的代码review,说他们的代码review都是每周进行,而且十分严格,甚至整条业务线的技术一号位都会下场亲自debug代码,不由得心生敬佩。