VetorPers

VetorPers 查看完整档案

成都编辑西南科技大学  |  机械 编辑bpyd  |  php 编辑 vetor.win 编辑
编辑

vtr,坚持不懈

个人动态

VetorPers 收藏了文章 · 8月10日

建议收藏!TCP协议面试灵魂12 问

来源 | urlify.cn/rqumIn

先亮出这篇文章的思维导图:

TCP 作为传输层的协议,是一个IT工程师素养的体现,也是面试中经常被问到的知识点。在此,我将 TCP 核心的一些问题梳理了一下,希望能帮到各位。

001. 能不能说一说 TCP 和 UDP 的区别?

首先概括一下基本的区别:

TCP是一个面向连接的、可靠的、基于字节流的传输层协议。

UDP是一个面向无连接的传输层协议。(就这么简单,其它TCP的特性也就没有了)。

具体来分析,和 UDP 相比,TCP 有三大核心特性:

  1. 面向连接。所谓的连接,指的是客户端和服务器的连接,在双方互相通信之前,TCP 需要三次握手建立连接,而 UDP 没有相应建立连接的过程。
  2. 可靠性。TCP 花了非常多的功夫保证连接的可靠,这个可靠性体现在哪些方面呢?一个是有状态,另一个是可控制。

TCP 会精准记录哪些数据发送了,哪些数据被对方接收了,哪些没有被接收到,而且保证数据包按序到达,不允许半点差错。这是有状态

当意识到丢包了或者网络环境不佳,TCP 会根据具体情况调整自己的行为,控制自己的发送速度或者重发。这是可控制

相应的,UDP 就是无状态, 不可控的。

  1. 面向字节流。UDP 的数据传输是基于数据报的,这是因为仅仅只是继承了 IP 层的特性,而 TCP 为了维护状态,将一个个 IP 包变成了字节流。

002: 说说 TCP 三次握手的过程?为什么是三次而不是两次、四次?

恋爱模拟

以谈恋爱为例,两个人能够在一起最重要的事情是首先确认各自被爱的能力。接下来我们以此来模拟三次握手的过程。

第一次:

男: 我爱你。

女方收到。

由此证明男方拥有的能力。

第二次:

女: 我收到了你的爱,我也爱你。

男方收到。

OK,现在的情况说明,女方拥有被爱的能力。

第三次:

男: 我收到了你的爱。

女方收到。

现在能够保证男方具备被爱的能力。

由此完整地确认了双方被爱的能力,两人开始一段甜蜜的爱情。

真实握手

当然刚刚那段属于扯淡,不代表本人价值观,目的是让大家理解整个握手过程的意义,因为两个过程非常相似。对应到 TCP 的三次握手,也是需要确认双方的两样能力: 发送的能力接收的能力。于是便会有下面的三次握手的过程:

从最开始双方都处于CLOSED状态。然后服务端开始监听某个端口,进入了LISTEN状态。

然后客户端主动发起连接,发送 SYN , 自己变成了SYN-SENT状态。

服务端接收到,返回SYNACK(对应客户端发来的SYN),自己变成了SYN-REVD

之后客户端再发送ACK给服务端,自己变成了ESTABLISHED状态;服务端收到ACK之后,也变成了ESTABLISHED状态。

另外需要提醒你注意的是,从图中可以看出,SYN 是需要消耗一个序列号的,下次发送对应的 ACK 序列号要加1,为什么呢?只需要记住一个规则:

凡是需要对端确认的,一定消耗TCP报文的序列号。

SYN 需要对端的确认, 而 ACK 并不需要,因此 SYN 消耗一个序列号而 ACK 不需要。

为什么不是两次?

根本原因: 无法确认客户端的接收能力。

分析如下:

如果是两次,你现在发了 SYN 报文想握手,但是这个包滞留在了当前的网络中迟迟没有到达,TCP 以为这是丢了包,于是重传,两次握手建立好了连接。

看似没有问题,但是连接关闭后,如果这个滞留在网路中的包到达了服务端呢?这时候由于是两次握手,服务端只要接收到然后发送相应的数据包,就默认建立连接,但是现在客户端已经断开了。

看到问题的吧,这就带来了连接资源的浪费。

为什么不是四次?

三次握手的目的是确认双方发送接收的能力,那四次握手可以嘛?

当然可以,100 次都可以。但为了解决问题,三次就足够了,再多用处就不大了。

三次握手过程中可以携带数据么?

第三次握手的时候,可以携带。前两次握手不能携带数据。

如果前两次握手能够携带数据,那么一旦有人想攻击服务器,那么他只需要在第一次握手中的 SYN 报文中放大量数据,那么服务器势必会消耗更多的时间内存空间去处理这些数据,增大了服务器被攻击的风险。

第三次握手的时候,客户端已经处于ESTABLISHED状态,并且已经能够确认服务器的接收、发送能力正常,这个时候相对安全了,可以携带数据。

同时打开会怎样?

如果双方同时发 SYN报文,状态变化会是怎样的呢?

这是一个可能会发生的情况。

状态变迁如下:

在发送方给接收方发SYN报文的同时,接收方也给发送方发SYN报文,两个人刚上了!

发完SYN,两者的状态都变为SYN-SENT

在各自收到对方的SYN后,两者状态都变为SYN-REVD

接着会回复对应的ACK + SYN,这个报文在对方接收之后,两者状态一起变为ESTABLISHED

这就是同时打开情况下的状态变迁。

003: 说说 TCP 四次挥手的过程

过程拆解

刚开始双方处于ESTABLISHED状态。

客户端要断开了,向服务器发送 FIN 报文,在 TCP 报文中的位置如下图:

发送后客户端变成了FIN-WAIT-1状态。注意, 这时候客户端同时也变成了half-close(半关闭)状态,即无法向服务端发送报文,只能接收。

服务端接收后向客户端确认,变成了CLOSED-WAIT状态。

客户端接收到了服务端的确认,变成了FIN-WAIT2状态。

随后,服务端向客户端发送FIN,自己进入LAST-ACK状态,

客户端收到服务端发来的FIN后,自己变成了TIME-WAIT状态,然后发送 ACK 给服务端。

注意了,这个时候,客户端需要等待足够长的时间,具体来说,是 2 个 MSL(Maximum Segment Lifetime,报文最大生存时间), 在这段时间内如果客户端没有收到服务端的重发请求,那么表示 ACK 成功到达,挥手结束,否则客户端重发 ACK。

等待2MSL的意义

如果不等待会怎样?

如果不等待,客户端直接跑路,当服务端还有很多数据包要给客户端发,且还在路上的时候,若客户端的端口此时刚好被新的应用占用,那么就接收到了无用数据包,造成数据包混乱。所以,最保险的做法是等服务器发来的数据包都死翘翘再启动新的应用。

那,照这样说一个 MSL 不就不够了吗,为什么要等待 2 MSL?

  • 1 个 MSL 确保四次挥手中主动关闭方最后的 ACK 报文最终能达到对端
  • 1 个 MSL 确保对端没有收到 ACK 重传的 FIN 报文可以到达

这就是等待 2MSL 的意义。

为什么是四次挥手而不是三次?

因为服务端在接收到FIN, 往往不会立即返回FIN, 必须等到服务端所有的报文都发送完毕了,才能发FIN。因此先发一个ACK表示已经收到客户端的FIN,延迟一段时间才发FIN。这就造成了四次挥手。

如果是三次挥手会有什么问题?

等于说服务端将ACKFIN的发送合并为一次挥手,这个时候长时间的延迟可能会导致客户端误以为FIN没有到达客户端,从而让客户端不断的重发FIN

同时关闭会怎样?

如果客户端和服务端同时发送 FIN ,状态会如何变化?如图所示:

004: 说说半连接队列和 SYN Flood 攻击的关系

三次握手前,服务端的状态从CLOSED变为LISTEN, 同时在内部创建了两个队列:半连接队列全连接队列,即SYN队列ACCEPT队列

半连接队列

当客户端发送SYN到服务端,服务端收到以后回复ACKSYN,状态由LISTEN变为SYN_RCVD,此时这个连接就被推入了SYN队列,也就是半连接队列

全连接队列

当客户端返回ACK, 服务端接收后,三次握手完成。这个时候连接等待被具体的应用取走,在被取走之前,它会被推入另外一个 TCP 维护的队列,也就是全连接队列(Accept Queue)

SYN Flood 攻击原理

SYN Flood 属于典型的 DoS/DDoS 攻击。其攻击的原理很简单,就是用客户端在短时间内伪造大量不存在的 IP 地址,并向服务端疯狂发送SYN。对于服务端而言,会产生两个危险的后果:

  1. 处理大量的SYN包并返回对应ACK, 势必有大量连接处于SYN_RCVD状态,从而占满整个半连接队列,无法处理正常的请求。
  2. 由于是不存在的 IP,服务端长时间收不到客户端的ACK,会导致服务端不断重发数据,直到耗尽服务端的资源。

如何应对 SYN Flood 攻击?

  • 增加 SYN 连接,也就是增加半连接队列的容量。
  • 减少 SYN + ACK 重试次数,避免大量的超时重发。
  • 利用 SYN Cookie 技术,在服务端接收到SYN后不立即分配连接资源,而是根据这个SYN计算出一个Cookie,连同第二次握手回复给客户端,在客户端回复ACK的时候带上这个Cookie值,服务端验证 Cookie 合法之后才分配连接资源。

005: 介绍一下 TCP 报文头部的字段

报文头部结构如下(单位为字节):

请大家牢记这张图!

源端口、目标端口

如何标识唯一标识一个连接?答案是 TCP 连接的四元组——源 IP、源端口、目标 IP 和目标端口。

那 TCP 报文怎么没有源 IP 和目标 IP 呢?这是因为在 IP 层就已经处理了 IP 。TCP 只需要记录两者的端口即可。

序列号

Sequence number, 指的是本报文段第一个字节的序列号。

从图中可以看出,序列号是一个长为 4 个字节,也就是 32 位的无符号整数,表示范围为 0 ~ 2^32 - 1。如果到达最大值了后就循环到0。

序列号在 TCP 通信的过程中有两个作用:

  • 在 SYN 报文中交换彼此的初始序列号。
  • 保证数据包按正确的顺序组装。

ISN

Initial Sequence Number(初始序列号),在三次握手的过程当中,双方会用过SYN报文来交换彼此的 ISN

ISN 并不是一个固定的值,而是每 4 ms 加一,溢出则回到 0,这个算法使得猜测 ISN 变得很困难。那为什么要这么做?

如果 ISN 被攻击者预测到,要知道源 IP 和源端口号都是很容易伪造的,当攻击者猜测 ISN 之后,直接伪造一个 RST 后,就可以强制连接关闭的,这是非常危险的。

而动态增长的 ISN 大大提高了猜测 ISN 的难度。

确认号

ACK(Acknowledgment number)。用来告知对方下一个期望接收的序列号,小于ACK的所有字节已经全部收到。

标记位

常见的标记位有SYN,ACK,FIN,RST,PSH

SYN 和 ACK 已经在上文说过,后三个解释如下: FIN:即 Finish,表示发送方准备断开连接。

RST:即 Reset,用来强制断开连接。

PSH:即 Push, 告知对方这些数据包收到后应该马上交给上层的应用,不能缓存。

窗口大小

