打造千万级流量系统——秒杀系统(架构篇)

流量负载均衡:LVS 和 Nginx 原理和使用方法

流量负载均衡,是指让流量比较均衡地到达后端各服务器,确保各服务器负载相对均衡,不会导致某一台服务器负载太高而被压垮。

负载均衡的算法常用的有轮询法、随机法、源地址哈希法、加权轮询法、加权随机法、最小连接数法等。通常,我们会用一些组件来提供负载均衡的能力,比如 LVS 和 Nginx。

首先,我们来了解下 LVS 和 Nginx 常用的负载均衡算法的实现原理。

  • 轮询法和加权轮询法

轮询法就是假设后端服务器性能都一样,以依次循环的方式将请求调度到不同的服务器上。它的特点是实现简单,缺点是如果后端服务器性能不一样,性能好的服务器利用率较低,无法发挥最大性能。

为了解决服务器性能不一致出现负载不均衡的问题,加权轮询法出现了。它的思想是按服务器性能分配权重,性能好的权重大,性能差的权重小。

  • 随机法和加权随机法

所谓随机法,是指在转发请求的时候不是按顺序挑选后端服务器,而是随机挑选,它的优缺点跟轮询法一样。

与轮询法有些区别的是,随机法不需要用变量来记录当前轮询到的节点。而轮询法则需要该变量,以便查找到下一次轮询的节点,比如当前轮询到了 A,则记下来,下一次轮询到 B。

  • 最小连接数法和最低延迟法

如果说随机法和轮询法都是按照服务器理论容量来转发流量的,最小连接数法和最低延迟法则可以基于服务器实际压力来均衡负载。

所谓最小连接数法,是指将新请求转发给所有服务器中连接数最小的服务器。因为连接数小通常说明该服务器正在处理的请求最少,也就意味着压力最小。最小连接数法通常用在使用了 WebSocket、HTTP2.0、TCP 等长连接的业务场景。

同样的道理,最低延迟法是将最新的请求转发给延迟最低的服务器。因为延迟最低说明该服务器性能最好,或者负载最低。

最小连接数法和最低延迟法的优点是能达到实际上的负载均衡,缺点是需要维护各节点负载状态。

  • 源地址哈希法

源地址哈希法的主要原理是:根据请求来源 IP ,按照某个规则将请求转发到特定的服务器上。换句话说,它通常是对来源 IP ,采用哈希算法来选取服务器,比如将 12.12.12.12 和 12.12.12.13 这两个 IP 分别哈希到服务器 A 和 B 上。

它的好处是可以通过人为干预的方式控制流量,通常用于将特定流量转发到特定机器上实现灰度发布。缺点是同一个用户的请求始终在固定的服务器上,假如该服务器出现故障了,哈希到该服务器的所有用户的请求都会失败。

LVS 是如何工作的?

LVS 是 Linux Virtual Server 的简称,也就是 Linux 虚拟服务器,它是 Linux 内核的一部分。

网络模型分为 OSI 七层模型和 TCP/IP 四层模型。LVS 主要是工作在七层网络模型中的第四层,也就是传输层,能负责转发 TCP、UDP 协议。

在网络通信里,网络设备可以利用报文中的目标 IP ,通过 ARP(Address Resolution Protocol,地址解析协议)来搜寻下一跳设备的 MAC 地址,而数据接收方利用来源 IP 识别网络会话,并在应答时将收到的来源 IP 填写到目标 IP 中。四层负载均衡器正是基于这个原理,通过修改来源 IP、目标 IP、MAC 地址等方式将流量转发给数据接收方的。

LVS 有三种工作模式:NAT(Network Address Translation,网络地址转换)、DR(Direct Route,直接路由)、TUN(Tunnel,隧道)。其中 NAT 模式和 DR 模式要求 LVS 与 RS(Real Server,真实服务器)在同一个网段,使用同一个 VIP(虚拟 IP),并将 VIP 暴露到网络中。

NAT 模式下,所有入网和出网的数据包都会经过 LVS 节点。对于客户端发起的入网数据包,目标 IP 是服务端的 VIP。LVS 收到入网数据包后,会将目标 IP 修改为某台 RS 的 IP ,并将数据包投递给该 RS。对于出网数据包,来源 IP 是 RS 的 IP,LVS 会将来源 IP 修改为 VIP,以便客户端关联上对应的网络会话,并正确处理数据。

