天涯旅店

记一次线上 CPU 100% 问题排查

最近有用户反馈思享汇论坛图片加载速度太慢,自从因为权限控制要求把 CDN 下了,这种问题完全在预料之中,每次从应用服务器加载几 MB 的图片,能不慢吗?但是这次用户说加载图片要一两分钟,慢得有点离谱,虽然已经不负责思享汇了,还是跟同事一起排查下系统问题。

前端页面


打开首页看下,好家伙,图片请求的次数和体积都把我吓到了。

io-bug-1

按道理来说这接口一共就返回十五个帖子,每个帖子最多预览四张图片,极限情况也就六十个图片请求,再加上一些头像之类的,最多也就一百个左右吧。

io-bug-2

Network 看下图片的网络请求,发现隐藏在 +20 的图片,也发起了请求。打开 Elements 选中一看,前端处理有问题,虽然是个图片预览,但是实际上 +20 内所有图片的元素全都加载到 DOM 了,只是没有显示出来,没有 Code Review 的代码有点任性。

io-bug-3

另外这里还有个历史遗留问题,这四张图片看上去是 120×120 的缩略图,实际和我这网站一样,首页的缩略图加载的全是原图。缩略图需求还没做项目的功能开发就暂停了,一直搁置着这个事情。这两项一叠加,等于每个帖子都加载了数量不等的原图,共计一百多张。

前端图片全部加载这是个 BUG,也相对好改,准备让前端修复;缩略图功能因为改动较大,数据结构和前后端都有调整内容,而后台唯一的同事准备离职,这项目也是处于维护状态,就先不做了。

查看图片加载情况,发现下了 CDN 以后连客户端缓存都没有做,导致每次刷新页面,图片全都要到服务端重新 IO 一次,这个压力可想而知。

https://cdn.sekiro.top/io-bug-7.png

服务器状态


页面看完还要看下服务有问题没有,毕竟这个所有图片都要用 Java IO 的实现方式确实让人对性能心里没底。

今年系统换到容器化部署了,进到 Rancher 控制台发现有 4 个 pod,看了下日志,刷得飞快,主要就是文件读取的请求。选取其中一个打开命令行,top命令看一下,瞬时 120 的 CPU 使用率,最低也是 90+。

io-bug-4

看了下逻辑 CPU 有 16 个,应该是共享宿主机的 CPU,由于不清楚 CPU 限制了多少,不一定是满载情况。转到 Grafana 控制台,看到 CPU 限制就是 1 个,确实已经达到上限了,分配的参数可见官方文档

io-bug-5

回到容器,用 top -Hp 201 找线程,看到有几个线程 CPU 使用率居高不下:

io-bug-6

但是比较奇怪的,读取文件是 IO 密集型操作,为啥 CPU 使用率这么高了,内存使用并没有突然上涨。

用 jstack 看下使用率最高的几个线程号,堆栈信息全都是一样的。

"http-nio-8080-exec-91" #186 daemon prio=5 os_prio=0 tid=0x00007fbbac053000 nid=0x191 runnable [0x00007fbb45c89000]
   java.lang.Thread.State: RUNNABLE
        at java.net.SocketInputStream.socketRead0(Native Method)
        at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
        at java.net.SocketInputStream.read(SocketInputStream.java:171)
        at java.net.SocketInputStream.read(SocketInputStream.java:141)
        at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:137)
        at org.apache.http.impl.io.SessionInputBufferImpl.fillBuffer(SessionInputBufferImpl.java:153)
        at org.apache.http.impl.io.SessionInputBufferImpl.readLine(SessionInputBufferImpl.java:280)
        at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:138)
        at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:56)
        at org.apache.http.impl.io.AbstractMessageParser.parse(AbstractMessageParser.java:259)
        at org.apache.http.impl.DefaultBHttpClientConnection.receiveResponseHeader(DefaultBHttpClientConnection.java:163)
        at org.apache.http.impl.conn.CPoolProxy.receiveResponseHeader(CPoolProxy.java:157)
        at org.apache.http.protocol.HttpRequestExecutor.doReceiveResponse(HttpRequestExecutor.java:273)
        at com.amazonaws.http.protocol.SdkHttpRequestExecutor.doReceiveResponse(SdkHttpRequestExecutor.java:82)
        at org.apache.http.protocol.HttpRequestExecutor.execute(HttpRequestExecutor.java:125)
        at org.apache.http.impl.execchain.MainClientExec.execute(MainClientExec.java:272)
        at org.apache.http.impl.execchain.ProtocolExec.execute(ProtocolExec.java:186)
        at org.apache.http.impl.client.InternalHttpClient.doExecute(InternalHttpClient.java:185)
        at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:83)
        at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:56)
        at com.amazonaws.http.apache.client.impl.SdkHttpClient.execute(SdkHttpClient.java:72)
        at com.amazonaws.http.AmazonHttpClient$RequestExecutor.executeOneRequest(AmazonHttpClient.java:1297)
        at com.amazonaws.http.AmazonHttpClient$RequestExecutor.executeHelper(AmazonHttpClient.java:1113)
        at com.amazonaws.http.AmazonHttpClient$RequestExecutor.doExecute(AmazonHttpClient.java:770)
        at com.amazonaws.http.AmazonHttpClient$RequestExecutor.executeWithTimer(AmazonHttpClient.java:744)
        at com.amazonaws.http.AmazonHttpClient$RequestExecutor.execute(AmazonHttpClient.java:726)
        at com.amazonaws.http.AmazonHttpClient$RequestExecutor.access$500(AmazonHttpClient.java:686)
        at com.amazonaws.http.AmazonHttpClient$RequestExecutionBuilderImpl.execute(AmazonHttpClient.java:668)
        at com.amazonaws.http.AmazonHttpClient.execute(AmazonHttpClient.java:532)
        at com.amazonaws.http.AmazonHttpClient.execute(AmazonHttpClient.java:512)
        at com.amazonaws.services.s3.AmazonS3Client.invoke(AmazonS3Client.java:4926)
        at com.amazonaws.services.s3.AmazonS3Client.invoke(AmazonS3Client.java:4872)
        at com.amazonaws.services.s3.AmazonS3Client.getObject(AmazonS3Client.java:1472)
        at com.cmskframework.modules.system.service.impl.AptFileServiceImpl.getObject(AptFileServiceImpl.java:132)
        at com.cmskframework.modules.system.service.impl.AptFileServiceImpl\$\$FastClassBySpringCGLIB$$69fbc6f0.invoke(<generated>)
        at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218)
        at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:684)
        at com.cmskframework.modules.system.service.impl.AptFileServiceImpl\$\$EnhancerBySpringCGLIB$$dab8ff39.getObject(<generated>)
        at com.cmskframework.modules.system.controller.FileController.getFiles(FileController.java:142)
       ...

