看完这篇,面试再也不怕遇到网络协议和Netty相关的问题了


网络协议

TCP三次握手

在这里插入图片描述

TCP是面向连接的通信协议,面向连接是指在数据传输开始之前需要先建立连接,而TCP建立连接是通过三次握手进行的。在网络编程中,三次握手过程是由客户端执行connect连接来触发的,上图三次握手过程:

  1. 第一次握手:客户端发起连接请求报文,首先将标志位 SYN 置为 1,同时设置随机初始化序号seq=J,接着把当前 SYN 报文发送给服务端器(该报文不携带应用层数据),表示向服务器端发起连接,之后客户端进行 SYN_SENT 状态,等待服务器端确认
  2. 第二次握手:服务器端收到客户端的 SYN 报文后,根据报文中标志位 SYN=1得知客户端请求建立连接。然后服务器端回复应答报文,首先将标志位 SYN 和 ACK 都置为 1,并设置确认应答号 ack=J+1,同时设置随机初始化序号seq= K。最后将该报文发送给客户端(该报文不携带应用层数据),之后服务器端进行SYN_RCVD 状态
  3. 第三次握手:客户端收到服务端确认报文后,先检查确认应答号ack 是否为 J+1 以及ACK是否为 1,如果正确则需要向服务器端回复一个应答报文,首先将报文的标志位 ACK 置为 1,并设置确认应答号 ack=K+1。最后把报文发送给服务器端(该报文可以携带应用层数据),之后客户端进行ESTABLISHED 状态。而服务器端收到客户端的应答报文后,也会进入ESTABLISHED 状态

从上面的过程可以看到只有第三次握手才可以携带应用层数据的,前两次握手是不能携带数据的

为什么 TCP 握手需要三次

避免历史连接

在TCP 的协议 RFC 793 中就提到了使用三次握手的首要原因 —— 为了阻止历史的重复连接初始化造成的混乱问题,防止使用 TCP 协议通信的双方建立了错误的连接

想象一下这个场景,如果通信双方的通信次数只有两次,那么发送方一旦发出建立连接的请求之后它就没有办法撤回这一次请求,如果在网络状况复杂或者较差的网络中,发送方连续发送多次建立连接的请求,如果 TCP 建立连接只能通信两次,那么接收方只能选择接受或者拒绝发送方发起的请求,它并不清楚这一次请求是不是由于网络拥堵而早早过期的连接

所以,TCP 选择使用三次握手来建立连接并在连接引入了 RST 这一控制消息,接收方当收到请求时会将发送方发来的 SEQ+1 发送回接收方,这时由发送方来判断当前连接是否是历史连接:

  1. 如果当前连接是历史连接,即 SEQ 过期或者超时,那么发送方就会直接发送 RST 报文控制消息中止这一次连接;
  2. 如果当前连接不是历史连接,那么发送方就会发送 ACK 控制消息,通信双方就会成功建立连接;

使用三次握手和 RST 控制消息将是否建立连接的最终控制权交给了发送方,因为只有发送方有足够的上下文来判断当前连接是否是错误的或者过期的,这也是 TCP 使用三次握手建立连接的最主要原因

保证可靠传输

为了实现可靠数据传输, TCP 协议的通信双方,都必须维护一个序列号,序列号是可靠传输的一个关键点,它的作用:

  1. 接收方可以去除重复的数据
  2. 接收方可以根据数据包的序列号按序接收
  3. 可以标识发送出去的数据包中,哪些是已经被对方收到的

举例说明,当发送方在发送数据包(假设大小为 10 byte)时, 同时送上一个序号( 假设 为 500),那么接收方收到这个数据包以后, 就可以回复一个确认号(510 = 500 + 10) 告 诉发送方 “我已经收到了你的数据包, 你可以发送下一个数据包, 序号从 511 开始” 。因此三次握手的过程即是通信双方相互告知序列号起始值,并确认对方已经收到了序列号起始值的必经步骤。 如果只是两次握手,至多只有连接发起方的起始序列号能被确认, 另一方选择的序列号则得不到确认

TCP四次挥手

在这里插入图片描述

由于 TCP 连接是全双工的,因此,每个方向都必须要单独进行关闭,这一原则是当主动方完成数据发送任务后,发送一个 FIN 给被动方来终止这一方向的连接,被动方收到一个 FIN 只是意味着不会再收到主动方数据了,但是被动方依然可以给主动方发送数据,直到这被动方也发送了 FIN 给主动方。首先进行关闭的一方将执行主动关闭,而另一方则执行被动关闭,上图四次挥手流程:

  1. 客户端发起连接释放报文,首先将标志位 FIN 置为 1,表示用来关闭客户端到服务器端的数据传输,之后客户端进入 FIN-WAIT-1 状态
  2. 服务器端收到连接释放报文后,就会向客户端发出 ACK 确认报文,此时服务器端就会进入CLOSE-WAIT状态,表示半关闭状态,即客户端已经没有数据要发送了,但是服务器端若发送数据,客户端依然要接受,此时相应的客户端收到服务器端的确认请求后,此时,客户端就进入FIN-WAIT-2状态,等待服务器发送连接释放报文(在这之前还需要接受服务器发送的最后的数据)
  3. 等待服务器端将最后的数据发送完毕后,就会向客户端发送 FIN ACK 报文,此时会关闭服务器端到客户端的数据传输,服务器端进入last-ack
  4. 客户端收到服务器端的 FIN 报文后,会进入TIME-WAIT状态,接着发送确认报文 ACK。之后,服务器端在接收到 ACK 确认报文后,就会进入CLOSED状态,完成四次挥手。而客户端等待2MSL一段时间后也会进入CLOSED状态(linux设置msl为30秒)

为什么TCP的挥手需要四次

TCP 是全双工的连接,必须两端同时关闭连接,连接才算真正关闭。 在关闭连接时,客户端向服务器端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据,服务器端收到客户端的 FIN 报文时,会先回复一个 ACK 确认报文,而服务器端可能还有数据需要发送,等服务器将最后的数据发送完毕后,才发送 FIN 报文给客户端,客户端再回复 ACK报文,此时,两端都关闭,TCP连接正常关闭

