nginx子请求并发处理

        子请求并非http协议标准的实现,可以说是nginx所特有的设计, 为什么需要子请求呢? 一般认为这主要是为了提高nginx内部对单个客户端请求处理的并发能力。如果客户端的某个主请求访问了多个资源(例如通过ssi功能包含了a.html,  b.hmtl、c.html三个资源), 那么对每一处资源访问建立一个子请求并让它们同时进行,效率自然会更高。 所谓的子请求,并不是由客户端直接发起的,它是由nginx服务器在处理客户端请求时,根据自身逻辑需要而内部建立的新请求。因此子请求只在nginx服务器内部进行处理,不会与客户端进行交互。

        远程鉴权、访问mysql服务器获取信息、分片回源、combo文件合并等等,以及所有需要跟中控系统交互,从而决定主请求行为的(例如使用子请求访问中控,中控决定是否对主请求限速),都可以用子请求来实现。

图:子请求示意图

一、nginx子请求数据结构

        (1)树加链表结构

        先来整体看下nginx为了支持子请求功能,而设计的数据结构。子请求几乎具备主请求的所有特征,比如有对应完整的ngx_http_request_t结构对象。并且子请求本身也可以发起新的子请求,这是一个嵌套过程。根据子请求的特征,即子请求可以递归的发起子请求(树型结构),  以及同一个请求可以发起多个子请求(链表结构),  因此按照树加链表的形式对它们进行组织是自然而然的事情。先来看下这个树加链表结构:

图: 树加链表结构

        对于原始请求,也就是主请求r, 创建了sub1, sub2两个子请求, 而sub2这个子请求本身又创建了两个子请求sub2_1, sub2_2。从图中可以看出子请求之间是通过next指针相互连接构成一个链表, 而父请求与第一个子请求是通过postponed相互连接构成一个树型结构。因此,树加链表的数据结构是这么得来的。因为子请求sub1是最先可以返回响应数据给客户端的请求,如果其他请求完成了与后端服务器通信,产生的响应数据都需要进行缓存。对于图中的r_data是主请求执行完后要发给客户端的响应数据,被缓存起来。sub2_data则是sub2子请求产生的响应数据,被缓存起来。 sub2_1_data则是sub2_1子请求产生的响应数据,被缓存起来。 sub2_2_data则是sub2_2子请求产生的响应数据,被缓存起来。nginx服务器如果对请求产生的数据需要进行缓存,则也会创建一个节点,这个节点用来存放请求产生的数据,而不是存放请求, 然后加入到链表的最末尾。           那什么情况下请求产生的数据需要缓存呢?  因为一个主请求可以创建多个子请求,这些子请求并行与后端服务器通信。但并一定先创建的子请求就一定会最先处理完与后端服务器的通信,因为这是一个异步过程, 是有可能最后创建的子请求却最先完成与后端服务器的通信。nginx服务器会记录哪个子请求是可以最先返回响应数据给客户端浏览器的,因此只要不是这个可以最先返回响应数据给客户端的子请求完成了与后端服务器的通信,这些子请求产生的数据都需要进行缓存。

        看下nginx服务器怎么维护这个树加链表的数据结构的:

struct ngx_http_request_s 
{
	//如果是子请求则指向父请求,如果是父请求则为NULL
	ngx_http_request_t             * parent;		
	
	//指向第一个子请求,构成一颗树结构
	ngx_http_postponed_request_t   * postponed;		
}

struct ngx_http_postponed_request_s 
{
	//指向当前这个请求
    ngx_http_request_t               *request;
	
	//完成与后端服务器通信后,如果这个请求是不最前面的可以与客户端交互的请求,
	//则这个请求产生的响应数据会缓存到out缓冲区中
    ngx_chain_t                      *out;
	
	//指向下一个子请求,构成一个链表
    ngx_http_postponed_request_t     *next;			
};

        nginx服务器就是使用上面的这个数据结构构成了一个树加链表的结构。除了这个树加链表的结构外, nginx服务器还维护了一个单项链表结构,目的是为了调度各个子请求进行处理。在ngx_http_run_posted_requests函数被事件机制调用时,将会遍历每一个子请求,包括孙子请求。因此每一个子请求都会被调度执行,子请求在11个http请求阶段中,都会调用相应的http模块共同完成一个子请求。这和原始请求的处理过程是一样的。也可以看出子请求是原始请求的一个派生,可以执行和原始请求一样的操作。

        (2)单项链表结构

