从服务化运维角度,解析APM之一键式线程分析实现

张真 2017-08-16 09:40:21

本文根据DBAplus社群第115期线上分享整理而成。

 

讲师介绍
 

张真

宜信技术研发中心高级架构师

研发总监

 

  • 目前负责金融基础服务、微服务计算、智能运维、DevOps平台等;

  • 曾任IBM,负责云计算、应用服务器等;

  • 拥有多个国际专利;开源社区活跃贡献者。

 

之前的分享给大家介绍了我们的微服务计算平台(上)(下),说明微服务计算的思想以及核心场景的思路;之后又由以微服务治理中服务图谱构建为话题《微服务治理实战:服务流的自动化构建与应用》,展示了在微服务架构下的一种自动化构建服务关联的方法。

 

此次分享会从服务化的运维角度的一个重点问题出发,带领大家了解自动化运维中的APM,希望能为大家带来新鲜的启示。

 

分享大纲:

  1. 自动化运维中的APM

  2. 为什么需要一键式线程分析

  3. 一键式线程分析的实现原理

  4. 应用场景解析

 

一、自动化运维中的APM

 

先谈谈自动化运维的概念,它是指利用系统化手段,将IT运维的流程,管理,执行,反馈,优化等诸多方面实现高度自动化处理。IT运维发展经历了几个阶段:

 

  • 初始期:这个时期是IT运维的软件工具,流程初始化的时期,工具的目标仅仅只是计算机化,流程尚属摸索阶段,还没有形成行业共识。

  • 流程形成期:这个时期ITIL出现了,ITIL强调流程管理质量,也就是通过流程和制度来管理人,业务,系统和设施。在这个时期,DevOps被提出,当然DevOps并不像ITIL是一种流程,它是一种理念强调打破开发,测试,运维的边界。这个时期也可以被看成是自动化运维的萌芽期

  • DevOps阶段:通过对ITIL的反思和整理,同时迎来DevOps的工具链已经比较成熟,这个时期自动化运维正式进入历史舞台,它更加强调从运维流程,运维措施等层面实现完全的自动化,在特定情况下,甚至实现无人干预。

 

那么自动化运维到底给IT运维带来了哪些变化呢?我认为有以下四个方面:

 

  • 执行角度:从人工执行转型成系统化执行

  • 管理角度:从以人工界面管理转型成无界面系统化调度

  • 模式角度:从以人为驱动的运维模式转型成系统自我驱动的运维模式

  • 工具角度:从分散的运维工具转型成一体化协作的运维平台

 

在自动化运维中,应用运维尤其重要。应用运维的关键是实现有效的应用性能管理,即APM。APM包含两个关键问题:

 

  • 实时监控:通过技术手段获取各种指标(业务,应用,基础设施),实现运维可视化,精确的报警等

  • 问题诊断:通过技术手段捕获问题现场,对问题现场进行自动化分析,并导出问题线索,甚至挖掘问题根因

 

本次分享的主要话题是问题诊断,它之所以重要,是因为在运维过程中往往因为不能及时的发现问题,解决问题,导致运维的时效下降,甚至导致业务阻断。所以有效提高问题诊断的时效,是提升运维效率的重点。

 

那么问题诊断又面临哪些挑战呢?可以从以下几个方面来看:

 

  • 微服务化,容器化的双重规模效应:微服务架构带来的直接效应是由于服务功能的拆分,服务类型变多,从而服务集群的数量变多,规模变大;同时,微服务通常采用容器技术实现部署,容器技术使得服务可部署实例大大高于虚拟机的数量,从而进一步促使规模变大。

  • 容器化隔离导致常规APM手段失效:容器技术的核心主要是利用Namespace和CGroup实现进程级隔离,而常规APM手段是利用工具对目标进程进行采集,例如使用jstack获得某jvm进程的线程dump。这种方式由于进程隔离,而变得不可用。

  • 高实效性要求,更短的MTTR(平均故障恢复时间):以互联网为主导的IT运维要求越来越高的时效性,这种时效的要求逐渐的超过了人+工具,它要求能多快恢复就多快恢复。

 

