高并发系统谨防被一行日志击垮

一、同步打印日志中的坑

1.1 高并发场景下 logback 造成线程泄露

调用 logback 去打印日志的时候是会加锁的,加锁的位置在:

// ch.qos.logback.core.OutputStreamAppender#writeBytes
private void writeBytes(byte[] byteArray) throws IOException {
    if (byteArray != null && byteArray.length != 0) {
        this.lock.lock();
        try {
            this.outputStream.write(byteArray);
            if (this.immediateFlush) {
                this.outputStream.flush();
            }
        } finally {
            this.lock.unlock();
        }

    }
}

这就意味着同一个appender的日志写是串行的,在高并发的场景下因为有锁的争用现象,所以看似很简单的一行日志,耗费的时间却不少。接下来我们在本地简单的模拟一下高并发的场景,并记录打一行日志的耗时是多少

public static void main(String[] args) {
    ExecutorService threadPool = new ThreadPoolExecutor(500, 750, 20, TimeUnit.MINUTES, new ArrayBlockingQueue<>(1),
                                                        new ThreadFactoryBuilder().setNameFormat("test-log-thread").build(), new ThreadPoolExecutor.CallerRunsPolicy());
    for (int i = 0; i < 750; i++) {
        LoggerExecutor commonExecutor = new LoggerExecutor();
        threadPool.submit(commonExecutor);
    }
}
static class LoggerExecutor implements Runnable {
    @SneakyThrows
    @Override
    public void run() {
        while (true) {
            long start = System.currentTimeMillis();
            logger.info("log info message at {}", System.currentTimeMillis());
            long end = System.currentTimeMillis();
            long time = end - start;
            System.out.println(time);
        }
    }
}

需要说明的是,现实中高并发的请求应当用一个线程池向另一个线程池反复提交任务来模拟,这里我们简化了这个过程。

上图是我记录下来的logger.info的耗时曲线,从这张统计图上可以看到,当并发量上来之后锁争用的情况加剧,仅仅是打印一行info日志就可能花费 20-40ms(作为对比,在我的机器上单线程打印一行日志通常是 1-2ms),而且图上有比较明显的毛刺,打印日志的耗时超过了 100ms,毛刺的原因在于OutputStream在缓存区满了后要执行刷盘动作,但是在真正大流量应用中这种毛刺是致命的,可能导致RPC框架的线程池被吃光,正常业务服务的成功率下降。所以在高并发大流量的场景下info日志的打印一定要谨慎。

1.2 大量异常引发性能毛刺

在上一小节中我们提到,高并发场景下info日志要谨慎打印,一般我们只记录系统异常的日志。我们把刚才的代码片段做一个小的修改,改为调用logger.error打印日志,然后再统计下这行代码的耗时

// 驱动代码同1.1节,所以这里省略
static class LoggerExecutor implements Runnable {
    @SneakyThrows
    @Override
    public void run() {
        while (true) {
            long start = System.currentTimeMillis();
            //logger.info("log info message at {}", System.currentTimeMillis());
            logger.error("log info message occurs error: {}", new RuntimeException());
            long end = System.currentTimeMillis();
            long time = end - start;
            System.out.println(time);
        }
    }
}

与 1.1 节相比,一个很明显的变化就是error日志的执行平均耗时到了 40-50ms,要比 1.1 节中的info日志慢不少,这是为什么呢?原因就在于调用logger.error(String, Throwable)时为了打印堆栈加载了每个调用节点的class,代码在:

//ch.qos.logback.classic.spi.PackagingDataCalculator#computeBySTEP
private ClassPackagingData computeBySTEP(StackTraceElementProxy step, ClassLoader lastExactClassLoader) {
    String className = step.ste.getClassName();
    ClassPackagingData cpd = cache.get(className);
    if (cpd != null) {
        return cpd;
    }
    // 注意这行代码
    Class type = bestEffortLoadClass(lastExactClassLoader, className);
    String version = getImplementationVersion(type);
    String codeLocation = getCodeLocation(type);
    cpd = new ClassPackagingData(codeLocation, version, false);
    cache.put(className, cpd);
    return cpd;
}

在bestEffortLoadClass中则会尝试类加载:

private Class bestEffortLoadClass(ClassLoader lastGuaranteedClassLoader, String className) {
    Class result = loadClass(lastGuaranteedClassLoader, className);
    if (result != null) {
        return result;
    }
    ClassLoader tccl = Thread.currentThread().getContextClassLoader();
    if (tccl != lastGuaranteedClassLoader) {
        result = loadClass(tccl, className);
    }
    if (result != null) {
        return result;
    }

    try {
        return Class.forName(className);
    } catch (ClassNotFoundException e1) {
        return null;
    } catch (NoClassDefFoundError e1) {
        return null;
    } catch (Exception e) {
        e.printStackTrace(); // this is unexpected
        return null;
    }
}
// ch.qos.logback.classic.spi.PackagingDataCalculator#loadClass
private Class loadClass(ClassLoader cl, String className) {
    if (cl == null) {
        return null;
    }
    try {
        return cl.loadClass(className);
    } catch (ClassNotFoundException e1) {
        return null;
    } catch (NoClassDefFoundError e1) {
        return null;
    } catch (Exception e) {
        e.printStackTrace(); // this is unexpected
        return null;
    }
}