图: 原始请求维护的子请求单链表

        对于树加链表结构中的每一个子请求节点(注意: 不包括数据节点,例如r_data,这些数据节点是不会加入到原始请求的链表末尾), 都会加入到主请求,也就是原始请求的posted_requests链表末尾。为什么要这么做呢? 就是为了在这事件循环中,遍历这个链表可以调度所有的子请求进行处理,使得每一个子请求都有机会被执行。如果读者对这块逻辑不是很清楚也不要着急,这里只是一个框架结构,让大家知道nginx是如何维护这些数据结构,如何对这些数据结构进行处理稍后将会分析。

struct ngx_http_request_s 
{
	//这个指针只对原始请求有效,其它请求则会空。
	//如果是原始请求,则指向第一个子请求链表节点
	ngx_http_posted_request_t        * posted_requests;	
}

//子请求链表节点
struct ngx_http_posted_request_s 
{
    ngx_http_request_t               *request;		//指向子请求
    ngx_http_posted_request_t        *next;			//指向下一个子请求
};

        看下nginx服务器是如何维护这个单链表的。http请求结构中有一个posted_requests指针,这个指针只对原始请求有效,其它的请求不会用到这个字段,也就是把posted_requests设置为null。而如果是原始请求,则会创接子请求链表。构成这样的一个单链表,后续有事件到时,可以调度所有的子请求进行处理,使得每一个子请求都有机会调度执行。

        (3)子请求输出顺序

        原始请求创建的各个子请求,以及子请求本身也可以创建子请求。那这些子请求处理完成后,总要把响应数据发给客户端浏览器吧!  在发送响应给客户端浏览器时,这些子请求、以及原始请求总有一个先后顺序。先后顺序的规则就是: 树的后序遍历操作的结果。以图: 树加链表结构为例来说明各个请求的输出响应数据给客户端浏览器的先后顺序。

        sub2_1_data是sub2_1子请求产生的响应数据; sub2_2_data是sub2_2子请求产生的响应数据;    sub2_data则是sub2子请求产生的响应数据; r_data是原始请求产生的响应数据。这些都是叶子节点,可以直接输出数据。如果不好理解,可以换一种角度来理解。假设把这些请求产生的数据保存到子请求本身中,而不是重新创建一个节点并插入到postponed链表末尾,则输出顺序为:

        不知这样有没更好理解子请求、以及原始请求的输出顺序? 子请求产生的数据节点与子请求本身节点对应关系如下:

二、子请求的创建

        在对子请求有了整体的认识后,下面来看下nginx是如何创建子请求的。创建子请求的过程其实就是在创建前面介绍的树加链表结构,以及构成一个原始请求维护的单项链表结构。下面按场景来分析子请求的创建过程。

         (1)创建子请求时,子请求会复用原始请求的成员。例如新创建的子请求会复用原始请求的包体缓冲区, http请求的版本号信息等、以及http请求的uri参数。子请求作为内部请求,会从 SERVER_REWRITE 阶段开始其处理流程

//创建一个子请求,使用链表构造一颗树形结构
//将子请求添加到原始请求的链表模块,这样原始请求可以知道所有子请求,包括孙子请求
ngx_int_t ngx_http_subrequest(ngx_http_request_t *r,
    ngx_str_t *uri, ngx_str_t *args, ngx_http_request_t **psr,
    ngx_http_post_subrequest_t *ps, ngx_uint_t flags)
{
    sr->request_body = r->request_body;
	//子请求方法只能是get
    sr->method = NGX_HTTP_GET;
    sr->http_version = r->http_version;
    sr->request_line = r->request_line;
    sr->uri = *uri;
}

        (2)创建子请求时,会设置子请求的读事件回调,以及写事件回调。因为子请求并不直接跟客户端交互,所有不需要处理子请求的读事件方法,因此读事件方法设置为不做任何事情的空函数。而写事件回调会被设为:ngx_http_handler, 这样在子请求被调度执行时,可以调用介入11个阶段的http模块进行处理。

