一篇大数据调优

一、flink

flink官网

1、Flink 是如何支持批流一体的?

Flink 通过一个底层引擎同时支持流处理和批处理.

1.检查点机制和状态机制:用于实现容错、有状态的处理; 2.水印机制:用于实现事件时钟; 3.窗口和触发器:用于限制计算范围,并定义呈现结果的时间

同一个流处理引擎之上,Flink 还存在另一套机制,用于实现高效的批处理。 1.用于调度和恢复的回溯法:由 Microsoft Dryad 引入,现在几乎用于所有批处理器; 2.用于散列和排序的特殊内存数据结构:可以在需要时,将一部分数据从内存溢出到硬盘上; 3.优化器:尽可能地缩短生成结果的时间。

2、Flink 是如何做容错的?

Flink 实现容错主要靠强大的 CheckPoint 机制和 State 机制。

3、Flink 分布式快照的原理是什么?

1.barrier 机制

4、Flink 是如何保证 Exactly-once 语义的?

1.barrier 机制

  1. 通过 Flink 的 TwoPhaseCommitSinkFunction 两阶段提交协议能支持端到端(KafkaSource, KafkaSink)的 Exactly-Once 语义。

5、Flink 中的 Window 出现了数据倾斜,你有什么解决办法?

1、进入窗口进行预聚合

2、重新设计窗口聚合的key

6、Flink 中在使用聚合函数 GroupBy、Distinct、KeyBy 等函数时出现数据热点该如何解决?

(1)在业务上规避这类问题

2、热key拆分

3、(3)参数设置 Flink 1.9.0 SQL(Blink Planner) 性能优化中一项重要的改进就是升级了微批模型,即 MiniBatch。原理是缓存一定的数据后再触发处理,以减少对 State 的访问,从而提升吞吐和 减少数据的输出量。

7、Flink 任务延迟高,想解决这个问题,你会如何入手?

1、后台看哪个task出现了反压

2、手段是资源调优和算子调优。 资源调优即是对作业中的 Operator 的并发数(parallelism)、CPU(core)、堆内存 (heap_memory)等参数进行调优。 作业参数调优包括:并行度的设置,State 的设置,checkpoint 的设置。

8、Flink 什么情况下才会把 Operator chain 在一起形成算子链?

两个 operator chain 在一起的的条件: (1)上下游的并行度一致 (2)下游节点的入度为 1 (也就是说下游节点没有来自其他节点的输入) (3)上下游节点都在同一个 slot group 中(下面会解释 slot group) (4)下游节点的 chain 策略为 ALWAYS(可以与上下游链接,map、flatmap、filter 等默 认是 ALWAYS) (5)上游节点的 chain 策略为 ALWAYS 或 HEAD(只能与下游链接,不能与上游链接, Source 默认是 HEAD) (6)两个节点间数据分区方式是 forward(参考理解数据流的分区) (7)用户没有禁用 chain

9、说说 Flink 资源管理中 Task Slot 的概念?

最多能同时·执行的task数量,但是只做了内存隔离,没有做cpu隔离。

10、说说 Flink1.9 的新特性?

参考答案: 1.支持 hive 读写,支持 UDF 2.Flink SQL TopN 和 GroupBy 等优化 3.Checkpoint 与 savepoint 针对实际业务场景做了优化 4.支持 Flink state 查询

11、Flink 的反压和 Storm 有哪些不同?

参考答案: Storm 是通过监控 Bolt 中的接收队列负载情况,如果超过高水位值就会将反压信息写到 Zookeeper ,Zookeeper 上的 watch 会通知该拓扑的所有 Worker 都进入反压状态,最后 Spout 停止发送 tuple。 Flink 中的反压使用了高效有界的分布式阻塞队列,下游消费变慢会导致发送端阻塞。二者最大的区别是 Flink 是逐级反压,而 Storm 是直接从源头降速

12、Flink 中水印是什么概念,起到什么作用?

延迟数据

13、flink的时间

event、摄入、处理

14、说说 Flink 中的状态存储?

memory、hdfs、rockDB

15、重启策略

固定延迟、故障率、没有

16、Flink 面对数据高峰期时如何处理?

kafka小峰后flink处理

17、和spark的checkpoint的区别

spark 仅对drive的恢复做了数据和元数据快照,而flink是对每个算子和流动中的数据做快照

18、海量数据去重

etl-》redis去重-》flink\spark处理

1、bloom过滤器 +redis的bit操作

  class MyBloomFilter(lengthBits:Long) extends Serializable{
​
    /**
     * 根据车牌,计算布隆过滤器(二进制向量)中对应的下标
     * 由于从用两个哈希函数(提高去重准确率)
     * @param car
     * @return
     */
    def getOffsets(car:String):Array[Long] = {
      var result  = new Array[Long](2)
      //调用谷歌的函数算法
      var hashcode1 = googleHash(car)
      if(hashcode1<0){
        hashcode1 = ~ hashcode1 //防止哈希值为负数
      }
      var bit1 =  hashcode1 % lengthBits
      result(0) = bit1
​
      //调用JDK的哈希算法
      var hashcode2 = car.hashCode()
      if(hashcode2<0){
        hashcode2 = ~ hashcode2 //防止哈希值为负数
      }
      result(1) = hashcode2 % lengthBits
      result
    }
​
    /**
     * 调用谷歌的哈希算法得到一个哈希值
     * @param car
     * @return
     */
    def googleHash(car:String):Long ={
      Hashing.murmur3_128(1).hashString(car,Charset.forName("UTF-8")).asLong()
    }
​
  }
  
  
  
  for(offset<-offsets){
  //有了下标需要位图计算,采用redis帮助我们做位图计算
  //如果返回true,当前车辆可能是重复的,如果是false当前车辆肯定不重复
  val isContain: lang.Boolean = jedis.getbit(mapKey,offset)
  if(!isContain){
  repeated =false
  loop.break()
  }
  }

或者直接离线

19、parllelism和slot的关系

parllism是任务实际并发,slot 是task manger拥有的并发

20、State Backends

Flink State 最佳实践

1、根据数据结构分类

valuestate:单值,与key绑定
liststate:状态值为一个list
reducingstate:
foldingstate:
mapstate:状态值为一个map

2、托管类型

原生状态:自己管理状态,需要自己写snapshot和restore方法。

