postgreSQL源码分析——存储管理——内存管理(7)

2021SC@SDUSC

概述

这次来分析一下postgreSQL对表的一些操作。

源码分析

接口函数位于src/backend/access/heap/heapam.c

打开表

位于src/backend/access/table/table.c
表的打开并不是打开具体的物理文件,而是返回一个RelationData结构体。

//为打开表的接口函数
Relation
table_open(Oid relationId, LOCKMODE lockmode)
{//两个参数为OID和锁类型
	Relation	r;//待返回的关系描述符
	//通过调用relation_open函数从RelCache中找到相应的关系描述符
	r = relation_open(relationId, lockmode);
	//如果找到的关系为索引或者分区索引类型,则报错
	if (r->rd_rel->relkind == RELKIND_INDEX ||
		r->rd_rel->relkind == RELKIND_PARTITIONED_INDEX)
		ereport(ERROR,
				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
				 errmsg("\"%s\" is an index",
						RelationGetRelationName(r))));
	//如果找到的关系为复合类型则报错
	else if (r->rd_rel->relkind == RELKIND_COMPOSITE_TYPE)
		ereport(ERROR,
				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
				 errmsg("\"%s\" is a composite type",
						RelationGetRelationName(r))));
	//将找到的关系描述符返回
	return r;
}

该函数根据表的OID打开,调用relation_open函数,该函数根据表的OID从RelCache中找到相应的关系描述符,并将引用计数加一。如果是第一次打开,会在RelCache中创建一个新的RelationData,并将引用计数设置为1。然后根据第二个参数,即锁的类型进行上锁。

还有另外一种打开表的方法:

  • table_openrv通过一个范围节点来打开一个表

扫描表

扫描表的目的就是从表中获得需要的元组。
扫描表的方法有两种,第一种是不依赖索引的扫描(顺序扫描),第二种是以索引为基础的扫描(索引扫描)。
由于顺序扫描的内容比较多,所有这次就只分析顺序扫描。

顺序扫描

下面的结构体位于src/include/access/heapam.h中。

typedef struct HeapScanDescData
{
	TableScanDescData rs_base;	//描述符访问方法的独立部分
	//该结构体的详细分析在下面
	
	//初始化扫描参数
	BlockNumber rs_nblocks;		//该表的块数
	BlockNumber rs_startblock;	//开始扫描的块号
	BlockNumber rs_numblocks;	//要扫描的最大的块数
	//当rs_numblocks为InvalidBlockNumber时,为扫描整个表

	//记录当前的扫描状态
	bool		rs_inited;		//如果扫描还未初始化则为false
	BlockNumber rs_cblock;		//当前扫描到的块号
	Buffer		rs_cbuf;		//当前扫描的缓冲区
	//当rs_cbuf不为NULL的时候,会pin住当前扫描的缓冲区


	BufferAccessStrategy rs_strategy;	//缓冲区读取访问策略

	HeapTupleData rs_ctup;		//当前扫描的元组

	//用于page-at-a-time模式和位图扫描
	int			rs_cindex;		//当前元组在rs_vistuples数组中的位置
	int			rs_ntuples;		//当前块内可见的元组数
	OffsetNumber rs_vistuples[MaxHeapTuplesPerPage];	//存储当前块内可见的元组偏移数组
}			HeapScanDescData;
typedef struct HeapScanDescData *HeapScanDesc;//声明该结构体的指针类型

上面用到的TableScanDescData结构体

typedef struct TableScanDescData
{
	//扫描参数
	Relation	rs_rd;			//表的描述符
	struct SnapshotData *rs_snapshot;	//快照,前面博客分析过
	int			rs_nkeys;		//扫描键的个数
	struct ScanKeyData *rs_key; //扫描键描述符的数组

	 //关于扫描的类型和行为的信息,是ScanOptions类型的一位掩码(src/include/access/tableam.h)
	uint32		rs_flags;
	//比较重要的为下面三个
	//SO_ALLOW_STRAT = 1 << 4,  允许使用缓冲区控制策略
	//SO_ALLOW_SYNC = 1 << 5,   允许使用同步扫描
	//SO_ALLOW_PAGEMODE = 1 << 6, 验证page-at-a-time的可见性
	
	struct ParallelTableScanDescData *rs_parallel;	//并行扫描信息

} TableScanDescData;
typedef struct TableScanDescData *TableScanDesc;