ngx_int_t ngx_http_subrequest(ngx_http_request_t *r,
    ngx_str_t *uri, ngx_str_t *args, ngx_http_request_t **psr,
    ngx_http_post_subrequest_t *ps, ngx_uint_t flags)
{
	//指向父请求
    sr->parent = r;
    sr->post_subrequest = ps;
	//子请求不跟客户端交互,因此不需要读取客户端事件
    sr->read_event_handler = ngx_http_request_empty_handler;
	 //调用各个http模块协同处理这个请求
    sr->write_event_handler = ngx_http_handler;  			 
}

      (3)创建子请求时,将子请求添加到父请求的postponed链表中,构成一个树+链表组成的数据结构。

ngx_int_t ngx_http_subrequest(ngx_http_request_t *r,
    ngx_str_t *uri, ngx_str_t *args, ngx_http_request_t **psr,
    ngx_http_post_subrequest_t *ps, ngx_uint_t flags)
{
    pr = ngx_palloc(r->pool, sizeof(ngx_http_postponed_request_t));


    pr->request = sr;
    pr->out = NULL;
    pr->next = NULL;

	//将子请求添加到链表,构成一个树+链表结构
    if (r->postponed) 
	{
        for (p = r->postponed; p->next; p = p->next) 
		{
		
		}
        p->next = pr;

    }
	else 
	{
        r->postponed = pr;
    }		 
}

        (4)最后将子请求加入到原始请求你的posted_requests链表中,这样原始请求就知道了所有的子请求,包括孙子请求。后续将会调用ngx_http_run_posted_requests遍历所有的子请求,调度各个子请求进行处理。

//将子请求加入到原始请求链表的末尾。这样原始请求就知道了所有的子请求,包括孙子请求
ngx_int_t ngx_http_post_request(ngx_http_request_t *r, ngx_http_posted_request_t *pr)
{
    ngx_http_posted_request_t  **p;

    if (pr == NULL) 
	{
        pr = ngx_palloc(r->pool, sizeof(ngx_http_posted_request_t));
    }

    pr->request = r;
    pr->next = NULL;

    for (p = &r->main->posted_requests; *p; p = &(*p)->next)
	{
	}

    *p = pr;

    return NGX_OK;
}

        (5)有个疑问? 在创建子请求时,nginx服务器是怎么知道哪一个请求是最前面的请求,也就是最先发送响应数据给客户端的那个请求? 答案是nginx使用连接对象ngx_connection_s的data成员, 使用data成员指向最先发送响应数据给客户端的那个请求。 来看个例子,  假设原始请求开始的时候创建了两个子请求sub1,sub2, 则最先发送响应数据给客户端的是子请求sub1

        而现在子请求sub1又创建了一个子请求sub1_1, 则此时最先发送响应数据给客户端的子请求为sub1_1。 也就是说此时c->data指向了sub1_1。

     来看下代码的实现过程。原始请求创建子请求sub1, sub2时, c->data指向的是sub1这个子请求。而子请求本身又可以创建子请求,这是一个递归的过程。因此sub1这个子请求创建sub1_1子请求时, 此时c->data指向了sub1_1这个子请求。因此sub1_1这个子请求是可以最先给客户端发送响应数据的请求, 其它请求则需要缓存,等待sub1_1子请求结束。

ngx_int_t ngx_http_subrequest(ngx_http_request_t *r,
    ngx_str_t *uri, ngx_str_t *args, ngx_http_request_t **psr,
    ngx_http_post_subrequest_t *ps, ngx_uint_t flags)
{
	//复用data存放最前面的请求,这个请求的数据可以直接发送给客户端。其它的子请求需要缓存数据,等待
	//最前面的请求结束。如果r为子请求,r没有子请求了,且r为最前面的请求。则最前面的请求将会被切换为
	//这个刚刚创建的r的子请求
    if (c->data == r && r->postponed == NULL)
	{
        c->data = sr;
    }

}

