Apache Structured Streaming GZhY166

Spark Structured Streaming

一、概述

http://spark.apache.org/docs/latest/structured-streaming-programming-guide.html

Structured Streaming构建在Spark SQL基础之上的一个可靠且容错的流数据处理引擎。

简短来说,Structured Streaming提供快速、可靠、容错、端对端的精确一次流数据处理语义流数据处理方法。

流数据进行处理,处理容错语义:

  • at exactly once: 精确一次; 流数据不论处理成功还是失败 一定能够精确处理1次
  • at least once:最少一次; 流数据处理成功(1次),处理失败(n次)
  • at most once: 最多一次;流数据处理成功(1次),处理失败(0次)

注意:在内部,Structured Streaming依然会将流数据,划分为micro batch , 并且达到端对端低于100ms的处理延迟和精确一次的容错处理语义

优点:

  • 支持多种数据端(流数据的输入和输出可以有多种方式)
  • 应用Spark SQL操作,可以通过SQL语法计算流数据; select word,num(word) from t_word group by word
  • 支持容错语义: at exactly once , Spark 2.3版本之后,提供端对端低于1ms的处理延迟和at least once
  • 借助于Spark SQL底层优化,保证对流数据处理以高效方式处理

二、Quick Example

实现实时单词计数

Maven依赖

<dependency>
    <groupId>org.apache.spark</groupId>
    <artifactId>spark-core_2.11</artifactId>
    <version>2.4.4</version>
</dependency>
<dependency>
    <groupId>org.apache.spark</groupId>
    <artifactId>spark-sql_2.11</artifactId>
    <version>2.4.4</version>
    <!--集群中运行打开,本地运行注释-->
    <!--<scope>provided</scope>-->
</dependency>

开发应用

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.OutputMode

object StructuredStreamingWordCount {
  def main(args: Array[String]): Unit = {
    //1. 构建spark session
    val spark = SparkSession.builder().master("local[*]").appName("word count").getOrCreate()

    spark.sparkContext.setLogLevel("OFF")

    import spark.implicits._

    //2. 构建流数据的DF   接受tcp请求端口的访问数据 作为df流数据
    val df = spark.readStream.format("socket").option("host", "SparkOnStandalone").option("port", "5555").load()
    // value  count
    /*
    1  Spark Spark   Row("Spark Spark",)  // 0
    2  Hello Spark   Row("Hello Spark")
    3  Hello Scala   Row("...")
    4  AA AA AA


    1  Spark        Row("Spark")
    2  Spark        Row("Spark")
    3  Hello
    4
    */

    //3. 应用SQL操作,对流数据进行处理  // Hello Spark  => Hello  Spark
    df.flatMap(row => row.getString(0).split(" ")).createOrReplaceTempView("t_word") // 列名 value

    val wordcounts = spark.sql("select value,count(value) from t_word group by value")

    //4. 结果DF写出
    wordcounts
      .writeStream
      .format("console")
      .outputMode(OutputMode.Complete()) // 输出模式: 支持 追加、完整、更新
      .start() // streaming应用 持续运行
      .awaitTermination()
  }
}

启动TCP数据服务器

[root@SparkOnStandalone ~]# nc -lk 5555
Spark Spark
Hello Spark
Hello Scala
AA AA AA

查看结果

-------------------------------------------
Batch: 1
-------------------------------------------
+-----+------------+
|value|count(value)|
+-----+------------+
|Spark|           2|
+-----+------------+

-------------------------------------------
Batch: 2
-------------------------------------------
+-----+------------+
|value|count(value)|
+-----+------------+
|Hello|           1|
|Spark|           3|
+-----+------------+

-------------------------------------------
Batch: 3
-------------------------------------------
+-----+------------+
|value|count(value)|
+-----+------------+
|Hello|           2|
|Scala|           1|
|Spark|           3|
+-----+------------+

-------------------------------------------
Batch: 4
-------------------------------------------
+-----+------------+
|value|count(value)|
+-----+------------+
|   AA|           3|
|Hello|           2|
|Scala|           1|
|Spark|           3|
+-----+------------+

三、编程模型

Spark结构化流核心思想将流数据视为一个持续追加的数据库表; Spark结构流处理类似于Spark SQL的批处理操作
在这里插入图片描述

