Flink项目实战(二)---原理及调优

1、checkPoint

 (1.1)Flink 中的每个方法或算子都是有状态的。 状态化的方法在处理元素/事件的时候存储数据,使得状态成为使各个类型的算子重要部分。 Flink 通过为状态添加 checkpoint(检查点),使状态具备容错能力。

 (1.2)Flink的CheckPoint机制可以与Stream和State持久化存储交互的前提条件:

     1)持久化的Source,支持在一定时间内重放事件,这种Source的典型例子就是持久化的消息队列(如Kafka、RabbitMQ等)或文件系统(如HDFS、S3等)。

     2)用于State的持久化存储介质,比如分布式文件系统(如HDFS、S3等)。

 (1.3)Checkpoint 使得 Flink 能够恢复状态和在流中的位置,从而向应用提供和无故障执行时一样的语义。

 (1.4)可部分重发的数据源

  Flink选择最近完成的checkPoint,系统重放整个分布式数据流,然后给予每个operator在检查点快照中的状态。数据源被设置为从开始位置重新读取流。eg:在Kafka中,消费者从偏移量开始重新消费。

(1.5)CheckPoint检查点机制

  checkpoint定期触发产生快照,快照中的内容如下:

  • 当前checkPoint开始时数据源(例如Kafka)中消息的offset;
  • 记录了所有有状态的operator当前的状态信息(中间计算结果)。

 2、barrier

  Flink 使用 Chandy-Lamport algorithm 算法的一种变体,即异步 barrier 快照(asynchronous barrier snapshotting)。当 checkpoint coordinator(job manager 的一部分)指示 task manager 开始 checkpoint 时,它会让所有 sources 记录它们的偏移量,并将编号的 checkpoint barriers 插入到它们的流中。这些 barriers 流经 job graph,标注每个 checkpoint 前后的流部分。

  这些barrier被注入数据流并与记录一起作为数据流的一部分向下流动。 barriers永远不会超过记录,数据流严格有序,barrier将数据流中的记录隔离成一系列的记录集合,并将一些集合中的数据加入到当前的快照中,而另一些数据加入到下一个快照中。

  checkpoint n将包含每个 operator 的 state,这些 state 对应的 operator 消费了严格在 checkpoint barrier n 之前的所有事件,并且不包含在此(checkpoint barrier n)后的任何事件后而生成的状态。

  每个barrier都带有快照的ID,并且barrier之前的记录都进入了该快照。 barriers不会中断流处理,非常轻量级。 来自不同快照的多个barrier可以同时在流中出现,即多个快照可能并发地发生。

   单流的barrier

 

   barrier在数据流源处被注入并行数据流中。快照n的barriers被插入的位置(sn)是快照所包含的数据在数据源中最大位置。例如,在Kafka中,此位置将是分区中最后一条记录的偏移量。 将该位置Sn报告给checkpoint协调器(Flink的JobManager)。

  然后barriers向下游流动。当一个中间操作算子从其所有输入流中收到快照n的barriers时,它会为快照n发出barriers进入其所有输出流中。 一旦sink操作算子(流式DAG的末端)从其所有输入流接收到barriers n,它就向checkpoint协调器确认快照n完成。在所有sink确认快照后,意味着快照着已完成。一旦完成快照n,job将永远不再向数据源请求sn之前的记录,因为此时这些记录(及其后续记录)将已经通过整个数据流拓扑,也即是已经被处理结束。

  当 job graph 中的每个 operator 接收到 barriers 时,它就会记录下其状态。拥有两个输入流的 Operators(例如 CoProcessFunction)会执行 barrier 对齐(barrier alignment) 以便当前快照能够包含消费两个输入流 barrier 之前(但不超过)的所有 events 而产生的状态。

  Flink 的 state backends 利用写时复制(copy-on-write)机制允许当异步生成旧版本的状态快照时,能够不受影响地继续流处理。只有当快照被持久保存后,这些旧版本的状态才会被当做垃圾回收。

   多流的barrier:

  

