pg内核之日志管理器(三)CLOG日志

概述

CLOG日志记录的是事务的最终状态。CLOG日志管理器管理者CLOG日志缓冲池,该日志缓冲池基于SLRU缓冲池实现。

CLOG日志管理器相关结构

在pg中事务系统一共记录了四种事务状态,分别如下

#define TRANSACTION_STATUS_IN_PROGRESS		0x00 //事务正在运行中
#define TRANSACTION_STATUS_COMMITTED		0x01 //事务已提交
#define TRANSACTION_STATUS_ABORTED			0x02 //事务被终止
#define TRANSACTION_STATUS_SUB_COMMITTED	0x03 //事务的子事务已提交

由于需要记录的事务状态只有四种,所以只需要2个byte就能记录一个事务状态信息,这样的话,一个字节就能存储4个CLOG日志记录,一个页面(8K)就能保存32K个日志记录。而CLOG日志文件每个段文件由32个页组成,也就能保存1M个日志记录。
又由于事务ID最多能达到232个,所以理论上可以存在的CLOG日志文件数量等于232/220=212=4096个,当然由于事务ID会发生回卷和冻结,实际情况是不可能达到这么多的,不过这样亿段号来命名文件的话,最多就只需要12位就够了,而实际上段文件都是以4为16进制数字来命名的、CLOG日志文件保存在PGDATA/pg_xact目录下。
在这里插入图片描述

要定位一条CLOG日志记录,可以通过一个四元组表示,<segmentno, Pageno, Byte, Bindex>

  • segmentno: 段号,即CLOG日志文件名
  • Pageno: 页号
  • Byte: 页面偏移
  • Bindex: 字节内偏移
    给定一个事务ID xid,可以通过四元组定位到其在日志中的位置,其计算公式为:
segmentno = xid /2^20
pageno = xid / 32K
Byte = xid % 32k
bindex = xid % 4

有了上面的四元组定位法,给定一个事务ID,我们就可以获取其日志记录对应的四元组,继而去对应位置读写数据.例如给定一个事务ID1688,其四元组为<0,0,1688,0>即第一个段文件的第0页的第1688个字节的前两位就是其对应的事务提交状态值。

#define CLOG_BITS_PER_XACT	2 //每个事务状态占的比特位
#define CLOG_XACTS_PER_BYTE 4 //每个Byte能保存的CLOG信息数量
#define CLOG_XACTS_PER_PAGE (BLCKSZ * CLOG_XACTS_PER_BYTE)  //每个页能保存的CLOG数量=32K
#define CLOG_XACT_BITMASK	((1 << CLOG_BITS_PER_XACT) - 1) //每个CLOG对应的mask
 
#define TransactionIdToPage(xid)	((xid) / (TransactionId) CLOG_XACTS_PER_PAGE) //获取保存的页号
#define TransactionIdToPgIndex(xid) ((xid) % (TransactionId) CLOG_XACTS_PER_PAGE) //获取页内偏移
#define TransactionIdToByte(xid)	(TransactionIdToPgIndex(xid) / CLOG_XACTS_PER_BYTE) //获取对应Byte
#define TransactionIdToBIndex(xid)	((xid) % (TransactionId) CLOG_XACTS_PER_BYTE)//获取Byte内偏移

clog日志缓冲池是一个SLRU缓冲池,在整个数据库系统中,CLOG日志缓冲池只有一个,它注册在共享内存中,其名为CxactCtl, 它的结构是SlruCtlData,记录了CLOG的SLRU缓冲池的数据缓冲区在共享内存中的地址

CLOG管理器主要操作函数

初始化函数

初始化的CLOG缓冲区大小由CLOGShmemSize函数确定,实际不会很大,一般几十到几百M之间。
CLOGShmemInit函数

  1. 注册比较CLOG日志页面的函数CLOGPagePrecedes
  2. 调用SimpleLruInit初始化SLRU缓冲池
  3. 执行测试PagePrecedes函数
	XactCtl->PagePrecedes = CLOGPagePrecedes;
	SimpleLruInit(XactCtl, "Xact", CLOGShmemBuffers(), CLOG_LSNS_PER_PAGE,
				  XactSLRULock, "pg_xact", LWTRANCHE_XACT_BUFFER,
				  SYNC_HANDLER_CLOG);
	SlruPagePrecedesUnitTests(XactCtl, CLOG_XACTS_PER_PAGE);