在这里插入图片描述

基本概念

  • InputTable: 输入表,当流数据中产生新记录等价于InputTable中追加一个新行;换句来说:InputTable代表反应Data Stream

  • ResultTable: 结果表,当在InputTable中使用查询,则产生ResultTable

  • OutputMode: 输出模式,表示使用何种方式将ResultTable中的结果写出到外部存储系统

注意:

  1. Spark结构化流并不会长时间持有InputTable中内容,实际上流数据产生后会应用增量更新,完成后流数据丢弃;这样设计目的是为了保证对于内存的使用保证在一个合理的范围;只使用内存存放ResultTable(状态表)

  2. Spark结构化流模型不同于其它流数据处理引擎,状态管理是自动处理

Fault Tolerance Semantics(容错语义)

Structure Streaming通过checkpoint和write ahead log去记录每一次批处理的数据源的偏移量(区间),可以保证在失败的时候可以重复的读取数据源

其次Structure Streaming也提供了Sink的幂等写的特性(在编程中一个操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同), 因此

Structure Streaming实现end-to-end exactly-once 语义的故障恢复。

Spark结构化流实现端对端精确一次处理语义;

输入端: 通常是Kafka,处理引擎正常处理流数据则提交消费位置offset;如果未正常处理流数据则不提交消费位置offset,下一次消费数据时,还会重新拉取这条记录;

输出端: 实现幂等写操作(写出一次或者多次影响结果是一致的)

四、操作API

Input Sources

内置sources

  • File source
  • Kafka source
  • Socket source (for testing)

不同类型的Sources,容错支持

SourceOptionsFault-tolerantNotes
File sourcepath: path to the input directory, and common to all file formats. maxFilesPerTrigger: maximum number of new files to be considered in every trigger (default: no max) latestFirst: whether to process the latest new files first, useful when there is a large backlog of files (default: false) fileNameOnly: whether to check new files based on only the filename instead of on the full path (default: false). With this set to true, the following files would be considered as the same file, because their filenames, “dataset.txt”, are the same: “file:///dataset.txt” “s3://a/dataset.txt” “s3n://a/b/dataset.txt” “s3a://a/b/c/dataset.txt” For file-format-specific options, see the related methods in DataStreamReader (Scala/Java/Python/R). E.g. for “parquet” format options see DataStreamReader.parquet(). In addition, there are session configurations that affect certain file-formats. See the SQL Programming Guide for more details. E.g., for “parquet”, see Parquet configuration section.YesSupports glob paths, but does not support multiple comma-separated paths/globs.
Socket Sourcehost: host to connect to, must be specified port: port to connect to, must be specifiedNo
Rate SourcerowsPerSecond (e.g. 100, default: 1): How many rows should be generated per second. rampUpTime (e.g. 5s, default: 0s): How long to ramp up before the generating speed becomes rowsPerSecond. Using finer granularities than seconds will be truncated to integer seconds. numPartitions (e.g. 10, default: Spark’s default parallelism): The partition number for the generated rows. The source will try its best to reach rowsPerSecond, but the query may be resource constrained, and numPartitions can be tweaked to help reach the desired speed.Yes
Kafka SourceSee the Kafka Integration Guide.Yes
# 1. kafka和结构化流的集成依赖
<dependency>
    <groupId>org.apache.spark</groupId>
    <artifactId>spark-sql-kafka-0-10_2.11</artifactId>
    <version>2.4.4</version>
</dependency>
# 2. 确保kafka服务正常 (zk & kafka进程 )
# 3. 创建测试topic
[root@HadoopNode00 kafka_2.11-0.11.0.0]# bin/kafka-topics.sh --create --topic test --zookeeper HadoopNode00:2181 --partitions 1 --replication-factor 1
Created topic "test".
# 4. 启动kafka生产者,用以生产流数据
[root@HadoopNode00 kafka_2.11-0.11.0.0]# bin/kafka-console-producer.sh --topic test --broker-list HadoopNode00:9092
>
package sources

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.OutputMode
import org.apache.spark.sql.types.{BooleanType, IntegerType, StringType, StructType}