接收多个输入流的运算符需要基于快照barriers上对齐(align)输入流。 上图说明了这一点:

  • 一旦操作算子从一个输入流接收到快照barriers n,它就不能处理来自该流的任何记录,直到它从其他输入接收到barriers n为止。 否则,它会搞混属于快照n的记录和属于快照n + 1的记录。
  • barriers n所属的流暂时会被搁置。 从这些流接收的记录不会被处理,而是放入输入缓冲区。可以看到1,2,3会一直放在Input buffer,直到另一个输入流的快照到达Operator。
  • 一旦从最后一个流接收到barriers n,操作算子就会发出所有挂起的向后传送的记录,然后自己发出快照n的barriers。
  • 之后,它恢复处理来自所有输入流的记录,在处理来自流的记录之前优先处理来自输入缓冲区的记录。

3、state

  (3.1)state一般指一个具体的task/operator的状态。Flink中包含两种基础的状态:Keyed StateOperator State

  Keyed State,就是基于KeyedStream上的状态。这个状态是跟特定的key绑定的,对KeyedStream流上的每一个key,可能都对应一个state。

  Operator State与Keyed State不同,Operator State跟一个特定operator的一个并发实例绑定,整个operator只对应一个state。相比较而言,在一个operator上,可能会有很多个key,从而对应多个keyed state。eg:Flink中的Kafka Connector,就使用了operator state。它会在每个connector实例中,保存该实例中消费topic的所有(partition, offset)映射。

   Keyed State和Operator State,可以以两种形式存在:原始状态和托管状态(Raw and Managed State)。托管状态是由Flink框架管理的状态,如ValueState, ListState, MapState等。而raw state即原始状态,由用户自行管理状态具体的数据结构,框架在做checkpoint的时候,使用byte[]来读写状态内容。通常在DataStream上的状态推荐使用托管的状态,当实现一个用户自定义的operator时,会使用到原始状态。

   (3.2)State-Keyed State

  基于key/value的状态接口,这些状态只能用于keyedStream之上。keyedStream上的operator操作可以包含window或者map等算子操作。这个状态是跟特定的key绑定的,对KeyedStream流上的每一个key,都对应一个state。

  key/value下可用的状态接口:

  ValueState: 状态保存的是一个值,可以通过update()来更新,value()获取。
  ListState: 状态保存的是一个列表,通过add()添加数据,通过get()方法返回一个Iterable来遍历状态值。
  ReducingState: 这种状态通过用户传入的reduceFunction,每次调用add方法添加值的时候,会调用reduceFunction,最后合并到一个单一的状态值。
  MapState:即状态值为一个map。用户通过put或putAll方法添加元素。

  (3.3)state backend 

  由 Flink 管理的 keyed state 是一种分片的键/值存储,每个 keyed state 的工作副本都保存在负责该键的 taskmanager 本地中。另外,Operator state 也保存在机器节点本地。Flink 定期获取所有状态的快照,并将这些快照复制到持久化的位置,例如分布式文件系统。

  如果发生故障,Flink 可以恢复应用程序的完整状态并继续处理,就如同没有出现过异常。

  Flink 管理的状态存储在 state backend 中。Flink 有两种 state backend 的实现 – 一种基于 RocksDB 内嵌 key/value 存储将其工作状态保存在磁盘上的,另一种基于堆的 state backend,将其工作状态保存在 Java 的堆内存中。这种基于堆的 state backend 有两种类型:FsStateBackend,将其状态快照持久化到分布式文件系统;MemoryStateBackend,它使用 JobManager 的堆保存状态快照。

名称Working State状态备份快照
RocksDBStateBackend 本地磁盘(tmp dir) 分布式文件系统 全量 / 增量
  • 支持大于内存大小的状态
  • 经验法则:比基于堆的后端慢10倍
FsStateBackend JVM Heap 分布式文件系统 全量
  • 快速,需要大的堆内存
  • 受限制于 GC
MemoryStateBackend JVM Heap JobManager JVM Heap 全量
  • 适用于小状态(本地)的测试和实验

  当使用基于堆的 state backend 保存状态时,访问和更新涉及在堆上读写对象。但是对于保存在 RocksDBStateBackend 中的对象,访问和更新涉及序列化和反序列化,所以会有更大的开销。但 RocksDB 的状态量仅受本地磁盘大小的限制。还要注意,只有 RocksDBStateBackend 能够进行增量快照,这对于具有大量变化缓慢状态的应用程序来说是大有裨益的。

  所有这些 state backends 都能够异步执行快照,这意味着它们可以在不妨碍正在进行的流处理的情况下执行快照。