DR 模式下,目标服务器与 LVS 共用 VIP 。客户端发起的网络报文会经过 LVS,LVS 只会将网络报文中 MAC 地址修改为目标服务器的 MAC 地址,并转发给目标服务器。

由于源 IP 和 目标 IP 都未修改,且 MAC 地址是目标服务器的 MAC 地址,目标服务器会正常处理接收到的数据。在目标服务器处理完请求后,回传的报文目标 IP 不是 LVS 的 IP,而是客户端的 IP。而且,来源 MAC 地址不是 LVS 的 MAC 地址,回传的网络报文也就不会经过 LVS。因为 DR 模式下 LVS 不需要处理 RS 回传的报文,所以性能非常高,也是最常用的模式。

TUN 模式下,不要求 LVS 与 RS 在同一网段,但需要 RS 支持 TUN。当 LVS 收到入网数据包时,它会在数据包的基础上再封装一层 IP 协议,协议中的目标 IP 地址是某台 RS 的 IP 地址,以便 RS 收到并正常处理。RS 收到数据包后,先拆开 LVS 封装的 IP 包,然后再拆开客户端的 IP 包并处理数据。处理完数据后,RS 再按照 DR 模式下那样正常返回报文,该报文不再经过 LVS 处理,而是直接投递给客户端。

在以上三种模式下,都涉及如何选取 RS 的问题。实际上,LVS 提供了数十种负载均衡策略,以便应对不同业务场景。

在秒杀系统中,LVS 是如何发挥作用的呢?

秒杀接口服务并发能力达到千万级别,节点数超过 50 台,必然需要负载均衡器来确保每个节点负载均衡,而且是多个负载均衡器来做负载均衡。由于 LVS 性能非常高,它可以作为云架构中的 ELB(Elastic Load Balancing,弹性负载均衡器)为秒杀系统提供服务,也可以利用它的多种负载均衡模式为秒杀服务均衡流量。并且,LVS 工作在网络四层,它对秒杀请求延迟影响很小。

当然,一台 LVS 是无法扛住千万并发的,通常需要 10 台左右,其上再用 DDNS 来做域名解析负载均衡。由于秒杀流量大,对机器网卡性能也有较高要求,而 LVS 在搭配高性能网卡时,能够提供单机百万以上并发能力。

需要注意的是,虽然 LVS 能做网络四层协议转发,但它无法按 HTTP 协议中的请求路径做负载均衡,所以还需要 Nginx 。

Nginx 是如何工作的?

与 LVS 不同的是,Nnginx 虽然也能工作在网络四层,但其无法修改 IP 地址和 MAC 地址,只能做简单的数据转发。但 Nginx 网络七层协议处理能力非常强,比如处理 HTTP 、HTTPS、HTTP2 等协议。所以,Nginx 常用于做网络七层协议的负载均衡。

与 LVS 工作在 Linux 内核空间不同,Nginx 工作在用户空间,这也意味着它的性能比 LVS 要稍微差一些。Nginx 在用户空间利用多进程和 Epoll 模型,只要机器性能足够,单机并发能力甚至能达到惊人的百万级别。

Nginx 是如何配置负载均衡的呢?它主要有四种负载均衡模式:轮询法、加权轮询法、来源 IP 哈希法、最小连接数法。接下来我们看下各种负载均衡是如何配置的。

Nginx 默认提供轮询模式负载均衡,配置示例如下:

upstream seckill-admin {
  server 127.0.0.1:8081;
  server 127.0.0.1:8082;
}

当在每个 server 后面加上 weight 参数后,轮询模式变成加权轮询模式:

upstream seckill-admin {
  server 127.0.0.1:8081 weight=4;
  server 127.0.0.1:8082 weight=6;
}

当在轮询模式的配置中加上 ip_hash,Nginx 将会按来源 IP 哈希做负载均衡。配置如下:

upstream seckill-admin {
  ip_hash;
  server 127.0.0.1:8081;
  server 127.0.0.1:8082;
}

同样,如果在配置中加上 least_conn,Nginx 会采用最小连接数法做负载均衡。配置如下:

upstream seckill-admin {
  least_conn;
  server 127.0.0.1:8081;
  server 127.0.0.1:8082;
}