object InputSources {
  def main(args: Array[String]): Unit = {
    //1. 构建spark session
    val spark = SparkSession.builder().master("local[*]").appName("csv sources").getOrCreate()

    spark.sparkContext.setLogLevel("OFF")

    import spark.implicits._

    //2. 构建流数据的DF   接受tcp请求端口的访问数据 作为df流数据
	
    /*
    val inputTable = spark
      .readStream
      .format("csv")  // csv
      .schema(
        new StructType()
          .add("id", IntegerType)
          .add("name", StringType)
          .add("sex", BooleanType)
          .add("salary", IntegerType))
      .load("file:///d://csv")
    */

    /*
    val inputTable = spark
      .readStream
      .format("json")  // csv
      .schema(
      new StructType()
        .add("id", IntegerType)
        .add("name", StringType)
        .add("sex", BooleanType)
        .add("salary", IntegerType))
      .load("file:///d://json")
     */

    /*
    val inputTable = spark
      .readStream
      .format("orc")  // csv
      .schema(
      new StructType()
        .add("id", IntegerType)
        .add("name", StringType)
        .add("sex", BooleanType)
        .add("salary", IntegerType))
      .load("file:///d://orc")

    inputTable.createOrReplaceTempView("t_user")
    val resultTable = spark.sql("select * from t_user")
    */

    /*
    val inputTable = spark.readStream.textFile("hdfs://SparkOnStandalone:9000/data")

    inputTable.createOrReplaceTempView("t_data")

    val resultTable = spark.sql("select * from t_data")
    */

    //***************************************************************
    val df = spark
      .readStream
      .format("kafka")
      .option("kafka.bootstrap.servers", "HadoopNode00:9092")
      .option("subscribe", "test") // 默认 订阅test topic 所有分区
      //.option("startingOffsets", """{"test":{"0":2}}""")  //  从test topic 0号分区offset为2的位置拉取流数据(kafka指定消费分区和消费位置)
      //.option("endingOffsets", """{"test":{"0":4}}""")    // 无法使用
      //.option("startingOffsets", """{"test":{"0":-1}}""") //  -2 == earliest  -1 == latest  大于0的整数代表消费位置offset
      .option("startingOffsets", """{"test":{"0":-2}}""")   //  -2 == earliest  -1 == latest  大于0的整数代表消费位置offset
                                                            // latest     如果有已提交的offset则从提交的offset开始消费数据,如果没有则消费最新的数据
                                                            // earliest   如果有已提交的offset则从提交的offset开始消费数据,如果没有则从头消费数据
      .load()

    // kafka record:k v topic partition offset timestamp
    df
      .selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)", "topic", "partition", "offset", "timestamp").as[(String, String, String, Int, Long, Long)]
      .createOrReplaceTempView("t_kafka")

    val resultTable = spark.sql("select * from t_kafka")

    //***************************************************************
    resultTable
      .writeStream
      .format("console")
      .outputMode(OutputMode.Append())
      .start()
      .awaitTermination()

  }
}

Output Sinks

内置sink:

  • File sink
  • Kafka sink
  • Foreach sink
  • Console sink (for debugging)

不同类型的Sink,容错语义和输出模式:

SinkSupported Output ModesOptionsFault-tolerantNotes
File SinkAppendpath: path to the output directory, must be specified. For file-format-specific options, see the related methods in DataFrameWriter (Scala/Java/Python/R). E.g. for “parquet” format options see DataFrameWriter.parquet()Yes (exactly-once)Supports writes to partitioned tables. Partitioning by time may be useful.
Kafka SinkAppend, Update, CompleteSee the Kafka Integration GuideYes (at-least-once)More details in the Kafka Integration Guide
Foreach SinkAppend, Update, CompleteNoneYes (at-least-once)More details in the next section
ForeachBatch SinkAppend, Update, CompleteNoneDepends on the implementationMore details in the next section
Console SinkAppend, Update, CompletenumRows: Number of rows to print every trigger (default: 20) truncate: Whether to truncate the output if too long (default: true)No
package sinkes

import org.apache.spark.sql.{ForeachWriter, Row, SparkSession}
import org.apache.spark.sql.streaming.OutputMode
import redis.clients.jedis.Jedis