写CLOG日志函数

TransactionIdSetTreeStatus

该函数在事务提交时调用,用于记录事务状态信息到CLOG日志缓冲池中。

  • 根据事务ID获取对应的页号
pageno = TransactionIdToPage(xid);	/* 获取父事务所在的页 */ 
  • 判断事务状态是否为“TRANSACTION_STATUS_COMMITTED”或者“TRANSACTION_STATUS_ABORTED”,只能是这两种状态时才能记录,因为页面初始化时会全写0(TRANSACTION_STATUS_IN_PROGRESS)不用处理,而子事务那个会由父事务去处理,所以也不用处理。
	Assert(status == TRANSACTION_STATUS_COMMITTED ||
		   status == TRANSACTION_STATUS_ABORTED);
  • 检查是否所有子事务都与父事务在同一个页上
	for (i = 0; i < nsubxids; i++)
	{
		if (TransactionIdToPage(subxids[i]) != pageno)
			break;
	}
  • 若所有子事务跟父事务在同一个页上,直接调用TransactionIdSetPageStatus函数设置对应事务状态到CLOG日志缓冲去
	if (i == nsubxids)
	{
		/*
		 * 使用单个调用设置父事务和所有子事务的状态
		 */
		TransactionIdSetPageStatus(xid, nsubxids, subxids, status, lsn,
								   pageno, true);
	}
  • 若有子事务跟父事务不在一个页上,先设置在同一个页上的子事务和父事务
		/*
		 * 现在设置与父事务在同一页上的子事务,如果有的话
		 */
		pageno = TransactionIdToPage(xid);
		TransactionIdSetPageStatus(xid, nsubxids_on_first_page, subxids, status,
								   lsn, pageno, false);
  • 按页处理剩余的页上的子事务
   		/*
		 * 现在按页处理剩余的子事务,从第二个页开始,就像上面一样。
		 */
		set_status_by_pages(nsubxids - nsubxids_on_first_page,
							subxids + nsubxids_on_first_page,
							status, lsn);
TransactionIdSetPageStatus

该函数主要用于设置事务ID的状态,因为可能存在XactSLRULock锁的竞争,所以为了减少竞争,我们会尝试使用group分组的方式去更新事务ID,就是将多个要更新的事务放到一个group中,group中第一个事务作为leader统一去抢锁,如果抢到锁会将该组内所有的事务ID信息都更新到CLOG中,这样就可以减少抢锁的次数,从而提高并发性能。

  • 若要使用group更新方式,需要保证所有事务在同一个页中,且事务ID子事务信息与当前进程Myproc中保存的信息一致才行,而且不能有太多的子事务。
    if (all_xact_same_page && xid == MyProc->xid &&
        nsubxids <= THRESHOLD_SUBTRANS_CLOG_OPT &&
        nsubxids == MyProc->subxidStatus.count &&
        (nsubxids == 0 ||
         memcmp(subxids, MyProc->subxids.xids,
                nsubxids * sizeof(TransactionId)) == 0))
    {
        /*
         * 如果我们可以立即获取XactSLRULock,我们将更新我们自己的XID的状态并释放锁。
         如果没有,尝试使用组XID更新。如果不起作用,退回到等待锁来只为该事务执行更新。
         */
        if (LWLockConditionalAcquire(XactSLRULock, LW_EXCLUSIVE))
        {
            /* 无需等待即可获取锁!执行更新。 */
            TransactionIdSetPageStatusInternal(xid, nsubxids, subxids, status,
                                              lsn, pageno);
            LWLockRelease(XactSLRULock);
            return;
        }
        else if (TransactionGroupUpdateXidStatus(xid, status, lsn, pageno))
        {
            /* 组更新机制已经完成了工作。 */
            return;
        }

        /* 只有在更新尚未完成时才通过。 */
    }
  • 组更新不适用,直接抢锁然后更新
    LWLockAcquire(XactSLRULock, LW_EXCLUSIVE);
    TransactionIdSetPageStatusInternal(xid, nsubxids, subxids, status,
                                      lsn, pageno);
    LWLockRelease(XactSLRULock);
TransactionIdSetPageStatusInternal