在秒杀系统中,秒杀接口服务要求高并发、高性能,它已经有一层 ELB 为其做负载均衡了,如果再加一层 Nginx,会带来两方面的问题:首先,多一层处理,意味着请求延迟的增加,性能下降;其次,千万级别的高并发下,需要至少 10 台 Nginx,也就会增加不少成本。

那我们如何在秒杀系统中用 Nginx 呢?秒杀管理后台功能较多,需要提供不少 HTTP 接口,其中还包括文件上传接口。同时,也需要提供多个节点确保其可用性。那么,我们可以使用 Nginx 来作为它的负载均衡器,采用轮询法即可满足要求。

在长连接场景下,我们应该使用最小连接数来控制连接的负载均衡。而在 HTTP1.x 等短连接场景下,我们需要使用轮询或者加权轮询来做负载均衡。

池化技术:连接池和协程池为何能提升并发能力?

池化技术的核心是“预分配”和“循环使用”。这就像食堂在用餐高峰期安排餐具一样。食堂通常会在餐厅提前准备一批餐具,并在用餐过程中安排人专门清洗餐具,然后循环使用。食堂采用这种方法,节省了开支,原来只有 50 套的餐具就能保障 100 人有序就餐。池化技术也是类似的作用。

那么,除了前面提到的内存池和进程池外,都还有哪些池化技术呢?常用的池化技术还有连接池和协程池。它们解决什么具体问题呢?

连接池的作用
连接池是指预先分配一批连接,并将它们放入一个缓冲区中循环使用,形成池化效应。以秒杀为例,秒杀接口服务并发非常高,如果不用连接池,会导致什么后果?

在介绍 KV 存储的时候,我提到过秒杀系统使用 Redis 缓存活动信息。假如秒杀接口服务与 Redis 只有一个连接,平均每次请求 Redis 耗时 10ms,一秒钟能请求多少次呢?没错,一个连接一秒钟最多只能处理 100 次请求。

在秒杀系统中,除了活动信息外,秒杀库存信息也缓存在 Redis 中,而且需要支持 1 万以上并发能力。活动信息加上库粗存信息,分摊到 50 个秒杀接口节点上,平均每个节点向 Redis 发起的请求可能超过 300 QPS,远超过单个连接的处理能力。

这就会出现第一个问题:单连接无法承载高并发。

可能你会说,既然复用单个连接无法承载高并发,那就每次请求都新建连接嘛!想法很好,但现实很残酷。建立连接的时候,TCP 需要经历三次握手,假如网络延迟是 5 ms,三次握手就耗费 15ms,这比一次请求来回的时间都长了。其次,如果每次请求都建立连接,还需要考虑关闭连接,以免连接数过多压垮 Redis。而关闭连接的过程涉及 TCP 四次挥手,这又是一笔时间开销。

所以,如果不用连接池就会出现第二个问题:每次请求建立、关闭连接会导致请求延迟增加,还有可能把 Redis 压垮。

另外,如果高并发下频繁地建立、关闭连接,会导致操作系统耗费过多 CPU 用于分配、回收系统资源。

那我们该如何设计连接池来解决这三个问题呢?

通常,连接池有几个参数:最小连接数、空闲连接数、最大连接数。为何这么设计呢?请看下图:

最小连接数通常用于控制当前连接数的最小值,如果连接数小于最小值,遇到突发流量容易导致性能问题。

空闲连接数就是用于控制连接池中空闲连接的数量,如果超过这个值,意味着浪费资源,需要关闭多余连接;如果低于这个值,则可能无法应对突发流量,需要分配新的空闲连接。

有关空闲连接的分配,可以通过定时器来控制。设置时,要尽量保障秒杀服务向 Redis 发起请求的时候,有足够的空闲连接,这样可以减少建立连接的时间和资源开销。通常是由独立的线程定时检查空闲连接是否小于某个值,比如每隔 1 秒钟检查空闲连接数是否小于 2,是的话就新建一批空闲连接。

最大连接数通常用于控制系统中连接数不超过最大值,以免大量连接将 Redis 压垮。

秒杀服务在启动时,将按照配置文件中的参数初始化好连接数池,比如设置初始连接数为最小连接数。当秒杀服务需要发起一个 Redis 请求时,会先尝试从连接池中获取连接,如果获取不到,则会建立一个新的连接。

