写点什么

一文熟知网络 – 文章巨长,但是很详细

作者:邹志全
  • 2023-07-09
    北京
  • 本文字数:16280 字

    阅读完需:约 53 分钟

一文熟知网络 – 文章巨长,但是很详细

更多可查看原文:zouzhiquan.com

0.前言

网络是一个比较有趣的事情,但是其内容确相对的枯燥,决定写这篇文章的时候,忽然想起来,网络才是我当年的本专业呀,写了这么多文章的,好像就网络没怎么说过,趁着最近对 F5 的好奇心就展开描述一下吧。

网络有趣的点在于它的落地应用,网络枯燥的事儿在于那些字典般的协议,所以本文就穿插着讲吧,少说一点原理,多一点实践;就从一个最常见的场景来描述网络:“你点了一个链接”。

1.我点了一个链接

当你开了一个链接,比如点了下http://baidu.com或者某个钓鱼邮件。

这里放一下“我点了一个链接” 全局的链路图,大家可以前置看下会涉及哪些点,可以节选阅读。

1.1 这是个链接?

首先你的浏览器得知道你要进行什么资源访问,就是要根据你的 url 地址解析出:“你要什么?” 这里的 url(资源定位符),就是一种用于找资源的协议:

protocol : // hostname[:port] / path / [;parameters][?query]#fragment

浏览器能对这段字符串做识别和解析并完成一个请求,这里最常见的就是各种 http/https 链接。但除此以外,每个 app 都会有的 URL scheme,我们也可以定义自己的私有协议,app 间开屏跳来跳去大家一定感受过吧,本质上就是触发了其他 app 的 url scheme 访问,这里有兴趣可以搜一搜“chrome 自定义 protocol” 、 “url scheme”。

除了协议相关,浏览器还会尝试对于链接进行合法性校验,保证执行的正确性。

继续说 http,检测到是 http 协议时则会尝试进行 host 的解析,极小部分是直接 ip:host 访问的,但是由于 ip(Internet Protocol,互联网定位用的协议)这东西记起来相当费劲,并且可能会有多个 ip 地址,所以就诞生了域名(Domain Name)。

要想解析域名就需要 DNS(Domain Name System)。接下来就看下这个过程是怎么发生的,是如何找到服务者的。

1.2 谁来提供服务

1.2.1 DNS 的由来

名称到 IP 的映射,最原始的方式就是本地维护一个 hosts 文件,用来记录域名到 ip 的映射。但是随着域名数量越来越大,完全依赖本地文件已经庞大难以维护,并且没有集中式的维护,名称会冲突,就算有集中的站点,这个站点也扛不了这么大的访问量。

所以,DNS 就诞生了,由 DNS 完成域名到映射的集中存储,并且建设了一个高可用的、合理组织的集群部署。

1.2.2 DNS 的架构

接下来就看下这个服务是怎么落地的:

dns 命名

首先 DNS 规范了命名,DNS 规定的命名是一种层次结构,首先是顶级域名,然后是二级域名,然后三级,以此类推。

目前已有 250 个顶级域名,二级/三级的数量基本是指数级膨胀。这些域名 ICANN 负责管理,例如http://www.baidu.com 一级域是 com、二级域是http://baidu.com、三级域是http://www.baidu.com

数据结构

站在最上层来看,最初名称和 IP 的映射,存储的数据结构就是一个大 MAP,NAME:IP 或者 NAME:List,后来随着上面所说问题的愈演愈烈,DNS 孕育而生,此时简单的 map 结构存储已经不满足需求了,后来就变成一种树形的存储结构(非本地),这颗树要尽可能的扁平。

整体来看就是一个大的索引结构,叶子结点就是真正的映射信息,至于为什么这么来设计,主要是管理效率和部署设计上的考量,单就效率和存储而言 map 没啥问题。

部署架构

单机无论是带宽、计算能力、存储极限都不可能承载全球的流量访问,而这个问题的解决方案就是 “将服务集群化,并把不同的数据按照层次及地域属性等,合理分布到各地域的服务器集群上”,简单来看就是做了一个 DNS 映射数据的分布式数据库。

解析流程


1. 客户端发起一个DNS请求,先查看本地浏览器是否有这个域名的缓存,有解析结束2. 没有则去看操作系统是否有缓存,有解析结束3. 没有则去看本地host文件,有解析结束4. 没有则发送该请求到本地DNS服务器,如果本地DNS服务器存在则返回5. 不存在本地DNS服务器则请求根域名服务器,比如请求"www.baidu.com",根域名服务器告诉本地DNS “.com” 顶级域名服务器的位置6. 本地DNS服务器收到顶级域名服务器位置后则向顶级域名服务器请求"www.baidu.com"的IP地址,顶级域名服务器收到后则告诉本地DNS服务器"www.baidu.com"的权威域名服务器地址7. 本地DNS服务器再向权威DNS服务器发起请求,权威DNS服务器查询后将对应的IP地址告诉本地DNS8. 本地DNS服务器缓存该域名与对应IP然后返回IP给客户端9. 浏览器根据TTL缓存该值
复制代码

劫持

整体的访问流程中加了 DNS 这一层,优点不言而喻,但是加这一层也带来了不少的问题,比如说可用性、安全、性能等问题,可用性、性能这些都是可以通过技术手段压到最小。

但是安全性问题是一直存在的,本机、DNS 服务都有概率被劫持,并且 DNS 劫持指向钓鱼网站,如果是单调的转发倒是还行,如果骗你输入点密码啥的就比较有趣了。

为什么是 UDP?为什么是 TCP?

接下来看下 DNS 底层的协议细节,DNS 域名解析过程的传输层协议是基于 UDP 的,DNS 服务内部通信是基于 TCP 的。

为什么这样操作呢?

详细聊聊差异