托管:flink来给做快照和恢复。

1)operator state:支持list、union list、broadcast

自己管理状态。

存储在内存。

获取方式:1、实现CheckpointedFunction接口 2、实现ListCheckPointed

和算子实例绑定,算子并行度发生变化自动重新分配。

建议:

1、慎用,因为内部会用long存储offset,存的多的话,checkpoint 时会使jm的oom。

2、union state:在失败后恢复,所有算子都会有原先所以operator的全量的state状态

2)keystate:支持valuestate、liststate、reducingstate、foldingstate、mapstate,支持过期

托管

存储在内存或者rocksdb。

和operator和key绑定,通过function获取。

建议:

1、state.clear() 只会清理当前key的的value,要清空整个state,可以用applyToAllKeys ()方法。

2、rocksdb对于大value,上限是2^31 bytes,可以考虑用mapstate代替liststate和valuestate,因为RocksDB 的 map state 并不是将整个 map 作为 value 进行存储,而是将 map 中的一个条目作为键值对进行存储。

3、监控rockdb的wirte buffer、compactions、flush等metric

3、存储分类

1、memory

:state 存储在jobmanger的内存,checkpoint的时候也存储在内存。元数据存储在文件里。

2、file state

state存储在内存,checkpoint的时候存储在文件。

适用情况:大状态,长窗口、failover的时候恢复、要注意state在没checkpoint时,数据还是在内存,所以不能大于taskmanger内存。

缺点:不支持增量checkpoint

3、rockesdb

state:存储在本地文件系统

checkpoint的时候存到远端文件系统

好处:gc少,支持增量checkpoint、支持超大状态

坏处:受限于磁盘大小,更新和获取状态需求序列化和序列化,比内存性能烧地,

4、大状态的一些优化

1、flink-conf.yaml中配置state.backend.rocksdb.localdir 多个磁盘,分担单个磁盘压力,因为flink会随机选择要使用的目录。

2、修改源码去自定义磁盘轮询。Flink 1.8.1 版本,使用random来获取任务使用的磁盘,导致有的磁盘会分配多个任务,把磁盘占满,可以修改为roundrobin。

roundrobin不能解决jvm有taskmanger,可以利用zookeeper来获取唯一id来分散磁盘压力,然后做成配置项在flink-conf.yaml里配置。目前flink.11版本还是random策略。

5、Flink 状态生存时间(State TTL)机制的底层实现

6、广播流去广播配置文件等

零、Flink 使用 broadcast 实现维表或配置的实时更新

广播会将信息一直存储在内存,要注意大小和定制删除策略,或者使用keyby 来让每个节点只存储key对应的维度信息

一、keyBy的广播流使用

KeyedBroadcastProcessFunction

public class TwoStreamJoinWithBroadcastStream {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(3);
 
        FlinkKafkaConsumer010<String> dwdConsumer = new FlinkKafkaConsumer010<>("dwd", new SimpleStringSchema(), KafkaUtils.comsumerProps());
        FlinkKafkaConsumer010<String> dimConsumer = new FlinkKafkaConsumer010<>("dim", new SimpleStringSchema(), KafkaUtils.comsumerProps());
 
        DataStreamSource<String> dwdStream = env.addSource(dwdConsumer);
        DataStreamSource<String> dimStream = env.addSource(dimConsumer);
 
        // 声明一个MapStateDescriptor,维度表作为广播state
        MapStateDescriptor<String, String> dimState = new MapStateDescriptor<>("dimState", BasicTypeInfo.STRING_TYPE_INFO, BasicTypeInfo.STRING_TYPE_INFO);
        BroadcastStream<String> broadcastStream = dimStream.broadcast(dimState);
 
        KeyedStream<String, String> vodKeyStream = dwdStream.keyBy(new KeySelector<String, String>() {
            @Override
            public String getKey(String line) throws Exception {
                JSONObject jn = JSON.parseObject(line);
                return jn.get("vodid").toString();
            }
        });
 
        SingleOutputStreamOperator<String> output = vodKeyStream
                .connect(broadcastStream)
                .process(new KeyedBroadcastProcessFunction<String, String, String, String>() {
                    /*
                    处理数据流数据
                     */
                    @Override
                    public void processElement(String line, ReadOnlyContext context, Collector<String> collector) throws Exception {
                        // 通过MapStateDescriptor获取BroadcastState
                        ReadOnlyBroadcastState<String, String> state = context.getBroadcastState(dimState);
 
                        JSONObject jn = JSON.parseObject(line);
                        String vodid = jn.get("vodid").toString();
                        String userid = jn.get("userid").toString();
                        String time = jn.get("time").toString();
 
                        if (state.contains(vodid)) {
                            String vodInfo = state.get(vodid);
                            String[] infos = vodInfo.split(",");
                            String vodName = infos[1];
                            String vodTag = infos[2];
                            String vodActor = infos[3];
 
                            StringJoiner joiner = new StringJoiner(",");
 
                            joiner.add(time)
                                    .add(userid)
                                    .add(vodid)
                                    .add(vodName)
                                    .add(vodTag)
                                    .add(vodActor);
 
                            collector.collect(joiner.toString());
                        }
                    }
 
                    /*
                    处理广播流数据
                     */
                    @Override
                    public void processBroadcastElement(String s, Context context, Collector<String> collector) throws Exception {
                        // 通过MapStateDescriptor获取BroadcastState
                        BroadcastState<String, String> state = context.getBroadcastState(dimState);
                        String key = s.split(",")[0];
                        if (!state.contains(key)) {
                            System.out.println("新的vod加入" + key);
                            state.put(key, s);
                        }
                    }
                });
 
        output.print();
        env.execute();
    }
}

二、非keyby的广播流使用

public class TwoStreamJoinWithBroadcastStream {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(3);
 
        FlinkKafkaConsumer010<String> dwdConsumer = new FlinkKafkaConsumer010<>("dwd", new SimpleStringSchema(), KafkaUtils.comsumerProps());
        FlinkKafkaConsumer010<String> dimConsumer = new FlinkKafkaConsumer010<>("dim", new SimpleStringSchema(), KafkaUtils.comsumerProps());
 
        DataStreamSource<String> dwdStream = env.addSource(dwdConsumer);
        DataStreamSource<String> dimStream = env.addSource(dimConsumer);
 