占用两个字节,也就是 16 位,但实际上是不够用的。因此 TCP 引入了窗口缩放的选项,作为窗口缩放的比例因子,这个比例因子的范围在 0 ~ 14,比例因子可以将窗口的值扩大为原来的 2 ^ n 次方。

校验和

占用两个字节,防止传输过程中数据包有损坏,如果遇到校验和有差错的报文,TCP 直接丢弃之,等待重传。

可选项

可选项的格式如下:

常用的可选项有以下几个:

  • TimeStamp: TCP 时间戳,后面详细介绍。
  • MSS: 指的是 TCP 允许的从对方接收的最大报文段。
  • SACK: 选择确认选项。
  • Window Scale:窗口缩放选项。

006: 说说 TCP 快速打开的原理(TFO)

第一节讲了 TCP 三次握手,可能有人会说,每次都三次握手好麻烦呀!能不能优化一点?

可以啊。今天来说说这个优化后的 TCP 握手流程,也就是 TCP 快速打开(TCP Fast Open, 即TFO)的原理。

优化的过程是这样的,还记得我们说 SYN Flood 攻击时提到的 SYN Cookie 吗?这个 Cookie 可不是浏览器的Cookie, 用它同样可以实现 TFO。

TFO 流程

首轮三次握手

首先客户端发送SYN给服务端,服务端接收到。

注意哦!现在服务端不是立刻回复 SYN + ACK,而是通过计算得到一个SYN Cookie, 将这个Cookie放到 TCP 报文的 Fast Open选项中,然后才给客户端返回。

客户端拿到这个 Cookie 的值缓存下来。后面正常完成三次握手。

首轮三次握手就是这样的流程。而后面的三次握手就不一样啦!

后面的三次握手

在后面的三次握手中,客户端会将之前缓存的 CookieSYNHTTP请求(是的,你没看错)发送给服务端,服务端验证了 Cookie 的合法性,如果不合法直接丢弃;如果是合法的,那么就正常返回SYN + ACK

重点来了,现在服务端能向客户端发 HTTP 响应了!这是最显著的改变,三次握手还没建立,仅仅验证了 Cookie 的合法性,就可以返回 HTTP 响应了。

当然,客户端的ACK还得正常传过来,不然怎么叫三次握手嘛。

流程如下:

注意: 客户端最后握手的 ACK 不一定要等到服务端的 HTTP 响应到达才发送,两个过程没有任何关系。

TFO 的优势

TFO 的优势并不在与首轮三次握手,而在于后面的握手,在拿到客户端的 Cookie 并验证通过以后,可以直接返回 HTTP 响应,充分利用了1 个RTT(Round-Trip Time,往返时延)的时间提前进行数据传输,积累起来还是一个比较大的优势。

007: 能不能说说TCP报文中时间戳的作用?

timestamp是 TCP 报文首部的一个可选项,一共占 10 个字节,格式如下:

kind(1 字节) + length(1 字节) + info(8 个字节)

其中 kind = 8, length = 10, info 有两部分构成: timestamptimestamp echo,各占 4 个字节。

那么这些字段都是干嘛的呢?它们用来解决那些问题?

接下来我们就来一一梳理,TCP 的时间戳主要解决两大问题:

  • 计算往返时延 RTT(Round-Trip Time)
  • 防止序列号的回绕问题

计算往返时延 RTT

在没有时间戳的时候,计算 RTT 会遇到的问题如下图所示:

如果以第一次发包为开始时间的话,就会出现左图的问题,RTT 明显偏大,开始时间应该采用第二次的;

如果以第二次发包为开始时间的话,就会导致右图的问题,RTT 明显偏小,开始时间应该采用第一次发包的。

实际上无论开始时间以第一次发包还是第二次发包为准,都是不准确的。

那这个时候引入时间戳就很好的解决了这个问题。

比如现在 a 向 b 发送一个报文 s1,b 向 a 回复一个含 ACK 的报文 s2 那么:

  • step 1: a 向 b 发送的时候,timestamp 中存放的内容就是 a 主机发送时的内核时刻 ta1
  • step 2: b 向 a 回复 s2 报文的时候,timestamp 中存放的是 b 主机的时刻 tb, timestamp echo字段为从 s1 报文中解析出来的 ta1。
  • step 3: a 收到 b 的 s2 报文之后,此时 a 主机的内核时刻是 ta2, 而在 s2 报文中的 timestamp echo 选项中可以得到 ta1, 也就是 s2 对应的报文最初的发送时刻。然后直接采用 ta2 - ta1 就得到了 RTT 的值。

防止序列号回绕问题

现在我们来模拟一下这个问题。

序列号的范围其实是在0 ~ 2 ^ 32 - 1, 为了方便演示,我们缩小一下这个区间,假设范围是 0 ~ 4,那么到达 4 的时候会回到 0。

image.png

假设在第 6 次的时候,之前还滞留在网路中的包回来了,那么就有两个序列号为1 ~ 2的数据包了,怎么区分谁是谁呢?这个时候就产生了序列号回绕的问题。

那么用 timestamp 就能很好地解决这个问题,因为每次发包的时候都是将发包机器当时的内核时间记录在报文中,那么两次发包序列号即使相同,时间戳也不可能相同,这样就能够区分开两个数据包了。

008: TCP 的超时重传时间是如何计算的?

TCP 具有超时重传机制,即间隔一段时间没有等到数据包的回复时,重传这个数据包。

那么这个重传间隔是如何来计算的呢?

今天我们就来讨论一下这个问题。

这个重传间隔也叫做超时重传时间(Retransmission TimeOut, 简称RTO),它的计算跟上一节提到的 RTT 密切相关。这里我们将介绍两种主要的方法,一个是经典方法,一个是标准方法。

经典方法

经典方法引入了一个新的概念——SRTT(Smoothed round trip time,即平滑往返时间),没产生一次新的 RTT. 就根据一定的算法对 SRTT 进行更新,具体而言,计算方式如下(SRTT 初始值为0):

SRTT = (α * SRTT) + ((1 - α) * RTT)

其中,α 是平滑因子,建议值是0.8,范围是0.8 ~ 0.9

拿到 SRTT,我们就可以计算 RTO 的值了:

RTO = min(ubound, max(lbound, β * SRTT))

β 是加权因子,一般为1.3 ~ 2.0, lbound 是下界,ubound 是上界。

其实这个算法过程还是很简单的,但是也存在一定的局限,就是在 RTT 稳定的地方表现还可以,而在 RTT 变化较大的地方就不行了,因为平滑因子 α 的范围是0.8 ~ 0.9, RTT 对于 RTO 的影响太小。

标准方法

为了解决经典方法对于 RTT 变化不敏感的问题,后面又引出了标准方法,也叫Jacobson / Karels 算法

一共有三步。

第一步: 计算SRTT,公式如下:

SRTT = (1 - α) * SRTT + α * RTT

注意这个时候的 α跟经典方法中的α取值不一样了,建议值是1/8,也就是0.125

第二步: 计算RTTVAR(round-trip time variation)这个中间变量。

RTTVAR = (1 - β) * RTTVAR + β * (|RTT - SRTT|)

β 建议值为 0.25。这个值是这个算法中出彩的地方,也就是说,它记录了最新的 RTT 与当前 SRTT 之间的差值,给我们在后续感知到 RTT 的变化提供了抓手。

第三步: 计算最终的RTO:

RTO = µ * SRTT + ∂ * RTTVAR

µ建议值取1, 建议值取4

这个公式在 SRTT 的基础上加上了最新 RTT 与它的偏移,从而很好的感知了 RTT 的变化,这种算法下,RTO 与 RTT 变化的差值关系更加密切。

009: 能不能说一说 TCP 的流量控制?

对于发送端和接收端而言,TCP 需要把发送的数据放到发送缓存区, 将接收的数据放到接收缓存区

而流量控制索要做的事情,就是在通过接收缓存区的大小,控制发送端的发送。如果对方的接收缓存区满了,就不能再继续发送了。

要具体理解流量控制,首先需要了解滑动窗口的概念。

TCP 滑动窗口

TCP 滑动窗口分为两种: 发送窗口接收窗口

发送窗口

发送端的滑动窗口结构如下:

其中包含四大部分:

  • 已发送且已确认
  • 已发送但未确认
  • 未发送但可以发送
  • 未发送也不可以发送

其中有一些重要的概念,我标注在图中:

发送窗口就是图中被框住的范围。SND 即send, WND 即window, UNA 即unacknowledged, 表示未被确认,NXT 即next, 表示下一个发送的位置。

接收窗口

接收端的窗口结构如下:

REV 即 receive,NXT 表示下一个接收的位置,WND 表示接收窗口大小。

流量控制过程

这里我们不用太复杂的例子,以一个最简单的来回来模拟一下流量控制的过程,方便大家理解。

首先双方三次握手,初始化各自的窗口大小,均为 200 个字节。

假如当前发送端给接收端发送 100 个字节,那么此时对于发送端而言,SND.NXT 当然要右移 100 个字节,也就是说当前的可用窗口减少了 100 个字节,这很好理解。

现在这 100 个到达了接收端,被放到接收端的缓冲队列中。不过此时由于大量负载的原因,接收端处理不了这么多字节,只能处理 40 个字节,剩下的 60 个字节被留在了缓冲队列中。

注意了,此时接收端的情况是处理能力不够用啦,你发送端给我少发点,所以此时接收端的接收窗口应该缩小,具体来说,缩小 60 个字节,由 200 个字节变成了 140 字节,因为缓冲队列还有 60 个字节没被应用拿走。

因此,接收端会在 ACK 的报文首部带上缩小后的滑动窗口 140 字节,发送端对应地调整发送窗口的大小为 140 个字节。

此时对于发送端而言,已经发送且确认的部分增加 40 字节,也就是 SND.UNA 右移 40 个字节,同时发送窗口缩小为 140 个字节。

这也就是流量控制的过程。尽管回合再多,整个控制的过程和原理是一样的。

010: 能不能说说 TCP 的拥塞控制?

上一节所说的流量控制发生在发送端跟接收端之间,并没有考虑到整个网络环境的影响,如果说当前网络特别差,特别容易丢包,那么发送端就应该注意一些了。而这,也正是拥塞控制需要处理的问题。

对于拥塞控制来说,TCP 每条连接都需要维护两个核心状态:

  • 拥塞窗口(Congestion Window,cwnd)
  • 慢启动阈值(Slow Start Threshold,ssthresh)

涉及到的算法有这几个:

  • 慢启动
  • 拥塞避免
  • 快速重传和快速恢复

接下来,我们就来一一拆解这些状态和算法。首先,从拥塞窗口说起。

拥塞窗口

拥塞窗口(Congestion Window,cwnd)是指目前自己还能传输的数据量大小。

那么之前介绍了接收窗口的概念,两者有什么区别呢?

  • 接收窗口(rwnd)是接收端给的限制
  • 拥塞窗口(cwnd)是发送端的限制

限制谁呢?

限制的是发送窗口的大小。

有了这两个窗口,如何来计算发送窗口

发送窗口大小 = min(rwnd, cwnd)

取两者的较小值。而拥塞控制,就是来控制cwnd的变化。

慢启动

刚开始进入传输数据的时候,你是不知道现在的网路到底是稳定还是拥堵的,如果做的太激进,发包太急,那么疯狂丢包,造成雪崩式的网络灾难。