首先对于传递方式而言,TCP 是面向连接的,基于一个个的有序数据包从而构成可靠的传输;而 UDP 是基于数据报、无序的而导致不可靠的传输方式;而这些差异也从侧面说明了实现上的差异,首先 TCP 要想面向连接,就得有连接建立的过程,要想有序就要有确认机制,而这些就要求是双工的。

对于传输效率上,上面说的这些工作必然导致了 TCP 必然比 UDP 的头部开销(或者叫元数据开销)要大的多,而且准备工作更多,也就意味着对于一条消息传递,UDP 没这么多事儿,要简单得多,效率也就更高。

对于数据传输上,TCP 是面向连接的,可以切成多个数据包来传,而 UDP 面向报文的,一次性不可能传过大的信息。

对于可靠性上,TCP 基于连接和确认机制可以保证更可靠,而 UDP 对于发出去的消息是否成功毫不关注(可以应用层解决的)。

这就导致了使用场景上的差异,首先对实时性要求极高,比如游戏场景、语音场景、实时视频等,还有多点通讯场景 UDP 刚刚好,偶尔的丢失也问题不大。其他场景如果对可靠性较高,那就直接 TCP 吧。

根据场景来判定

DNS 此时最核心的问题是效率和可靠性的取舍,上面提到过 DNS 的解析过程是一个迭代查询的过程,尤其对于一些冷门域名,通常需要查询多次,才能查询到对应的权威服务器。基于这个查询过程,UDP 是基于报文广播的,相对于 TCP 面向连接的处理过程,UDP 少了握手的过程(尤其对于小数据传输场景),头字段更短,效率也就更高了。

而对于服务内部,信息同步的可靠性是非常必要的,并且内部传递的信息包长也比较大,所以这里就直接用 TCP 了,那直接用 UDP 不行吗,也行,在应用层之上补齐 ACK 机制就可以啦,很多 IM 应用就是这么做的。

当然啦,UDP 也有自己的劣势,由于某些原因(协议限制、以太网数据帧限制、UDP 发送缓冲区),最小的 MTU 是 576,而 DNS 为了不超 576,把报文长度限制到了 512,一旦超过 512 就会阶段,也就导致了报文的不完整。

针对这种情况,DNS 启用了 TCP 重试机制,就目前而言,DNS 是完整支持 TCP 和 UDP 的,不仅是降级重试使用。虽然 RFC6891 中引入了 EDNS 机制,它允许我们使用 UDP 最多传输 4096 字节的数据,但仅仅是从 UDP 的角度而言。由于 MTU 的限制,导致的传输数据分片以及丢失,这个过程是不可靠的,存在被切片和丢弃的可能。

TCP 和 UDP 的效率差异本质上是相对而言的,如果要传输的数据包越大、建立的连接越少,链接所产生的开销影响也就越小,要根据具体场景分析来看哈,就一次连接尝试分析来看:

TCP 协议(共 330 字节):

三次握手 — 14×3(Ethernet) + 20×3(IP) + 44 + 44 + 32 字节

查询协议头 — 14(Ethernet) + 20(IP) + 20(TCP) 字节

响应协议头 — 14(Ethernet) + 20(IP) + 20(TCP) 字节

UDP 协议(共 84 字节)

查询协议头 — 14(Ethernet) + 20(IP) + 8(UDP) 字节

响应协议头 — 14(Ethernet) + 20(IP) + 8(UDP) 字节

1.3 手剥笋

拿到具体的 IP 地址之后,下一步就是开始发起调用了,整体协议层次是 HTTP -> TCP -> IP -> 更底层的协议(暂时就先说到 IP 哈)。 要研究这个,过程非常像手剥笋的逆过程,先是具体请求内容,然后把请求内容包进 HTTP 请求中,再把 HTTP 请求报文包进 TCP 数据包,然后再抱进 IP 数据报,然后再往下执行包装和传输。

1.3.1 HTTP 是怎么工作的

HTTP 概要

一个 http 请求通常由 Request-Line、Header、Body 构成

Request-Line:请求方法、Request-URI、HTTP-version、CRLF 构成

Header:一堆键值对 Body:请求的业务数据

然后返回也相对类似,返回的 Status-Line(http-version、status-code、Reason-Phrase)、Header、Body 先来看一个 HTTP 请求:

curl -H “Content-Type: application/json” -H “Cookie: cc=2333” -X POST —data ‘{}’ http://localhost:8080/say-hello

HTTP 拆解

构建一个 http 请求报文,先生成一个 request-line,指定好 uri、version 等,会有部分数据存放于 URI 中,这里会进行对应的 urlEncode

进行 header 的填充,常用的比如有 keep-alive 可以让链接保活(复用下层链接);User-agent 标示一下客户端信息、Accept 表明一下要接受什么数据、Cookie 记录的端信息等、Content-Type 表明 body 编码,你也可以自定一些业务属性的 header 来封装一些共性的逻辑,避免每次都操作 Body

然后把业务数据放到 body 中,body 通常会有三种编码格式:application/json、application/x-www-form-urlencoded、multipart/form-data、raw、binary,具体的类型可以看下请求的 content type,表达方式不同而已,在使用上会有差异,但是对于网络本身都差不多,对于使用上,基于约定、做好统一就 OK 了。

然后请求按照不同的 Method 发起请求,比如最常见的 get/post,再或者依赖 HTTP 语义(get/post/put/delete 等)的 RestFull 使用方式。

至此就完成了一个 HTTP 请求,然后把报文交给下一层了,然后等 HTTP Response 就好了。

HTTP 状态码

等请求返回时,如写一般,获取对应的 header、body 信息即可,但是 Status-line 相对 Requet-Line 多了一部分状态标示。这里放一点经常会出现的错误码,比如 200、302。