java.lang.ClassLoader#loadClass(java.lang.String)这个方法就是我们熟知的加载类的接口,其实会在className维度加锁。到这里为什么logger.error比logger.info慢多大家应该就猜到了,logger.error会打印出异常的堆栈,在高并发的场景下如果某个接口频繁抛异常,那在打印error日志的时候要各个线程要先去加载异常堆栈上各个类信息,产生锁竞争,然后才会在appender维度排队。一个常见的现实场景是下游服务限流或者直接宕机了,容易因为logback引发系统的毛刺。

二、异步日志配置不当造成线程泄露

其实在高并发的场景下,一般都会将日志配置为异步打印,其原理大概如下图所示:

AsyncAppender将Logging Event扔到一个队列里,然后会有一个单独的线程从队列中消费LoggingEvent,并派发到具体要干活的Appender中,因为避免了直接直接调用writeBytes,所以性能应该有非常大的提升。我们对logback的配置稍作改动,采取异步的方式打印日志:

<appender name="FILE" class="ch.qos.logback.core.FileAppender">
  <file>/Users/idealism/Desktop/log.txt</file>
  <encoder>
    <pattern>%logger{35} - %msg%n</pattern>
  </encoder>
</appender>
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
  <appender-ref ref="FILE" />
</appender>
<root level="DEBUG">
  <appender-ref ref="ASYNC"/>   
</root>
</configuration>

并采样了每次打印日志的耗时如下图所示:

令人震惊的一幕出现了,在并发量比较大的场景下,异步打印日志的的性能与同步相比竟然差了 10 倍!这是为什么呢?上文说到,logback异步日志的实现原理是一个生产者-消费者模型,问题在于在大流量的场景下,分配线程这个单线程的消费能力是赶不上生产能力的,最后导致所有的线程在日志打印的阻塞队列上排队。此时通过arthas也可以看到线程的排队情况,如果这是线上的业务系统,业务线程早被日志的阻塞队列吃光了,会引起业务响应异常

[arthas@9341]$ thread
Threads Total: 780, NEW: 0, RUNNABLE: 13, BLOCKED: 745, WAITING: 4, TIMED_WAITIN
G: 3, TERMINATED: 0, Internal threads: 15
ID NAME                GROUP     PRIORI STATE  %CPU  DELTA_ TIME   INTER DAEMON
10 AsyncAppender-Worke main      5      RUNNAB 43.01 0.090  0:13.7 false true
77 arthas-command-exec system    5      RUNNAB 4.9   0.010  0:0.02 false true
39 test-log-thread     main      5      BLOCKE 0.75  0.001  0:0.14 false false
71 test-log-thread     main      5      BLOCKE 0.71  0.001  0:0.15 false false
37 test-log-thread     main      5      BLOCKE 0.71  0.001  0:0.14 false false
74 test-log-thread     main      5      BLOCKE 0.69  0.001  0:0.15 false false
67 test-log-thread     main      5      BLOCKE 0.69  0.001  0:0.14 false false
-1 C2 CompilerThread2  -         -1     -      0.68  0.001  0:1.68 false true
69 test-log-thread     main      5      BLOCKE 0.66  0.001  0:0.15 false false
55 test-log-thread     main      5      BLOCKE 0.66  0.001  0:0.14 false false
38 test-log-thread     main      5      BLOCKE 0.65  0.001  0:0.15 false false
36 test-log-thread     main      5      BLOCKE 0.65  0.001  0:0.14 false false
28 test-log-thread     main      5      BLOCKE 0.65  0.001  0:0.15 false false
50 test-log-thread     main      5      BLOCKE 0.65  0.001  0:0.14 false false
36 test-log-thread     main      5      BLOCKE 0.64  0.001  0:0.14 false false

三、其他问题

除了上面提到的几个在高并发大流量场景下特有的坑外,还有一些其他的坑这里列一下,因为网上的博客比较多了,这里不再赘述

  1. https://www.elietio.xyz/posts/f6b1711c.html《低版本 bug 导致 totalSizeCap 参数不生效》
  2. logback版本过低导致SizeAndTimeBasedRollingPolicy不生效的问题

四、最佳实践

应当遵循以下几个原则:

【最佳实践一】日志工具对象的logger应当声明为private static final

  1. 声明为private是为了安全性考虑,防止logger被其他类非法使用
  2. 声明为static和final则是因为在类的声明周期内无需变更logger,并且占用内存也更小

【最佳实践二】日志字符串通过"+"拼接即占用了额外的内存,也不直观,应当使用占位符

【最佳实践三】日志内容和日志级别相符合

  1. debug和trace一般是开发者调试程序使用,线上应关闭这类日志
  2. info日志应当记录重要且无风险的信息,如上下文初始化,定时任务开始执行或者远程连接建立等场合
  3. warn日志应当记录可能有风险,但是不会影响系统继续运行的错误,如系统参数配置错误,用户请求参数不正确等。或者是在一些耗时异常的场景,如一次请求超时,一次sql执行超过 2 秒等
  4. error日志用于程序出错打印堆栈,不应该输出程序问题之外的其他信息

【最佳实践四】高并发系统应少打或不打info日志,且应当配置为异步日志,当阻塞队列满时采取丢日志的策略,以保证业务系统正常运行

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,290评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,399评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,021评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,034评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,412评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,651评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,902评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,605评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,339评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,586评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,076评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,400评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,060评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,851评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,685评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,595评论 2 270

推荐阅读更多精彩内容