        // 声明一个MapStateDescriptor,维度表作为广播state
        MapStateDescriptor<String, String> dimState = new MapStateDescriptor<>("dimState", BasicTypeInfo.STRING_TYPE_INFO, BasicTypeInfo.STRING_TYPE_INFO);
        BroadcastStream<String> broadcastStream = dimStream.broadcast(dimState);
 
        SingleOutputStreamOperator<String> output = dwdStream
                .connect(broadcastStream)
                .process(new BroadcastProcessFunction<String, String, String>() {
                     /*
                    处理数据流数据
                     */
                    @Override
                    public void processElement(String line, ReadOnlyContext readOnlyContext, Collector<String> collector) throws Exception {
                        ReadOnlyBroadcastState<String, String> state = readOnlyContext.getBroadcastState(dimState);
 
                        JSONObject jn = JSON.parseObject(line);
                        String vodid = jn.get("vodid").toString();
                        String userid = jn.get("userid").toString();
                        String time = jn.get("time").toString();
 
                        if (state.contains(vodid)) {
                            String vodInfo = state.get(vodid);
                            String[] infos = vodInfo.split(",");
                            String vodName = infos[1];
                            String vodTag = infos[2];
                            String vodActor = infos[3];
 
                            StringJoiner joiner = new StringJoiner(",");
 
                            joiner.add(time)
                                    .add(userid)
                                    .add(vodid)
                                    .add(vodName)
                                    .add(vodTag)
                                    .add(vodActor);
 
                            collector.collect(joiner.toString());
                        }
                    }
 
                    /*
                    处理广播流数据
                     */
                    @Override
                    public void processBroadcastElement(String s, Context context, Collector<String> collector) throws Exception {
                        BroadcastState<String, String> state = context.getBroadcastState(dimState);
                        String key = s.split(",")[0];
                        if (!state.contains(key)) {
                            System.out.println("新的vod加入" + key);
                            state.put(key, s);
                        }
                    }
                });
 
        output.print();
        env.execute();
    }
}

21、checkpoint && savepoint

savepoint为主动行为,强烈推荐为每个算子指定uid,因为savepoint回复的时候是根据算子的uid进行回复,代码结构更改会导致flink自动为算子分配的uid更改,从而无法从savepoint回复。

手动触发:bin/flink savepoint :jobId [:targetDirectory]

使用yanr触发savepoint:bin/flink savepoint :jobId [:targetDirectory] -yid :yarnAppId

checkpoint 恢复会比savepoint快。

无论RETAIN_ON_CANCELLATION、DELETE_ON_CANCELLATION,flink重启或者失败,checkpoint都会永远存在存储上,需要手动清除。手动清理不能通过时间来判断清理,因为rocksdb 增量快照恢复会依赖之前的快照,可以通过解析metadata文件,解析出哪些checkpoint还被依赖,删除那些已经被不被依赖的checkpoint。

22、整合apollo

1、setcheckpointintervel 去动态更改是不生效的。

23、内存管理

1、预先分配内存,内存不足则刷写磁盘

2、二进制形式存储对象,比java原生方式消耗内存低

3、排序先基于key进行前缀排序,相等时才比较整个对象。

如何分配内存:

taskmanager由actor:负责跟mater通信、iomanger:负责一些磁盘和从磁盘读数据、memorymanager:负责memorysegment分配,启动后预先分配,可重用,默认站70%内存。

对象序列化:BasicTypeInfo:java基础数据类型、string

BasicArrayTypeInfo:java基础类型构成的数组、string

WritableTypeInfo:hadoop writable

TupleTypeInfo

CaseClassTypeInfo:scala caseclass 或者scala tuples

PojoTypeInfo:任何pojo对象

24、常用参数

Flink 参数配置和常见参数调优

  • jobmanger.rpc.address jm的地址。

  • jobmanager.rpc.port jm的端口号。

  • jobmanager.heap.mb jm的堆内存大小。不建议配的太大,1-2G足够。

  • taskmanager.heap.mb tm的堆内存大小。大小视任务量而定。需要存储任务的中间值,网络缓存,用户数据等。

  • taskmanager.numberOfTaskSlots slot数量。在yarn模式使用的时候会受到yarn.scheduler.maximum-allocation-vcores值的影响。此处指定的slot数量如果超过yarn的maximum-allocation-vcores,flink启动会报错。在yarn模式,flink启动的task manager个数可以参照如下计算公式:

num_of_tm = ceil(parallelism / slot) 即并行度除以slot个数,结果向上取整。

  • parallelsm.default 任务默认并行度,如果任务未指定并行度,将采用此设置。

  • web.port Flink web ui的端口号。

  • jobmanager.archive.fs.dir 将已完成的任务归档存储的目录。

  • history.web.port 基于web的history server的端口号。

  • historyserver.archive.fs.dir history server的归档目录。该配置必须包含jobmanager.archive.fs.dir配置的目录,以便history server能够读取到已完成的任务信息。

  • historyserver.archive.fs.refresh-interval 刷新存档作业目录时间间隔

  • state.backend 存储和检查点的后台存储。可选值为rocksdb filesystem hdfs。

  • state.backend.fs.checkpointdir 检查点数据文件和元数据的默认目录。

  • state.checkpoints.dir 保存检查点目录。

  • state.savepoints.dir save point的目录。

  • state.checkpoints.num-retained 保留最近检查点的数量。

  • state.backend.incremental 增量存储。

  • akka.ask.timeout Job Manager和Task Manager通信连接的超时时间。如果网络拥挤经常出现超时错误,可以增大该配置值。

  • akka.watch.heartbeat.interval 心跳发送间隔,用来检测task manager的状态。

  • akka.watch.heartbeat.pause 如果超过该时间仍未收到task manager的心跳,该task manager 会被认为已挂掉。

  • taskmanager.network.memory.max 网络缓冲区最大内存大小。

  • taskmanager.network.memory.min 网络缓冲区最小内存大小。

  • taskmanager.network.memory.fraction 网络缓冲区使用的内存占据总JVM内存的比例。如果配置了taskmanager.network.memory.maxtaskmanager.network.memory.min,本配置项会被覆盖。

  • fs.hdfs.hadoopconf hadoop配置文件路径(已被废弃,建议使用HADOOP_CONF_DIR环境变量

  • yarn.application-attempts job失败尝试次数,主要是指job manager的重启尝试次数。该值不应该超过yarn-site.xml中的yarn.resourcemanager.am.max-attemps的值。

Flink HA(Job Manager)的配置

  • high-availability: zookeeper 使用zookeeper负责HA实现

  • high-availability.zookeeper.path.root: /flink flink信息在zookeeper存储节点的名称

  • high-availability.zookeeper.quorum: zk1,zk2,zk3 zookeeper集群节点的地址和端口

  • high-availability.storageDir: hdfs://nameservice/flink/ha/ job manager元数据在文件系统储存的位置,zookeeper仅保存了指向该目录的指针。

Flink metrics 监控相关配置

  • metrics.reporters: prom

  • metrics.reporter.prom.class: org.apache.flink.metrics.prometheus.PrometheusReporter

  • metrics.reporter.prom.port: 9250-9260

Kafka相关调优配置

  • linger.ms/batch.size 这两个配置项配合使用,可以在吞吐量和延迟中得到最佳的平衡点。batch.size是kafka producer发送数据的批量大小,当数据量达到batch size的时候,会将这批数据发送出去,避免了数据一条一条的发送,频繁建立和断开网络连接。但是如果数据量比较小,导致迟迟不能达到batch.size,为了保证延迟不会过大,kafka不能无限等待数据量达到batch.size的时候才发送。为了解决这个问题,引入了linger.ms配置项。当数据在缓存中的时间超过linger.ms时,无论缓存中数据是否达到批量大小,都会被强制发送出去。

ack 数据源是否需要kafka得到确认。all表示需要收到所有ISR节点的确认信息,1表示只需要收到kafka leader的确认信息,0表示不需要任何确认信息。该配置项需要对数据精准性和延迟吞吐量做出权衡。

Kafka topic分区数和Flink并行度的关系

  • Flink kafka source的并行度需要和kafka topic的分区数一致。最大化利用kafka多分区topic的并行读取能力。

Yarn相关调优配置

  • yarn.scheduler.maximum-allocation-vcores

  • yarn.scheduler.minimum-allocation-vcores

Flink单个task manager的slot数量必须介于这两个值之间

  • yarn.scheduler.maximum-allocation-mb

  • yarn.scheduler.minimum-allocation-mb

Flink的job manager 和task manager内存不得超过container最大分配内存大小。

yarn.nodemanager.resource.cpu-vcores yarn的虚拟CPU内核数,建议设置为物理CPU核心数的2-3倍,如果设置过少,会导致CPU资源无法被充分利用,跑任务的时候CPU占用率不高。

25、Flink 使用 connect 实现双流匹配

一、案例分析

在生产环境中,我们经常会遇到双流匹配的案例,例如:

  • 一个订单包含了订单主体信息和商品的信息。

  • 外卖行业,一个订单包含了订单付款信息和派送信息。

  • 互联网广告行业,一次点击包含了用户的点击行为日志和计费日志。

  • 等其他相关的案例

上述这些案例都需要涉及到双流匹配的操作,也就是所谓的双流 join。下面用一个案例来详解如何用 connect 实现双流 join。

本文案例

一个订单分成了大订单和小订单,大小订单对应的数据流来自 Kafka 不同的 Topic,需要在两个数据流中按照订单 Id 进行匹配,这里认为相同订单 id 的两个流的延迟最大为 60s。大订单和小订单匹配成功后向下游发送,若 60s 还未匹配成功,意味着当前只有一个流来临,则认为订单异常,需要将数据进行侧流输出。

思路描述

提取两个流的时间戳,因为要通过订单 Id 进行匹配,所以这里按照订单 Id 进行 keyBy,然后两个流 connect,大订单和小订单的处理逻辑一样,两个流通过 ValueState 进行关联。假如大订单流对应的数据先来了,需要将大订单的相关信息保存到大订单的 ValueState 状态中,注册一个 60s 之后的定时器。

  • 如果 60s 内来了小订单流对应的数据来了,则将两个数据拼接发送到下游。

  • 如果 60s 内小订单流对应的数据还没来,就会触发 onTimer,然后进行侧流输出。

如果小订单流对应的数据先到,也是同样的处理逻辑,先将小订单的信息保存到小订单的 ValueState 中,注册 60s 之后的定时器。

二、实现

用代码来讲述如何实现,首先要配置 Checkpoint 等参数,这里就不详细阐述。

1. 定义订单类

这里大小订单都使用同一个类演示:

@Data
publicclass Order {
    /** 订单发生的时间 */
    long time;
​
    /** 订单 id */
    String orderId;
​
    /** 用户id */
    String userId;
​
    /** 商品id */
    int goodsId;
​
    /** 价格 */
    int price;
​
    /** 城市 */
    int cityId;
}

2. 从 Kafka 的 topic 读取大小订单数据

读取大订单数据,从 json 解析成 Order 类。从 Order 中提取 EventTime、并分配 WaterMark。按照订单 id 进行 keyBy 得到 bigOrderStream。

// 读取大订单数据,读取的是 json 类型的字符串
FlinkKafkaConsumerBase<String> consumerBigOrder =
        new FlinkKafkaConsumer011<>("big_order_topic_name",
                new SimpleStringSchema(),
                KafkaConfigUtil.buildConsumerProps(KAFKA_CONSUMER_GROUP_ID))
                .setStartFromGroupOffsets();
​
KeyedStream<Order, String> bigOrderStream = env.addSource(consumerBigOrder)
        // 有状态算子一定要配置 uid
        .uid(KAFKA_TOPIC)
        // 过滤掉 null 数据
        .filter(Objects::nonNull)
        // 将 json 解析为 Order 类
        .map(str -> JSON.parseObject(str, Order.class))
        // 提取 EventTime,分配 WaterMark
        .assignTimestampsAndWatermarks(
                new BoundedOutOfOrdernessTimestampExtractor<Order>
                        (Time.seconds(60)) {
                    @Override
                    public long extractTimestamp(Order order) {
                        return order.getTime();
                    }
                })
        // 按照 订单id 进行 keyBy
        .keyBy(Order::getOrderId);

小订单的处理逻辑与上述流程完全类似,只不过读取的 topic 不是同一个。

// 小订单处理逻辑与大订单完全一样
FlinkKafkaConsumerBase<String> consumerSmallOrder =
        new FlinkKafkaConsumer011<>("small_order_topic_name",
                new SimpleStringSchema(),
                KafkaConfigUtil.buildConsumerProps(KAFKA_CONSUMER_GROUP_ID))
                .setStartFromGroupOffsets();
​
KeyedStream<Order, String> smallOrderStream = env.addSource(consumerSmallOrder)
        .uid(KAFKA_TOPIC)
        .filter(Objects::nonNull)
        .map(str -> JSON.parseObject(str, Order.class))
        .assignTimestampsAndWatermarks(
                new BoundedOutOfOrdernessTimestampExtractor<Order>
                        (Time.seconds(10)) {
                    @Override
                    public long extractTimestamp(Order order) {
                        return order.getTime();
                    }
                })
        .keyBy(Order::getOrderId);

3. connect 连接大小订单流,使用 process 进行匹配

再次描述一下处理流程:

两个流通过 ValueState 进行关联,假如大订单流对应的数据先来了,需要将大订单的相关信息保存到大订单的 ValueState 状态中,注册一个 60s 之后的定时器。

  • 如果 60s 内来了小订单流对应的数据来了,则将两个数据拼接发送到下游。

  • 如果 60s 内小订单流对应的数据还没来,就会触发 onTimer,然后进行侧流输出。

需要提前定义好侧流输出需要用到的 OutTag:

privatestatic OutputTag<Order> bigOrderTag = new OutputTag<>("bigOrder");
privatestatic OutputTag<Order> smallOrderTag = new OutputTag<>("smallOrder")

代码实现如下:

// 使用 connect 连接大小订单的流,然后使用 CoProcessFunction 进行数据匹配
SingleOutputStreamOperator<Tuple2<Order, Order>> resStream = bigOrderStream
        .connect(smallOrderStream)
        .process(new CoProcessFunction<Order, Order, Tuple2<Order, Order>>() {
            // 大订单数据先来了,将大订单数据保存在 bigState 中。
            ValueState<Order> bigState;
            // 小订单数据先来了,将小订单数据保存在 smallState 中。
            ValueState<Order> smallState;

            // 大订单的处理逻辑
            @Override
            public void processElement1(Order bigOrder, Context ctx,
                                        Collector<Tuple2<Order, Order>> out)
                    throws Exception {
                // 获取当前 小订单的状态值
                Order smallOrder = smallState.value();
                // smallOrder 不为空表示小订单先来了,直接将大小订单拼接发送到下游
                if (smallOrder != null) {
                    out.collect(Tuple2.of(smallOrder, bigOrder));
                    // 清空小订单对应的 State 信息
                    smallState.clear();
                } else {
                    // 小订单还没来,将大订单放到状态中,并注册 1 分钟之后触发的 timerState
                    bigState.update(bigOrder);
                    // 1 分钟后触发定时器,当前的 eventTime + 60s
                    long time = bigOrder.getTime() + 60000;
                    ctx.timerService().registerEventTimeTimer(time);
                }
            }

            @Override
            public void processElement2(Order smallOrder, Context ctx,
                                        Collector<Tuple2<Order, Order>> out)
                    throws Exception {
                // 这里先省略代码,小订单的处理逻辑与大订单的处理逻辑完全类似
            }

            @Override
            public void onTimer(long timestamp, OnTimerContext ctx,
                                Collector<Tuple2<Order, Order>> out)
                    throws Exception {
                // 定时器触发了,即 1 分钟内没有接收到两个流。
                // 大订单不为空,则将大订单信息侧流输出
                if (bigState.value() != null) {
                    ctx.output(bigOrderTag, bigState.value());
                }
                // 小订单不为空,则将小订单信息侧流输出
                if (smallState.value() != null) {
                    ctx.output(smallOrderTag, smallState.value());
                }
                bigState.clear();
                smallState.clear();
            }

            @Override
            public void open(Configuration parameters) throws Exception {
                super.open(parameters);
                // 初始化状态信息
                bigState = getRuntimeContext().getState(
                        new ValueStateDescriptor<>("bigState", Order.class));
                smallState = getRuntimeContext().getState(
                        new ValueStateDescriptor<>("smallState", Order.class));
            }
        });

小优化

假如 60s 以内,两个流的数据都到了,也就是执行了 out.collect(Tuple2.of(smallOrder, bigOrder)); 还有必要触发定时器吗?

定时器的目的是为了保证当 60s 时间到了,仍然有一个流还未到达。那么当两个流都到达时,没有必要再去触发定时器。所以当两个流都到达时,可以删除注册的定时器。(定时器的维护和触发也是需要成本的,所以及时清理这些垃圾是一个比较好的习惯)

改造后的代码如下,申请了一个 ValueState 类型的 timerState 用于维护注册的定时器时间,如果两个流都到达时,触发 delete 操作,同时要注意调用 timerState.clear() 去清理 timerState 的状态信息。

// 大订单的处理逻辑
@Override
public void processElement1(Order bigOrder, Context ctx,
                            Collector<Tuple2<Order, Order>> out)
        throws Exception {
    // 获取当前 小订单的状态值
    Order smallOrder = smallState.value();
    // smallOrder 不为空表示小订单先来了,直接将大小订单拼接发送到下游
    if (smallOrder != null) {
        out.collect(Tuple2.of(smallOrder, bigOrder));
        // 清空小订单对应的 State 信息
        smallState.clear();
        // 这里可以将 Timer 清除。因为两个流都到了,没必要再触发 onTimer 了
        ctx.timerService().deleteEventTimeTimer(timerState.value());
        timerState.clear();
    } else {
        // 小订单还没来,将大订单放到状态中,并注册 1 分钟之后触发的 timerState
        bigState.update(bigOrder);
        // 1 分钟后触发定时器,并将定时器的触发时间保存在 timerState 中
        long time = bigOrder.getTime() + 60000;
        timerState.update(time);
        ctx.timerService().registerEventTimeTimer(time);
    }
}

4. 结果输出

这里直接将正常的输出结果还有策略输出都通过 print 进行输出,生产环境肯定是需要通过 Sink 输出到外部系统的。侧流输出的数据属于异常数据,需要保存到外部系统,进行特殊处理。

// 正常匹配到数据的 输出。生产环境肯定是需要通过 Sink 输出到外部系统
resStream.print();

// 只有大订单时,没有匹配到 小订单,属于异常数据,需要保存到外部系统,进行特殊处理
resStream.getSideOutput(bigOrderTag).print();
// 只有小订单时,没有匹配到 大订单,属于异常数据,需要保存到外部系统,进行特殊处理
resStream.getSideOutput(smallOrderTag).print();

env.execute(JOB_NAME);

三、 总结

Flink 使用 connect 实现双流 join 在 Flink 的 Streaming Api 中相对经常使用的 map、flatMap 等算子来讲已经属于比较复杂的场景了。文中开始处介绍的那些场景都可以通过本文的案例经过简单改造即可实现。而且 connect 实现双流 join 属于 Flink 面试的高频考点,希望读者通过本文有所收获。

###

26、Flink 精进学习知识星球内容整理

一文搞懂 Flink 网络流控与反压机制.pdf

从0到1搭建一套 Flink 监控系统.pdf

4.3Flink Checkpoint 和 Savepoint 的区别及其配置使用 (1).pdf

2如何选择 Flink 状态后端存储_.pdf

使用 Prometheus Grafana 监控 Flink.pdf

27、分流split/sideout

split的流再spilt select不生效。

想完成连续split流可以用 split+filter或者side output来代替

Flink 从0到1学习—— Flink 不可以连续 Split(分流)?

Flink 从0到1学习 —— 如何使用 Side Output 来分流?

28、Flink 全链路端到端延迟的测量方法

env.getConfig().setLatencyTrackingInterval()

GET taskmanagers/ABCDE/metrics

一口气搞懂「Flink Metrics」监控指标和性能优化,全靠这33张图和7千字(建议收藏)

Flink 实时 metrics

sideout

  • ProcessFunction

  • KeyedProcessFunction

  • CoProcessFunction

  • ProcessWindowFunction

  • ProcessAllWindowFunction

private static final OutputTag<AlertEvent> middleware = new OutputTag<AlertEvent>("MIDDLEWARE") {};
private static final OutputTag<AlertEvent> machine = new OutputTag<AlertEvent>("MACHINE") {};
private static final OutputTag<AlertEvent> docker = new OutputTag<AlertEvent>("DOCKER") {};


//dataStream 是总的数据流
SingleOutputStreamOperator<AlertEvent, AlertEvent> outputStream = dataStream.process(new ProcessFunction<AlertEvent, AlertEvent>() {
    @Override
    public void processElement(AlertEvent value, Context ctx, Collector<AlertEvent> out) throws Exception {
        if ("MACHINE".equals(value.type)) {
            ctx.output(machine, value);
        } else if ("DOCKER".equals(value.type)) {
            ctx.output(docker, value);
        } else if ("MIDDLEWARE".equals(value.type)) {
            ctx.output(middleware, value);
        } else {
            //其他的业务逻辑
            out.collect(value);
        }
    }
});
    
    
//机器相关的告警&恢复数据
outputStream.getSideOutput(machine).print();

//容器相关的告警&恢复数据
outputStream.getSideOutput(docker).print();

//中间件相关的告警&恢复数据
outputStream.getSideOutput(middleware).print();


这样你就可以获取到 Side Output 数据了。
另外你还可以看下我在 Github 放的一个完整 demo 代码: https://github.com/zhisheng17/flink-learning/blob/master/flink-learning-examples/src/main/java/com/zhisheng/examples/streaming/sideoutput/Main.java

29、Flink HDFS Sink 如何保证 exactly-once 语义

30、Flink on YARN 常见问题与排查思路

一张图轻松掌握 Flink on YARN 基础架构与启动流程

Flink on YARN 常见问题与排查思路

Maven专题(六) - 插件maven-shade-plugin

31、Flink 单并行度内使用多线程来提高作业性能

利用线程池+CyclicBarrier 来完成异步多线程+at least one语义。

public class MultiThreadConsumerSink extends RichSinkFunction<String> implements CheckpointedFunction {
    private Logger LOG = LoggerFactory.getLogger(MultiThreadConsumerSink.class);

    // Client 线程的默认数量
    private final int DEFAULT_CLIENT_THREAD_NUM = 5;
    // 数据缓冲队列的默认容量
    private final int DEFAULT_QUEUE_CAPACITY = 5000;

    private LinkedBlockingQueue<String> bufferQueue;
    private CyclicBarrier clientBarrier;

    @Override
    public void open(Configuration parameters) throws Exception {
        super.open(parameters);
        // new 一个容量为 DEFAULT_CLIENT_THREAD_NUM 的线程池
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(DEFAULT_CLIENT_THREAD_NUM, DEFAULT_CLIENT_THREAD_NUM,
                0L,TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
        // new 一个容量为 DEFAULT_QUEUE_CAPACITY 的数据缓冲队列
        this.bufferQueue = Queues.newLinkedBlockingQueue(DEFAULT_QUEUE_CAPACITY);
        // barrier 需要拦截 (DEFAULT_CLIENT_THREAD_NUM + 1) 个线程
        this.clientBarrier = new CyclicBarrier(DEFAULT_CLIENT_THREAD_NUM + 1);
        // 创建并开启消费者线程
        MultiThreadConsumerClient consumerClient = new MultiThreadConsumerClient(bufferQueue, clientBarrier);
        for (int i=0; i < DEFAULT_CLIENT_THREAD_NUM; i++) {
            threadPoolExecutor.execute(consumerClient);
        }
    }

    @Override
    public void invoke(String value, Context context) throws Exception {
        // 往 bufferQueue 的队尾添加数据
        bufferQueue.put(value);
    }

    @Override
    public void snapshotState(FunctionSnapshotContext functionSnapshotContext) throws Exception {
        LOG.info("snapshotState : 所有的 client 准备 flush !!!");
        // barrier 开始等待
        clientBarrier.await();
    }

    @Override
    public void initializeState(FunctionInitializationContext functionInitializationContext) throws Exception {
    }

}

public class MultiThreadConsumerClient implements Runnable {

    private Logger LOG = LoggerFactory.getLogger(MultiThreadConsumerClient.class);
    private LinkedBlockingQueue<String> bufferQueue;
    private CyclicBarrier barrier;

    public MultiThreadConsumerClient(
            LinkedBlockingQueue<String> bufferQueue, CyclicBarrier barrier) {
        this.bufferQueue = bufferQueue;
        this.barrier = barrier;
    }

    @Override
    public void run() {
        String entity;
        while (true){
            try {
                // 从 bufferQueue 的队首消费数据,并设置 timeout
                entity = bufferQueue.poll(50, TimeUnit.MILLISECONDS);
                // entity != null 表示 bufferQueue 有数据
                if(entity != null){
                    // 执行 client 消费数据的逻辑
                    doSomething(entity);
                } else {
                    // entity == null 表示 bufferQueue 中已经没有数据了,
                    // 且 barrier wait 大于 0 表示当前正在执行 Checkpoint,
                    // client 需要执行 flush,保证 Checkpoint 之前的数据都消费完成
                    if ( barrier.getNumberWaiting() > 0 ) {
                        LOG.info("MultiThreadConsumerClient 执行 flush, " +
                                "当前 wait 的线程数:" + barrier.getNumberWaiting());
                        flush();
                        barrier.await();
                    }
                }
            } catch (InterruptedException| BrokenBarrierException e) {
                e.printStackTrace();
            }
        }
    }

    // client 消费数据的逻辑
    private void doSomething(String entity) {

    }

    // client 执行 flush 操作,防止丢数据
    private void flush() {
        // client.flush();
    }
}

32、背压

Flink Back Pressure(背压)是怎么实现的?有什么绝妙之处?

1、每50ms对任务执行100个Thread.getStackTrace()。,来确定任务是否存在背压。

33、部署模式

0、local,本地调试

1、standalone 单机模式,独立的flink集群

2、yarn模式

1)session :开启的是一个yarn session,然后使用flin-run提交任务给yarn session,对于大量的小任务可以用这样的方式,减少资源创建时间。

2)per job 直接提交任务给yarn,相当于一个yarn session只跑一个flink job.