4、CheckPoint参数说明及调优

     Checkpoint 属性包括:

  • 精确一次(exactly-once)对比至少一次(exactly-once):可以选择向 enableCheckpointing(long interval, CheckpointingMode mode) 方法中传入一个模式。 对于大多数应用来说,exactly-once是较好的选择。exactly-once可能与某些延迟超低(始终只有几毫秒)的应用的关联较大。

  • checkpoint 超时:如果 checkpoint 执行的时间超过了该配置的阈值,还在进行中的 checkpoint 操作就会被抛弃。

  • checkpoints 之间的最小时间:该属性定义在 checkpoint 之间需要多久的时间,以确保流应用在 checkpoint 之间有足够的进展。如果值设置为了 5000, 无论 checkpoint 持续时间与间隔是多久,在前一个 checkpoint 完成时的至少五秒后会才开始下一个 checkpoint。

    往往使用“checkpoints 之间的最小时间”来配置应用会比 checkpoint 间隔容易很多,因为“checkpoints 之间的最小时间”在 checkpoint 的执行时间超过平均值时不会受到影响(例如如果目标的存储系统忽然变得很慢)。

    注意这个值也意味着并发 checkpoint 的数目是一。

  • 并发 checkpoint 的数目: 默认情况下,在上一个 checkpoint 未完成(失败或者成功)的情况下,系统不会触发另一个 checkpoint。确保了拓扑不会在 checkpoint 上花费太多时间,从而影响正常的处理流程。 不过允许多个 checkpoint 并行进行是可行的,对于有确定的处理延迟(例如某方法所调用比较耗时的外部服务),但是仍然想进行频繁的 checkpoint 去最小化故障后重跑的 pipelines 来说,是有意义的。

    注:该选项不能和 “checkpoints 间的最小时间”同时使用。

  • externalized checkpoints: 配置周期存储 checkpoint 到外部系统中。Externalized checkpoints 将他们的元数据写到持久化存储上并且在 job 失败的时候不会被自动删除。 这种方式下,如果 job 失败,将会有一个现有的 checkpoint 去恢复。

    上面调用enableExternalizedCheckpoints设置为ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION,表示一旦Flink处理程序被cancel后,会保留Checkpoint数据,以便根据实际需要恢复到指定的Checkpoint处理。
  ExternalizedCheckpointCleanup 可选项如下:

    • ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION: 取消作业时保留检查点。请注意,在这种情况下,您必须在取消后手动清理检查点状态。
    • ExternalizedCheckpointCleanup.DELETE_ON_CANCELLATION: 取消作业时删除检查点。只有在作业失败时,检查点状态才可用。
  • 在 checkpoint 出错时使 task 失败或者继续进行 task:他决定了在 task checkpoint 的过程中发生错误时,是否使 task 也失败,使失败是默认的行为。 或者禁用它时,这个任务将会简单的把 checkpoint 错误信息报告给 checkpoint coordinator 并继续运行。

  • 优先从 checkpoint 恢复(prefer checkpoint for recovery):该属性确定 job 是否在最新的 checkpoint 回退,即使有更近的 savepoint 可用,这可以减少恢复时间(checkpoint 恢复比 savepoint 恢复更快)。

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

// 每 1000ms 开始一次 checkpoint
env.enableCheckpointing(1000);

// 高级选项:

// 设置模式为精确一次 (这是默认值)
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);

// 确认 checkpoints 之间的时间会进行 500 ms
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500);

// Checkpoint 必须在一分钟内完成,否则就会被抛弃
env.getCheckpointConfig().setCheckpointTimeout(60000);

// 同一时间只允许一个 checkpoint 进行
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);