为什么TIME-WAIT等待的时间是 2MSL(最大报文段生存时间)

防止收到旧连接的数据包

在 Linux 系统上,一个 TCP 端口不能被同时打开多次,当一个 TCP 连接处于 TIME_WAIT 状态时,我们无法使用该连接的端口来建立一个新连接。反过来思考,如果不存在 TIME_WAIT 状态,则应用程序能立即建立一个和刚关闭的连接相似的连接(这里的相似,是指他们具 有相同的 IP 地址和端口号)。这个新的的连接被称为原来连接的化身。新的连接可能收到属于原来连接携带应用程序数据的 TCP 报文段(迟到的报文段),这显然是不该发生的

可靠的终止TCP连接

客户端收到服务器的连接释放的 FIN 报文后, 必须发出确认。但是因为网络是不可靠的,这个 ACK 确认报文可能丢失。所以TIME_WAIT状态就是用来重发可能丢失的ACK报文。如果ACK 确认报文丢失,服务器端没有收到ACK,将不断重复发送FIN报文。所以客户端不能立即关闭,它必须确认服务器端接收到了该ACK。客户端会在发送出ACK之后进入到TIME_WAIT状态。并会设置一个计时器,等待2MSL的时间。如果在该时间内再次收到FIN,那么客户端会重发ACK并再次等待2MSL

TIME-WAIT过多有什么危害?

过多的 TIME-WAIT 状态主要的危害有两种:

  1. 内存资源占用
  2. 对端口资源的占用,一个TCP连接至少消耗一个本地端口

如果服务端 TIME-WAIT状态过多,占满了所有端口资源,则会导致无法创建新连接

建立连接后,客户端故障了怎么办

TCP有一个保活机制,原理是定义一个时间段,在这个时间段,如果没有任务连接相关的活动,TCP保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,如果未收到响应会继续发送,如果尝试发送次数超时保活探测数仍未收到响应则中断连接

在Linux内核可以有对应的参数可以设置保活时间、保活时间间隔、保活探测的次数,这些变量分别对应net.ipv4.tcp_keepalive_time、net.ipv4.tcp_keepalive_intvl、net.ipv4.tcp_keepalive_probes,默认设置是7200秒、75秒和9次探测。也就是说在Linux系统中,最少需要经过 2小时 11分 15秒才可以发现一次无效连接

DDOS 攻击

DDOS 攻击利用合理的服务请求占用过多的服务资源,使正常用户的请求无法得到相应。 常见的 DDOS 攻击有计算机网络带宽攻击连通性攻击

带宽攻击指以极大的通信量冲击网络,使得所有可用网络资源都被消耗殆尽,最后导致合法的用户请求无法通过

连通性攻击指用大量的连接请求冲击计算机,使得所有可用的操作系统资源都被消耗殆 尽,最终计算机无法再处理合法用户的请求

SYN 洪水攻击

SYN 洪水攻击属于 DDOS 攻击的一种,它利用 TCP 协议缺陷,通过发送大量的半连接请求,耗费 CPU 和内存资源。 客户端在短时间内伪造大量不存在的 IP 地址,向服务器不断地发送 SYN 报文,服务器回复 ACK 确认报文,并等待客户的确认,由于源地址是不存在的,服务器需要不断的重发直至超时,因此就会造成服务器等待连接队列被占满,使服务器无法处理正常用户的连接请求了

HTTP1.0 和 HTTP1.1 的区别

