前情
公司项目使用的是七牛云存储图片,然后通过后置?
参数来添加水印来裁剪、缩放、添加水印等操作,也就是数据库db中存储的url只有原始图片,目前只有问卷系统才能展示有水印的图片,新需求需要将压缩包打包的资源也要添加水印
解决方案是在打包前将所有资源追加水印后,最后再统一导出带水印的资源
但是七牛云是不支持打包的资源的同时进行裁剪、缩放、添加水印等操作,并且阅读文档和提交工单后发现没有水印相关的批量操作,相关api只有每个资源单独调用
线上代码异常
创建了一个线程池,默认容量为2,最大容量为6,内部使用了一个阻塞队列
private final ThreadPoolExecutor executorService = new ThreadPoolExecutor(
2,
6,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(),
new ThreadFactoryBuilder().setNameFormat("watermark-pool-%d").build()
);
处理相关代码,可以看到代码中为了避免线上性能负荷占用过大,通过阻塞队列的长度判断,如果长度大于200会让线程休眠5秒
通过git日志发现休眠相关代码是之后加的,因为虽然表象只有线程池的6个线程,但是七牛sdk内部使用了okhttp,okhttp也是异步的会创建大量的线程(上千个),最终导致OOM.刚开始功能上线的时候和测试环境测试的时候数据量较小所以没有暴露问题,所以不得不添加休眠临时处理
CountDownLatch latch = new CountDownLatch(answerDetails.size());
orders.forEach(order -> {
// 省略其他业务代码
List<QuestionVO.AnswerDetail> answerVOList = surveyService.convertAndProcessAnswerDetail(questionVO, answers, recentAddress);
for (QuestionVO.AnswerDetail answerDetail : answerVOList) {
if (executorService.getQueue().size() > 200) ThreadUtil.sleep(5000);
executorService.submit(() -> {
run(answerDetail.getValue(), answerDetail.getWatermark(), prefixUrl, latch);
});
}
});
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
后续问题1
通过arthas查看执行状态
[arthas@31938]$ thread --all | grep watermark
225 watermark-pool-0 main 5 TIMED_WAITING 1.82 0.003 0:0.262 false false
224 watermark_thread main 5 WAITING 0.0 0.000 0:1.155 false false
226 watermark-pool-1 main 5 TIMED_WAITING 0.0 0.000 0:0.137 false false
可以通过arthas查看到永远只有3个线程执行着,分别是主线程watermark_thread和2个线程池中的线程,因为使用了LinkedBlockingQueue无界队列队列不会满,所以不会创建新的线程
优化
通过设置阻塞队列的长度而不是直接休眠,使用队列满时执行策略,当队列满了通过调用者会执行该任务
private static final ThreadPoolExecutor executorService = new ThreadPoolExecutor(
2,
5,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadFactoryBuilder().setNameFormat("watermark-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
后续问题2
通过调试发现问题1已解决,线程池创建了更多线程,但是测试发现latch一直不放开,永远阻塞,通过arthas执行发现还是有多个线程,但是显然循环已经结束
并且再调用接口,线程池会被再次激活满负载运行,最后2个watermark_thread阻塞着
[arthas@32623]$ thread --all | grep watermark
208 watermark_thread main 5 WAITING 0.0 0.000 0:1.015 false false
209 watermark-pool-0 main 5 TIMED_WAITING 0.0 0.000 0:2.490 false false
210 watermark-pool-1 main 5 TIMED_WAITING 0.0 0.000 0:2.733 false false
211 watermark-pool-2 main 5 TIMED_WAITING 0.0 0.000 0:3.145 false false
212 watermark-pool-3 main 5 TIMED_WAITING 0.0 0.000 0:2.610 false false
213 watermark-pool-4 main 5 TIMED_WAITING 0.0 0.000 0:2.422 false false
1682 watermark_thread main 5 WAITING 0.0 0.000 0:0.003 false false
[arthas@32623]$ thread --all | grep watermark
208 watermark_thread main 5 WAITING 0.0 0.000 0:1.015 false false
210 watermark-pool-1 main 5 WAITING 0.0 0.000 0:2.733 false false
211 watermark-pool-2 main 5 WAITING 0.0 0.000 0:3.146 false false
1682 watermark_thread main 5 WAITING 0.0 0.000 0:0.003 false false
通过添加System.out.println(latch.getCount());
发现永远卡在10
问题基本已经很明确了
原因是执行convertAndProcessAnswerDetail方法,list的add方法有continue操作,导致answerDetails的size和answerVOList累计的size数量对不上
protected ArrayList<QuestionVO.AnswerDetail> convertAndProcessAnswerDetail(QuestionVO questionVO, List<Answer> detailList, String recentAddress) {
ArrayList<QuestionVO.AnswerDetail> answerVOList = Lists.newArrayList();
if (CommonUtil.isEmpty(detailList)) return answerVOList;
for (Answer detail : detailList) {
if (// ...) {
continue;
}
// 省略业务代码
answerVOList.add(answerDetail);
}
return answerVOList;
}
优化
优化执行代码,线上没有遇到这个问题,正好我测试导出时任务遇上这个问题了
private List<QuestionVO.AnswerDetail> parseAnswerDetail(List<Order> orders, List<Answer> answerDetails) {
// 省略多余代码
List<QuestionVO.AnswerDetail> list = Lists.newArrayList();
orders.forEach(order -> {
// 省略多余代码
List<QuestionVO.AnswerDetail> answerVOList = surveyService.convertAndProcessAnswerDetail(questionVO, answers, recentAddress);
list.addAll(answerVOList);
});
return list;
}
parseAnswerDetail获取真正的size,并且执行countDown添加到finally中
List<QuestionVO.AnswerDetail> list = parseAnswerDetail(orders, answerDetails);
CountDownLatch latch = new CountDownLatch(list.size());
for (QuestionVO.AnswerDetail answerDetail : list) {
executorService.submit(() -> {
try {
applyWatermark(answerDetail.getValue(),
answerDetail.getWatermark(),
prefixUrl,
orderPicZipDTO.isWaterMarkOfPicture(),
orderPicZipDTO.isWaterMarkOfVideo()
);
} finally {
latch.countDown();
}
});
}
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
后续问题3
convertAndProcessAnswerDetail方法原本是无状态的,也就是说不会有多余查询操作,提测后,测试提出其他bug.我再在本地调试时发现控制台竟然莫名其妙多出一堆查询sql,真是不能忍,让对应小伙伴把convertAndProcessAnswerDetail中的查询的代码在外部查询出来在统一传进去
我这里用不到所以就传一个空的map就行了
List<QuestionVO.AnswerDetail> answerVOList = surveyService.convertAndProcessAnswerDetail(questionVO, answers, recentAddress, Maps.newHashMap());
未解决问题
后续思考,性能问题并没有被真正解决,实际上如果需要添加水印的图片过多(如上万张),依然要10多分钟才能完成
实际上现在的项目是有多个节点,通过k8s组件负载均衡到对应的jvm中,每个jvm中都有一个executorService,但是每次请求只有一个jvm下的executorService是处于工作状态下的
代码中目前是将所有订单的所有answer图片一次性加载到内存中的,可能有数十万张图片之多,项目没有挂纯粹是因为导出时没有达到业务高峰期,并且水印处理非常耗时,大量资源常驻在内存当中,大大增加OOM的可能性
处理完的图片,没有重复利用,如果再次发起打包,需要重新处理