断线重连与重登陆

背景

先设想以下几种常见的会导致移动设备断线的场景:

  • 手机4G切换到了Wi-Fi
  • 使用手机时由于移动切换了基站
  • 内网路由器的NAT映射表刷新了

诸如此类的情景,其实本质上都可以归纳为一个问题:你正在使用的设备的外网IP地址突然变了。对TCP协议而言,其标识连接的方式是通过一个包含源IP地址,源IP端口,目的IP地址,目的IP端口的4元组来实现的。如果我们在应用程序中只根据TCP的连接来区分用户,那么在出现上述情况时必然会产生的一个问题就是来自同一个用户新老连接的会产生冲突。如果放任不处理,用户就会出现明明网络是好的,但是自己却与服务器断开了连接的情况。这无疑是用户体验极差的,甚至会产生一种这样的印象:这破应用服务器怎么这么差。比较好的做法是自动帮同一个用户把老的连接关掉,数据传输移植到新的连接上,用户可能会感受到轻微的延迟/卡顿,但是不会出现直接断线的情况。这个问题Google称之为Connection Migration即连接迁移。

进一步分析

解决这个问题首先需要了解一下IP地址的改变会产生什么影响,前面提到过TCP是以IP地址+端口号为标识来区分不同连接的数据的。更深入一点分析,当准备建立一条TCP连接的时候,本质上是客户端发送一个特殊的包给服务端,然后服务端根据这个特殊的包的信息可以知道是来自xx地址xx端口的一个客户端请求建立连接,一旦接受,在服务端这里就会生成一个专门为这个客户端服务的一条连接,这之后双方就可以通过这条连接来通讯了,绝大部分使用TCP的应用都会把这条网络连接进行层层的封装来达到给上层调用进行收发数据的目的。当一方的IP地址发生改变,显而易见,这条TCP连接就失效了,服务端无法再接收到任何消息,客户端发送出去的消息也杳无音讯。不难想到如果不使用这种IP地址+端口的四元组作为连接的唯一标识不是就可以无视IP地址的变动了吗?答案确实如此,如果你使用的是TCP协议,那么你可以参考MPTCP( https://en.wikipedia.org/wiki/Multipath_TCP )的实现,不过需要注意的是MPTCP没有办法完全解决上述问题(如手机仅有4G且切换了基站),MPTCP本质上是提前建立多条平行的连接,然后利用多条连接来最大程度减小某种渠道连接失败带来的影响。但是如果你使用的是UDP协议,可操作性就很多了。由于UDP本身是没有连接概念的,大部分使用UDP协议的应用都会封装出一套类似TCP连接相关接口的,并且通过读UDP包头的一些数据或者干脆还是用源的IP地址+端口来区分来源,所以完全可以在包头添加一个自己管理的16/32/64位的ID作为连接的唯一标识,这样就完全不依赖IP地址来区分数据来源了,自然也就不会被IP地址变动所影响。

更具体的一些细节

说起来容易做起来难,真正尝试在UDP网路库里面加这个特性的时候,倒也遇到了一些问题和产生了一些思考。

  • IP变动的感知:尽管我们不再依赖IP地址+端口来区分来源了,但是我们还是需要知晓同一个connection id的IP变动这一事情的,所以对于数据包的来源IP地址和端口还是要记录一下,并且最好有显式的通知给上层。
  • 旧连接状态的迁移:使用UDP构建网络库的时候由于服务端需要自己在底层分包,所以即使IP切换了,也不会对数据的接受产生影响,在服务端的角度看上去就是收到了一个数据包,它的唯一标识connection id是一个已知的,只不过对应的IP地址改变了。那么在服务端只需要将这个数据包继续抛给上层对应的connection id的连接处理就行了。而在客户端,IP地址切换可能导致你调用的socket相关API报错(也可能没有,视你使用的底层库而定)这时候处理好报错,恢复正常发送流程也就可以了。这里需要注意一点,抽象分层做的好的话,UDP网络库里面一些有状态的层如可靠重传层,加密层等这些应该是和底层真正用来发送数据的socket层相互分开的,所以本质上IP地址的变化不会对连接状态产生太大影响。
    如果使用的是TCP协议,则需要提前记录下来两端的收发数据状态,重连建立新的连接的时候有一个重新握手的流程,让两端交换一下旧连接没有收发完的数据。
  • 连接唯一标识(ID)的管理:理论上来说为了防止碰撞最好让ID尽可能的长(如64位),但是如果实在不放心客户端自动随机生成的话,可以考虑使用服务端生成。因为这样的话就可以保证唯一,但是坏处就是建立连接的步骤和耗时会变多。对于一些并发量比较高比较看中延时的且以短连接为主网页应用,可能这样不是非常适合。但是以长连接为主的游戏应用,其实不是特别在意建立连接时多耗一些时间。
  • 预防恶意攻击:这点也是绝大部分网络库最头疼的一点,或者说在网络库里我觉得其实根本没有最好的处理办法。以最无脑的DDoS攻击为例,在你网络库还没有开始调用系统调用读取数据的时候,其实已经就被攻击了,因为人家攻击的根本就是不是你这个维度的东西。分布式的DDos完全可以在带宽,CPU资源这层对服务端进行攻击。那么再退一步说,对于一些伪装包、篡改包的攻击,还是可以进行防御的。数据包中加上签名就可以在绝大部分情况下防止中间人篡改数据。防止中间人读取数据的话也可以加上加密。对于那种强行获取到了别人的connection id,然后开始伪装成别人的发包,可以再建立连接的时候使用DH秘钥交换之类的算法,这样可以得到一个只有双方知道的秘钥,再用这个秘钥加密就可以保证中间人无法伪装成别人发包。如果客户端被完全破解了,或者整个网路库的代码被破解了,攻击者可以完全按照合法的客户端登录流程一步步发送合法的数据包给服务端这种情况,目前我也没有想到特别好的办法。。。但是其实换个角度想,这种其实对于网络层来说已经是无法辨别真伪的数据包了,只有在业务逻辑层加上限制,才能检测到是否非法(如果这个伪装的客户端没有触发任何限制呢?那就只能当做正常用户了)。

有感知&无感知的断线重连

一般来说,我们通常会把应用层和网络传输层分开,中间用接口的方式实现解耦。因此上层应用只需要看到一些send/recv/connect的接口就可以了,对于底下网络层是怎样实现数据传输的并则不需要关心。如果我们在网络层内部实现一些自动重连的机制,就可以对上层不暴露断线这件事情。当底层的断线发生时,上层应用仍然可以正常的使用下层提供的send/recv接口,只是可能会感受到微弱的延时而已(当然这取决于你重连的耗时),这种情况下由于应用层是无感知的,所以我们可以称之为无感知(对上层应用而言)的断线重连。

但是网络传输层也不是万能的,如果遇到了底层的重连无法恢复的情况,这时候就只能把断线这件事情告诉上层应用了。一般来说,这时候上层应用可以返回登录界面让用户重新登录。如果想做的用户体验好一点,重新登陆以后可以自动帮用户恢复到上次断线前的状态,诸如重连回到上次的房间,位置,进行中的游戏等。

实现一个底层的断线重连

虽然上层应用处理断线重连时可能会根据业务的不同而有不用的变化,但是在底下的网络传输层内部,需要做的事情缺十分相似,建立好新的连接,将双方未送达的数据恢复。考虑到无论是使用TCP协议还是自己定义的可靠UDP类协议,都存在断线的可能性,因此,我们可以考虑在连接之上再封装一层session层,session层可以感知到下层持有连接的断线情况(通过心跳等方式实现感知),并且在断线发生时立即重新建立连接。此外,对上层应用暴露出来的send/recv接口并不是直接与内核的socket相关api通讯,而是在session内部稍稍缓存。一来这样在session尝试重连的时候,上层应用仍然可以继续调用send/recv接口。二来为了保证数据的完整性,在session内部也可以记录收发过的数据包,这样重新建立好最底层的连接之后可以继续收发之前被截断的数据。

一个简单的session实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Session
{
private Connection conn; //真正的底层连接,可被替换
private SendRecvState state; //记录收发数据状态的类
private Task keepAliveLoop; //检测连接状态的task

public void Connect()
{
//do connect
}

public void Send(byte[] data)
{
//do send
}

public byte[] Recv()
{
//do recv
}

private bool reconnect()
{
//do reconnect
}
}

一点总结

断线重连的解决方案,说白了就是用新的连接替换已经失效的老的连接。在这个替换过程中,对原来连接的一些读写的状态需要恢复,从而保证上层应用的数据的完整性。另外在使用一些网络协议的时候,如何检测到断线也是一个很重要的问题,以TCP为例(RFC里及其难用的TCP-Keep—Alive就算了),如果用户不主动收发数据,是不知道这条连接是否还健在的,所以一个定时的心跳检测也是必须的。所以一个完整的断线重连流程总结起来就是:侦测断线–>重新连接–>恢复状态

关于QUIC的一点研究

QUIC是什么

QUIC全称Quick UDP Internet Connections,是Google于2013年推出的一种新的网络传输协议,旨在解决传统http协议中存在的一系列问题。需要注意的是QUIC从一开始针对的应用场景就很明确,就是为了减少web latency。在Google的首发演讲中对QUIC的描述是“TCP + TLS + SPDY over UDP”(现在应该换成HTTP2了),所以QUIC很看重一些web比较关心的指标(比如0-RTT的连接建立,以及一条连接多路复用)。

QUIC要做什么

以TCP协议为例子,第一版的TCP于1981年在RFC-793中公布。距今已经20年有余,现在的网络状况早已经和当年大不一样。虽然TCP协议一直在更新,一直有着新特性在加入,但是有个矛盾的问题一直没有办法解决。那就是TCP做了太多的事情在传输层,并且都是在操作系统的内部实现的。这样做操作系统上的应用开发者就无需关心底层传输实现,可以直接调用系统的API来实现网络传输,否则开发一个应用就要开发一套传输算法,会造出来无数的轮子。但是同时也有一个问题,如果想要使用更新TCP协议版本,则需要操作系统的厂商来推进更新内核的TCP协议实现,而目前看操作系统的厂商的积极性很低。同时,仅仅你个人的操作系统更新了并没有用,还需要网络中大量的网络设备一起更新,这显然是不现实的。因此,只能把一些网络传输特性实现在更上层的应用中,尽管一个APP造一个轮子显然显得有些蠢,但是在浏览器内浏览网页却是个不错的应用场景。从目前看,操作系统内核的UDP协议实现 + 应用层自定义的可靠传输算法 + 实现一些更上面应用层的标准是这类轮子的标配。当然,也包括QUIC。

QUIC扮演的角色

1
2
3
4
5
6
7
8
9
10
11
12
+++++++++++           ++++++++++++++        
| HTTP/2 | | HTTP/2 API |
| | ++++++++++++++
+++++++++++ | |
| TLS 1.2 | | QUIC |
| | | |
+++++++++++ | |
| TCP | ++++++++++++++
| | | UDP |
++++++++++++++++++++++++++++++++++++
| IP |
++++++++++++++++++++++++++++++++++++

QUIC的优势

  • Connection establishment latency:
    相比于TCP+TLS建立连接需要3-RTT以上的时间,QUIC大部分情况下建立握手都可以做到0-RTT和1-RTT。
  • Improved congestion control:目前Google版本的QUIC使用的拥塞控制算法其实就是把TCP的Cubic再实现了一遍,在一些边角如ack做了一些优化。
  • Multiplexing without head-of-line blocking:在http2.0的一个特性是多路复用(Multiplexing),即某个客户端对同一个域名的多次请求只会使用同一条tcp连接。这样可以显著的减少建立tcp连接带来的开销,但是由于在tcp中是一个数据包丢失了会导致其之后的数据包都被卡住,而http2.0中使用又是同一条连接,这样就会导致“多路”同时卡住。QUIC则是可以同时支持多个独立的stream,stream之间的数据包在实际传输的过程中是穿插的,但是每个stream的数据却一定是严格FIFO的,这样某个包丢失了只会影响自己那一条stream。其实这点主要原因还是因为建立一条tcp连接的代价太高了,尤其在使用浏览器这种情景下人们本能的是想要复用同一条连接减少开销,只不过这时候tcp的“可靠性”却成了一种负担。
  • Forward error correction:向前纠错能力,这点简单的来说就是比如你要发10个包,QUIC实际上给你发的是12个包,有2个包是由FEC算法根据前10个包的内容计算出来。这样这10个包中只要有2个以下的包丢了,接受方根据同样的FEC算法可以还原出那几个丢失的包。这本质上是一种冗余策略,虽然会显著的提高数据量但是在已知网络情况较差的时候会显著降低延迟(重传带来的延迟更大)。类似的算法在KCP和很多其余的网络库中也可以看到。
  • Connection migration:TCP的协议中标记一条连接的唯一标识是一个来源IP地址+来源端口+目标IP地址+目标端口的4元组构成的。如果移动设备一旦切换了基站或者4G切到了Wi-Fi又或者某个NAT路由器的映射表动态刷新了等行为都会直接导致TCP断线。QUIC使用的是一个64位的唯一ID来标识的,这样即使切换了网络,应用层也是无感的。不过QUIC中这个64位的唯一ID是客户端自己生成的,这里可能有人会问为什么不是服务器来生成?答案其实就在上面,是因为QUIC要做到0-RTT的握手,如果是服务器生成,那么建立连接的步骤就会多一步服务器发送生成好的唯一ID给客户端并且等待客户端的确认回包。但是这里有一个无法避免的问题,那就是connection id collision。不过好在有人算过两个64位的随机数作id,在一个100,000并发的服务器上碰撞的概率大概是3*10^-10。考虑到QUIC应用场景,似乎可以理解为什么会这样做。

QUIC的一些问题

目前QUIC协议在IETF工作组中还处于频繁修改的状态,IETF工作组也给出了一些协议实现建议,github上也有人在造一些轮子(比如这个golang版本的实现 https://github.com/lucas-clemente/quic-go )。在众多选择和没有统一标准的面前的是数不清的坑(是使用Google QUIC的实现还是IETF QUIC第三方实现?)真正能够做出稳定上线产品的还是凤毛麟角(这里有一篇关于微博的应用笔记 http://www.infoq.com/cn/news/2018/03/weibo-quic )。同时,在健康的网络状况下QUIC和TCP相比并没有优势,在弱网情况下是有的。如果坚持使用QUIC作为线上产品的网络传输解决方案的话,可以备用一套TCP的方案,在测试到一些网络封闭了UDP包这种情况时可以随时回滚。

最后,期待2019年能形成QUIC的RFC技术规范。

构建联机网络库

为什么需要一个网络库或者说网络库是干什么的

对任何一个联网游戏而言,其联机部分的业务逻辑都需要一个网络库来支持。一般而言,这个网络库需要实现以下的需求:

1
2
3
4
1.提供简单的Send/Recv/Accept/Listen接口
2.至少提供可靠的信道
3.方便做出符合游戏特性的优化
4.内存分配优化

如何实现

从传输协议选择来看,几乎绝大部分游戏在联机相关业务逻辑上使用的都是UDP协议,由于TCP协议本身的一些特点,导致其在游戏等实时性要求较高的领域上表现并不佳,相关的讨论网上有很多。但是使用UDP协议的时候必须要解决一个问题,那就是传输可靠性。由于网络本身的不稳定因素,UDP协议并不保证传输内容是否能够准确到达目的地,这就需要你设计一套算法来保证传输可靠性。

除此之外,我们在传输数据时经常会做一些压缩,加密的操作,这些也是可以直接在网络库中直接实现的。因此,简单总结来说我们需要做的是一个“UDP协议 + 可靠传输算法 + 上层额外功能”的网络库

可靠传输算法

对于网络传输过程中遇到的丢包问题,所有的解决方法归结到最后就是两个字–重传。但是何时重传,重传哪些数据就有很大的操作空间了。绝大部分的网络库实现可靠传输算法使用的都是类似于TCP协议的send-ack模型。这种模型简单来说就是在发送的时候给每个数据包打上一个递增的序列号,发送出去后不会丢弃掉这个数据包,而是放在自己的队列中。当接收端收到这个序列号的TCP数据包时,就会往发送端返回一个ack包确认自己已经收到这个序列号的包。接收端如果收到了这个ack,就可以确认这个数据包已经被对方接收到了,可以从队列中移除它了。如果经过一段时间发送方没有收到队列中数据包的对应的ack,就会重新发送这个数据包,直到收到ack。
对于这种模型而言,在编写代码时有一些小技巧。

  • 序列号循环使用,每个数据包的都会有一个序列号,并且我们希望它尽可能的小,能使用int16就尽量不要使用int32,但是当发送的时间长了,序列号超过int16上限怎么办?答案是循环使用。不过这样可能会有一个问题:接收端收到一大一小两个序列号的包,到底谁先谁后呢?其实也很简单,计算两者的差的绝对值,如果小于1/2个int16.maxValue,就可以认为较小的值先。如果大于,那么就认为是已经循环了一轮序列号了,较大的值才是先发的包。(这里认为不会出现某个包在序列号已经用了一轮的情况下才终于发送到接收方的情况)
    数据的合包,用户发送的数据是长度不一的,有的可能很大,有的可能很小。频繁的调用底层接口发送小包显得很浪费,而一次性发送某个很大的包时会被底层拆分成一个个小于mtu(最大传输单元,以太网一般为1500 byte)的数据包依次发送。而对于udp而言,只要这批次中有一个包丢了,整个数据大包就会认为全部丢失了且需要重传,这无疑是极大的浪费。所以如果我们可以把用户的数据整合一下,小包合并,大包拆分。最终以略小于mtu的大小依次发送出去,这样就会有效的提高传输效率。当然,在合包和拆包的过程中难免会有额外的流量开销,但是由于小的数据包被合并了,压缩就会有比较好的效果了,这样实际上会节省流量。

  • 重传策略,一般而言在发送完某个数据包之后,会启动一个定时器,当定时器时间到了以后如果还没有收到ack,就会触发定时器重新发送数据包。但是,当数据包数量上去了以后,启动如此多的定时器显然是一笔巨大开销。在TCP中有个叫做滑动窗口的结构,可以较好的解决这个问题。我们简单的把发送出去的数据包全部缓存在一个优先重传队列中并记录时间戳。然后单独启动一个定时器定期检测这个队列中数据包是否收到ack了,检测周期理论上应该是2倍的rtt值,但是可以适当放宽到3~4倍的rtt值。需要注意的是这个队列应设置上限,如果发送数据包完后发现这个队列满了,就放到另一个更大的缓冲区域内,这个缓冲区没有上限,但是不会定期检查ack情况,只有优先重传队列不满的情况下才会从这个缓冲区中取包塞进去。如果频繁出现优先重传队列满了的情况,可以考虑放大队列的长度。关于ack包,一般而言ack是不重传的,不过可以做很多优化,比如一个ack包中冗余多个确认的序列号。这样可以减少因为ack包丢包导致产生的重传。

  • 加密,比较常见的做法是先使用diffie-hellman秘钥交换算法得到一把双方都有的公钥,然后再基于这个公钥使用其余的加密算法,rc4/sha128/256等。当然,为了保证数据的完整性,我们还应该在数据包中加上签名,比如加上一个md5签名在包头。

设计适合游戏特性的接口

绝大部分游戏都有帧这一概念,因为绝大部分游戏的主逻辑都是在一个死循环内定期update。而往往网络层的收发包,都会在update里面执行。所以很明显我们必须提供给游戏用的是非阻塞的接口,因为如果阻塞了游戏的主循环就直接卡住了。
前面提到的合包策略中有一点其实也是很适合游戏的,那就是合包算法需要一个合适的时间把缓冲区中的包全部发送出去,而游戏定期update的这个特点正好满足。每次update的最后flush一下缓冲区中的数据,正好避免缓冲区因为等待太久导致数据没有办法及时送达。
还有就是绝大部分游戏的连接需要能够检测到自己什么时候与服务器断开了连接,所以可以在传输层做一个类似keep-alive的功能,这样应用层可以随时知道自己的状态。

一些值得借鉴的其余网络库

KCP

kcp在国内算是比较有名的开源可靠网络库,由于其kcp是一个纯算法层面的可靠传输层实现,故很容易被port到别的语言上(目前已经有C#,Golang,),同时网上也有比较多的分析这里就不赘述了。

GameNetworking Sockets

GameNetworking Sockets是valve在今年开源的自家的使用C++写的网络库,也是基于UDP协议。被广泛应用于v社自家的项目中,如CS:GO,Dota2等。根据github上的描述,提供了类似于TCP的面向连接的API,但是又是基于消息的数据传输方式(而不是流),同时支持可靠不可靠的信道,内部自带合包/拆包,在可靠性上面也是参考了巨佬Glenn Fiedler的文章以及Google QUIC实现的一套基于send-ack的算法并且也自带了网络模拟的工具链(已经是开源网络库的标配)。但是由于其本身是从valve庞大的祖传代码库中抽出来的一部,故代码中会有很多奇怪的命名(比如无数个steamxxx,steamworkxxxx),并且负责开源的员工似乎只会C++,并没有提供比较好的C接口供别人封装成dll来让别的语言调用(参见其项目中排名第一但是至今还没有close的关于C#的wrapper的issue)。所以除非你的项目是C/C++的并且没有时间自己开发一套网络库,否则都不建议使用该库。

Netcode.IO

Netcode.IO是一个开源的基于UDP的面向连接的网络库。原版是游戏网络传输方面的巨佬Glenn Fiedler用C写的,非常干净利索,现在已经被port到C#,Golang,Rust,并且有专用的适合Unity,UE4的版本。这里我以.net版本的实现为例进行研究。
.net版本的实现结构大概分2层,Public层和Core层。

  • Public层:由于是为了游戏而专门设计的,所以在这层为上层应用封装了Client和Server两个类。

    • Client:对外暴露了简单的Connect/Send接口,内部主要靠Tick(即内部有一个循环定时发送自己缓冲区的数据,读取远端发送过来的数据并处理)来驱动。提供了个一些重要事件的回调(onStateChanged/onMessageReceived等)内部实现了加密,keep-alive
    • Server:提供了ClientConnect/DisConnect,MessageReceive之类的回调,在内部定义了一种RemoteClient类来表示连接上来的客户端便于管理。同时在构造Server时就需要指定能容纳的最大的Client数目,比较适合做那种有人数上限的房间制的游戏,内部也是靠Tick驱动。
  • Core层:定义内部用的网络包格式,也是比较常见的前若干个字节分配个PacketHead,然后PacketHead里面会有一些描述信息,重复包的检测也依赖于包头的数据。
    加密也是在这里实现的(加密的实现较low,客户端和服务器共用一个私钥,然后利用这把私钥对第一个Connect包加密,客户端在发送这个Connect包时会为其添加两个key,一个用来给发出去的包加密,一个用来给收到的包解密,客户端和服务器共用这两个key,由于非法的Connect包会被直接忽视掉,由此达到其声称的抗DDos效果…)

  • 一些工具类:
    自己实现了诸如LockFreeQueue,BufferPool,DateTimeExChange,KeyGenerate之类的工具类,造了一些轮子,主要是因为作者写的时候按照的标准是 .net 3.5,并且他和我一样希望做到pure managed implementation and use zero native DLLs or wrappers

底层传输基本是调用的.net提供的socket API(自己稍微封装了一下原生的API,定义了一个ISocketContext接口,这样方便做测试)。

总的来说.net版本的实现中规中矩,自定义了一个ISocketContext接口方便之后写一些模拟的Socket层来模拟丢包之类的测试算是一个亮点,并且代码也比较简洁清爽(强烈吐槽C版本动辄8000行+的单个大文件)。

Reliable.IO

上面的Netcode.IO并不能保证数据的可靠性,一般来说需要在其之上实现一套重传算法来保证消息的可靠性。上面C#版本的作者基于原版的Reliable.IO也实现了一个pure managed C# socket agnostic reliability layer。ReliableNetcode里面定义了3种信道:reliable、unreliable、unreliableOrderd。其中unreliableOrderd信道非常适合做游戏中诸如状态,位置等数据的传输,因为很多时候这些属性只需要最新的数据就行了。

其中reliable信道的实现是传统的send-ack方式,主要依赖两个队列,一个叫做sendBuffer,一个叫做messageQueue。上层应用调用send接口后并不会立即将消息发出,而是尝试往sendBuffer里面放,如果满了则放到messageQueue中,并且在存放数据的时候都会打上一个唯一的序列号来标记这个数据包。然后内部会定期的调用update函数,在update内部会尝试将sendBuffer中缓存的数据包重组成一个个指定大小的包(一般为mtu左右)然后再发出去。并且实际上发送时调用的是外部传递进来的回调函数,这样就比较好的达到了重传算法和底下传输层实现的解耦。接收方接收到了这个ack包之后会根据数据包中包含的唯一的序列号发送一个ack给发送方,发送方根据ack中包含的序列号从sendBuffer中移除相应数据包并更新相关数据,中间丢失的包或者没有收到ack的包会被重传直到收到ack。