那么在自动化运维中,APM又涵盖哪些内容呢?其实涵盖的内容很多,在问题诊断这个方向上至少包含以下几种:

 

 

  • 线程诊断分析:这是本此分享的重点,稍后会详细讲解。之所以线程分析是问题诊断的重要手段,是因为线程是计算资源分配的直接体现;线上问题都跟线程的工作行为有关;线程可以关联代码逻辑,而通常代码逻辑是造成线上问题的主要因素

  • 慢操作跟踪:某个请求很缓慢,也是常见问题,常见的慢操作包括SQL、MQ、Redis、Mongo、Http等

  • 服务调用链跟踪:调用链是用来跟踪服务之间调用最直接的手段,通过调用链可以快速定位哪个服务是问题的根因

  • 客户端体验跟踪:是通过收集Web/App客户端呈现的用户操作的性能数据,来帮助分析和定位用户体验方面的问题

  • 主动健康巡检探测:通过定期的对应用进行探测以及执行与应用健康相关的状态收集来提早发现潜在问题的手段。

 

二、为什么需要一键式线程分析

 

上面已经谈到线程分析的重要性,接下来说说为什么需要一键式线程分析?我们先来看一个经典的运维问题:

 

  1. 发现或被通知某个机器上CPU很高(经典的监控系统)

  2. 运维人员登录堡垒机,没有带电脑是致命的,或许还需要短信验证码

  3. 人工找到报CPU高的机器,使用某种SSH客户端登录到系统

  4. 输入top命令

  5. 人眼观察哪个进程CPU高,找到那个高CPU的进程,获取PID

  6. 输入top -Hp PID

  7. 人眼观察哪些线程CPU高,然后截图保存备用

  8. 输入jstack PID > xxx.threaddump, 输出java thread dump文件

  9. 通过堡垒机下载刚才输出的java thread dump文件

  10. 使用java thread dump分析工具打开dump文件

  11. 从刚才的截图中找出高CPU的TID(线程号),然后需要转换成16进制

  12. 人眼在分析工具中定位到那个高CPU的线程,然后找到对应的代码

 

通过上面实现线程分析的运维过程,我们可以看到以下问题:

 

  • 过度专业化:对于有经验的运维人员来说是常规技能,但如今运维(DevOps期望人人参与运维)的参与方包括了开发,测试,运维,甚至运营的人员,并不是人人都能有这些专业知识。

  • 很被动:需要人工的主动介入。如果人休假,或没有专业设备(堡垒机登录,线程分析软件等),将无法进行

  • 很低效:超长的人工驱动链路,人眼搜索,纯手工分析

  • 无法并行:更好的实效来源于并行化,人工处理是串行的。举例:如果是多个进程CPU高,那么需要把上面的多数步骤都重复一遍,耗时会更长

 

此外,大数据甚至是人工智能等相关技术也正在被引入运维领域,而实现自动化运维也提出以下要求:

 

  • 诊断数据需要统一管理:人工收集数据,没有统一的存储,也无法提供随时查询的能力

  • 知识沉淀,案例演练:团队希望将运维知识沉淀传承,能够回溯案例

  • 自动分析的可能:问题诊断往往有模式可循,只有将模式抽象出来,让系统可以使用,才能实现真正的自动化

  • 大数据分析的来源:利用大数据工具对各种诊断数据进行深入挖掘,发现新知识和规律

 

那什么是一键式线程分析?我认为可以从以下方面来说明其内涵:

 

  • 从用户角度:目标驱动,结果呈现。举例:用户一键指定目标进程,几秒后系统告知哪些线程CPU高对应代码是哪些

  • 从流程角度:覆盖全流程,包括诊断数据捕获,归集,存储,查询和分析

  • 从数据角度:诊断数据统一集中管理,随时随地可查

  • 从工作模式:从人工被动转变为触发式或主动式或联动式(后面会提到)

  • 从体验角度:人人通过系统,都能实现移动化运维,不再受专业工具的束缚

 

一键式线程分析的实现原理

 

接下来,我们进入本次分享的重点,剖析一下一键式线程分析的实现原理。本文以JVM的一键式线程分析来说明实现原理。

 

它包含5个基本环节:

 

  • 捕获:收集线程相关的原始诊断数据

  • 归集:将数据从目标机器传输到服务端的过程

  • 分析:基础分析和深度分析

  • 存储:将分析结果存储的过程

  • 查询:提供查询接口,支持灵活的查询

 