HTTP1.0 最早在网页中使用是在 1996 年,那个时候只是使用一些较为简单的网页上和网络请求上,而 HTTP1.1 则在 1999 年才开始广泛应用于现在的各大浏览器网络请求中,同时 HTTP1.1 也是当前使用最为广泛的 HTTP 协议。 主要区别主要体现在:

  1. 缓存处理,在 HTTP1.0 中主要使用 header 里的 If-Modified-Since,Expires 来做为缓存判断的标准,HTTP1.1 则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match 等更多可供选择的缓存头来控制缓存策略
  2. 带宽优化及网络连接的使用,HTTP1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能, HTTP1.1 则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206 (Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接
  3. 错误通知的管理,在 HTTP1.1 中新增了 24 个错误状态响应码,如 409(Conflict)表 示请求的资源与资源的当前状态发生冲突;410(Gone)表示服务器上的某个资源被永久性的删除
  4. Host 头处理,在 HTTP1.0 中认为每台服务器都绑定一个唯一的 IP 地址,因此,请求消息中的 URL 并没有传递主机名(hostname)。但随着虚拟主机技术的发展,在一台物理服务器上可以存在多个虚拟主机(Multi-homed Web Servers),并且它们共享一个 IP 地址。 HTTP1.1 的请求消息和响应消息都应支持 Host 头域,且请求消息中如果没有 Host 头域会报告一个错误(400 Bad Request)
  5. 长连接,HTTP 1.1 支持长连接(PersistentConnection)和请求的流水线(Pipelining) 处理,在一个 TCP 连接上可以传送多个 HTTP 请求和响应,减少了建立和关闭连接的消耗和延迟,在 HTTP1.1 中默认开启 Connection: keep-alive,一定程度上弥补了 HTTP1.0 每次请求都要创建连接的缺点

HTTP2.0 和 HTTP1.X 相比的新特性

  1. 新的二进制格式(Binary Format),HTTP1.x 的解析是基于文本。基于文本协议的格式解析存在天然缺陷,文本的表现形式有多样性,要做到健壮性考虑的场景必然很多,二进制则不同,只认 0 和 1 的组合。基于这种考虑 HTTP2.0 的协议解析决定采用二进制格式,实现方便且健壮
  2. 多路复用(MultiPlexing),即连接共享,即每一个 request 都是是用作连接共享机制的。 一个 request 对应一个 id,这样一个连接上可以有多个 request,每个连接的 request 可以随机的混杂在一起,接收方可以根据 request 的 id 将 request 再归属到各自不同的服务端请求里面
  3. header 压缩,如上文中所言,对前面提到过 HTTP1.x 的 header 带有大量信息,而且每次都要重复发送,HTTP2.0 使用 encoder 来减少需要传输的 header 大小,通讯双方各自 cache 一份 header fields 表,既避免了重复 header 的传输,又减小了需要传输的大小
  4. 服务端推送(server push),HTTP2.0 也具有 server push 功能

HTTP 与 HTTPs 的区别

  1. HTTPS 协议(HyperText Transfer Protocol over Secure Socket Layer):一般理解为 HTTP+SSL/TLS,通过 SSL 证书来验证服务器的身份,并为浏览器和服务器之间的通信进行加密
  2. HTTP的URL 以 http:// 开头,而 HTTPS 的 URL 以 https:// 开头
  3. HTTP 是不安全的,而 HTTPS 是安全的
  4. HTTP 标准端口是 80 ,而 HTTPS 的标准端口是 443
  5. 在 OSI 网络模型中,HTTP工作于应用层,而 HTTPS 的安全传输机制工作在传输层
  6. HTTP 无法加密,而 HTTPS 对传输的数据进行加密
  7. HTTP 无需证书,而 HTTPS 需要 CA 机构颁发的 SSL 证书

HTTPS 方式与 Web 服务器通信时的步骤

在这里插入图片描述

  1. 浏览器使用 HTTPS 的 URL 访问 Web 服务器,要求与 Web 服务器建立 SSL 连接
  2. Web 服务器收到浏览器请求后,会将网站的证书信息(证书中包含服务器公钥<非对称加密>)传送一份给浏览器。(HTTPS 中,服务端将公钥发给数字证书认证机构进行安全认证并对公钥进行数字签名,完成后公钥和签名组合成数字证书。在和客户端通信时, 服务端将数字证书发给客户端,客户端通过第三方安全认证机构(一般会在浏览器开发时, 内置在浏览器中)对数字证书上的签名进行验证。)
  3. 浏览器进入数字证书认证环节,这一部分是浏览器内置的 TSL 完成的
  4. 首先浏览器会从内置的证书列表中索引,找到服务器下发证书对应的机构,如果没有找到,此时就会提示用户该证书是不是由权威机构颁发,是不可信任的。如果查到了对应的机构,则取出该机构颁发的公钥
  5. 用机构的证书公钥解密得到证书的内容和证书签名,内容包括网站的网址、网站的公钥、证书的有效期等。浏览器会先验证证书签名的合法性。签名通过后,浏览器验证证书记录的网址是否和当前网址是一致的,不一致会提示用户。如果网址一致会检查证书有效期,证书过期了也会提示用户。这些都通过认证时,浏览器就可以安全使用证书中的网站公钥了
  6. 浏览器生成一个随机数 R,并使用网站公钥对 R 进行加密
  7. 浏览器将加密的 R 传送给服务器
  8. 服务器用自己的私钥解密得到 R
  9. 服务器以 R 为密钥使用了对称加密算法加密网页内容并传输给浏览器
  10. 浏览器以 R 为密钥使用之前约定好的解密算法获取网页内容

浏览器地址栏输入URL会发生什么

1、首先,浏览器地址栏输入要访问的URL
2、然后,浏览器会根据输入的URL,进行DNS域名解析,解析过程:

  1. 会搜索浏览器自身的 DNS 缓存(缓存时间比较短,大概只有 1 分钟,且只能容纳 1000 条缓 存)
  2. 如果浏览器自身的缓存里面没有找到,那么浏览器会搜索系统自身的 DNS 缓存
  3. 如果还没有找到,那么尝试从 hosts 文件里面去找
  4. 如果还是找不到,就会向网络发起一个DNS查询,查询过程会先找到本地DNS服务器来查询是否包含IP地址,如果本地DNS服务器无法查询到目标地址,就会向根域名服务器发起一个DNS查询,就会经过根域名-顶级域名-权威域名来查询,并由权威域名服务器告诉本地DNS服务器目标IP地址,再有本地DNS服务器告诉浏览器要访问的IP地址

3、接下来,浏览器根据找到的IP地址和端口与目标服务器建立TCP连接,经过三次握手的过程
4、在建立连接后,浏览器会向目标服务器发起HTTP请求
5、服务器对请求进行响应,会把带有html文本的HTTP响应报文发送给浏览器
6、浏览器拿到html文件后,就开始解析其中的html代码,遇到 js/css/image 等静态资源时,就向服务器端去请求下载, 并在显示窗口内解析渲染页面
7、关闭TCP连接(一般情况下,一旦服务器向客户端返回了请求数据,它就要关闭 TCP 连接,然后如果客户端或者服务器在其头信息加入了这行代码 Connection:keep-alive ,TCP 连接在发送后将仍然保持打开状态,于是,客户端可以继续通过相同的连接发送请求,也就是说前面的 3 到 6,可以反复进行。保持连接节省了为每个请求建立新连接所需的时间,还节约了网络带宽)

常见的 HTTP 相应状态码

200:请求被正常处理
204:请求被受理但没有资源可以返回
206:客户端只是请求资源的一部分,服务器只对请求的部分资源执行 GET 方法,相应报文中通过 Content-Range 指定范围的资源。
301:永久性重定向
302:临时重定向
303:与 302 状态码有相似功能,只是它希望客户端在请求一个 URI 的时候,能通过 GET 方法重定向到另一个 URI 上
304:发送附带条件的请求时,条件不满足时返回,与重定向无关
307:临时重定向,与 302 类似,只是强制要求使用 POST 方法
400:请求报文语法有误,服务器无法识别
401:请求需要认证
403:请求的对应资源禁止被访问
404:服务器无法找到对应资源
500:服务器内部错误
503:服务器正忙

常用的 HTTP 方法有哪些

GET: 用于请求访问已经被 URI(统一资源标识符)识别的资源,可以通过 URL 传参给服务器
POST:用于传输信息给服务器,主要功能与 GET 方法类似,但一般推荐使用 POST 方式。
PUT: 传输文件,报文主体中包含文件内容,保存到对应 URI 位置。
HEAD: 获得报文首部,与 GET 方法类似,只是不返回报文主体,一般用于验证 URI 是否有效。
DELETE:删除文件,与 PUT 方法相反,删除对应 URI 位置的文件。
OPTIONS:查询相应 URI 支持的 HTTP 方法

什么是 HTTP 协议无状态协议?怎么解决?

无状态协议指的是浏览器对于事务处理没有记忆功能,比如客户请求获取网页之后关闭浏览器,然后再一次启动浏览器,再登录该网站,但是服务器并不知道客户关闭了一次浏览器。HTTP就是一种无状态的协议,它会通过Cookie的机制来解决无状态的问题

在这里插入图片描述

当浏览器向服务器发起HTTP请求时,携带用户认证信息,服务端认证成功后,会创建一个Session对象,同时生成一个sessionId,并通过响应头的Set-Cookie: JSESSIONID = XXX命令,向客户端发送要求设置Cookie的响应,客户端收到响应后会提取并存储一个JSESSIONID=XXX的Cookie信息于内存或磁盘(该Cookie的过期时间为浏览器会话结束)。之后,客户端每次向同一个服务器发送请求时,请求都会携带上Cookie信息,然后,服务端会解析该Cookie信息,获取名称为JSESSIONID的值,得到此次请求的sessionId。这样,浏览器就具备了记忆能力

HTTP 请求报文格式

在这里插入图片描述

在这里插入图片描述

1、请求行 —— 包含用于请求的方法、请求 URI 和 HTTP 版本,它们用空格分隔
2、首部字段 —— 包含表示请求的各种条件和属性的各类首部。(通用首部、请求首部、 实体首部以及 RFC 里未定义的首部如 Cookie 等)
3、最后一个请求头之后是一个空行,发送回车符和换行符,通知服务器以下不再有请求头
4、请求数据不在GET方法中使用,而是在POST方法中使用。与请求数据相关的最常使用的请求头是Content-Type和Content-Length

HTTP 响应报文格式

在这里插入图片描述
在这里插入图片描述
1、状态行 —— 包含表明响应结果的状态码、原因短语和 HTTP 版本
2、首部字段 —— 包含表示请求的各种条件和属性的各类首部。(通用首部、响应首部、 实体首部以及 RFC 里未定义的首部如 Cookie 等)

HTTP 常见的请求首部

通用首部

通用首部主要有Date、Cache-Control 和 Connection:

  1. Date 是一个通用标头,它可以出现在请求首部和响应首部中,表示的是格林威治标准时间,这个时间要比北京时间慢八个小时
  2. Cache-Control 是一个通用首部,它可以出现在请求首部和响应首部中,Cache-Control 的种类比较多,虽然说这是一个通用首部,但是有一些特性是请求首部具有的,有一些是响应首部才有的。主要大类有 可缓存性、阈值性、 重新验证并重新加载和其他特性
  3. Connection 决定当前事务(三次握手)完成后,是否会关闭网络连接

实体首部

实体首部是描述消息正文内容的 HTTP 首部。实体首部用于 HTTP 请求和响应中。头部Content-Length、 Content-Language、 Content-Encoding 是实体头

  1. Content-Length 实体报头指示实体主体的大小,以字节为单位,发送到接收方
  2. Content-Language 实体报头描述了客户端或者服务端能够接受的语言
  3. Content-Encoding 这又是一个比较麻烦的属性,这个实体报头用来压缩媒体类型。Content-Encoding 指示对实体应用了何种编码

请求首部

  1. Host 请求资源所在服务器
  2. Referer 属性是请求标头的一部分,当浏览器向 web 服务器发送请求的时候,一般会带上 Referer,告诉服务器该网页是从哪个页面链接过来的,服务器因此可以获得一些信息用于处理
  3. Accept 用来代理可处理的媒体类型
  4. Accept-Charset 属性规定服务器处理表单数据所接受的字符集
  5. Accept-Language 用来告知服务器用户代理能够处理的自然语言集(指中文或英文等),以及自然语言集的相对优先级
  6. If-Modified-Since 通常会与 If-None-Match 搭配使用,If-Modified-Since 用于确认代理或客户端拥有的本地资源的有效性。获取资源的更新日期时间,可通过确认首部字段 Last-Modified 来确定
  7. If-None-Match 使请求成为条件请求。对于 GET 和 HEAD 方法,仅当服务器没有与给定资源匹配的 ETag 时,服务器才会以 200 状态发送回请求的资源。对于其他方法,仅当最终现有资源的ETag与列出的任何值都不匹配时,才会处理请求

响应首部

  1. Access-Control-Allow-Origin 指定一个来源,它告诉浏览器允许该来源进行资源访问
  2. Keep-Alive 表示的是 Connection 非持续连接的存活时间,可以进行指定
  3. Set-Cookie 用于服务器向客户端发送 sessionID
  4. Server HTTP服务器的安装信息

TCP和UDP的区别

在这里插入图片描述

哪些应用适合UDP方式进行传输

1、多播的信息一定要用 udp 实现,因为 tcp 只支持一对一通信。
2、如果一个应用场景中大多是简短的信息,适合用 udp 实现,因为 udp 是基于报文段的, 它直接对上层应用的数据封装成报文段,然后丢在网络中,如果信息量太大,会在链路层中被分片,影响传输效率。
3、如果一个应用场景重性能甚于重完整性和安全性,那么适合于 udp,比如多媒体应用, 缺一两帧不影响用户体验,但是需要流媒体到达的速度快,因此比较适合用 udp。 如果要求快速响应,那么 udp 听起来比较合适。
4、如果又要利用 udp 的快速响应优点,又想可靠传输,那么只能考上层应用自己制定规则了,比如 UDT。
常见的使用 udp 的例子:ICQ,QQ 的聊天模块、DNS 等等

select、poll、epoll 的区别

select,poll,epoll 都是 操作系统实现 IO 多路复用的机制。 我们知道,I/O 多路复用 就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪), 能够通知程序进行相应的读写操作。那么这三种机制有什么区别呢