因此,拥塞控制首先就是要采用一种保守的算法来慢慢地适应整个网路,这种算法叫慢启动。运作过程如下:

  • 首先,三次握手,双方宣告自己的接收窗口大小
  • 双方初始化自己的拥塞窗口(cwnd)大小
  • 在开始传输的一段时间,发送端每收到一个 ACK,拥塞窗口大小加 1,也就是说,每经过一个 RTT,cwnd 翻倍。如果说初始窗口为 10,那么第一轮 10 个报文传完且发送端收到 ACK 后,cwnd 变为 20,第二轮变为 40,第三轮变为 80,依次类推。

难道就这么无止境地翻倍下去?当然不可能。它的阈值叫做慢启动阈值,当 cwnd 到达这个阈值之后,好比踩了下刹车,别涨了那么快了,老铁,先 hold 住!

在到达阈值后,如何来控制 cwnd 的大小呢?

这就是拥塞避免做的事情了。

拥塞避免

原来每收到一个 ACK,cwnd 加1,现在到达阈值了,cwnd 只能加这么一点: 1 / cwnd。那你仔细算算,一轮 RTT 下来,收到 cwnd 个 ACK, 那最后拥塞窗口的大小 cwnd 总共才增加 1。

也就是说,以前一个 RTT 下来,cwnd翻倍,现在cwnd只是增加 1 而已。

当然,慢启动拥塞避免是一起作用的,是一体的。

快速重传和快速恢复

快速重传

在 TCP 传输的过程中,如果发生了丢包,即接收端发现数据段不是按序到达的时候,接收端的处理是重复发送之前的 ACK。

比如第 5 个包丢了,即使第 6、7 个包到达的接收端,接收端也一律返回第 4 个包的 ACK。当发送端收到 3 个重复的 ACK 时,意识到丢包了,于是马上进行重传,不用等到一个 RTO 的时间到了才重传。

这就是快速重传,它解决的是是否需要重传的问题。

选择性重传

那你可能会问了,既然要重传,那么只重传第 5 个包还是第5、6、7 个包都重传呢?

当然第 6、7 个都已经到达了,TCP 的设计者也不傻,已经传过去干嘛还要传?干脆记录一下哪些包到了,哪些没到,针对性地重传。

在收到发送端的报文后,接收端回复一个 ACK 报文,那么在这个报文首部的可选项中,就可以加上SACK这个属性,通过left edgeright edge告知发送端已经收到了哪些区间的数据报。因此,即使第 5 个包丢包了,当收到第 6、7 个包之后,接收端依然会告诉发送端,这两个包到了。剩下第 5 个包没到,就重传这个包。这个过程也叫做选择性重传(SACK,Selective Acknowledgment),它解决的是如何重传的问题。

快速恢复

当然,发送端收到三次重复 ACK 之后,发现丢包,觉得现在的网络已经有些拥塞了,自己会进入快速恢复阶段。

在这个阶段,发送端如下改变:

  • 拥塞阈值降低为 cwnd 的一半
  • cwnd 的大小变为拥塞阈值
  • cwnd 线性增加

以上就是 TCP 拥塞控制的经典算法: 慢启动拥塞避免快速重传和快速恢复

011: 能不能说说 Nagle 算法和延迟确认?

Nagle 算法

试想一个场景,发送端不停地给接收端发很小的包,一次只发 1 个字节,那么发 1 千个字节需要发 1000 次。这种频繁的发送是存在问题的,不光是传输的时延消耗,发送和确认本身也是需要耗时的,频繁的发送接收带来了巨大的时延。

而避免小包的频繁发送,这就是 Nagle 算法要做的事情。

具体来说,Nagle 算法的规则如下:

  • 当第一次发送数据时不用等待,就算是 1byte 的小包也立即发送
  • 后面发送满足下面条件之一就可以发了:
  • 数据包大小达到最大段大小(Max Segment Size, 即 MSS)
  • 之前所有包的 ACK 都已接收到

延迟确认

试想这样一个场景,当我收到了发送端的一个包,然后在极短的时间内又接收到了第二个包,那我是一个个地回复,还是稍微等一下,把两个包的 ACK 合并后一起回复呢?

延迟确认(delayed ack)所做的事情,就是后者,稍稍延迟,然后合并 ACK,最后才回复给发送端。TCP 要求这个延迟的时延必须小于500ms,一般操作系统实现都不会超过200ms。

不过需要主要的是,有一些场景是不能延迟确认的,收到了就要马上回复:

  • 接收到了大于一个 frame 的报文,且需要调整窗口大小
  • TCP 处于 quickack 模式(通过tcp_in_quickack_mode设置)
  • 发现了乱序包

两者一起使用会怎样?

前者意味着延迟发,后者意味着延迟接收,会造成更大的延迟,产生性能问题。

012. 如何理解 TCP 的 keep-alive?

大家都听说过 http 的keep-alive, 不过 TCP 层面也是有keep-alive机制,而且跟应用层不太一样。

试想一个场景,当有一方因为网络故障或者宕机导致连接失效,由于 TCP 并不是一个轮询的协议,在下一个数据包到达之前,对端对连接失效的情况是一无所知的。

这个时候就出现了 keep-alive, 它的作用就是探测对端的连接有没有失效。

在 Linux 下,可以这样查看相关的配置:

sudo sysctl -a | grep keepalive// 每隔 7200 s 检测一次net.ipv4.tcp_keepalive_time = 7200// 一次最多重传 9 个包net.ipv4.tcp_keepalive_probes = 9// 每个包的间隔重传间隔 75 snet.ipv4.tcp_keepalive_intvl = 75

不过,现状是大部分的应用并没有默认开启 TCP 的keep-alive选项,为什么?

站在应用的角度:

  • 7200s 也就是两个小时检测一次,时间太长
  • 时间再短一些,也难以体现其设计的初衷, 即检测长时间的死连接

因此是一个比较尴尬的设计。

image

查看原文

VetorPers 赞了文章 · 8月10日

建议收藏!TCP协议面试灵魂12 问

来源 | urlify.cn/rqumIn

先亮出这篇文章的思维导图:

TCP 作为传输层的协议,是一个IT工程师素养的体现,也是面试中经常被问到的知识点。在此,我将 TCP 核心的一些问题梳理了一下,希望能帮到各位。

001. 能不能说一说 TCP 和 UDP 的区别?

首先概括一下基本的区别:

TCP是一个面向连接的、可靠的、基于字节流的传输层协议。

UDP是一个面向无连接的传输层协议。(就这么简单,其它TCP的特性也就没有了)。

具体来分析,和 UDP 相比,TCP 有三大核心特性:

  1. 面向连接。所谓的连接,指的是客户端和服务器的连接,在双方互相通信之前,TCP 需要三次握手建立连接,而 UDP 没有相应建立连接的过程。
  2. 可靠性。TCP 花了非常多的功夫保证连接的可靠,这个可靠性体现在哪些方面呢?一个是有状态,另一个是可控制。

TCP 会精准记录哪些数据发送了,哪些数据被对方接收了,哪些没有被接收到,而且保证数据包按序到达,不允许半点差错。这是有状态

当意识到丢包了或者网络环境不佳,TCP 会根据具体情况调整自己的行为,控制自己的发送速度或者重发。这是可控制

相应的,UDP 就是无状态, 不可控的。

  1. 面向字节流。UDP 的数据传输是基于数据报的,这是因为仅仅只是继承了 IP 层的特性,而 TCP 为了维护状态,将一个个 IP 包变成了字节流。

002: 说说 TCP 三次握手的过程?为什么是三次而不是两次、四次?

恋爱模拟

以谈恋爱为例,两个人能够在一起最重要的事情是首先确认各自被爱的能力。接下来我们以此来模拟三次握手的过程。

第一次:

男: 我爱你。

女方收到。

由此证明男方拥有的能力。

第二次:

女: 我收到了你的爱,我也爱你。

男方收到。

OK,现在的情况说明,女方拥有被爱的能力。

第三次:

男: 我收到了你的爱。

女方收到。

现在能够保证男方具备被爱的能力。

由此完整地确认了双方被爱的能力,两人开始一段甜蜜的爱情。

真实握手

当然刚刚那段属于扯淡,不代表本人价值观,目的是让大家理解整个握手过程的意义,因为两个过程非常相似。对应到 TCP 的三次握手,也是需要确认双方的两样能力: 发送的能力接收的能力。于是便会有下面的三次握手的过程:

从最开始双方都处于CLOSED状态。然后服务端开始监听某个端口,进入了LISTEN状态。

然后客户端主动发起连接,发送 SYN , 自己变成了SYN-SENT状态。

服务端接收到,返回SYNACK(对应客户端发来的SYN),自己变成了SYN-REVD

之后客户端再发送ACK给服务端,自己变成了ESTABLISHED状态;服务端收到ACK之后,也变成了ESTABLISHED状态。

另外需要提醒你注意的是,从图中可以看出,SYN 是需要消耗一个序列号的,下次发送对应的 ACK 序列号要加1,为什么呢?只需要记住一个规则:

凡是需要对端确认的,一定消耗TCP报文的序列号。

SYN 需要对端的确认, 而 ACK 并不需要,因此 SYN 消耗一个序列号而 ACK 不需要。

为什么不是两次?

根本原因: 无法确认客户端的接收能力。

分析如下:

如果是两次,你现在发了 SYN 报文想握手,但是这个包滞留在了当前的网络中迟迟没有到达,TCP 以为这是丢了包,于是重传,两次握手建立好了连接。

看似没有问题,但是连接关闭后,如果这个滞留在网路中的包到达了服务端呢?这时候由于是两次握手,服务端只要接收到然后发送相应的数据包,就默认建立连接,但是现在客户端已经断开了。

看到问题的吧,这就带来了连接资源的浪费。

为什么不是四次?

三次握手的目的是确认双方发送接收的能力,那四次握手可以嘛?

当然可以,100 次都可以。但为了解决问题,三次就足够了,再多用处就不大了。

三次握手过程中可以携带数据么?

第三次握手的时候,可以携带。前两次握手不能携带数据。

如果前两次握手能够携带数据,那么一旦有人想攻击服务器,那么他只需要在第一次握手中的 SYN 报文中放大量数据,那么服务器势必会消耗更多的时间内存空间去处理这些数据,增大了服务器被攻击的风险。

第三次握手的时候,客户端已经处于ESTABLISHED状态,并且已经能够确认服务器的接收、发送能力正常,这个时候相对安全了,可以携带数据。

同时打开会怎样?

如果双方同时发 SYN报文,状态变化会是怎样的呢?

这是一个可能会发生的情况。

状态变迁如下:

在发送方给接收方发SYN报文的同时,接收方也给发送方发SYN报文,两个人刚上了!

发完SYN,两者的状态都变为SYN-SENT

在各自收到对方的SYN后,两者状态都变为SYN-REVD

接着会回复对应的ACK + SYN,这个报文在对方接收之后,两者状态一起变为ESTABLISHED

这就是同时打开情况下的状态变迁。

003: 说说 TCP 四次挥手的过程

过程拆解

刚开始双方处于ESTABLISHED状态。

客户端要断开了,向服务器发送 FIN 报文,在 TCP 报文中的位置如下图:

发送后客户端变成了FIN-WAIT-1状态。注意, 这时候客户端同时也变成了half-close(半关闭)状态,即无法向服务端发送报文,只能接收。