设置事务ID页面状态

  • 读取CLOG的缓冲页面位置
 slotno = SimpleLruReadPage(XactCtl, pageno, XLogRecPtrIsInvalid(lsn), xid);
  • 如果存在子事务,且事务状态是“已提交,先更新子事务
  • 更新父事务
  if (TransactionIdIsValid(xid))
    {
        /* 如果需要,先更新子事务... */
        if (status == TRANSACTION_STATUS_COMMITTED)
        {
            for (i = 0; i < nsubxids; i++)
            {
                Assert(XactCtl->shared->page_number[slotno] == TransactionIdToPage(subxids[i]));
                TransactionIdSetStatusBit(subxids[i],
                                          TRANSACTION_STATUS_SUB_COMMITTED,
                                          lsn, slotno);
            }
        }

        /* ...然后更新主事务 */
        TransactionIdSetStatusBit(xid, status, lsn, slotno);
    }

  • 更新子事务
  /* 更新子事务 */
    for (i = 0; i < nsubxids; i++)
    {
        Assert(XactCtl->shared->page_number[slotno] == TransactionIdToPage(subxids[i]));
        TransactionIdSetStatusBit(subxids[i], status, lsn, slotno);
    }

    XactCtl->shared->page_dirty[slotno] = true;
  • TransactionIdSetStatusBit
    更新事务ID的状态位
  • 获取事务ID对应的CLOG页的Byte位置以及Byte内偏移
    int byteno = TransactionIdToByte(xid);
    int bshift = TransactionIdToBIndex(xid) * CLOG_BITS_PER_XACT;
  • 更新CLOG的值
    假设该事务的Byte偏移是2,事务状态是3(已提交)
    byteptr = XactCtl->shared->page_buffer[slotno] + byteno;
    curval = (*byteptr >> bshift) & CLOG_XACT_BITMASK; //(1000 0010) & 0000 0011 = 1000 0010
    byteval = *byteptr; //1000 1000
    byteval &= ~(((1 << CLOG_BITS_PER_XACT) - 1) << bshift); //   1000 1000 & ~(0000 1100) = 1000 1000
    byteval |= (status << bshift); //1000 1000 | 0000 1100 = 1000 1100
    *byteptr = byteval;//10001100,Byte内偏移2即第三第四位表示当前事务状态,已提交标记为11
  • 如果事务完成的LSN比当前页中LSN更高,则更新LSN
    if (!XLogRecPtrIsInvalid(lsn))
    {
        int lsnindex = GetLSNIndex(slotno, xid);

        if (XactCtl->shared->group_lsn[lsnindex] < lsn)
            XactCtl->shared->group_lsn[lsnindex] = lsn;
    }
TransactionGroupUpdateXidStatus

