JVM第五讲:纵横数据如何应对洪峰推送

简介: JVM第五讲:纵横数据如何应对洪峰推送

1、整体功能架构图

2、问题背景

湖南网站群的公告相关数据需要通过纵横进行数据推送,从网站群的开发了解到每天大概有10W条数据需要推送,这个数据量均摊到一天来推送并不是什么问题,但是了解到这些数据可能会集中在短时间内(10分钟)推送,此时将会出现数据洪峰的情况。

Action:出现数据洪峰时怎么应对呢?
  • 应对高并发场景的三板斧就是缓存、熔断(降级)、限流,而数据推送的业务场景显然缓存和熔断并不适用,可选的就是限流了,那么常用的限流方案有哪些呢?各自的优缺点以及适用场景呢?

线程池

异步

  • 使用方式见这个系列

漏斗算法和令牌算法

3、数据推送

3.1、数据来源
  • 业务方组装数据包发送MQ
  • 纵横动态配置定时任务通过开放平台拉取数据,自行组装投递MQ
3.2、推送流程
  • 纵横网关通过消费MQ,执行职责链进行数据推送
  • 具体流程如下图所示
  • 为了避免推送效率受mq消费线程数(zmq默认20)的限制或者采用线程池但不便设置具体线程池大小(每个消息体的大小差异性极大),推送时采用apache的 HttpAsyncClient(内部基于reactor模型)来执行网络请求,通过callback回调的方式来获取推送结果。

示例代码如下:

this.getHttpAsyncClient().execute(request, new FutureCallback<HttpResponse>() {
    @Override
    public void completed(HttpResponse result) {
        //todo 请求成功后的逻辑处理
    }
    @Override
    public void failed(Exception ex) {
    }
    @Override
    public void cancelled() {
    }
});
3.3、原限流方案
  • 根据业务类型在当前推送队列中的数量进行限流。
  • 例如公告数据的业务类型为 ANNOUNCEMENT_RELEASE_LOCAL_WEB,每种业务类型的限流数设为50,当单台实例内存中该种业务类型正在推送的数据量达到50后,该业务类型的数据从第51条开始都将会被拒绝,返给MQ稍后消费的状态,被拒绝的消息MQ会间歇性消费重试,重试时间可以动态配置。
@Component
public class BizTypeLimitHandler extends AbstractHandler<MsgInput, MsgOutput> {
    @Autowired
    FlowLimitConfig flowLimitConfig;
    @Value("${over.load.limit:50}")
    private Integer defaultLimit;
    @Override
    public void task(MsgInput input, MsgOutput output) {
        //该业务类型在当前节点的流量
        Integer flowCount = BizFlowLimitUtil.get(input.getBizType());
        //该种业务类型对应的限流
        Integer overload = this.flowLimitConfig.getBizMap().getOrDefault(input.getBizType(), this.defaultLimit);
        //overload小于0代表无限制
        if (overload >= 0 && flowCount >= overload) {
            throw new OverloadException(ErrorCode.ERROR_CODE_NSQ_LIMIT, "业务类型:" + input.getBizType() + "负载过高,阈值:" + overload + ",当前负载值:" + flowCount);
        }
    }
}
3.4、存在的问题
  • 随着业务需求的增加,业务类型的种类也在不断的增加,而 所需最大资源 = 业务类型种数 * 限流数,在服务实例的资源有限的情况下,只根据业务类型的数据量来进行限流,效果会逐渐变得不理想。
3.5、数据指标及工具选择
  • Arthas 是Alibaba开源的Java诊断工具,当我们遇到以下类似问题而束手无策时,Arthas可以帮助解决:
  • 这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?
  • 我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?
  • 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
  • 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
  • 是否有一个全局视角来查看系统的运行状况?
  • 有什么办法可以监控到JVM的实时运行状态?
  • 怎么快速定位应用的热点,生成火焰图?
  • 怎样直接从JVM内查找某个类的实例?
  • paas平台提供了arthas这个工具便于我们进行线上问题的排查,首先从应用发布平台进入服务实例对应的arthas控制台。
  • arthas 提供了dashboard 命令,可以查看服务jvm的实时运行状态,如不指定刷新间隔时间,默认5s刷新一次。键入 dashboard 出现如下画面:

上半部分主要是当前服务jvm中的线程情况,可以看到各线程对cpu的使用率极低,基本处于闲置状态。

接着看下半部分Memory框中的信息,主要的数据项含义如下:

  • heap(堆大小)
  • par_eden_space(伊甸区大小)
  • par_survivor_space(S区大小)
  • cms_old_gen(老年代大小)
  • gc.parnew.count(young gc总次数)
  • gc.parnew.time(young gc总耗时)
  • gc.concurrentmarksweep.count(full gc总次数)
  • gc.concurrentmarksweep.time(full gc总耗时)
  • 各列分别代表了已使用、总大小、最大值、已使用百分比

5s后如下:

结合5s前的数据,我们主要关注以下指标:

  • 线程情况:线程cpu使用率并没有明显变化
  • heap(堆大小):堆使用大小增加3M
  • par_eden_space(伊甸区大小):年轻代中的伊甸区只增加3M,按照伊甸区426M的大小,大约需要710秒(约12分钟)才会触发一次young gc
  • par_survivor_space(S区大小):无变化
  • cms_old_gen(老年代大小):无变化
  • gc.parnew.count(young gc总次数):无变化
  • gc.parnew.time(young gc总耗时):无变化
  • gc.concurrentmarksweep.count(full gc总次数):无变化
  • gc.concurrentmarksweep.time(full gc总耗时):无变化

这是服务无流量基本处于闲置状态时一个情况,接下来模拟积压数据进行推送时的场景,数据由测试同学提前通过自动化脚本投递到MQ当中。

积压5000条数据

  • 使用arthas命令 dashboard -i 1000,按照1s的间隔输出:
    1S后:

对比两次数据发现:

  • 线程情况:zmq默认的20个消费者线程都处于活跃状态占用cpu资源
  • heap(堆大小):已使用大小从293M上升至478M
  • par_eden_space(伊甸区大小):发生young gc之前伊甸区使用23m,伊甸区总大小为426m,发生young gc之后伊甸区使用了211m,这说明在1s之内至少增加了(426-23)+211 = 614 m大小的对象
  • par_survivor_space(S区大小):young gc之前s区大小为31m,young gc之后s区大小为29m
  • cms_old_gen(老年代大小):无变化
  • gc.parnew.count(young gc总次数):发生了1次young gc
  • gc.parnew.time(young gc总耗时):时间增加了(9018-8992)= 26 毫秒,为一次young gc的时长
  • gc.concurrentmarksweep.count(full gc总次数):无变化
  • gc.concurrentmarksweep.time(full gc总耗时):无变化

1s一次young gc,可能是时间间隔太短老年代的数据没有变化,我们按照5s的间隔,再观察一下老年代:

5s后:

对比两次数据,关键信息如下:

  • 5s之内发生了7次young gc
  • 老年代由233m增长至265m,增长了32m左右,按照老年代512m的大小,大约80s就会发生一次full gc

积压1W条数据

开始:

1S后:

对比两次数据得知:

1s之内发生了两次young gc

同时老年代从304m增长至314m,1s增长了10m,老年代大小为512m,按照这个速率,大约50s就会触发一次full gc

此时下游还未将数据推送完成,同时上游还不断的从mq中消费消息,继续观察

1s后:

对比两次数据发现:

  • gc线程的cpu使用率居高不下
  • 发生了一次full gc

一秒前老年代已使用大小为418M,总大小为512M,1s后发现触发了一次full gc,按照上面分析出老年代1s增加10M的速度,老年代的剩余空间是足够的,为什么还会出现full gc这种情况呢?