服务端接收后向客户端确认,变成了CLOSED-WAIT状态。

客户端接收到了服务端的确认,变成了FIN-WAIT2状态。

随后,服务端向客户端发送FIN,自己进入LAST-ACK状态,

客户端收到服务端发来的FIN后,自己变成了TIME-WAIT状态,然后发送 ACK 给服务端。

注意了,这个时候,客户端需要等待足够长的时间,具体来说,是 2 个 MSL(Maximum Segment Lifetime,报文最大生存时间), 在这段时间内如果客户端没有收到服务端的重发请求,那么表示 ACK 成功到达,挥手结束,否则客户端重发 ACK。

等待2MSL的意义

如果不等待会怎样?

如果不等待,客户端直接跑路,当服务端还有很多数据包要给客户端发,且还在路上的时候,若客户端的端口此时刚好被新的应用占用,那么就接收到了无用数据包,造成数据包混乱。所以,最保险的做法是等服务器发来的数据包都死翘翘再启动新的应用。

那,照这样说一个 MSL 不就不够了吗,为什么要等待 2 MSL?

  • 1 个 MSL 确保四次挥手中主动关闭方最后的 ACK 报文最终能达到对端
  • 1 个 MSL 确保对端没有收到 ACK 重传的 FIN 报文可以到达

这就是等待 2MSL 的意义。

为什么是四次挥手而不是三次?

因为服务端在接收到FIN, 往往不会立即返回FIN, 必须等到服务端所有的报文都发送完毕了,才能发FIN。因此先发一个ACK表示已经收到客户端的FIN,延迟一段时间才发FIN。这就造成了四次挥手。

如果是三次挥手会有什么问题?

等于说服务端将ACKFIN的发送合并为一次挥手,这个时候长时间的延迟可能会导致客户端误以为FIN没有到达客户端,从而让客户端不断的重发FIN

同时关闭会怎样?

如果客户端和服务端同时发送 FIN ,状态会如何变化?如图所示:

004: 说说半连接队列和 SYN Flood 攻击的关系

三次握手前,服务端的状态从CLOSED变为LISTEN, 同时在内部创建了两个队列:半连接队列全连接队列,即SYN队列ACCEPT队列

半连接队列

当客户端发送SYN到服务端,服务端收到以后回复ACKSYN,状态由LISTEN变为SYN_RCVD,此时这个连接就被推入了SYN队列,也就是半连接队列

全连接队列

当客户端返回ACK, 服务端接收后,三次握手完成。这个时候连接等待被具体的应用取走,在被取走之前,它会被推入另外一个 TCP 维护的队列,也就是全连接队列(Accept Queue)

SYN Flood 攻击原理

SYN Flood 属于典型的 DoS/DDoS 攻击。其攻击的原理很简单,就是用客户端在短时间内伪造大量不存在的 IP 地址,并向服务端疯狂发送SYN。对于服务端而言,会产生两个危险的后果:

  1. 处理大量的SYN包并返回对应ACK, 势必有大量连接处于SYN_RCVD状态,从而占满整个半连接队列,无法处理正常的请求。
  2. 由于是不存在的 IP,服务端长时间收不到客户端的ACK,会导致服务端不断重发数据,直到耗尽服务端的资源。

如何应对 SYN Flood 攻击?

  • 增加 SYN 连接,也就是增加半连接队列的容量。
  • 减少 SYN + ACK 重试次数,避免大量的超时重发。
  • 利用 SYN Cookie 技术,在服务端接收到SYN后不立即分配连接资源,而是根据这个SYN计算出一个Cookie,连同第二次握手回复给客户端,在客户端回复ACK的时候带上这个Cookie值,服务端验证 Cookie 合法之后才分配连接资源。

005: 介绍一下 TCP 报文头部的字段

报文头部结构如下(单位为字节):

请大家牢记这张图!

源端口、目标端口

如何标识唯一标识一个连接?答案是 TCP 连接的四元组——源 IP、源端口、目标 IP 和目标端口。

那 TCP 报文怎么没有源 IP 和目标 IP 呢?这是因为在 IP 层就已经处理了 IP 。TCP 只需要记录两者的端口即可。

序列号

Sequence number, 指的是本报文段第一个字节的序列号。

从图中可以看出,序列号是一个长为 4 个字节,也就是 32 位的无符号整数,表示范围为 0 ~ 2^32 - 1。如果到达最大值了后就循环到0。

序列号在 TCP 通信的过程中有两个作用:

  • 在 SYN 报文中交换彼此的初始序列号。
  • 保证数据包按正确的顺序组装。

ISN

Initial Sequence Number(初始序列号),在三次握手的过程当中,双方会用过SYN报文来交换彼此的 ISN

ISN 并不是一个固定的值,而是每 4 ms 加一,溢出则回到 0,这个算法使得猜测 ISN 变得很困难。那为什么要这么做?

如果 ISN 被攻击者预测到,要知道源 IP 和源端口号都是很容易伪造的,当攻击者猜测 ISN 之后,直接伪造一个 RST 后,就可以强制连接关闭的,这是非常危险的。

而动态增长的 ISN 大大提高了猜测 ISN 的难度。

确认号

ACK(Acknowledgment number)。用来告知对方下一个期望接收的序列号,小于ACK的所有字节已经全部收到。

标记位

常见的标记位有SYN,ACK,FIN,RST,PSH

SYN 和 ACK 已经在上文说过,后三个解释如下: FIN:即 Finish,表示发送方准备断开连接。

RST:即 Reset,用来强制断开连接。

PSH:即 Push, 告知对方这些数据包收到后应该马上交给上层的应用,不能缓存。

窗口大小

占用两个字节,也就是 16 位,但实际上是不够用的。因此 TCP 引入了窗口缩放的选项,作为窗口缩放的比例因子,这个比例因子的范围在 0 ~ 14,比例因子可以将窗口的值扩大为原来的 2 ^ n 次方。

校验和

占用两个字节,防止传输过程中数据包有损坏,如果遇到校验和有差错的报文,TCP 直接丢弃之,等待重传。

可选项

可选项的格式如下:

常用的可选项有以下几个:

  • TimeStamp: TCP 时间戳,后面详细介绍。
  • MSS: 指的是 TCP 允许的从对方接收的最大报文段。
  • SACK: 选择确认选项。
  • Window Scale:窗口缩放选项。

006: 说说 TCP 快速打开的原理(TFO)

第一节讲了 TCP 三次握手,可能有人会说,每次都三次握手好麻烦呀!能不能优化一点?

可以啊。今天来说说这个优化后的 TCP 握手流程,也就是 TCP 快速打开(TCP Fast Open, 即TFO)的原理。

优化的过程是这样的,还记得我们说 SYN Flood 攻击时提到的 SYN Cookie 吗?这个 Cookie 可不是浏览器的Cookie, 用它同样可以实现 TFO。

TFO 流程

首轮三次握手

首先客户端发送SYN给服务端,服务端接收到。

注意哦!现在服务端不是立刻回复 SYN + ACK,而是通过计算得到一个SYN Cookie, 将这个Cookie放到 TCP 报文的 Fast Open选项中,然后才给客户端返回。

客户端拿到这个 Cookie 的值缓存下来。后面正常完成三次握手。

首轮三次握手就是这样的流程。而后面的三次握手就不一样啦!

后面的三次握手

在后面的三次握手中,客户端会将之前缓存的 CookieSYNHTTP请求(是的,你没看错)发送给服务端,服务端验证了 Cookie 的合法性,如果不合法直接丢弃;如果是合法的,那么就正常返回SYN + ACK

重点来了,现在服务端能向客户端发 HTTP 响应了!这是最显著的改变,三次握手还没建立,仅仅验证了 Cookie 的合法性,就可以返回 HTTP 响应了。

当然,客户端的ACK还得正常传过来,不然怎么叫三次握手嘛。

流程如下:

注意: 客户端最后握手的 ACK 不一定要等到服务端的 HTTP 响应到达才发送,两个过程没有任何关系。

TFO 的优势

TFO 的优势并不在与首轮三次握手,而在于后面的握手,在拿到客户端的 Cookie 并验证通过以后,可以直接返回 HTTP 响应,充分利用了1 个RTT(Round-Trip Time,往返时延)的时间提前进行数据传输,积累起来还是一个比较大的优势。

007: 能不能说说TCP报文中时间戳的作用?

timestamp是 TCP 报文首部的一个可选项,一共占 10 个字节,格式如下:

kind(1 字节) + length(1 字节) + info(8 个字节)

其中 kind = 8, length = 10, info 有两部分构成: timestamptimestamp echo,各占 4 个字节。

那么这些字段都是干嘛的呢?它们用来解决那些问题?

接下来我们就来一一梳理,TCP 的时间戳主要解决两大问题:

  • 计算往返时延 RTT(Round-Trip Time)
  • 防止序列号的回绕问题

计算往返时延 RTT

在没有时间戳的时候,计算 RTT 会遇到的问题如下图所示:

如果以第一次发包为开始时间的话,就会出现左图的问题,RTT 明显偏大,开始时间应该采用第二次的;

如果以第二次发包为开始时间的话,就会导致右图的问题,RTT 明显偏小,开始时间应该采用第一次发包的。

实际上无论开始时间以第一次发包还是第二次发包为准,都是不准确的。

那这个时候引入时间戳就很好的解决了这个问题。

比如现在 a 向 b 发送一个报文 s1,b 向 a 回复一个含 ACK 的报文 s2 那么:

  • step 1: a 向 b 发送的时候,timestamp 中存放的内容就是 a 主机发送时的内核时刻 ta1
  • step 2: b 向 a 回复 s2 报文的时候,timestamp 中存放的是 b 主机的时刻 tb, timestamp echo字段为从 s1 报文中解析出来的 ta1。
  • step 3: a 收到 b 的 s2 报文之后,此时 a 主机的内核时刻是 ta2, 而在 s2 报文中的 timestamp echo 选项中可以得到 ta1, 也就是 s2 对应的报文最初的发送时刻。然后直接采用 ta2 - ta1 就得到了 RTT 的值。

防止序列号回绕问题

现在我们来模拟一下这个问题。

序列号的范围其实是在0 ~ 2 ^ 32 - 1, 为了方便演示,我们缩小一下这个区间,假设范围是 0 ~ 4,那么到达 4 的时候会回到 0。

image.png

假设在第 6 次的时候,之前还滞留在网路中的包回来了,那么就有两个序列号为1 ~ 2的数据包了,怎么区分谁是谁呢?这个时候就产生了序列号回绕的问题。

那么用 timestamp 就能很好地解决这个问题,因为每次发包的时候都是将发包机器当时的内核时间记录在报文中,那么两次发包序列号即使相同,时间戳也不可能相同,这样就能够区分开两个数据包了。

008: TCP 的超时重传时间是如何计算的?

TCP 具有超时重传机制,即间隔一段时间没有等到数据包的回复时,重传这个数据包。

那么这个重传间隔是如何来计算的呢?

今天我们就来讨论一下这个问题。

这个重传间隔也叫做超时重传时间(Retransmission TimeOut, 简称RTO),它的计算跟上一节提到的 RTT 密切相关。这里我们将介绍两种主要的方法,一个是经典方法,一个是标准方法。

经典方法