object OutputSinkes {
  def main(args: Array[String]): Unit = {
    //1. 构建spark session
    val spark = SparkSession.builder().master("local[*]").appName("csv sources").getOrCreate()

    spark.sparkContext.setLogLevel("OFF")

    import spark.implicits._

    //***************************************************************
    //2. 构建流数据的DF   接受tcp请求端口的访问数据 作为df流数据
    /*
    val df = spark
      .readStream
      .format("kafka")
      .option("kafka.bootstrap.servers", "HadoopNode00:9092")
      .option("subscribe", "test") // 默认 订阅test topic 所有分区
      .load()

    // kafka record:k v topic partition offset timestamp
    df
      .selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)", "topic", "partition", "offset", "timestamp").as[(String, String, String, Int, Long, Long)]
      .createOrReplaceTempView("t_kafka")

    val resultTable = spark.sql("select * from t_kafka")
    */

    /*
    resultTable
      .writeStream
      //.format("json") // filesink 文件格式,可以json csv orc parquet text
      .format("csv") // filesink 文件格式,可以json csv orc parquet text
      .outputMode(OutputMode.Append()) // filesink 只支持append输出模式
      .option("path", "file:///d://csv2")
      .option("checkpointLocation", "hdfs://SparkOnStandalone:9000/checkpoint5") // 写权限
      .start()
      .awaitTermination()
    */

    //***************************************************************
    // kafka source
    val df = spark
      .readStream
      .format("kafka")
      .option("kafka.bootstrap.servers", "HadoopNode00:9092")
      .option("subscribe", "test") // 默认 订阅test topic 所有分区
      .option("group.id", "g1") // 默认 订阅test topic 所有分区
      .load()

    df
      .selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)", "CAST(offset AS LONG)").as[(String, String, Long)]
      .flatMap(t3 => t3._2.split(" "))
      .map(word => (word, 1))
      .createOrReplaceTempView(q"t_word")

    val resultTable = spark.sql("select _1 as key, count(_2) as value from t_word group by _1")

    // (Hello,10)  record k = Hello  v = 10


    // kafka sink 输出模式:append  update  complete
    //****************************************************************
    /*
    resultTable
      .selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")
      .writeStream
      .format("kafka")
      .option("kafka.bootstrap.servers", "HadoopNode00:9092")
      .option("topic", "result")
      .option("checkpointLocation", "hdfs://SparkOnStandalone:9000/checkpoint6")
      .outputMode(OutputMode.Update())
      .start()
      .awaitTermination()
    */


    // 写入redis中
    //****************************************************************
    resultTable
      .writeStream
      .foreach(new ForeachWriter[Row] {
        // 允许对当前分区数据进行处理
        override def open(partitionId: Long, epochId: Long): Boolean = true

        // 处理  row 对象代表是resultTable中一行数据 Row("Hello",10)
        override def process(value: Row): Unit = {
          val word = value.getString(0)
          val num = value.getLong(1)
          // redis 插值API
          val jedis = new Jedis("SparkOnStandalone", 6379)
          jedis.set(word, num.toString)
          jedis.close()
        }

        // 当null或者错误回调close
        override def close(errorOrNull: Throwable): Unit = {
          if (errorOrNull != null) {
            errorOrNull.printStackTrace()
          }
        }
      })
      .outputMode(OutputMode.Update())
      .option("checkpointLocation", "hdfs://SparkOnStandalone:9000/checkpoint7")
      .start()
      .awaitTermination()
  }
}

SQL操作(略)

五、基于EventTime的窗口操作

基于事件时间的窗口操作

滑动event-time时间窗口的聚合在StructuredStreaming上很简单,并且和分组聚合非常相似。在分组聚合中,为用户指定的分组列中的每个唯一值维护聚合值(例如计数)。在基于窗口的聚合的情况下,为每一个event-time窗口维护聚合值

想象一下,quickexample中的示例被修改,现在stream中的每行包含了生成的时间。我们不想运行word count,而是要在10分钟的窗口内计数单词,每5分钟更新一次。也就是说,在10分钟窗口12:00 - 12:10,12:05 - 12:15,12:10 - 12:20等之间收到的单词中的字数。请注意,12:00 - 12:10是指在12:00之后但在12:10之前到达的数据。现在,考虑在12:07收到的一个word。这个词应该增加对应于两个窗口的计数,分别为12:00 - 12:10和12:05 - 12:15。所以计数counts将会被group key(ie:the word)和window(根据event-time计算)索引。将会如下所示
在这里插入图片描述
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Uny7XWvu-1582271199834)(assets/1575877030590.png)]

