全网第一 | Flink学习面试灵魂40问答案!

来源:王知无

作者:王知无

By 暴走大数据

场景描述:这是一份Flink学习面试指北。看看你搞清楚自己的定位没有?

关键词:Flink 学习 面试

《大数据技术与架构》和《暴走大数据》读者拥有本文的优先阅读权。

转载请联系作者本人。

概念和基础篇

1. 简单介绍一下Flink 

Flink核心是一个流式的数据流执行引擎,其针对数据流的分布式计算提供了数据分布、数据通信以及容错机制等功能。基于流执行引擎,Flink提供了诸多更高抽象层的API以便用户编写分布式任务:

DataSet API, 对静态数据进行批处理操作,将静态数据抽象成分布式的数据集,用户可以方便地使用Flink提供的各种操作符对分布式数据集进行处理,支持Java、Scala和Python。

DataStream API,对数据流进行流处理操作,将流式的数据抽象成分布式的数据流,用户可以方便地对分布式数据流进行各种操作,支持Java和Scala。

Table API,对结构化数据进行查询操作,将结构化数据抽象成关系表,并通过类SQL的DSL对关系表进行各种查询操作,支持Java和Scala。

此外,Flink还针对特定的应用领域提供了领域库,例如:

Flink ML,Flink的机器学习库,提供了机器学习Pipelines API并实现了多种机器学习算法。

Gelly,Flink的图计算库,提供了图计算的相关API及多种图计算算法实现。

2.  Flink相比Spark Streaming有什么区别?

这个问题问的很大,分几个方面回答:

架构模型上:Spark Streaming 的task运行依赖driver 和 executor和worker,当然driver和excutor还依赖于集群管理器Standalone或者yarn等。而Flink运行时主要是JobManager、TaskManage和TaskSlot。另外一个最核心的区别是:Spark Streaming 是微批处理,运行的时候需要指定批处理的时间,每次运行 job 时处理一个批次的数据;Flink 是基于事件驱动的,事件可以理解为消息。事件驱动的应用程序是一种状态应用程序,它会从一个或者多个流中注入事件,通过触发计算更新状态,或外部动作对注入的事件作出反应。

任务调度上:Spark Streaming的调度分为构建 DGA 图,划分 stage,生成 taskset,调度 task等步骤而Flink首先会生成 StreamGraph,接着生成 JobGraph,然后将 jobGraph 提交给 Jobmanager 由它完成 jobGraph 到 ExecutionGraph 的转变,最后由 jobManager 调度执行。

时间机制上:flink 支持三种时间机制事件时间,注入时间,处理时间,同时支持 watermark 机制处理滞后数据。Spark Streaming 只支持处理时间,Structured streaming则支持了事件时间和watermark机制。

容错机制上:二者保证exactly-once的方式不同。spark streaming 通过保存offset和事务的方式;Flink 则使用两阶段提交协议来解决这个问题。

3. Flink的组件栈是怎么样的

Flink是一个分层架构的系统,每一层所包含的组件都提供了特定的抽象,用来服务于上层组件。Flink分层的组件栈如下图所示:

Deployment层

该层主要涉及了Flink的部署模式,Flink支持多种部署模式:本地、集群(Standalone/YARN)、云(GCE/EC2)。

Runtime层

Runtime层提供了支持Flink计算的全部核心实现,比如:支持分布式Stream处理、JobGraph到ExecutionGraph的映射、调度等等,为上层API层提供基础服务。

API层

API层主要实现了面向无界Stream的流处理和面向Batch的批处理API,其中面向流处理对应DataStream API,面向批处理对应DataSet API。

Libraries层

该层也可以称为Flink应用框架层,根据API层的划分,在API层之上构建的满足特定应用的实现计算框架,也分别对应于面向流处理和面向批处理两类。面向流处理支持:CEP(复杂事件处理)、基于SQL-like的操作(基于Table的关系操作);面向批处理支持:FlinkML(机器学习库)、Gelly(图处理)。

4. Flink的基础编程模型了解吗? 

Flink 程序的基础构建单元是流(streams)与转换(transformations)。DataSet API 中使用的数据集也是一种流。数据流(stream)就是一组永远不会停止的数据记录流,而转换(transformation)是将一个或多个流作为输入,并生成一个或多个输出流的操作。