三、子请求的调度执行

        在创建完子请求后,那这些子请求什么时候被调度执行呢? 当原始请求已经启动并且执行完成后,会查看是否有待处理的子请求,当然也包括孙子请求,有的话逐个调度这些子请求,开始子请求的处理。这些子请求,包括孙子请求都是原始请求的posted_requests单项链表中的一个节点。函数ngx_http_run_posted_requests会遍历这个单项链表,逐个调度各个子请求。当子请求调度执行后,会从单向链表中删除。如果后续子请求产生的数据被缓存了,还是会重新加回到这个单项链表中,这样可以再次触发这个子请求。

static void ngx_http_process_request(ngx_http_request_t *r)
{
	//调用各个http模块协同处理这个原始请求
    ngx_http_handler(r);

	//调度执行所有子请求
    ngx_http_run_posted_requests(c);
}
//处理子请求,处理完后将从队列中删除
void ngx_http_run_posted_requests(ngx_connection_t *c)
{
	//循环处理所有的子请求
    for ( ;; ) 
	{
        r = c->data;
        pr = r->main->posted_requests;

		//指向下一个子请求
        r->main->posted_requests = pr->next;

        r = pr->request;

		//在函数ngx_http_handler设置为ngx_http_core_run_phases
        r->write_event_handler(r);
    }
}

        那子请求被调度执行时,会做些什么呢? 在创建子请求时,已经把读事件回调设置为不做任何事件的ngx_http_request_empty_handler, 而把写事件回调设置为: ngx_http_handler。在子请求调度执行时,会调用这个函数进行处理。

ngx_int_t ngx_http_subrequest(ngx_http_request_t *r, ngx_str_t *uri, ngx_str_t *args, ngx_http_request_t **psr,
						ngx_http_post_subrequest_t *ps, ngx_uint_t flags)
{
    sr->read_event_handler = ngx_http_request_empty_handler;//子请求不跟客户端交互,因此不需要读取客户端事件
    sr->write_event_handler = ngx_http_handler;  			  //调用各个http模块协同处理这个请求
}

        现在看下ngx_http_handler这个函数做了些什么? 这个函数其实就是为了调度介入11个请求阶段的各个http模块进行处理。这和原始请求的处理过程是一样的。在前面的文章已经详细分析过了,如果不是很清楚可以参考nginx处理http请求这篇文章。

void ngx_http_handler(ngx_http_request_t *r)
{
    r->write_event_handler = ngx_http_core_run_phases;
    ngx_http_core_run_phases(r);
}

//调用各个http模块协同处理这个请求
void ngx_http_core_run_phases(ngx_http_request_t *r)
{
    ph = cmcf->phase_engine.handlers;
	//调用各个http模块的checker方法,使得各个http模块可以介入http请求
    while (ph[r->phase_handler].checker)
	{

        rc = ph[r->phase_handler].checker(r, &ph[r->phase_handler]);

		//返回NGX_OK,则会把控制全交由给事件模块
        if (rc == NGX_OK) 		
		{
            return;
        }
    }
}

