多线程异常处理:挖掘页面空窗背后的原因

本文涉及的产品
性能测试 PTS,5000VUM额度
简介: 作为一名应用开发,大家是否有遇到以下现象,为什么一套非常优秀的兜底机制还是会出现页面空窗现象?本文将会通过实例和大家分享,作者在线程池使用过程中遇到的问题:异常处理,以及下线程池的参数设置经验。

什么现象

先解释下什么是空窗,就是数据缺失导致某块或整页出现空白的现象。
事情有点早了,刚接聚划算,还没来得及看逻辑,就被告知,压测时页面出现了空窗,像这样:

image.png

原因是什么

其实就是对应的接口超时或者数据处理异常,导致该块儿数据没有返回。
我们的代码是运行在阿拉丁容器里的,阿拉丁本身是有兜底机制的,并且有两层:

  1. 如果接口发生异常,阿拉丁会从tair里取缓存的数据返回给前端做兜底
  2. 如果阿拉丁也没有兜住,前端接收到错误的code,会自动从cdn取对应接口的数据做兜底

这套机制还是非常优秀的,但为什么还是出现了空窗了。
翻看代码发现,是我们把对应的异常给吃掉了,没有抛给阿拉丁容器,代码是这样的:

try {
    executorService.invokeAll(callableHashSet);
} catch (Exception e) {
    throw new RuntimeException(e);
}

初看,是不是以为把try catch拿掉就没问题了,然而不是,我们看看java.util.concurrent.ExecutorService#invokeAll的实现,先看我们最常用的ThreadPoolExecutor,它的invokeAll方法在父类AbstractExecutorService里实现:

image.png

这里变量ignore的命名非常漂亮,想都不用想,它被忽略了,为什么要看这个ExecutionException,是因为线程里发生的异常都被包装成了ExecutionException,我们跟着AbstractExecutorService##invokeAll看下,上图有个newTaskFor,看下实现:

protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
    return new FutureTask<T>(callable);
}

看看FutureTask#get方法:

public V get() throws InterruptedException, ExecutionException {
    int s = state;
    if (s <= COMPLETING)
        s = awaitDone(false, 0L);
    return report(s);
}

最终在report方法里实现:

private V report(int s) throws ExecutionException {
    Object x = outcome;
    if (s == NORMAL)
        return (V)x;
    if (s >= CANCELLED)
        throw new CancellationException();
    throw new ExecutionException((Throwable)x);
}

可以看到,如果线程里抛出了异常,都被包装成了ExecutionException,而ThreadPoolExecutor#invokeAll方法里忽略了这个异常,导致我们根本捕捉不到异常。

上边说的是ThreadPoolExecutor,我们再看另一个常用的ExecutorService的实现类ForkJoinPool:
image.png

看到了吧,方法命名就告诉你了,我不会抛异常给你,进去看看ForkJoinTask#quietlyJoin:

image.png

注释说得很清楚,不抛异常。

怎么解决

首先,根据上边的分析,要慎用invokeAll,解决也很简单,可以有以下几种方式:

  1. 能让主线程感知到异常,并向外抛,就可以触发阿拉丁的兜底
  2. 模块内部做数据缓存,捕捉到异常以后取缓存数据做兜底

▐ 第一回合

因为对整体的逻辑没摸透,不敢直接替换掉invokeAll,会影响整个聚划算首页,时间又比较急,就先缩小改动范围,选取方法二:

  1. 在对应模块内容做数据缓存,为了兼顾时效性(聚划算商品有上团和下团时间,所以时效性很强),做了1分钟的缓存和5分钟的缓存,如果发生异常,按优先级取缓存,优先1分钟的缓存。
  2. 为了减轻写压力,只针对一定比例的请求写缓存

效果
上线之后没问题,然而第二次全链路压测,半夜又收到消息说空窗了。
第一回合失败。

▐ 第二回合
经过分析,可能有多个原因:

  1. 应该是压测状态下,下游服务持续压力大,导致缓存数据过期,
  2. 写入缓存的数据也没有做好校验,可能写入不合法的数据

继续做调整:

  1. 严格校验写入缓存的数据,保持缓存数据的合法性
  2. 既然是兜底数据,可以直接缓存在内存,这样就不用关心写比例,直接100%缓存合法数据,并且不设置失效时间,这样保证兜底时总能取到最新的合法数据
  3. 把该组件的Callable从invokeAll里拎出来,增加预案,可以触发整页兜底,作为最后的保命手段,如下:

image.png

效果

后续压测和日常没再出现过空窗,就这个模块来说,应该没问题了。

这样就好了吗
其实不应该结束,上述方案都是在时间紧张的情况下做的临时补救措施,代码里到处是特判逻辑,我们应该有更系统的设计方案:

  1. 模块异常都外抛,触发阿拉丁的兜底,但阿拉丁的兜底是接口级别的,我们一个接口里边通常包含多个模块,如果因为次要模块导致用户看到的主要模块也是兜底的数据,用户体验不好
  2. 针对每一个模块做独立的兜底,但像上述方法一样,一个模块一个模块来改,太累,也容易遗漏。我们应该有一个框架性设计,让以后的开发只需要关心业务逻辑,而不用关心这些非功能性问题,这点我准备在EasyWidget里边来实现,基础设施已经具备,只需要在模板方法里加几行就能实现。

