线上问题解决方案:一次CPU 100%的线上事故排查。

这是又一起笔者在微服务化平台上遇到的线上应急和技术攻关案例:某一Java服务的CPU占用率飙高,偶尔发生且没有规律。

我们首先确定最近是否进行了上线,经过与业务组技术负责人沟通,确定最近没有大的上线,只有一个日志推送的新功能。这个问题发生时,平台上还没有建立应用性能管理系统,无法追踪调用链,定位问题困难,耗时较长,因此,各个业务组开始自建简单地调用链跟踪系统,通过应用层将日志推送到另一个类似运营后台的服务器上,由运营后台的服务器来收集整理日志,然后显示在运营后台的界面上,帮助定位问题。

出问题的这个业务系统最近上线的调用链跟踪系统的设计如下图所示。

由于需要推送业务逻辑中的一些关键事件到运营后台,所以如果在同步的线程中推送,则会影响核心的业务逻辑,一旦运营后台的服务有问题,则也会阻塞核心的业务逻辑,因此,这个系统设计了异步推送功能。

业务服务的发送逻辑如下图所示。

业务项目根据需要构建推送的事件,然后判断缓存是否已满,如果满了,则抛弃事件,避免由于过多的事件没有推送出去而占满内存,影响业务应用的正常工作。

实现的伪代码如下:

public class AsyncEventSender<T> {
    // 默认设置的缓存事件的最大允许数量
    private static final long MAX_BUFFER_SIZE = 10000000;
    // 使用并发的队列缓存消息
    private ConcurrentLinkedQueue<T> bufferQueue = new ConcurrentLinkedQueue<T>();

    /**
     * 发送时间消息
     * @param event
     */
    public void sendEventAsync(T event) {
        // 根据队列是否已满,判断是抛弃还是发送
        if (bufferQueue.size() < MAX_BUFFER_SIZE) {
            this.bufferQueue.add(event); 
        }
    }
}

从程序的角度来看,这段代码写的还不错,不但使用了支持高并发的数据集合类ConcurrentLinkedQueue,也判断了队列的占用情况,根据占用情况判断是抛弃还是发送。

然而,问题就出在这里,当服务器报CPU占用率100%的时候,我们通过观察jstack的输出,看到大部分线程工作在如下代码上:

bufferQueue.size()

到这里,我们会想到Concurrent系列的集合类的size方法并不是常量时间内返回的,这是因为Concurrent系列的集合类使用分桶的策略减少集合的线程竞争,在获取其整体大小时需要进行统计,而不是直接返回一个预先存储的值。

下面是ConcurrentLinkedQueue类的size方法的注释:

    /**
     * Returns the number of elements in this queue.  If this queue
     * contains more than {@code Integer.MAX_VALUE} elements, returns
     * {@code Integer.MAX_VALUE}.
     *
     * <p>Beware that, unlike in most collections, this method is
     * <em>NOT</em> a constant-time operation. Because of the
     * asynchronous nature of these queues, determining the current
     * number of elements requires an O(n) traversal.
     * Additionally, if elements are added or removed during execution
     * of this method, the returned result may be inaccurate.  Thus,
     * this method is typically not very useful in concurrent
     * applications.
     *
     * @return the number of elements in this queue
     */
    public int size() {
        int count = 0;
        for (Node<E> p = first(); p != null; p = succ(p))
            if (p.item != null)
                // Collection.size() spec says to max out
                if (++count == Integer.MAX_VALUE)
                    break;
        return count;
    }

从注释的解释中可以看到,Concurrent系列的集合类的size方法和其他集合类的size方法不一样,获取的时间复杂度是o(n)。

到现在为止,我们可以确定引发CPU 100%的原因是size方法导致的,但是为什么这个问题是偶然发生的,而不是必然发生的呢?

继续分析缓存事件的处理机即推送服务,推送服务从缓存中提取事件并发送给运营后台,由于运营后台的机器较少,在业务量稍微有些峰值的时候,运营后台的机器负载就会攀升,导致遇到瓶颈。也存在另外一个场景,由于运营后台的部署是单机房的,如果跨机房的网络抖动,则导致推送服务延时,所以推送进程会被阻塞,导致缓存的数据积压过多。缓存的数据量越大,size计算的时间越长,最后导致CPU占用率100%。

最后,我们对这个问题提出了下面的改进方案。

  • 使用环形队列来解决问题,生产者通过环形队列的写入指针存储数据,消费者通过队列的读取指针来读取数据,如果生产者的进度快于消费者,则生产者可抛出问题。
  • 可参考或者使用开源的 Disruptor Ring Buffer来实现。
  • 可以采用其他有界队列来实现。
  • 可以采用专业的日志收集器来实现,例如Fluentd、Flume、Logstash等。

猜你喜欢

转载自blog.csdn.net/en_joker/article/details/89451010