四、子请求的缓存

        那什么情况下请求产生的数据需要缓存呢?  因为一个主请求可以创建多个子请求,这些子请求并行与后端服务器通信。但并一定先创建的子请求就一定会处理完与后端服务器的通信过程,因为这是一个异步过程。nginx服务器会记录哪个子请求是可以最先返回数据给客户端浏览器的,因此只要不是这个可以最先返回数据给客户端的子请求完成了与后端服务器的通信,这些子请求产生的数据都需要进行缓存。子请求处理完成时,调用过滤器模块提供的方法,把响应数据发送给客户端浏览器。但执行到ngx_http_postpone_filter_module过滤模块时,会判断是否需要对子请求产生的数据进行缓存。 ngx_http_postpone_filter函数就是负责缓存各个子请求产生的响应数据的,当然了,如果是最前面的请求,则会立马发送响应数据给客户端浏览器,而不会进行缓存。还是以图: 树加链表结构为例进行说明。

        假设最开始时,主请求完成处理后,发送响应数据给客户端浏览器。而由于主请求不是最前面可以发送响应数据给客户端浏览器的请求,因此主请求产生的响应数据将会缓存。nginx做法是创建一个子节点r_data,用来存放原始请求产生的数据,并把这个子节点挂载到原始请求r的postponed链表的末尾。

        现在假设子请求sub2_1也完成了处理,发送响应数据给客户端浏览器。而由于sub2_1不是最前面可以发送响应数据给客户端浏览器的子请求,因此该请求产生的响应数据将会缓存。同样的,nginx将会创建一个子节点sub2_1_data, 缓存sub2_1产生的响应数据,并加入到sub2_1子请求的postponed链表的末尾。

        现在假设子请求sub2也完成了处理,发送响应数据给客户端浏览器。而由于sub2不是最前面可以发送响应数据给客户端浏览器的子请求,因此该请求产生的响应数据将会缓存。同样的,nginx将会创建一个子节点sub2_data, 缓存sub2产生的响应数据,并加入到sub2子请求的postponed链表的末尾。

  

        现在假设子请求sub2_2也完成了处理,发送响应数据给客户端浏览器。而由于sub2_2不是最前面可以发送响应数据给客户端浏览器的子请求,因此该请求产生的响应数据将会缓存。同样的,nginx将会创建一个子节点sub2_2_data,缓存sub2_2产生的响应数据,并加入到sub2_2子请求的postponed链表的末尾。      

        最后,sub1子请求处理完成,发送响应数据给客户端浏览器。而由于sub1是最前面可以发送响应数据给客户端浏览器的子请求,因此ngx_http_postpone_filter函数会直接将sub1子请求产生的数据发送给客户端浏览器,而不会进行缓存。

        如果理解了nginx服务器是如何缓存子请求产出的响应数据的过程,现在分析源码就简单多了。需要注意的是子请求不需要将响应头部发给客户端, 因此在http头部过滤器中会将子请求的响应头部给干掉,例如ngx_http_header_filter

static ngx_int_t ngx_http_postpone_filter(ngx_http_request_t *r, ngx_chain_t *in)
{
	//当前请求不是最前面的请求,则需要把当前请求的数据缓存起来
    if (r != c->data) 
	{
        if (in) 
		{
            ngx_http_postpone_filter_add(r, in);
            return NGX_OK;
        }
        return NGX_OK;
    }

	//*********执行到这里,说明当前请求就是最前面的请求***********//
	//这个最前面的请求没有子请求了,也就是后续遍历结束,左孩子,右孩子都没有了。
	//可以把内容直接输出给客户端
    if (r->postponed == NULL)
	{
        if (in || c->buffered) 
		{
            return ngx_http_next_filter(r->main, in);
        }
        return NGX_OK;
    }

    return NGX_OK;
}

五、子请求结束处理

        在最前面的子请求结束时,也就是最先可以发送响应数据给客户端浏览器的那个子请求。这个子请求结束时,那最新可以发送响应数据给客户端浏览器的子请求会切换成哪一个子请求呢? 还是以图: 树加链表结构为例进行说明。

       开始时sub1是最先可以发送响应数据给客户端浏览器的子请求。这个子请求结束时,会将c->data指向原始请求r, 此时原始请求就变成了最先可以发送响应数据给客户端浏览器的请求。

        原始请求变成了最先可以发送响应数据给客户端浏览器的请求。但原始请求不是叶子节点,因此需要递归的在原始请求的子请求链表中继续查找最先可以发送响应数据给客户端浏览器的请求。此时原始请求r的第一子请求sub2就变成了最先可以发送响应数据给客户端浏览器的请求。其实这也就是对一颗树进行后续遍历的操作。

       sub2子请求原始请求变成了最先可以发送响应数据给客户端浏览器的请求。但sub2子请求也不是叶子节点,因此需要递归的在sub2请求的子请求链表中继续查找最先可以发送响应数据给客户端浏览器的请求。此时sub2的第一个子请求sub2_1就变成了最先可以发送响应数据给客户端浏览器的请求。其实这也就是对以sub2为根节点的树进行后续遍历的操作。

        sub2_1子请求变成了最先可以发送响应数据给客户端浏览器的请求。但sub2_1子请求仍然不是叶子节点,因此需要递归的在sub2_1请求的子请求链表中继续查找最先可以发送响应数据给客户端浏览器的请求。其实这也就是对以sub2_1为根节点的树进行后续遍历的操作。此时sub2_1的第一子请求sub2_1_data就变成了最先可以发送响应数据给客户端浏览器的请求。而这个sub2_1_data是一个叶子节点了,存放了sub2_1子请求的响应数据。因此sub2_1_data是最先可以发送响应数据给客户端浏览器的请求,可以直接把响应数据发给客户端浏览器。

        理解了最前面的子请求结束后,如何查找到下一个最先可以发送响应数据给客户端浏览器的子请求的处理过程,现在分析代码就简单多了。