下图是一个参考实现架构,它说明这5个环节的工作关联:

 

  • 捕获通过对目标进程的采集,将采集结果生成Dump(内存或文件皆可能)

  • 归集客户端将Dump封装成消息,发送到消息队列

  • 归集服务端从消息队列取出该消息,并提取Dump数据

  • 基础分析对Dump数据进行分析转换成存储数据

  • 存储环节实现数据存储,这个部分推荐使用Elastic Search,后面会说明选型的原因

  • 查询提供对Dump数据的多维度检索,是深度分析的基础

  • 深度分析是将Dump数据真正实现自动化问题诊断的步骤,后面的应用场景剖析会重点说明

 

捕获实现
 

 

首先需要确定要捕获的内容:

 

  • 目标进程的资源消耗:CPU,内存等,Linux系统可通过top获得

  • 目标进程的线程资源消耗:通常Linux系统通过top -Hp <pid>获得

  • JVM的线程Dump:Java中可以通过jstack或jdk自带API来获得

  • 目标进程的业务元数据:可以实现业务关联,例如应用名称,属于业务线,这些数据有助于业务层面实现关联性

 

要实现对以上数据的捕获通常有两种方式。

 

方式一:捕获直接触发Dump生成

 

 

[1]通过top命令获得进程性能

[2]通过top -Hp <pid>获得线程性能

[3]通过jstack <pid>生成Java Thread Dump

 

  • 优点:结构简单,易于实现

  • 限制:采集程序与目标进程必须相同用户启动。这是因为由于操作系统限制,不同用户的进程状态信息互不可见。

 

方式二:捕获间接触发Dump生成

 

 

[1]在目标JVM增一个代理,该代理可以接受远程请求从而触发以下动作

[2]通过top命令获得进程性能

[3]通过top -Hp <pid>获得线程性能

[4]通过jstack <pid>生成Java Thread Dump

 

  • 优点:采集程序与目标JVM可以是不同用户,采用了远程通信的方式来触发。对容器友好(Docker等),可以通过容器暴露的端口来进行采集。在与应用共享JVM,可以提取一些应用业务特征,例如应用名称。

  • 限制:尽管概率较小,在目标JVM完全hang死时,将无法捕获Dump

 

在实际应用中,我们使用的是第二种方式,供大家参考。

 

这种方式需要实现一个代理,它需要负责接受触发指令和触发Dump数据生成。实现代理有以下方式:

 

方法一:应用级别增加一个新的Servlet或Filter

 

优点:实现简单

 

缺点:与应用绑定,生命周期的管理需应用参与;与应用隔离弱,可能互相干扰

 

方法二:如果是JEE应用服务器,可实现一个全局Filter或Listener,类似Tomcat,Jetty等都支持类似功能

 

优点:与应用解耦,实现也比较简单

 

缺点:非JEE应用服务器场景不支持

 

方法三:实现一个内置服务(推荐)

 

这是一种参考实现:基于com.sun.net.httpserver.HttpServer实现Http服务,这是JDK自带的功能,无需外部依赖;使用javaagent机制,在程序启动时,启动Http服务,如图:

 

优点:

  • 实现比较简单,并没有引入第三方技术栈,都是JDK原生的

  • 部署简单,与应用无关,只是追加-javaagent参数即可

  • 对应用本身无侵入,无论是否是JEE应用服务器都可用

  • 可以增加一些无需停机或静态配置的管理特性,例如:指定/改变dump目录存放位置

 

接下来要考虑应该采用什么方式来输出Dump数据,通常是将Dump数据输出成文件。通常有两种方式:

 

方法一:原始方式,至少3个文件:进程性能,线程性能,线程Dump

 

优点:无需任何处理,实现简单

缺点:

  • 不便统一归集,要保证3个文件被同时归集

  • 不便这些临时文件的管理

  • 如果执行中有异常而中断,产生碎片文件

 

方法二(推荐):合并为一个文件(格式布局参见下图)

 

这个文件的命名可以是:<ip>_<端口或工作目录>_<时间戳>,这样可以识别是哪个机器上的哪个进程,什么时间产生的。另外,不使用pid的原因是重启后pid会变,但其实还是那个程序(程序特征不变)。

 

优点:

  • 一次归集即可

  • 临时文件管理简单,自动刷掉前一次

  • 没有碎片文件,如果中断后再来一次,自动刷掉前一次

 