这两个结构体保存了表的基本信息以及当前扫描状态,后面以扫描描述符替代。
其实内部隐藏了两种扫描策略,rs_flags中的注释我说明了同步扫描策略。如果不开启同步扫描策略则为默认扫描策略。

初始化扫描

TableScanDesc
heap_beginscan(Relation relation, Snapshot snapshot,
			   int nkeys, ScanKey key,
			   ParallelTableScanDesc parallel_scan,
			   uint32 flags)
//参数分别为:relation-要扫描的表的描述符,snapshot-快照,nkeys-扫描键的个数
//key-扫描键,parallel_scan-并行扫描描述符,flags-扫描的类型和行为信息
{
	//存放生成的扫描描述符
	HeapScanDesc scan;

	 //增加该表的引用数
	RelationIncrementReferenceCount(relation);
	
	 //为扫描描述符分配内存空间,并且将其初始化
	scan = (HeapScanDesc) palloc(sizeof(HeapScanDescData));
	//初始化描述符中要扫描的表
	scan->rs_base.rs_rd = relation;
	//初始化描述符的快照
	scan->rs_base.rs_snapshot = snapshot;
	//初始化描述符中扫描键的个数
	scan->rs_base.rs_nkeys = nkeys;
	//初始化描述符中记录的扫描的类型和行为信息
	scan->rs_base.rs_flags = flags;
	//初始化描述符中并行扫描的描述符
	scan->rs_base.rs_parallel = parallel_scan;
	//缓冲区策略设置为NULL,因为是初始化扫描
	scan->rs_strategy = NULL;	

	 //如果不是MVCC安全的快照,则不允许page-at-a-time模式
	if (!(snapshot && IsMVCCSnapshot(snapshot)))
		scan->rs_base.rs_flags &= ~SO_ALLOW_PAGEMODE;

	 //如果是顺序扫描或者样本扫描
	if (scan->rs_base.rs_flags & (SO_TYPE_SEQSCAN | SO_TYPE_SAMPLESCAN))
	{
		Assert(snapshot);
		//需要获取谓词锁
		PredicateLockRelation(relation, snapshot);
	}

	//设置当前扫描元组的表的OID
	scan->rs_ctup.t_tableOid = RelationGetRelid(relation);

	 //如果有扫描键
	if (nkeys > 0)
		//为扫描键数组分配内存空间
		scan->rs_base.rs_key = (ScanKey) palloc(sizeof(ScanKeyData) * nkeys);
	else
		//没有扫描键则设置为NULL
		scan->rs_base.rs_key = NULL;
	//调用initscan对scan的扫描参数进行初始化
	initscan(scan, key, false);
	//返回构建的扫描描述符
	return (TableScanDesc) scan;
}

被调用的initscan函数