该函数用于在更新事务状态时,如果无法立即获取到XactSLRULock锁时,会将自己添加事务ID的等待组中,组内第一个事务作为leader,会代表所有成员去抢占XactSLRULock锁,如果抢到锁就将组内所有所有成员都更新,避免多个进程同事提交时抢锁竞争过大。

  • 将当前进程添加到需要更新事务组的列表中
    proc->clogGroupMember = true;
    proc->clogGroupMemberXid = xid;
    proc->clogGroupMemberXidStatus = status;
    proc->clogGroupMemberPage = pageno;
    proc->clogGroupMemberLsn = lsn;
  • 获取组的leader,如果leader要更新的页与当前事务要更新的页不一致,表示当前进程还不在要更新的组中,退出;否则把当前事务ID标记为组的leader
    nextidx = pg_atomic_read_u32(&procglobal->clogGroupFirst);

    while (true)
    {
        // 如果当前进程需要更新的页与组领导者的页相同,则将进程添加到列表中
        if (nextidx != INVALID_PGPROCNO &&
            ProcGlobal->allProcs[nextidx].clogGroupMemberPage != proc->clogGroupMemberPage)
        {
            // 确保当前进程不在任何需要更新XID状态的事务组中
            proc->clogGroupMember = false;
            pg_atomic_write_u32(&proc->clogGroupNext, INVALID_PGPROCNO);
            return false;
        }

        pg_atomic_write_u32(&proc->clogGroupNext, nextidx);

        if (pg_atomic_compare_exchange_u32(&procglobal->clogGroupFirst,
                                           &nextidx,
                                           (uint32) proc->pgprocno))
            break;
    }
  • 如果leader不为空,那么久等待leader更新完即可,等待proc->clogGroupMember为false
  if (nextidx != INVALID_PGPROCNO)
    {
        int extraWaits = 0;

        // 等待组领导者的XID状态更新
        pgstat_report_wait_start(WAIT_EVENT_XACT_GROUP_UPDATE);
        for (;;)
        {
            // 作为读屏障
            PGSemaphoreLock(proc->sem);
            if (!proc->clogGroupMember)
                break;
            extraWaits++;
        }
        pgstat_report_wait_end();

        Assert(pg_atomic_read_u32(&proc->clogGroupNext) == INVALID_PGPROCNO);

        // 为吸收的唤醒次数进行补偿
        while (extraWaits-- > 0)
            PGSemaphoreUnlock(proc->sem);
        return true;
    }
  • 如果当前进程是leader,抢XactSLRULock锁,然后逐个更新组成员的事务状态
    LWLockAcquire(XactSLRULock, LW_EXCLUSIVE);

    // 清除等待组XID状态更新的进程列表,并保存列表的头部指针
    nextidx = pg_atomic_exchange_u32(&procglobal->clogGroupFirst,
                                     INVALID_PGPROCNO);

    // 保存列表的头部指针以便在释放锁后进行唤醒
    wakeidx = nextidx;

    // 遍历列表并更新所有XID的状态
    while (nextidx != INVALID_PGPROCNO)
    {
        PGPROC *proc = &ProcGlobal->allProcs[nextidx];

        // 事务具有超过THRESHOLD_SUBTRANS_CLOG_OPT个子XID时,不应使用组XID状态更新机制
        Assert(proc->subxidStatus.count <= THRESHOLD_SUBTRANS_CLOG_OPT);

        TransactionIdSetPageStatusInternal(proc->clogGroupMemberXid,
                                           proc->subxidStatus.count,
                                           proc->subids.xids,
                                           proc->clogGroupMemberXidStatus,
                                           proc->clogGroupMemberLsn,
                                           proc->clogGroupMemberPage);

        // 移动到列表中的下一个进程
        nextidx = pg_atomic_read_u32(&proc->clogGroupNext);
    }

    // 现在可以释放锁了
    LWLockRelease(XactSLRULock);
  • 更新完之后唤醒所有成员,设置proc->clogGroupMember为false
    while (wakeidx != INVALID_PGPROCNO)
    {
        PGPROC *proc = &ProcGlobal->allProcs[wakeidx];

        wakeidx = pg_atomic_read_u32(&proc->clogGroupNext);
        pg_atomic_write_u32(&proc->clogGroupNext, INVALID_PGPROCNO);

        // 确保所有之前的写操作对跟随者可见
        pg_write_barrier();

        proc->clogGroupMember = false;

        if (proc != MyProc)
            PGSemaphoreUnlock(proc->sem);
    }
set_status_by_pages

该函数是TransactionIdSetTreeStatus的辅助函数,用于设置一组事务的状态。它将事务分成不同的CLOG页面进行处理。该函数不会将整个事务树传递给它,只会传递与顶级事务ID不同的子事务
它会遍历每个子事务并调用TransactionIdSetPageStatus设置事务状态

    // 获取第一个subxids的页码
    int pageno = TransactionIdToPage(subxids[0]);
    // 初始化偏移量
    int offset = 0;
    // 初始化循环计数器
    int i = 0;

    // 断言nsubxids大于0,否则上面的页码获取是不安全的
    Assert(nsubxids > 0);

    // 循环处理每个页码
    while (i < nsubxids)
    {
        // 初始化当前页码上的事务数量
        int num_on_page = 0;
        // 获取下一个页码
        int nextpageno;

        // 循环判断当前页码是否与上一个页码相同
        do
        {
            nextpageno = TransactionIdToPage(subxids[i]);
            if (nextpageno != pageno)
                break;
            num_on_page++;
            i++;
        } while (i < nsubxids);

        // 设置无效事务id在当前页码上的事务数量、事务id、状态、日志位置等信息
        TransactionIdSetPageStatus(InvalidTransactionId,
                                   num_on_page, subxids + offset,
                                   status, lsn, pageno, false);
        // 更新偏移量
        offset = i;
        // 更新下一个页码
        pageno = nextpageno;
    }