归集实现
 

 

在完成捕获以后,接下来就是实现归集,归集的实现本质就是消息发送(归集客户端)和消息消费(归集服务端)。

 

我们使用RocketMQ来完成这项工作:

 

  • 捕获程序完成捕获(成功生成Dump文件),通知归集客户端Dump文件的路径

  • 归集客户端对该路径Dump文件启动归集,一次性读取文件

  • 将读取的Dump文件转换成消息格式,并进行压缩(GZip),发送到消息队列

  • 归集服务端从消息队列读取消息,并解压缩

  • 从消息中提取Dump的数据,为基本分析做准备

 

 

实现归集,需要注意两个方面的问题:

 

1、尽量使用同一个消息封装一次捕获的数据,避免时序问题。下图是一种归集消息的schema参考实现

 

 

2、如果确实很大(压缩后依然很大),则拆分的多个消息要保持时序。这里提供一种参考实现:

 

  • 给每个消息加个唯一有序编号和序列长度

  • 任何一个归集消费者获取到消息时,先缓存消息

  • 然后检查缓存消息是否达到序列长度

  • 如果未达成,则不处理

  • 如果达成,则进行基本分析

  • 基本分析完成后,清理缓存

 

基本分析
 

 

归集完成后,接下是基本分析的步骤。它就是对Dump文件进行解析,提取各个区段的数据,并转换成存储所需的格式。下图是提取的映射关系。

 

 

存储实现
 

 

基本分析完成后,就要将数据存储起来。推荐使用Elastic Search(简称ES)来实现,这样做的原因是:

 

  • 从查询角度考虑,高效的搜索,分词支持

  • 从深度分析角度考虑,易于对接其他大数据工具,易于时序化

  • 从存储角度,实现多索引/Mapping管理,易于清理和合并

 

使用ES来存储,要考虑线程Dump索引的管理模式,这里提供一种参考方法:

 

  • 全部线程Dump信息在一定周期内使用同一个索引,比如1周

  • 索引命名:jta_<某周的第一天>,判断当前是属于那个周的索引,命名是以周日开头的那天日期

  • 每隔一个自然周(7天)自动建立一个全局的新索引,让数据自动流入这个新的索引

    例如:2017-06-24的数据实际是在2017-06-18(周日)的索引里面,实际索引是jta_2017-06-18

  • 当然增加一个统一别名也可以

 

为什么要采用这样的索引管理,是因为:

 

  • ES中一个index不宜过大,同时单index查询效率高于跨index查询,多数情况下,线程Dump的查询在时间维度很集中

  • 每隔7天自动切分一个新索引,从数据清除的角度,ES的index是整块清除,粒度大小合适,比按Document的逐条清除的代价小,且不同索引之间干扰较小

 

那么如何实现自动的索引切分呢?在要插入数据之前,获取当前应该使用的索引,根据当前日期判断属于哪一周,从而获得当周的周日日期。之后,判断索引是否创建,如果没有创建,就创建索引并完成Mapping工作,然后将数据插入ES。

 

下图是ES索引Mapping的参考实现

 

 

查询实现
 

 

存储完成后,我们已经可以初步来使用线程Dump数据了。实现查询通常有两种方式:

 

方式一:直接使用ES的DSL

 

  • 适用场景:与支持ES的数据分析软件集成,比如Kibana或Grafana

  • 优点:无需二次开发,可以十分灵活的查询

  • 缺点:调用方需要完全了解存储schema以及DSL语法;返回结果包含一些不会使用ES元数据

 

方式二:实现查询服务

 

  • 适用场景:系统对接

  • 优点:包含业务场景,简化调用方式;可以提供通用的查询模板,深度分析的开发可以简化

  • 缺点:需要二次开发

 

为了实现深度分析,我们在实际应用中实现了查询服务。

 

深度分析
 

 

接下来谈谈深度分析的实现。

 

如果有读者对于Java的线程Dump知识还不太熟悉,可以搜索一下这方面的内容,网上的解读很多。这里只着重强调一下关于线程等待的关键字,因为线程等待是我们经常遇到的问题,而从等待状态入手做线程分析是常用手段。Java的线程等待状态有两种关键字:

 

  1. Wait on condition:等待资源,或等待某个条件的发生;程序代码读取某种资源;等待其他线程执行;等待网络I/O;主动Sleep

  2. Waiting for Monitor Entry / in Object.wait():每个对象有且仅有一个Monitor,可被看成对象/Class锁。这时线程会有两种表现:

  • Active Thread:同一时刻,只能一个线程持有Monitor

  • Waiting Thread:处于Entry Set=Waiting for Monitor Entry;处于Wait Set=Object.wait()

 