1XX 标示一些中间状态码,比如100收到header、101协议切换、102 处理中等等
2XX 标示成功,200成功返回、201创建资源成功、204返回了寂寞、202处理中等等
3XX 标示重定向,常见的有301/308永久重定向(记得刚开始学编程时,发现301很多浏览器会对于post请求丢失body)、302/303 临时重定向、304 未发生变化、307临时重定向,通常用于upload场景的定向,具体重定向的实现要看浏览器,标准是标准,实现是实现
4XX 标示客户端错误,400 bad request 格式有问题、401 需要认证信息、403 无权限、404 uri资源丢失、405 不支持的方法、408 接受请求超时
5XX 标示服务端错误,500 server端异常、502 访问代理正常但是Server丢了、504 代理访问server超时、505 不支持对应的http版本
复制代码

SSL

提到 HTTP 不得不提的就是 https,https 其实就是在 http 体系之中插了一层 SSL 协议(Secure Socket Layer),SSL 是作用于传输层的,对于传输层进行加密完成应用层的安全传输。

SSL 分为记录协议和握手协议,其中记录协议建立在可靠的传输协议(如 TCP)之上,为高层协议提供数据封装、压缩、加密等基本功能的支持。而握手协议建立在 SSL 记录协议之上,用于在实际的数据传输开始前,通讯双方进行身份认证、协商加密算法、交换加密密钥等。

本质上是在传输层协议和应用层协议之间的一层处理切面,是对于传输层进行加密,进而赋能应用层。至于更高层的协议是什么,SSL 是不关注的,所以认为它是传输层的协议。

SSL 基于非对称加密,提供信息加密服务;同时利用机密共享和 hash 策略提供完整性校验;并且 SSL 是双向认证的,握手时交换各自的识别号,避免冒名。

1.3.2 TCP 是怎么工作的

拿到 http 等应用层报文之后,下一步是将这些报文放到 TCP 数据包中。数据包的组装还原都是由操作系统完成的,应用程序并不感知具体的 TCP 细节,操作系统负责 TCP 数据的完整性,对应的 TCP 中的具体数据完全由应用程序感知,操作系统毫不关心。

TCP 是面向连接的,并且 TCP 是可靠的数据传输,核心原因是 TCP 的连接机制和 ACK 校验机制。 记住:when in doubt,use TCP。

TCP 包格式

大体看一下 TCP 的包结构,不用刻意去记,碰到具体场景,就知道是做什么的了

链接建立

首先是三次握手 建立连接,发送端一个 seq=x,然后接受端收到之后,会应答一个 SYN=1,ack=1,并回执一个 seq = y,ack=x+1,然后发送端应答一个 SYN=0, ack=1,并回执一个 seq=y+1,ack=y+1 这个握手过程是为了校验两端的发送/接受状态是否 ok,如果校验通过则完成连接建立,3 次数字是一个刚刚好的值,多了冗余,少了不够用。

尝试翻译一下,先是发送端发送没问题,然后接收端接受没问题,回执告知接收没问题,验证接受端发送没问题,然后回执告知接收端发动端接收没问题,至此两端就知道了双方都没问题,开启传送。

可靠性与效率的兼顾:ACK & 慢重启

网络传输当然是越快越好,发送、传输、接受都是有限制的,并且硬件不完全是可靠的,过热、缓冲溢出,如果拥堵程度过高或者硬件不稳定时,很有可能发生丢包。

发的越快,丢的越多,重试的也就越多,效率反而底下,还不如慢慢发,但是磨磨叽叽的也不合适,那就需要找一个最佳的传输速度。而这个传输速度完全是试出来的,怎么试呢?

收到消息时,接受端会回执一个 ACK,这个 ACK 中包含两个信息,一个是序号,另一个是接收窗口的剩余容量。

定义丢包:接受到数据包时,就会回执下一个消息的需要,如果发送端发现持续在回执已发送的一个序号,那说明此前发过的包就丢了。包丢了怎么办呢,就从那个位置开始重新发送。

接受窗口:发送端和接受端的窗口通常是不相通的,我们发送的数据包不能超过窗口的大小。

就以这两个信息未基准,开始慢慢启动,寻找最优速率,通常从第 10 个数据包启动(这个是 TCP_INIT_CWND 这个常量定义的),即时带宽很大,TCP 主要是兼顾可靠性和传输效率。

可靠性与效率的兼顾:拥塞避免 & 快恢复

大量的持续性拥塞会导致丢包数量加剧,这会导致整个传输的可用性下降,除了慢重启探索最佳的传输效率,另外两个手段是拥塞避免和快恢复。

首先对于是否拥塞,TCP 有个变量记录拥塞窗口(cwnd),本质上就是个发送方发送数据的滑动窗口。

这里有个阈值(ssthresh),到达阈值之前慢重启指数级增量探索 cwnd,然后在用拥塞避免算法线性增加窗口,如果发现丢包啦,也就是开始重传了。

这里丢包的原因可能有两个, 一个是确实网络环境差(没有收到回执),一个是偶现丢失(重复的 ACK),对于这两种情况处理策略是不同的。

对于网络环境确实差,直接重新慢启动。

对于偶现的丢包,把 cwnd/2+3,ssthresh=cwnd,进入快速恢复阶段,等收到新数据的 ACK 之后,再从慢重启阈值进入拥塞避免阶段。

TCP 差不多就是这样来兼顾可靠性和效率,以此榨干带宽的。

怎么看下 TCP 的过程呢?

1.3.3 IP 是怎么工作的

在拿到 TCP 的包之后,下一步就是对于这个包进行再包装,加上 IP 的头,这个 IP 的头里包含了 IP 相关的信息(核心是自身 IP 和目标 IP)

IP 协议下是怎么工作的呢?

首先最早期的网络是通过 MAC 地址进行传输的,但是如果机器不在一个子网络内是无法知道对方 mac 地址的,怎么办呢,IP 就诞生了,用来连接多个子网络。

首先电脑要想上网,有这么几个信息必须要关注:本机的 IP 地址、子网掩码、网关的 IP 地址。

本机 IP

本机 IP 分两种,静态分配和动态获得,所谓的动态 IP 就是基于 DHCP 协议,发送一个 DHCP 数据包申请对应的 IP 地址和对应的网络参数。