当连接数超过最大值时, 请求就会阻塞,等待其他请求归还连接。在请求完 Redis 后,秒杀服务需要将连接放回到连接池中。如果空闲连接超过参数指定的数,秒杀服务会直接关闭该连接。

另外,为了确保 Redis 集群中的多个实例负载均衡,连接池中的连接也需要做负载均衡。

如何从连接池中获取连接,用完后又如何将连接放回到连接池中呢?

通常可以采用循环队列来保存空闲连接。使用的时候,可以从队列头部取出连接,用完后将空闲连接放到队列尾部。在 Go 语言中,还有另外一种方法,那就是利用带缓冲区的 channel 来充当队列。这个实现起来非常简单,在代码实战环节我会详细给你介绍。

协程池的作用
协程池,简单来说就是由多个协程实现的池化技术。

如果你了解过 Linux 内核,应该知道 Linux 内核中是以进程为单元来调度资源的,线程也是轻量级进程。可以说,进程、线程都是由内核来创建并调度。而协程是由应用程序创建出来的任务执行单元,比如 Go 语言中的协程“goroutine”。协程本身是运行在线程上,由应用程序自己调度,它是比线程更轻量的执行单元。

相比进程、线程,协程有什么优点呢?那得从高并发下进程、线程的性能说起。

当应用程序需要创建一个进程或者线程时,它需要先调用系统函数向内核提交申请。接着它会从用户态切换到内核态,由内核创建进程和线程,然后再从内核态切换到用户态。

在用户态与内核态来回切换的过程中,操作系统需要保存大量的上下文信息。具体来说,从用户态切换到内核态时,内核需要将 CPU 各寄存器中的数据写入到栈内存;当程序从内核态恢复到用户态时,需要内核将前面保存的寄存器数据从栈内存加载到 CPU 中。

内核创建进程和线程的时候,需要分配 PCB (Processing Control Block,进程控制块),其作用是用于保存进程和线程的运行状态。也就是说,进程和线程在内核中是占用内存空间的。并且,在分配 PCB 的过程也会耗费额外的 CPU 资源。

特别是在父进程创建子进程的时候,子进程会继承父进程的内存状态。当子进程修改某块内存的数据时,会触发写时拷贝,系统会为子进程分配新的内存空间,这意味着运行时子进程有额外的 CPU 开销。另外,每个线程也有自己的栈空间,比如 Linux 下默认栈空间大小是 8MB,也就是说即使线程程什么事情都不做,也会白白浪费 8MB 内存。

还有,前面提到了创建进程和线程时会发生状态切换,主要是因为内核中的任务是以内核线程的方式来运行的。而且内核线程是可以动态创建的,可以超过 CPU 线程数。但是,一个 CPU 线程在同一时刻只能运行一个内核线程,这就涉及内核线程间上下文切换的问题了。

要知道,上下文切换是很耗费 CPU 资源的。首先,上下文切换时 CPU 需要耗费更多的时间保存或者加载数据,这会导致 CPU 有效使用率降低。其次,上下文切换后,需要加载新的数据,可能导致 CPU 中的 L1、L2 缓存中数据失效,性能打折扣。

在高并发场景下,每创建、销毁一个线程,带来的 CPU 和内存开销都不小。为了解决这些问题,协程诞生了。

在 Go 语言中,一个协程初始内存空间是 2KB,相比线程和进程来说要小很多。协程的创建和销毁完全是在用户态执行的,不涉及用户态和内核态的切换。另外,协程完全由应用程序在用户态下调用,不涉及内核态的上下文切换。协程切换时由于不需要处理线程状态,需要保存的上下文也很少,速度很快。

既然协程的创建、切换、销毁性能已经很高了,为何还要做协程池呢?

前面提到了,每个协程初始内存大小为 2K。假如每个请求都创建一个协程,当秒杀服务单机并发达到 10 万的时候,会带来近 200MB 的内存分配和占用,可能会带来 GC 回收内存的问题。

另外,虽然协程创建很快,但还是要耗费时间的,比如需要分配内存,需要初始化协程状态等。在秒杀这种高并发场景下,每个请求哪怕是增加 1 毫秒延迟,也会给服务带来不小的 CPU 开销。

那我们如何设计协程池呢?

在 Go 语言中,协程池的实现方法有两种:抢占式和调度式。