首先,说明一下深度分析的目标:

  • 将SRE的线程分析经验按照场景实现成分析模板

  • 线程分析按某个维度的数据聚类

  • 实现与其他APM数据之间的关联

 

下面会重点说明一下分析模板的实现思路。

 

分析模板是根据问题诊断的目标场景建立的分析过程模型,它具备良好的通用性,只与目标场景有关,它的实现方式通常是一个算法。特别注意:这些算法与目标场景可以是一对多的关系。

 

分析模板实现分析的步骤:

  • 确定分析目标

  • 根据经验,确定分析目标达成的步骤以及数据关联

  • 确认分析模板的算法实现

  • 通过查询来提取分析目标所需的原始数据

  • 输入原始数据,执行分析步骤,获得分析

 

下图是深度分析中实现分析模板处理的一种参考实现。

 

 

  • 筛选:根据场景,确定候选的线程Dump数据

  • 分析目标匹配:根据输入意图,读取分析模板配置,匹配可能的分析模板

  • 分析模板加载:根据配置,加载分析模板,分析模板的实现可以是一个jar包或其他可加载代码

  • 分析模板运行:通过分析模板处理获得分析结果

  • 分析结果输出:将分析结果以某种格式输出,用于可视化展示或提供给其他系统使用

 

应用场景解析

 

这个部分会就深度分析的一些典型场景进行剖析,来说明如何实现分析模板以及如何应用。

 

场景一:高CPU分析
 

 

这是一个经常遇到的场景。它的分析模板如下:

 

  • 使用PID提取一次捕获的所有State为Runnable线程Dump数据

  • 按CPU字段降序排列

 

通过这个分析模板可以如下效果:

 

 

当然,这个基础上可以扩展分析模板做一些后续处理:

 

  • 识别线程类型:GC线程/RMI线程/中间件线程/用户线程池等

  • 根据高CPU的线程类型,实现根源分析提示:

1)GC类线程:GC过于频繁

2)中间件线程/用户线程池:如果是用户代码,则可能是代码有高计算处理

3)RMI或通信类线程:则可能是网络流量大或达到瓶颈

 

场景二:死锁分析
 

 

死锁也是比较典型的线程问题。实际上Java的线程Dump自带了Dead Lock的分析结果,所以如果希望直接知道结果,通过查询Dump数据的Dead Lock即可。

 

这里要介绍另一种方法,基于锁依赖的有向图。它期望通过对线程锁的互相依赖来实现更加全面的根因分析。

 

在死锁这个场景里,是这样实现的:

 

  • 使用PID提取一次捕获的所有线程Dump数据

  • 构造基于锁依赖的有向图,每个线程是一个节点,它对其他线程的锁依赖是一条有向连线

  • 构造完成后,根据标记的环,获得所有的死锁环路

  • 提取每个死锁环路中的关键线索

    1)每个线程的持有对象ID以及类型:持有对象类型可以辅助死锁的业务分析

    2)每个线程等待的对象ID以及类型:等待对象类型可以帮助定位死锁的业务语义

    3)每个线程Stack中的线程栈方法:方法对应处理逻辑,通常是问题根源所在

 

死锁的锁依赖的有向图的算法过程如下:

 

  • 遍历每个线程的Dump数据

  • 如果State是非等待状态,则检查节点是否已经存在,若不存在,则增加一个图的节点

  • 如果State是等待状态,则检查节点是否已经存在,若不存在,则增加一个图的节点

    1)然后提取ThreadInfo(包含thread stack)包含wait to lock <XXX>, XXX为对象ID

    2)查找包含locked <XXX>的那个线程,也增加一个图的节点(如果存在就不增加),画一条有向连线指向该节点

    3)查找所有包含wait to lock <XXX>的线程,每找到一个也增加一个图的节点(如果存在就不增加),并画一条有向连线指向locked <XXX>的线程节点

 