// 开启在 job 中止后仍然保留的 externalized checkpoints
env.getCheckpointConfig().enableExternalizedCheckpoints(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);

  默认情况下,如果设置了Checkpoint选项,则Flink只保留最近成功生成的1个Checkpoint,而当Flink程序失败时,可以从最近的这个Checkpoint来进行恢复。如果希望保留多个Checkpoint,并能够根据实际需要选择其中一个进行恢复,这样会更加灵活,比如,发现最近4个小时数据记录处理有问题,希望将整个状态还原到4小时之前。
  Flink可以支持保留多个Checkpoint,需要在Flink的配置文件conf/flink-conf.yaml中,添加如下配置,指定最多需要保存Checkpoint的个数:

state.checkpoints.num-retained: 20

  Flink checkpoint目录分别对应的是 jobId,flink提供了在启动之时通过设置 -参数指定checkpoint目录, 让新的jobId 读取该checkpoint元文件信息和状态信息,从而达到指定时间节点启动job。

5、Savepoint

  Savepoint 是依据 Flink checkpointing 机制所创建的流作业执行状态的一致镜像。 你可以使用 Savepoint 进行 Flink 作业的停止与重启、fork 或者更新。 Savepoint 由两部分组成:稳定存储(列入 HDFS,S3,…) 上包含二进制文件的目录(通常很大),和元数据文件(相对较小)。 稳定存储上的文件表示作业执行状态的数据镜像。 Savepoint 的元数据文件以(绝对路径)的形式包含(主要)指向作为 Savepoint 一部分的稳定存储上的所有文件的指针。

  从概念上讲, Flink 的 Savepoint 与 Checkpoint 的不同之处类似于传统数据库中的备份与恢复日志之间的差异。 Checkpoint 的主要目的是为意外失败的作业提供恢复机制。 Checkpoint 的生命周期由 Flink 管理,即 Flink 创建,管理和删除 Checkpoint - 无需用户交互。 作为一种恢复和定期触发的方法,Checkpoint 实现有两个设计目标:

  i)轻量级创建

    ii)尽可能快地恢复。

  可能会利用某些特定的属性来达到这个,例如, 工作代码在执行尝试之间不会改变。 在用户终止作业后,通常会删除 Checkpoint(除非明确配置为保留的 Checkpoint)。

  与此相反、Savepoint 由用户创建,拥有和删除。 他们的用例是计划的,手动备份和恢复。 例如,升级 Flink 版本,调整用户逻辑,改变并行度等。 当然,Savepoint 必须在作业停止后继续存在。 从概念上讲,Savepoint 的生成,恢复成本可能更高一些,Savepoint 更多地关注可移植性和对前面提到的作业更改的支持。

  除去这些概念上的差异,Checkpoint 和 Savepoint 的当前实现基本上使用相同的代码并生成相同的格式。然而,目前有一个例外,是使用 RocksDB 状态后端的增量 Checkpoint。他们使用了一些 RocksDB 内部格式,而不是 Flink 的本机 Savepoint 格式。这使他们与 Savepoint 相比,是更轻量级的 Checkpoint 机制的一个实例。

(2)通过手动指定算子Id的方式,从 Savepoint 自动恢复算子

DataStream<String> stream = env.
  // Stateful source (e.g. Kafka) with ID
  .addSource(new StatefulSource())
  .uid("source-id") // ID for the source operator
  .shuffle()
  // Stateful mapper with ID
  .map(new StatefulMapper())
  .uid("mapper-id") // ID for the mapper
  // Stateless printing sink
  .print(); // Auto-generated ID

如果不手动指定 ID ,则会自动生成 ID 。只要这些 ID 不变,就可以从 Savepoint 自动恢复。生成的 ID 取决于程序的结构,并且对程序更改很敏感。因此,强烈建议手动分配这些 ID 。

6、Watermark(水位线)

(0)时间机制

  Event Time:事件产生的时间,它通常由事件中的时间戳描述;

  

  Ingestion Time:事件进入Flink的时间;

  

  Processing Time:事件被处理时当前系统的时间;

  