总结一下

这里边遇到的主要问题是没有正确处理线程池的异常和兜底设计不完善导致,兜底的设计上边提到了思路,我们再看下处理子线程内部异常的常用方式:

▐ 通过原子变量


AtomicBoolean exception = new AtomicBoolean(false);

Callable<Void> qwbkt = () -> {
    try {
        qwbktSections.add(qwbktManager.query(context, null));
    } catch (Throwable t) {
        context.getLogger().error("qwbkt exception:", t);
        exception.set(true);
    }
    return null;
};

//...

if (exception.get()) {
    throw new RuntimeException("queryError");
}

▐ 以code形式返回

Callable<String> task = new Callable<String>() {
    @Override
    public String call() throws Exception {
        Result<String> result = new Result<>();
        try {
            //..
        } catch (Exception e) {
            result.setCode("500");
        }
        return result;
    }
};

▐ 老老实实future#get

try {
    String s = future.get();
} catch (InterruptedException e) {
    //..
} catch (ExecutionException e) {
    //todo: 这里处理线程内部异常
}

再说说线程池的其它问题

▐ 线程池设置不合理

看到很多应用里的线程池参数不合理,尤其是很多新同学,分不清前台应用和后台任务需要的线程数和拒绝策略怎么设置。
很多同学从教程里边或者某些框架源码里边看到线程池的线程数尽量跟机器核数保持一致,就一直保持这个设置。
还有看到前台应用了设置了少量的线程,队列长度是10000。这种情况在遇到突发流量的情况下很容易把自己拖垮,之所以一直没触发问题,一种原因可能是没有遇到过大流量,另一种可能是被限流保护了,一旦限流没有设置好,就可能遇到致命问题。

这里简单说下自己的经验:

  • 搞清楚核心线程数、最大线程数、任务队列的工作原理,核心线程用完了是先放任务队列,队列满了才会继续增加线程数至最大线程数
  • 前台应用队列长度一定不能太大,根据线程数、接口RT、客户端所能接受的RT来计算队列长度
  • 分清我们的应用是CPU密集型还是IO密集型,大多数情况我们的业务应用都是IO密集型的,这种情况下不必拘泥于线程数跟核数保持一致
  • 用Runtime.getRuntime().availableProcessors()设置线程数的时候,你以为取到的是虚拟机的线程数,但很可能取到的是物理机的线程数,要注意这个坑
  • 前台应用的线程数必须通过压测不断调整,才能获得合理的线程数,但一旦依赖接口的RT等情况发生变化,线程数就可能不再合理,所以合理的线程数很难保持
  • 后台应用如果不关心响应的及时性,可以设置较大的队列,但要关注机器内存,也要主要机器重启时的任务丢失问题

▐ 线程池的关闭

任务不能丢失的时候一定要在jvm关闭的时候通过钩子关闭线程池。

Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
    @Override
    public void run()
{
        threadPool.shutdown();
    }
}));

上述方法只在jvm正常关闭的时候有效,如果强杀或断电等情况还是有问题,就要做更强有力的保障,如先发消息队列,再处理。

相关实践学习
通过性能测试PTS对云服务器ECS进行规格选择与性能压测
本文为您介绍如何利用性能测试PTS对云服务器ECS进行规格选择与性能压测。
相关文章
|
6月前
|
数据处理
Swing通过后台线程实现页面更新
Swing通过后台线程实现页面更新
83 2
非UI线程下页面处理:view的postInvalidate和post对消息处理的差异化
我们知道view有一系列post方法,用于在非UI线程中发出一些页面处理。view还有另外一个postInvalidate方法,同样在非UI线程中发起重绘。 同样是在非UI线程向UI线程发出消息,但是这里面有很大的区别。
214 0
|
Android开发 Java 安全
Xamarin.Android 使用线程无法更改页面文本问题
前言:   刚接触Xamarin.Android不到一个月时间,却被他折磨的不要不要的,随着开发会出现莫名其妙的问题,网上类似Xamarin.Android的文档也不多,于是本片文章是按照Java开发Android的思路写过来的,于是记录下来,希望大家碰到这个问题少走些弯路。
1669 0
|
Web App开发 XML Java
Web---演示Servlet的相关类、下载技术、线程问题、自定义404页面
Servlet的其他相关类: ServletConfig – 代表Servlet的初始化配置参数。 ServletContext – 代表整个Web项目。 ServletRequest – 代表用户的请求。
1014 0
|
1月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
48 1
C++ 多线程之初识多线程
|
30天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
20 3
|
30天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
19 2
|
30天前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
30 2
|
30天前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
34 1
|
30天前
|
安全 Java 开发者
Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用
本文深入解析了Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用。通过示例代码展示了如何正确使用这些方法,并分享了最佳实践,帮助开发者避免常见陷阱,提高多线程程序的稳定性和效率。
38 1
下一篇
无影云桌面