通过锁依赖的有向图可以观察到四种基本死锁(如下图)

 

 

  • 互等死锁:两个线程互相等待同一个锁

  • 传递死锁:多个线程之间互相等待一个或多个锁,构成环状

  • 混合死锁:由上面两种死锁造成对其他线程的影响,所以它可以反应死锁影响的范围

1)形式一:某些线程依赖于互等死锁的线程持有的锁

2)形式二:某些线程依赖于传递死锁的线程持有的锁

 

场景三:单线程等待分析
 

 

单线程的等待分析也是经常使用的手段。它的分析模板如下:

 

  • 提取该线程Dump数据(State应该为Blocked,in Object.wait等等待状态)

  • 提取wait to lock <XXX>, 获得XXX(对象ID)

  • 查找locked <XXX>的线程Dump数据

  • 检查其状态

1)如果是非等待状态,那么说明线程在运行

2)如果是等待状态,则提取wait to lock <YYY>,获得YYY(对象ID)

3)查找locked <YYY>的线程Dump数据,以此类推

  • 同时在过程中提取线程的关键线索

1)每个线程的持有对象ID以及类型

2)每个线程等待的对象ID以及类型

3)每个线程Stack中的线程栈方法

 

其实可以发现这也是一个基于锁依赖的有向图,只是只有一条有向路径而已。

 

场景四:单线程的工作状态分析
 

 

当然可能我们不仅仅只关注等待状态。有时候我们需要对线程的全部状态进行分析。对单线程而言,它的分析模板:

 

  • 连续提取N次捕获的某线程Dump数据(是一个有时序的列表)

  • 遍历每次的线程Dump数据,并构造该线程每次捕获的基于锁依赖的有向图

  • 将所有有向图按时序叠加,获得一个时序有向图

 

这个时序有向图可以帮助发现以下内容:

  • 可看到某个线程在某个时间范围的线程状态变化

  • 可以直观的获得线程等待状态的根源,比如死锁

 

下图是一个例子:线程A在10:35处于运行状态(绿色),而10:45时A处于等待(橙色),A在等待B持有的锁,到10:55,A仍然处于等待,这时候发现B和C形成了互等死锁,A与B,C形成了混合死锁,这就是A仍然处于等待的原因。

 

场景五:多线程的工作状态分析
 

 

在单线程的基础上,我们把问题扩展到多线程。它的分析模板:

 

  • 连续提取N次捕获的符合某种过滤条件的线程Dump数据

  • 遍历每次的每个线程Dump数据,并构造该线程每次捕获的基于锁依赖的有向图,将该线程的所有有向图按时序叠加,获得一个时序有向图

  • 将所有时序有向图叠加,就获得了多线程的时序有向图

 

通过这样的分析模板,可以获得全部线程的时序有向图。看下面的例子:10:35时,A、B都是运行态,C处于等待B的某个锁;10:45时,A结束运行等待B的某个锁,B结束运行处于等待,C运行,而此时B等待C的某个锁;10:55时,A依然等待B,B等待C,而C结束运行,等待B,由于此时B和C死锁,造成A也“被”死锁。

 

 

总结

 

下面来总结一下本次分享的内容:

 

  • 第一部分:描述了自动化运维中APM的内容是什么,说明两个关键问题:实时监控和问题诊断,而问题诊断又面临了哪些挑战,列举了APM工具箱包含的内容,其中线程诊断分析尤其重要

  • 第二部分:通过一个经典线程分析问题,说明传统手段的痛点,也结合自动化运维以及数据深度的要求来阐述为什么需要一键式线程分析,并从多个角度来说明什么是一键式线程分析

  • 第三部分:剖析一键式线程分析的基本架构,分别从其5个关键步骤(捕获,归集,存储,查询,分析)剖析实现原理

  • 第四部分:从深度分析的应用场景出发,分别对高CPU,死锁,单线程分析,多线程分析进行分析模板的说明和应用

 

直播链接
 

https://m.qlchat.com/topic/details?topicId=220000520028839&isGuide=Y

密码:333

 

-END-

读完仍意犹未尽?

更多自动化运维干货

尽在全球敏捷运维峰会北京站!

9月15日 重磅来袭

 

 
 
 
分享企业与嘉宾
 
 
 


抢座链接:
http://www.bagevent.com/event/643565#website_moduleId_60229

 
 
 
活动预告