生产一般用yarn,极少用standalone

34、异步io

使用前提:

1、数据库或者存储支持异步请求的client

2、没有异步客户端,那就丢到线程池中执行

2、默认超时会重启 job,可以重写timeout方法

3、提供了 exactly-onece保证,他将未执行回调的任务都保存在检查点中,恢复的时候会再次调用定义的方法查询结果,然后发送给下游

4、

1)AsyncFunction#asyncInvoke 的操作不用调用阻塞操作,他不是被flink多线程调用的

2)AsyncDataStream.orderedWait(有序):消息发送顺序和接收到顺序相同

3)AsyncDataStream.unorderedWait 无序

注意:processtime做watermark的时候完全无序(开销和延迟低)

eventtime做watermark的时候,窗口中的无序,但是watermark后的消息不能早于watermark前的消息发送。(开销取决于watermark的频率)

	private static class SampleAsyncFunction extends RichAsyncFunction<Integer, String> {
		private static final long serialVersionUID = 2098635244857937717L;

		private transient ExecutorService executorService;

		/**
		 * The result of multiplying sleepFactor with a random float is used to pause
		 * the working thread in the thread pool, simulating a time consuming async operation.
		 */
		private final long sleepFactor;

		/**
		 * The ratio to generate an exception to simulate an async error. For example, the error
		 * may be a TimeoutException while visiting HBase.
		 */
		private final float failRatio;

		private final long shutdownWaitTS;

		SampleAsyncFunction(long sleepFactor, float failRatio, long shutdownWaitTS) {
			this.sleepFactor = sleepFactor;
			this.failRatio = failRatio;
			this.shutdownWaitTS = shutdownWaitTS;
		}

		@Override
		public void open(Configuration parameters) throws Exception {
			super.open(parameters);

			executorService = Executors.newFixedThreadPool(30);
		}

		@Override
		public void close() throws Exception {
			super.close();
			ExecutorUtils.gracefulShutdown(shutdownWaitTS, TimeUnit.MILLISECONDS, executorService);
		}

		@Override
		public void asyncInvoke(final Integer input, final ResultFuture<String> resultFuture) {
			executorService.submit(() -> {
				// wait for while to simulate async operation here
				long sleep = (long) (ThreadLocalRandom.current().nextFloat() * sleepFactor);
				try {
					Thread.sleep(sleep);

					if (ThreadLocalRandom.current().nextFloat() < failRatio) {
						resultFuture.completeExceptionally(new Exception("wahahahaha..."));
					} else {
						resultFuture.complete(
							Collections.singletonList("key-" + (input % 10)));
					}
				} catch (InterruptedException e) {
					resultFuture.complete(new ArrayList<>(0));
				}
			});
		}
	}