1、支持一个进程所能打开的最大连接数

名称描述
select单个进程所能打开的最大连接数有 FD_SETSIZE 宏定义,其大小是 32 个整数的大小(在 32 位的机器上,大小就是 3232,同理 64 位机器上 FD_SETSIZE 为 3264),当然我们可以对进行修改,然后重新编译内核, 但是性能可能会受到影响
pollpoll 本质上和 select 没有区别,但是它没有最大连接数的限制,原因 是它是基于链表来存储的
epoll虽然连接数基本上只受限于机器的内存大小

2、FD 剧增后带来的 IO 效率问题

名称描述
select因为每次调用时都会对连接进行线性遍历,所以随着 FD 的增加会造 成遍历速度慢的“线性下降性能问题”
poll同上
epoll因为 epoll 内核中实现是根据每个 fd 上的 callback 函数来实现的,只 有活跃的 socket 才会主动调用 callback,所以在活跃 socket 较少的情况下, 使用 epoll 没有前面两者的线性下降的性能问题,但是所有 socket 都很活跃的情况下,可能会有性能问题

3、消息传递方式

名称描述
select内核需要将消息传递到用户空间,都需要内核拷贝动作
poll同上
epollepoll 通过内核和用户空间共享一块内存来实现的

综上,在选择 select,poll,epoll 时要根据具体的使用场合以及这三种方式的自身特点。
1、表面上看 epoll 的性能最好,但是在连接数少并且连接都十分活跃的情况下,select 和 poll 的性能可能比 epoll 好,毕竟 epoll 的通知机制需要很多函数回调。
2、select 低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过 良好的设计改善

