记一次线上Java程序故障惊心动魄两小时

开发 前端
当我起来收拾好一切准备出发的时候,我瞄了一眼手机,发现手机的邮箱里有一份报警邮件,报警邮件显示线上最近10分钟流量有异常,而且是多个渠道。有突然有一种不祥的预感:线上kafka出问题了。

周日早上醒来,明媚的阳光从卧室的窗户直射进来,久违的好天气。穿好衣服我开始筹划今天去哪里转转。一周忙碌的工作几乎没有时间陪家人,今天该好好陪陪家人了。

当我起来收拾好一切准备出发的时候,我瞄了一眼手机,发现手机的邮箱里有一份报警邮件,报警邮件显示线上最近10分钟流量有异常,而且是多个渠道。有突然有一种不祥的预感:线上kafka出问题了。我让媳妇和孩子下楼在车里等我,我赶紧打开了电脑,查看线上系统。果然不出意外,kafka已经积压了几千万的数据。因为我们的业务分为实时数据和离线数据,实时数据是FileBeat负责收集日志发到Kafka,然后我们这个业务系统消费Kafka统计数据,实时数据对于当前流量分析、预算控制、熔断有非常重要的作用,如果实时数据异常,其它业务系统都会受到一定的影响。

定位到报警邮件是由于kafka消息积压而导致实时数据异常触发的,我立马连上了我们消费Kafka的业务系统(data-collect)。这是一个运行时间很长了的Java服务,它的作用就是实时消费kafka数据,然后经过一定的业务逻辑处理,将最终结果更新到mongodb中。进到服务器以后,我发现这个服务已经处于假死状态,最后一条日志显示系统发生了OOM,也就是服务器内存爆了。

 

记一次线上Java程序故障惊心动魄两小时

关于data-collect这个Java服务的核心逻辑我在这里详细说明一下。这个系统的代码是很早的一位同事写的,因为早期我们的数据体量还不是很大,所以,他采用了一种简单的处理方式。先消费数据,处理完成以后放到一个Map中,然后,启动了一个每10s执行一次的定时任务,定时任务读取Map数据更新到mongo中,然后清空Map(ConcurrentMap)。这样做的优点是将消费Kafka的操作和入库操作分开了,可以防止因为入库时间太长而导致消费速度变慢,但是,这种做法有一个致命的缺点:内存不可控。如果定时任务因为Mongo操作时间太长而没有及时清空Map,Map中会积累大量的数据,最终耗尽内存,系统发生OOM。这时候如果系统自启了,也会丢失大量的数据。

其实,这个问题我很早有意识到,但是系统一直运行良好,没有出现任何问题,我们认为在现有数据体量下它是安全的。而碰巧的是,就在前一天我们升级了Mongo的配置,mongo机器进行了一个主从切换。同时,有一些大表清理和TTL索引重建的工作还在mongo后台运行。这就导致了我们操作mongo耗时的增加。进而导致了我们一直认为安全的系统出现了这个问题。

回到data-collect这个系统的设计上。可能有的同学会在这里有个疑问,为什么不直接消费出来就入库操作呢?这里我们有一个重要的处理逻辑:为了防止频繁的更新mongo,我们会将消费出来的数据在内存中进行一个合并处理,你可以简单的理解为一个Map,如果key存在,我们就进行++的操作。最终操作mongo是$inc的操作,不是insert和update的操作。这也是我们需要一个ConcurrentMap的原因。也就是我们大概消费了1000万条数据,但是最终我们处理完成以后只有10万条数据,很多key相同的数据我们都进行了合并处理,这样我们mongo的操作就减少了很多。

data-collect发生了OOM,我只能第一时间重启,重启以后,消费正常,系统开始有了数据。但是大概运行了几分钟以后,又发生了OOM。原因很简单:kafka积压的了大量的消息,消费很快,但是异步如mongo太耗时,所以导致数据全部挤压在了这个Map内存中。看到这里,我想只能动手改造代码了。改造的最终要达到的结果是:系统在不发生OOM的前提下,消费积压在kafka中的数据,完成mongo操作。

改造的思路很简单,就是干掉定时任务。在消费kafka消息中增加一个逻辑,每当消费消息并且内存进行数据合并完成以后,我们判断Map的大小,如果Map的大小超过我们设定的限制以后,开始触发mongo操作。之前的代码mongo操作是单线程执行,为了提升mongo插入操作,我们开启20个线程并行执行,所以我们这里需要一个带阻塞队列的线程池。改造后的代码如下:

 

记一次线上Java程序故障惊心动魄两小时

这里是SpringBoot集成Kafka的消费代码。

 

记一次线上Java程序故障惊心动魄两小时

 

记一次线上Java程序故障惊心动魄两小时

这是内存处理完成以后入mongo的操作。因为我们的topic有20个分区,所以代码中的listenPartition0是多线程执行的。如果没有synchronized的同步代码块,那assembleyAdxTrafficVo方法就会多线程执行,这就会导致数据重复插入mongo,具体大家可以体会assembleyAdxTrafficVo方法的逻辑。

而插入mongo操作的用了线程池ExecutorService,注意这里我们executorService对象的定义。

 

记一次线上Java程序故障惊心动魄两小时

 

记一次线上Java程序故障惊心动魄两小时

为什么要自己定义一个阻塞队列CustomeBlockQueue?这相比很多人碰到过这个问题,如果采用默认的阻塞队列,例如:ArrayBlockingQueue,当队列长度长度超过设置的值时,ArrayBlockingQueue会拒绝新的数据进入,并且抛出异常,所以我们需要自己定义CustomeBlockQueue,并且重写他的offer方法(BlockingQueue默认采用offer方法将元素增加到队列),offer方法不会阻塞,put方法会阻塞,所以我们需要重写offer方法,并且内部采用put方法实现。关于这一点,大家可以多尝试。ArrayBlockingQueue和LinkedBlockingQueue都有很多坑等大家去踩。

按照上述代码处理完成上线以后,系统开始正常运行,kafka积压的消息也开始慢慢降低,系统趋于恢复正常,而这时已经是12点了,惊心动魄的2小时总算过去了,阿弥陀佛。

责任编辑:未丽燕 来源: 今日头条
相关推荐

2017-09-01 09:17:51

DNS缓存惨案

2018-01-07 01:39:32

2019-08-05 10:15:33

系统缓存架构

2018-12-14 10:46:55

2009-05-08 08:59:47

微软Windows 7操作系统

2022-12-17 19:49:37

GCJVM故障

2023-01-04 18:32:31

线上服务代码

2021-01-08 13:52:15

Consul微服务服务注册中心

2017-11-01 16:15:23

SQL优化权限类型案例

2009-07-28 09:18:17

2020-11-16 07:19:17

线上函数性能

2021-12-02 07:50:30

NFS故障内存

2021-05-26 11:06:06

Kubernetes网络故障集群节点

2021-11-23 21:21:07

线上排查服务

2014-12-19 16:08:18

2020-10-14 11:37:07

MAXHUB

2020-07-13 09:05:47

2021-12-06 17:21:05

异常报错故障

2010-09-11 11:12:23

2019-09-10 10:31:10

JVM排查解决
点赞
收藏

51CTO技术栈公众号