网络通信面试攻略
网络通信方向主要考察
- 七层架构的基本知识
- TCP、UDP
- HTTP
- RPC
- 真实场景设计
-
简: 30表示的是网络号(network number)是30位,剩下2位中11是广播(broadcast)地址,00是multicast地址,只有01和10可以作为host address。
详: 210.27.48.21/30代表的子网的网络号是30位,即网络号是210.27.48.21 & 255.255.255.251=210.27.48.20,此子网的地址空间是2位,即可以有4个地址:210.27.48.20, 210.27.48.21, 210.27.48.22, 210.27.48.23。第一个地址的主机号(host number/id)是0,而主机号0代表的是multicast地址。最后一个地址的最后两位是11,主机号每一位都为1代表的是广播(broadcast)地址。所以只有中间两个地址可以给host使用。其实那个问题本身不准确,广播或multicast地止也是可以使用的地址,所以回答4也应该正确,当然问的人也可能是想要你回答2。我个人觉得最好的回答是一个广播地址,一个multicast地址,2个unicast地址。
-
路由表是用来决定如何将包从一个子网传送到另一个子网的,换局话说就是用来决定从一个网卡接收到的包应该送的哪一张网卡上的。在Linux上可以用“route add default gw <默认路由器IP>”来配置一条默认路由。
详: 路由表是用来决定如何将包从一个子网传送到另一个子网的,换局话说就是用来决定从一个网卡接收到的包应该送的哪一张网卡上的。路由表的每一行至少有目标网络号、netmask、到这个子网应该使用的网卡。当路由器从一个网卡接收到一个包时,它扫描路由表的每一行,用里面的netmask和包里的目标IP地址做并逻辑运算(&)找出目标网络号,如果此网络号和这一行里的网络号相同就将这条路由保留下来做为备用路由,如果已经有备用路由了就在这两条路由里将网络号最长的留下来,另一条丢掉,如此接着扫描下一行直到结束。如果扫描结束任没有找到任何路由,就用默认路由。确定路由后,直接将包送到对应的网卡上去。在具体的实现中,路由表可能包含更多的信息为选路由算法的细节所用。题外话:路由算法其实效率很差,而且不scalable,解决办法是使用IP交换机,比如MPLS。 在Linux上可以用“route add default gw <默认路由器IP>”来配置一条默认路由。
- 答:测试这两台机器是否连通:从一台机器ping另一台机器,如果ping不通,用traceroute可以确定是哪个路由器不能连通,然后再找问题是在交换设备/hup/cable等。
-
答案一:
-
进程:子进程是父进程的复制品。子进程获得父进程数据空间、堆和栈的复制品。
-
线程:相对与进程而言,线程是一个更加接近与执行体的概念,它可以与同进程的其他线程共享数据,但拥有自己的栈空间,拥有独立的执行序列。
两者都可以提高程序的并发度,提高程序运行效率和响应时间。
线程和进程在使用上各有优缺点:线程执行开销小,但不利于资源管理和保护;而进程正相反。同时,线程适合于在SMP机器上运行,而进程则可以跨机器迁移。
答案二:
根本区别就一点:用多进程每个进程有自己的地址空间(address space),线程则共享地址空间。所有其它区别都是由此而来的:
- 速度:线程产生的速度快,线程间的通讯快、切换快等,因为他们在同一个地址空间内。
- 资源利用率:线程的资源利用率比较好也是因为他们在同一个地址空间内。
- 同步问题:线程使用公共变量/内存时需要使用同步机制还是因为他们在同一个地址空间内。
-
-
存储: 根据RIP协议报文(包含目的网络,距离以及下一跳),若目的网络在原路由表中不存在,则直接加入路由表中;若存在,且距离比原路由表比较小,则更新距离,否则忽略。
查找: 路由表中有目的地址网络,子网掩码,下一跳这些数据; 先从数据包中获取目的地址,让该地址与路由表中的子网掩码逐个进行与操作,若结果和子网掩码对应的目的地址相匹配,则从下一跳指向的端口进行转发,否则查找下一个路由表项;
-
-
主机通过ISP接入了互联网,那么ISP就会分配一个DNS服务器;
-
主机向ISP DNS发起查询www.baidu.com请求;
-
ISP DNS收拿到请求后,先检查一下自己的缓存中有没有这个地址,有的话就直接返回,如果缓存中没有的话,ISP DNS会从配置文件里面读取13个根域名服务器的地址。并向其中一台发起请求。
-
根服务器拿到这个请求后,知道他是com.这个顶级域名下的,所以就会返回com域中的NS记录;
-
ISP DNS向其中一台再次发起请求,com域的服务器发现你这请求是baidu.com这个域的,一查发现了这个域的NS,那我就返回给你,你再去查;
-
ISP DNS不厌其烦的再次向baidu.com这个域的权威服务器发起请求,baidu.com收到之后,查了下有www的这台主机,就把这个IP返回给你了;
-
然后ISPDNS拿到了之后,将其返回给了客户端,并且把这个保存在高速缓存中。
-
-
-
IPv6具有更大的地址空间。IPv4中规定IP地址长度为32,最大地址个数为232;而IPv6中IP地址的长度为128,即最大地址个数为2128。与32位地址空间相比,其地址空间增加了2128-232个。
-
IPv6使用更小的路由表。IPv6的地址分配一开始就遵循聚类(Aggregation)的原则,这使得路由器能在路由表中用一条记录(Entry)表示一片子网,大大减小了路由器中路由表的长度,提高了路由器转发数据包的速度。
-
IPv6增加了增强的组播(Multicast)支持以及对流的控制(Flow Control),这使得网络上的多媒体应用有了长足发展的机会,为服务质量(QoS,Quality of Service)控制提供了良好的网络平台。
-
IPv6加入了对自动配置(Auto Configuration)的支持。这是对DHCP协议的改进和扩展,使得网络(尤其是局域网)的管理更加方便和快捷。
-
IPv6具有更高的安全性。在使用IPv6网络中用户可以对网络层的数据进行加密并对IP报文进行校验,在IPV6中的加密与鉴别选项提供了分组的保密性与完整性。极大的增强了网络的安全性。
-
允许扩充。如果新的技术或应用需要时,IPV6允许协议进行扩充。
-
更好的头部格式。IPV6使用新的头部格式,其选项与基本头部分开,如果需要,可将选项插入到基本头部与上层数据之间。这就简化和加速了路由选择过程,因为大多数的选项不需要由路由选择。
-
新的选项。IPV6有一些新的选项来实现附加的功能。
-
-
TCP:传输控制协议,提供的是面向连接、可靠的字节流服务。当客户和服务器彼此交换数据前,必须先在双方之间建立一个TCP连接,之后才能传输数据。TCP提供超时重发,丢弃重复数据,检验数据,流量控制等功能,保证数据能从一端传到另一端。
UDP:用户数据报协议,是一个简单的面向数据报的运输层协议。UDP不提供可靠性,它只是把应用程序传给IP层的数据报发送出去,但是并不能保证它们能到达目的地。由于UDP在传输数据报前不用在客户和服务器之间建立一个连接,且没有超时重发等机制,故而传输速度很快
-
拥塞控制 --- 网络拥塞现象是指到达通信子网中某一部分的分组数量过多,使得该部分网络来不及处理,以致引起这部分乃至整个网络性能下降的现象,严重时甚至会导致网络通信业务陷入停顿,即出现死锁现象。拥塞控制是处理网络拥塞现象的一种机制。
流量控制 --- 数据的传送与接收过程当中很可能出现收方来不及接收的情况,这时就需要对发方进行控制,以免数据丢失。流量控制用于防止在端口阻塞的情况下丢帧,这种方法是当发送或接收缓冲区开始溢出时通过将阻塞信号发送回源地址实现的。流量控制可以有效的防止由于网络中瞬间的大量数据对网络带来的冲击,保证用户网络高效而稳定的运行。
-
TCP提供的可靠数据传输服务,是依靠接收端TCP软件按序号对收到的数据分组进行逐一确认实现的。
三次握手协议指的是在发送数据的准备阶段,服务器端和客户端之间需要进行三次交互:
第一次握手:客户端发送syn包(syn=j)到服务器,并进入SYN_SEND状态,等待服务器确认;
第二次握手:服务器收到syn包,必须确认客户的syn(ack=j+1),同时自己也发送一个SYN包(syn=k),即SYN+ACK包,此时服务器进入SYN_RECV状态;
第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=k+1),此包发送完毕,客户端和服务器进入ESTABLISHED状态,完成三次握手。
连接建立后,客户端和服务器就可以开始进行数据传输了。
为什么客户端需要再发送一次确认?
client发出的第一个连接请求报文段并没有丢失,而是在某个网络结点长时间的滞留了,以致延误到连接释放以后的某个时间才到达server。本来这是一个早已失效的报文段。但server收到此失效的连接请求报文段后,就误认为是client再次发出的一个新的连接请求。于是就向client发出确认报文段,同意建立连接。假设不采用三次握手,那么只要server发出确认,新的连接就建立了。由于现在client并没有发出建立连接的请求,因此不会理睬server的确认,也不会向server发送数据。但server却以为新的运输连接已经建立,并一直等待client发来数据。这样,server的很多资源就白白浪费掉了。采用三次握手的办法可以防止上述现象发生。例如刚才那种情况,client不会向server的确认发出确认。server由于收不到确认,就知道client并没有要求建立连接。
四次挥手:
(1)客户端A发送一个FIN,用来关闭客户A到服务器B的数据传送。
(2)服务器B收到这个FIN,它发回一个ACK,确认序号为收到的序号加1。和SYN一样,一个FIN将占用一个序号。
(3)服务器B关闭与客户端A的连接,发送一个FIN给客户端A。
(4)客户端A发回ACK报文确认,并将确认序号设置为收到序号加1。
为什么TCP连接是三次,挥手确是四次?
在TCP连接中,服务器端的SYN和ACK向客户端发送是一次性发送的,而在断开连接的过程中,B端向A端发送的ACK和FIN是是分两次发送的。因为在B端接收到A端的FIN后,B端可能还有数据要传输,所以先发送ACK,等B端处理完自己的事情后就可以发送FIN断开连接了。
为什么在第四次挥手后会有2个MSL的延时?
MSL是Maximum Segment Lifetime,最大报文段生存时间,2个MSL是报文段发送和接收的最长时间。 假定网络不可靠,那么第四次发送的ACK可能丢失,即B端无法收到这个ACK,如果B端收不到这个确认ACK,B端会定时向A端重复发送FIN,直到B端收到A的确认ACK。所以这个2MSL就是用来处理这个可能丢失的ACK的。而且能确保下一个新的连接中没有这个旧连接的报文。
-
- seq:占 4 字节,序号范围[0,2^32-1],序号增加到 2^32-1 后,下个序号又回到 0。TCP 是面向字节流的,通过 TCP 传送的字节流中的每个字节都按顺序编号,而报头中的序号字段值则指的是本报文段数据的第一个字节的序号。
- ack:占 4 字节,期望收到对方下个报文段的第一个数据字节的序号。
- 23: B接收到A发来的seq=40000,ack=70000,size=1518的数据包
- 24: 于是B向A也发一个数据包,告诉A,你的上个包我收到了。A的seq就以它收到的数据包的ack填充,ack是它收到的数据包的seq加上数据包的大小(不包括:以太网协议头=14字节,IP头=20字节,TCP头=20字节),以证实B发过来的数据全收到了。
- 25: A在收到B发过来的ack为41460的数据包时,一看到41460,正好是它的上个数据包的seq加上包的大小,就明白,上次发送的数据包已安全到达。于是它再发一个数据包给B。这个正在发送的数据包的seq也以它收到的数据包的ack填充,ack 就以它收到的数据包的seq(70000)加上包的size(54)填充,即ack=70000+54-54(全是头长,没数据项)。
-
- 合理分片:将数据分割成最适合tcp发送的数据块
- 超时重传:tcp发送端发送数据后会启动一个计时器,当计时器超过某个时间没有收到接收端的确认就,重新发送数据。
- 确认:报文当中带有对应的确认序列号,告诉发送方,接收到了哪些数据,下一次的数据从哪里发。
- 校验:将发送的数据段都当做一个16位的整数,将这些整数加起来作为校验和。接收方独立计算的结果如果和发送方的不一致,会丢弃报文段,不给出响应,发送端会超时重传
- 失序重排:tcp是用ip数据报传送数据的,ip数据报到达会失序,因此数据到达也会失序。Tcp会对失序的数据重新排列。
- 重复丢弃:对收到的重复数据丢弃掉。
- 流量控制:报头信息当中,有一个16位字段的窗口大小,即接收端接收数据缓冲区的剩余大小。接收端会在确认应答发送ACK报文时,将自己的即时窗口大小填入,一起发送过去。发送方根据这个值改变自己的发送速度。如果接收到窗口大小的值为0,那么发送方将停止发送数据。
- 拥塞控制:一般来说,TCP拥塞控制默认认为网络丢包是由于网络拥塞导致的,所以一般的TCP拥塞控制算法以丢包为网络进入拥塞状态的信号。又涉及慢启动、拥塞避免、拥塞时处理、快速恢复等不同做法。
-
select,poll,epoll都是IO多路复用的机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
它们都是同步I/O,都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。
以下是
select
函数:int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout) 返回值:就绪描述符的数目,超时返回0,出错返回-1
- 第一个参数maxfdp1指定待测试的描述符个数。
- 中间的三个参数readset、writeset和exceptset指定我们要让内核测试读、写和异常条件的描述符。(struct fd_set可以理解为描述符集合)
select的几大缺点:
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
- select支持的文件描述符数量太小了,由FD_SETSIZE设置,默认是1024。
poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,其他的都差不多。
epoll的不同点其实就是针对poll和select的缺点。
select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create, epoll_ctl和epoll_wait。
- epoll_create是创建一个epoll句柄;
- epoll_ctl是注册要监听的事件类型;
- epoll_wait则是等待事件的产生。
这样,
- 每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。
- epoll只在epoll_ctl时挂一遍描述符,并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd。
- 所支持的FD上限是最大可以打开文件的数目。
- 无论是select还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的,而select则做了不必要的拷贝
-
- LT:水平触发,效率会低于ET触发,尤其在大并发,大流量的情况下。但是LT对代码编写要求比较低,不容易出现问题。LT模式服务编写上的表现是:只要有数据没有被获取,内核就不断通知你,因此不用担心事件丢失的情况。
- ET:边缘触发,效率非常高,在并发,大流量的情况下,会比LT少很多epoll的系统调用,因此效率高。但是对编程要求高,需要细致的处理每个请求,否则容易发生丢失事件的情况。
- 最通常的方法最有效的是加定时器;也可以采用非阻塞模式。 或者考虑采用异步传输机制,同步传输与异步传输的主要区别在于同步传输中,如果调用recvfrom后会一致阻塞运行,从而导致调用线程暂停运行;异步传输机制则不然,会立即返回。
-
如果server用select/epoll/iocp,客户重启,进程结束时发送FIN,server可读,读取长度为0 。
如果客户端突然掉线,不会发送FIN,服务器需要主动发送数据才能知道。
-
TTL是Time To Live,一般是hup count,每经过一个路由就会被减去一,如果它变成0,包会被丢掉。它的主要目的是防止包在有回路的网络上死转,浪费网络资源。ping和traceroute用到它。
详: TTL是Time To Live,目前是hup count,当包每经过一个路由器它就会被减去一,如果它变成0,路由器就会把包丢掉。IP网络往往带有环(loop),比如子网A和子网B有两个路由器相连,它就是一个loop。TTL的主要目的是防止包在有回路的网络上死转,因为包的TTL最终后变成0而使得此包从网上消失(此时往往路由器会送一个ICMP包回来,traceroute就是根据这个做的)。ping会送包出去,所以里面有它,但是ping不一定非要不可它。traceroute则是完全因为有它才能成的。ifconfig是用来配置网卡的,netstat -rn 是用来列路由表的,所以都用不着它
-
对于TCP连接:
-
服务器端
-
创建套接字create;
-
绑定端口号bind;
-
监听连接listen;
-
接受连接请求accept,并返回新的套接字;
-
用新返回的套接字recv/send;
-
关闭套接字。
-
-
客户端
-
创建套接字create
-
发起建立连接请求connect;
-
发送/接收数据send/recv;
-
关闭套接字。
-
TCP总结:
Server端: socket -- bind -- listen -- accept -- recv/send -- close
Client端:socket -- conncet -- send/recv -- close.
对于UDP连接:
-
服务器端:
-
创建套接字create;
-
绑定端口号bind;
-
接收/发送消息recvfrom/sendto;
-
关闭套接字。
-
-
客户端:
-
创建套接字create;
-
发送/接收消息sendto/recvfrom;
-
关闭套接字.
-
UDP总结:
Server端:socket -- bind -- recvfrom/sendto -- close
Client端:socket -- sendto/recvfrom -- close.
-
-
-
滑动窗口机制,确立收发的边界,能让发送方知道已经发送了多少(已确认)、尚未确认的字节数、尚待发送的字节数;让接收方知道(已经确认收到的字节数)。
-
选择重传,用于对传输出错的序列进行重传。
-
-
DDOS原理 如果A与B两个进程通信,如果仅是两次连接。可能出现的一种情况就是:A发送完请报文以后,由于网络情况不好,出现了网络拥塞,即B延时很长时间后收到报文,即此时A将此报文认定为失效的报文。B收到报文后,会向A发起连接。此时两次握手完毕,B会认为已经建立了连接可以通信,B会一直等到A发送的连接请求,而A对失效的报文回复自然不会处理。依次会陷入B忙等的僵局,造成资源的浪费。
-
使用定时器(适合有数据流动的情况);
使用socket选项SO_KEEPALIVE(适合没有数据流动的情况)
- client 的 connect 引起3次握手 server 在socket, bind, listen后,阻塞在accept,三次握手完成后,accept返回一个fd,因此accept发生在三次握手之后。
-
-
TCP本身是面向连接的协议,S和C之间要使用TCP,必须先建立连接,数据就在该连接上流动,可以是双向的,没有边界。所以叫数据流 ,占系统资源多
-
UDP不是面向连接的,不存在建立连接,释放连接,每个数据包都是独立的包,有边界,一般不会合并。
-
TCP保证数据正确性,UDP可能丢包,TCP保证数据顺序,UDP不保证
-
-
-
接收缓冲区有数据,一定可读
-
对方正常关闭socket,也是可读
-
对于侦听socket,有新连接到达也可读
-
socket有错误发生,且pending
-
-
拥塞控制是把整体看成一个处理对象的,流量控制是对单个的节点。
节点计算机感知拥塞可以通过带宽、时延、吞吐量等方式判断
-
通过观察网络的吞吐量与网络负载间的关系
如果随着网络负载的增加,网络的吞吐量明显小于正常的吞吐量,那么网络就进入例如轻度拥塞的状况。
如果网络得吞吐量随着网络负载的增大反而下降,那么网络就可能进入拥塞状态。
如果网络的负载继续增大,而网络的吞吐量下降到零,网络就可能进入了死锁状态
-
BBR认为当吞吐量随着负载的增大下降时,说明已经存满了缓存,这是不应该的。因此BBR的检测采取了另一种方式:因为最优带宽和延迟无法同时测量(btlBw的测量会造成存在网络缓存增加RTT,而RTprop的测量要求网络缓存为空),所以分别估计带宽(btlBw)和延迟(RTprop),最后计算出cwnd。同时增加变量pacing rate(btlBw * 增益系数),用于控制发送端的发送速率,以解决发送端突发造成的网络排队问题。
-
- 答:select 返回0代表超时。select出错返回-1。 select到读事件,但是读到的数据量为0,说明对方已经关闭了socket的读端。本端关闭读即可。 当select出错时,会将接口置为可读又可写。这时就要通过判断select的返回值为-1来区分。
- 答:在UDP之上自定义一个通讯协议:每个数据包中包含一个唯一标识,可以用编号也可以用时间;接收端收到数据包后回发一个数据包,包含收到的这个唯一标识;发送端在预定时间内没有收到回执则自动重发,重发一定次数后仍未收到回执则认为发送失败。
-
滑动窗口协议是用来改善吞吐量的一种技术,即容许发送方在接收任何应答之前传送附加的包。接收方告诉发送方在某一时刻能送多少包(称窗口尺寸)。
TCP中采用滑动窗口来进行传输控制,滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据。发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据。当滑动窗口为0时,发送方一般不能再发送数据报,但有两种情况除外,一种情况是可以发送紧急数据,例如,允许用户终止在远端机上的运行进程。另一种情况是发送方可以发送一个1字节的数据报来通知接收方重新声明它希望接收的下一字节及发送方的滑动窗口大小。
滑动窗口机制为端到端设备间的数据传输提供了可靠的流量控制机制。然而,它只能在源端设备和目的端设备起作用,当网络中间设备(例如路由器等)发生拥塞时,滑动窗口机制将不起作用。
-
在网络编程中,connect函数通常用于客户端建立tcp连接。tcp连接的建立实际上就是三次“握手”的过程。
udp协议提供的是面向非连接的服务,通信双方不需要建立连接。一方只需要建立好套接字,并显式或由系统绑定地址和端口号后就可以发送/接收数据包。和tcp不同的是,使用udp协议的数据报套接字(SOCK_DGRAM)并不限定唯一的通信方。既可以发送(sendto)数据给任意的接受方,也可以从任意的发送方接收(recvfrom)数据。
如果希望为一个数据报套接字指定唯一的通信方时,可以使用connect来实现这一功能。需要注意的是,在数据报套接字上使用connect并不是建立连接,不存在“握手”的过程。仅仅是为这个套接字指定一个通信方,一旦指定了对方的地址,就可以通过send/recv来发送/接收数据了。而且可以在这个数据报套接字上多次调用connect函数来指定不同的通信方。 在udp中使用connect的方法和tcp中类似,只需在创建套接字时,把套接字的类型由SOCK_STREAM换成SOCK_DGRAM即可。
-
套接字准备好读的条件:
-
该套接字接受缓冲区中的数据字节数大于等于套接字接受缓冲区低水位标记的当前大小。对这样的套接字执行读操作不会阻塞并将返回一个大于0的值(也就是返回准备好读入的数据)。我们可以使用SO_RCVLOWAT套接字选项设置该套接字的低水位标记。对于tcp和udp套接字而言,其默认值为1。
-
该套接字的读半部关闭(也就是接受了FIN的tcp连接)。对这样的套接字的读操作将不阻塞并返回0.(也就是返回EOF)
-
该套接字是一个监听套接字且已完成的连接数不为0。对这样的套接字的accept通常不阻塞。
-
其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件。这样待处理错误(pending error)也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。
套接字准备好写的条件:
-
该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的当前大小,并且或者该套接字已连接,或者该套接字不需要连接(如udp套接字)。这意味着如果我们把这样的套接字设置成非阻塞,写操作将不阻塞并返回一个正值(例如由传输层接受的字节数)。我们可以使用SO_SNDLOWAT套接字选项来设置该套接字的低水位标记。对于tcp和udp而言,其默认值通常为2048。
-
该连接的写半部关闭。对这样的套接字的写操作将产生SIGPIPE信号。
-
使用非阻塞connect的套接字已建立连接,或者connect已经以失败告终。
-
其上有一个套接字错误待处理。对这样的套接字的写操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件。这些待处理的错误也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。
-
- TCP将所要传送的整个报文(这可能包括许多个报文段)看成是一个个字节组成的数据流,并使每一个字节对应于一个序号。TCP的确认是对接收到的数据的最高序号(即收到的数据流中的最后一个序号)表示确认。但接收端返回的确认序号是已收到的数据的最高序号加1。也就是说,确认序号表示接收端期望下次收到的数据中的第一个数据字节的序号。
-
TCP有三种基本机制来控制报文段的发送。
第一种机制是TCP维持一个变量,它等于最大报文段长度MSS,只要发送缓存从发送进程得到的数据达到MSS字节时,就组装成—个TCP报文段,然后发送出去。
第二种机制是发送端的应用进程指明要求发送报文段,即TCP支持的推送(push)操作。
第三种机制是发送端的一个计时器时间到了,这时就把当前已有的缓存数据装入报文段发送出去。
-
接受方告诉发送方自己的接受窗口大小,发送方调整自己的发送窗口大小。窗口以字节为单位。
在TCP的实现中广泛使用Nagle算法:Nagle算法就是为了尽可能发送大块数据,避免网络中充斥着许多小数据块。若发送端应用进程将欲发送的数据逐个字节地达到发送端的TCP缓存,则发送端就将第一个字符(—个字符的长度是一个字节)发送出去,将后面到达的字符将都缓存起来。当接收端收到对第一个字符的确认后,再将缓存中的所有字符装成一个报文段发送出去,同时继续对随后到达的字符进行缓存。只有在收到对前一个报文段的确认时才继续发送下一个报文段。当字符到达较快而网络速率较慢时,用这样的方法可明显的减少所用的网络带宽,算法还规定,当到达的字符已达到窗口大小的一半或己达到报文段的最大长度时,就立即发送一个报文段。
-
- 如果包长度达到MSS,则允许发送;
- 如果该包含有FIN,则允许发送;
- 设置了TCP_NODELAY选项,则允许发送;
- 未设置TCP_CORK选项时,若所有发出的小数据包(长度小于MSS)均被确认,则允许发送;
- 上述条件都未满足,但发生了超时(一般为200ms),则立即发送。
-
有时也会使TCP的性能变坏。
设想这种情况:接收端的缓存已满,而交互的应用进程一次只从缓存中读取一个字符(这样就在缓存产生1个字节的空位,然后向发送端发送确认,并将窗口设置为1个字节(但发送的数据报是40字节长)。接着,发送端又传来1个字符(但发来的IP数据报是41字节长。接收端发回确认,仍然将窗口设置为一个字节。这样进行下去,网络的效率将会很低。
要解决这个问题,可让接收端等待一段时间,使得或者缓存已能有足够的空间容纳—个最长的报文段,或者缓存已有一半的中间处于空的状态。只要出现这两种情况之一,就发出确认报文,并向发送端通知当前的窗口大小。此外,发送端也不要发送太小的报文段,而是将数掘积累成足够大的报文段,或达到接收端缓存的空间的—半大小。
上述两种方法(nagle和糊涂窗口)可配合使用。使得在发送端不发送很小的报文段的同时,接收端也不要在缓存刚刚有了一点小的空位置就急忙将一个很小的窗口大小通知给发送端。
-
若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就要变坏。这种情况叫做拥塞。所谓拥塞控制就是防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载。拥塞控制是一个全局性过程,设计所有主机,所有的路由器。而流量控制是个端到端的问题。
四种拥塞控制的算法:慢开始、拥塞避免、快重传、快恢复
慢开始和拥塞避免(以下cwnd的大小单位都是报文段)
发送方维持一个拥塞窗口cwnd, 发送方让发送窗口 等于 拥塞窗口和 接收方 接收窗口的最小值。
发送方控制拥塞窗口的原则是:只要没有出现拥塞,拥塞窗口就再增大一些,以便把更多的分组发送出去。但只要网络出现拥塞,拥塞窗口就减小一些,以减少注入到网络中的分组数。
慢开始 : 开始发送数据时,先探测一下,有小到大逐渐增大发送窗口。cwnd = 1, 然后每经过一个传输轮次就翻倍
拥塞避免 : 让cwnd缓慢增大, 每经过一个传输轮次就+1
慢开始门限ssthresh : 只要发送方判断网络出现拥塞(根据就是没有按时收到确认),就要把慢开始门限ssthresh设置为出现拥塞时的发送方窗口值的一般,然后把cwnd重新设定为1
cwnd < ssthresh, 使用慢开始算法
cwnd > ssthresh, 使用拥塞避免算法
cwnd = ssthresh, 随意
快重传和快恢复
快重传 : 接收方及时发送确认, 而发送方只要一连收到三个重复确认, 马上重传而不等待重传计时器。(需要明确的是确认指的是确认收到的有序的最大分节序号)由于尽早重传未被确认的报文段,整的网络的吞吐量提高20%
快恢复 : 当发送方一连收到三个重复确认时, ssthresh减半, cwnd设为ssthresh,然后执行拥塞避免算法。
-
这有两个理由:
- 保证A发送的最后一个ACK报文能够到达B。在Client发送出最后的ACK回复,但该ACK可能丢失。Server如果没有收到ACK,将不断重复发送FIN+ACK片段。所以Client不能立即关闭,它必须确认Server接收到了该ACK。Client会在发送出ACK之后进入到TIME_WAIT状态。Client会设置一个计时器,等待2MSL的时间。如果在该时间内再次收到FIN,那么Client会重发ACK并再次等待2MSL。如果Client不等待一段时间,则有可能会对Server发来的FIN+ACK报文回以RST导致Server无法进入CLOSED状态。
- 防止旧连接请求报文影响新的连接。MSL指一个片段在网络中最大的存活时间,2MSL就是一个发送和一个回复所需的最大时间。Client在发送完ACK后,再经过2MSL,就可以使本连接持续时间内所产生的所有报文段从网络中消失。
更加接地气的解释 :
第一次挥手 : A告诉B, 我没数据发了, 准备关闭连接了, 你要发送数据吗第二次挥手 : B发送最后的数据第三次挥手 : B告诉A, 我也要关闭连接了第四次挥手 : A告诉B你可以关闭了, 我这边也关闭了
-
- 重传计时器:
为了控制丢失的报文段或丢弃的报文段,也就是对报文段确认的等待时间。当TCP发送报文段时,就创建这个特定报文段的重传计时器,可能发生两种情况:若在计时器超时之前收到对报文段的确认,则撤销计时器;若在收到对特定报文段的确认之前计时器超时,则重传该报文,并把计时器复位; 重传时间=2RTT;
RTT的值应该动态计算。常用的公式是:
RTT=previous RTTi + (1-i)*current RTT
。i的值通常取90%,即新的RTT是以前的RTT值的90%加上当前RTT值的10%.Karn算法:对重传报文,在计算新的RTT时,不考虑重传报文的RTT。因为无法推理出:发送端所收到的确认是对上一次报文段的确认还是对重传报文段的确认。干脆不计入。
- 坚持计时器:persistent timer
专门为对付零窗口通知而设立的。
当发送端收到零窗口的确认时,就启动坚持计时器,当坚持计时器截止期到时,发送端TCP就发送一个特殊的报文段,叫探测报文段,这个报文段只有一个字节的数据。探测报文段有序号,但序号永远不需要确认,甚至在计算对其他部分数据的确认时这个序号也被忽略。探测报文段提醒接收端TCP,确认已丢失,必须重传。 坚持计时器的截止期设置为重传时间的值,但若没有收到从接收端来的响应,则发送另一个探测报文段,并将坚持计时器的值加倍和并复位,发送端继续发送探测报文段,将坚持计时器的值加倍和复位,知道这个值增大到阈值为止(通常为60秒)。之后,发送端每隔60s就发送一个报文段,直到窗口重新打开为止;
- 保活计时器:keeplive timer
每当服务器收到客户的信息,就将keeplive timer复位,超时通常设置2小时,若服务器超过2小时还没有收到来自客户的信息,就发送探测报文段,若发送了10个探测报文段(没75秒发送一个)还没收到响应,则终止连接。
- 时间等待计时器:Time_Wait Timer
在连接终止期使用,当TCP关闭连接时,并不认为这个连接就真正关闭了,在时间等待期间,连接还处于一种中间过度状态。这样就可以时重复的fin报文段在到达终点后被丢弃,这个计时器的值通常设置为一格报文段寿命期望值的两倍。
-
- 可以采取类似于HTTP的分隔符/r/n实现
- 可以采取在包头封装长度、索引信息实现
-
粘包就是连续给对端发送两个或者两个以上的数据包,对端在一次收取中可能收到的数据包大于 1 个,大于 1 个,可能是几个(包括一个)包加上某个包的部分,或者干脆就是几个完整的包在一起。当然,也可能收到的数据只是一个包的部分,这种情况一般也叫半包。
无论是半包还是粘包问题,其根源是上文介绍中 TCP 协议是流式数据格式。解决问题的思路还是想办法从收到的数据中把包与包的边界给区分出来。那么如何区分呢?目前主要有三种方法:
固定包长的数据包
顾名思义,即每个协议包的长度都是固定的。举个例子,例如我们可以规定每个协议包的大小是 64 个字节,每次收满 64 个字节,就取出来解析(如果不够,就先存起来)。
这种通信协议的格式简单但灵活性差。如果包内容不足指定的字节数,剩余的空间需要填充特殊的信息,如 \0(如果不填充特殊内容,如何区分包里面的正常内容与填充信息呢?);如果包内容超过指定字节数,又得分包分片,需要增加额外处理逻辑——在发送端进行分包分片,在接收端重新组装包片(分包和分片内容在接下来会详细介绍)。
以指定字符(串)为包的结束标志
这种协议包比较常见,即字节流中遇到特殊的符号值时就认为到一个包的末尾了。例如,我们熟悉的 FTP协议,发邮件的 SMTP 协议,一个命令或者一段数据后面加上"\r\n"(即所谓的 CRLF)表示一个包的结束。对端收到后,每遇到一个”\r\n“就把之前的数据当做一个数据包。
这种协议一般用于一些包含各种命令控制的应用中,其不足之处就是如果协议数据包内容部分需要使用包结束标志字符,就需要对这些字符做转码或者转义操作,以免被接收方错误地当成包结束标志而误解析。
包头 + 包体格式
这种格式的包一般分为两部分,即包头和包体,包头是固定大小的,且包头中必须含有一个字段来说明接下来的包体有多大。
例如:
struct msg_header { int32_t bodySize; int32_t cmd; };
这就是一个典型的包头格式,bodySize 指定了这个包的包体是多大。由于包头大小是固定的(这里是 size(int32_t) + sizeof(int32_t) = 8 字节),对端先收取包头大小字节数目(当然,如果不够还是先缓存起来,直到收够为止),然后解析包头,根据包头中指定的包体大小来收取包体,等包体收够了,就组装成一个完整的包来处理。在有些实现中,包头中的 bodySize可能被另外一个叫 packageSize 的字段代替,这个字段的含义是整个包的大小,这个时候,我们只要用 packageSize 减去包头大小(这里是 sizeof(msg_header))就能算出包体的大小,原理同上。
在使用大多数网络库时,通常你需要根据协议格式自己给数据包分界和解析,一般的网络库不提供这种功能是出于需要支持不同的协议,由于协议的不确定性,因此没法预先提供具体解包代码。当然,这不是绝对的,也有一些网络库提供了这种功能。在 Java Netty 网络框架中,提供了FixedLengthFrameDecoder 类去处理长度是定长的协议包,提供了 DelimiterBasedFrameDecoder 类去处理按特殊字符作为结束符的协议包,提供 ByteToMessageDecoder 去处理自定义格式的协议包(可用来处理包头 + 包体 这种格式的数据包),然而在继承 ByteToMessageDecoder 子类中你需要根据你的协议具体格式重写 decode() 方法来对数据包解包。
-
操作系统的 TCP/IP 协议栈其实提供了这个的功能,即 keepalive 选项。在 Linux 操作系统中,我们可以通过代码启用一个 socket 的心跳检测(即每隔一定时间间隔发送一个心跳检测包给对端),代码如下:
//on 是 1 表示打开 keepalive 选项,为 0 表示关闭,0 是默认值 int on = 1; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on));
但是,即使开启了这个选项,这个选项默认发送心跳检测数据包的时间间隔是 7200 秒(2 小时),这时间间隔实在是太长了,不具有实用性。
我们可以通过继续设置 keepalive 相关的三个选项来改变这个时间间隔,它们分别是 TCP_KEEPIDLE、TCP_KEEPINTVL 和 TCP_KEEPCNT,示例代码如下:
//发送 keepalive 报文的时间间隔 int val = 7200; setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &val, sizeof(val)); //两次重试报文的时间间隔 int interval = 75; setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval)); int cnt = 9; setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));
TCP_KEEPIDLE 选项设置了发送 keepalive 报文的时间间隔,发送时如果对端回复 ACK。则本端 TCP 协议栈认为该连接依然存活,继续等 7200 秒后再发送 keepalive 报文;如果对端回复 RESET,说明对端进程已经重启,本端的应用程序应该关闭该连接。
如果对端没有任何回复,则本端做重试,如果重试 9 次(TCP_KEEPCNT 值)(前后重试间隔为 75 秒(TCP_KEEPINTVL 值))仍然不可达,则向应用程序返回 ETIMEOUT(无任何应答)或 EHOST 错误信息。
我们可以使用如下命令查看 Linux 系统上的上述三个值的设置情况:
[root@iZ238vnojlyZ ~]# sysctl -a | grep keepalive net.ipv4.tcp_keepalive_intvl = 75 net.ipv4.tcp_keepalive_probes = 9 net.ipv4.tcp_keepalive_time = 7200
在 Windows 系统设置 keepalive 及对应选项的代码略有不同:
//开启 keepalive 选项 const char on = 1; setsockopt(socket, SOL_SOCKET, SO_KEEPALIVE, (char *)&on, sizeof(on); // 设置超时详细信息 DWORD cbBytesReturned; tcp_keepalive klive; // 启用保活 klive.onoff = 1; klive.keepalivetime = 7200; // 重试间隔为10秒 klive.keepaliveinterval = 1000 * 10; WSAIoctl(socket, SIO_KEEPALIVE_VALS, &klive, sizeof(tcp_keepalive), NULL, 0, &cbBytesReturned, NULL, NULL);
由于 keepalive 选项需要为每个连接中的 socket 开启,这不一定是必须的,可能会产生大量无意义的带宽浪费,且 keepalive 选项不能与应用层很好地交互,因此一般实际的服务开发中,还是建议读者在应用层设计自己的心跳包机制。那么如何设计呢?
从技术来讲,心跳包其实就是一个预先规定好格式的数据包,在程序中启动一个定时器,定时发送即可,这是最简单的实现思路。但是,如果通信的两端有频繁的数据来往,此时到了下一个发心跳包的时间点了,此时发送一个心跳包。这其实是一个流量的浪费,既然通信双方不断有正常的业务数据包来往,这些数据包本身就可以起到保活作用,为什么还要浪费流量去发送这些心跳包呢?所以,对于用于保活的心跳包,我们最佳做法是,设置一个上次包时间,每次收数据和发数据时,都更新一下这个包时间,而心跳检测计时器每次检测时,将这个包时间与当前系统时间做一个对比,如果时间间隔大于允许的最大时间间隔(实际开发中根据需求设置成 15 ~ 45 秒不等),则发送一次心跳包。总而言之,就是在与对端之间,没有数据来往达到一定时间间隔时才发送一次心跳包。
发心跳包的伪码:
bool CIUSocket::Send() { int nSentBytes = 0; int nRet = 0; while (true) { nRet = ::send(m_hSocket, m_strSendBuf.c_str(), m_strSendBuf.length(), 0); if (nRet == SOCKET_ERROR) { if (::WSAGetLastError() == WSAEWOULDBLOCK) break; else { LOG_ERROR("Send data error, disconnect server:%s, port:%d.", m_strServer.c_str(), m_nPort); Close(); return false; } } else if (nRet < 1) { //一旦出现错误就立刻关闭Socket LOG_ERROR("Send data error, disconnect server:%s, port:%d.", m_strServer.c_str(), m_nPort); Close(); return false; } m_strSendBuf.erase(0, nRet); if (m_strSendBuf.empty()) break; ::Sleep(1); } { //记录一下最近一次发包时间 std::lock_guard<std::mutex> guard(m_mutexLastDataTime); m_nLastDataTime = (long)time(NULL); } return true; } bool CIUSocket::Recv() { int nRet = 0; char buff[10 * 1024]; while (true) { nRet = ::recv(m_hSocket, buff, 10 * 1024, 0); if (nRet == SOCKET_ERROR) //一旦出现错误就立刻关闭Socket { if (::WSAGetLastError() == WSAEWOULDBLOCK) break; else { LOG_ERROR("Recv data error, errorNO=%d.", ::WSAGetLastError()); //Close(); return false; } } else if (nRet < 1) { LOG_ERROR("Recv data error, errorNO=%d.", ::WSAGetLastError()); //Close(); return false; } m_strRecvBuf.append(buff, nRet); ::Sleep(1); } { std::lock_guard<std::mutex> guard(m_mutexLastDataTime); //记录一下最近一次收包时间 m_nLastDataTime = (long)time(NULL); } return true; } void CIUSocket::RecvThreadProc() { LOG_INFO("Recv data thread start..."); int nRet; //上网方式 DWORD dwFlags; BOOL bAlive; while (!m_bStop) { //检测到数据则收数据 nRet = CheckReceivedData(); //出错 if (nRet == -1) { m_pRecvMsgThread->NotifyNetError(); } //无数据 else if (nRet == 0) { long nLastDataTime = 0; { std::lock_guard<std::mutex> guard(m_mutexLastDataTime); nLastDataTime = m_nLastDataTime; } if (m_nHeartbeatInterval > 0) { //当前系统时间与上一次收发数据包的时间间隔超过了m_nHeartbeatInterval //则发一次心跳包 if (time(NULL) - nLastDataTime >= m_nHeartbeatInterval) SendHeartbeatPackage(); } } //有数据 else if (nRet == 1) { if (!Recv()) { m_pRecvMsgThread->NotifyNetError(); continue; } DecodePackages(); }// end if }// end while-loop LOG_INFO("Recv data thread finish..."); }
同理,检测心跳包的一端,应该是在与对端没有数据来往达到一定时间间隔时才做一次心跳检测。
心跳检测一端的伪码如下:
void BusinessSession::send(const char* pData, int dataLength) { bool sent = TcpSession::send(pData, dataLength); //发送完数据更新下发包时间 updateHeartbeatTime(); } void BusinessSession::handlePackge(char* pMsg, int msgLength, bool& closeSession, std::vector<std::string>& vectorResponse) { //对数据合法性进行校验 if (pMsg == NULL || pMsg[0] == 0 || msgLength <= 0 || msgLength > MAX_DATA_LENGTH) { //非法刺探请求,不做任何应答,直接关闭连接 closeSession = true; return; } //更新下收包时间 updateHeartbeatTime(); //省略包处理代码... } void BusinessSession::updateHeartbeatTime() { std::lock_guard<std::mutex> scoped_guard(m_mutexForlastPackageTime); m_lastPackageTime = (int64_t)time(nullptr); } bool BusinessSession::doHeartbeatCheck() { const Config& cfg = Singleton<Config>::Instance(); int64_t now = (int64_t)time(nullptr); std::lock_guard<std::mutex> lock_guard(m_mutexForlastPackageTime); if (now - m_lastPackageTime >= cfg.m_nMaxClientDataInterval) { //心跳包检测,超时,关闭连接 LOGE("heartbeat expired, close session"); shutdown(); return true; } return false; } void TcpServer::checkSessionHeartbeat() { int64_t now = (int64_t)time(nullptr); if (now - m_nLastCheckHeartbeatTime >= m_nHeartbeatCheckInterval) { m_spSessionManager->checkSessionHeartbeat(); m_nLastCheckHeartbeatTime = (int64_t)time(nullptr); } } void SessionManager::checkSessionHeartbeat() { std::lock_guard<std::mutex> scoped_lock(m_mutexForSession); for (const auto& iter : m_mapSessions) { //这里调用 BusinessSession::doHeartbeatCheck() iter.second->doHeartbeatCheck(); } }
需要注意的是:一般是客户端主动给服务器端发送心跳包,服务器端做心跳检测决定是否断开连接。而不是反过来,从客户端的角度来说,客户端为了让自己得到服务器端的正常服务有必要主动和服务器保持连接状态正常,而服务器端不会局限于某个特定的客户端,如果客户端不能主动和其保持连接,那么就会主动回收与该客户端的连接。当然,服务器端在收到客户端的心跳包时应该给客户端一个心跳应答。
上面介绍的心跳包是从纯技术的角度来说的,在实际应用中,有时候我们需要定时或者不定时从服务器端更新一些数据,我们可以把这类数据放在心跳包中,定时或者不定时更新。
这类带业务数据的心跳包,就不再是纯粹技术上的作用了(这里说的技术的作用指的上文中介绍的心跳包起保活和检测死链作用)。
这类心跳包实现也很容易,即在心跳包数据结构里面加上需要的业务字段信息,然后在定时器中定时发送,客户端发给服务器,服务器在应答心跳包中填上约定的业务数据信息即可。
通常情况下,多数应用场景下,与服务器端保持连接的多个客户端中,同一时间段活跃用户(这里指的是与服务器有频繁数据来往的客户端)一般不会太多。当连接数较多时,进出服务器程序的数据包通常都是心跳包(为了保活)。所以为了减轻网络代码压力,节省流量,尤其是针对一些 3/4 G 手机应用,我们在设计心跳包数据格式时应该尽量减小心跳包的数据大小。
如前文所述,对于心跳包,服务器端的逻辑一般是在一定时间间隔内没有收到客户端心跳包时会主动断开连接。在我们开发调试程序过程中,我们可能需要将程序通过断点中断下来,这个过程可能是几秒到几十秒不等。等程序恢复执行时,连接可能因为心跳检测逻辑已经被断开。
调试过程中,我们更多的关注的是业务数据处理的逻辑是否正确,不想被一堆无意义的心跳包数据干扰实线。
鉴于以上两点原因,我们一般在调试模式下关闭或者禁用心跳包检测机制。代码大致如下:
ChatSession::ChatSession(const std::shared_ptr<TcpConnection>& conn, int sessionid) : TcpSession(conn), m_id(sessionid), m_seq(0), m_isLogin(false) { m_userinfo.userid = 0; m_lastPackageTime = time(NULL); //这里设置了非调试模式下才开启心跳包检测功能 #ifndef _DEBUG EnableHearbeatCheck(); #endif }
当然,你也可以将开启心跳检测的开关做成配置信息放入程序配置文件中。
实际生产环境,我们一般会将程序收到的和发出去的数据包写入日志中,但是无业务信息的心跳包信息是个例外,一般会刻意不写入日志,这是因为心跳包数据一般比较多,如果写入日志会导致日志文件变得很大,且充斥大量无意义的心跳包日志,所以一般在写日志时会屏蔽心跳包信息写入。
我这里的建议是,可以将心跳包信息是否写入日志做成一个配置开关,一般处于关闭状态,有需要时再开启。例如,对于一个 WebSocket 服务,ping 和 pong 是心跳包数据,下面示例代码按需输出心跳日志信息:
void BusinessSession::send(std::string_view strResponse) { bool success = WebSocketSession::send(strResponse); if (success) { bool enablePingPongLog = Singleton<Config>::Instance().m_bPingPongLogEnabled; //其他消息正常打印,心跳消息按需打印 if (strResponse != "pong" || enablePingPongLog) { LOGI("msg sent to client [%s], sessionId: %s, session: 0x%0x, clientId: %s, accountId: %s, frontId: %s, msg: %s", getClientInfo(), m_strSessionId.c_str(), (int64_t)this, m_strClientID.c_str(), m_strAccountID.c_str(), BusinessSession::m_strFrontId.c_str(), strResponse.data()); } } }
-
- HTTP 的URL 以http:// 开头,而HTTPS 的URL 以https:// 开头
- HTTP 是不安全的,而 HTTPS 是安全的
- HTTP 标准端口是80 ,而 HTTPS 的标准端口是443
- 在OSI 网络模型中,HTTP工作于应用层,而HTTPS 的安全传输机制工作在传输层
- HTTP 无法加密,而HTTPS 对传输的数据进行加密
- HTTP无需证书,而HTTPS 需要CA机构wosign的颁发的SSL证书
-
- 无状态协议对于事务处理没有记忆能力,缺少状态意味着如果后续处理需要前面的信息。也就是说,当客户端一次HTTP请求完成以后,客户端再发送一次HTTP请求,HTTP并不知道当前客户端是一个”老用户“。
- 可以使用Cookie来解决无状态的问题,Cookie就相当于一个通行证,第一次访问的时候给客户端发送一个Cookie,当客户端再次来的时候,拿着Cookie(通行证),那么服务器就知道这个是”老用户“。
-
URI,是uniform resource identifier,统一资源标识符,用来唯一的标识一个资源。
- Web上可用的每种资源如HTML文档、图像、视频片段、程序等都是一个来URI来定位的
- URI一般由三部组成:
- 访问资源的命名机制
- 存放资源的主机名
- 资源自身的名称,由路径表示,着重强调于资源。
URL是uniform resource locator,统一资源定位器,它是一种具体的URI,即URL可以用来标识一个资源,而且还指明了如何locate这个资源。
- URL是Internet上用来描述信息资源的字符串,主要用在各种WWW客户程序和服务器程序上,特别是著名的Mosaic。
- 采用URL可以用一种统一的格式来描述各种信息资源,包括文件、服务器的地址和目录等。URL一般由三部组成:
- ①协议(或称为服务方式)
- ②存有该资源的主机IP地址(有时也包括端口号)
- ③主机资源的具体地址。如目录和文件名等
URN,uniform resource name,统一资源命名,是通过名字来标识资源,比如mailto:java-net@java.sun.com。
- URI是以一种抽象的,高层次概念定义统一资源标识,而URL和URN则是具体的资源标识的方式。URL和URN都是一种URI。笼统地说,每个 URL 都是 URI,但不一定每个 URI 都是 URL。这是因为 URI 还包括一个子类,即统一资源名称 (URN),它命名资源但不指定如何定位资源。上面的 mailto、news 和 isbn URI 都是 URN 的示例。
在Java的URI中,一个URI实例可以代表绝对的,也可以是相对的,只要它符合URI的语法规则。而URL类则不仅符合语义,还包含了定位该资源的信息,因此它不能是相对的。
在Java类库中,URI类不包含任何访问资源的方法,它唯一的作用就是解析。
相反的是,URL类可以打开一个到达资源的流。
-
- GET: 用于请求访问已经被URI(统一资源标识符)识别的资源,可以通过URL传参给服务器
- POST:用于传输信息给服务器,主要功能与GET方法类似,但一般推荐使用POST方式。
- PUT: 传输文件,报文主体中包含文件内容,保存到对应URI位置。
- HEAD: 获得报文首部,与GET方法类似,只是不返回报文主体,一般用于验证URI是否有效。
- DELETE:删除文件,与PUT方法相反,删除对应URI位置的文件。
- OPTIONS:查询相应URI支持的HTTP方法。
-
-
请求报文包含四部分:
-
请求行:包含请求方法、URI、HTTP版本信息
-
请求首部字段
-
请求内容实体
-
空行
-
-
响应报文包含四部分:
-
状态行:包含HTTP版本、状态码、状态码的原因短语
-
响应首部字段
-
响应内容实体
-
空行
-
-
常见的首部:
- 通用首部字段(请求报文与响应报文都会使用的首部字段)
- Date:创建报文时间
- Connection:连接的管理
- Cache-Control:缓存的控制
- Transfer-Encoding:报文主体的传输编码方式
- 请求首部字段(请求报文会使用的首部字段)
- Host:请求资源所在服务器
- Accept:可处理的媒体类型
- Accept-Charset:可接收的字符集
- Accept-Encoding:可接受的内容编码
- Accept-Language:可接受的自然语言
- 响应首部字段(响应报文会使用的首部字段)
- Accept-Ranges:可接受的字节范围
- Location:令客户端重新定向到的URI
- Server:HTTP服务器的安装信息
- 实体首部字段(请求报文与响应报文的的实体部分使用的首部字段)
- Allow:资源可支持的HTTP方法
- Content-Type:实体主类的类型
- Content-Encoding:实体主体适用的编码方式
- Content-Language:实体主体的自然语言
- Content-Length:实体主体的的字节数
- Content-Range:实体主体的位置范围,一般用于发出部分请求时使用
- 通用首部字段(请求报文与响应报文都会使用的首部字段)
-
-
- 首先HTTP请求服务端生成证书,客户端对证书的有效期、合法性、域名是否与请求的域名一致、证书的公钥(RSA加密)等进行校验;
- 客户端如果校验通过后,就根据证书的公钥的有效, 生成随机数,随机数使用公钥进行加密(RSA加密);
- 消息体产生的后,对它的摘要进行MD5(或者SHA1)算法加密,此时就得到了RSA签名;
- 发送给服务端,此时只有服务端(RSA私钥)能解密。
- 解密得到的随机数,再用AES加密,作为密钥(此时的密钥只有客户端和服务端知道)。
-
HTTP通信机制是在一次完整的HTTP通信过程中,Web浏览器与Web服务器之间将完成下列7个步骤:
-
建立TCP连接
在HTTP工作开始之前,Web浏览器首先要通过网络与Web服务器建立连接,该连接是通过TCP来完成的,该协议与IP协议共同构建 Internet,即著名的TCP/IP协议族,因此Internet又被称作是TCP/IP网络。HTTP是比TCP更高层次的应用层协议,根据规则, 只有低层协议建立之后才能,才能进行更层协议的连接,因此,首先要建立TCP连接,一般TCP连接的端口号是80。
-
Web浏览器向Web服务器发送请求行
一旦建立了TCP连接,Web浏览器就会向Web服务器发送请求命令。例如:GET /sample/hello.jsp HTTP/1.1。
-
Web浏览器发送请求头
浏览器发送其请求命令之后,还要以头信息的形式向Web服务器发送一些别的信息,之后浏览器发送了一空白行来通知服务器,它已经结束了该头信息的发送。
-
Web服务器应答
客户机向服务器发出请求后,服务器会客户机回送应答, HTTP/1.1 200 OK ,应答的第一部分是协议的版本号和应答状态码。
-
Web服务器发送应答头
正如客户端会随同请求发送关于自身的信息一样,服务器也会随同应答向用户发送关于它自己的数据及被请求的文档。
-
Web服务器向浏览器发送数据
Web服务器向浏览器发送头信息后,它会发送一个空白行来表示头信息的发送到此为结束,接着,它就以Content-Type应答头信息所描述的格式发送用户所请求的实际数据。
-
Web服务器关闭TCP连接
一般情况下,一旦Web服务器向浏览器发送了请求数据,它就要关闭TCP连接,然后如果浏览器或者服务器在其头信息加入了这行代码:
-
Connection:keep-alive
TCP连接在发送后将仍然保持打开状态,于是,浏览器可以继续通过相同的连接发送请求。保持连接节省了为每个请求建立新连接所需的时间,还节约了网络带宽。
建立TCP连接->发送请求行->发送请求头->(到达服务器)发送状态行->发送响应头->发送响应数据->断TCP连接
-
- 200:请求被正常处理
- 204:请求被受理但没有资源可以返回
- 206:客户端只是请求资源的一部分,服务器只对请求的部分资源执行GET方法,相应报文中通过Content-Range指定范围的资源。
- 301:永久性重定向
- 302:临时重定向
- 303:与302状态码有相似功能,只是它希望客户端在请求一个URI的时候,能通过GET方法重定向到另一个URI上
- 304:发送附带条件的请求时,条件不满足时返回,与重定向无关
- 307:临时重定向,与302类似,只是强制要求使用POST方法
- 400:请求报文语法有误,服务器无法识别
- 401:请求需要认证
- 403:请求的对应资源禁止被访问
- 404:服务器无法找到对应资源
- 500:服务器内部错误
- 503:服务器正忙
-
-
默认持久连接节省通信量,只要客户端服务端任意一端没有明确提出断开TCP连接,就一直保持连接,可以发送多次HTTP请求
-
管线化,客户端可以同时发出多个HTTP请求,而不用一个个等待响应
-
断点续传
实际上就是利用HTTP消息头使用分块传输编码,将实体主体分块传输。
-
-
-
单一连接。在HTTP/2中,客户端向某个域名的服务器请求页面的过程中,只会创建一条TCP连接,即使这页面可能包含上百个资源。
- 单一的连接能减少TCP握手带来的时延(如果是建立在SSL/TLS上面,HTTP2能减少很多不必要的SSL握手)
- HTTP 性能优化的关键并不在于高带宽,而是低延迟。TCP的慢启动让原本就具有突发性和短时性的 HTTP 连接变的更加低效。由于 TCP 连接的减少而使网络拥塞状况得以改善,同时慢启动时间的减少,使拥塞和丢包恢复速度更快。
-
多路复用。HTTP2虽然只有一条TCP连接,基于二进制分帧层,却可以在共享TCP连接的基础上,同时发送请求和响应。只要同一个请求或者响应的帧是有序的,不同的请求或者响应帧可以互相穿插。
-
头部压缩。因为一些重复东西在每个http请求里面都有,例如method: GET。当一个客户端从同一服务器请求一些资源(例如页面的图片)的时候,这些请求看起来几乎是一致的。而这些大量一致的东西正好值得被压缩。
-
-
- TCP复用:TCP连接复用是将多个客户端的HTTP请求复用到一个服务器端TCP连接上,而HTTP复用则是一个客户端的多个HTTP请求通过一个TCP连接进行处理。前者是负载均衡设备的独特功能;而后者是HTTP 1.1协议所支持的新功能,目前被大多数浏览器所支持。
- 内容缓存:将经常用到的内容进行缓存起来,那么客户端就可以直接在内存中获取相应的数据了。
- 压缩:将文本数据进行压缩,减少带宽
- SSL加速(SSL Acceleration):使用SSL协议对HTTP协议进行加密,在通道内加密并加速
- TCP缓冲:通过采用TCP缓冲技术,可以提高服务器端响应时间和处理效率,减少由于通信链路问题给服务器造成的连接负担。
-
- RPC (Remote Procedure Call)即远程过程调用,是分布式系统常见的一种通信方法。它允许程序调用另一个地址空间(通常是共享网络的另一台机器上)的过程或函数,而不用程序员显式编码这个远程调用的细节。
- 除 RPC 之外,常见的多系统数据交互方案还有分布式消息队列、HTTP 请求调用、数据库和分布式缓存等。
- 其中 RPC 和 HTTP 调用是没有经过中间件的,它们是端到端系统的直接数据交互。
简单的说
- RPC就是从一台机器(客户端)上通过参数传递的方式调用另一台机器(服务器)上的一个函数或方法(可以统称为服务)并得到返回的结果。
- RPC会隐藏底层的通讯细节(不需要直接处理Socket通讯或Http通讯)。
- 客户端发起请求,服务器返回响应(类似于Http的工作方式)RPC在使用形式上像调用本地函数(或方法)一样去调用远程的函数(或方法)。
-
RPC 的主要目标是让构建分布式应用更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。为实现该目标,RPC 框架需提供一种透明调用机制让使用者不必显式的区分本地调用和远程调用。
-
RPC要达到的目标:远程调用时,要能够像本地调用一样方便,让调用者感知不到远程调用的逻辑。
- Call ID映射。我们怎么告诉远程机器我们要调用哪个函数呢?在本地调用中,函数体是直接通过函数指针来指定的,我们调用具体函数,编译器就自动帮我们调用它相应的函数指针。但是在远程调用中,是无法调用函数指针的,因为两个进程的地址空间是完全不一样。所以,在RPC中,所有的函数都必须有自己的一个ID。这个ID在所有进程中都是唯一确定的。客户端在做远程过程调用时,必须附上这个ID。然后我们还需要在客户端和服务端分别维护一个 {函数 <--> Call ID} 的对应表。两者的表不一定需要完全相同,但相同的函数对应的Call ID必须相同。当客户端需要进行远程调用时,它就查一下这个表,找出相应的Call ID,然后把它传给服务端,服务端也通过查表,来确定客户端需要调用的函数,然后执行相应函数的代码。
- 序列化和反序列化。客户端怎么把参数值传给远程的函数呢?在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。甚至有时候客户端和服务端使用的都不是同一种语言(比如服务端用C++,客户端用Java或者Python)。这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。这个过程叫序列化和反序列化。同理,从服务端返回的值也需要序列化反序列化的过程。
- 网络传输。远程调用往往是基于网络的,客户端和服务端是通过网络连接的。所有的数据都需要通过网络传输,因此就需要有一个网络传输层。网络传输层需要把Call ID和序列化后的参数字节流传给服务端,然后再把序列化后的调用结果传回客户端。只要能完成这两者的,都可以作为传输层使用。因此,它所使用的协议其实是不限的,能完成传输就行。尽管大部分RPC框架都使用TCP协议,但其实UDP也可以,而gRPC干脆就用了HTTP2。Java的Netty也属于这层的东西。
-
- 既然系统采用分布式架构,那一个服务势必会有多个实例,要解决如何获取实例的问题。所以需要一个服务注册中心,比如在Dubbo中,就可以使用Zookeeper作为注册中心,在调用时,从Zookeeper获取服务的实例列表,再从中选择一个进行调用;
- 如何选择实例呢?就要考虑负载均衡,例如dubbo提供了4种负载均衡策略;
- 如果每次都去注册中心查询列表,效率很低,那么就要加缓存;
- 客户端总不能每次调用完都等着服务端返回数据,所以就要支持异步调用;
- 服务端的接口修改了,老的接口还有人在用,这就需要版本控制;
- 服务端总不能每次接到请求都马上启动一个线程去处理,于是就需要线程池;
-
RPC 服务端通过RpcServer去导出(export)远程接口方法,而客户端通过RpcClient去导入(import)远程接口方法。客户端像调用本地方法一样去调用远程接口方法,RPC 框架提供接口的代理实现,实际的调用将委托给代理RpcProxy。代理封装调用信息并将调用转交给RpcInvoker去实际执行。在客户端的RpcInvoker通过连接器RpcConnector去维持与服务端的通道RpcChannel,并使用RpcProtocol执行协议编码(encode)并将编码后的请求消息通过通道发送给服务端。
RPC 服务端接收器RpcAcceptor接收客户端的调用请求,同样使用RpcProtocol执行协议解码(decode)。
解码后的调用信息传递给RpcProcessor去控制处理调用过程,最后再委托调用给RpcInvoker去实际执行并返回调用结果。
-
服务治理型
- dubbo:是阿里巴巴公司开源的一个Java高性能优秀的服务框架,使得应用可通过高性能的 RPC 实现服务的输出和输入功能,可以和 Spring框架无缝集成。dubbo 已经与12年年底停止维护升级。
- dubbox:是当当团队基于dubbo升级的一个版本。是一个分布式的服务架构,可直接用于生产环境作为SOA服务框架。dubbox资源链接
- motan:是新浪微博开源的一个Java框架。它诞生的比较晚,起于2013年,2016年5月开源。Motan 在微博平台中已经广泛应用,每天为数百个服务完成近千亿次的调用。motan资源链接
-
- 异步处理 - 相比于传统的串行、并行方式,提高了系统吞吐量。
- 应用解耦 - 系统间通过消息通信,不用关心其他系统的处理。
- 流量削锋 - 可以通过消息队列长度控制请求量;可以缓解短时间内的高并发请求。
- 日志处理 - 解决大量日志传输。
- 消息通讯 - 消息队列一般都内置了高效的通信机制,因此也可以用在纯的消息通讯。比如实现点对点消息队列,或者聊天室等。
怎么保证 MQ 的高可用?
- 将所有 Broker 和待分配的 Partition 排序
- 将第 i 个 Partition 分配到第(i mod n)个 Broker 上
- 将第 i 个 Partition 的第 j 个 Replica 分配到第((i + j) mode n)个 Broker 上
消息的顺序问题
消息有序指的是可以按照消息的发送顺序来消费。
假如生产者产生了 2 条消息:M1、M2,假定 M1 发送到 S1,M2 发送到 S2,如果要保证 M1 先于 M2 被消费,怎么做?
解决方案:
(1)保证生产者 - MQServer - 消费者是一对一对一的关系
缺陷:
- 并行度就会成为消息系统的瓶颈(吞吐量不够)
- 更多的异常处理,比如:只要消费端出现问题,就会导致整个处理流程阻塞,我们不得不花费更多的精力来解决阻塞的问题。 (2)通过合理的设计或者将问题分解来规避。
- 不关注乱序的应用实际大量存在
- 队列无序并不意味着消息无序 所以从业务层面来保证消息的顺序而不仅仅是依赖于消息系统,是一种更合理的方式。
消息重复问题
造成消息重复的根本原因:网络不可达
所以解决这个问题的办法就是绕过这个问题。那么问题就变成了:如果消费端收到两条一样的消息,应该怎样处理?
消费端处理消息的业务逻辑保持幂等性。只要保持幂等性,不管来多少条重复消息,最后处理的结果都一样。保证每条消息都有唯一编号且保证消息处理成功与去重表的日志同时出现。利用一张日志表来记录已经处理成功的消息的 ID,如果新到的消息 ID 已经在日志表中,那么就不再处理这条消息。
-
- 务容器负责启动,加载,运行服务提供者
- 服务提供者在启动时,向注册中心注册自己提供的服务。
- 服务消费者在启动时,向注册中心订阅自己所需的服务。
- 注册中心返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者。
- 服务消费者,从提供者地址列表中,基于软负载均衡算法,选一台提供者进行调用,如果调用失败,再选另一台调用。
- 服务消费者和提供者,在内存中累计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。
-
Random
- 随机,按权重设置随机概率。
- 在一个截面上碰撞的概率高,但调用量越大分布越均匀,而且按概率使用权重后也比较均匀,有利于动态调整提供者权重。
RoundRobin
- 轮循,按公约后的权重设置轮循比率。
- 存在慢的提供者累积请求的问题,比如:第二台机器很慢,但没挂,当请求调到第二台时就卡在那,久而久之,所有请求都卡在调到第二台上。
LeastActive
- 最少活跃调用数,相同活跃数的随机,活跃数指调用前后计数差。
- 使慢的提供者收到更少请求,因为越慢的提供者的调用前后计数差会越大。
ConsistentHash
- 一致性 Hash,相同参数的请求总是发到同一提供者。
- 当某一台提供者挂时,原本发往该提供者的请求,基于虚拟节点,平摊到其它提供者,不会引起剧烈变动。
- 缺省只对第一个参数 Hash,如果要修改,请配置 <dubbo:parameter key="hash.arguments" value="0,1" />
- 缺省用 160 份虚拟节点,如果要修改,请配置 <dubbo:parameter key="hash.nodes" value="320" />
-
- Failover - 失败自动切换,当出现失败,重试其它服务器。通常用于读操作,但重试会带来更长延迟。可通过 retries="2" 来设置重试次数(不含第一次)。
- Failfast - 快速失败,只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。
- Failsafe - 失败安全,出现异常时,直接忽略。通常用于写入审计日志等操作。
- Failback - 失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知操作。
- Forking - 并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks="2" 来设置最大并行数。
- Broadcast - 播调用所有提供者,逐个调用,任意一台报错则报错。通常用于通知所有提供者更新缓存或日志等本地资源信息。
-
Dubbo 作为 RPC 框架,首先要完成的就是跨系统,跨网络的服务调用。消费方与提供方遵循统一的接口定义,消费方调用接口时,Dubbo 将其转换成统一格式的数据结构,通过网络传输,提供方根据规则找到接口实现,通过反射完成调用。也就是说,消费方获取的是对远程服务的一个代理(Proxy),而提供方因为要支持不同的接口实现,需要一个包装层(Wrapper)。调用的过程大概是这样:
消费方的 Proxy 和提供方的 Wrapper 得以让 Dubbo 构建出复杂、统一的体系。而这种动态代理与包装也是通过基于 SPI 的插件方式实现的,它的接口就是ProxyFactory。
@SPI("javassist")
public interface ProxyFactory {
@Adaptive({Constants.PROXY_KEY})
<T> T getProxy(Invoker<T> invoker) throws RpcException;
@Adaptive({Constants.PROXY_KEY})
<T> Invoker<T> getInvoker(T proxy, Class<T> type, URL url) throws RpcException;
}
ProxyFactory 有两种实现方式,一种是基于 JDK 的代理实现,一种是基于 javassist 的实现。ProxyFactory 接口上定义了@SPI("javassist"),默认为 javassist 的实现。
- dubbo 序列化,阿里尚不成熟的 java 序列化实现。
- hessian2 序列化:hessian 是一种跨语言的高效二进制的序列化方式,但这里实际不是原生的 hessian2 序列化,而是阿里修改过的 hessian lite,它是 dubbo RPC 默认启用的序列化方式。
- json 序列化:目前有两种实现,一种是采用的阿里的 fastjson 库,另一种是采用 dubbo 中自已实现的简单 json 库,一般情况下,json 这种文本序列化性能不如二进制序列化。
- java 序列化:主要是采用 JDK 自带的 java 序列化实现,性能很不理想。
- Kryo 和 FST:Kryo 和 FST 的性能依然普遍优于 hessian 和 dubbo 序列化。
Hessian 是一个轻量级的 remoting on http 工具,采用的是 Binary RPC 协议,所以它很适合于发送二进制数据,同时又具有防火墙穿透能力。
- Hessian 支持跨语言串行
- 比 java 序列化具有更好的性能和易用性
-
Protocol Buffer 是 Google 出品的一种轻量 & 高效的结构化数据存储格式,性能比 Json、XML 真的强!太!多!
- 编码 / 解码 方式简单(只需要简单的数学运算 = 位移等等)
- 采用 Protocol Buffer 自身的框架代码 和 编译器 共同完成
- 采用了独特的编码方式,如 Varint、Zigzag 编码方式等等
- 采用 T - L - V 的数据存储方式:减少了分隔符的使用 & 数据存储得紧凑
-
可以。Dubbo 消费者在应用启动时会从注册中心拉取已注册的生产者的地址接口,并缓存在本地。每次调用时,按照本地存储的地址进行调用。
-
ZooKeeper 是一个分布式应用协调系统,已经用到了许多分布式项目中,用来完成统一命名服务、状态同步服务、集群管理、分布式应用配置项的管理等工作。
- 每个 Server 在内存中存储了一份数据;
- Zookeeper 启动时,将从实例中选举一个 leader(Paxos 协议);
- Leader 负责处理数据更新等操作(Zab 协议);
- 一个更新操作成功,当且仅当大多数 Server 在内存中成功修改数据。
-
Netty 是一个“网络通讯框架”。
Netty 进行事件处理的流程。Channel是连接的通道,是 ChannelEvent 的产生者,而ChannelPipeline可以理解为 ChannelHandler 的集合。
-
- 同步阻塞的 BIO
- 同步非阻塞的 NIO
- 异步非阻塞的 AIO 在使用同步 I/O 的网络应用中,如果要同时处理多个客户端请求,或是在客户端要同时和多个服务器进行通讯,就必须使用多线程来处理。
NIO 基于 Reactor,当 socket 有流可读或可写入 socket 时,操作系统会相应的通知引用程序进行处理,应用再将流读取到缓冲区或写入操作系统。也就是说,这个时候,已经不是一个连接就要对应一个处理线程了,而是有效的请求,对应一个线程,当连接没有数据时,是没有工作线程来处理的。
与 NIO 不同,当进行读写操作时,只须直接调用 API 的 read 或 write 方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入 read 方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将 write 方法传递的流写入完毕时,操作系统主动通知应用程序。即可以理解为,read/write 方法都是异步的,完成后会主动调用回调函数。
-
系统拆分从资源角度分为:应用拆分和数据库拆分。
从采用的先后顺序可分为:水平扩展、垂直拆分、业务拆分、水平拆分。
是否使用服务依据实际业务场景来决定。
当垂直应用越来越多,应用之间交互不可避免,将核心业务抽取出来,作为独立的服务,逐渐形成稳定的服务中心,使前端应用能更快速的响应多变的市场需求。此时,用于提高业务复用及整合的分布式服务框架(RPC)是关键。
当服务越来越多,容量的评估,小服务资源的浪费等问题逐渐显现,此时需增加一个调度中心基于访问压力实时管理集群容量,提高集群利用率。此时,用于提高机器利用率的资源调度和治理中心(SOA)是关键。
-
- Thrift 是跨语言的 RPC 框架。
- Dubbo 支持服务治理,而 Thrift 不支持
-
服务器端为了能流畅处理多个客户端链接,一般在某个线程A里面accept新的客户端连接并生成新连接的socket fd,然后将这些新连接的socketfd给另外开的数个工作线程B1、B2、B3、B4,这些工作线程处理这些新连接上的网络IO事件(即收发数据),同时,还处理系统中的另外一些事务。这里我们将线程A称为主线程,B1、B2、B3、B4等称为工作线程。工作线程的代码框架一般如下:
while (!m_bQuit) { epoll_or_select_func(); handle_io_events(); handle_other_things(); }
在epoll_or_select_func()中通过select()或者poll/epoll()去检测socket fd上的io事件,若存在这些事件则下一步handle_io_events()来处理这些事件(收发数据),做完之后可能还要做一些系统其他的任务,即调用handle_other_things()。
这样做有三个好处:
-
线程A只需要处理新连接的到来即可,不用处理网络IO事件。由于网络IO事件处理一般相对比较慢,如果在线程A里面既处理新连接又处理网络IO,则可能由于线程忙于处理IO事件,而无法及时处理客户端的新连接,这是很不好的。
-
线程A接收的新连接,可以根据一定的负载均衡原则将新的socket fd分配给工作线程。常用的算法,比如round robin,即轮询机制,即,假设不考虑中途有连接断开的情况,一个新连接来了分配给B1,又来一个分配给B2,再来一个分配给B3,再来一个分配给B4。如此反复,也就是说线程A记录了各个工作线程上的socket fd数量,这样可以最大化地来平衡资源,避免一些工作线程“忙死”,另外一些工作线程“闲死”的现象。
-
即使工作线程不满载的情况下,也可以让工作线程做其他的事情。比如现在有四个工作线程,但只有三个连接。那么线程B4就可以在handle_other_thing()做一些其他事情。
下面讨论一个很重要的效率问题:
在上述while循环里面,epoll_or_selec_func()中的epoll_wait/poll/select等函数一般设置了一个超时时间。如果设置超时时间为0,那么在没有任何网络IO时间和其他任务处理的情况下,这些工作线程实际上会空转,白白地浪费cpu时间片。如果设置的超时时间大于0,在没有网络IO时间的情况,epoll_wait/poll/select仍然要挂起指定时间才能返回,导致handle_other_thing()不能及时执行,影响其他任务不能及时处理,也就是说其他任务一旦产生,其处理起来具有一定的延时性。这样也不好。那如何解决该问题呢?
其实我们想达到的效果是,如果没有网络IO时间和其他任务要处理,那么这些工作线程最好直接挂起而不是空转;如果有其他任务要处理,这些工作线程要立刻能处理这些任务而不是在epoll_wait/poll/selec挂起指定时间后才开始处理这些任务。
我们采取如下方法来解决该问题,以linux为例,不管epoll_fd上有没有文件描述符fd,我们都给它绑定一个默认的fd,这个fd被称为唤醒fd。当我们需要处理其他任务的时候,向这个唤醒fd上随便写入1个字节的,这样这个fd立即就变成可读的了,epoll_wait()/poll()/select()函数立即被唤醒,并返回,接下来马上就能执行handle_other_thing(),其他任务得到处理。反之,没有其他任务也没有网络IO事件时,epoll_or_select_func()就挂在那里什么也不做。
这个唤醒fd,在linux平台上可以通过以下几种方法实现:
-
管道pipe,创建一个管道,将管道绑定到epoll_fd上。需要时,向管道一端写入一个字节,工作线程立即被唤醒。
-
linux 2.6新增的eventfd:
int eventfd(unsigned int initval, int flags);
步骤也是一样,将生成的eventfd绑定到epoll_fd上。需要时,向这个eventfd上写入一个字节,工作线程立即被唤醒。
- 第三种方法最方便。即linux特有的socketpair,socketpair是一对相互连接的socket,相当于服务器端和客户端的两个端点,每一端都可以读写数据。
int socketpair(int domain, int type, int protocol, int sv[2]);
调用这个函数返回的两个socket句柄就是sv[0],和sv[1],在一个其中任何一个写入字节,在另外一个收取字节。
将收取的字节的socket绑定到epoll_fd上。需要时,向另外一个写入的socket上写入一个字节,工作线程立即被唤醒。
如果是使用socketpair,那么domain参数一定要设置成AFX_UNIX。
由于在windows,select函数只支持检测socket这一种fd,所以windows上一般只能用方法3的原理。而且需要手动创建两个socket,然后一个连接另外一个,将读取的那一段绑定到select的fd上去。这在写跨两个平台代码时,需要注意的地方。
-
-
该程序总共有 17 个线程,其中分为 9 个数据库工作线程 D 和一个日志线程 L,6个普通工作线程 W,一个主线程 M。(以下会用这些字母来代指这些线程)
9 个数据库工作线程在线程启动之初,与 mysql 建立连接,也就是说每个线程都与 mysql 保持一路连接,共 9 个数据库连接。
每个数据库工作线程同时存在两个任务队列,第一个队列 A 存放需要执行数据库增删查改操作的任务 sqlTask,第二个队列 B 存放 sqlTask 执行完成后的结果。sqlTask 执行完成后立即放入结果队列中,因而结果队列中任务也是一个个的需要执行的任务。大致伪代码如下:
void db_thread_func() { while (!m_bExit) { if (NULL != (pTask = m_sqlTask.Pop())) { //从m_sqlTask中取出的任务先执行完成后,pTask将携带结果数据 pTask->Execute(); //得到结果后,立刻将该任务放入结果任务队列 m_resultTask.Push(pTask); continue; } sleep(1000); }//end while-loop }
现在的问题来了:
- 任务队列 A 中的任务从何而来,目前只有消费者,没有生产者,那么生产者是谁?
- 任务队列 B 中的任务将去何方,目前只有生产者没有消费者。
这两个问题先放一会儿,等到后面我再来回答。
在介绍主线程和工作线程具体做什么时,我们介绍下服务器编程中常常抽象出来的几个概念(这里以 tcp 连接为例):
- TcpServer 即 Tcp 服务,服务器需要绑定ip地址和端口号,并在该端口号上侦听客户端的连接(往往由一个成员变量 TcpListener 来管理侦听细节)。所以一个 TcpServer 要做的就是这些工作。除此之外,每当有新连接到来时,TcpServer 需要接收新连接,当多个新连接存在时,TcpServer 需要有条不紊地管理这些连接:连接的建立、断开等,即产生和管理下文中说的 TcpConnection 对象。
- 一个连接对应一个 TcpConnection 对象,TcpConnection 对象管理着这个连接的一些信息:如连接状态、本端和对端的 ip 地址和端口号等。
- 数据通道对象 Channel,Channel 记录了 socket 的句柄,因而是一个连接上执行数据收发的真正执行者,Channel 对象一般作为TcpConnection 的成员变量。
- TcpSession 对象,是将 Channel 收取的数据进行解包,或者对准备好的数据进行装包,并传给 Channel 发送。
归纳起来:一个 TcpServer 依靠 TcpListener 对新连接的侦听和处理,依靠TcpConnection 对象对连接上的数据进行管理,TcpConnection 实际依靠Channel 对数据进行收发,依靠 TcpSession 对数据进行装包和解包。也就是说一个 TcpServer 存在一个 TcpListener,对应多个 TcpConnection,有几个TcpConnection 就有几个 TcpSession,同时也就有几个 Channel。
以上说的 TcpServer、TcpListener、TcpConnection、Channel 和TcpSession 是服务器框架的网络层。一个好的网络框架,应该做到与业务代码脱耦。即上层代码只需要拿到数据,执行业务逻辑,而不用关注数据的收发和网络数据包的封包和解包以及网络状态的变化(比如网络断开与重连)。
拿数据的发送来说:
当业务逻辑将数据交给 TcpSession,TcpSession 将数据装好包后(装包过程后可以有一些加密或压缩操作),交给 TcpConnection::SendData(),而TcpConnection::SendData() 实际是调用 Channel::SendData(),因为Channel 含有 socket 句柄,所以 Channel::SendData() 真正调用send()/sendto()/write() 方法将数据发出去。
对于数据的接收,稍微有一点不同:
通过 select()/poll()/epoll() 等IO multiplex技术,确定好了哪些 TcpConnection上有数据到来后,激活该 TcpConnection 的 Channel 对象去调用recv()/recvfrom()/read() 来收取数据。数据收到以后,将数据交由 TcpSession来处理,最终交给业务层。注意数据收取、解包乃至交给业务层是一定要分开的。我的意思是:最好不要解包并交给业务层和数据收取的逻辑放在一起。因为数据收取是 IO 操作,而解包和交给业务层是逻辑计算操作。IO 操作一般比逻辑计算要慢。到底如何安排要根据服务器业务来取舍,也就是说你要想好你的服务器程序的性能瓶颈在网络 IO 还是逻辑计算,即使是网络 IO,也可以分为上行操作和下行操作,上行操作即客户端发数据给服务器,下行即服务器发数据给客户端。有时候数据上行少,下行大。(如游戏服务器,一个 npc 移动了位置,上行是该客户端通知服务器自己最新位置,而下行确是服务器要告诉在场的每个客户端)。
工作线程的流程:
while (!m_bQuit) { epoll_or_select_func(); handle_io_events(); handle_other_things(); }
其中 epoll_or_select_func() 即是上文所说的通过 select()/poll()/epoll() 等 IO multiplex 技术,确定好了哪些 TcpConnection 上有数据到来。我的服务器代码中一般只会监测 socket 可读事件,而不会监测 socket 可写事件。至于如何发数据,文章后面会介绍。所以对于可读事件,以 epoll 为例,这里需要设置的标识位是:
- EPOLLIN 普通可读事件(当连接正常时,产生这个事件,recv()/read()函数返回收到的字节数;当连接关闭,这两个函数返回0,也就是说我们设置这个标识已经可以监测到新来数据和对端关闭事件)
- EPOLLRDHUP 对端关闭事件(linux man 手册上说这个事件可以监测对端关闭,但我实际调试时发送即使对端关闭也没触发这个事件,仍然是EPOLLIN,只不过此时调用recv()/read()函数,返回值会为0,所以实际项目中是否可以通过设置这个标识来监测对端关闭,仍然待考证)
- EPOLLPRI 带外数据
muduo 里面将 epoll_wait 的超时事件设置为 1 毫秒,我的另一个项目将epoll_wait 超时时间设置为 10 毫秒。这两个数值供大家参考。
这个项目中,工作线程和主线程都是上文代码中的逻辑,主线程监听侦听socket 上的可读事件,也就是监测是否有新连接来了。主线程和每个工作线程上都存在一个 epollfd。如果新连接来了,则在主线程的 handle_io_events() 中接受新连接。产生的新连接的socket句柄挂接到哪个线程的 epollfd 上呢?这里采取的做法是 round-robin 算法,即存在一个对象 CWorkerThreadManager 记录了各个工作线程上工作状态。伪码大致如下:
void attach_new_fd(int newsocketfd) { workerthread = get_next_worker_thread(next); workerthread.attach_to_epollfd(newsocketfd); ++next; if (next > max_worker_thread_num) next = 0; }
即先从第一个工作线程的 epollfd 开始挂接新来 socket,接着累加索引,这样下次就是第二个工作线程了。如果所以超出工作线程数目,则从第一个工作重新开始。这里解决了新连接 socket “负载均衡”的问题。在实际代码中还有个需要注意的细节就是:epoll_wait 的函数中的 struct epoll_event 数量开始到底要设置多少个才合理?存在的顾虑是,多了浪费,少了不够用,我在曾经一个项目中直接用的是 4096:
const int EPOLL_MAX_EVENTS = 4096; const int dwSelectTimeout = 10000; struct epoll_event events[EPOLL_MAX_EVENTS]; int nfds = epoll_wait(m_fdEpoll, events, EPOLL_MAX_EVENTS, dwSelectTimeout / 1000);
我在陈硕的 muduo 网络库中发现作者才用了一个比较好的思路,即动态扩张数量:开始是 n 个,当发现有事件的 fd 数量已经到达 n 个后,将 struct epoll_event 数量调整成 2n 个,下次如果还不够,则变成 4n 个,以此类推,作者巧妙地利用 stl::vector 在内存中的连续性来实现了这种思路:
//初始化代码 std::vector<struct epoll_event> events_(16); //线程循环里面的代码 while (m_bExit) { int numEvents = ::epoll_wait(epollfd_, &*events_.begin(), static_cast<int>(events_.size()), 1); if (numEvents > 0) { if (static_cast<size_t>(numEvents) == events_.size()) { events_.resize(events_.size() * 2); } } }
读到这里,你可能觉得工作线程所做的工作也不过就是调用 handle_io_events()来接收网络数据,其实不然,工作线程也可以做程序业务逻辑上的一些工作。也就是在 handle_other_things() 里面。那如何将这些工作加到handle_other_things() 中去做呢?写一个队列,任务先放入队列,再让handle_other_things() 从队列中取出来做?我在该项目中也借鉴了muduo库的做法。即 handle_other_things() 中调用一系列函数指针,伪码如下:
void do_other_things() { somefunc(); } //m_functors是一个stl::vector,其中每一个元素为一个函数指针 void somefunc() { for (size_t i = 0; i < m_functors.size(); ++i) { m_functors[i](); } m_functors.clear(); }
当任务产生时,只要我们将执行任务的函数 push_back 到 m_functors 这个 stl::vector 对象中即可。但是问题来了,如果是其他线程产生的任务,两个线程同时操作 m_functors,必然要加锁,这也会影响效率。muduo 是这样做的:
void add_task(const Functor& cb) { std::unique_lock<std::mutex> lock(mutex_); m_functors.push_back(cb); } void do_task() { std::vector<Functor> functors; { std::unique_lock<std::mutex> lock(mutex_); functors.swap(m_functors); } for (size_t i = 0; i < functors.size(); ++i) { functors[i](); } }
看到没有,利用一个栈变量 functors 将 m_functors 中的任务函数指针倒换(swap)过来了,这样大大减小了对 m_functors 操作时的加锁粒度。前后变化:变化前,相当于原来 A 给 B 多少东西,B 消耗多少,A 给的时候,B 不能消耗;B 消耗的时候A不能给。现在变成A将东西放到篮子里面去,B 从篮子里面拿,B 如果拿去一部分后,只有消耗完了才会来拿,或者 A 通知 B 去篮子里面拿,而 B 忙碌时,A 是不会通知 B 来拿,这个时候 A 只管将东西放在篮子里面就可以了。
bool bBusy = false; void add_task(const Functor& cb) { std::unique_lock<std::mutex> lock(mutex_); m_functors_.push_back(cb); //B不忙碌时只管往篮子里面加,不要通知B if (!bBusy) { wakeup_to_do_task(); } } void do_task() { bBusy = true; std::vector<Functor> functors; { std::unique_lock<std::mutex> lock(mutex_); functors.swap(pendingFunctors_); } for (size_t i = 0; i < functors.size(); ++i) { functors[i](); } bBusy = false; }
看,多巧妙的做法!
因为每个工作线程都存在一个 m_functors,现在问题来了,如何将产生的任务均衡地分配给每个工作线程。这个做法类似上文中如何将新连接的 socket 句柄挂载到工作线程的 epollfd 上,也是 round-robin 算法。上文已经描述,此处不再赘述。
还有种情况,就是希望任务产生时,工作线程能够立马执行这些任务,而不是等 epoll_wait 超时返回之后。这个时候的做法,就是使用一些技巧唤醒epoll_wait,Linux 系统可以使用 socketpair 或 timerevent、eventfd 等技巧.
上文中留下三个问题:
- 数据库线程任务队列A中的任务从何而来,目前只有消费者,没有生产者,那么生产者是谁?
- 数据库线程任务队列B中的任务将去何方,目前只有生产者没有消费者。
- 业务层的数据如何发送出去?
问题 1 的答案是:业务层产生任务可能会交给数据库任务队列A,这里的业务层代码可能就是工作线程中 do_other_things() 函数执行体中的调用。至于交给这个 9 个数据库线程的哪一个的任务队列,同样采用了 round-robin 算法。所以就存在一个对象 CDbThreadManager 来管理这九个数据库线程。下面的伪码是向数据库工作线程中加入任务:
bool CDbThreadManager::AddTask(IMysqlTask* poTask ) { if (m_index >= m_dwThreadsCount) { m_index = 0; } return m_aoMysqlThreads[m_index++].AddTask(poTask); }
同理问题 2 中的消费者也可能就是 do_other_things() 函数执行体中的调用。
现在来说问题 3,业务层的数据产生后,经过 TcpSession 装包后,需要发送的话,产生任务丢给工作线程的 do_other_things(),然后在相关的 Channel 里面发送,因为没有监测该 socket 上的可写事件,所以该数据可能调用 send() 或者 write() 时会阻塞,没关系,sleep() 一会儿,继续发送,一直尝试,到数据发出去。伪码如下:
bool Channel::Send() { int offset = 0; while (true) { int n = ::send(socketfd, buf + offset, length - offset); if (n == -1) { if (errno == EWOULDBLOCK) { ::sleep(100); continue; } } //对方关闭了socket,这端建议也关闭 else if (n == 0) { close(socketfd); return false; } offset += n; if (offset >= length) break; } return true; }
-
一、 操作系统提供的网络接口
为了能更好的排查网络通信问题,我们需要熟悉操作系统提供的以下网络接口函数,列表如下:
接口函数名称 接口函数描述 接口函数签名 socket 创建套接字 int socket(int domain, int type, int protocol); connect 连接一个服务器地址 int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); send 发送数据 ssize_t send(int sockfd, const void *buf, size_t len, int flags); recv 收取数据 ssize_t recv(int sockfd, void *buf, size_t len, int flags); accept 接收连接 int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags); shutdown 关闭收发链路 int shutdown(int sockfd, int how); close 关闭套接字 int close(int fd); setsockopt 设置套接字选项 int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen); 注意:这里以berkeley提供的标准为例,不包括特定操作系统上特有的接口函数(如Windows平台的WSASend,linux的accept4),也不包括实际与网络数据来往不相关的函数(如select、linux的epoll),这里只讨论与tcp相关的接口函数,像与udp相关的函数sendto/recvfrom等函数与此类似。
下面讨论一下以上函数的一些使用注意事项:
-
以上函数如果调用出错后,返回值均为-1;但是返回值是-1,不一定代表出错,这还得根据对应的套接字模式(阻塞与非阻塞模式)。
-
默认使用的socket函数创建的套接字是阻塞模式的,可以调用相关接口函数将其设置为非阻塞模式(Windows平台可以使用ioctlsocket函数,linux平台可以使用fcntl函数,具体设置方法可以参考这里。)。阻塞模式和非阻塞模式的套接字,对服务器的连接服务器和网络数据的收发行为影响很大。详情如下:
-
阻塞模式下,connect函数如果不能立刻连上服务器,会导致执行流阻塞在那里一会儿,直到connect连接成功或失败或网络超时;而非阻塞模式下,无论是否连接成功connect将立即返回,此时如果未连接成功,返回值将是-1,错误码是EINPROGRESS,表示连接操作仍然在进行中。Linux平台后续可以通过使用select/poll等函数检测该socket是否可写来判断连接是否成功。
-
阻塞套接字模式下,send函数如果由于对端tcp窗口太小,不足以将全部数据发送出去,将阻塞执行流,直到出错或超时或者全部发送出去为止;同理recv函数如果当前协议栈系统缓冲区中无数据可读,也会阻塞执行流,直到出错或者超时或者读取到数据。send和recv函数的超时时间可以参考下文关于常用socket选项的介绍。
-
非阻塞套接字模式下,如果由于对端tcp窗口太小,不足以将数据发出去,它将立刻返回,不会阻塞执行流,此时返回值为-1,错误码是EAGAIN或EWOULDBLOCK,表示当前数据发不出去,希望你下次再试。但是返回值如果是-1,也可能是真正的出错了,也可能得到错误码EINTR,表示被linux信号中断了,这点需要注意一下。recv函数与send函数情形一样。
-
-
send函数虽然名称叫“send”,但是其并不是将数据发送到网络上去,只是将数据从应用层缓冲区中拷贝到协议栈内核缓冲区中,具体什么时候发送到网络上去,与协议栈本身行为有关系(socket选项nagle算法与这个有关系,下文介绍常见套接字选项时会介绍),这点需要特别注意,所以即使send函数返回一个大于0的值n,也不能表明已经有n个字节发送到网络上去了。同样的道理,recv函数也不是从网络上收取数据,只是从协议栈内核缓冲区拷贝数据至应用层缓冲区,并不是真正地从网络上收数据,所以,调用recv时,操作系统的协议栈已经将数据从网络上收到自己的内核缓冲区中了,recv仅仅是一次数据拷贝操作而已。
-
由于套接字实现是收发全双工的,收和发通道相互独立,不会相互影响,shutdown函数是用来选择关闭socket收发通道中某一路(当然,也可以两路都关闭),其how参数取值一般有三个:SHUT_RD/SHUT_WR/SHUT_RDWR,SHUT_RD表示关闭收消息链路,即该套接字不能再收取数据,同理SHUT_WR表示关闭套接字发消息链路,但是这里有个问题,有时候我们需要等待缓冲区中数据发送完后再关闭连接怎么办?这里就要用到套接字选项LINGER,关于这个选项请参考下文常见的套接字选项介绍。最后,SHUT_RDWR同时关闭收消息链路和发消息链路。通过上面的分析,我们得出结论,shutdown函数并不会要求操作系统底层回收套接字等资源,真正会回收资源是close函数,这个函数会要求操作系统回收相关套接字资源,并释放对ip地址与端口号二元组的占用,但是由于tcp四次挥手最后一个阶段有个TIME_WAIT状态(关于这个状态下文介绍tcp三次握手和四次回收时会详细介绍),导致与该socket相关的端口号资源不会被立即释放,有时候为了达到释放端口用来复用,我们会设置套接字选项SOL_REUSEPORT(关于这个选项,下文会介绍)。综合起来,我们关闭一个套接字,一般会先调用shutdown函数再调用close函数,这就是所谓的优雅关闭:
- 常见的套接字选项
严格意义上说套接字选项是有不同层级的(level),如socket级别、TCP级别、IP级别,这里我们不区分具体的级别。
- SO_SNDTIMEO与SO_RCVTIMEO
这两个选项用于设置阻塞模式下套接字,SO_SNDTIMEO用于在send数据由于对端tcp窗口太小,发不出去而最大的阻塞时长;SO_RCVTIMEO用于recv函数因接受缓冲区无数据而阻塞的最大阻塞时长。如果你需要获取它们的默认值,请使用getsockopt函数。
- TCP_NODELAY
操作系统底层协议栈默认有这样一个机制,为了减少网络通信次数,会将send等函数提交给tcp协议栈的多个小的数据包合并成一个大的数据包,最后再一次性发出去,也就是说,如果你调用send函数往内核协议栈缓冲区拷贝了一个数据,这个数据也许不会马上发到网络上去,而是要等到协议栈缓冲区积累到一定量的数据后才会一次性发出去,我们把这种机制叫做nagle算法。默认打开了这个机制,有时候我们希望关闭这种机制,让send的数据能够立刻发出去,我们可以选择关闭这个算法,这就可以通过设置套接字选项TCP_NODELAY,即关闭nagle算法。
- SO_LINGER
linger这个单词本身的意思,是“暂停、逗留”。这个选项的用处是用于解决,当需要关闭套接字时,协议栈发送缓冲区中尚有未发送出去的数据,等待这些数据发完的最长等待时间。
- SO_REUSEADDR/SO_REUSEPORT
一个端口,尤其是作为服务器端端口在四次挥手的最后一步,有一个为TIME_WAIT的状态,这个状态一般持续2MSL(MSL,maximum segment life, 最大生存周期,RFC上建议是2分钟)。这个状态存在原因如下:1. 保证发出去的ack能被送达(超时会重发ack)2. 让迟来的报文有足够的时间被丢弃,反过来说,如果不存在这个状态,那么可以立刻复用这个地址和端口号,那么可能会收到老的连接迟来的数据,这显然是不好的。为了立即回收复用端口号,我们可以通过开启套接字SO_REUSEADDR/SO_REUSEPORT。
- SO_KEEPALIVE
默认情况下,当一个连接长时间没有数据来往,会被系统防火墙之类的服务关闭。为了避免这种现象,尤其是一些需要长连接的应用场景下,我们需要使用心跳包机制,即定时从两端定时发一点数据,这种行为叫做“保活”。而tcp协议栈本身也提供了这种机制,那就是设置套接字SO_KEEPALIVE选项,开启这个选项后,tcp协议栈会定时发送心跳包探针,但是这个默认时间比较长(2个小时),我们可以继续通过相关选项改变这个默认值。
二、常用的网络故障排查工具
- ping
ping命令可用于测试网络是否连通。
-
telnet
命令使用格式:
telnet ip或域名 port
例如:
telnet 120.55.94.78 8888
telnet www.baidu.com 80
结合ping和telnet命令我们就可以判断一个服务器地址上的某个端口号是否可以对外提供服务。
由于我们使用的开发机器以windows居多,默认情况下,windows系统的telnet命令是没有打开的,我们可以在【控制面板】- 【程序】- 【程序和功能】- 【打开或关闭Windows功能】中打开telnet功能。
-
host命令 host 命令可以解析域名得到对应的ip地址。例如,我们要得到www.baidu.com这个域名的ip地址,可以输入:host www.baidu.com
-
netstat命令
常见的选项有:
-a (all)显示所有选项,netstat默认不显示LISTEN相关
-t (tcp)仅显示tcp相关选项
-u (udp)仅显示udp相关选项
-n 拒绝显示别名,能显示数字的全部转化成数字。(重要)
-l 仅列出有在 Listen (监听) 的服務状态
-p 显示建立相关链接的程序名(macOS中表示协议 -p protocol)
-r 显示路由信息,路由表
-e 显示扩展信息,例如uid等
-s 按各个协议进行统计 (重要)
-c 每隔一个固定时间,执行该netstat命令。
-
lsof命令
lsof,即list opened filedescriptor,即列出当前操作系统中打开的所有文件描述符,socket也是一种file descriptor,常见的选项是:
-i 列出系统打开的socket fd
-P 不要显示端口号别名
-n 不要显示ip地址别名(如localhost会用127.0.0.1来代替)
+c w 程序列名称最大可以显示到w个字符。
常见的选项组合为lsof –i –Pn:
可以看到列出了当前侦听的socket,和连接socket的tcp状态。
-
pstack
严格意义上来说,这个不算网络排查故障和调试命令,但是我们可以利用这个命令来查看某个进程的线程数量和线程调用堆栈是否运行正常。指令使用格式:
pstack pid
即,pstack 进程号,如:
7.nc命令
即netcat命令,这个工具在排查网络故障时非常有用,因而被业绩称为网络界的“瑞士军刀”。常见的用法如下:
-
模拟服务器端在指定ip地址和端口号上侦听
nc –l 0.0.0.0 8888
-
模拟客户端连接到指定ip地址和端口号
nc 0.0.0.0 8888
我们知道客户端连接服务器一般都是操作系统随机分配一个可用的端口号连接到服务器上去,这个指令甚至可以指定使用哪个端口号连接,如:
nc –p 12345 127.0.0.1 8888
客户端使用端口12345去连接服务器127.0.0.1::8888。
-
使用nc命令发消息和发文件
客户端
服务器
- tcpdump
这个是linux系统自带的抓包工具,功能非常强大,默认需要开启root权限才能使用。
其常见的选项有:
-i 指定网卡
-X –XX 打印十六进制的网络数据包
-n –nn 不显示ip地址和端口的别名
-S 以绝对值显示包的ISN号(包序列号)
常用的过滤条件有如下形式:
tcpdump –i any ‘port 8888’
tcpdump –i any ‘tcp port 8888’
tcpdump –i any ‘tcp src port 8888’
tcpdump –i any ‘tcp src port 8888 and udp dst port 9999’
tcpdump -i any 'src host 127.0.0.1 and tcp src port 12345' -XX -nn -vv
-
-
高并发的接口/系统有一个共同的特性,那就是”快”。 在系统其它条件既定的情况下,系统处理请求越快,用户得到反馈的时间就越短,单位时间内服务器能够处理请求的数量就会越多。所以”快”几乎可以算是高并发系统的要满足的必要条件,要评估一个系统性能如何,某次优化是否提高系统的容量,”快”是一个很直观的衡量标准。
那么,如何才能做得快呢?有两个需要注意的原则
- 做得少,一方面是指在功能特性上有所为,有所不为,另一方面是指一次处理的信息量要少。
- 做得巧,根据业务自身的特点,选择合理的业务实现方式,选择合理的缓存类型和缓存调用时机。
世界上最快的程序,是什么都不做的程序。 一个接口负责的功能越少,读取信息量越少,速度越快。
对于一个需要承受高并发的接口,在功能上,尽量不涉及一些难以缓存和预热的数据。 一个典型的例子,用户维度个性化的数据,用户和用户的信息不同,userId数量又很多,即使加上缓存,缓存命中率依然很低,压力还是会打到数据库,不光接口快不了,高并发的sql也会给数据库带来风险。
举一个例子,在点评电影早期的秒杀活动页上,展示了一个用户当前秒杀资格的信息,由于不同用户抢到秒杀资格的时间、优惠不同,每次都需要读数据库的来取,也就是每个用户进入主页都会产生一条sql。 还有一个例子,一般电商搞大促的时候,比如同时有多个优惠活动可以降低商品的价格,而一般只展示最低价的优惠,同时用户一个优惠只能参与一次,这样不同用户参与了不同活动之后可以享受的最低价就会随之改变,如果要在商品页面上展示这个动态价格,就免不了取到各个用户参加这些在线优惠的信息。
如果遇到这样的数据,要怎么解决呢? 一个办法是尝试转移数据的维度:刚才说的秒杀活动资格信息,如果以用户userId为key,会出现缓存命中率低,仍要sql读的情况,但是能够秒到的用户数量其实很少,所以如果以这次秒杀活动id为key,存储一个成功秒到用户的userid的list,就能够解决缓存命中率低的问题。
还有一个办法是可以把这些需要个性化数据的功能在业务流程上后移,流量漏斗,越往后流量越少,创建订单级的sql查询是可接受的。 刚才说的第二个例子,商品最优惠的价格,可以排除用户相关信息,只在商品列表/详情上展示只和优惠相关的最低价,而在提交订单的时候才真正去取用户参加活动情况,如果用户已经参加过给出提示并选择次优的优惠。商品的列表/详情页都在用户路径上相对靠前的位置,排除了用户个性化信息可以让商品列表/详情更容易缓存,响应速度更快,系统可承受的高并发量更高。
我们写业务代码的时候都有对应的业务对象,它们都存在一定的业务范围之内,比如类目、地区、日期等自身相关的维度。 一个系统中的业务对象,在多个维度的细分下,对应的量并不多,但如果一次全部都展示在一个页面/接口下,即使覆盖上了缓存,也会由于缓存占用空间过大或者缓存key数目过多、网络传输耗时、对象序列化反序列耗时等拖慢接口/页面响应速度。一般只要看一下这个页面/接口给出的业务对象的数量级,就能大致知道这个接口的性能了。
大家在做设计的时候,一般会估算一个接口的量级,如果一看就有几千几万个业务对象,就不会这样设计了,但是需要警惕的是业务对象数量级可变的情况,比如随着业务发展数量会快速增长,或者某些特殊维度下业务对象特别多。设计的时候要按照预估的最大量级来,并且对接口/页面做出数量的限制,如果发现当前返回的业务对象过多,可以继续根据业务维度来拆分,分次分批来处理。
举一个例子,比如一个影院下所有的活动场次,开始的时候一家影院下的场次有限,几十一百场,很好展示,后来随着业务发展,一个影院下各个影院下场次数到了几百一千,一次全部拿完,在高并发时,memcached缓存的multi get会出现很多超时,请求会打到mysql数据库,给系统很大压力。之后我们做了改造项目,每次根据用户的交互按照影片、日期、影院的维度来分批取,一次只有十几个场次,接口响应变快了,服务的压力也小的多。
平时涉及到的业务,总有属于它的特性,比如实时性要求多高,数据一致性要求多高,涉及什么维度的数据,量有多大等等,我们要根据这些特性来选择实现的方案,比如一些统计数据,如某类目下所有商品的最低价,按照逻辑需要遍历商品来获取,但这样每次实时读取所有的对象,涉及读取缓存数据库操作,接口会很耗时,但如果选择作业离线计算,把计算结果写表,加上缓存,搜索直接读取,显然会快很多了。
涉及到业务各阶段特性的例子就是秒杀系统,在第二部分秒杀实践中我会详细介绍。
除了业务特性方面,缓存是业务对抗高并发非常重要的一个环节,合理选择缓存的类型和调用缓存的时机非常重要。
我们知道内存运算速度快于远程连接,所以存储上来说效率如下 内存 <= ehcache < redis <= memcached < mysql 可以看出,尽量少的远程连接,常规覆盖数据库访问的缓存,都能提高程序的性能。
要根据不同缓存的特性和原理,才能根据业务选出最合适的,来看看几种常用的缓存
- varnish,可以作为反向代理,缓存一些资源,例如可以把struts,freemarker动态生成的页面存储起来,达到直接挡掉到达web服务器的请求。
- ehcache,主要存储在当前机器内存中,存取非常快,缺点是内存有限,各台机器内存中各存一份,失效时间不一致,数据就会出现不一致,一般用来缓存不常变化,且缓存个数较少的数据。
- memcached缓存,kv分布式缓存集群,可扩展性好,可以存储个数较多的缓存对象,也可以承接高流量的访问,读取缓存时远程连接,一般耗时也在零点几到几ms不等。
- redis,nosql,是内存的kv存储,可以做为缓存使用,也可以持久化,它的性能和memcached相近。而redis最大的特点是一个data-structure store,这时redis官网首页介绍redis的第一句话,它可以保存list,hash,set,sorted set等数据结构,使用时和memcached区别是,它不用将数据取到客户端再做逻辑判断,而是可以直接在redis服务器上完成操作,比如查看某个元素是不是一个范围内,队列的长度有多长等。redis可以用来做分布式服务器的进程间的通信,比如我们经常有需要分布式锁的场景,控制同一个用户发券的并发等。
根据业务需要选择了合适类型的缓存后,还要合理去使用。 虽然说缓存是为了抵挡数据库的流量而生,本身性能非常强大,但仍然是受到缓存服务器性能甚至服务器网卡流量的限制的,不合理的使用比如单个key对应的缓存对象过大、一次读取中缓存key数量过多、短时间内频繁更新缓存等都是系统的隐患、并发越高时就越能体现。
秒杀业务的典型特点有:
-
瞬时流量大
-
参与用户多,可秒杀商品数量少
-
请求读多写少
-
秒杀状态转换实时性要求高
一次秒杀的流程可以分为三个阶段:
- 活动未开始 活动开始前,用户进入活动页,这个阶段有两种请求,一种是加载活动页信息,一个是查询活动状态得到未开始的结果, 一个用户进入页面两个请求各发起一次,这两种请求占比各半。
- 活动进行中 这个阶段持续时间非常短,看到抢购按钮的用户大量发起秒杀请求,瞬时秒杀请求占比增高,能不能抗住秒杀请求就是秒杀系统是否能抗住高并发的关键。
- 活动结束 当商品被抢购完,进入结束状态,请求情况同活动开始前
各阶段流量图 其实贯穿整个活动的只有三种请求,加载活动页请求,读取活动状态请求,秒杀请求
主要是展示活动相关配置信息,活动背景图片,优惠力度,活动规则等相对静态的内容,通过web项目渲染成页面。
对于这样的请求,我们可以使用varnish反向代理,以页面相关的参数比如本次秒杀的活动ID和城市ID的hash为key把整个页面缓存在varnish机器上,而秒杀活动的状态等动态信息通过ajax来刷新。 varnish作用机制
达到的效果是活动期间,加载页面请求都会打到varnish机器直接返回,而不会给web和service带来任何压力。
秒杀状态就三种,未开始,可抢,已抢完,由两个因素共同决定
- 活动开始时间
- 剩余库存
读取秒杀状态的请求数并发也是非常高的,对于这个接口也要加上合适的缓存来处理。 对于活动开始时间,是一个较固定且不会发生变化的属性,并且,同时在线的秒杀活动数目并不多,所以把它也作为discount相关的信息,选择用响应快的ehcache来缓存。
对于库存,剩余库存个数,一般来说是全局需要一致的,可以用memcached来缓存,在秒杀的过程中,库存变化的非常快,如果直接对库存个数进行缓存,那么秒杀期间就需要频繁的更新缓存,像之前说的,虽然缓存是用来扛并发的,但要调用缓存的时机也要合理,memcached处理的并发请求越少,相对成功率就会越高。 其实对于秒杀活动来说,当时的剩余库存数在秒杀期间变化非常快,某个时间点上的库存个数并没有太大的意义,而用户更关心的是 能不能抢,true or false。如果缓存true or false的话,这个值在秒杀期间是相对稳定的,只需要在库存耗尽的时候更新一次,而且为了防止这一次的更新失败,可以重复更新,利用memcached的cas操作,最后memcached也只会真正执行一次set写操作。 因为秒杀期间查询活动状态的请求都打在memcached上,减少写的频率可以明显减轻memcached的负担。
其实活动状态除了活动时间和库存之外,还有第三个因素来决定,下面说到秒杀请求的优化时会详细来说
秒杀请求是一个秒杀系统能不能抗住高并发的关键 因为秒杀请求和之前两个请求不同,它是写请求,不能缓存,而且是活动峰值的主力。
一个用户从发出秒杀请求到成功秒杀简单地说需要两个步骤: 1. 扣库存 2. 发送秒杀商品 这是至少两条数据库操作,而且扣库存的这一步,在mysql的innodb引擎行锁机制下,update的sql到了数据库就开始排队,期间数据库连接是被占用的,当请求足够多时就会造成数据库的拥堵。 可以看出,秒杀请求接口是一个耗时相对长的接口,而且并发越高耗时越长,所以首先,一定要限制能够真正进行秒杀的人数。
上面说了,秒杀业务的一个特点是参与人数多,但是可供秒杀的商品少,也就是说只有极少部分的用户最终能够秒杀成功 比如有2500个名额,理论上来说先发送请求的2500个用户能够秒杀成功,这2500个用户扣库存的sql在数据库排队的时候,库存还没有消耗完,比如2500个请求,全部排队更新完是需要时间的,就比如说0.5s 在这个时间内,用户会看到当前仍然是可抢状态,所以这段时间内持续会有秒杀请求进入,秒杀的高峰期,0.5秒也有几万的请求,让几万条sql来竞争是没有意义的,所以要限制这些参与到扣库存这一步的人数。
可抢状态需要第三个因素来决定,那就是当前秒杀的排队人数。 加在判断库存剩余之前,挡上一层排队人数的校验, 即有库存 并且 排队人数 < 限制请求数 = 可抢,有库存 并且 排队人数 >= 限制请求数 = 抢完
比如2500个名额秒杀名额,目标放过去3000个秒杀请求
那么排队人数记在哪里? 这个可以有所选择,如果只记请求个数,可以用memcached的计数,一个用户进入秒杀流程increase一次,判断库存之前先判断队列长度,这样就限制了可参与秒杀的用户数量。
发起秒杀先去问排队队列是不是已满,满了直接秒杀失败,同时可以去更新之前缓存了是否可抢 true or false的缓存,直接把前台可抢的状态变为不可抢。没满继续查询库存等后续流程,开始扣库存的时候,把当前用户id入队。 这样,就限制了真正进入秒杀的人数。
这种方法,可能会有一个问题,既然限制了请求数,那就必须要保证放过去的用户能够秒完商品,假设有重复提交的用户,如果重复提交的量大,比如放过去的请求中有一半都是重复提交,就会造成最后没秒完的情况,怎么屏蔽重复用户呢? 就要有个地方来记参与的用户id,可以使用redis的set结构来保存,这个时候set的size代表当前排队的用户数,扣库存之前add当前用户id到set,根据add是否成功的结果,来判断是否继续处理请求。
最终,把实际上几万个参与数据库操作的用户从减少到秒杀商品的级别,这是一个数据库可控制的范围,即使参与的用户再多,实际上也只处理了秒杀商品数量级的请求。
1.分库存 一般这样做就已经能够满足常规秒杀的需求了,但有一个问题依然没有解决,那就是加锁扣库存依然很慢 假设的活动秒杀的商品量能够再上一个量级,像小米卖个手机,一次有几W到几十万的时候,数据库也是扛不住这个量的,可以先把库存数放在redis上,然而单一库存加锁排队依然存在,库存这个热点数据会成为扣库存的瓶颈。
一个解决的办法是 分库存,比如总共有50000个秒杀名额,可以分50份,放在redis上的50个不同的key,那么每份上1000个库存,用户进入秒杀流程后随机到其中一个库存来修改,这样有50个库存数来竞争,缩短请求的排队时间。
这样专门为高并发设计的系统最大的敌人 是低流量,在大部分库存都好近,而有几个剩余库存时, 用户会看到明明还能抢却总是抢不到,而在高并发下,用户根本就觉察不到。
2.异步消息 如果有必要继续优化,就是扣库存和发货这两个费时的流程,可以改为异步,得到秒杀结果后通过短信/push异步通知用户。 主要是利用消息系统削峰填谷的特性 来增加系统的容量。
流量图 先用varnish挡掉了所有的读取状态请求 然后用ehcache缓存活动时间,挡掉活动未开始时查询活动状态的请求 memcached缓存是否可抢的状态,挡掉活动开始后到结束状态的活动查询请求 redis队列挡掉了活动进行中,过量的秒杀请求 到最后只留下了秒杀商品数量级的请求到数据库中。
-
服务治理主要针对于当前分布式架构下多服务、微服务等。它贯穿服务的整体生命周期,设计期、开发期,和运行期。
服务治理的一个核心话题是 “变更”。它就涉及非常多样化的情况。包括:
- 软硬件的升级;
- 配置项调整;
- 数据库表结构的调整;
- 增加 / 减少机器;
- 入口域名、IP 的调整;……
到了运行期,服务治理重点关注:
- 健康状况,包括:日志、监控与报警
- 故障处理,包括:故障域与故障预案、故障排查与根因分析、过载保护与容量规划
-
系统做分层的好处:
- 有利于分工,让人们专注于所负责层次。
- 能提高系统复用度。
- 便于横向拓展。任意一层可单独拓展。
常见分层结构有:
- 请求处理层(web)
- 业务逻辑层(service)
- 数据持久层(DAO)
在service和DAO之间还可以插入通用业务处理层(Manager层)。主要有两个作用,
- 将原先 Service 层的一些通用能力下沉到这里,比如与缓存交互策略,中间件的接入
- 可以在这一层封装对第三方接口的调用,比如调用支付服务,调用审核服务等。
-
一般而言,业务系统对ID有如下要求:
-
全局唯一,不多解释
-
保持递增
,主要是
- a) 这个id很常用于db主键,多数RDBMS使用B-tree数据结构来存储索引,主键的有序能保证写入性能
- b) 排序、版本号等某些业务上场景也要求有序
-
信息安全,不应连续,以避免直接按顺序拼出url进行恶意下载,或者通过订单号直接估算敏感商业数据。
带着上述诉求,看看几个方案:
标准型式包含32个16进制数字,以连字号分为五段。
- 优点:性能好,本地生成,可靠。
- 缺点:不易存储,太长;信息不安全;无序不宜做主键。
把64bit分别划分成多段,分开来标示机器、时间等。41-bit的时间可以表示(1L<<41)/(1000L360024*365)=69年的时间,10-bit机器可以分别表示1024台机器。如果我们对IDC划分有需求,还可以将10-bit分5-bit给IDC,分5-bit给工作机器。
- 优点:毫秒数在高位,自增序列在低位,整个ID都是趋势递增的。可在客户端独立生成,可靠,也可以服务的方式部署,性能也是非常高的。可以根据自身业务特性分配bit位,非常灵活。
- 缺点:强依赖机器时钟,如果机器上时钟回拨,会导致发号重复。
如果TPS不高,比如说发号器每毫秒只发一个 ID,就会造成生成ID的末位永远是 1,可能导致ID的哈希结果不均匀。解决办法主要有两个:
- 时间戳不记录毫秒而是记录秒
- 序列号的起始号可以加个随机量
此外,如何保证工作机器编号不重复也需要一点额外工作,比引入ZooKeeper等分布式一致性组件来保证每次机器重启时都能获得唯一的机器 ID。
做如下设计:字段max_id表示该业务biz_tag目前所被分配的ID号段的最大值,step表示每次分配的号段长度。原来获取ID每次都需要写数据库。那么只有当step个号被消耗完了之后才会去重新读写一次数据库。
Begin UPDATE table SET max_id=max_id+step WHERE biz_tag=xxx SELECT tag, max_id, step FROM table WHERE biz_tag=xxx Commit
优点:
- 可以很方便的线性扩展,性能能够支撑大多数业务场景。
- ID递增。
- 比起单纯依赖db自增主键每次都请求,对db的压力小很多而且可控,即使短时间db宕机,依然能正常工作。
缺点:
- ID号码不够随机,能够泄露发号数量的信息,不太安全。
- 严苛环境下,性能不够,P999数据波动大,当号段使用完之后还是会hang在更新数据库的I/O上。
- DB宕机会造成整个系统不可用。
-
-
取决于场景,有以下常见策略:
- 旁路缓存(cache aside)
- 读穿/写穿(read/write through)
- 写回(write back)
读步骤:
- 从缓存中读取数据,如果缓存命中,则直接返回数据;
- 如果缓存不命中,则从数据库中查询数据;
- 查询到数据后,将数据写入到缓存中,并且返回。
写步骤:
- 更新数据库中的记录;
- 删除缓存记录。
这是最常用的缓存策略,但它存在明显问题:
- 高流量下,如果缓存miss或删除,引发短时间大量请求穿透到数据库。
- 高并发下存在数据不一致问题。比如,如果a和b请求都试图写缓存,a请求读到v1版数据,中途有c请求更新了数据,使得b请求读到v2版数据,但b请求先写到缓存,a请求后写到缓存,导致缓存数据是过期的v1版。
潜在的解决方案有:
- 加分布式锁,保证更新数据库和写缓存的原子性。
- 线性写缓存,比如订阅binlog通过独立线程写缓存。
用户只与缓存打交道,由缓存和数据库通信,写入或者读取数据。
读操作:
- 查询缓存,存在则直接返回;
- 不存在,由缓存从数据库中同步加载 写操作:
- 先查缓存,如果存在,则更新缓存,由缓存组件同步更新到数据库中
- 如果缓存中数据不存在,可有两种做法:
- 写入缓存相应位置,再由缓存组件同步更新到数据库,或:
- 不写入缓存中,直接更新到数据库中
应用案例如,Guava Cache中的 Loading Cache 就使用了类似Read Through策略
写入数据时只写入缓存,并且把对应缓存块标记为“脏”,脏数据只有当必要时才会写到存储中。
常用于计算机内部,如文件系统缓存,CPU的L1缓存等。
-
明确缓存穿透的含义:缓存中没有查到数据,而不得不从存储中查询。
一定比率的穿透再所难免,但大量的穿透可能超过后端系统的承受范围,引发故障。
-tags: 架构
-解答: 一般情况下,缓存的失效时间加个随机量,减少同一时刻过期的概率。
特别要避免的穿透场景是查询的数据在存储中并不存在,导致每次查询都被穿透。
大体有两种方案:
- 回种空值 在缓存空间允许的情况下,即使后端没有数据,也在缓存中设置一个空值或特殊的标记,下次查询在缓存中到这个标记时不再穿透。
- 使用bloom filter 后端存储新增数据时,将对应key插入bloom filter。查询时先查询bloom filter,如果没有,直接返回;如果有,再去查询缓存,进而后端存储。bloom filter的特点是有一定概率发生false positive,即告诉你存在,其实不一定存在,但告诉你不存在,则一定不存在。这样,不存在的数据会直接被挡掉,不会请求到缓存和后端。
-
两种方案。第一种级联同步方案,做法简单,但需要暂停业务。第二种双写方案,可做到在线迁移。
- 先将新库配置为旧库的从库,进行数据同步;
- 再将一个备库配置为新库的从库,用作数据的备份;
- 等到三个库的写入一致后,将数据库的读流量切换到新库;
- 选择业务低峰期,暂停应用的写入,将业务的写入流量切换到新库。
- 将新的库配置为源库的从库,用来同步数据;如果数据要同步到多库多表,可以订阅Binlog,解析增量日志之后按分库分表的逻辑写入到新的库表中。
- 改造业务代码,上线开关:“同时写老库和新库”,断开新老库的同步机制的同时打开该开关。切换瞬间有小概率数据丢失,可忽略或手动补充。此外,写新库以也可异步化,失败的记日志事后补。
- 校验新老库的数据一致性。
- 读流量逐步切换到新库。由于有双写,切换的过程中出现任何的问题,都可以将读写流量随时切换到回老库。
- 观察一段时间,无误后只写新库,去除老库。
-
首先,尽量降低重复的概率。
可以为每条消息生成唯一id,消费时先比对这个id是否已经存在,如果存在,则认为消息已经被消费过;不存在,才执行消费逻辑,并记录该id。
当然,极端情况下这依然有一定概率导致重复消费。因为消费逻辑之后可能来不及记录id就异常退出了,或者还没记录就有另外的线程在消费这条重复的消息了。此时不得不引入事务机制,但一般业务并不需要这么高的一致性要求。
其次,消费逻辑里保证幂等性,比如使用乐观锁,使得即使重复消费了,最终业务效果和只消费一次是一样的。
-
- 轮询策略(Round Robin)
- Hash策略,如nginx的基于ip或url的hash
带权重的策略,如:
- Dubbo的LeastActive,优先选择活跃连接数最少的节点
- Ribbon的WeightedResponseTimeRule ,使用响应时间,给每个服务节点计算一个权重,据此选择服务节点
-
KISS:简单比复杂好。
Modularity:模块化。模块的规格,也就是模块的接口,比模块的实现机制更重要。
Orthogonal Decomposition:正交分解。鼓励用相互正交、完全没有相关性的模块,组合出我们要的业务场景。而不是通过叠加能力把一个模块改造成另一个模块(继承)。
-
Service Mesh 通过在应用程序同主机上部署一个代理程序,进行服务之间的通信。
这个代理程序称为“Sidecar(边车)”,服务之间的通信也从之前的
客户端 <--> 服务端
变成
客户端 <--> Sidecar <--> Sidecar <--> 服务端
Service Mesh的主要作用是:把业务代码和服务治理的策略隔离开,将服务治理的细节从 RPC 客户端中拆分出来,下沉为独立的基础模块。
这样,负载均衡、熔断降级、流量控制、打印分布式追踪日志等服务治理策略,就可以实现跨语言复用,还能对这些 Sidecar 做统一的管理。
-
这种提供两种方案,各有优缺点。
- 利用数据库的事务特性。在同一个本地事务中记录业务数据和消息数据。
- 依旧在业务成功后发送消息,在消息发布成功后立即删除记录的消息,这样能够保证消息投递的实时性。
- 定时从消息表中轮循,重新发布未发布成功的消息并删除之,这样能够保证消息一定能够被投递。
缺点是: 业务系统和消息耦合在一起,额外的消息操作给数据库带来压力,也成为异步消息机制的瓶颈。
- 在事务提交前,通过实时消息服务向消息系统请求发送消息,消息系统只记录并不真正发送
- 业务在提交后,向消息系统确认发送,得到确认后消息系统才真正发布消息
- 业务回滚时,向消息系统取消发送请求
- 消息系统定期找到未确认发送的消息,若一致性要求不高,可以直接过期丢弃,若要求强一致,可向业务系统查询状态,根据返回的状态决定是要发布还是取消。
-
有很多可以讲,包括不限于:
- Producer端可以通过GZIP或Snappy格式对消息集合进行压缩,减少传输的数据量(在大数据处理上,瓶颈往往体现在网络上而不是CPU)
- 消息的持久化大量使用线性写。 broker端是以消息块的形式追加消息到log中的,consumer在查询的时候也是一次查询大量的线性数据块。(7200rpm SATA RAID-5 的磁盘阵列上线性写的速度大概是600M/秒,但是随机写的速度只有100K/秒,两者相差将近6000倍。)
- 针对kafka的消息体普遍不大,但消息数量很多的特点,即存在大量小I/O操作的特点,Producer一次网络请求可以发送一个消息集合,而不是每一次只发一条消息。
- zero-copy,数据只被拷贝到页缓存一次,然后就可以在每次消费时重得利用,而不需要将数据存在内存中,然后在每次读的时候拷贝到内核空间中。通过页面缓存和sendfile的结合使用,整个kafka集群几乎都已以缓存的方式提供服务。