DHCP 是一个应用层协议,建立在 UDP 之上,设置自己的 MAC 地址后,直接对外广播(无目的 IP 和 MAC 地址),只有 DHCP 知道是发给自己的,然后按照对应的 MAC 地址分配 IP 执行应答。

子网掩码 & 网关

子网掩码是用来校验要访问的目的 IP 是否为子网内的 IP,他会对于自己的 IP 进行 AND 运算,然后对目的 IP 也进行 AND 运算,如果结果不同,则不是一个子网,必须通过访问网关来访问其他的子网络。

然后数据包就根据这些目标 IP 和网关之间各种大街小巷的传递了 netstat/route 可以看对应的信息

扯点下一层

仅仅知道 IP 是不够的,真正的物理层传输还是需要 MAC 地址的,拿到 IP 地址后怎么知道访问哪台机器呢,ARP 协议、RARP 协议就是干这个事儿的,每个主机内部都维护了个映射表,用于解析出 IP 对应的是哪个地址。再往下一层就是介质访问协议了。

1.4 到达服务端

你的请求兜兜转转终于到了服务端,但是最初接触到的并不是直接的服务者,我们日常访问的网站,背后的服务器都是数以万计的,并且这些服务承载着各种各样的职责,比如说流量接入、各种业务功能提供(登录、处理你的请求)、数据存储、缓存提速、协同、负载均衡、数据计算等等若干的能力。

再回顾一下这个图,接下来就看下冰山一角的背后到底发生了什么?

1.4.1 流量接入

首先一台机器是不可能扛的了百万、甚至千万的 qps 的,必然要做集群,再后来为了系统的可维护性、可用性,并且由于康威定律等因素按照功能进行了拆分,每个业务系统一个或者多个业务 Server 集群,多个 Server 合作构成整体的服务,呈分布式架构。

最初的 web 互联网,功能相对简单,流量也小,DNS 做流量分发足够了。但是后来,我们有了多个业务 Server 集群,完全按照 DNS 进行分发是不现实的。

同一个服务不同功能用不同域? 一个域名解出 2w 台机器? 业务 Server 不可用了 DNS 发现不了呀! 这个功能的机器 32 核? 这个功能 2 核心?这 DNS 去哪知道 想在流量入口处加一点通用逻辑?对不起,每个 Server 都改一下吧 加了台机器,好久才生效啊!

怎么整呢?起一个单独的集群来搞定这件事儿吧,把负载均衡这个事儿控制控制在自己手里吧。

首先我们要做的事儿是把负载合理的分摊到各个后端的服务上。现状是,主机和主机的通信是基于 ip+端口的,软件能实现的流量分发只能在 4-7 层,再向下第 2-3 层 需要相关的硬件支持。

1.4.1.1 负载均衡

负载均衡主要是三大应用场景:

链路负载均衡(LLB):运营商的链路选择,通常用于企业或数据中心的网络出口,选择不同的网络运营商,完成负载分担,并且流量的源进源出做到同源,来降低时延。

全局负载均衡(GLB):全局负载均衡的本质是智能 DNS,当解析流量到达各个数据中心 GLB 时,GLB 会根据用户 local DNS 的具体区域来返回对应的 IP

服务器负载均衡(SLB):就是本片要说的内容,如何将负载合理的分摊到后端的服务上。

1.4.1.2 每一层的负载均衡

在第二层做负载均衡:单臂 硬件可以直接对 MAC 地址进行处理,对外虚拟一个 MAC 地址,然后接受请求后分配真实的 MAC 地址,业务请求处理完成后直接返回给客户端。

在第三层做负载均衡:单臂 跟 MAC 地址的处理类似,提供虚拟 IP,接受请求后分配真实的 IP 地址。通常用于一个路由域内,同样业务请求处理完成后直接返回给客户端。

在第四层做负载均衡: 在网络层之上,也就是传输层加以逻辑做处理,修改数据包中的 IP + 端口,转发给对应的后端服务。

再高层做负载均衡: 基于应用层协议,起一个专门做流量入口然后分发到各个服务的请求处理集群,或者直接做一个应用,然后让它来代理业务 Server,把分发的职责给落地。比如 HTTP,根据 URI 进行请求的转发。

对于 SLB 而言,最佳的落地是传输层负载均衡(四层)和应用层负载均衡(七层),接下来看为什么及业界的最佳实践。

1.4.1.3 基于硬件的负载均衡

直接基于硬件去做,比如 F5 Network Big-IP,从硬件层面做了优化,可以理解为就是一个非常强大的网络交换机,每秒百万级的处理轻轻松松,完整的网络处理解决方案,易用性、功能丰富度都不错,省心省力,就是贵。

1.4.1.4 基于软件的负载均衡 – LVS

首当其冲的就是 LVS,分为 DR 模式、IP TUNNEL 模式、NAT 模式 DR 模式就如上面说的,直接对于 MAC 地址进行虚拟和分配,要求负载均衡服务和后端服务必须在一个 VLAN 内,由于数据包由后端服务器直接返回给客户端,因此也会要求后端服务器必须绑定公网 IP,这个模式性能最好,但是对于组网要求非常苛刻。

IP TUNNEL 模式,将客户端请求数据包报文首部再封装一层 IP 报文,目标地址为后端服务,包通信通过 TUNNEL 模式实现,可以完成跨 VLAN 通信,但 TUNNEL 模式走的隧道模式,运维起来比较困难,在实际应用中不常用。

NAT 模式,在传输层对于 IP 和端口进行修改(以虚拟 IP 对外提供访问,然后篡改目标 IP 地址,可以理解为三层+四层负载,在四层上干了点三层的事儿,就一次链接),中间完整插了一层 LVS Server,请求和响应都需要经过 LVS Server。

还有一种更加彻底的 NAT 模式:即均衡器在转发时,不仅修改目标 IP 地址,连源 IP 地址也一起改了,源地址就改成均衡器自己的 IP,称作 Source NAT(SNAT)。