由于此窗口类似于分组,因此在代码中,可以使用groupBy()和window()操作来表示窗口聚合。如:

package window

import java.sql.Timestamp
import java.text.SimpleDateFormat

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.OutputMode

object WindowOnEventTime {
  def main(args: Array[String]): Unit = {
    val spark = SparkSession.builder().appName("window on event time").master("local[*]").getOrCreate()
    spark.sparkContext.setLogLevel("OFF")
    import spark.implicits._

    // 构建流数据源
    // 巧妙设计:发送每一条记录包含 数据+时间
    // 记录 = 单词,eventTime
    // 如: Hello,1575862650000  11:37:30
    //     Spark,1575862648000  11:37:28
    // 11:37:30
    val df = spark.readStream.format("socket").option("host", "SparkOnStandalone").option("port", "8888").load()

    import org.apache.spark.sql.functions._

    df
      .map(row => { // Hello,1575862650000 =>(Hello,Timestamp(1575862650000))
        val line = row.getString(0)
        val arr = line.split(",")
        val word = arr(0)
        val timestamp = arr(1).toLong
        (word, new Timestamp(timestamp))
      })
      .toDF("word", "timestamp")
      .groupBy(window($"timestamp", "10 seconds", "5 seconds"), $"word") // 指定分组规则:窗口(10s,5s) + 单词
      .count()
        // .printSchema()  // Struct(Window) | Word |  Count
        .map(row => {
        val start = row.getStruct(0).getTimestamp(0)
        val end = row.getStruct(0).getTimestamp(1)
        val word = row.getString(1)
        val count = row.getLong(2)
        (new SimpleDateFormat("HH:mm:ss").format(start), new SimpleDateFormat("HH:mm:ss").format(end), word, count)
      })
      .toDF("start", "end", "word", "count")
      .writeStream
      .format("console")
      .outputMode(OutputMode.Complete())
      .start()
      .awaitTermination()
  }
}

六、处理延迟数据和水位线

处理延迟数据和水位线

默认: 延迟数据累加到窗口计算中,并且Spark在内存保存所有窗口计算的中间结果

现在考虑如果一个事件迟到应用程序会发生什么。例如,假设12:04(即event-time)生成的一个word可以在12:11被应用程序接收。应用程序应该使用时间12:04而不是12:11更新窗口的较旧计数,即12:00 - 12:10。这在我们基于窗口的分组中很自然有可能发生- Structured Streaming可以长时间维持部分聚合的中间状态,以便延迟的数据可以正确地更新旧窗口的聚合,如下所示:

但是,为了长久的运行这个查询,必须限制内存中间状态的数量。这就意味着,系统需要知道什么时候能够从内存中删除旧的聚合,此时默认应用接受延迟的数据之后不再进行聚合。Spark2.1中引入了watermarking(水位线),它能够让engine自动跟踪当前的数据中的event time并据此删除旧的状态表。你可以通过指定event-time列和时间阀值来指定一个查询的watermark,阀值以内的数据才会被处理。对于一个特定的开始于时间T的window窗口,引擎engine将会保持状态并且允许延迟的数据更新状态直到(max event time seen by the engine - late threshold > T)。换句话说,阀值内的数据将被聚合,阀值外的数据将会被丢弃。

wm(水位线) = 最大的事件时间-数据的延迟时间

作用: 界定过期数据和有效数据的一种规则

  • 水位线以内的延迟数据为有效数据,参与窗口的计算
  • 水位线以外的数据为无效数据,直接丢弃,水位线以外的窗口会自动drop
import spark.implicits._

val words = ... // streaming DataFrame of schema { timestamp: Timestamp, word: String }

// Group the data by window and word and compute the count of each group
val windowedCounts = words
    .withWatermark("timestamp", "10 minutes")
    .groupBy(
        window($"timestamp", "10 minutes", "5 minutes"),
        $"word")
    .count()