经典方法引入了一个新的概念——SRTT(Smoothed round trip time,即平滑往返时间),没产生一次新的 RTT. 就根据一定的算法对 SRTT 进行更新,具体而言,计算方式如下(SRTT 初始值为0):

SRTT = (α * SRTT) + ((1 - α) * RTT)

其中,α 是平滑因子,建议值是0.8,范围是0.8 ~ 0.9

拿到 SRTT,我们就可以计算 RTO 的值了:

RTO = min(ubound, max(lbound, β * SRTT))

β 是加权因子,一般为1.3 ~ 2.0, lbound 是下界,ubound 是上界。

其实这个算法过程还是很简单的,但是也存在一定的局限,就是在 RTT 稳定的地方表现还可以,而在 RTT 变化较大的地方就不行了,因为平滑因子 α 的范围是0.8 ~ 0.9, RTT 对于 RTO 的影响太小。

标准方法

为了解决经典方法对于 RTT 变化不敏感的问题,后面又引出了标准方法,也叫Jacobson / Karels 算法

一共有三步。

第一步: 计算SRTT,公式如下:

SRTT = (1 - α) * SRTT + α * RTT

注意这个时候的 α跟经典方法中的α取值不一样了,建议值是1/8,也就是0.125

第二步: 计算RTTVAR(round-trip time variation)这个中间变量。

RTTVAR = (1 - β) * RTTVAR + β * (|RTT - SRTT|)

β 建议值为 0.25。这个值是这个算法中出彩的地方,也就是说,它记录了最新的 RTT 与当前 SRTT 之间的差值,给我们在后续感知到 RTT 的变化提供了抓手。

第三步: 计算最终的RTO:

RTO = µ * SRTT + ∂ * RTTVAR

µ建议值取1, 建议值取4

这个公式在 SRTT 的基础上加上了最新 RTT 与它的偏移,从而很好的感知了 RTT 的变化,这种算法下,RTO 与 RTT 变化的差值关系更加密切。

009: 能不能说一说 TCP 的流量控制?

对于发送端和接收端而言,TCP 需要把发送的数据放到发送缓存区, 将接收的数据放到接收缓存区

而流量控制索要做的事情,就是在通过接收缓存区的大小,控制发送端的发送。如果对方的接收缓存区满了,就不能再继续发送了。

要具体理解流量控制,首先需要了解滑动窗口的概念。

TCP 滑动窗口

TCP 滑动窗口分为两种: 发送窗口接收窗口

发送窗口

发送端的滑动窗口结构如下:

其中包含四大部分:

  • 已发送且已确认
  • 已发送但未确认
  • 未发送但可以发送
  • 未发送也不可以发送

其中有一些重要的概念,我标注在图中:

发送窗口就是图中被框住的范围。SND 即send, WND 即window, UNA 即unacknowledged, 表示未被确认,NXT 即next, 表示下一个发送的位置。

接收窗口

接收端的窗口结构如下:

REV 即 receive,NXT 表示下一个接收的位置,WND 表示接收窗口大小。

流量控制过程

这里我们不用太复杂的例子,以一个最简单的来回来模拟一下流量控制的过程,方便大家理解。

首先双方三次握手,初始化各自的窗口大小,均为 200 个字节。

假如当前发送端给接收端发送 100 个字节,那么此时对于发送端而言,SND.NXT 当然要右移 100 个字节,也就是说当前的可用窗口减少了 100 个字节,这很好理解。

现在这 100 个到达了接收端,被放到接收端的缓冲队列中。不过此时由于大量负载的原因,接收端处理不了这么多字节,只能处理 40 个字节,剩下的 60 个字节被留在了缓冲队列中。

注意了,此时接收端的情况是处理能力不够用啦,你发送端给我少发点,所以此时接收端的接收窗口应该缩小,具体来说,缩小 60 个字节,由 200 个字节变成了 140 字节,因为缓冲队列还有 60 个字节没被应用拿走。

因此,接收端会在 ACK 的报文首部带上缩小后的滑动窗口 140 字节,发送端对应地调整发送窗口的大小为 140 个字节。

此时对于发送端而言,已经发送且确认的部分增加 40 字节,也就是 SND.UNA 右移 40 个字节,同时发送窗口缩小为 140 个字节。

这也就是流量控制的过程。尽管回合再多,整个控制的过程和原理是一样的。

010: 能不能说说 TCP 的拥塞控制?

上一节所说的流量控制发生在发送端跟接收端之间,并没有考虑到整个网络环境的影响,如果说当前网络特别差,特别容易丢包,那么发送端就应该注意一些了。而这,也正是拥塞控制需要处理的问题。

对于拥塞控制来说,TCP 每条连接都需要维护两个核心状态:

  • 拥塞窗口(Congestion Window,cwnd)
  • 慢启动阈值(Slow Start Threshold,ssthresh)

涉及到的算法有这几个:

  • 慢启动
  • 拥塞避免
  • 快速重传和快速恢复

接下来,我们就来一一拆解这些状态和算法。首先,从拥塞窗口说起。

拥塞窗口

拥塞窗口(Congestion Window,cwnd)是指目前自己还能传输的数据量大小。

那么之前介绍了接收窗口的概念,两者有什么区别呢?

  • 接收窗口(rwnd)是接收端给的限制
  • 拥塞窗口(cwnd)是发送端的限制

限制谁呢?

限制的是发送窗口的大小。

有了这两个窗口,如何来计算发送窗口

发送窗口大小 = min(rwnd, cwnd)

取两者的较小值。而拥塞控制,就是来控制cwnd的变化。

慢启动

刚开始进入传输数据的时候,你是不知道现在的网路到底是稳定还是拥堵的,如果做的太激进,发包太急,那么疯狂丢包,造成雪崩式的网络灾难。

因此,拥塞控制首先就是要采用一种保守的算法来慢慢地适应整个网路,这种算法叫慢启动。运作过程如下:

  • 首先,三次握手,双方宣告自己的接收窗口大小
  • 双方初始化自己的拥塞窗口(cwnd)大小
  • 在开始传输的一段时间,发送端每收到一个 ACK,拥塞窗口大小加 1,也就是说,每经过一个 RTT,cwnd 翻倍。如果说初始窗口为 10,那么第一轮 10 个报文传完且发送端收到 ACK 后,cwnd 变为 20,第二轮变为 40,第三轮变为 80,依次类推。

难道就这么无止境地翻倍下去?当然不可能。它的阈值叫做慢启动阈值,当 cwnd 到达这个阈值之后,好比踩了下刹车,别涨了那么快了,老铁,先 hold 住!

在到达阈值后,如何来控制 cwnd 的大小呢?

这就是拥塞避免做的事情了。

拥塞避免

原来每收到一个 ACK,cwnd 加1,现在到达阈值了,cwnd 只能加这么一点: 1 / cwnd。那你仔细算算,一轮 RTT 下来,收到 cwnd 个 ACK, 那最后拥塞窗口的大小 cwnd 总共才增加 1。

也就是说,以前一个 RTT 下来,cwnd翻倍,现在cwnd只是增加 1 而已。

当然,慢启动拥塞避免是一起作用的,是一体的。

快速重传和快速恢复

快速重传

在 TCP 传输的过程中,如果发生了丢包,即接收端发现数据段不是按序到达的时候,接收端的处理是重复发送之前的 ACK。

比如第 5 个包丢了,即使第 6、7 个包到达的接收端,接收端也一律返回第 4 个包的 ACK。当发送端收到 3 个重复的 ACK 时,意识到丢包了,于是马上进行重传,不用等到一个 RTO 的时间到了才重传。

这就是快速重传,它解决的是是否需要重传的问题。

选择性重传

那你可能会问了,既然要重传,那么只重传第 5 个包还是第5、6、7 个包都重传呢?

当然第 6、7 个都已经到达了,TCP 的设计者也不傻,已经传过去干嘛还要传?干脆记录一下哪些包到了,哪些没到,针对性地重传。

在收到发送端的报文后,接收端回复一个 ACK 报文,那么在这个报文首部的可选项中,就可以加上SACK这个属性,通过left edgeright edge告知发送端已经收到了哪些区间的数据报。因此,即使第 5 个包丢包了,当收到第 6、7 个包之后,接收端依然会告诉发送端,这两个包到了。剩下第 5 个包没到,就重传这个包。这个过程也叫做选择性重传(SACK,Selective Acknowledgment),它解决的是如何重传的问题。

快速恢复

当然,发送端收到三次重复 ACK 之后,发现丢包,觉得现在的网络已经有些拥塞了,自己会进入快速恢复阶段。

在这个阶段,发送端如下改变:

  • 拥塞阈值降低为 cwnd 的一半
  • cwnd 的大小变为拥塞阈值
  • cwnd 线性增加

以上就是 TCP 拥塞控制的经典算法: 慢启动拥塞避免快速重传和快速恢复

011: 能不能说说 Nagle 算法和延迟确认?

Nagle 算法

试想一个场景,发送端不停地给接收端发很小的包,一次只发 1 个字节,那么发 1 千个字节需要发 1000 次。这种频繁的发送是存在问题的,不光是传输的时延消耗,发送和确认本身也是需要耗时的,频繁的发送接收带来了巨大的时延。

而避免小包的频繁发送,这就是 Nagle 算法要做的事情。

具体来说,Nagle 算法的规则如下:

  • 当第一次发送数据时不用等待,就算是 1byte 的小包也立即发送
  • 后面发送满足下面条件之一就可以发了:
  • 数据包大小达到最大段大小(Max Segment Size, 即 MSS)
  • 之前所有包的 ACK 都已接收到

延迟确认

试想这样一个场景,当我收到了发送端的一个包,然后在极短的时间内又接收到了第二个包,那我是一个个地回复,还是稍微等一下,把两个包的 ACK 合并后一起回复呢?

延迟确认(delayed ack)所做的事情,就是后者,稍稍延迟,然后合并 ACK,最后才回复给发送端。TCP 要求这个延迟的时延必须小于500ms,一般操作系统实现都不会超过200ms。

不过需要主要的是,有一些场景是不能延迟确认的,收到了就要马上回复:

  • 接收到了大于一个 frame 的报文,且需要调整窗口大小
  • TCP 处于 quickack 模式(通过tcp_in_quickack_mode设置)
  • 发现了乱序包

两者一起使用会怎样?

前者意味着延迟发,后者意味着延迟接收,会造成更大的延迟,产生性能问题。

012. 如何理解 TCP 的 keep-alive?

大家都听说过 http 的keep-alive, 不过 TCP 层面也是有keep-alive机制,而且跟应用层不太一样。

试想一个场景,当有一方因为网络故障或者宕机导致连接失效,由于 TCP 并不是一个轮询的协议,在下一个数据包到达之前,对端对连接失效的情况是一无所知的。

这个时候就出现了 keep-alive, 它的作用就是探测对端的连接有没有失效。

在 Linux 下,可以这样查看相关的配置:

sudo sysctl -a | grep keepalive// 每隔 7200 s 检测一次net.ipv4.tcp_keepalive_time = 7200// 一次最多重传 9 个包net.ipv4.tcp_keepalive_probes = 9// 每个包的间隔重传间隔 75 snet.ipv4.tcp_keepalive_intvl = 75

不过,现状是大部分的应用并没有默认开启 TCP 的keep-alive选项,为什么?

站在应用的角度:

  • 7200s 也就是两个小时检测一次,时间太长
  • 时间再短一些,也难以体现其设计的初衷, 即检测长时间的死连接