阿里云对 LVS 增加了一种模式,封装了个 SLB,新增转发模式 FULLNAT,实现了 NAT 下跨 VLAN 通信,有兴趣可以查一下。

1.4.1.5 基于软件的负载均衡 – HAProxy

NGINX/HAProxy 下的四层负载均衡,直接暴露的是负载均衡服务的 IP 地址(纯粹的四层负载),会单独同后端服务新建立连接,所以能跨 VLAN 了。算上最初的请求,整体会有两次链接发生。很多 mysql 集群的接入就是用 HAProxy 来做的。

1.4.1.6 基于软件的负载均衡 – Nginx

估计是大家接触最多的一个应用啦,实现于应用层,属于第七层负载,根据 HTTP 协议内容进行相关的负载均衡工作。 首先客户端同 Nginx Server 建立连接,然后 nginx server 同后端 Server 建立连接,会有两次链接发生,同时由于是应用层协议,会多 1 次拆包、装包的过程,处理应用层协议的过程。

1.4.1.7 业界的通常实践

直接拿 F5/LVS NAT 做入口负载均衡,然后再挂一层 nginx 做具有业务属性的负载均衡,然后然后内网中使用 LVS DR 或者 NAT 或者 HAProxy 再针对服务集群单独做负载均衡。 就是最开始看到的这样子:

1.4.1.8 负载均衡算法

轮询法: 将请求按顺序轮流地分配到后端服务器上,它均衡地对待后端的每一台服务器,而不关心服务器实际的连接数和当前的系统负载。

随机法: 通过系统的随机算法,根据后端服务器的列表大小值来随机选取其中的一台服务器进行访问。由概率统计理论可以得知,随着客户端调用服务端的次数增多,其实际效果越来越接近于平均分配调用量到后端的每一台服务器,也就是轮询的结果。

源地址哈希法: 源地址哈希的思想是根据获取客户端的 IP 地址,通过哈希函数计算得到的一个数值,用该数值对服务器列表的大小进行取模运算,得到的结果便是客服端要访问服务器的序号。采用源地址哈希法进行负载均衡,同一 IP 地址的客户端,当后端服务器列表不变时,它每次都会映射到同一台后端服务器进行访问。

加权轮询法: 不同的后端服务器可能机器的配置和当前系统的负载并不相同,因此它们的抗压能力也不相同。给配置高、负载低的机器配置更高的权重,让其处理更多的请;而配置低、负载高的机器,给其分配较低的权重,降低其系统负载,加权轮询能很好地处理这一问题,并将请求顺序且按照权重分配到后端。

加权随机法: 与加权轮询法一样,加权随机法也根据后端机器的配置,系统的负载分配不同的权重。不同的是,它是按照权重随机请求后端服务器,而非顺序。

最小连接数法: 最小连接数算法比较灵活和智能,由于后端服务器的配置不尽相同,对于请求的处理有快有慢,它是根据后端服务器当前的连接情况,动态地选取其中当前。

1.4.1.9 部署方式汇总

有三种部署方式:路由模式、桥接模式、服务直接返回模式。

路由模式: 路由模式的部署方式,服务器的网关必须设置成负载均衡机的 LAN 口地址,且与 WAN 口分署不同的逻辑网络。因此所有返回的流量也都经过负载均衡。

桥接模式: 桥接模式配置简单,不改变现有网络。负载均衡的 WAN 口和 LAN 口分别连接上行设备和下行服务器。LAN 口不需要配置 IP(WAN 口与 LAN 口是桥连接),所有的服务器与负载均衡均在同一逻辑网络中。

服务直接返回模式: 这种安装方式负载均衡的 LAN 口不使用,WAN 口与服务器在同一个网络中,互联网的客户端访问负载均衡的虚 IP(VIP),虚 IP 对应负载均衡机的 WAN 口,负载均衡根据策略将流量分发到服务器上,服务器直接响应客户端的请求。因此对于客户端而言,响应他的 IP 不是负载均衡机的虚 IP(VIP),而是服务器自身的 IP 地址。也就是说返回的流量是不经过负载均衡的。因此这种方式适用大流量高带宽要求的服务。

1.4.2 业务 Server 开始处理

经过 n 层的负载均衡处理之后,接下来真正进入业务 Server 的处理流程,首先到达的 API Web Server,这些业务通常是运行在 web 容器中的,比如说 tomcat、apache,后这些 API Server 背后,通过网络依赖了各种功能职责的基于 RPC 框架的业务 Server,然后业务逻辑的处理过程中会用到各种各样的中间件,比如 Redis、Kafka、Mysql,这些技术中间件也有着自己的部署集群,要想访问也大多存在网络过程,这些 API 服务、RPC 服务、中间件服务共同完成了业务功能。

1.4.2.1 网络 IO

站在业务 Server 实现的角度而言,处理跨越千山万水而来的请求,首先第一步是接受请求、第二步解析请求、第三步处理业务逻辑、第四步写入响应并返回。而接受请求的处理,就是如何去处理网络 IO(发起请求也是一样的,叙述顺序放在这里感觉更流畅)。

在 TCP 完成握手之后,接收缓冲区就开始不断的被写入数据,然后应用程序就从缓冲区(内核)中读取数据,复制到进程缓冲区(用户),这个过程就是指网络 IO 的处理过程。

网络 IO 的处理模式发展到现在,常见的有这么几种:阻塞 IO、非阻塞 IO、多路复用的 IO。

盘盘概念 — 阻塞/非阻塞

阻塞 IO 就是在应用程序创建一个线程/进程读取缓冲时,如果数据没准备好,那(线程/进程)就一直等着。 非阻塞 IO 就是应用程序创建一个线程/进程读取缓冲时,如果数据没准备好,那(线程/进程)不会等待,先去干点别的。

至于 IO 多路复用,就是应用程序创建一个或者几个线程/进程 去读取缓冲,在非阻塞的基础上,去读那些准备好的数据,常见的有 select、poll、epoll。