static void
initscan(HeapScanDesc scan, ScanKey key, bool keep_startblock)
//三个参数分别为:scan-待初始化的扫描描述符,key-扫描键,keep_startblock-是否保留之前的startblock
{
	ParallelBlockTableScanDesc bpscan = NULL;
	//是否允许使用缓冲区控制策略
	bool		allow_strat;
	//是否允许使用同步扫描
	bool		allow_sync;

	 //如果并行扫描描述符不为空
	if (scan->rs_base.rs_parallel != NULL)
	{
		//获取并行扫描描述符
		bpscan = (ParallelBlockTableScanDesc) scan->rs_base.rs_parallel;
		//设置scan中需要读的表块数
		scan->rs_nblocks = bpscan->phs_nblocks;
	}
	else//此时为不并行扫描
		//通过RelationGetNumberOfBlocks函数获取表的块数并赋给scan
		scan->rs_nblocks = RelationGetNumberOfBlocks(scan->rs_base.rs_rd);

	 //如果该表相对于 NBuffers 较大,使用批量读取访问策略并启用同步扫描
	if (!RelationUsesLocalBuffers(scan->rs_base.rs_rd) &&
		scan->rs_nblocks > NBuffers / 4)
	{
		//设置缓冲区读取策略
		allow_strat = (scan->rs_base.rs_flags & SO_ALLOW_STRAT) != 0;
		//启用同步扫描
		allow_sync = (scan->rs_base.rs_flags & SO_ALLOW_SYNC) != 0;
	}
	else//否则
		//不适用缓冲区策略,且不使用同步扫描
		allow_strat = allow_sync = false;

	if (allow_strat)//如果允许使用缓冲区控制策略
	{
		//这里之所以要判断策略是否为NULL,是因为rescan函数也会调用该函数,就不必重新设置策略了。
		if (scan->rs_strategy == NULL)
			//设置缓冲区控制策略
			scan->rs_strategy = GetAccessStrategy(BAS_BULKREAD);
	}
	else//如果不允许
	{
		if (scan->rs_strategy != NULL)//如果策略不为NULL
			FreeAccessStrategy(scan->rs_strategy);//释放策略
		scan->rs_strategy = NULL;//设置为NULL
	}

	if (scan->rs_base.rs_parallel != NULL)//如果允许并行扫描
	{
		//为了并行扫描,需要根据并行扫描描述符设置相关参数
		if (scan->rs_base.rs_parallel->phs_syncscan)//如果允许同步扫描
			scan->rs_base.rs_flags |= SO_ALLOW_SYNC;//添加同步扫描
		else//不允许同步扫描
			scan->rs_base.rs_flags &= ~SO_ALLOW_SYNC;//抹去同步扫描
	}
	else if (keep_startblock)//如果需要保持之前的startblock参数
	{
	
		 //如果允许同步扫描
		if (allow_sync && synchronize_seqscans)
			scan->rs_base.rs_flags |= SO_ALLOW_SYNC;//添加同步扫描
		else//否则
			scan->rs_base.rs_flags &= ~SO_ALLOW_SYNC;//抹去同步扫描
	}
	else if (allow_sync && synchronize_seqscans)//如果允许同步扫描并且为顺序扫描
	{
		scan->rs_base.rs_flags |= SO_ALLOW_SYNC;
		//设置开始扫描的块号
		scan->rs_startblock = ss_get_location(scan->rs_base.rs_rd, scan->rs_nblocks);
	}
	else
	{
		scan->rs_base.rs_flags &= ~SO_ALLOW_SYNC;
		//设置开始扫描的块号为0
		scan->rs_startblock = 0;
	}
	//当前扫描状态的初始化
	scan->rs_numblocks = InvalidBlockNumber;//默认全表扫描
	scan->rs_inited = false;//并未初始化扫描
	scan->rs_ctup.t_data = NULL;
	ItemPointerSetInvalid(&scan->rs_ctup.t_self);
	scan->rs_cbuf = InvalidBuffer;//当前没有扫描缓冲区
	scan->rs_cblock = InvalidBlockNumber;//当前没有扫描块

	 //如果有扫描键的话
	if (key != NULL)
		//复制扫描键
		memcpy(scan->rs_base.rs_key, key, scan->rs_base.rs_nkeys * sizeof(ScanKeyData));

	 //如果是顺序扫描
	if (scan->rs_base.rs_flags & SO_TYPE_SEQSCAN)
		//设置顺序扫描统计计数器
		pgstat_count_heap_scan(scan->rs_base.rs_rd);
}

初始化扫描描述符完成以后,就可以调用heap_next函数进行表扫描。

HeapTuple//返回值为一个元组
heap_getnext(TableScanDesc sscan, ScanDirection direction)
//两个入参,分别为扫描描述符和位置信息
{
	//进行类型转换
	HeapScanDesc scan = (HeapScanDesc) sscan;
	
	//如果不经过表的访问方法(AM)
	if (unlikely(sscan->rs_rd->rd_tableam != GetHeapamTableAmRoutine()))
		//则报错
		ereport(ERROR,
				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
				 errmsg_internal("only heap AM is supported")));

	//因为无需加锁操作

	
	//如果为page-at-a-time模式
	if (scan->rs_base.rs_flags & SO_ALLOW_PAGEMODE)
		//调用heapgettup_pagemode函数寻找下一个元组
		heapgettup_pagemode(scan, direction,
							scan->rs_base.rs_nkeys, scan->rs_base.rs_key);
	else//否则
		//调用heapgettup函数寻找下一个元组
		heapgettup(scan, direction,
				   scan->rs_base.rs_nkeys, scan->rs_base.rs_key);
	//如果没找到满足条件的元组
	if (scan->rs_ctup.t_data == NULL)
	{			
		//返回NULL
		return NULL;
	}
	
	 //如果执行到这里,说明找到了一个符合条件的元组
					
	//调用pgstat_count_heap_getnext函数指向正确的返回缓冲区
	pgstat_count_heap_getnext(scan->rs_base.rs_rd);
	//返回找到的元组
	return &scan->rs_ctup;
}