本例中,watermark的指定列为“timestamp”,并且指定了“10minute”作为阀值。如果这个查询运行在update的输出模式,引擎engine会持续更新window的counts到结果集中,直到窗口超过watermark的阀值,本例中,则是如果timestamp列的时间晚于当前时间10minute。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qEHXOzsa-1582271199836)(assets/1575876333812.png)]

如上所示,蓝色虚线表示最大event-time,每次数据间隔触发开始时,watermark被设置为max eventtime - ‘10 mins’,如图红色实线所示。例如,当引擎engine观测到到数据(12:14,dog),对于下个触发器,watermark被设置为12:04。这个watermark允许引擎保持十分钟内的中间状态并且允许延迟数据更新聚合集。例如(12:09,cat)的数据未按照顺序延迟到达,它将落在12:05 – 12:15 和 12:10 – 12:20 。因为它依然大于12:04 ,所以引擎依然保持着中间结果集,能够正确的更新对应窗口的结果集。但是当watermark更新到12:11,中间结果集12:00-12:10的数据将会被清理掉,此时所有的数据(如(12:04,donkey))都会被认为“too late”从而被忽略。注意,每次触发器之后,更新的counts(如 purple rows)都会被写入sink作为输出的触发器,由更新模式控制。

某些接收器(例如文件)可能不支持更新模式所需的细粒度更新。要与他们一起工作,我们还支持append模式,只有最后的计数被写入sink。这如下所示。

请注意,在非流数据集上使用watermark是无效的。由于watermark不应以任何方式影响任何批次查询,我们将直接忽略它。

七、Join Operations

流和批连接操作

Streaming DataFrames可以与静态 DataFrames连接,以创建新的Streaming DataFrames。 例如下面的例子:

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.types.{BooleanType, StructType}

object SparkStructuredStreamingForJoinOpt {
  def main(args: Array[String]): Unit = {
    val spark = SparkSession.builder().appName("join opt").master("local[*]").getOrCreate()

    val df1 = spark   // 基于静态文件创建DF
      .read
      .format("json")
      .load("file:///G:\\IDEA_WorkSpace\\scala-workspace\\spark-day11\\src\\main\\resources") // id name sex

    val df2 = spark  // 基于流数据创建DF
      .readStream
      .format("csv")
      .schema(
        new StructType()
          .add("id", "integer")
          .add("name", "string")
          .add("sex", BooleanType)
          .add("salary", "double")
      )
      .csv("file:///d://csv")

    // 批和流不允许Join【流数据不能join给批数据】
    // 正常:         【批数据join给流数据】
    df2.join(df1,Seq("id","id"),"leftOuter")  // 流DF join 批DF
      .writeStream
      .format("console")
      .outputMode("append")
      .start()
      .awaitTermination()
  }
}

//----------------------------------------------------------------------
Batch: 0
-------------------------------------------
+---+---+----+------+----+------+
| id| id|name|salary|name|salary|
+---+---+----+------+----+------+
|  1|  1|  zs|1000.0|  zs|3000.0|
|  2|  2|  ls|2000.0|  ls|3000.0|
|  3|  3|  ww|3000.0|  ww|3000.0|
|  4|  4|  zs|1000.0| zs2|3000.0|
|  5|  5|  ls|2000.0| ls2|3000.0|
|  6|  6|  ww|3000.0| ww2|3000.0|
|  4|  4|  zs|3000.0| zs2|3000.0|
|  7|  7|  ls|2000.0|null|  null|
|  8|  8|  ww|3000.0|null|  null|
+---+---+----+------+----+------+

.start()
.awaitTermination()
}
}

//----------------------------------------------------------------------
Batch: 0

±–±--±—±-----±—±-----+
| id| id|name|salary|name|salary|
±–±--±—±-----±—±-----+
| 1| 1| zs|1000.0| zs|3000.0|
| 2| 2| ls|2000.0| ls|3000.0|
| 3| 3| ww|3000.0| ww|3000.0|
| 4| 4| zs|1000.0| zs2|3000.0|
| 5| 5| ls|2000.0| ls2|3000.0|
| 6| 6| ww|3000.0| ww2|3000.0|
| 4| 4| zs|3000.0| zs2|3000.0|
| 7| 7| ls|2000.0|null| null|
| 8| 8| ww|3000.0|null| null|
±–±--±—±-----±—±-----+


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值