35、窗口触发器

窗口类型:滚动:默认对齐整分、整点、整秒、

滑动:默认对齐整分、整点、整秒、

session:定于静态gap或者动态gap,为每个event创建一个新窗口,窗口之间距离比定义的gap小则将其合并成一个。

窗口允许延迟触发,即窗口触发后又有属于该窗口的数据到达,也可能会导致session的合并。

36、常用算子

coGroup :将两个流的相同key合到一起。

###

37、窗口是串行执行的吗

单个task窗口是串行,如果一个窗口阻塞住了,会导致下一个时间窗口的数据留到下下个窗口中。

38、看完flink 大会

错误

Cannot instantiate user function.

修改:默认设置指示首先从用户代码jar加载类,这意味着用户代码jar可以包含和加载不同于Flink使用的依赖项(传递性地)。

在onf/flink-conf.yaml 添加如下内容并重启 flink.

classloader.resolve-order: parent-first

Flink流计算编程--Flink扩容、程序升级前后的思考

删除一个有状态的operator:状态丢了,这时会报出找不到operator的错误,你要通过-n(--allowNonRestoredState)来指定跳过这个operator的状态恢复

二、hive

1、Hive 在 select 查询数据后,执行 insert 操作插入 ORC 表和 parquet 表操作的过程中,遇到over gc limit、java.lang.OutMemoryError:Java heap space 等字样的错误,大概从哪些地方查找原因解决问题?

