Project Tungsten On Spark-内存设计

最近对线上spark作业的GC长达十几分钟(主要是频繁的Young GC)问题进行了一些优化,其中涉及到了spark内存模型的知识点,这里做一个复盘总结。关于spark内存优化不得不提到Project Tungsten(钨丝计划),因为spark的内存模型属于该项目的一个优化点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fHU89YJO-1606041526841)(https://imgkr2.cn-bj.ufileos.com/9b03c47c-7a9e-4061-95ad-f63539b853cd.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=wwOaB%252FSDirPt%252BknJh2rj9a6UKAc%253D&Expires=1605937919)]

一、Tungsten起源背景

众所周知,Spark是由Scala+Java开发的一种基于内存计算的大数据解决方案,底层运行在JVM上,那么自然而然的会有GC的问题反过来限制Spark的性能,而且绝大多数Spark应用程序的主要瓶颈不在于IO/网络,而是在于CPU和内存。此时Project Tungsten由DataBricks提出,并在Spark1.5中引入,在1.6.X对内存进行了优化,在2.X对CPU进行了优化,也就是说该项目主要是针对于CPU和Memory进行优化,具体优化集中在以下三个方面:

  1. Memory Management And Binary Processing(内存管理和二进制处理),也是本篇主要总结的重点
  2. Cacahe-aware Computation(缓存感知计算):使用了友好的数据结构和算法来完成数据的存储和复用,提升缓存命中率
  3. Code Generation(代码生成):扩展了更多的表达式求值和SQL操作,把已有的代码变成本地的字节,不需要很多的抽象和匹配等,避免了昂贵的虚拟函数调用

二、Tungsten优化-Memory

本篇主要讲述tungsten在内存这块的优化点,以及spark是如何进行内存分配的(On-Heap和Off-Heap结合,Storage/Executor/Other划分),通过何种方式的寻址(通过引入了Page table来管理On-Heap和Off-Heap)来实现的统一内存管理。

2.0 堆划分

这里先复习一下Spark运行的整体流程:

  1. 通过spark-submit命令提交Spark作业,启动Driver(根据不同的模式如yarn-client,yarn-cluster,启动点不同),生成SparkContext对象(这里会进行DAG–>Stage–>Task划分)
  2. SparkContext和Cluster Manager进行通信,申请资源以及后续的任务分配和监控,并在指定Worker节点上启动Executor
  3. SparkContext在实例化的时候,会构建DAG,并分解为多个Stage,并把每个Stage中的TaskSet发送给TaskScheduler
  4. Executor向Driver申请Task,然后Driver将应用程序以及相关依赖包发送到Executor端,并在Executor端执行task
  5. Executor将task运行结果反馈给TaskScheduler,然后再反馈给DAGScheduler
  6. 当整个作业结束后,SparkContext会向ClusterManager注销并释放所有资源

从运行的整体流程上看,Driver端的工作主要是负责创建SparkContext,提交作业以及协调任务;Executor端的工作主要就是执行task。从内存使用的角度上看,Executor端的内存设计相当比较复杂些,所以本文也将基于Executor的内存进行概述(本文中讲到Spark内存指的也是Executor端的内存)。

那么接下来再针对Executor端的内存设计进行拆解,见下图:
在这里插入图片描述

Worker节点启动的Executor其实也是一个JVM进程,因此Executor的内存管理是建立在JVM内存管理上的(On-Heap堆内内存),同时Spark也引入了Off-Heap(堆外内存),使之可以直接在系统内存中开辟空间,避免了在数据处理过程中不必要的序列化和反序列化的开销,同时也降低了GC的开销。

2.0.1 On-Heap(堆内)

Spark对堆内内存的管理其实只是一种逻辑上的管理,内存的申请和释放都是由JVM来完成的,而Spark只是在申请后和释放前记录这些内存。

  1. 申请内存后:如创建一个对象,JVM从堆内内存中分配空间,并返回对象的引用,而Spark会保存该对象的引用,记录该对象占用的内存
  2. 释放内存前:spark删除该对象的引用,记录该对象释放的内存,等待JVM来真正释放掉该对象占用的内存

这里需要说明一下spark关于序列化的一个小知识点:

经过序列化的对象,是以字节流的形式存在,占用的内存大小是可以直接计算,而对于非序列化的对象,占用的内存只能通过周期性地采样近似估算得到的,也就是说每次新增的数据项都会计算一次占用的内存大小,这种方式会有一定的误差,可能会导致某一时刻的实际内存超过预期。当被Spark标记为释放的对象实例时,也有可能没有被JVM回收,导致实际可用的内存小于spark记录的可用内存,造成OOM的发生。

为了减少OOM异常的发生,Spark对堆内内存再次进行了划分(即分为Storage,Executor,Other,下一小节将进行详解),通过内存划分方式各自规划管理来提升内存的利用率。

2.0.2 Off-Heap(堆外)

为了解决基于JVM托管方式存在的缺陷,Tungsten引入了基于Off-Heap管理内存的方式,通过sun.misc.Unsafe管理内存,这样可以使得Spark的operation直接使用分配的二进制数据,而不是JVM对象,降低了GC带来的开销。而且对于序列化的数据占用的空间可以被精准计算,相对堆内内存来说降低了管理难度。当然默认情况堆外内存是没有启用的,需要通过配置参数spark.memory.offHeap.enabled来启用.

2.1 内存划分

在这里插入图片描述

根据内存使用目的不同,对堆内外内存进行了如上图的划分:

针对堆内内存来说,划分了4块:

  1. 存储内存(Storage Memory):该部分的内存主要是用于缓存或者内部数据传输过程中使用的,比如缓存RDD或者广播数据
  2. 执行内存(Execution Memory):该部分的内存主要用于计算的内存,包括shuffles,Joins,sorts以及aggregations
  3. 其他内存(Other Memory):该部分的内存主要给存储用户定义的数据结构或者spark内部的元数据
  4. 预留内存:和other内存作用是一样的,但由于spark对堆内内存采用估算的方式,所以提供了预留内存来保障有足够的空间

针对堆外内存来说,划分了2块(前面也提到过了spark对堆外内存的使用可以精准计算):

  1. 存储内存(Storage Memory)
  2. 执行内存(Execution Memory)
2.2 内存管理

Tungsten使用了Off-Heap使得spark实现了自己独立的内存管理,避免了GC引发的性能问题,省去了序列化和反序列化的过程。Spark1.6版本之前使用了静态内存管理模式,而在此之后使用统一内存管理模型,并,可以直接操作内存中的二进制数据,而不是Java对象,很大程度上摆脱了JVM内存管理的限制。

2.2.1 Spark1.6之前-静态内存管理模型

下面的两张图相信有些读者已经很熟悉了。

静态内存模型最大的特点就是:堆内内存中的每个区域的大小在spark应用程序运行期间是固定的,用户可以在启动前进行配置;这也需要用户对spark的内存模型非常熟悉,否则会因为配置不当造成严重后果

对于堆内内存区域的划分以及比例如下图:

  1. 存储内存(Storage Memory):
    默认情况下,存储内存占用整个堆内存的60%(该占比由spark.storage.memoryFraction来控制),主要用了存储缓存的RDD或者广播数据;
  2. 执行内存(Execution Memory):
    默认情况下,执行内存占用整个堆内存的20%(该占比由spark.shuffle.memoryFraction来控制),主要用来存储进行shuffle计算的内容
  3. 预留内存
    默认情况下,预留内存占用整个堆内存的20%(该占比取决于上面两个内存区域的大小),主要用来存储一些元数据或者用户定义的数据结构

这里需要说一下Unroll过程:RDD以Block形式被缓存到存储内存,Record在堆内或堆外存储内存中占用一块连续的空间。把Partition由不连续的存储空间转换为连续空间的过程就是Unroll,也称之为展开操作
在这里插入图片描述

对于堆外内存的划分,比较简单,即只有存储内存和执行内存(具体原因上文也已经讲到了,即spark对堆外内存的使用计算是比较精确的,所以不需要额外的预留空间来避免OOM的发生)
在这里插入图片描述

源码实现

基类MemoryManager封装了静态内存管理模型和统一内存管理模型,而StaticMemoryManager类负责实现静态内存模型,UnifiedMemoryManager类实现统一内存模型。具体采用哪种内存分配由tungstenMemoryMode来决定,即由MemoryAllocator来负责具体分配(分别实现了两个子类),其中allocate和free函数来提供内存的分配和释放,分配的内存以MemoryBlock来表示。
在这里插入图片描述

静态内存模型管理器-StaticMemoryManager类

//Unroll过程中可用的内存,占最大Storage内存的0.2
private val maxUnrollMemory: Long = {
  (maxOnHeapStorageMemory * conf.getDouble("spark.storage.unrollFraction", 0.2)).toLong
}

//获取最大的Storage内存
private def getMaxStorageMemory(conf: SparkConf): Long = {
  val systemMaxMemory = conf.getLong("spark.testing.memory", Runtime.getRuntime.maxMemory)
  val memoryFraction = conf.getDouble("spark.storage.memoryFraction", 0.6) //Storage内存占全部内存占比
  val safetyFraction = conf.getDouble("spark.storage.safetyFraction", 0.9) //Storage内存的安全系数
  (systemMaxMemory * memoryFraction * safetyFraction).toLong
}

//获取最大的Execution内存
private def getMaxExecutionMemory(conf: SparkConf): Long = {
  val systemMaxMemory = conf.getLong("spark.testing.memory", Runtime.getRuntime.maxMemory)
  if (conf.contains("spark.executor.memory")) {
    val executorMemory = conf.getSizeAsBytes("spark.executor.memory")
  }
  val memoryFraction = conf.getDouble("spark.shuffle.memoryFraction", 0.2) //Execution内存占全部内存占比
  val safetyFraction = conf.getDouble("spark.shuffle.safetyFraction", 0.8) //Execution内存的安全系数
  (systemMaxMemory * memoryFraction * safetyFraction).toLong
}

Spark1.6之后-统一内存管理模型

关于新的统一内存管理模型,有兴趣的读者可以参考https://issues.apache.org/jira/secure/attachment/12765646/unified-memory-management-spark-l0000.pdf

统一内存模型和静态内存模型的区别在于:存储内存和执行内存共享同一块空间(占堆内内存的60%),且可以动态占用对方的空闲区域

统一内存模型关于堆内内存区域的划分,这里有以下几点需要注意:

  1. 执行内存和存储内存共享同一空间,该空间占用可用内存的60%,例如我们设置1G内存,那么Execution和Storage共用内存就是(1024-300)*0.6 = 434MB
  2. 执行内存和存储内存可以互相占用对方空闲空间
  3. 存储内存可以借用执行内存,直到执行内存重新占用它的空间为止。当发生这种情况的时候,缓存块将从内存中清除,直到足够的借入内存被释放,满足执行内存、请求内存的需要
  4. 执行内存可以借用尽可能多空闲的存储内存,但是执行内存由于执行操作所涉及的复杂性,执行内存永远不会被存储区逐出,也就是说如果执行内存已经占用存储内存的大部分空间,那么缓存块就会有可能失败,在这种情况下,根据存储级别的设置,新的块会被立即逐出内存
  5. 虽然存储内存和执行内存共享同一空间,但是会存在一个初始边界值,具体可见UnifiedMemoryManager.apply方法
    在这里插入图片描述

统一内存模型对于堆外内存的设计和静态内存模型是一样的,这里不再重复介绍了
在这里插入图片描述

源码实现-UnifiedMemoryManager

private def getMaxMemory(conf: SparkConf): Long = {
  val systemMemory = conf.getLong("spark.testing.memory", Runtime.getRuntime.maxMemory)
  //系统预留的内存大小,默认为300MB
  val reservedMemory = conf.getLong("spark.testing.reservedMemory",
                                    if (conf.contains("spark.testing")) 0 else RESERVED_SYSTEM_MEMORY_BYTES)
  
  //当前最小的内存需要300*1.5,即450MB,不满足条件就会退出
  val minSystemMemory = (reservedMemory * 1.5).ceil.toLong
  if (systemMemory < minSystemMemory) {
      throw new IllegalArgumentException(s"System memory $systemMemory must " +
        s"be at least $minSystemMemory. Please increase heap size using the --driver-memory " +
        s"option or spark.driver.memory in Spark configuration.")
    }
  
  // SPARK-12759 Check executor memory to fail fast if memory is insufficient
  if (conf.contains("spark.executor.memory")) {
    val executorMemory = conf.getSizeAsBytes("spark.executor.memory")
    if (executorMemory < minSystemMemory) {
        throw new IllegalArgumentException(s"Executor memory $executorMemory must be at least " +
          s"$minSystemMemory. Please increase executor memory using the " +
         
  }
            
  val usableMemory = systemMemory - reservedMemory
                                           
  //当前Execution和Storage共享的最大内存占比默认为0.6                                        
  val memoryFraction = conf.getDouble("spark.memory.fraction", 0.6)
  (usableMemory * memoryFraction).toLong
}
                                           
def apply(conf: SparkConf, numCores: Int): UnifiedMemoryManager = {
  val maxMemory = getMaxMemory(conf)
  new UnifiedMemoryManager(
    conf,
    maxHeapMemory = maxMemory,
    //通过配置参数spark.memory.storageFraction,设置Execution和Storage共享内存初始边界,即默认各占总内存一半
    onHeapStorageRegionSize =
      (maxMemory * conf.getDouble("spark.memory.storageFraction", 0.5)).toLong,
    numCores = numCores)
}
2.3 内存寻址

以上小节介绍了spark对堆的划分,根据使用目的不同,对堆进行了区域划分,并说明了spark1.6之前和之后使用的两种不同内存模型管理以及之间的区别,那么这里继续逐步分析,说到内存管理,spark是如何通过进行内存寻址,内存块是如何封装的,通过何种方式来组织管理这些内存块?

问1:如何进行内存寻址的?

答:这里需要再次回到Project Tungsten计划中,由于spark引入了Off-Heap内存模式,为了方便统一管理On-Heap和Off-Heap这两种模式,Tungsten引入了统一的地址表示形式,即通过MemoryLocation类来表示On-Heap或Off-Heap两种内存模式下的地址,该类中有两个属性obj和offset,当处于On-Heap模式下,通过使用obj(即为对象的引用)和64 bit的offset来表示内存地址,当处于Off-Heap模式下,直接使用64 bit的offset绝对地址来描述内存地址。而这个64bit 的offset在对外编码时,前13 bit用来表示Page Number,后51 bit用来表示对应的offset

问2:内存块是如何封装的?

答:在Project Tungsten内存管理中,会使用一块连续的内存空间来存储数据,通过MemoryBlock类对内存块进行封装。该类继承了MemoryLocation类,采用了组合复用方式,即指定了内存块的地址,也提供了内存块本身的内存大小

问3:如何组织管理内存块?

答:Project Tungsten采用了类似于操作系统的内存管理模式,使用Page Table方式(其实本质就是一个数组,从源码中就可以看出)来管理内存,内部以Page来管理存储内存块(通过MemoryBlock来封装),通过pageNumber找到对应的Page,Page内部会根据Off-Heap或On-Heap两种模式分别存储Page对应内存块的起始地址(或对象内偏移地址)

在spark中,数据是以分区进行处理的,而每个分区对应一个task,所以对于内存的组织和管理可以借助于TaskMemoryManager来理解,同时以上这些疑惑均可以在TaskMemoryManager类(该类对MemoryManager又做了一层封装)中找到解答。
在这里插入图片描述

源码实现-TaskMemoryManager
在这里插入图片描述

属性信息

//Page Number长度位数
private static final int PAGE_NUMBER_BITS = 13;

//Offset位数
static final int OFFSET_BITS = 64 - PAGE_NUMBER_BITS;

//Page Table底层实现,其实就是一个MemoryBlock数组
private final MemoryBlock[] pageTable = new MemoryBlock[PAGE_TABLE_SIZE];

//真正进行内存分配和释放
private final MemoryManager memoryManager;

// 内存模式
final MemoryMode tungstenMemoryMode;

分配页

//调用ExectorMemoryManager进行内存分配,分配得到一个内存页,并将其添加到page table中,以便内存地址映射
public MemoryBlock allocatePage(long size, MemoryConsumer consumer) {
  assert(consumer != null);
  assert(consumer.getMode() == tungstenMemoryMode);
  if (size > MAXIMUM_PAGE_SIZE_BYTES) {
    throw new TooLargePageException(size);
  }
  //申请一定的内存量
  long acquired = acquireExecutionMemory(size, consumer);
  if (acquired <= 0) {
    return null;
  }

  final int pageNumber;
  synchronized (this) {
    //获取当前未被分配的页码
    pageNumber = allocatedPages.nextClearBit(0);
    if (pageNumber >= PAGE_TABLE_SIZE) {
      releaseExecutionMemory(acquired, consumer);
    }
    //设置该页码已经被占用
    allocatedPages.set(pageNumber);
  }
  MemoryBlock page = null;
  try {
    //开始通过MemoryAllocator进行真正的内存分配,注意:这里并不是真正的内存分配,只是控制内存使用大小而已
    page = memoryManager.tungstenMemoryAllocator().allocate(acquired);
  } catch (OutOfMemoryError e) {
    //当没有足够的内存时,应该保持获得的内存
    synchronized (this) {
      acquiredButNotUsed += acquired;
      allocatedPages.clear(pageNumber);
    }
    //触发溢出,释放一些页面
    return allocatePage(size, consumer);
  }
  //分配得到内存块后,会设置该内存块对应的page number
  page.pageNumber = pageNumber;
  pageTable[pageNumber] = page;
  return page;
}

释放页

public void freePage(MemoryBlock page, MemoryConsumer consumer) {
    //首先断言确定要释放的内存块在pageTable中,即页码必须有效
    assert (page.pageNumber != MemoryBlock.NO_PAGE_NUMBER) :
      "Called freePage() on memory that wasn't allocated with allocatePage()";
    assert (page.pageNumber != MemoryBlock.FREED_IN_ALLOCATOR_PAGE_NUMBER) :
      "Called freePage() on a memory block that has already been freed";
    assert (page.pageNumber != MemoryBlock.FREED_IN_TMM_PAGE_NUMBER) :
            "Called freePage() on a memory block that has already been freed";
    assert(allocatedPages.get(page.pageNumber));
    pageTable[page.pageNumber] = null;
    //控制Page Table中对应的位置是否可用,这里考虑到释放和分配的并发性,需要同步处理
    synchronized (this) {
      allocatedPages.clear(page.pageNumber);
    }
    if (logger.isTraceEnabled()) {
      logger.trace("Freed page number {} ({} bytes)", page.pageNumber, page.size());
    }
    long pageSize = page.size();

    page.pageNumber = MemoryBlock.FREED_IN_TMM_PAGE_NUMBER;

    //通过MemoryAllocator真正释放内存块
    memoryManager.tungstenMemoryAllocator().free(page);
    releaseExecutionMemory(pageSize, consumer);
  }

地址编码

//给定分配到的内存页和页内偏移,生成一个64 bits的逻辑地址
public long encodePageNumberAndOffset(MemoryBlock page, long offsetInPage) {
    if (tungstenMemoryMode == MemoryMode.OFF_HEAP) {
      offsetInPage -= page.getBaseOffset();
    }
    return encodePageNumberAndOffset(page.pageNumber, offsetInPage);
}

//高13bits是page number,低51bits是页内偏移
public static long encodePageNumberAndOffset(int pageNumber, long offsetInPage) {
  assert (pageNumber >= 0) : "encodePageNumberAndOffset called with invalid page";
  return (((long) pageNumber) << OFFSET_BITS) | (offsetInPage & MASK_LONG_LOWER_51_BITS);
}

地址解码

//给定逻辑地址,获取page number
public static int decodePageNumber(long pagePlusOffsetAddress) {
  return (int) (pagePlusOffsetAddress >>> OFFSET_BITS);
}
//给定逻辑地址,获取页内偏移
private static long decodeOffset(long pagePlusOffsetAddress) {
  return (pagePlusOffsetAddress & MASK_LONG_LOWER_51_BITS);
}
//获取对象的引用,对于Off-Heap模式,则返回Null
public Object getPage(long pagePlusOffsetAddress) {
  if (tungstenMemoryMode == MemoryMode.ON_HEAP) {
    //从地址中解析出PageNumber
    final int pageNumber = decodePageNumber(pagePlusOffsetAddress);
    assert (pageNumber >= 0 && pageNumber < PAGE_TABLE_SIZE);

    //根据页码从pageTable中获取内存块
    final MemoryBlock page = pageTable[pageNumber];
    assert (page != null);
    assert (page.getBaseObject() != null);

    //获取内存块对应的Object
    return page.getBaseObject();
  } else {
    //如果是Off-Heap模型,则Obj为null
    return null;
  }
}
2.4 参数配置

更多参数可以在org.apache.spark.internal.config类中查找,这里只给出涉及到该部分内容的相关参数

参数描述
spark.memory.useLegacy内存模型开关,1.6之前使用静态内存模型,1.6之后使用统一内存模型;主要是为了向后兼容,默认为false
spark.shuffle.memoryFractionExecution内存,默认占用堆内内存的20%。1.6之前的配置
spark.shuffle.safetyFraction用于缓存在Shuffle过程中的中间数据,默认占用Execution总内存的90%。1.6之前的配置
spark.storage.memoryFractionStorage总内存,默认占用堆内内存的60%。1.6之前的配置
spark.storage.safetyFraction可缓存RDD数据和广播数据的大小,默认占用Storage总内存的90%。1.6之前的配置
spark.storage.unrollFraction缓存iterator形式的Block数据,默认占用Storage总内存的20%。1.6之前的配置
spark.memory.fraction堆内的存储内存和执行内存总共所占的比例,1.6版本默认为0.75,2.0之后默认为0.6。
spark.memory.storageFractionStorage内存,默认占用堆外可用内存的50%
spark.testing.memory该参数用于测试,一般可以认为是executor 的最大可用内存
spark.memory.offHeap.size堆外内存大小,需要结合spark.memory.offHeap.enabled来使用
spark.memory.offHeap.enabled堆外内存启用开关,默认为false
spark.executor.memoryExecutor内存大小,也就是堆内内存设置
spark.storage.storageFraction用于缓存数据的内存比例,1.6.X之后默认0.5。

以上是对Project Tungsten针对内存的设计,笔者总结的可能不是很全面。关于二进制处理这块的内容笔者考虑将以单独的篇幅讲解,避免文章过长,内容过多不易读者消化。

三、Tungsten优化-CPU

3.1 缓存感知计算Cache-aware computation

缓存感知计算通过使用L1/L2/L3 CPU缓存来提升速度,同时也可以处理超过寄存器大小的数据。在Spark开发者们在做性能分析的时候发现大量的CPU时间会因为等待从内存中读取数据而浪费,所以在Tungsten项目中,通过设计了更加友好的缓存算法和数据结构,让spark花费更少的时间等待cpu从内存中读取数据,提供了更多的计算时间。

缓存感知计算的解析

网上有很多关于缓存感知的说明,都是以KV排序为例,那么笔者这里也使用同样的例子再结合自己的理解,尽量解释的通俗易通。
在这里插入图片描述

3.2 代码生成Code Generation

代码生成指的是在运行时,spark会动态生成字节码,而不需要通过解释器对原始数据类型进行打包,同时也避免了虚拟函数的调用。当然该技术的优势并不止于此,还包括了将中间数据从存储器移动到CPU寄存器;使用向量化技术,利用现代CPU功能加快了对复杂操作运行速度

这里以一个sql为例,可以通过explain来查看spark在哪些过程中使用了代码生成的功能。

当算子前面有一个*时,说明全阶段代码生成被启用,在下面的例子中,Exchange算子没有实现代码生成,这是因为这里会发生Shuffle,需要通过网络发送数据。

select count(1) from tmp.user where id='123'

== Physical Plan ==
*(2) HashAggregate(keys=[], functions=[count(1)])
+- Exchange SinglePartition
   +- *(1) HashAggregate(keys=[], functions=[partial_count(1)])
      +- *(1) Project
         +- *(1) Filter ((id#2802514 = 123))
            +- HiveTableScan [id#2802514], HiveTableRelation `tmp`.`user`, org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe, [id#2802514]

关于底层的一些实现,读者如果感兴趣可以自行查阅资料,笔者这里不再做总结。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

进击吧大数据

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

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

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

打赏作者

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

抵扣说明:

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

余额充值