什么是水平触发(LT)和边缘触发(ET)

Level_triggered(水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait() 会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么 下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果 你一直不去读写,它会一直通知你。如果系统中有大量你不需要读写的就绪文件描述符, 而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率

Edge_triggered(边缘触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait() 会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调 用 epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第 二次可读写事件才会通知你!!!这种模式比水平触发效率高,系统不会充斥大量你不关心 的就绪文件描述符

select(),poll()模型都是水平触发模式,信号驱动 IO 是边缘触发模式,epoll()模型即支持 水平触发,也支持边缘触发,默认是水平触发

Netty

Netty的特点

1、Netty 是一个 基于 NIO 的高性能、异步事件驱动的框架,支持多种传输协议,包括TCP、UDP 和文件传输等
2、使用更高效的 Socket 底层,对 epoll 空轮询引起的 cpu 占用飙升在内部通过创建一个新的Selector并替换旧的Selector进行了处理,避免了直接使用 NIO 的陷阱,简化了 NIO 的处理方式
3、内置了许多编解码器和ChannelHandler,提供了对TCP粘包/拆包进行自动化处理、多种序列化技术的集成以及对重连、心跳检测的支持等
4、服务端可单独使用Acceptor线程池和IO线程池,提高连接效率。同时可配置IO线程数,TCP连接参数以及将TCP接收和发送缓冲区使用直接内存代替堆内存
5、可以通过内存池的方式循环利用 ByteBuf,并通过引用计数器及时申请释放不再引用的对象,降低了 GC 频率
6、使用单线程串行化的方式,高效的 Reactor 线程模型

Netty的应用场景

1、实现自己的HTTP/HTTPS服务器,可以参考《Netty框架进阶篇 - 分析和实战内置的编解码器及ChannelHandler
2、实现UDP的单播和广播,可以参考《Netty框架实战篇 - 实现UDP单播和广播
3、实现即时通讯的聊天室,可以参考《Netty框架实战篇 - 基于WebSocket实现网页版的聊天室服务器
4、可以自定义一个私有协议栈,并完成内部系统的RPC远程调用。备注: 需要参考代码可以留言备注

Netty中有哪些重要组件

1、Channel:基本的I/O操作(bind()、connect()、read()和write())依赖于底层网络传输所提供的原语,在传统的网络编程中,其基本的构造是Socket,而Socket类对开发者来说并不友好,使用起来相对复杂。相对的Netty的Channel接口所提供的API,大大地降低了直接使用Socket类的复杂性
2、EventLoop:可以称之为事件循环,它定义了Netty的核心抽象,用于处理网络连接的生命周期中所发生的事件。对于一个 EventLoop将由一个永远都不会改变的Thread驱动,同时任务可以直接提交给EventLoop,以立即执行或者调度执行
3、ChannelFuture:Netty中所有的I/O操作都是异步的。因为一个操作可能不会立即返回,所以我们需要一种用于在之后的某个时间点确定其结果的方法。为此,Netty 提供了ChannelFuture接口, 其 addListener()方法注册了一个ChannelFutureListener,以便在某个操作完成时(无论是否成功)得到通知
4、ChannelHandler:从应用程序开发人员的角度来看,Netty的主要组件是ChannelHandler,它充当了所有处理入站和出站数据的应用程序逻辑的容器。ChannelHandler的方法是由网络事件触发的。 事实上,ChannelHandler可专门用于几乎任何类型的动作,例如将数据从一种格式转换为另外一种格式,例如各种编解码,或者处理转换过程中所抛出的异常
5、ChannelPipeline:ChannelPipeline提供了ChannelHandler链的容器,并定义了用于在该链上传播入站和出站事件流的API。当Channel 被创建时,它将会被自动地分配一个新的ChannelPipeline,这项关联是永久性的;Channel既不能附加另外一个 ChannelPipeline,也不能分离其当前的。在Netty组件的生命周期中,这是一项固定的操作,不需要开发人员的任何干预

详细了解,可以参考《Netty框架入门篇 - 核心组件再认识

Netty的线程模型

Netty通过 Reactor 模型(Reactor 模型基于事件驱动,采用多路复用将事件分发给相应的 Handler处理)基于多路复用器接收并处理用户请求,内部实现了两个线程池:boss 线程池和 work 线程池,其中 boss 线程池的线程负责处理请求的 accept 事件,当接收到 accept 事件的请求时,把对应的 socket 封装到一个 NioSocketChannel 中,并交给 work 线程池,其中 work 线程池负责请求的 read 和 write 事件,由对应的 Handler 处理。下面详细介绍下Netty中的线程模型:

单线程模型

所有 I/O 操作都由一个线程完成,即多路复用、事件分发和处理都是在一个 Reactor 线程上完成的。既要接收客户端的连接请求,向服务端发起连接,又要发送/读取 请求或应答/响应消息。一个 NIO 线程同时处理成百上千的链路,性能上无法支撑,速度慢, 若线程进入死循环,整个程序不可用,对于高负载、大并发的应用场景不合适

 EventLoopGroup eventGroup = new NioEventLoopGroup();
 ServerBootstrap boobtstrap = new ServerBootstrap();
 boobtstrap.group(eventGroup)

多线程模型

一个Acceptor线程只负责监听客户端的连接,一个NIO 线程池负责网络 IO 的操作,即消息的读取、解码、编码和发送。1个 NIO 线程可以同时处理 N 条链路,但是 1 个链路只对应1个 NIO 线程,这是为了防止发生并发操作问题

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workGroup = new NioEventLoopGroup();
ServerBootstrap boobtstrap = new ServerBootstrap();
boobtstrap.group(bossGroup, workGroup )

主从多线程模型

服务端用于接收客户端连接的不再是一个单独的 NIO 线程,而是一个独立的 NIO 线程池。首先从 NIO 线程池中选择一个线程作为 Acceptor 线程,绑定监听端口,接收客户端连接的连接,其他线程负责后续的接入认证等工作。连接建立完成后,Sub NIO 线程池负责具体处理 I/O 读写

EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workGroup = new NioEventLoopGroup();
ServerBootstrap boobtstrap = new ServerBootstrap();
boobtstrap.group(bossGroup, workGroup )

TCP粘包/拆包的原因及解决方法

TCP是一个“流”协议,所谓流,就是没有界限的一长串二进制数据。TCP作为传输层协议并不不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行数据包的划分,所以在业务上认为是一个完整的包,可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题

TCP粘包/分包的原因:

  1. Nagle算法
  2. TCP的MSS(最大报文段)限制
  3. 以太网的MTU(最大传输单元)限制

解决办法

  1. 消息定长:FixedLengthFrameDecoder
  2. 增加分隔符:行分隔符类(LineBasedFrameDecoder) 或自定义分隔符类 (DelimiterBasedFrameDecoder)
  3. 消息头+消息体的自定义长度:LengthFieldBasedFrameDecoder

详细了解,可以参考《Netty框架进阶篇 - 解决TCP粘包和拆包问题以及集成MessagePack序列化框架实战

请概要介绍下序列化

序列化(编码)是将对象序列化为二进制形式(字节数组),主要用于网络传输、数据 持久化等;而反序列化(解码)则是将从网络、磁盘等读取的字节数组还原成原始对象,主要用于网络传输对象的解码,以便完成远程调用

影响序列化性能的关键因素:序列化后的码流大小(网络带宽的占用)、序列化的性能 (CPU 资源占用);是否支持跨语言(异构系统的对接和开发语言切换)

Java 默认提供的序列化:无法跨语言、序列化后的码流太大、序列化的性能差

XML,优点:人机可读性好,可指定元素或特性的名称。缺点:序列化数据只包含数据 本身以及类的结构,不包括类型标识和程序集信息;只能序列化公共属性和字段;不能序列 化方法;文件庞大,文件格式复杂,传输占带宽。适用场景:当做配置文件存储数据,实时数据转换

JSON,是一种轻量级的数据交换格式,优点:兼容性高、数据格式比较简单,易于读 写、序列化后数据较小,可扩展性好,兼容性好、与 XML 相比,其协议比较简单,解析速 度比较快。缺点:数据的描述性比 XML 差、不适合性能要求为 ms 级别的情况、额外空间开销比较大。适用场景(可替代XML):跨防火墙访问、可调式性要求高、基于 Web browser 的 Ajax 请求、传输数据量相对小,实时性要求相对低(例如秒级别)的服务

Fastjson,采用一种“假定有序快速匹配”的算法。优点:接口简单易用、目前 java 语言中最快的 json 库。缺点:过于注重快,而偏离了“标准”及功能性、代码质量不高,文 档不全、安全漏洞较多。适用场景:协议交互、Web 输出、Android 客户端

Thrift,不仅是序列化协议,还是一个 RPC 框架。优点:序列化后的体积小, 速度快、 支持多种语言和丰富的数据类型、对于数据字段的增删具有较强的兼容性、支持二进制压缩 编码。缺点:使用者较少、跨防火墙访问时,不安全、不具有可读性,调试代码时相对困难、 不能与其他传输层协议共同使用(例如 HTTP)、无法支持向持久层直接读写数据,即不适 合做数据持久化序列化协议。适用场景:分布式系统的 RPC 解决方案

Protobuf,将数据结构以.proto 文件进行描述,通过代码生成工具可以生成对应数据结构的 POJO 对象和 Protobuf 相关的方法和属性。优点:序列化后码流小,性能高、结构化数 据存储格式(XML JSON 等)、通过标识字段的顺序,可以实现协议的前向兼容、结构化的 文档更容易管理和维护。缺点:需要依赖于工具生成代码、支持的语言相对较少,官方只支 持 Java 、C++ 、python。适用场景:对性能要求高的 RPC 调用、具有良好的跨防火墙的访 问属性、适合应用层对象的持久化

关于Netty序列化相关的应用,可以参考《Netty框架进阶篇 - 分析和实战内置的编解码器及ChannelHandler

Netty是如何解决 JDK 中的Selector BUG 的

Selector BUG:JDK NIO 的BUG,例如臭名昭著的 epoll bug,它会导致 Selector 空轮询, 最终导致 CPU 100%。官方声称在 JDK1.6 版本的 update18 修复了该问题,但是直到 JDK1.7 版本该问题仍旧存在,只不过该 BUG 发生概率降低了一些而已,它并没有被根本解决

这个问题的具体原因是:在部分 Linux 的 2.6 的 kernel 中,poll 和 epoll 对于突然中断的连接, socket 会对返回的 eventSet 事件集合置为 POLLHUP,也可能是 POLLERR,eventSet 事件集合发生了变化,这就可能导致 Selector 会被唤醒。这个时候 Selector 的 select 方法,返回能检索的事件集个数还是 0,所以下面本应该对key值进行遍历的事件处理根本执行不了,又回到最上面的 while(true)循环,循环往复,不断的轮询,直到 linux 系统出现 100%的 CPU 情况,最终导致程序崩溃

       while(true){
			try {
				 //阻塞,只有当至少一个注册的事件发生的时候才会继续
				selector.select();
				Set<SelectionKey> selectionKeys= selector.selectedKeys();
				Iterator<SelectionKey> it = selectionKeys.iterator();
				while(it.hasNext()){
					SelectionKey selectionKey=it.next();
					it.remove();
					hanldeInput(selectionKey);
				}
			} catch (IOException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			} finally {
				// TODO: handle finally clause
			}
		}

Netty 采用的是创建一个新的 Selector来解决:对 Selector 的 select 操作周期进行统计,每完成一次空的 select 操作进行一次计数,若在某个周期内连续发生 N 次空轮询,则触发了 epoll 死循环 bug。重建 Selector,判断是否是其他线程发起的重建请求,若不是则将原 SocketChannel 从 旧的 Selector 上去除注册,重新注册到新的 Selector 上,并将原来的 Selector 关闭

为什么要用 Netty,它的优势是什么?

1、统一的 API,支持多种传输类型,阻塞和非阻塞的
2、封装了 NIO 的很多细节,使用更简单
3、预置了多种编解码功能,支持多种主流协议,其实就提供了解决 TCP 粘包/拆包编解码器
4、真正的无连接数据包套接字支持
5、通过与其他业界主流的 NIO 框架对比,Netty 的综合性能最优
6、Netty 修复了已经发现的所有 NIO 的 bug,让开发人员可以专注于业务本身
7、Netty 是活跃的开源项目,版本迭代周期短,bug 修复速度快
8、可以通过 ChannelHandler 对通信框架进行灵活地扩展

Netty 发送消息有几种方式

Netty 有两种发送消息的方式:
1、直接写入 Channel 中,消息从 ChannelPipeline 当中尾部开始移动;
2、 写入和 ChannelHandler 绑定的 ChannelHandlerContext 中,消息从 ChannelPipeline 中 的下一个 ChannelHandler 中移动

Netty的内存管理机制是什么

首先会预申请一大块内存 Arena,Arena 由许多 Chunk 组成,而每个 Chunk 默认由 2048 个 page 组成。Chunk 通过 AVL 树的形式组织 Page,每个叶子节点表示一个 Page,而中间节点表示内 存区域,节点自己记录它在整个 Arena 中的偏移地址。

当区域被分配出去后,中间节点上的标记位会被标记,这样就表示这个中间节点以下 所有节点都已被分配了,大于 8k 的内存分配在 poolChunkList 中,而 PoolSubpage 用于分配小于 8k 的内存,它会 把一个 page 分割成多段,进行内存分配

ByteBuf的特点

1、支持自动扩容(4M),保证 put 方法不会抛出异常、通过内置的复合缓冲类型,实现零拷贝(zero-copy)
2、不需要调用 flip()来切换读/写模式,读取和写入索引分开
3、引用计数基于原子变量 AtomicIntegerFieldUpdater 用于内存回收
4、PooledByteBuf 采用二叉树来实现一个内存池,集中管理内存的分配和释放,不用每次 使用都新建一个缓冲区对象。UnpooledHeapByteBuf 每次都会新建一个缓冲区对象

Netty的零拷贝

Netty的零拷贝主要包含三个方面:

在网络通信上,Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接 内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写 入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝

在缓存操作上,Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个 逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝

通过 wrap 操作,我们可以将 byte[]数组、ByteBuf、 ByteBuffer 等包装成一个 Netty ByteBuf 对象,进而避免了拷贝操作

ByteBuf支持slice操作,因此可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf, 避免了内存的拷贝

在文件传输上,Netty 的通过 FileRegion 包装的 FileChannel.tranferTo 实现文件传输,它 可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的 内存拷贝问题

Netty调优

设置合理的线程数

对于线程池的调优,主要集中在用于接收海量设备 TCP 连接、TLS 握手的 Acceptor 线程 池( Netty 通常叫 boss NioEventLoop Group)上,以及用于处理网络数据读写、心跳发送的 1O 工作线程池(Nety 通常叫 work Nio EventLoop Group)上

对于 Nety 服务端,通常只需要启动一个监听端口用于端侧设备接入即可,但是如果服务 端集群实例比较少,甚至是单机(或者双机冷备)部署,在端侧设备在短时间内大量接入时,需要对服务端的监听方式和线程模型做优化,以满足短时间内(例如 30s)百万级的端侧设备接入的需要。服务端可以监听多个端口,利用主从 Reactor 线程模型做接入优化,前端通过 SLB 做 4 层 门 7 层负载均衡

对于 IO 工作线程池的优化,可以先采用系统默认值(即 CPU 内核数×2)进行性能测试,在 性能测试过程中采集 IO 线程的 CPU 占用大小,看是否存在瓶颈, 具体可以观察线程堆栈, 如果连续采集几次进行对比,发现线程堆栈都停留在 Selectorlmpl. lockAndDoSelect,则说明 IO 线程比较空闲,无须对工作线程数做调整

如果发现 IO 线程的热点停留在读或者写操作,或者停留在 Channelhandler 的执行处,则 可以通过适当调大 Nio EventLoop 线程的个数来提升网络的读写性能

心跳优化

针对海量设备接入的服务端,心跳优化策略如下:
1、要能够及时检测失效的连接,并将其剔除,防止无效的连接句柄积压,导致 OOM 等问题
2、设置合理的心跳周期,防止心跳定时任务积压,造成频繁的老年代 GC(新生代和老年代 都有导致 STW 的 GC,不过耗时差异较大),导致应用暂停
3、使用 Nety 提供的链路空闲检测机制,不要自己创建定时任务线程池,加重系统的负担, 以及增加潜在的并发安全问题

当设备突然掉电、连接被防火墙挡住、长时间 GC 或者通信线程发生非预期异常时,会导 致链路不可用且不易被及时发现。特别是如果异常发生在凌晨业务低谷期间,当早晨业务高 峰期到来时,由于链路不可用会导致瞬间大批量业务失败或者超时,这将对系统的可靠性产生 重大的威胁

从技术层面看,要解决链路的可靠性问题,必须周期性地对链路进行有效性检测。目前最 流行和通用的做法就是心跳检测。心跳检测机制分为三个层面:
1、TCP 层的心跳检测,即 TCP 的 Keep-Alive 机制,它的作用域是整个 TCP 协议栈
2、协议层的心跳检测,主要存在长连接协议中,例如 MQTT
3、应用层的心跳检测,它主要由各业务产品通过约定方式定时给对方发送心跳消息实现

心跳检测的目的就是确认当前链路是否可用,对方是否活着并且能够正常接收和发送消 息。作为高可靠的 NIO 框架,Nety 也提供了心跳检测机制。一般的心跳检测策略如下:
1、连续 N 次心跳检测都没有收到对方的 Pong 应答消息或者 Ping 请求消息,则认为链路 已经发生逻辑失效,这被称为心跳超时
2、在读取和发送心跳消息的时候如果直接发生了 IO 异常,说明链路已经失效,这被称为 心跳失败。无论发生心跳超时还是心跳失败,都需要关闭链路,由客户端发起重连操作,保证链 路能够恢复正常

Nety 提供了三种链路空闲检测机制,利用该机制可以轻松地实现心跳检测
1、读空闲,链路持续时间 T 没有读取到任何消息
2、写空闲,链路持续时间 T 没有发送任何消息
3、读写空闲,链路持续时间 T 没有接收或者发送任何消息
对于百万级的服务器,一般不建议很长的心跳周期和超时时长

接收和发送缓冲区调优

在一些场景下,端侧设备会周期性地上报数据和发送心跳,单个链路的消息收发量并不大, 针对此类场景,可以通过调小 TCP 的接收和发送缓冲区来降低单个 TCP 连接的资源占用率。当然对于不同的应用场景,收发缓冲区的最优值可能不同,用户需要根据实际场景,结合 性能测试数据进行针对性的调优

合理使用内存池

随着 JVM 虚拟机和 JT 即时编译技术的发展,对象的分配和回收是一个非常轻量级的工作。 但是对于缓冲区 Buffer,情况却稍有不同,特别是堆外直接内存的分配和回收,是一个耗时的操作。为了尽量重用缓冲区,Nety 提供了基于内存池的缓冲区重用机制。

在百万级的情况下,需要为每个接入的端侧设备至少分配一个接收和发送 ByteBuf 缓冲 区对象,采用传统的非池模式,每次消息读写都需要创建和释放 ByteBuf 对象,如果有 100 万个 连接,每秒上报一次数据或者心跳,就会有 100 万次/秒的 ByteBuf 对象申请和释放,即便服务 端的内存可以满足要求,GC 的压力也会非常大

以上问题最有效的解决方法就是使用内存池,每个 NioEventLoop 线程处理 N 个链路,在 线程内部,链路的处理是串行的。假如 A 链路首先被处理,它会创建接收缓冲区等对象,待解码 完成,构造的 POJO 对象被封装成任务后投递到后台的线程池中执行,然后接收缓冲区会被释 放,每条消息的接收和处理都会重复接收缓冲区的创建和释放。如果使用内存池,则当 A 链路 接收到新的数据报时,从 NioEventLoop 的内存池中申请空闲的 ByteBuf,解码后调用 release 将 ByteBuf 释放到内存池中,供后续的 B 链路使用

Nety 内存池从实现上可以分为两类:堆外直接内存和堆内存。由于 Byte Buf 主要用于网 络 IO 读写,因此采用堆外直接内存会减少一次从用户堆内存到内核态的字节数组拷贝,所以 性能更高。由于 DirectByteBuf 的创建成本比较高,因此如果使用 DirectByteBuf,则需要配合内 存池使用,否则性价比可能还不如 Heap Byte

Netty 默认的 IO 读写操作采用的都是内存池的堆外直接内存模式,如果用户需要额外使 用 ByteBuf,建议也采用内存池方式;如果不涉及网络 IO 操作(只是纯粹的内存操作),可以使用 堆内存池,这样内存的创建效率会更高一些

IO 线程和业务线程分离

如果服务端不做复杂的业务逻辑操作,仅是简单的内存操作和消息转发,则可以通过调大 NioEventLoop 工作线程池的方式,直接在 IO线程中执行业务 Channelhandler,这样便减少了一 次线程上下文切换,性能反而更高

如果有复杂的业务逻辑操作,则建议 IO 线程和业务线程分离,对于 IO 线程,由于互相之间 不存在锁竞争,可以创建一个大的 NioEvent Loop Group 线程组,所有 Channel 都共享同一个 线程池

对于后端的业务线程池,则建议创建多个小的业务线程池,线程池可以与 IO 线程绑定,这 样既减少了锁竞争,又提升了后端的处理性能

针对端侧并发连接数的流控

无论服务端的性能优化到多少,都需要考虑流控功能。当资源成为瓶颈,或者遇到端侧设 备的大量接入,需要通过流控对系统做保护。流控的策略有很多种,比如针对端侧连接数的流控

在 Nety 中,可以非常方便地实现流控功能:新增一个 FlowControlchannelhandler,然后添 加到 ChannelPipeline 靠前的位置,覆盖 channelActive()方法,创建 TCP 链路后,执行流控逻辑, 如果达到流控阈值,则拒绝该连接,调用 ChannelHandler Context 的 close(方法关闭连接

参与评论 您还未登录,请先 登录 后发表或查看评论

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

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:大白 设计师:CSDN官方博客 返回首页

打赏作者

wzljiayou

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

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

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

打赏作者

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

抵扣说明:

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

余额充值