堆栈中的 FileController.getFiles,就是系统从对象存储读取文件的入口,看下相关代码吧。

后台代码


找到文件读取的代码:

    /**
     * 基于objectKey获取object
     */
    @Override
    public S3Object getObject(String key) throws IOException {
        S3Object s3Object = s3Client.getObject(new GetObjectRequest(bucketName, key));
        return s3Object;
    }

代码的问题就出在 getObject 方法,但是这个方法是 AWS 的 SDK 提供的,是不是传输的文件有问题,导致了读取 BUG?

在 tomcat 日志找到导致 "http-nio-8080-exec-91" 出问题的的文件 key,访问正常返回了图片,不是文件问题。

找到 s3Client 的创建方法,发现初始化的时候设置了超时时间,而且 ClientConfiguration 的构造方法也设置了 socketTimeoutconnectionTimeout

但是这段代码也还是有个问题,以我们这个请求量,maxConnections 设置成 10 确实太保守了,默认的 50 都不太够用。

    /**
     * 初始化Amazon S3 Client
     */
    @PostConstruct
    public void initClint() {
        endPoint = environment.getProperty("cmsk.endPoint");
        accessKey = environment.getProperty("cmsk.accessKey");
        secretKey = environment.getProperty("cmsk.secretKey");
        bucketName = environment.getProperty("cmsk.bucketName");
        AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
        ClientConfiguration clientConfig = new ClientConfiguration();
        clientConfig.setMaxErrorRetry(3);
        clientConfig.setConnectionTimeout(20 * 1000);
        clientConfig.setMaxConnections(10);
        clientConfig.setSignerOverride("S3SignerType");
        clientConfig.setProtocol(Protocol.HTTP);
        s3Client = new AmazonS3Client(awsCredentials, clientConfig);
        s3Client.setEndpoint(endPoint);
        s3Client.setS3ClientOptions(S3ClientOptions.builder().setPathStyleAccess(true).build());
        log.info("s3Client初始化完成-----> endPoint:{}", endPoint);
    }

这么看还是 AWS 源代码的问题,本质上是用 Apache HttpClient 发起了一个 HTTP 请求,最后在 java.net.SocketInputStream.socketRead0 这个本地方法出了问题,没法正常读取,也没有超时。

在网上一番搜索,发现有大量相同问题的反馈,Java 官方 807548482336608143992Stackoverflow1Stackoverflow2

大多数给出的解决方案是设置超时时间,但是我们的超时时间并未起作用。第二种方案是定时清理 Httpclient 连接,这个实现比较复杂。第三种方案是使用非阻塞版本的 Apache HttpClient,这需要修改 AWS 的 SDK。

查看 SDK


找到 AWS 的官方 github,想看下有没有相似的 Issue,意外发现了这个:

A version 2.x of the SDK is generally available. It is a major rewrite of the 1.x code base, built on top of Java 8+ and adds several frequently requested features. These include support for non-blocking I/O, improved start-up performance, automatic iteration over paginated responses and the ability to plug in a different HTTP implementation at run time.

查看 V2 版本的 SDK 文档,用 Netty 实现了非阻塞 IO,要求只有 Java 1.8+,完美符合我们的需求。

修复方案


最终确定了几个需要解决的问题:

  • 前端不再加载不需要展示的图片。
  • 加大 S3 的最大连接数。
  • 文件读取 SDK 替换成非阻塞的实现方案。
  • 代码增加缓存控制头,避免每次都重新发送图片请求。

发表回复

textsms
account_circle
email

天涯旅店

记一次线上 CPU 100% 问题排查
最近有用户反馈思享汇论坛图片加载速度太慢,自从因为权限控制要求把 CDN 下了,这种问题完全在预料之中,每次从应用服务器加载几 MB 的图片,能不慢吗?但是这次用户说加载图片要一两分…
扫描二维码继续阅读
2021-10-24