盘盘概念 — 同步/异步

阻塞/非阻塞的参考标准是执行对象(线程/进程),被挂起等待就是阻塞的,反之非阻塞;相信大家肯定还看过“同步/异步”这俩词儿,参考标准是事儿的执行对象是谁,同步是当前线程/进程是操作的执行者,异步是非当前线程/进程作为执行者。

很清晰,同步异步指的是执行者是否为当前线程,阻塞非阻塞指的是当前线程是否被挂起,组合一下有这么三种模式:

同步阻塞 IO,每次起一个线程/进程,夯在那里读数据,开销极高,性能比较差。

同步非阻塞 IO,select、poll、epoll 模式都是同步非阻塞的,由当前线程不断的检查是否有数据可读并完成读取,根本差异是对于活跃链接、非活跃链接的维护方式不同。

比如 epoll 红黑树存放监听链接、双向链表存放就绪链接,当 tcp 三次握手,对端反馈 ack,socket 进入 rcvd 状态时标为可读、established 状态时标为可读、

另外

异步 IO 需要内核的支持,一次性把数据读取这个事儿做完,然后通知应用线程/进程。

不少编程语言在同步非阻塞之上利用通知机制做出了异步编程模型。

1.4.2.2 C10K & epoll

想要高效就用 epoll 吧。

往前数大约 20 年,网络方面最头疼的问题应该是 C10K 问题,目前单机处理 1w 连接不是什么难事儿,但是 20 年前如何单机如何突破网络处理的性能极限,如何小资源成本完成大连接数的处理,是一个业界最大的难题。

C10K 问题最核心的是早期基于进程/线程处理模型的 BIO 模式,如果要扛 10k 链接,就需要 10k 个线程或进程,但是进程是一个极耗资源的操作,一台机器常驻 10k 网络处理的进程是不现实的,虽然可以用分布式来解决单机极限,但是机器成本确无法忽视,并且一味的提升机器配置也不解决问题,链接数膨胀所带来的额外开销是指数级上升的。

C10K 问题本质上是操作系统的问题,早期的操作系统并没有提供小成本的网络链接处理方法,BIO 模式所带来的线程/进程上下文切换、数据拷贝的成本极高,导致 CPU 消耗过高,以至于极少链接就会到达 CPU 处理极限。

对于这个问题的解决就是 IO 多路复用,用有限的线程/进程处理无限的网络链接,如果单线程处理多链接,首先要解决的就是阻塞问题,一个阻塞就大家就都没的玩了,落地的实现有 select、poll、epoll。

首先是 select:

用一个 fd_set 结构体来告诉内核同时监控多个文件句柄(网络链接),当其中有文件句柄的状态发生指定变化(例如某句柄由不可用变为可用)或超时,则调用返回。这种模式对于处理上有小规模的提升,但是句柄数量的上限是有限的,并且遍历检查每个句柄效率比较低。

然后是 poll:

poll 的处理模式跟 select 一致,主要是解决了句柄数量上限的问题,通过一个 pollfd 数组向内核传递需要关注的事件消除文件句柄上限,但是遍历检查效率低的问题依旧没有解决。

最后憋了个大招,epoll:

既然轮询所有的文件句柄效率太低,那么是不是可以只关注活跃的链接,epoll 就是这个思路,把链接分为活跃和非活跃,当链接有事件发生时,回调 epoll 的 api,把链接放到遍历的双向链表中,由于现实情况活跃链接在整体链接中的占比相对较小,epoll 能处理的链接数要远超 poll、select,比起 BIO 更是高了几个数量级。

目前基于 epoll 落地的编程模型就是异步非阻塞回调模型,也可以叫做 Reactor、事件驱动、事件轮询。epoll 本身是同步非阻塞的哈,nginx、libevent、node.js 都是当时 epoll 产生之后的产物。

epoll 是 linux 上的产物,win 有对应的 IOCP、Solaris 推出了/dev/poll。

C10M

C10K 问题的研究是一个非常好的开端,下一个时代,网络应用继续膨胀,我们要面临的可能就是 C10M 问题了,我们现在看 C10M,就同 20 年前看 C10K 是一样的,一座大山,上来了,也就上来了。盲猜一下解决的角度,首先是协议的角度;其次是对于数据包的处理模式(内核的角度);CPU 核心处理的专用优化(硬件的角度)。

1.4.2.3 Web 容器 — api Server

web 容器顾名思义,存放 web 服务的容器,业界最常使用的 web 容器应该就是 Tomcat 了,tomcat 就是在 Java EE 的 JSP、Servlet 标准下的 web 服务器,从上学那会儿就是必看科目,工作这么些年了,还是它。

tomcat 涉及到的内容非常多,对应的 servlet 规范、网络处理模型、work 模型等等,这里不展开,仅说和网络相关的内容。

首先 Tomcat 由 Java 语言实现,处理的是 HTTP 请求,站在实现的角度而言就是处理网络 IO,解析出请求后进行业务处理。

Java 中对于网络 IO 的处理发展到现在有:BIO、NIO、AIO BIO 就是同步阻塞式 IO,性能很差,NIO(New IO)指得是同步非阻塞 IO,通常我们的服务是部署在 Linux 之上的,NIO 是基于 epoll 实现的。

举个例子 tomcat 处理模型

接下来看下 tomcat 整体的处理细节,tomcat 是一个 web server,然后其中运行的服务是 service,然后每个 service 有两大关键部分,connector、container,connector 负责链接的处理、container 负责具体的业务请求处理。 我们要关注网络相关的,最核心的就是 connector。

首先连接器中的 acceptor 监听对应的 socket 链接,然后 handler 处理接受到的 scoket,内部调用,交由 processor 处理生成对应的 Request 对象,然后将 request 交付对应 Servlet 进行处理。

根据协议和端口确定 Service 和 engine,根据域名找到对应的 host,然后根据 uri 找到对应的 context 和对应的 Servlet 实例。