1)可能数据分布不均匀造成的,可以在 select 阶段加 cluster by rand()让数据均匀分布。 (2)map 和 reduce 阶段的内存不够,通过 hive 参数增加内存 set mapreduce.reduce.memory.mb=16384;(实际调试的时候,8G 内存不够,增加到 16G 解决 问题) set mapreduce.map.memory.mb=4096; (3)另外在数据格式不统一的情况下,不同格式的 insert 操作效率奇低。

2、手写连续登入 7 日的用户 SQL?

select * from (select user_id,dt,date_sub(dt, (row_number over(partition by user_id order by dt asc )) as c from a where status=1) a group by user_id,c having count(1)>=7

求连续登录的天数、开始、结束时间和间隔时间

select 
	user_id, 
    duration, 
    min(le) as start_time, 
    max(le) as end_time, 
    max(le) - min(le) as interval1
from (select user_id,
             dt,
             date_sub(dt, (row_number over (partition by user_id order by dt asc )) as duration,
                      lead(dt, 1) over (partiton by user_id order by dt asc ) as le
                      from a where status = 1
                 ) a
      group by user_id, duration

3、hive 性能调优的常见方法?

参考答案: (1)HQL 层面优化 1)利用分区表优化 2)利用桶表优化 3)join 优化 4 ) Group By 数 据 倾 斜 优 化 解 决 这 个 问 题 的 方 法 是 配 置 一 个 参 数 : set hive.groupby.skewindata=true。 5)Order By 优化 6)一次读取多次插入 7)Join 字段显示类型转换 (2)Hive 架构层面优化 1)不执行 MapReduc 2)本地模式执行 MapReduce 3)JVM 重用 4)并行化 (2)底层 MapReduce 优化 1)合理设置 map 数 2)合理设置reduce