执行时,Flink程序映射到 streaming dataflows,由流(streams)和转换操作(transformation operators)组成。每个 dataflow 从一个或多个源(source)开始,在一个或多个接收器(sink)中结束。

详细参考:https://www.cnblogs.com/cxhfuujust/p/10925843.html

5. 说说Flink架构中的角色和作用?

JobManager:

JobManager是Flink系统的协调者,它负责接收Flink Job,调度组成Job的多个Task的执行。同时,JobManager还负责收集Job的状态信息,并管理Flink集群中从节点TaskManager。

TaskManager:

TaskManager也是一个Actor,它是实际负责执行计算的Worker,在其上执行Flink Job的一组Task。每个TaskManager负责管理其所在节点上的资源信息,如内存、磁盘、网络,在启动的时候将资源的状态向JobManager汇报。

Client:

当用户提交一个Flink程序时,会首先创建一个Client,该Client首先会对用户提交的Flink程序进行预处理,并提交到Flink集群中处理,所以Client需要从用户提交的Flink程序配置中获取JobManager的地址,并建立到JobManager的连接,将Flink Job提交给JobManager。Client会将用户提交的Flink程序组装一个JobGraph, 并且是以JobGraph的形式提交的。一个JobGraph是一个Flink Dataflow,它由多个JobVertex组成的DAG。其中,一个JobGraph包含了一个Flink程序的如下信息:JobID、Job名称、配置信息、一组JobVertex等。

6. 说说Flink中常用的算子?用过哪些?

举一些常用的例子:

flink中提供的大量的算子,下面将介绍常用的算子操作方式:

map

DataStream --> DataStream:输入一个参数产生一个参数,map的功能是对输入的参数进行转换操作。

flatMap

DataStream --> DataStream:输入一个参数,产生0、1或者多个输出,这个多用于拆分操作

filter

DataStream --> DataStream:结算每个元素的布尔值,并返回为true的元素

keyBy

DataSteam --> DataStream:逻辑地将一个流拆分成不相交的分区,每个分区包含具有相同key的元素,在内部以hash的形式实现的。以key来分组。

注意:以下类型无法作为key

  • POJO类,且没有实现hashCode函数

  • 任意形式的数组类型

reduce

KeyedStream --> DataStream:滚动 合并 操作,合并当前元素和上一次合并的元素结果。

fold

KeyedStream --> DataStream:用一个初始的一个值,与其每个元素进行滚动合并操作。

aggregation

KeyedStream --> DataStream:分组流数据的滚动聚合操作:min和minBy的区别是min返回的是一个最小值,而minBy返回的是其字段中包含的最小值的元素(同样元原理适用于max和maxBy)

window

KeyedStream --> DataStream:windows是在一个分区的KeyedStreams中定义的,windows根据某些特性将每个key的数据进行分组(例如:在5s内到达的数据)。

windowAll

DataStream --> AllWindowedStream:Windows可以在一个常规的DataStream中定义,Windows根据某些特性对所有的流(例如:5s内到达的数据)。

注意:这个操作在很多情况下都不是并行操作的,所有的记录都会聚集到一个windowAll操作的任务中

window apply

WindowedStream --> DataStream

AllWindowedStream --> DataStream:将一个通用的函数作为一个整体传递给window。

window reduce

WindowedStream --> DataStream:给窗口赋予一个reduce的功能,并返回一个reduce的结果。

window fold

WindowedStream --> DataStream:给窗口赋予一个fold的功能,并返回一个fold后的结果。

aggregation on windows

WindowedStream --> DataStream:对window的元素做聚合操作,min和minBy的区别是min返回的是最小值,而minBy返回的是包含最小值字段的元素。(同样原理适用于max和maxBy)

union

DataStream --> DataStream:对两个或两个以上的DataStream做union操作,产生一个包含所有的DataStream元素的新DataStream。

注意:如果将一个DataStream和自己做union操作,在新的DataStream中,将看到每个元素重复两次

window join

DataStream,DataStream --> DataStream:根据给定的key和window对两个DataStream做join操作

window coGroup

DataStream,DataStream --> DataStream:根据一个给定的key和window对两个DataStream做CoGroups操作。

connect

DataStream,DataStream --> ConnectedStreams:连接两个保持们类型的数据流。

coMap、coFlatMap