engine 是运行 servlet 处理器的引擎,host 就是主机的能力,context 代表应用程序,wrapper 是一个 servlet 实例。

springMVC 里面就一个 Servlet,所以模型大致是这样子:

1.4.2.4 rpc 框架 — 分布式利器

在微服务/分布式架构下,由若干个服务共同构成了完整的服务功能,而服务和服务之间的协作/通信就是依赖 rpc 框架来完成的。rpc 全称是远程过程调用,说白了就是像使用本地方法一样使用隔着网络远端的方法,这个调用就被称为 rpc,rpc 框架的意义就是能忽略网络相关的细节,隐藏网络编程细节。

常见的框架有 dubbo、sofa、grpc、brpc 等,实现原理都差不多,但底层细节差异较大。

对于同一个事物,在使用的时候细节更重要,在学习时“设计方法”更重要,所以,看 rpc 的本质就好了,对于 RPC 核心工作是隐藏网络细节:

对于网络高效操作,最核心的就是编解码、网络链接处理,只要把这两点搞定了就问题不大; 对于隐藏,尽可能的提升易用性,减少声明、调用时的难度,符合大家的开发习惯就问题不大。

这里主要说网络相关的,一个 rpc 协议比较核心的通常是通信协议、编码协议、序列化格式,客户端对传输内容(数据+指令) 进行序列化、协议编码、网络传输到远程服务器端,服务端接受输入对传输内容进行解码、反序列化完成数据的逻辑计算,产生输出后,同样方式传递给客户端,完成整个 RPC 调用。

除此之外,一个 RPC 框架除实现 RPC 协议外,通常提供了负载均衡、容错机制、服务注册发现等附加功能:(这些功能并不是 RPC 所必需的)

在调用过程中,为了解决分布式环境下机器 &服务数量巨大 &状态繁多导致的难以管理的问题,RPC 框架通常还集成了 “如何鉴别调用哪些机器,哪些机器是死是活” 的服务注册 &发现功能。对于分布式环境下必然存在的网络不稳定问题,提供了一定的容错机制。针对合理使用机器 &网络资源,保证各个机器的稳定程度,提供了一定的负载均衡功能。

通信协议

在通信协议方面,RPC 跨越了传输层和应用层,像 grpc 就直接基于 http 2.0 的协议、dubbo 在 tcp 基础上研发的应用层传输协议。

编码协议

首先 RPC 协议是语言无关的,客户端的实现语言与服务端的实现语言可以是相同的也可以是不同的,在 RPC 调用时必然需要一种标准的编码协议来约定接口数据格式、处理传输内容的编码解码操作,具体要看框架的实现程度和支持。 后续会对业界常见的 基于文本编码的 json、xml、基于二进制编码 protobuf 为例进行介绍。

举个例子,看看 grpc 网络实现

GRPC 框架完全是基于 HTTP2.0 的,而 http2.0 相对于 http1.x,编码格式是二进制的,相对于纯明文传输体积是要小的,并且 http2.0 是完全多路复用的,一个链接实现多 http 报文传输,链接利用率更高,并且解决了队头阻塞的问题,并且 http2.0 头部开销更小。 链接处理方面,GRPC 基于边缘触发的 epoll,将 epoll 的性能发挥到了极致,epoll 的数据读取分为两种,边缘触发是缓冲区发生变化时就会通知读取,需要一次性读完,而水平触发是只要可读就会通知。边缘触发比起水平触发 通知次数更少,并要求一次性读完,性能更好,但可能存在数据丢失的情况,但是可解。

1.4.2.5 epoll 的使用 — 关于 netty 的小白常问

问题一: 为什么需要 netty,netty 是对于 Java 网络相关库的一种补充,基于 Java NIO 实现,隐藏了部分网络编程的细节,netty 写出来的程序,通常不会太差,让不擅长网络编程的同学能够网络编程。

可以看下大致的过程,注册 channel 就是注册文件描述符,loop 中会调 Selectors.select 方法,对应就会在内核中调用 epoll_wait 函数,内部事件就是 epoll 维护的就绪队列,靠中断激活然后回调加入队列。

要没有 netty,你得自己实现这些东西,JDK 还有 bug…

落地实现有三种线程模型,单线程模式、多线程模式、主从 reactor 多线程,单线程是由一个线程完成链接读写和业务处理(跟单线程 redis 的处理模式很像);多线程是 reactor 只负责链接读写,业务动作找到 handler 后提交到 worker 线程池处理;主 reactor 接受链接,分发给子 reactor 进行读写,然后业务动作由 worker 线程池处理。

这三种模式的差异主要是面临 IO 场景下:链接建立、IO 读写、业务处理占比及成本情况。 一些 RPC 框架通常是后两种,业务代价太高,如果读写也同样很大的话,可能就是第三种,区分版本/配置查看,通常都支持。

问题二: 为什么同样的机器配置,tomcat 比 netty 落地的 rpc 框架性能差这么多,首先不能这么比哈,一个是 web 容器、一个是通信框架。

非得比的话,核心原因是 tomcat 的 servlet 规范,虽然网络处理性能在 NIO 之后就提上来了,但是完成链接处理后的 tomcat,阻塞的业务处理模式下由于 servlet 规范依旧很差,除此之外,还有编码/序列化协议的差异性,那为什么还用 tomcat,因为稳定和替换代价。在多线程业务阻塞处理模型下,rpc 框架的处理复杂度、协议跟 tomcat 一致了,性能其实差不多。

对啦,如果觉着当前同步模式下的编程方式性能差,试试 reactive 编程吧。

问题三: 为什么看上去 proactor 比 reactor 性能更好呀,为什么大量的应用还是 reactor 啊,因为 proactor 写不明白呀,复杂度过高,还需要操作系统支持,linux 支持的还不太好。不过,一些中间件还是适合用 proactor 的,给业务用的框架,还是乖乖 reactor 吧。

看个完整的从入口到 tomcat 再到 gRPC 的调用 demo