被调用的heapgettup函数

static void
heapgettup(HeapScanDesc scan,//扫描描述符
		   ScanDirection dir,//用于定位下一个元组
		   int nkeys,//扫描键的个数
		   ScanKey key)//扫描键
//这里可能会有个疑问,因为扫描描述符中已经有扫描键的相关信息了,为何这里还要单独传入?
//这是因为调用者可能不想这里去检索扫描键
{
	HeapTuple	tuple = &(scan->rs_ctup);
	Snapshot	snapshot = scan->rs_base.rs_snapshot;
	//这是判断是否从后向前扫描,后面都用backward来表示
	bool		backward = ScanDirectionIsBackward(dir);
	BlockNumber page;
	bool		finished;
	Page		dp;
	int			lines;
	OffsetNumber lineoff;
	int			linesleft;
	ItemId		lpp;

	/*
	 * calculate next starting lineoff, given scan direction
	 */
	 //根据给定的dir计算下一个开始的扫描
	if (ScanDirectionIsForward(dir))
	{
		//如果扫描描述符没有初始化
		if (!scan->rs_inited)
		{
			 //如果要扫描的表是空的
			if (scan->rs_nblocks == 0 || scan->rs_numblocks == 0)
			{	
				//判断缓冲区是否有效,无效说明出错了
				Assert(!BufferIsValid(scan->rs_cbuf));
				//将该变量置为NULL表示没有找到
				tuple->t_data = NULL;
				//结束
				return;
			}
			if (scan->rs_base.rs_parallel != NULL)//如果可以并行扫描
			{
				//获取并行扫描描述符
				ParallelBlockTableScanDesc pbscan =
				(ParallelBlockTableScanDesc) scan->rs_base.rs_parallel;
				//初始化并行扫描
				table_block_parallelscan_startblock_init(scan->rs_base.rs_rd,
														 pbscan);
				//获取要扫描的页(块)
				page = table_block_parallelscan_nextpage(scan->rs_base.rs_rd,
														 pbscan);

				//因为是并行扫描,其他进程可能已经完成这次扫描
				//如果获取的扫描页为InvalidBlockNumber,说明扫描已经被其他进程完成
				if (page == InvalidBlockNumber)
				{
					//结束本次扫描
					Assert(!BufferIsValid(scan->rs_cbuf));
					tuple->t_data = NULL;
					return;
				}
			}
			else//如果不可以并行扫描
				//获取扫描起始块号
				page = scan->rs_startblock; 
			//根据块号调用heapgetpage将块装入扫描描述符中
			heapgetpage((TableScanDesc) scan, page);
			//获取扫描起始线
			lineoff = FirstOffsetNumber;
			//此时扫描已经初始化完毕
			scan->rs_inited = true;
		}
		else//如果扫描描述符已经初始化
		{
			//之间获取上次扫描到的块,即当前块号
			page = scan->rs_cblock; 
			//计算这次的扫描起始线
			lineoff =		
				OffsetNumberNext(ItemPointerGetOffsetNumber(&(tuple->t_self)));
		}
		//给缓冲区上读锁
		LockBuffer(scan->rs_cbuf, BUFFER_LOCK_SHARE);
		//从缓冲区获取要扫描的块
		dp = BufferGetPage(scan->rs_cbuf);
		//快照相关操作
		TestForOldSnapshot(snapshot, scan->rs_base.rs_rd, dp);
		//获取该块的最大偏移量(有效大小)
		lines = PageGetMaxOffsetNumber(dp);
		
		//扫描偏移量+1(从起始线扫描了几个元组)
		linesleft = lines - lineoff + 1;
	}
	else if (backward)//如果为backward
	{
		//后台扫描不支持并行扫描,如果为并行扫描则报错
		Assert(scan->rs_base.rs_parallel == NULL);
		
		//和上面一样,如果没有初始化,意味着这是第一次扫描
		if (!scan->rs_inited)
		{
			
			 //如果表为空
			if (scan->rs_nblocks == 0 || scan->rs_numblocks == 0)
			{
				//直接标记未找到并结束扫描
				Assert(!BufferIsValid(scan->rs_cbuf));
				tuple->t_data = NULL;
				return;
			}
 
			 //禁用同步扫描
			scan->rs_base.rs_flags &= ~SO_ALLOW_SYNC;
			
			//从扫描起始块的前一块开始扫描
			if (scan->rs_startblock > 0)
				page = scan->rs_startblock - 1;
			else
				page = scan->rs_nblocks - 1;
			//根据块号获取块装入扫描描述符
			heapgetpage((TableScanDesc) scan, page);
		}
		else//如果已经初始化
		{
			//直接从上次扫描的地方继续扫描
			page = scan->rs_cblock; 
		}
		//和上面差不多的一系列读操作
		//给缓冲区上锁
		LockBuffer(scan->rs_cbuf, BUFFER_LOCK_SHARE);
		//获取缓冲区中存放的块
		dp = BufferGetPage(scan->rs_cbuf);
		//快照相关操作
		TestForOldSnapshot(snapshot, scan->rs_base.rs_rd, dp);
		//获取块有效大小
		lines = PageGetMaxOffsetNumber(dp);

		if (!scan->rs_inited)//如果未初始化
		{
			//最终偏移量
			lineoff = lines;	
			//标记为已经初始化
			scan->rs_inited = true;
		}
		else//如果已经初始化
		{
			//获取之前的起始线
			lineoff =			
				OffsetNumberPrev(ItemPointerGetOffsetNumber(&(tuple->t_self)));
		}
		linesleft = lineoff;
	}
	else//如果不是backend
	{
		//如果扫描未初始化
		if (!scan->rs_inited)
		{
			//直接设置未找到并结束
			Assert(!BufferIsValid(scan->rs_cbuf));
			tuple->t_data = NULL;
			return;
		}
		//走到这里说明扫描已经初始化
		//获取块号
		page = ItemPointerGetBlockNumber(&(tuple->t_self));
		//如果取到的块号和扫描描述符里的不相等
		if (page != scan->rs_cblock)
			//更新扫描描述符里的块
			heapgetpage((TableScanDesc) scan, page);
			
		//由于元组在之前已经被取出来,这里就不需要上锁了
		//一系列读操作
		dp = BufferGetPage(scan->rs_cbuf);
		TestForOldSnapshot(snapshot, scan->rs_base.rs_rd, dp);
		lineoff = ItemPointerGetOffsetNumber(&(tuple->t_self));
		lpp = PageGetItemId(dp, lineoff);
		Assert(ItemIdIsNormal(lpp));
		//存放找到的元组
		tuple->t_data = (HeapTupleHeader) PageGetItem((Page) dp, lpp);
		tuple->t_len = ItemIdGetLength(lpp);
		//结束
		return;
	}

	
	 //推进扫描,直到找到符合条件的元组或用完要扫描的内容
	 //这里是扫描的核心部分
	lpp = PageGetItemId(dp, lineoff);
	for (;;)
	{
		while (linesleft > 0)
		{
			//如果这次的OID是有效的
			if (ItemIdIsNormal(lpp))
			{
				bool		valid;
				//获取元组的数据和长度
				tuple->t_data = (HeapTupleHeader) PageGetItem((Page) dp, lpp);
				tuple->t_len = ItemIdGetLength(lpp);
				//修改lineoff
				ItemPointerSet(&(tuple->t_self), page, lineoff);

				 //判断当前的元组是否满足条件
				valid = HeapTupleSatisfiesVisibility(tuple,
													 snapshot,
													 scan->rs_cbuf);

				//如果满足条件且扫描键不为空
				if (valid && key != NULL)
					//调用HeapKeyTest判断扫描键是否也符合,如果不符合会将valid置为false
					HeapKeyTest(tuple, RelationGetDescr(scan->rs_base.rs_rd),
								nkeys, key, valid);
				//如果满足条件
				if (valid)
				{
					//释放锁
					LockBuffer(scan->rs_cbuf, BUFFER_LOCK_UNLOCK);
					//返回元组
					return;
				}
			}

			 //执行到这里说明这次找的元组不符合条件,继续找下一个
			 //剩余的条目数-1
			--linesleft;
			//如果是backward
			if (backward)
			{
				//前移(因为从后向前)
				--lpp;			
				--lineoff;
			}
			else//否则
			{
				//后移
				++lpp;			
				++lineoff;
			}
		}

		 //如果执行到这里,说明该块上的条目已经遍历完了,该去下一个块了
		 //释放该块的锁
		LockBuffer(scan->rs_cbuf, BUFFER_LOCK_UNLOCK);

		 //移动到下一块,并且检测是否到扫描的终点
		if (backward)//如果是backward
		{
			//判断是否完成扫描
			finished = (page == scan->rs_startblock) ||
				(scan->rs_numblocks != InvalidBlockNumber ? --scan->rs_numblocks == 0 : false);
			if (page == 0)
				page = scan->rs_nblocks;
			page--;
		}
		else if (scan->rs_base.rs_parallel != NULL)//如果允许并行扫描
		{	
			//获取并行扫描描述符
			ParallelBlockTableScanDesc pbscan =
			(ParallelBlockTableScanDesc) scan->rs_base.rs_parallel;
			//继续并行扫描获取下一块
			page = table_block_parallelscan_nextpage(scan->rs_base.rs_rd,
													 pbscan);
			//判断是否完成扫描,如果没有拿到块,说明扫描完成
			finished = (page == InvalidBlockNumber);
		}
		else//则为正常的正向扫描
		{
			page++;//块号加一
			//如果已经遍历到最后一块
			if (page >= scan->rs_nblocks)
				page = 0;//置为0
			//判断是否已经完成扫描
			finished = (page == scan->rs_startblock) ||
				(scan->rs_numblocks != InvalidBlockNumber ? --scan->rs_numblocks == 0 : false);

			
			 //报告新扫描位置以进行同步。 然而,当向后移动时,不会这样做。 那只会弄乱任何其他向前移动的扫描。 
			 //如果允许同步扫描
			if (scan->rs_base.rs_flags & SO_ALLOW_SYNC)
			//调用ss_report_location函数报告新扫描位置以进行同步。 
				ss_report_location(scan->rs_base.rs_rd, page);
		}

		
		 //如果已经耗尽了所有页的资源,即遍历了整个表都没有找到
		if (finished)
		{
			//释放缓冲区
			if (BufferIsValid(scan->rs_cbuf))
				ReleaseBuffer(scan->rs_cbuf);
			//重置扫描描述符
			scan->rs_cbuf = InvalidBuffer;
			scan->rs_cblock = InvalidBlockNumber;
			tuple->t_data = NULL;
			scan->rs_inited = false;
			//返回空
			return;
		}
		//执行到这里说明没有耗尽
		//获取下一个块
		heapgetpage((TableScanDesc) scan, page);
		//给缓冲区上锁
		LockBuffer(scan->rs_cbuf, BUFFER_LOCK_SHARE);
		//获取缓冲区中块内容
		dp = BufferGetPage(scan->rs_cbuf);
		//快照相关操作
		TestForOldSnapshot(snapshot, scan->rs_base.rs_rd, dp);
		//设置扫描线
		lines = PageGetMaxOffsetNumber((Page) dp);
		linesleft = lines;
		if (backward)//如果是backward
		{
			//从后向前扫描
			lineoff = lines;
			lpp = PageGetItemId(dp, lines);
		}
		else//否则
		{
			//从前向后扫描
			lineoff = FirstOffsetNumber;
			lpp = PageGetItemId(dp, FirstOffsetNumber);
		}
	}
}

扫描的流程以及细节我都写在函数注释里了,这里就不再复述了。

总结

这次大概分析了一下postgreSQL对表的一些基本操作,如打开表、扫描表,以及用到的一些数据结构。也了解了相关的一些细节,弄明白了是怎么进行扫描的,包括其中的前向扫描、后向扫描等。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值