ConnectedStreams --> DataStream:作用于connected数据流上,功能与map和flatMap一样。

split

DataStream --> SplitStream:根据某些特征把一个DataStream拆分成两个或多个DataStream

select

SplitStream --> DataStream:从一个SplitStream中获取一个或多个DataStream

iterate

DataStream --> IterativeStream --> DataStream:在流程中创建一个反馈循环,将一个操作的输出重定向到之前的操作,这对于定义持续更新模型的算法来说很有意义的。

extract timestamps

DataStream --> DataStream:提取记录中的时间戳来跟需要事件时间的window一起发挥作用。

参考:https://ci.apache.org/projects/flink/flink-docs-release-1.9/dev/batch/

7. Flink中的分区策略有哪几种?

GlobalPartitioner: DataStream => DataStream

GlobalPartitioner,GLOBAL分区。将记录输出到下游Operator的第一个实例。

ShufflePartitioner: DataStream => DataStream

ShufflePartitioner,SHUFFLE分区。将记录随机输出到下游Operator的每个实例。

RebalancePartitioner: DataStream => DataStream

RebalancePartitioner,REBALANCE分区。将记录以循环的方式输出到下游Operator的每个实例。

RescalePartitioner: DataStream => DataStream

RescalePartitioner,RESCALE分区。基于上下游Operator的并行度,将记录以循环的方式输出到下游Operator的每个实例。举例: 上游并行度是2,下游是4,则上游一个并行度以循环的方式将记录输出到下游的两个并行度上;上游另一个并行度以循环的方式将记录输出到下游另两个并行度上。若上游并行度是4,下游并行度是2,则上游两个并行度将记录输出到下游一个并行度上;上游另两个并行度将记录输出到下游另一个并行度上。

BroadcastPartitioner: DataStream => DataStream

BroadcastPartitioner,BROADCAST分区。广播分区将上游数据集输出到下游Operator的每个实例中。适合于大数据集Join小数据集的场景。

ForwardPartitioner

ForwardPartitioner,FORWARD分区。将记录输出到下游本地的operator实例。ForwardPartitioner分区器要求上下游算子并行度一样。上下游Operator同属一个SubTasks。

KeyGroupStreamPartitioner(HASH方式):

KeyGroupStreamPartitioner,HASH分区。将记录按Key的Hash值输出到下游Operator实例。

CustomPartitionerWrapper

CustomPartitionerWrapper,CUSTOM分区。通过Partitioner实例的partition方法(自定义的)将记录输出到下游。

8. Flink的并行度有了解吗?Flink中设置并行度需要注意什么?

Flink程序由多个任务(Source、Transformation、Sink)组成。任务被分成多个并行实例来执行,每个并行实例处理任务的输入数据的子集。任务的并行实例的数量称之为并行度。

Flink中人物的并行度可以从多个不同层面设置:

操作算子层面(Operator Level)、执行环境层面(Execution Environment Level)、客户端层面(Client Level)、系统层面(System Level)。

Flink可以设置好几个level的parallelism,其中包括Operator Level、Execution Environment Level、Client Level、System Level

在flink-conf.yaml中通过parallelism.default配置项给所有execution environments指定系统级的默认parallelism;在ExecutionEnvironment里头可以通过setParallelism来给operators、data sources、data sinks设置默认的parallelism;如果operators、data sources、data sinks自己有设置parallelism则会覆盖ExecutionEnvironment设置的parallelism。 

9. Flink支持哪几种重启策略?分别如何配置? 

重启策略种类:

固定延迟重启策略(Fixed Delay Restart Strategy)

故障率重启策略(Failure Rate Restart Strategy)

无重启策略(No Restart Strategy)

Fallback重启策略(Fallback Restart Strategy)

详细参考:https://www.jianshu.com/p/22409ccc7905

10. Flink的分布式缓存有什么作用? 如何使用?

Flink提供了一个分布式缓存,类似于hadoop,可以使用户在并行函数中很方便的读取本地文件,并把它放在taskmanager节点中,防止task重复拉取。

此缓存的工作机制如下:程序注册一个文件或者目录(本地或者远程文件系统,例如hdfs或者s3),通过ExecutionEnvironment注册缓存文件并为它起一个名称。