读CLOG日志

TransactionIdGetStatus

该函数用来查询CLOG中存储的事务的状态和LSN值

  • 根据事务ID获取其页号,Byte号,Byte内位数
  • 从SLRU缓冲池中读取对应内容
  • 获取LSN值
    假如事务ID等于17386,其四元组值为<0,0,4346,2>,假设读取的事务状态是3(已提交)
   int pageno = TransactionIdToPage(xid);//页码为0
    // 将事务ID转换为字节号
    int byteno = TransactionIdToByte(xid); //byteno=4346
    // 将事务ID转换为块索引,并乘以每个事务的位数
    int bshift = TransactionIdToBIndex(xid) * CLOG_BITS_PER_XACT;//=2*2=4
    int slotno; // 用于存储槽号
    int lsnindex; // 用于存储LSN索引
    char *byteptr; // 用于存储字节指针
    XidStatus status; // 用于存储事务状态

    // 锁已由SimpleLruReadPage_ReadOnly获取

    // 通过SimpleLruReadPage_ReadOnly获取槽号
    slotno = SimpleLruReadPage_ReadOnly(XactCtl, pageno, xid);
    // 获取字节指针
    byteptr = XactCtl->shared->page_buffer[slotno] + byteno;

    // 获取事务状态
    status = (*byteptr >> bshift) & CLOG_XACT_BITMASK;//(11110011 >> 4) & 00000011 = 00000011

    // 获取LSN索引
    lsnindex = GetLSNIndex(slotno, xid);
    // 获取LSN
    *lsn = XactCtl->shared->group_lsn[lsnindex];

    // 释放锁
    LWLockRelease(XactSLRULock);

    return status;

CLOG 日志创建

BootStrapCLOG

该函数在系统安装的时候创建,用于创建初始的CLOG缓冲池

  • 以排他模式获取XactSLRULock
  • 创建第一个CLOG页并初始化为0
  • 写入磁盘
  • 释放锁
ZeroCLOGPage

该函数用于初始化一个CLOG页,并根据需要写XLOG日志。

CLOG日志启动

StartupCLOG

该函数会在每次数据库启动时调用,初始化最新的事务ID对应的页
XactCtl->shared->latest_page_number = pageno

CLOG日志关闭

TrimCLOG

该函数会在数据库关闭时将当前字节的剩余位置零。在正常情况下,它应该已经是零了,但似乎至少理论上可能会出现下一个XID值小于上一个数据库生命周期标记的实际使用的最大XID值的情况(因为子事务提交会写入clog,但不会写入WAL条目)。让我们保持安全。 (我们不需要担心超过当前页的页,因为第一次使用时会将它们置零。出于同样的原因,在nextXid正好位于页边界时也不需要做任何事情;而且很可能“当前”页在那种情况下还没有存在。)

        int byteno = TransactionIdToByte(xid);
        int bshift = TransactionIdToBIndex(xid) * CLOG_BITS_PER_XACT;
        int slotno;
        char *byteptr;

        slotno = SimpleLruReadPage(XactCtl, pageno, false, xid);
        byteptr = XactCtl->shared->page_buffer[slotno] + byteno;

        /* 将目前为止未使用的字节位置置零 */
        *byteptr &= (1 << bshift) - 1;
        /* 将当前字节的其余部分置零 */
        MemSet(byteptr + 1, 0, BLCKSZ - byteno - 1);

        XactCtl->shared->page_dirty[slotno] = true;

CLOG日志的checkpoint

CheckPointCLOG

在checkpoint进程运行时会调用该函数将CLOG缓冲区中的所有脏数据写入磁盘,调用的是SimpleLruWriteAll函数

CLOG日志的扩展

ExtendCLOG

该函数用于确保CLOG有足够的空间来分配新的XID。该函数在持有XidGenLock时被调用。

   int pageno;

    /*
     * 除了页面的第一个XID之外,没有其他工作。但是要注意:在循环之后,页面零的第一个XID是FirstNormalTransactionId。
     */
    if (TransactionIdToPgIndex(newestXact) != 0 &&
        !TransactionIdEquals(newestXact, FirstNormalTransactionId))
        return;

    pageno = TransactionIdToPage(newestXact);

    LWLockAcquire(XactSLRULock, LW_EXCLUSIVE);

    /* 将页面清零并记录XLOG条目 */
    ZeroCLOGPage(pageno, true);

    LWLockRelease(XactSLRULock);