1.4.2.6 关于长链接

http 长链接、tcp 长链接,是一回事儿嘛,为什么要有长链接呢?keepalive 是怎么保活的呢?

链接的建立是需要成本的,即时这个成本很小,很多场景下确无法忽略。端到端负载较小时还好,当负载很大时,同一个端不同请求链接建立的成本所带来的 CPU 成本就无法忽略了;当时延要求较高的场景,链接建立所带来的时间开销也无法忽略。

TCP 长链接

http 长链接是 long-polling 实现的,在应用层 http 请求中我们可以指定“Connection: keep-alive”开启长链接,当设置 keepalive 时,tcp 链接就不会主动断开,并且启用 long-polling,保证链接存活。

保活机制

首先 HTTP 的 keepalive 保活和 TCP 的保活不是一回事儿,HTTP 保活是为了不断开链接,而 TCP 保活是保证连接是正常存活的。

启用 TCP Keepalive 的一端会启动一个计时器,当这个计时器数值到达 0 之后,一个 TCP 探测包便会被发出。这个 TCP 探测包是一个纯 ACK 包,其 Seq 号与上一个包是重复的,所以其实探测保活报文不在窗口控制范围内。

这么做主要是为了避免过多的半打开连接,因为当连接的任意一方崩溃时,这个链接就进入了半开状态,如果不探活,这类链接会越来越多。

很多时候为了让链接是活的,需要在应用层再挂一层保活机制,因为 TCP 默认的两小时活跃并不可靠,运营商经常会回收空闲链接。

什么时候需要长链接

长链接的长期维护对 Server 端是具有较大开销的,常规的 web 应用短链足够了,但对于游戏场景时延要求极高 &大量同一端到端请求 &具有推送诉求、Server 端服务和服务间的调用、mysql 的调用就比较适合使用长链接了(将链接入池) 看个 demo

1.4.3 到中间件了

请求从客户端启动,找到地址,发起请求,到达接入层入口,执行 4 层负载均衡,到达 nginx Server,执行 7 层负载均衡,然后到达 API Server,通过 RPC client 发起调用,到达 RPC Server,然后在 RPC Server 间兜兜转转,有的逻辑访问了 Redis,有的逻辑调用了 mysql,接下来看下对于这些中间件是怎么调用的。

1.4.3.1 redis 调用

常见的 redis 集群架构有两种,豌豆荚的 codis 模式、redis-cluster 模式,两者的网络链路差异较大,业界比较常用的是 codis 模式。 codis 模式透过 proxy 根据一致性 Hash 策略,到达对应的 redis 实例,而 redis-cluster 模式则有 client 直接到达 redis 实例。

目前 redis 的版本已经有多线程模式了,将读写动作换做其他线程来执行了。

到达 redis 实例之后,单线程处理多链接,可以理解为近似 netty 的单线程模式,因为 redis 完全基于内存操作,成本极小,这种模式就够了。相关内容已经说过了,跟 netty 实现模式有一定的差异,可以参照 redis-ae-epoll 实现方式(server.c 主函数中 initServer 调用了 aeCreateEventLoop、anetTcpServer、anetNonBlock 来完成初始化)

# 主从同步

redis 在 master 操作完成后,会基于命令传播(AOF)将对应的命令写入从库。新挂从库时会基于 RDB 快照复制的方式写入从实例。 主从之间是通过长链接进行数据传输的,细节基本和前面描述的一致。

1.4.3.2 mysql 调用

同样的通过长链接先到接入层,mysql 常用的有 HAProxy(前面提到过),然后透过 proxy 跟 mysql 实例的长链接到达具体实例,执行对应的动作。

执行完成写入动作之后,通过 binlog 同步的方式完成主从同步,同步的方式是三种:同步、半同步、异步,业界的落地常用方式通常是半同步模式,根据超时时间来确定最终的同步方式,或强制指定,延迟就摘掉。根据不同的场景(数据一致性要求)选择不同的模式。但是这三种模式的通信都是依赖于长链接完成的。

如果你的 mysql 集群是多活部署的,通常还会有通过网络专线完成的跨地域数据同步,完成多主的数据同步。

1.4.3.2 其他中间件

有一个共性,对于网络方面中间件为了追求效率通常是基于长链接、自定义应用层协议落地的。

1.5 网络优化 tips

至此所有的处理都完成了,剩下的就是一层层的响应,完成返回了。

经过这么长的剖析,大家应该对网络链路有了一个相对清晰的感知,接下来看下对于网络的处理,我们日常有哪些优化操作。

1: 网络是一件开销较大的事儿,首先要做的是避免发生网络 IO。 2: 如果一定会有 IO 链路产生,那就尽可能的剪短 IO 链路,比如说异步操作。 3: 如果网络操作无法避免,要节省网络中的动作,比如使用长链接、批处理,但长链接一定要因地制宜。 4: 使用高效的工具,别自己瞎整,netty、广泛使用的 RPC 框架,成熟的接入层等等。 5: 如果非要进行网络编程,合理利用 epoll。 6: 从顶向下进行优化,优化到极致还不解决问题,再去动底层。 7: 别猜,代码都是人写的。

2. 写在最后

网络是一件有趣且复杂的事情,只是一个普通的业务研发,没那么专业,文章中可能会有一些描述不准确或者错误的事情,如果说错了,恳请斧正。

研究网络的过程中,发现了较多的设计启发,比如 DNS 的架构模式、网络的多层处理、负载均衡的设计、reactor 的模式、容灾设计、设计的出发点等等,对于技术本身的研究会带来较多的日常设计的启示,也希望大家读文章的时候能有对于网络之外的设计启发。


发布于: 13 小时前阅读数: 2
用户头像

邹志全

关注

还未添加个人签名 2020-05-13 加入

还未添加个人简介

评论

发布
暂无评论
一文熟知网络 – 文章巨长,但是很详细_网络编程_邹志全_InfoQ写作社区