因此是一个比较尴尬的设计。

image

查看原文

赞 60 收藏 52 评论 0

VetorPers 赞了文章 · 2019-07-24

Nginx+PHP-FPM优化技巧总结

Nginx+PHP-FPM优化技巧总结

这里是从网上找到的一片文章,认真的实践了一遍,有很多值得参考的地方可以学习,由于之前的文章排版非常混乱,所以本人一边学习一边加重写整理此文,所有版权归原作者所有

Unix域Socket通信

之前简单介绍过Unix Domain Socket这种通信方式,参见:Nginx+PHP-FPM的域Socket配置方法
UnixSocket因为不走网络,的确可以提高Nginxphp-fpm通信的性能,但在高并发时会不稳定。

Nginx会频繁报错:

connect() to unix:/dev/shm/php-fcgi.sock failed (11: Resource temporarily unavailable) while connecting to upstream

可以通过下面两种方式提高稳定性:

1.调高nginxphp-fpm中的backlog
配置方法为:在nginx配置文件中这个域名的server下,在listen 80后面添加default backlog=1024
同时配置php-fpm.conf中的listen.backlog1024,默认为128
2.增加sock文件和php-fpm实例数再新建一个sock文件,在Nginx中通过upstream模块将请求负载均衡到两个sock文件
背后的两套php-fpm实例上。

php-fpm参数调优

2.1进程数

# php-fpm初始/空闲/最大worker进程数
 pm.max_children = 300
 pm.start_servers = 20
 pm.min_spare_servers = 5
 pm.max_spare_servers = 35

2.2最大处理请求数

最大处理请求数是指一个php-fpmworker进程在处理多少个请求后就终止掉,master进程会重新respawn一个新的。
这个配置的主要目的是避免php解释器或程序引用的第三方库造成的内存泄露

pm.max_requests = 10240

2.3最长执行时间

最大执行时间在php.iniphp-fpm.conf里都可以配置,配置项分别为max_execution_timerequest_terminate_timeout
其作用及其影响参见:Nginx中502和504错误详解

php-fpm的高CPU使用率排查方法

1. CPU使用率监控方法

top命令:
直接执行top命令后,输入1就可以看到各个核心的CPU使用率。而且通过top -d 0.1可以缩短采样时间。
下面的sar貌似最短只能是1秒

sar命令:

# sar和iostat命令的安装:
 sysstat.x86_64 : The sar and iostat system monitoring commands
 yum install -y sysstat.x86_64
 
# 执行sar -P ALL 1 100。-P ALL表示监控所有核心,1表示每1秒采集,100表示采集100次。
# 输出结果如下:
CPU     %user     %nice   %system   %iowait    %steal     %idle
all     85.54      0.00      5.69      0.00      0.00      8.76
  0     74.75      0.00     25.25      0.00      0.00      0.00
  1     98.00      0.00      2.00      0.00      0.00      0.00
  2     89.22      0.00      3.92      0.00      0.00      6.86
  3     91.00      0.00      2.00      0.00      0.00      7.00
  4     75.00      0.00      9.00      0.00      0.00     16.00
  5     94.95      0.00      5.05      0.00      0.00      0.00
  6     95.00      0.00      4.00      0.00      0.00      1.00
  7     87.88      0.00      4.04      0.00      0.00      8.08
  8     93.94      0.00      3.03      0.00      0.00      3.03
  9     88.00      0.00      3.00      0.00      0.00      9.00
 10     89.11      0.00      2.97      0.00      0.00      7.92
 11     82.35      0.00      3.92      0.00      0.00     13.73
 12     73.27      0.00      7.92      0.00      0.00     18.81
 13     81.44      0.00      4.12      0.00      0.00     14.43
 14     77.23      0.00      6.93      0.00      0.00     15.84
 15     78.79      0.00      4.04      0.00      0.00     17.17
 

2. 开启慢日志

配置输出php-fpm慢日志,阀值为2秒:

request_slowlog_timeout = 2
slowlog = log/$pool.log.slow

利用sort/uniq命令分析汇总php-fpm慢日志:

[root@boole log] grep -v "^$" www.log.slow.tmp | cut -d " " -f 3,2 | sort | uniq -c | sort -k1,1nr | head -n 50
   5181 run() /www/test.net/framework/web/filters/CFilter.php:41
   5156 filter() /www/test.net/framework/web/filters/CFilterChain.php:131
   2670 = /www/test.net/index.php
   2636 run() /www/test.net/application/controllers/survey/index.php:665
   2630 action() /www/test.net/application/controllers/survey/index.php:18
   2625 run() /www/test.net/framework/web/actions/CAction.php:75
   2605 runWithParams() /www/test.net/framework/web/CController.php:309
   2604 runAction() /www/test.net/framework/web/filters/CFilterChain.php:134
   2538 run() /www/test.net/framework/web/CController.php:292
   2484 runActionWithFilters() /www/test.net/framework/web/CController.php:266
   2251 run() /www/test.net/framework/web/CWebApplication.php:276
   1799 translate() /www/test.net/application/libraries/Limesurvey_lang.php:118
   1786 load_tables() /www/test.net/application/third_party/php-gettext/gettext.php:254
   1447 runController() /www/test.net/framework/web/CWebApplication.php:135
 
# 参数解释:
     sort:  对单词进行排序
     uniq -c:  显示唯一的行,并在每行行首加上本行在文件中出现的次数
     sort -k1,1nr:  按照第一个字段,数值排序,且为逆序
     head -10:  取前10行数据

3. 用strace跟踪进程

1.利用nohupstrace转为后台执行,直到attach上的php-fpm进程死掉为止:

nohup strace -T -p 13167 > 13167-strace.log &
# 参数说明:
-c 统计每一系统调用的所执行的时间,次数和出错的次数等.
-d 输出strace关于标准错误的调试信息.
-f 跟踪由fork调用所产生的子进程.
-o filename,则所有进程的跟踪结果输出到相应的filename
-F 尝试跟踪vfork调用.在-f时,vfork不被跟踪.
-h 输出简要的帮助信息.
-i 输出系统调用的入口指针.
-q 禁止输出关于脱离的消息.
-r 打印出相对时间关于,,每一个系统调用.
-t 在输出中的每一行前加上时间信息.
-tt 在输出中的每一行前加上时间信息,微秒级.
-ttt 微秒级输出,以秒了表示时间.
-T 显示每一调用所耗的时间.
-v 输出所有的系统调用.一些调用关于环境变量,状态,输入输出等调用由于使用频繁,默认不输出.
-V 输出strace的版本信息.
-x 以十六进制形式输出非标准字符串
-xx 所有字符串以十六进制形式输出.
-a column
设置返回值的输出位置.默认为40.
-e execve 只记录 execve 这类系统调用
-p 主进程号

2.也可以用利用-c参数让strace帮助汇总,非常方便非常强大!

[root@b28-12 log]# strace -cp 9907
Process 9907 attached - interrupt to quit
Process 9907 detached
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
56.61    0.016612           5      3121           read
11.11    0.003259           1      2517       715 stat
  8.04    0.002358           7       349           brk
  6.02    0.001767           1      1315           poll
  4.28    0.001255           6       228           recvfrom
  2.71    0.000796           1       671           open
  2.54    0.000745           0      2453           fcntl
  2.37    0.000696           1      1141           write
  1.69    0.000497           1       593        13 access
  1.37    0.000403           0      1816           lseek
  0.89    0.000262           1       451        22 sendto
  0.56    0.000163           1       276       208 lstat
  0.49    0.000145           0       384           getcwd
  0.31    0.000090           0      1222           fstat
  0.28    0.000082           0       173           munmap
  0.26    0.000077           0       174           mmap
  0.24    0.000069           2        41           socket
  0.23    0.000068           0       725           close
  0.00    0.000000           0        13           rt_sigaction
  0.00    0.000000           0        13           rt_sigprocmask
  0.00    0.000000           0         1           rt_sigreturn
  0.00    0.000000           0        78           setitimer
  0.00    0.000000           0        26        26 connect
  0.00    0.000000           0        15         2 accept
  0.00    0.000000           0        39           recvmsg
  0.00    0.000000           0        26           shutdown
  0.00    0.000000           0        13           bind
  0.00    0.000000           0        13           getsockname
  0.00    0.000000           0        65           setsockopt
  0.00    0.000000           0        13           getsockopt
  0.00    0.000000           0         8           getdents
  0.00    0.000000           0        26           chdir
  0.00    0.000000           0         1           futex
------ ----------- ----------- --------- --------- ----------------
100.00    0.029344                 18000       986 total

4.加速PHP解释执行

如果自己的程序的确没有问题,只是执行了太多操作,没法再做优化了。则考虑使用APCxcache等PHP加速器来减少CPU解释php文件的耗时。
这些PHP加速器在php文件第一次解释时会生成中间代码opcode,所以之后的执行会快很多,并且减少了一些CPU的运算。下面以xcache为例,
看下如何安装和配置。

安装xcache命令如下,./configure的参数好多不知道是做什么用的,官网上也没说明,所以只开启--enable-xcache了:

 tar zxvf xcache-3.0.3.tar.gz
     /usr/local/php/bin/phpize
     ./configure --with-php-config=/usr/local/php/bin/php-config --enable-xcache
     make
     make install

php.ini中配置如下,最重要的是标红的两个参数,一般推荐xcache.size根据php文件多少来定,xcache.countCPU核心数相同:

[xcache.admin]
xcache.admin.enable_auth = Off
xcache.admin.user = "xcache"
xcache.admin.pass = ""
 
[xcache]
xcache.shm_scheme ="mmap"
xcache.size=1024M
xcache.count =16
xcache.slots =8K
xcache.ttl=0
xcache.gc_interval =0
xcache.var_size=16M
xcache.var_count =1
xcache.var_slots =8K
xcache.var_ttl=0
xcache.var_maxttl=0
xcache.var_gc_interval =300
xcache.test =Off
xcache.readonly_protection = Off
;xcache.readonly_protection = On
xcache.mmap_path ="/dev/zero"
;xcache.mmap_path ="/tmp/xcache"
xcache.coredump_directory =""
xcache.cacher =On
xcache.stat=On
xcache.optimizer =Off
 
[xcache.coverager]
;;xcache.coverager =On
;;xcache.coveragedump_directory =""

常见问题是启动php-fpm时会报错:

Cannot open or create file set by xcache.mmap_path, check the path permission or check xcache.size/var_size against system limitation

这是因为/tmp/xcache是一个文件,而不能创建成目录。

重启php-fpm服务后,用top命令观察会发现每个worker进程的VIRT(包含了swap区)都是xcache.size大小,但REQ变得很小了。
使用上面的配置在使CPU使用率的峰值时间变短了,但峰值时还是所有核心都会达到90%以上,不知道是不是哪里没有配置对。
另外高并发时,/dev/zero这种配置方式经常会导致Nginx 502错误。/tmp/xcache和开启readonly_protection则很稳定。

php程序性能监控

常用的方法就是开启xdebug的性能监控功能,将xdebug输出结果通过WinCacheGrind软件分析。
xdebug的安装和配合IDE调试的方法参见:Vim+XDebug调试PHP

php.ini中配置的这几项是输出性能信息的:

xdebug.auto_trace = on
xdebug.auto_profile = on
xdebug.collect_params = on
xdebug.collect_return = on
xdebug.profiler_enable = on
xdebug.trace_output_dir = "/tmp"
xdebug.profiler_output_dir ="/tmp"

这样XDebug会输出所有执行php函数的性能数据,但产生的文件也会比较大。可以关闭一些选项如collect_params、collect_return,
来减少输出的数据量。或者关闭自动输出,通过在想要监控的函数首尾调用xdebug函数来监控指定的函数。

输出的文件名类似cachegrind.out.1277560600trace.3495983249.txt,可以拿到Windows平台下用WinCacheGrind进行图形化分析。
WinCacheGrind使用方法网上有很多介绍,这里就不详细说明了,WinCacheGrind for github

clipboard.png

结束语

以上都是近期做php程序优化工作总结出的一些优化方法,针对每个地方的配置请详细阅读官方文档进行修改,并不一定要以本文为依据,本文档只阐述方法

警告:本文所有版权归博客园toxic所有,本人只是自己学习对以前的文章进行动手实验和借助segmentfault强大的markdown编辑器重新排版,以便好的文章能够让自己更多的开发者学习和收益,此才是分享文章的真正意义!
参考文献: Nging+PHP-FPM优化总结

查看原文

赞 16 收藏 22 评论 0

VetorPers 发布了文章 · 2019-06-28

Redis 哨兵使用以及在 Laravel 中的配置

主从配置(master-slave)

  • 复制 redis 配置文件以开启多个 slave
sudo cp /etc/redis.conf /etc/redis-6381.conf

sudo cp /etc/redis.conf /etc/redis-6382.conf

  • 编辑 slave 配置文件,主要修改参数

port 6381

pidfile "/var/run/redis-6381.pid"

logfile "/var/log/redis/redis-6381.log"

slaveof 11.11.11.11 6381

masterauth "123456" # 主从都保持一样的密码,且 master 的配置也需要这一行,在执行切换 master 的时候好像不会去添加这一行
  • /usr/bin/redis-server /etc/redis.conf 通过配置启动 redis

哨兵配置(sentinel)

  • 复制哨兵配置,这儿开启3个哨兵
sudo cp /etc/redis-sentinel.conf /etc/redis-sentinel-26381.conf

sudo cp /etc/redis-sentinel.conf /etc/redis-sentinel-26382.conf

  • 编辑哨兵配置文件,主要修改参数如下,根据具体情况配置

port 26381

pidfile "/var/run/redis-sentinel-26381.pid"

logfile "/var/log/redis/redis-sentinel-26381.log"

sentinel monitor mymaster 11.11.11.11 6379 2 #主节点别名为mymaster,后面是ip和端口,2代表判断主节点失败至少需要2个sentinel节点同意

sentinel auth-pass mymaster 123456

sentinel down-after-milliseconds mymaster 30000 #主节点故障30秒后启用新的主节点

sentinel parallel-syncs mymaster 1 #故障转移时最多可以有1个从节点同时对主节点进行数据同步,数字越大,用时越短,存在网络和 IO 开销

sentinel failover-timeout mymaster 180000 #故障转移超时时间180s:a 如果转移超时失败,下次转移时时间为之前的2倍;b 从节点变主节点时,从节点执行 slaveof no one 命令一直失败的话,当时间超过180S时,则故障转移失败;c 从节点复制新主节点时间超过180S转移失败
  • /usr/bin/redis-sentinel /etc/redis-sentinel.conf 通过配置启动哨兵

laravel 哨兵配置


'default' => [
            'tcp://11.11.11.11:26379',
            'tcp://11.11.11.11:26381',
            'tcp://11.11.11.11:26382',    //这3个都是sentinel节点的地址
            'options' => [
                'replication' => 'sentinel',
                'service'     => env('REDIS_SENTINEL_SERVICE', 'mymaster'),    //sentinel
                'parameters'  => [
                    'password' => env('REDIS_PASSWORD', null),    //redis的密码,没有时写null
                    'database' => 0,
                ],
            ],
        ]
查看原文

赞 2 收藏 2 评论 0

VetorPers 关注了标签 · 2019-06-24

redis

REmote DIctionary Server(Redis) 是一个由Salvatore Sanfilippo写的key-value存储系统。Redis提供了一些丰富的数据结构,包括 lists, sets, ordered sets 以及 hashes ,当然还有和Memcached一样的 strings结构.Redis当然还包括了对这些数据结构的丰富操作。

推荐NoSQL“ FanRedis资料汇总专题

关注 10845

VetorPers 发布了文章 · 2019-06-07

一个用于收藏文章的扩展包 Laravel Collect

Laravel Collect 是我开发的一个收藏文章的扩展,借鉴于 cybercog/laravel-love ,我也有幸参加了社区对该扩展的外文翻译文章 为你的 Eloquent 模型添加喜欢和讨厌功能。我的初衷是学习怎么开发 Laravel 扩展包,所以实现的功能可能比较简单,请大神勿喷。但是对于想学习开发 Laravel 扩展包的同学还是不错的。望大家点赞支持,感谢。

最近发现已经有人使用我的扩展包到项目里,我更有动力不断更新完善。fight!

安装

通过 composer 安装,命令如下:

$ composer require vetor/laravel-collect

我们需要执行模型迁移命令,将 Collections 表发布到我们的数据库:

$ php artisan migrate

使用

在我们的收藏者表,即 User 表里需要实现 CollectorContract 接口,并引用 Collector trait:

use Illuminate\Foundation\Auth\User as Authenticatable;
use Vetor\Laravel\Collect\Collector\Models\Traits\Collector;
use Vetor\Contracts\Collect\Collector\Models\Collector as CollectorContract;

class User extends Authenticatable implements CollectorContract
{
    use Collector;
}

如果用户需要收藏文章,在 Article 表里实现 CollectableContract 接口并引用 Collectable trait 即可:

use Vetor\Laravel\Collect\Collectable\Models\Traits\Collectable;
use Vetor\Contracts\Collect\Collectable\Models\Collectable as CollectableContract;

class Article extends Model implements CollectableContract
{
    use Collectable;
}

可用的方法

对于用户来说,可用的方法有:

// 收藏
$user->collect($article);

// 取消收藏
$user->cancelCollect($article);

// 用户的所有收藏记录
$user->collections;

// 用户收藏的文章记录
$user->collectionsWhereCollectable(Article::class);

文章可用的方法有:

// 收藏
$article->collect();

// 取消收藏(默认为当前用户,可以把用户实例作为参数传入)
$article->cancelCollect();

//  获取文章的收藏情况
$article->collections();

// 获取文章收藏数
$article->collections_count;

// 根据收藏数排序(升序 'asc';降序 'desc';默认为升序)
Article::orderByCollectionsCount()->get();

我们可以通过下面的方法来获取收藏表里所有文章:

Collection::whereCollectable(Article::class)->get();

更多

代码参见 Github 仓库 vetor/laravel-collect,欢迎大家提出自己的想法,指出不足,我们一起学习进步。再次感谢 cybercog/laravel-love

查看原文

赞 0 收藏 0 评论 0

VetorPers 发布了文章 · 2019-06-07

一个用于收藏文章的扩展包 Laravel Collect

Laravel Collect 是我开发的一个收藏文章的扩展,借鉴于 cybercog/laravel-love ,我也有幸参加了社区对该扩展的外文翻译文章 为你的 Eloquent 模型添加喜欢和讨厌功能。我的初衷是学习怎么开发 Laravel 扩展包,所以实现的功能可能比较简单,请大神勿喷。但是对于想学习开发 Laravel 扩展包的同学还是不错的。望大家点赞支持,感谢。

最近发现已经有人使用我的扩展包到项目里,我更有动力不断更新完善。fight!

安装

通过 composer 安装,命令如下:

$ composer require vetor/laravel-collect

我们需要执行模型迁移命令,将 Collections 表发布到我们的数据库:

$ php artisan migrate

使用

在我们的收藏者表,即 User 表里需要实现 CollectorContract 接口,并引用 Collector trait:

use Illuminate\Foundation\Auth\User as Authenticatable;
use Vetor\Laravel\Collect\Collector\Models\Traits\Collector;
use Vetor\Contracts\Collect\Collector\Models\Collector as CollectorContract;

class User extends Authenticatable implements CollectorContract
{
    use Collector;
}

如果用户需要收藏文章,在 Article 表里实现 CollectableContract 接口并引用 Collectable trait 即可:

use Vetor\Laravel\Collect\Collectable\Models\Traits\Collectable;
use Vetor\Contracts\Collect\Collectable\Models\Collectable as CollectableContract;

class Article extends Model implements CollectableContract
{
    use Collectable;
}

可用的方法

对于用户来说,可用的方法有:

// 收藏
$user->collect($article);

// 取消收藏
$user->cancelCollect($article);

// 用户的所有收藏记录
$user->collections;

// 用户收藏的文章记录
$user->collectionsWhereCollectable(Article::class);

文章可用的方法有:

// 收藏
$article->collect();

// 取消收藏(默认为当前用户,可以把用户实例作为参数传入)
$article->cancelCollect();

//  获取文章的收藏情况
$article->collections();

// 获取文章收藏数
$article->collections_count;

// 根据收藏数排序(升序 'asc';降序 'desc';默认为升序)
Article::orderByCollectionsCount()->get();

我们可以通过下面的方法来获取收藏表里所有文章:

Collection::whereCollectable(Article::class)->get();

更多

代码参见 Github 仓库 vetor/laravel-collect,欢迎大家提出自己的想法,指出不足,我们一起学习进步。再次感谢 cybercog/laravel-love

查看原文

赞 0 收藏 0 评论 0

VetorPers 赞了文章 · 2019-04-17

Nginx高并发下的优化

写在前面

最近在进行服务器的优化,正好在看nginx相关的知识,所以把一些知识整理一下。参考资料为《Nginx高性能web服务器详解》,建议大家都去读读这本书。
我的机器为四核CPU,16G内存。

内核参数优化

把如下的参数追加到Linux系统的/etc/sysctl.conf文件中,然后使用如下命令使修改生效:/sbin/sysctl -p

net.core.netdev_max_backlog = 262144
net.core.somaxconn = 262144
net.ipv4.tcp_max_orphans = 262144
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_timestamps = 0
net.ipv4.tcp_synack_retries = 1
net.ipv4.tcp_syn_retries = 1

net.core.netdev_max_backlog参数

参数net.core.netdev_max_backlog,表示当每个网络接口接受数据包的速率比内核处理这些包的速率快时,允许发送队列的数据包的最大数目,我们调整为262144.

net.core.somaxconn

该参数用于调节系统同时发起的TCP连接数,一般默认值为128,在客户端高并发的请求的情况下,该默认值较小,可能导致连接超时或者重传问题,我们可以根据实际情况结合并发数来调节此值。

net.ipv4.tcp_max_orphans

该参数用于设定系统中最多允许存在多少TCP套接字不被关联到任何一个用户文件句柄上,如果超过这个数字,没有与用户文件句柄关联到TCP套接字将立即被复位,同时发出警告信息,这个限制只是为了简单防治Dos攻击,一般系统内存充足的情况下,可以增大这个参数。

net.ipv4.tcp_max_syn_backlog

该参数用于记录尚未收到客户端确认信息的连接请求的最大值,对于拥有128内存的系统而言,此参数的默认值为1024,对小内存的系统则是128,一般在系统内存比较充足的情况下,可以增大这个参数的赋值。