skew join

三、spark

1、分配足够的资源 executor、core、memory

2、用缓存,先MEMORY_ONLY、MEMORY_ONLY_SER、MEMORY_AND_DISK_SER、MEMORY_AND_DISK

3、用checkpoint,对依赖长,计算时间长的rdd 做checkpoint,防止失败后再次计算。

4、避免shuffle类算子。使用broadcast+map替代

5、用reduceByKey、 aggregateByKey、 combineByKey代替group bykey,因为groupbykey不会在map端预聚合。

aggregateByKey:指定初始值,分区内、分区间聚合函数

6、尽量使用高性能的算子

使用reduceByKey替代groupByKey

使用mapPartition替代map

使用foreachPartition替代foreach

filter后使用coalesce减少分区数

使用repartition和coalesce算子操作分区。

7、善用broadcast

8、使用Kryo (科瑞啊)序列化,性能比java原生高10倍。

9、拉取数据、executor 失联等超时时间,spark.network.timeout=120s

10、解决数据倾斜

3.2版本出现aqe 官网调优

11、shuffle类型

官网调优

12、数据本地性

官网调优

13、小文件输出合并

SQL 查询的合并提示

hints

14、善用bucket

15、Spark Skew Join 的原理及在 eBay 的优化

支持单边是bucket表的skew join

四、hbase

数据模型:

master:存储元数据,负责创建、分配、平衡region

zookeeper:存储元数据在哪个region server里,客户端会缓存这个信息。

region server:region->多个store(1个store 存储1个Column family)->多个memstore+多个hfile

1、创建表的时候预分区,指定region的startkey、end key

增加读写效率、负载均衡,防止数据倾斜

2、flush \compact\拆分机制

memstore

达到大小或者时间限制,flush到磁盘

compact

多个hfile大小和数量达到阈值会合并多个hfile为一个

也可以手动命名将region合并为一个

split

region达到大小或者key前缀相同等规定,会把大region拆分成小的region

3、协处理器

关系型数据库中的存储过程,也可以在get、scan的时候用来聚合数据,例如用来求最大值

4、rowkey设计

最大长度64k,建议16byte以下,越短越好,定长,高位作为散列

常见优化

1)预分区 2)加盐 3)哈希 4)反转:让经常变量的作为前缀,但牺牲了rowkey有序性

5、二级索引

借助例如Phoenix或者solr或者ES等,来完成对非rowkey字段查询

6、hbase的布隆过滤器

五、ES

六、clickhouse

1、jion支持不好,尽量用宽表

2、merge tree\

replaceingmerge tree\summingmergetree

dictionary

3、分区

1、分区大小控制在百万或1000万的数据条数

2、类型:整形、日期、string和float用128hash值作为分区id

3、order by:查询频率大的在前,基数非常大的不适合在前

4、抽样:对于基数大的表进行抽样

5、尽量不存储null,而用无意义的默认值,因为null不能索引,也需要占额外的存储空间

4、设置ttl

5、sql 优化

1、count()或count(*)且没有where条件则直接使用system.tables记录的行数

2、explain syntax select ,可以查看优化后的sql,拿过来替换自己的sql

EXPLAIN indexes=1,json = 1 select * from mt_table;

3、消除重复字段的查询

4、prewhere 替换where

5、uniqCombined 替换count distinct

6、local join

7、global in

8、in代替join

9、join小表在右

10、设置查询熔断

11、关闭虚拟内存

12、关注cpu,最好保证在50%,而到了70%一般会查询超时

13、批量写入控制批次和提前排序,无序或者设计分区太多会导致ck对数据进行合并,影响查询

七、Doris

1、分区分桶

1、分区建议日期、分桶建议区分度大的列

2、表的tablet数量=partition×bucket数量,tablet数量应该略多余磁盘数量,单个大小在1G-10G,分区可单独指定数量,show data可以看表的数据量

2、模型

合理选择aggergation、unique、duplicate模型

3、副本数

副本数取决与集群的独立ip,所以尽量选择多个小机器而不是少量大机器

4、rollup\物化视图

劣势:rollup不能基于明细数据做预聚合

5、join

1、localjoin:分桶键一致、在一个group、sql语句不能用括号

2、broadcast join:适合小表,可显示指定

3、shuffle join:可显示指定

4、bucket join:shuffle部分数据到本地节点计算

性能:Colocate Join -> Bucket Shuffle Join ->Broadcast Join -> Shuffle Join,doris也自动按照这个顺序选择join。显示指定则按照指定的进行.

5、explain查看join类型

6、小表写在右边

7、runtimefilter:适合左表数据量大,右表过滤后数据量少的场景

8、使用bitmap索引,适合枚举值少的列

9、使用bloomfilter,适合枚举值多的列

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

i am cscs

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值