CLOG日志的删除

该函数用于删除在给定事务ID之前的CLOG段文件。在删除任何CLOG数据之前,必须将XLOG刷新到磁盘,以确保最近发出的FREEZE_PAGE记录已到达磁盘,否则在崩溃和重启时可能会留下一些未冻结的元组引用已删除的CLOG数据。我们选择发出一个特殊的TRUNCATE XLOG记录。从XLOG中重放删除操作不是关键,因为文件可以稍后删除,但这样做可以防止长时间运行的热备用服务器获得不合理膨胀的CLOG目录。由于CLOG段落包含大量事务,因此实际上可以删除一个段落的机会非常罕见,
因此似乎最好在确认存在可删除的段落时才进行XLOG刷新。

  • 根据提供的事务ID计算出页号
  • 遍历CLOG日志目录(PGDATA/pg_xact/)下的每个文件,并调用SlruScanDirCbReportPresence函数处理,若没有要处理的文件则直接返回。SlruScanDirCbReportPresence函数主要是判断文件是否可以删除。
  • 如果存在可以删除的段文件,先更新全局的oldestClogXid
  • 将当前的truncate CLOG操作写入XLOG日志
  • 移除旧的CLOG文件段
TruncateCLOG(TransactionId oldestXact, Oid oldestxid_datoid)
{
    int cutoffPage;  // 定义截止页面变量

    // 计算并设置截止页面,即包含oldestXact事务ID的页码
    cutoffPage = TransactionIdToPage(oldestXact);

    // 扫描目录下的每个文件,并对每个文件应用回调函数
    if (!SlruScanDirectory(XactCtl, SlruScanDirCbReportPresence, &cutoffPage))
        return;  // 若没有需要移除的内容,则直接返回

    // 在截断CLOG之前更新最旧的ClogXid,确保并发事务状态查询不会尝试访问已被截断的CLOG部分
    // 只有在实际会截断CLOG页面时才需要执行此操作
    AdvanceOldestClogXid(oldestXact);

    // 写入XLOG记录并刷盘。记录下我们正在保留信息的最旧事务ID,
    // 以便在崩溃情况下确保其始终领先于CLOG截断点,且备用节点能在下一次检查点前得知新的有效事务ID
    WriteTruncateXlogRec(cutoffPage, oldestXact, oldestxid_datoid);

    // 现在可以移除旧的CLOG段了
    SimpleLruTruncate(XactCtl, cutoffPage);
}

CLOG日志的redo

在故障恢复时,需要回放所有XLOG,如果是CLOG类型的XLOG,则会调用clog_redo函数回放CLOG数据

clog_redo
  • 根据XLOG中记录的clog info判断XLOG类型,主要有两种:CLOG_ZEROPAGE和CLOG_TRUNCATE
  • 如果是CLOG_ZEROPAGE,表示执行的是clog页面清零操作
		int			pageno;
		int			slotno;

		// 从日志记录中复制出页面号
		memcpy(&pageno, XLogRecGetData(record), sizeof(int));
		LWLockAcquire(XactSLRULock, LW_EXCLUSIVE);
		slotno = ZeroCLOGPage(pageno, false);
		SimpleLruWritePage(XactCtl, slotno);  // 将页面写回缓存
		LWLockRelease(XactSLRULock);
  • 如果是CLOG_TRUNCATE,表示执行的是clog日志删除操作
		xl_clog_truncate xlrec;

		// 从日志记录中复制出截断记录结构体内容
		memcpy(&xlrec, XLogRecGetData(record), sizeof(xl_clog_truncate));

		// 更新最旧的ClogXid
		AdvanceOldestClogXid(xlrec.oldestXact);

		// 执行CLOG段的截断操作
		SimpleLruTruncate(XactCtl, xlrec.pageno);

【参考】

  1. 《PostgreSQL数据库内核分析》
  2. 《Postgresql技术内幕-事务处理深度探索》
  3. pg14源码
  • 18
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值