(1)概述

  在使用EventTime处理Stream数据的时候会遇到数据乱序的问题,流处理从Event(事件)产生,流经Source,再到Operator,这中间需要一定的时间。虽然大部分情况下,传输到Operator的数据都是按照事件产生的时间顺序来的,但是也不排除由于网络延迟等原因而导致乱序的产生,特别是使用Kafka的时候,多个分区之间的数据无法保证有序。因此,在进行Window计算的时候,不能无限期地等下去,必须要有个机制来保证在特定的时间后,必须触发Window进行计算,这个特别的机制就是Watermark。Watermark是用于处理乱序事件的。

  简单来说 Watermark 是一个时间戳,表示已经收集完毕的数据的最大 event time,即 event time 小于 Watermark 的数据不应该再出现,基于这个前提我们才有可能将 event time 窗口视为完整并输出结果。Watermark 设计的初衷是处理 event time 和 processing time 之间的延迟问题。

(2)原理

 (2.1)waterMark有三种应用场景

  有序的Stream中的Watermark(理想情况下,window中的最大时间戳作为水位线watermark)

  

  无序的Stream中的Watermark(乱序情况下,需要根据实际情况定义watermark的计算方法,当前window中排除掉早于watermark的数据和过于延迟的数据)

  

  多并行度Stream中的Watermark

  

  注意:在多并行度的情况下,Watermark会有一个对齐机制,这个对齐机制会取所有Channel中最小的Watermark,图中的14和29这两个Watermark的最终取值为14。

(2.2)Watermark 的产生方式

  目前Apache Flink 有两种生产 Watermark 的方式,如下:

  • 标点水位线(Punctuated Watermark)- 数据流中每一个递增的 EventTime 都会产生一个 Watermark(即每接收到一条数据,判断事件时间,若大于上一次的时间戳,则更新水印为当前点的事件时间)。

        注意:在实际的生产中 Punctuated 方式在 TPS 很高的场景下会产生大量的 Watermark 在一定程度上对下游算子造成压力,所以只有在实时性要求非常高的场景才会选择 Punctuated 的方式进行 Watermark 的生成。

  • 定期水位线(Periodic Watermark)- 周期性的(一定时间间隔或者达到一定的记录条数)产生一个 Watermark。

    注意:在实际的生产中 Periodic 的方式必须结合时间积累条数两个维度继续周期性产生 Watermark,否则在极端情况下会有很大的延时。

  所以 Watermark 的生成方式需要根据业务场景的不同进行不同的选择。

(2.3)watermark和window处理乱序问题

  1)水位线(WaterMark)是一个时间戳,等于当前到达的消息最大时间戳减去配置的延迟时间,水位线是单调递增的,如果有晚到达的早消息并不会更新水位线,因为消息最大时间戳没变

    水位线 = 当前收集到的消息集的最大时间戳 - 配置的延迟时间;

  新消息到达时,会计算新的水位线,如果水位线大于等于窗口的endTime(左闭右开)则触发窗口计算,反之继续接收后续消息;消息的EventTime大于等于窗口beginTime则保留,反之被丢弃

    水位线 >= 窗口的endTime,则关闭窗口,对当前收集到这批数据进行计算;

   与window一起使用,可以对乱序到达的消息排序后再处理;

  2) 引入水位线机制的目的是根据实际需要,最大化保留有效数据的同时不因为部分数据的过分延迟而造成性能问题;

  3)window关闭窗口,触发计算的时间可通过代码配置(即最后判断是最后一条数据的时间上线)。

  总结:解决乱序问题,首先想到的是排序,但是对于一个无界数据数据流无法进行排序,由此引入窗口的概念,将有界数据流切分为一个个有界的窗口,在窗口内便于执行排序操作。flink的window机制通过将无界数据流划分成有界数据流的方式,利用watermark机制排除掉过早到达,或者过于延迟的数据,对符合条件的window中的有界数据进行排序后执行其他操作。

 备注:flink处理延迟事件

  1). 重新激活已经关闭的窗口并重新计算以修正结果。

  2.) 将迟到事件收集起来处理(收集在侧流中)。

  3.) 将迟到事件视为错误消息并丢弃。

  flink官方文档watermark部分链接 https://ci.apache.org/projects/flink/flink-docs-release-1.12/zh/dev/event_timestamps_watermarks.html