抢占式协程池中,所有任务存放到一个共享的 channel 中,由多个协程同时去消费 channel 中的任务,谁先拿到谁先执行。它的好处是下发任务的逻辑可以实现的很简单,拿到任务直接放到共享 channel 里即可。缺点是多个协程同时消费一个 channel 会涉及锁的争夺,当协程执行比较耗时的任务时,单个 channel 也容易带来容量问题。

调度式协程池中,每个协程都有自己的 channel,每个协程只消费自己的 channel。当下发任务的时候,可以采用负载均衡算法选择合适的协程来执行任务。比如选择排队中任务最少的协程,或者简单轮询。

其实池化技术是种未雨绸缪的思想,这种思想遍布在软件系统中。比如后端服务采用多个节点部署,并预留一部分余量。比如针对每天有高峰时段的系统,提前准备一批备用机器用于高峰前扩容,高峰后缩容。这些其实也是池化技术思想的应用。

漏斗模型:如何将并发流量过滤和串行化?

如何设计流量拦截器?
不知道你有没有见过做蛋糕?在做蛋糕的时候,糕点师会用筛子把粗颗粒的面粉过滤掉,只留下细腻的面粉,这样做出来的蛋糕口感细腻。

秒杀系统也是如此,由于秒杀活动的流量会远超平常,一般在流量入口,系统就要把那些非法的、无资格的、优先级低的流量过滤掉,减轻系统的并发压力。为了实现这个过滤功能,就需要我们设计流量拦截器。

什么是流量拦截器呢?通常,流量拦截器有多层,就像一个漏斗或者倒金字塔。在容量上,你可以联想到缓存金字塔,流量拦截器跟它正好相反。比如,位于上层的流量拦截器可能会负责过滤掉 40% 的流量,位于中间层的可能过滤掉 30% 的流量,而位于底层的则可能过滤掉 20% 的流量。

那么,流量拦截器的最上层是什么样子的呢?最上层流量入口是网关和 WAF(Web Application Firewall,Web 应用防火墙),它们会拦截大部分非法请求,比如一些恶意攻击的请求,一些用秒杀器疯狂刷接口的请求。

在设计上,这一层通常采用封禁攻击者来源 IP、拒绝带有非法参数的请求、按来源 IP 限流、按用户 ID 限流等方法,在顶层入口处就拦截掉这些请求。这样获得的收益也是最大的,能为下游业务系统节省大量资源。

经过上层拦截器处理后,还是会有一些漏网之鱼,比如“黄牛”。于是就有了中间层拦截器,它主要是为了识别出不具备抢购资格的用户,并拦截他们的流量。

以黄牛为例,早期的黄牛主要通过秒杀器来刷走商品,后来秒杀器被封禁后,他们改为采用多个账号同时参与秒杀活动。“黄牛”的这种行为无疑严重违反了秒杀活动的公平性,也损害了正常用户的利益,为此,我们就需要把这类流量拦截掉。

具体如何反黄牛呢?反黄牛的前提是需要先识别出谁是黄牛,这就需要一份黄牛名单了。那么,这份黄牛名单是如何产生的呢?通常它会由数据分析系统根据大量订单信息和用户信息生成,然后提供给秒杀接口服务使用。

像多个账号每次都在一个 IP 下参与秒杀,每次抢到的商品都不是给自己账号用,或者通过自制秒杀工具抢到商品后快速支付,等等。虽然对于后端服务来说有些行为看着像正常用户,但是,在大数据分析下,还是能抓到一些蛛丝马迹。

一般数据分析系统会定期生成黄牛名单,比如每天凌晨 3 点钟。然后秒杀接口服务会将黄牛名单更新到内存中。在秒杀活动进行时,秒杀接口服务会从请求中拿到账号信息后进行匹配。如果匹配到了,说明该账号是黄牛账号,需要拦截掉。

除了黄牛外,还有两部分不具备资格的流量需要拦截掉:

由未登录或者登录态过期的用户产生的流量,当他们点击秒杀购买时,我们可以让这些用户跳转到登录页进行登录;

如果用户购买数量已经达到该场次商品数量限制,此时需要提醒用户已经参与过该场次,请勿重复参与。