//由各个http模块调用的,释放http请求的函数
void ngx_http_finalize_request(ngx_http_request_t *r, ngx_int_t rc)
{
	//说明是子请求
    if (r != r->main)
	{
		//执行到这里,说明子请求的所有数据都发送完成了
        pr = r->parent;
        if (r == c->data) 
		{
            r->done = 1;
			//postponed链表头指向下一个节点
            if (pr->postponed && pr->postponed->request == r) 
			{
                pr->postponed = pr->postponed->next;
            }

			//父请求设置为最前面的请求,也就是可以最先发送响应数据给客户端的请求
            c->data = pr;
        } 
		//将子请求的父请求重新添加到链表,目的是为了使得请求再次被调度执行
        ngx_http_post_request(pr, NULL);
        return;
    }
}

        说白了ngx_http_finalize_request处理的事情就是在子请求结束时,将最先可以发送响应数据给客户端浏览器的子请求设置为父请求, 目的是为了唤醒父请求继续处理自己剩下的阶段的。而ngx_http_postpone_filter函数中的这个do while循环就是为了递归查找到叶子节点,使得这个叶子节点成为最先可以发送响应数据给客户端浏览器的子请求。nginx执行这两个操作,就可以查找到最先发送响应数据给客户端浏览器的子请求。这两个操作时密切配合的。

static ngx_int_t ngx_http_postpone_filter(ngx_http_request_t *r, ngx_chain_t *in)
{
  do 
  {
        pr = r->postponed;
		//pr还有子请求,则下面需要查找到最前面的请求
        if (pr->request) 
		{
            r->postponed = pr->next;
            c->data = pr->request;
			
			//将这个阶段重新加入到原始请求的子请求链表中。为什么要这么做呢?因为子请求数据已经产生了,子请求
			//调度执行时已经从原始请求的子请求链表中删除。这样这个子请求就不会被调度执行了。
            return ngx_http_post_request(pr->request, NULL);
        }

		//说明pr是一个数据节点,没有子请求了,是一个叶子节点,可以直接输出数据
        if (pr->out == NULL) 
		{
        }
		else
		{
            if (ngx_http_next_filter(r->main, pr->out) == NGX_ERROR) 
			{
                return NGX_ERROR;
            }
        }

        r->postponed = pr->next;

    } while (r->postponed);
}

六、子请求缺陷

        父子请求之间变量是共享,容易造成花费大量的时间和精力去调试这个变量被修改问题。

另一个问题是子请求复用了父请求的内存池。以 slice_filter 分片回源模块举例,它将一个 HTTP 请求划分成若干个的子请求,每个子请求向后端发起 HTTP Range 请求,在资源非常大 ,而配置的 slice_size 相对比较小的时候,会造成有大量的子请求的创建,整个资源下载过程可能会持续很长一段时间,这导致父请求的内存池在一段时间内没有释放,加之如果并发数比较大,可能会造成进程内存使用率变得很高,严重时可能会 OOM,影响到服务。        

        到此为止,子请求的并发处理已经分析完了。现在做个总结,对于子请求的并发处理需要掌握以下几个内容:

(1) nginx为了支持子请求功能使用了树+链表的数据结构,以及在原始请求中使用了一个单项链表post_requests维护了所有的子请求,包括孙子请求。

(2) 子请求是如何创建的,创建过程做了哪些事情。在创建过程是如何找到最先可以发送响应数据给客户端浏览器的子请求。

(3) nginx是如何调度各个子请求进行处理的。

(4) nginx子请求处理完成后是如何进行缓存的。

(5) 最前面的请求结束后,又是如何找到最先可以发送响应数据给客户端浏览器的子请求。

(6) nginx的子请求被调度执行时,会从原始请求的post_requests单项链表中移除,那为什么有些情况,这个被移除的子请求会再次插入到原始请求的post_requests单项链表的末尾呢?  这是为了使得这个子请求再次被http框架调度执行,将剩余的响应数据发送给客户端浏览器。

  • 8
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值