net.ipv4.tcp_timestamps

该参数用于设置时间戳,这个可以避免序列号的卷绕,在一个1Gb/s的链路上,遇到以前用过的序列号概率很大,当此值赋值为0时,警用对于TCP时间戳的支持,默认情况下,TCP协议会让内核接受这种异常的数据包,针对Nginx服务器来说,建议将其关闭。

net.ipv4.tcp_synack_retries

该参数用于设置内核放弃TCP连接之前向客户端发送SYN+ACK包的数量,为了建立对端的连接服务,服务器和客户端需要进行三次握手,第二次握手期间,内核需要发送SYN并附带一个回应前一个SYN的ACK,这个参数主要影响这个过程,一般赋予值为1,即内核放弃连接之前发送一次SYN+ACK包。

net.ipv4.tcp_syn_retries

该参数的作用与上一个参数类似,设置内核放弃建立连接之前发送SYN包的数量,赋值为1。

nginx优化

nginx的配置文件如下:

user www-data;
pid /run/nginx.pid;

worker_processes 4;
worker_cpu_affinity 0001 0010 0100 1000;
worker_rlimit_nofile 65535;


events {
        use epoll;
        worker_connections 65535;
        accept_mutex off;
        multi_accept off;

}

http {

        ##
        # Basic Settings
        ##

        sendfile on;
        tcp_nopush on;
        tcp_nodelay on;
        keepalive_timeout 60 50;
        send_timeout 10s;
        types_hash_max_size 2048;
        client_header_buffer_size 4k;
        client_max_body_size 8m;

        include /etc/nginx/mime.types;
        default_type application/octet-stream;

        ##
        # Logging Settings
        ##

        access_log /var/log/nginx/access.log;
        error_log /var/log/nginx/error.log;

        ##
        # Gzip Settings
        ##

        gzip on;
        gzip_disable "msie6";
        gzip_min_length 1024;
        gzip_vary on;
        gzip_comp_level 2;
        gzip_buffers 32 4k;
        gunzip_static on;
        gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;


        ##
        # Virtual Host Configs
        ##

        include /etc/nginx/conf.d/*.conf;
        include /etc/nginx/sites-enabled/*;
}

worker_processes

worker_processes用来设置Nginx服务的进程数。推荐是CPU内核数或者内核数的倍数,推荐使用CPU内核数,因为我的CPU为4核的,所以设置为4。

worker_cpu_affinity

默认情况下,Nginx的多个进程有可能跑在某一个CPU或CPU的某一核上,导致Nginx进程使用硬件的资源不均,因此绑定Nginx进程到不同的CPU上是为了充分利用硬件的多CPU多核资源的目的。
worker_cpu_affinity用来为每个进程分配CPU的工作内核,参数有多个二进制值表示,每一组代表一个进程,每组中的每一位代表该进程使用CPU的情况,1代表使用,0代表不使用。所以我们使用worker_cpu_affinity 0001 0010 0100 1000;来让进程分别绑定不同的核上。

worker_connections

设置一个进程理论允许的最大连接数,理论上越大越好,但不可以超过worker_rlimit_nofile的值。还有个问题,linux系统中有个指令open file resource limit,它设置了进程可以打开的文件句柄数量,可以用下面的指令查看你的linux系统中open file resource limit指令的值,cat /proc/sys/fs/file-max
可以将该指令设置为23900251
echo "2390251" > /proc/sys/fs/file-max; sysctl -p

worker_rlimit_nofile

设置毎个进程的最大文件打开数。如果不设的话上限就是系统的ulimit –n的数字,一般为65535。

use epoll

设置事件驱动模型使用epoll。事件驱动模型有select、poll、poll等。

  • select先创建事件的描述符集合,对于一个描述符,可以关注其上面的Read事件、Write事件以及Exception事件,所以要创建三类事件描述符集合,分别用来处理Read事件的描述符、Write事件的描述符、Exception事件的描述符,然后调用底层的select()函数,等待事件发生,轮询所有事件描述符集合的每一个事件描述符,检查是否有事件发生,有的话就处理。select效率低,主要是轮询效率低,而且还要分别轮询三个事件描述符的集合。
  • poll方法与select类似,都是先创建一个关注事件的描述符集合,再去等待这些事件发生,然后再轮询描述符集合,检查有无事件发生,如果有,就去处理。不同点是poll为Read事件、Write事件以及Exception事件只创建一个集合,在每个描述符对应的结构上分别设置Read事件、Write事件以及Exception事件。最后轮询的时候,可以同时检察权这三个事件是否发生。可以说,poll库是select库的优化实现。
  • epoll是Nginx支持的高性能事件驱动库之一。是公认的非常优秀的事件驱动模型。和poll库跟select库有很大的不同,最大区别在于效率。我们知道poll库跟select库都是创建一个待处理的事件列表,然后把这个列表发给内核,返回的时候,再去轮询检查这个列表,以判断事件是否发生。这样在描述符多的应用中,效率就显得比较低下了。一种比较好的方式是把列表的管理交由内核负责,一旦某种事件发生,内核就把发生事件的描述符列表通知给进程,这样就避免了轮询整个描述符列表。首先,epoll库通过相关调用同志内核创建一个有N个描述符的事件列表,然后给这些描述符设置所关注的事件,并把它添加到内核的事件列表中去。完成设置以后,epoll库就开始等待内核通知事件发生了,某一事件发生后,内核讲发生事件的描述符列表上报给epoll库,得到事件列表的epoll库,就可以进行事件处理了。epoll库在linux平台是高效的,它支持一个进程打开大数目的事件描述符,上限是系统可以打开文件的最大数目;同时,epoll库的IO效率不随描述符数量的增加而线性下降,因为它只会对内核上报的活跃的描述符进行操作。

accept_mutex

这个牵扯到《UNIX网络编程》第一卷中提到的“惊群”问题(Thundering herd problem),大致意思是当某一时刻只有一个网络连接到来时,多个睡眠进程会被同时叫醒,但只有一个进程可获得连接,如果每次唤醒的进程数目太多,会影响一部分系统性能。在Nginx服务器的多进程下,就可能出现这个问题,为了解决这个问题,Nginx配置了包含这样一条指令accept_mutex,当其设置为开启的时候,将会对多个Nginx进程接受连接进行序列化,防止多个进程对连接的争抢。当服务器连接数不多时,开启这个参数会让负载有一定程度的降低。但是当服务器的吞吐量很大时,为了效率,请关闭这个参数;并且关闭这个参数的时候也可以让请求在多个worker间的分配更均衡。所以我们设置accept_mutex off;

multi_accept

sendfile

使用开启或关闭是否使用sendfile()传输文件,普通应用应该设为on,下载等IO重负荷的应用应该设为off,因为大文件不适合放到buffer中。
传统文件传输中(read/write方式)在实现上3其实是比较复杂的,需要经过多次上下文切换,当需要对一个文件传输时,传统方式是:

  • 调用read函数,文件数据被copy到内核缓冲区
  • read函数返回,数据从内核缓冲区copy到用户缓冲区
  • write函数调用,将文件数据从用户缓冲区copy到内核与socket相关的缓冲区
  • 数据从socket缓冲区copy到相关协议引擎

从上面可以看出来,传统readwrite进行网络文件传输的方式,在过程中经历了四次copy操作。
硬盘->内核buffer->用户buffer->socket相关缓冲区->协议引擎
而sendfile系统调用则提供了一种减少多次copy,提高文件传输性能的方法。流程如下:

  • sendfile系统效用,文件数据被copy至内核缓冲区
  • 记录数据文职和长度相关的数据保存到socket相关缓存区
  • 实际数据由DMA模块直接发送到协议引擎

tcp_nopush

sendfile为on时这里也应该设为on,数据包会累积一下再一起传输,可以提高一些传输效率。

tcp_nodelay

小的数据包不等待直接传输。默认为on。
看上去是和tcp_nopush相反的功能,但是两边都为on时nginx也可以平衡这两个功能的使用。

keepalive_timeout

HTTP连接的持续时间。设的太长会使无用的线程变的太多。这个根据自己服务器访问数量、处理速度以及网络状况方面考虑。

send_timeout

设置Nginx服务器响应客户端的超时时间,这个超时时间只针对两个客户端和服务器建立连接后,某次活动之间的时间,如果这个时间后,客户端没有任何活动,Nginx服务器将关闭连接,将其设置为10s,Nginx与客户端建立连接后,某次会话中服务器等待客户端响应超过10s,就会自动关闭。

types_hash_max_size

types_hash_max_size影响散列表的冲突率。types_hash_max_size越大,就会消耗更多的内存,但散列key的冲突率会降低,检索速度就更快。types_hash_max_size越小,消耗的内存就越小,但散列key的冲突率可能上升。

client_header_buffer_size

该指令用于设置Nginx服务器允许的客户端请求头部的缓冲区大小,默认为1KB,此指令的赋值可以根据系统分页大小来设置,分页大小可以用以下命令获取getconf PAGESIZE

client_max_body_size 8m

客户端上传的body的最大值。超过最大值就会发生413(Request Entity Too Large)错误。默认为1m,最好根据自己的情况改大一点。

gzip on

启用gzip,对响应数据进行在线实时压缩,减少数据传输量。

gzip_disable

Nginx服务器在响应这些种类的客户端请求时,不使用Gzip功能缓存应用数据,gzip_disable "msie6"对IE6浏览器的数据不进行GZIP压缩。

gzip_min_length

Gzip压缩功能对大数据的压缩效果明显,但是如果压缩很小的数据,可能出现越压缩数据量越大的情况,因此应该根据相应页面的大小,选择性开启或者关闭Gzip功能。建议将值设置为1KB。

gzip_comp_level

设置压缩程度,包括级别1到级别9,级别1表示压缩程度最低,压缩效率最高;级别9表示压缩程度最高,压缩效率最低,最费时间。

gzip_vary

用于设置在使用Gzip功能时是否发送带有“Vary:Accept-Encoding”头域的响应头部,该头域的主要功能是告诉接收方发送的数据经过了压缩处理,开启后端效果是在响应头部Accept-Encoding: gzip,对于本身不支持Gzip的压缩的客户端浏览器是有用的。

gzip_buffers;

该指令用于设置Gzip压缩文件使用存储空间的大小,
语法gzip_buffers number size;number,指定Nginx服务器需要向系统申请存储空间的个数,size,指定每个缓存空间的大小。根据配置项,Nginx服务器在对响应输出数据进行Gzip压缩时需向系统申请numbersize大小的空间用于存储压缩数据。默认情况下,numbersize的值为128,其中size的值为系统内存页一页的大小,用getconf PAGESIZE来获取

gunzip_static

开启时,如果客户端浏览器不支持Gzip处理,Nginx服务器将返回解压后的数据,如果客户端浏览器支持Gzip处理,Nginx服务器忽略该指令设置。仍然返回压缩数据。

gzip_types

Nginx服务器可以根据MIME类型选择性开启Gzip压缩功能。该指令涌来设置MIME类型。

查看原文

赞 26 收藏 37 评论 2

VetorPers 关注了用户 · 2019-03-22

煎鱼 @eddycjy

喝口热水,写写代码。
博客地址:https://eddycjy.com/

关注 1866

认证与成就

  • 获得 2 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2017-04-13
个人主页被 302 人浏览