7、配置调优

   1)、并行度设置

     并行度控制任务的数量,影响操作后数据被切分成的块数。调整并行度让任务的数量和每个任务处理的数据与机器的处理能力达到最优。查看CPU使用情况和内存占用情况,当任务和数据不是平均分布在各节点,而是集中在个别节点时,可以增大并行度使任务和数据更均匀的分布在各个节点。增加任务的并行度,充分利用集群机器的计算能力,一般并行度设置为集群CPU核数总和的2-3倍。

  任务的并行度可以通过以下四种层次(按优先级从高到低排列)指定,用户可以根据实际的内存、CPU、数据以及应用程序逻辑的情况调整并行度参数。

算子层次

  一个算子、数据源和sink的并行度可以通过调用setParallelism()方法来指定,例如

final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
DataStream<String> text = [...]
DataStream<Tuple2<String, Integer>> wordCounts = text
.flatMap(new LineSplitter())
.keyBy(0)
.timeWindow(Time.seconds(5))
.sum(1).setParallelism(5);
wordCounts.print();
env.execute("Word Count Example");

执行环境层次

  Flink程序运行在执行环境中。执行环境为所有执行的算子、数据源、data sink定义了一个默认的并行度。

执行环境的默认并行度可以通过调用setParallelism()方法指定。例如:

final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(3);
DataStream<String> text = [...]
DataStream<Tuple2<String, Integer>> wordCounts = [...]
wordCounts.print();
env.execute("Word Count Example");

客户端层次

  并行度可以在客户端将job提交到Flink时设定。对于CLI客户端,可以通过“-p”参数指定并行度。例如:./bin/flink run -p 10 ../examples/WordCount-java.jar

系统层次

  在系统级可以通过修改Flink客户端conf目录下的“flink-conf.yaml”文件中的“parallelism.default”配置选项来指定所有执行环境的默认并行度。

8、flink高可用

  默认情况下,每个Flink集群只有一个JobManager,如果这个JobManager挂了,则不能提交新的任务,并且运行中的程序也会失败,从而导致单点故障。使用JobManager HA,集群可以从JobManager故障中恢复,从而避免单点故障。用户可以在Standalone或Flink on Yarn集群模式下配置Flink集群HA(高可用性)。

  Standalone模式下,JobManager的高可用性的基本思想是,任何时候都有一个MasterJobManager和多个Standby JobManager。Standby JobManager可以在Master JobManager挂掉的情况下接管集群成为Master JobManager,这样避免了单点故障,一旦某一个StandbyJobManager接管集群,程序就可以继续运行。Standby JobManagers和Master JobManager实例之间没有明确区别,每个JobManager都可以成为Master或Standby。(Flink on Yarn的高可用性其实主要利用YARN的任务恢复机制实现)。

(2)集群规划

  使用5台机器实现,其中3台Master节点(JobManager)和2台Slave节点(TaskManager)。实现HA还需要依赖ZooKeeper和HDFS,因此要有一个ZooKeeper集群和Hadoop集群。ZooKeeper和JobManager部署在相同的机器上(由于本地虚拟机个数有限,因此需要共用机器,生产环境中Zookeeper考虑单独部署在独立的服务器上)。Hadoop集群也和JobManager部署在相同的机器上,集群节点进程信息如图所示。

JobManager节点:hadoop100、hadoop101、hadoop102(一主两从,通过zookeeper实现分布式协调服务);

TaskManager节点:hadoop103、hadoop104;

ZooKeeper节点:hadoop100、hadoop101、hadoop102;

Hadoop节点:hadoop100、hadoop101、hadoop102;

 

                     集群节点进程信息

JobManager:Flink主节点的进程名称。

TaskManager:Flink从节点的进程名称。

NameNode:Hadoop中HDFS的主节点进程名称。

DataNode:Hadoop中HDFS的从节点进程名称。

SecondaryNameNode:Hadoop中HDFS的辅助节点名称。

ResourceManager:Hadoop中YARN的主节点进程名称。

NodeManager:Hadoop中YARN的从节点进程名称。

ZooKeeper:代表ZooKeeper服务的进程。

 

 

flink官方文档:https://ci.apache.org/projects/flink/flink-docs-release-1.12/zh/concepts/index.html

借鉴了不少文章,感谢各路大神分享,如需转载请注明出处,谢谢:https://www.cnblogs.com/huyangshu-fs/p/15062396.html

posted on 2021-08-29 19:42  ys-fullStack  阅读(1583)  评论(0编辑  收藏  举报