full gc发生的时机有哪些呢?
  • 第一种情况:老年代可用内存小于年轻代全部存活对象大小,同时没有开启空间担保参数(-XX:-HandlePromotionFailure
  • 从jdk6之后,HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,我们使用的都是jdk8,所以第一种情况不满足
  • 第二种情况:老年代可用内存小于年轻代全部对象大小,开启了空间担保参数,但是可用内存小于历次年轻代GC后进入老年代的平均对象大小
  • 根据之前的分析,1s内进入老年代的对象大小大约为10M,而目前老年代剩余大小约为(512-418)= 94M,所以第二种情况也不满足
  • 第三种情况:年轻代Minor GC后存活对象大于Survivor,就会进入老年代,但是老年代内存不足
  • 同第二种情况,第三种情况也不太满足
  • 第四种情况:设置了参数-XX:CMSInitiatingOccupancyFaction,老年代可用内存大于历次年轻代GC后进入老年代的对象的平均大小,但是老年代已使用内存超过该参数指定的比例,自动触发Full GC(建议92%)
  • 发现jvm启动参数中加了该参数: -XX:CMSInitiatingOccupancyFraction = 80,该参数表示老年代在达到 512 * 80% = 409m 大小时就会触发一次full gc。该参数主要是为了解决cmf(Concurrent Mode Failure)问题,不过该参数也会导致full gc更加的频繁。
  • 按照这个速率,老年代从目前的305m增长至409m只需10s,意味着10s后就会发生一次full gc!
  • 现在我们知道了提前触发了full gc的原因是由于 CMSInitiatingOccupancyFraction 参数的配置,正常情况下设为80%也不会有什么问题,但是有没有这种极端情况呢:

发生full gc后老年代的空间并没有回收多少,老年代已使用空间大小一直在设定的阈值之上,导致不停的full gc?

积压1.5W条数据

开始:

5S后:

对比两次数据,得知以下关键信息:

  • 线程情况:cms垃圾回收线程cpu占用率高达42%+
  • full gc次数:5s内发生了3次full gc
  • full gc总耗时:总耗时由14742 增加至 15131

5s之内居然发生了3次full gc,平均每次full gc耗时(15131-14742)/ 3 = 129 ms,虽然出现了full gc频繁的情况,但是还不至于造成服务不可用。

积压2W条数据

开始:

5s后:

对比两次数据发现:

  • 线程情况:cms垃圾回收线程cpu占用率极高
  • 老年代:已使用511m(总大小512m)
  • full gc次数:5s内发生了3次full gc
  • full gc总耗时:总耗时由14742 增加至 15131
  • 5s内发生了3次full gc,老年代始终处于已使用511m(总大小512m)的情况,每次的full gc平均耗时 (81942-79740)/3=734 ms,相当于5s内有2.2s的时间都在full gc。

此时查看日志发现数据推送时发生大量的socket连接超时:

再查看下当时的gc日志

使用默认的jvm参数,gc日志放置在该路径下:/opt/zcy/modules/gc.log

查看gc日志发现:两次full gc之间只差了1.4s左右,从524287k回收至524275k,只回收了12k的内存空间,却花费了0.71s时间,系统有一半的时间都在进行full gc,造成系统不可用。

使用监控大盘(grafana)查看下当时的cpu、网络io情况

通过监控大盘可以看到由于full gc频繁,导致cpu被打满,网络请求相关的线程得不到有效的调度,导致网络io吞吐下降。

4、优化方案

4.1、问题原因
  • 通过上面测试可以发现系统存在的问题主要是由于下游消费速率(网络请求)跟不上上游的投递速率(mq消费),导致内存逐渐被打满,系统频繁full gc造成服务不可用,直至产生OOM程序崩溃。
4.2、优化点
  • 在该场景中系统的主要瓶颈在于jvm内存大小上面,避免系统频繁full gc即可达到我们的目的,所以可以尝试基于jvm堆内存使用率来对上游进行限流控制。

jvm参数优化

  • 原来年轻代大小设为1024M并不适用于纵横这种数据推送场景。通过观察发现老年代常驻对象150M左右,考虑浮动垃圾等,老年代分配512M大小,所以年轻代调整为1.5g大小:-Xmn1536M
  • 修改年轻代中的伊甸区和s区的比例由10:1:1修改为8:1:1,避免young gc后存活对象过多直接进入老年代: -XX:SurvivorRatio=8
  • 元空间大小分配256m。-XX:MaxMetaspaceSize=256M -XX:MetaspaceSize=256M
  • 线程栈最大1m:-Xss1M
  • 针对年轻代使用ParNew垃圾收集器:-XX:+UseParNewGC

所以最终优化后的jvm参数如下:

-Xmx2g -Xms2g -Xmn1536M -Xss1M -XX:MaxMetaspaceSize=256M -XX:MetaspaceSize=256M -XX:SurvivorRatio=8

-XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+UseCMSCompactAtFullCollection -XX:CMSMaxAbortablePrecleanTime=5000 -XX:+CMSClassUnloadingEnabled -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExplicitGCInvokesConcurrent -Xloggc:/opt/zcy/modules/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/zcy/modules/java.hprof

增加资源限流

  • 纵横推送流程是基于责任链的模式来进行设计的,所以只需在链中再增加一个限流器即可。
  • 限流器中可设定一个jvm堆内存的使用率,当超过这个阈值后对当前的消费线程进行阻塞,直到使用率低于阈值后再进行放行
  • 当触发限流阻塞消费者线程时,为应对特殊情况,可设置一个最大阻塞时间,超过该时间后放行该消费者线程

其中阈值的配置范围给出一个参考公式:

  • 最大阈值百分比 = (年轻代可使用最大值 + 老年代可使用最大值 * -XX:CMSInitiatingOccupancyFraction参数值)/ 堆可使用最大值
  • 最小阈值百分比= 年轻代可使用最大值 / 堆可使用最大值

代入相关参数,即

  • 最大阈值百分比 = (1381 + 512*0.8 )/ 1894 = 94%
  • 最小阈值百分比 = 1381 / 1894 = 72%

参考代码如下:

@Slf4j
@Component
public class ResourceLimitHandler extends AbstractHandler<MsgInput, MsgOutput> {
    /**
     * 是否启用资源限流
     */
    @Value("${flow.limit.heap.trigger:true}")
    private Boolean trigger;
    /**
     * jvm堆限流阈值
     */
    @Value("${flow.limit.heap.threshold:70}")
    private Integer threshold;
    /**
     * 单次睡眠时间(毫秒)
     */
    @Value("${flow.limit.heap.sleepTime:1000}")
    private Integer sleepTime;
    /**
     * 最大阻塞时间(毫秒),-1代表无限制
     */
    @Value("${flow.limit.heap.maxBlockTime:15000}")
    private Integer maxBlockTime;
    private MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
    @SneakyThrows
    @Override
    public void task(MsgInput input, MsgOutput output) {
        if (!this.trigger) {
            return;
        }
        boolean isLimit = false;
        long startTime = System.currentTimeMillis();
        double percent = this.getHeapUsedPercent();
        while (this.trigger && percent >= this.threshold ) {
            if (!isLimit) {
                isLimit = true;
                log.warn("资源使用过高,开启资源限流。heap:【{}】,used:【{}】,percent:【{}%】,threshold:【{}%】,msgId:【{}】,bizType:【{}】,bizId:【{}】", this.getHeapMax(), this.getHeapUsed(), percent, this.threshold, input.getMessageId(), input.getBizType(), input.getBusinessId());
            }
            if (this.maxBlockTime >= 0 && (System.currentTimeMillis() - startTime) > this.maxBlockTime) {
                log.warn("资源使用过高,但超过最大阻塞时间({}毫秒),开始放行。heap:【{}】,used:【{}】,percent:【{}%】,threshold:【{}%】,msgId:【{}】,bizType:【{}】,bizId:【{}】", this.maxBlockTime, this.getHeapMax(), this.getHeapUsed(), percent, this.threshold, input.getMessageId(), input.getBizType(), input.getBusinessId());
                //兜底,防止因为限流导致年轻代无新对象产生,达不到触发young gc的条件,所以手动触发一次full gc
                synchronized (ResourceLimitHandler.class) {
                    if ((percent = this.getHeapUsedPercent()) >= this.threshold) {
                        System.gc();
                        log.warn("内存空间不足,手动触发full gc。heap:【{}】,used:【{}】,percent:【{}%】,threshold:【{}%】,msgId:【{}】,bizType:【{}】,bizId:【{}】", this.getHeapMax(), this.getHeapUsed(), percent, this.threshold, input.getMessageId(), input.getBizType(), input.getBusinessId());
                    }
                }
                return;
            }
            TimeUnit.MILLISECONDS.sleep(this.sleepTime);
            percent = this.getHeapUsedPercent();
        }
        if (isLimit) {
            log.info("资源使用率恢复,开始放行,限流时长:【{}】。heap:【{}】,used:【{}】,percent:【{}%】,threshold:【{}%】,msgId:【{}】,bizType:【{}】,bizId:【{}】", System.currentTimeMillis() - startTime, this.getHeapMax(), this.getHeapUsed(), percent, this.threshold, input.getMessageId(), input.getBizType(), input.getBusinessId());
        }
    }
    /**
     * 计算堆的使用百分比
     * @return
     */
    private double getHeapUsedPercent() {
        long max = this.getHeapMax();
        long used = this.getHeapUsed();
        double percent = NumberUtil.div(used, max) * 100;
        return percent;
    }
    /**
     * 可用堆最大值
     * @return
     */
    private long getHeapMax() {
        MemoryUsage memoryUsage = this.memoryMXBean.getHeapMemoryUsage();
        return memoryUsage.getMax();
    }
    /**
     * 已使用堆大小
     * @return
     */
    private long getHeapUsed() {
        MemoryUsage memoryUsage = this.memoryMXBean.getHeapMemoryUsage();
        return memoryUsage.getUsed();
    }
}
4.3、方案验证

资源配置

  • 数据量:5w条积压数据
  • 实例配置:1核4g
  • 实例数:2

资源限流前

推送完成后:

对应的监控大盘:

  • 现象:由于内存被打满,jvm频繁进行full gc,导致cpu被打满,数据推送线程获取的cpu时间片减少,引起网络io下降,大量请求返回socket连接超时。

资源限流后

  • 在对jvm参数进行优化以及添加资源限流器后,再次模拟数据积压情况观察效果。

推送完成后:

对应的监控大盘情况:

结果比对

  • 对比优化前后,使用新的限流方案后的效果还是挺显著的,效能近乎提升了一倍

5、线上案例

5.1、事件简述
  • 3月17日晚7点左右收到告警通知:安徽环境-纵横内存使用率过高-超过95%事件

处理过程

  • 登录arthas控制台,发现两台配置为2g内存的服务实例jvm内存使用率高达99%,在观察过程中一台实例挂掉自动重启。
  • 查看应用日志以及Apollo配置确认应用的资源限流功能已经触发,正常数据量推送情况不可能还会出现内存被打满的情况,必定是出现了数据洪峰!。
  • 数据库查询从中午12点至今的数据推送量发现达到了100W+,其中网站群公告、竞争性谈判公告各占近50%,该数据量相当于安徽环境6个月推送的数据总量。
  • 联系安徽陆安,经沟通发现是对方发版有误导致历史数据全部重推了一遍,相当于一下午推送了历史6个月的数据。
  • 立即申请运维同学紧急协助升配扩容为8G内存 4实例,纵横Apollo调整资源限流参数,同时联系安徽陆安停止历史重复数据推送。
5.2、事件分析
  • 上游业务方未对业务数据进行判断导致压力转嫁至纵横网关侧,造成数据洪峰。
  • 纵横机器实例内存配置不足以支撑该数据量,触发了资源限流,但为何仍然出现了内存使用高达99%的情况?
  • 当时Apollo中配置的限流阈值为 80%,单次限流最大阻塞时长为10秒,之所以触发了资源限流内存还被打满的原因是阻塞10s后,由于下游第三方接口响应较慢,之前推送的数据占据的内存资源仍未被释放掉,进而导致内存逐渐被打满,所以针对该种特殊情况,也需要合理配置单次限流最大阻塞时长。
5.3、结语
  • 每种方案都有各自的优缺点、局限性,根据jvm堆内存使用率进行限流,并不适用所有的业务场景,只是作为一个新的限流方案供大家参考扩展思路,起到一个抛砖引玉的作用
相关实践学习
RocketMQ一站式入门使用
从源码编译、部署broker、部署namesrv,使用java客户端首发消息等一站式入门RocketMQ。
消息队列 MNS 入门课程
1、消息队列MNS简介 本节课介绍消息队列的MNS的基础概念 2、消息队列MNS特性 本节课介绍消息队列的MNS的主要特性 3、MNS的最佳实践及场景应用 本节课介绍消息队列的MNS的最佳实践及场景应用案例 4、手把手系列:消息队列MNS实操讲 本节课介绍消息队列的MNS的实际操作演示 5、动手实验:基于MNS,0基础轻松构建 Web Client 本节课带您一起基于MNS,0基础轻松构建 Web Client
相关文章
|
21天前
|
消息中间件 存储 Java
jvm性能调优实战 - 47超大数据量处理系统是如何OOM的
jvm性能调优实战 - 47超大数据量处理系统是如何OOM的
46 0
|
21天前
|
存储 算法 Java
JVM 数据区域
JVM 数据区域
|
21天前
|
存储 Java 编译器
【JVM】运行时数据区域
【JVM】运行时数据区域
23 0
|
21天前
|
存储 算法 Java
JVM-01Java内存区域与内存溢出异常(上)【运行时区域数据】
JVM-01Java内存区域与内存溢出异常(上)【运行时区域数据】
40 0
|
21天前
|
存储 算法 Java
[JVM] 美团二面,说一下JVM数据区域
[JVM] 美团二面,说一下JVM数据区域
|
8月前
|
存储 Java 数据管理
【面试题精讲】JVM-运行时数据区-帧数据
【面试题精讲】JVM-运行时数据区-帧数据
|
10月前
|
存储 安全 Java
JVM - 运行时数据区域
详细描述 JVM 中五大运行时数据区域 的概念和作用
40 0
|
12月前
|
缓存 算法 Java
细说jvm(一)、jvm运行时的数据区域
细说jvm(一)、jvm运行时的数据区域
79 0
|
Java 编译器
JVM运行时数据区域学习
5.永久代属于HotSpot,JDK1.8废弃了永久代,取而代之的是Metaspace(元空间),元空间使用的是直接内存,不使用虚拟机内存;4.堆内存 = 新生代(1/3) + 老年代(2/3);1.本地方法栈和虚拟机栈并非所有的JVM都有区分,不是强制规定,HotSpot中本地方法栈和虚拟机栈是合在一起的;6.在HotSpot中字符串常量池在jdk1.8由原来的方法区(永久代)转移到java堆中.2.方法区不等于永久代,HotSpot使用永久代来实现方法区,但在其他的JVM中并不适用;
|
存储 Java 索引
JVM:Java运行时数据区域----Java虚拟机栈
JVM:Java运行时数据区域----Java虚拟机栈
86 0
JVM:Java运行时数据区域----Java虚拟机栈