那位于下层的拦截器负责做什么呢?我们知道,秒杀活动中库存数量远低于参与秒杀的用户数,于是如何快速判断哪些用户抢不到库存,就是个非常关键的问题。而这,正是下层拦截器的核心工作。虽然前面两层拦截器已经拦截了大量请求,但下层拦截器面临的流量还是很大,单节点 QPS 至少上万。因此,下游拦截器判断库存的时候,对性能要求非常高。

需要怎么做呢?通常是由秒杀服务将库存数据在本地内存中缓存一份,用于初步判断库存资格。在 Go 语言中,我们可以用 map 来缓存库存数据,利用锁来控制并发扣减库存。由于完全是在本地内存中操作,性能要比访问 Redis 好很多。

要注意的是,本地内存缓存中的库存数据是比较粗略的,时间长了也容易出现误差,不能作为最终的扣减依据。所以通常需要有个定时任务,从 Redis 中定时拉取最新的库存数据,并更新到本地内存缓存中。

这个更新速度不能太快,也不能太慢。太快的话,可能导致内存中已扣减的库存还原成 Redis 中未扣减的库存;如果太慢,因超时关单归还的库存会无法及时同步到内存缓存中。我们可以根据流量大小设定一个合理的值,比如 100 毫秒同步一次。

另外,内存缓存中的库存大小也需要注意按比例缩小。如果总共有 1000 个库存、50 个秒杀节点,平均分摊的话每个节点分到 20 个库存。实际上,每个节点需要略微高于平均值,以确保足够多的请求漏下去,将 Redis 中的库存扣减完,达到最大的活动效果。

由于内存中的库存数据不是十分准确,拿到库存资格的用户可能比实际库存要大,最终还是要通过从 Redis 中扣减库存来判断用户是否抢购成功。那么,如何保障 Redis 的并发压力不会超过它的承载能力呢?那就是下面要介绍的串行化。

如何将流量串行化?
所谓串行化,是指通过排队的方式将无序的并发流量整理成有序的串行流量。

在 Redis 集群模式出现以前,大多数 Redis 都是采用一主多从的模式,写操作由主节点执行。由于一主多从模式下主节点只有一个节点,因此 Redis 的写操作并发能力远低于读操作并发能力。

在千万级并发流量下,虽然前面我们通过流量拦截器将大部分流量过滤掉了,但剩下的流量也不小。比如虽然过滤了 990 万,也就是 99% 的流量,但还剩下 10 万流量。如果这些流量都去扣减库存,会对 Redis 主节点产生巨大压力。怎么办呢?这就需要流量串行化。具体要怎么做呢?

总的来说,秒杀中的串行化主要是通过队列和分布式事务来实现的,具体分三步。

第一步,秒杀服务在扣减内存缓存中的库存成功后,将流量转入到它的内存队列中,进行初步排序,为写 MQ 做准备。

这里注意的是,要控制好内存队列缓冲区大小,太小可能会导致并发写入的时候大量请求被阻塞,可以将大小设置为消费端速度的两倍。比如消费端速度是 1000 QPS,则缓冲区可以设定为可以缓存 2000 个请求。

第二步,使用一个线程或者协程以固定速度从内存队列中消费流量,将流量写入到像 RabbitMQ 这种 MQ 中。

这一步主要是为了减轻 MQ 的并发压力,需要根据 MQ 的承载能力计算好速度。比如 MQ 的并发承载能力为 5 万 QPS,秒杀有 50 个节点,则每个节点的速度应当低于 1000 QPS。保留 20% 余量的话,每个节点的速度可以设定为 800 QPS。

第三步,使用另一个线程或协程,以低于第二步中的固定速度从 MQ 中消费流量,然后利用 Redis 事务从 Redis 中扣减库存,避免超售。最终,根据扣减库存的结果,给用户返回对应的提示信息。

需要注意的是,秒杀服务有多个节点,不同节点需要用不同的队列,但单个节点的内部,必须用同一个队列。这么做是因为多个节点间是不能共享客户端连接的,只有在节点内部消费自己的流量,才能给用户返回处理结果。

看到上面这三步,不知道你有没有联想到之前学到的限流器。没错,第二步和第三步中的固定速度就是用限流器来实现。可以说,串行化的核心思路就是:使用队列将请求进行排队、限流,使用分布式锁对资源进行原子操作。

引自:拉勾打造千万级流量系统

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值