当程序执行,Flink自动将文件或者目录复制到所有taskmanager节点的本地文件系统,仅会执行一次。用户可以通过这个指定的名称查找文件或者目录,然后从taskmanager节点的本地文件系统访问它。

详细参考:https://www.jianshu.com/p/7770f9aec75d

11. Flink中的广播变量,使用广播变量需要注意什么事项?  

在Flink中,同一个算子可能存在若干个不同的并行实例,计算过程可能不在同一个Slot中进行,不同算子之间更是如此,因此不同算子的计算数据之间不能像Java数组之间一样互相访问,而广播变量Broadcast便是解决这种情况的。

我们可以把广播变量理解为是一个公共的共享变量,我们可以把一个dataset 数据集广播出去,然后不同的task在节点上都能够获取到,这个数据在每个节点上只会存在一份。

https://www.jianshu.com/p/3b6698ec10d8 

12. Flink中对窗口的支持包括哪几种?说说他们的使用场景 

详细参考:https://www.jianshu.com/p/0ad104778bcd

13. Flink 中的 State Backends是什么?有什么作用?分成哪几类?说说他们各自的优缺点?

Flink流计算中可能有各种方式来保存状态:

  • 窗口操作

  • 使用了KV操作的函数

  • 继承了CheckpointedFunction的函数

  • 当开始做checkpointing的时候,状态会被持久化到checkpoints里来规避数据丢失和状态恢复。选择的状态存储策略不同,会导致状态持久化如何和checkpoints交互。

  • Flink内部提供了这些状态后端:

  • MemoryStateBackend

  • FsStateBackend

  • RocksDBStateBackend

  • 如果没有其他配置,系统将使用MemoryStateBackend。

详细参考:https://www.cnblogs.com/029zz010buct/p/9403283.html

14.  Flink中的时间种类有哪些?各自介绍一下?

Flink中的时间与现实世界中的时间是不一致的,在flink中被划分为事件时间,摄入时间,处理时间三种。

如果以EventTime为基准来定义时间窗口将形成EventTimeWindow,要求消息本身就应该携带EventTime

如果以IngesingtTime为基准来定义时间窗口将形成IngestingTimeWindow,以source的systemTime为准。

如果以ProcessingTime基准来定义时间窗口将形成ProcessingTimeWindow,以operator的systemTime为准。

参考:https://www.jianshu.com/p/0a135391ff41

15. WaterMark是什么?是用来解决什么问题?如何生成水印?水印的原理是什么?

Watermark是Apache Flink为了处理EventTime 窗口计算提出的一种机制,本质上也是一种时间戳。

watermark是用于处理乱序事件的,处理乱序事件通常用watermark机制结合window来实现。

详细参考:

https://www.jianshu.com/p/1c2542f11da0

16. Flink的table和SQL熟悉吗?Table API和SQL中TableEnvironment这个类有什么作用? 

TableEnvironment是Table API和SQL集成的核心概念。它负责:

A)在内部catalog中注册表

B)注册外部catalog

C)执行SQL查询

D)注册用户定义(标量,表或聚合)函数

E)将DataStream或DataSet转换为表

F)持有对ExecutionEnvironment或StreamExecutionEnvironment的引用 

17.  Flink如何实现SQL解析的呢? 

StreamSQL API的执行原理如下:

1、用户使用对外提供Stream SQL的语法开发业务应用;

2、用calcite对StreamSQL进行语法检验,语法检验通过后,转换成calcite的逻辑树节点;最终形成calcite的逻辑计划;

3、采用Flink自定义的优化规则和calcite火山模型、启发式模型共同对逻辑树进行优化,生成最优的Flink物理计划;

4、对物理计划采用janino codegen生成代码,生成用低阶API DataStream 描述的流应用,提交到Flink平台执行

详细参考:https://cloud.tencent.com/developer/article/1471612

进阶篇

1. Flink是如何做到批处理与流处理统一的?

Flink设计者认为:有限流处理是无限流处理的一种特殊情况,它只不过在某个时间点停止而已。Flink通过一个底层引擎同时支持流处理和批处理。

详细参考:https://cloud.tencent.com/developer/article/1501348

2. Flink中的数据传输模式是怎么样的?

在一个运行的application中,它的tasks在持续交换数据。TaskManager负责做数据传输。

TaskManager的网络组件首先从缓冲buffer中收集records,然后再发送。也就是说,records并不是一个接一个的发送,而是先放入缓冲,然后再以batch的形式发送。这个技术可以高效使用网络资源,并达到高吞吐。类似于网络或磁盘 I/O 协议中使用的缓冲技术。

详细参考:https://www.cnblogs.com/029zz010buct/p/10156836.html

3.  Flink的容错机制

Flink基于分布式快照与可部分重发的数据源实现了容错。用户可自定义对整个Job进行快照的时间间隔,当任务失败时,Flink会将整个Job恢复到最近一次快照,并从数据源重发快照之后的数据。

详细参考:https://www.jianshu.com/p/1fca8fb61f86

4. Flink中的分布式快照机制是怎么样的

Flink容错机制的核心就是持续创建分布式数据流及其状态的一致快照。这些快照在系统遇到故障时,充当可以回退的一致性检查点(checkpoint)。Lightweight Asynchronous Snapshots for Distributed Dataflows 描述了Flink创建快照的机制。此论文是受分布式快照算法 Chandy-Lamport启发,并针对Flink执行模型量身定制。

可以参考:

https://zhuanlan.zhihu.com/p/43536305

https://blog.csdn.net/u014589856/article/details/94346801

5. Flink是如何实现Exactly-once的?

Flink通过状态和两次提交协议来保证了端到端的exactly-once语义。

详细请看:https://www.jianshu.com/p/9d875f6e54f2

6. Flink的Kafka-connector是如何做到向下兼容的呢?

在新的连接器中,Flink提供了一个基础connector模块,它是实现所有connector的核心模块,所有的connector都依赖于基础connector。

Kafka社区也改写了Java clients底层的网络客户端代码,里面会自动地判断连接的broker端所支持client请求的最高版本,并自动创建合乎标准的请求。

详细参考:

https://www.cnblogs.com/Springmoon-venn/p/10690531.html

https://www.cnblogs.com/huxi2b/p/6784795.html

关于flink-kafka-connector的实现参考:

https://www.cnblogs.com/0x12345678/p/10463539.html

7.  Flink中的内存管理是如何做的?

Flink 并不是将大量对象存在堆上,而是将对象都序列化到一个预分配的内存块上,这个内存块叫做 MemorySegment,它代表了一段固定长度的内存(默认大小为 32KB),也是 Flink 中最小的内存分配单元,并且提供了非常高效的读写方法。每条记录都会以序列化的形式存储在一个或多个MemorySegment中。

Flink堆内存划分:

Network Buffers: 一定数量的32KB大小的缓存,主要用于数据的网络传输。在 TaskManager启动的时候就会分配。默认数量是2048个,可以通过 taskmanager.network.numberOfBuffers来配置。

Memory Manager Pool:这是一个由MemoryManager管理的,由众多MemorySegment组成的超大集合。Flink中的算法(如 sort/shuffle/join)会向这个内存池申请MemorySegment,将序列化后的数据存于其中,使用完后释放回内存池。默认情况下,池子占了堆内存的70% 的大小。

Remaining (Free) Heap: 这部分的内存是留给用户代码以及TaskManager 的数据结构使用的,可以把这里看成的新生代。

Flink大量使用堆外内存。

详细参考:

https://www.cnblogs.com/ooffff/p/9508271.html

8.  Flink中的序列化是如何做的?

Flink实现了自己的序列化框架,Flink处理的数据流通常是一种类型,所以可以只保存一份对象Schema信息,节省存储空间。又因为对象类型固定,所以可以通过偏移量存取。

Java支持任意Java或Scala类型,类型信息由TypeInformation类表示,TypeInformation支持以下几种类型:

BasicTypeInfo:任意Java 基本类型或String类型。

BasicArrayTypeInfo:任意Java基本类型数组或String数组。

WritableTypeInfo:任意Hadoop Writable接口的实现类。

TupleTypeInfo:任意的Flink Tuple类型(支持Tuple1 to Tuple25)。Flink tuples 是固定长度固定类型的Java Tuple实现。

CaseClassTypeInfo: 任意的 Scala CaseClass(包括 Scala tuples)。

PojoTypeInfo: 任意的 POJO (Java or Scala),例如,Java对象的所有成员变量,要么是 public 修饰符定义,要么有 getter/setter 方法。

GenericTypeInfo: 任意无法匹配之前几种类型的类。

针对前六种类型数据集,Flink皆可以自动生成对应的TypeSerializer,能非常高效地对数据集进行序列化和反序列化。对于最后一种数据类型,Flink会使用Kryo进行序列化和反序列化。每个TypeInformation中,都包含了serializer,类型会自动通过serializer进行序列化,然后用Java Unsafe接口写入MemorySegments。如下图展示 一个内嵌型的Tuple3<integer,double,person> 对象的序列化过程:

操纵二进制数据:

Flink提供了如group、sort、join等操作,这些操作都需要访问海量数据。以sort为例:首先,Flink会从MemoryManager中申请一批 MemorySegment,用来存放排序的数据。

这些内存会分为两部分,一个区域是用来存放所有对象完整的二进制数据。另一个区域用来存放指向完整二进制数据的指针以及定长的序列化后的key(key+pointer)。将实际的数据和point+key分开存放有两个目的。

第一,交换定长块(key+pointer)更高效,不用交换真实的数据也不用移动其他key和pointer;

第二,这样做是缓存友好的,因为key都是连续存储在内存中的,可以增加cache命中。排序会先比较 key 大小,这样就可以直接用二进制的 key 比较而不需要反序列化出整个对象。访问排序后的数据,可以沿着排好序的key+pointer顺序访问,通过 pointer 找到对应的真实数据。

详细参考:https://www.cnblogs.com/ooffff/p/9508271.html

9. Flink中的RPC框架选型是怎么样的?

对于Flink中各个组件(JobMaster、TaskManager、Dispatcher等),其底层RPC框架基于Akka实现。如果你akka不了解,那么参考:https://www.cnblogs.com/letsfly/p/10853341.html

10. Flink在使用Window时出现数据倾斜,你有什么解决办法?

注意:这里window产生的数据倾斜指的是不同的窗口内积攒的数据量不同,主要是由源头数据的产生速度导致的差异。

核心思路:1.重新设计key 2.在窗口计算前做预聚合

可以参考这个:

https://blog.csdn.net/it_lee_j_h/article/details/88641894

11. Flink SQL在使用Groupby时出现热点数据,如何处理?

对于开源的Flink,可以参考:https://help.aliyun.com/knowledge_detail/68645.html

12.  Flink任务,delay极高,请问你有什么调优策略?

首先要确定问题产生的原因,找到最耗时的点,确定性能瓶颈点。比如任务频繁反压,找到反压点。主要通过:资源调优、作业参数调优。资源调优即是对作业中的Operator的并发数(parallelism)、CPU(core)、堆内存(heap_memory)等参数进行调优。作业参数调优包括:并行度的设置,State的设置,checkpoint的设置。

13.  Flink是如何处理反压的?和Spark有什么区别?Storm呢?

参考:https://yq.aliyun.com/articles/64821

14. Operator Chains(算子链)这个概念你了解吗?Flink是如何优化的?什么情况下Operator才会chain在一起?

为了更高效地分布式执行,Flink会尽可能地将operator的subtask链接(chain)在一起形成task。每个task在一个线程中执行。将operators链接成task是非常有效的优化:它能减少线程之间的切换,减少消息的序列化/反序列化,减少数据在缓冲区的交换,减少了延迟的同时提高整体的吞吐量。

两个operator chain在一起的的条件:

  • 上下游的并行度一致

  • 下游节点的入度为1 (也就是说下游节点没有来自其他节点的输入)

  • 上下游节点都在同一个 slot group 中(下面会解释 slot group)

  • 下游节点的 chain 策略为 ALWAYS(可以与上下游链接,map、flatmap、filter等默认是ALWAYS)

  • 上游节点的 chain 策略为 ALWAYS 或 HEAD(只能与下游链接,不能与上游链接,Source默认是HEAD)

  • 两个节点间数据分区方式是 forward(参考理解数据流的分区)

  • 用户没有禁用 chain

关于源码篇: 建议去读源码找答案,如果没读过源码,答案没有意义。

推荐一个源码解析电子书。

链接: https://pan.baidu.com/s/1L5KOhly6AvJb6nWBXeHYjQ 

提取码: mwa5 

或者长按扫描二维码下载:

欢迎点赞+收藏

欢迎转发至朋友圈

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章