点击上方 前端Q,关注公众号
回复加群,加入前端Q技术交流群
当你在浏览器输入 qq.com 按下回车键,到页面呈现在你面前,整个过程发生了什么?我以前思考过这个问题,从最前面的浏览器到最后的 db 都梳理的一遍,触发了一次技术顿悟,将很多散落的知识点贯通起来了。
01
IP、DNS 和 CDN
如果面试时问你「局域网 IP 有哪些 IP 段」,你怎么答?
1.1 为什么不是每个设备一个公网 IP?
先说个 QQ 的小故事。QQ 刚开发时也没想到 QQ 会发展成中国互联网基础设施,就用4字节整形表示 QQ 号了。早期内部的一些项目有用 int 表示 QQ 号,能表示的最大值是2^31-1,即21亿多。在 QQ 号发放近20亿时,即通搞了个22亿 QQ 号的专项,通知每个项目检查修改,使用 unsigned int 表示 QQ 号,以支持21亿以上的 QQ 号。
可以看出在底层和协议设计中,字段的扩大是非常麻烦的。IP 地址也有类似问题。目前广泛使用的是 IPv4,一个 IP 地址4个字节,理论上共有2^32个 IP 地址,接近43亿。这个数量还不到人均一个,远远不够,自然也不能每个设备一个公网 IP 了,所以 Internet 规定了 IPv4 地址空间的一部分供专用地址使用,这些地址永远不会被当做公用地址来分配,局域网内部 IP 就是使用这些专用地址。
常见专用地址有:10.x.x.x,172.16-31.x.x,192.168.x.x,另外127.0.0.1表示本地回环地址,代表设备的本地虚拟接口。
了解这个后,如果你发现你在公司的 IP 是192.168.0.100,在家里的IP也是192.168.0.100时,就不会诧异了。局域网内部 IP 只用于局域网内部通讯,如果要连接广域网,还要用到 NAT(网络地址转换)技术。
NAT 常用于局域网内部 IP 和局域网分配的公网 IP 之间进行转换,使用最多的是端口多路复用(PAT)方式,简单的描述就是,你在局域网内访问 qq 时,路由器会记录你的内网 IP 和端口(假设是192.168.0.100:12345),用路由器的公网IP和一个未使用的端口向公网发网络包(假设是202.96.134.133:23456),路由器还会把TCP~202.96.134.133:23456~192.168.0.100:12345配对保存起来。当 qq 的响应发到202.96.134.133:23456后,路由器通过查找配对表就知道是发给192.168.0.100:12345。
1.2 DNS 有话说
搞网络管理的同学对 DNS 比较熟悉,程序员也需要了解,不管是前端还是后端。
IP 地址不好记,于是就有了域名。浏览器访问 qq.com 时,会先做一次域名解析,把 qq.com 这个域名解析成 IP 地址,然后才能发出 IP 包。
在 windows 和 linux 下解析域名前,会先从本地 hosts 文件里查找网址映射关系,如果有,就先调用这个 IP 地址映射,完成域名解析。早期做 Web 开发时会用这种方式来切换开发环境、测试环境、预发布环境和正式环境。
如果你在腾讯云注册了一个个人用的域名,一般会直接用腾讯云的 DNS 服务器来管理该域名的解析。对于门户网站,则会自己搭建 DNS 服务器来管理域名。自建 DNS 服务器既方便管理,又能提高安全等级,防范 DDOS 域名攻击。
如果域名的访问量比较大,可以让域名对应多个 IP 地址,操作系统会随机选择其中一个,这是早期的 Web 负载均衡方式。但因为有些 DNS Server 不按 TTL 指示缓存,导致 DNS 变更生效时间最长可达到24~48小时。一旦某个 IP 的机器故障,而 DNS 又不能立即刷新,会让部分用户服务不可用。于是就有了用 LVS/nginx 来动态负载均衡的方式,LVS 的负载均衡基于 IP 层,nginx 则是用 HTTP 层的反向代理机制。
此外还要考虑到电信/联通/移动三大运营商跨网的问题。运营商之间的通讯带宽有限,如果你的服务器在电信机房,那么联通用户访问就会比较慢。所以就有了多线机房的出现。多线机房实际是一个机房有电信、联通、移动等多条线路接入。通过多线机房内部路由器设置,及 BGP 自动路由的分析,实现电信用户访问电信线路,联通用户访问联通线路,这样实现电信联通均可以快速访问 。
腾讯云的 CLB 选用 BGP 多线,就可实现电信/联通/移动多网统一接入和自动负载均衡。
1.3 CDN 登场
为了给用户提供更快的访问速度,人们发明了 CDN(Content Delivery Network,内容分发网络)。简单的说就是,一个域名对应有多个 IP,这些 IP 分布在全国各地,用户访问域名时,DNS 服务器根据用户的来源 IP,返回一个就近的 IP 给用户,从而实现更快的访问速度。
从上面的描述可以知道,CDN 要求各节点的内容是一致的,这样才能让各地用户访问到一致的内容,所以 CDN 主要用于 Web 静态资源的分发和加速。
Web 性能优化方案一般会有一条动静分离,即把静态资源使用的域名和动态脚本的域名分开,有了 CDN 后,一般会把静态资源的域名托管给 CDN 以提高更快的访问速度和更低的成本。
现在也有动态 CDN,针对接口这种请求也能做就近接入,但原理是转入云厂商的内部链路做网络加速,一般不把数据缓存在边缘节点上。
02
TCP、消息分包和协议设计
一次网络请求经过 DNS 解析知道了目的 IP,现在就要发出网络包了。
2.1 TCP 是一种流式协议
讲网络编程的教科书一般都会对 TCP 的可靠传输做详细说明,但对于 TCP 是一种流式协议讲解的不多,但这背后隐藏着很重要的一个知识点。先做个名词定义方便交流,这里的 “消息”是指应用层的一个完整的协议包。
流式协议的特点是什么?就像流水连续不断那样,消息之间没有边界。例如 send 了3条消息,分别是100字节、50字节、80字节,recv 时可能收到的是230字节,就是说一次 recv 收到了3条消息,需要应用逻辑自己对 recv 到的数据进行分析,得出完整的消息。
能一次 recv 到多个消息,也可能一次 recv 到一个半消息或半个消息,都是有可能的,这就是流式协议的特点。有的文章讲的粘包也是这个概念。
2.2 消息分包
既然 TCP 是一种流式协议,需要应用层自己来分析出完整的消息,那有哪些方式来确定一个完整消息呢?这个就是应用层通讯协议设计的工作了。
先看看最常见的 HTTP 协议是如何来分包的。HTTP 协议是一种文本协议(非二进制协议),用 \r\n\r\n 来分割消息头和消息体,HTTP 请求的消息头中有 Content-Length 来告知消息体有多大,如果没有该字段就表示无消息体,GET 请求大多是这样。HTTP 响应的消息头中,或者有 Content-Length,或者有 Transfer-Encoding: chunked 告知以 chunk 模式分析消息体。
HTTP 用 \r\n\r\n 来分割消息头和消息体,这种用特定字符/字符串来分割或分包的方式,还有不少协议用到。例如 FTP/SMTP/POP3 都是用 \n 来作为一个命令结束的标志。
这种消息分包的方式,需要应用层去扫描已 recv 到的数据,性能上还不够高效,代码不严谨的还容易被攻击。在需要自定义协议的项目中,不少选择用二进制协议,解析高效,安全性更好些。
最简单的二进制协议分包方式是消息的头4个字节表示消息的总长度。这种方式还需要对最大消息长度做个限制,例如 64K 或 1024K 大小,避免超大数据包对接收方缓冲区的破坏。更进一步的,可以加入简单校验方法。例如消息头1个字节固定式0x2,消息的最后1个字节固定式0x3,消息总长度放在第2~5字节。这样收到完整消息后,如果头尾不是0x2和0x3,就直接异常处理。
2.3 协议设计
消息分包是协议设计的一个工作,协议设计的话题还不少,这里以 HTTP 协议为例,简要的说说里面设计的点,自己设计的协议也可以对照着有选择的使用,原理是共通的。
由消息头+消息体组成:空行分割 HTTP head 和 body,HTTP 头的每一行以 \r\n 结尾,空行就是 \r\n\r\n 。
消息分包:如上所述,HTTP 用 Content-Length 和 Transfer-Encodeing 来分包。
消息压缩:请求中有 Accept-Encoding 字段,响应中用 Content-Encoding 字段表明压缩方式,一般采用 gzip 压缩。
消息加密:https (SSL: Secure Socket Layer)。
消息 ID:URL 就是消息 ID 。
响应的状态码:第一个数字定义了响应的类别。
1xx:指示信息--表示请求已接收,继续处理
2xx:成功--表示请求已被成功接收、理解、接受
3xx:重定向--要完成请求必须进行更进一步的操作
4xx:客户端错误--请求有语法错误或请求无法实现
5xx:服务器端错误--服务器未能实现合法的请求
协议版本号:HTTP/1.1中的1.1就是 HTTP 1.1 版本
长连接:请求中 Connection: keep-alive 表示希望服务器保持连接,减少 TCP 连接的开销。
字符集:Content-Type 字段表明了字符集,例如: Content-Type: text/html; charset=gb2312。
字符转义:URL 中的参数需要做 URL 转义处理,例如 http://xx.com/do?name=t%2F%3F%23%3Daa 表示 name 为 t/?#=aa。
在我们自己设计协议时,可以有选择的使用,如果消息比较大,可以采用支持压缩;如果要兼容多个版本的协议,那版本号必不可少。如果采用二进制协议,字符集和字符转义的用处不大。
2.4 HTTP 协议和二进制协议的对比
HTTP 协议是一种文本协议,也是一种 Name-Based 协议,就从这两方面来说。
文本协议 vs 二进制协议
文本协议的特点:
便于人。
易于阅读、理解、调试、构造。
解析复杂、冗余多。
需要考虑字符转义。
二进制协议的特点:
便于机器。
Name-Based vs Position-Based
Name-Based 协议的特点:
协议字段都用 Name 标识。
协议字段与位置无关。
协议字段可缺省。
新增协议字段比较方便。
解析复杂。
需要考虑字符转义。
Position-Based 协议的特点:
每个协议字段都有特定的位置。
新增协议字段需要做好协议版本管理(protobuf 这类就挺好)。
解析更高效。
03
CGI 和 FastCGI
消息经过网络传输,到达了服务器端,最常见的服务器是 Web 服务器,做 PHP 的同学都知道 FastCGI 模式的 PHP 比普通 PHP 更高效,其中的原理是什么呢?
3.1 古老但常见的 CGI
Web 服务器能解析 HTTP 请求,返回静态资源(HTML 页、图片等),但要输出动态内容,必须得 PHP/C#/Ruby/Java/Python/C/C++ 这些外部程序来实现。
早期有个技术叫 CGI(Common Gateway Interface,通用网关接口),是用于 Web 服务器和外部程序之间传输数据的一种标准。一个简单的 CGI 程序(C++ 语言)如下:
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("Content-type: text/html\r\n\r\n");
printf("your name is:%s\n", getenv("QUERY_STRING"));
return 0;
}
浏览器访问这个 CGI 程序,就会显示:your name is:name=xxx 。
CGI 规定了 Web 服务器如何和 CGI 程序之间传输数据,具体过程大体是这样:
Web 服务器收到的请求信息后,启动 CGI 程序(apache 是 fork 进程 exec CGI 程序);
Web 服务器通过环境变量和标准输入把请求信息传递给 CGI 程序;
CGI 程序执行业务逻辑后,通过标准输出和标准错误把响应数据返回给 Web 服务器,CGI 程序 exit;
Web 服务器再组织成 HTTP 响应包发给浏览器。
在上面的例子中,第一行 printf 是输出 HTTP 头(还记得 HTTP Header 和 Body 是用 \r\n\r\n 分割的么?),getenv("QUERY_STRING")是从环境变量获取 URL,printf 是通过标准输出返回内容。
Web 服务器会把哪些信息通过环境变量传递给 CGI 程序?常用的有这些:
CONTENT_LENGTH :向标准输入发送的数据的字节数(POST)。
QUERY_STRING:实际存放发送给 CGI 程序的数据(GET)。
REQUEST_METHOD:传送数据所用的 CGI 方法(GET或POST)。
HTTP_COOKIE:cookie 值。
REMOTE_ADDR:用户 IP。
SCRIPT_NAME:请求的 CGI。
可以看到 CGI 只是一种标准,可以用任何一种语言编写 CGI 程序,只要这种语言具有标准输入、标准输出和环境变量,比如:C/C++,perl,PHP、ruby。按照 CGI 标准要求,就能和 Web 服务器交互起来。
3.2 FastCGI 应运而生
CGI 是通过环境变量/标准输入、标准输出/标准错误来传输数据,运行性能比较低,主要有两点:
每个请求都需要 Web 服务器去 fork 出 CGI 程序,频繁 fork 进程比较耗时。
CGI 程序每次都是从头运行,读配置、连接其他服务都得重新来,也比较耗时。
FastCGI 是对 CGI 的改进,FastCGI 模式下,Web 服务器和 FastCGI 程序传输数据的过程大体是:
Web 服务器收到的请求信息后,按 FastCGI 协议把请求信息通过 socket 发给 FastCGI 程序;
FastCGI 程序执行业务逻辑后,通过 socket 把响应数据返回给 Web 服务器,FastCGI 程序不 exit;
Web 服务器再组织成 HTTP 响应包发给浏览器。
对比 CGI 的通过,可以发现主要是少了每次 fork 的过程,并且用 socket 来传输数据,这是 FastCGI 接口更高效的原因。
FastCGI 有这些特点:
FastCGI 程序常驻内存,启动后可以反复处理请求。
FastCGI 就是进程池/线程池模型的通用同步服务器框架。
FastCGI 程序处理请求后不会退出,可以反复处理请求,那么在启动后就把配置解析、与其他后台的连接建立好,不用每次请求时搞一边,自然更快了。
至于这个 FastCGI 内部如何实现进城池/线程池,就是 FastCGI 进程管理器(FastCGI 引擎)的事情了。C/C++ FastCGI 常用 apache 的 mod_fastcgi 模块,PHP 常用 spawn-fcgi 和 PHP-FPM。
3.3 nginx 的反向代理
现在更多是用 nginx 的反向代理功能,把 HTTP 请求转发到后端的 trpc 服务直接处理。这里的 trpc 服务就有点 FastCGI 的感觉,但不用与 nginx 部署在一起了。
04
服务器模型谈
上节讲到 Web 服务器和 CGI/FastCGI 能动态输出内容,从而提供更强大的业务处理能力。Web 服务器这种架构,我称之为 Web 模式,与之相对的是 Svr 模式。Web 模式和 Svr 模式是互联网项目的后台最常见的两种模式。先介绍几个概念。
4.1 同步通讯 vs 异步通讯
同步通讯是指在一个连接中,一个请求的应答没回来前,不能发送下一个请求,整个通讯过程是请求1-应答1-请求2-应答2……这种。异步通讯与同步通讯相反,在一个连接中,可以随意发送请求,而且收到应答的顺序可能与发送请求的顺序不一致。
从描述上就能理解,同步通讯的通讯性能比异步低,但好处是简单,不用考虑乱序应答的复杂情况。
4.2 同步逻辑 vs 异步逻辑
同步逻辑是指在代码中遇到需要等待的调用时(例如向数据库查询数据),阻塞着,一直等待调用完成。异步逻辑则是不阻塞,继续执行后续代码。
我们常见的文件 IO 接口 read/write,网络 IO 接口 send/recv 默认都是同步的,需要执行特别的设置 API 才能变成非阻塞的。同步逻辑符合人脑的思维模式,写异步逻辑需要处理各种非阻塞和异常情况,极其挑战智力,就算采用有限状态机,也是件很具挑战的工作。
4.3 无状态 vs 有状态
CGI/FastCGI 每次执行时,会从数据层(db 或数据 cache)获得数据,修改后再写回到数据层,也就是说 CGI/FastCGI 并不会缓存数据。这就是无状态。
无状态的架构中,请求是这台 Web 服务器处理,还是那台处理,都没有区别,因为数据都是从数据层获得的。这种架构的扩容非常方便,但需注意,要防范一个请求同时多并发时,可能出现的数据不一致的漏洞,即要做防并发处理。
有状态是与无状态相对的概念,是指服务器中缓存了数据。这种架构中,因为不需要反复的从数据层取数据,性能会高很多,但因为服务器缓存了数据,为了保持数据一致性,只能把该数据的请求都分发到这台服务器来处理。对于游戏来说,每个区的用户数据是独立的,对交互的实时性要求高,采用有状态的架构正好合适。
4.4 Web 模式 vs Svr 模式
早期的浏览器如果要实现在线聊天,需要浏览器定时请求服务器获取聊天信息,Web 服务器无法主动给客户端推送消息。到 WebSocket 出来后才具备实时推送的能力。
Web 模式业务一般有这些特点:
是请求-应答式,即先客户端请求,才会有服务器应答(少数场景可借助 WebSocket 主动推送);
是同步通讯,一个连接里,只有收到应答后才能发下一个请求(HTTP2 可多路复用);
是同步逻辑,Web 模式较少采用异步逻辑;
是无状态架构,CGI/FastCGI 每次从数据层获取数据,修改后再写回到数据层。
Svr 模式就是与之相对的,客户端和服务器之间采用长连接,客户端的请求不一定会有应答,服务器还可以主动推送消息到客户端,通讯也不限定是同步的,客户端可以不断的发送请求,服务器的应答甚至可能与请求的顺序不一致。
Svr 模式相对 Web 模式来说,通讯性能更强,因为采用了长连接和异步通讯,还能主动推送消息,这是优势。但也因为采用了长连接和异步通讯,对客户端开发的要求就更高些,需要处理好断线重连和支持响应乱序。
Web 模式因为模式简单,Web 服务器自己实现了 HTTP 协议处理和 FastCGI 进程管理等通用操作,FastCGI 这些外部程序只需要处理业务逻辑就行,降低了很多门槛。而且因为是无状态的,扩容非常方便,直接加机器就能搞定,这个平滑扩容的优势在 Web 时代的作用非常大——搞性能优化、架构优化的时间成本比较大,而且不可控,加硬件就能快速抗住,是个好的方案。
05
数据层的演进
我们用一个做手游的故事来聊聊数据层不断优化提升的演进过程。
5.1 10:简单设计
有一天,老板突然说做个山寨版的糖果传奇手游,你接到任务后,分析出游戏的交互频率不大,都是点查询,用 MySQL 能简单搞定。建个表,设好主键和索引,你轻松搞定数据库设计,惬意的泡了杯茶边喝边敲代码。
这里说的“点查询”,是指基于指定主键的查询,例如查询指定用户的信息,因为是基于指定主键,查询结果有限且较少,点查询的效率非常高。另一种叫“面查询”,是基于主键或索引的范围查询,例如查询昨天所有的订单,这种查询虽然有主键或索引,但结果数量不确定,有时处理不好时会出现严重性能问题。
游戏删档内测上线了,用户数不多,请求的响应也很及时,老板拍了拍你的肩膀。
5.2 100:数据库调优
游戏上线反响不错,精美的画面给了玩家不少惊喜,更多玩家蜂拥而入,你从监控上发现 MySQL 的压力有点大,当初只是对数据库表结构做了设计,现在你开始 review 数据库优化了:修改 MySQL 参数加大 InnoDB 的 cache,不使用事务提交。
做了这些优化后,db 性能提升明显,整个系统跑得很欢,你又惬意的去泡茶了。
5.3 1000:分库分表
你们游戏山寨得比较牛叉,用户持续增加,作为有风险意识的你,肯定不会等到系统告警了才去优化,于是你在想更大访问量时怎么办?
单台 db 的性能有极限,必须有扩展到多台 db 的能力,于是你重新修改了数据库表结构和后台代码,把主键按规则做了分库分表,目前用户增长迅猛,假定单台 db 存放500万用户,最终可能有上亿用户,那么可能有20台 DB,于是你分了32个库,每个库里有32张表,共1024张表。
初始时这1024张表都在一台 db 上,当用户数增加时,分裂成2台、4台、8台、16台。涉及好分库分表策略后,db 压力能通过扩容来解决,你放心了。
PS:虽然使用 TDSQL 可以隐含的帮你分表,但有追求的你还是需要了解原始的分库分表做法。
5.4 关于读写分离
有不少介绍 MySQL 读写分离已提升 MySQL 并发性能的文章,在游戏项目中用得比较少,主要是读写比例的原因。像网站那种读多写少的应用场景可以采用读写分离,而游戏的读和写差不多多,读写分离的用处不大;而且用户可能是海量的,分多台 db 是常事,如果分库后再搞读写分离,整个 db 就过于复杂了。
MySQL 读写分离是基于 MySQL 主从复制功能的,现在的项目实用的 db 基本都是一主多备。如果项目有 OLAP 需求要直接查询 MySQL ,往往也是在 slave 上查询,不直接操作 master,避免低效查询降低 master 性能影响业务。这种做法也是 OLDP(On-Line Transaction Processing,联机事务处理)和 OLAP(On-Line Analytical Processing,联机分析处理)分离的常见做法。
5.5 10000:缓存
有了分库分表来平滑扩容,项目安稳了较长一段时间,直到某一天,运维说db机器增长比较快,4个月就增加到了64台(master+slave),希望后台能提升单台db的性能,以应对后续的业务增长。
OK,你祭出你留的后手——Redis,麻利的操起机械键盘,咔哒咔哒的改起后台的代码,加入缓存逻辑:读的时候从 Redis 读,如果没有就从 MySQL 查询并写入 Redis。写的时候同时写入 MySQL 和 Redis。
好吧,你终于使用了 NoSQL。搞定这个问题后,成本下降了,项目收入可观,happy!
5.6 为什么 Redis 性能比 MySQL 高?
首要因素是 Redis 的数据是在内存中,而用 MySQL 一般是希望数据持久化到磁盘的。从 IO 速度来说,内存 IO 比磁盘 IO 会快几个数量级,Redis 也就比 MySQL 性能更高。
架构和性能优化做到后面,会发现最终限制性能的是硬件瓶颈。例如 nginx 做静态 Webserver 时,出口流量往往能达到网卡的最大值或出口带宽的最大值。MySQL 是个性能不错的 db,但它的数据持久化在磁盘上,自然就受限于磁盘 IO 速度。
次要因素是 MySQL 支持完全的增删查改 SQL 操作,还得支持 where 条件组合,这种复杂性让 MySQL 的缓存机制有较大挑战,不像 Redis 这种 key-value 类型的操作是点查询,所以 MySQL 的缓存机制不如 Redis 那么简单有效。
总结一点就是,性能、通用性、成本三者难以同时满足。在成本一定的情况下,专用的高效,通用的低效。如何同时满足性能和通用性呢?那自然就是提高成本,最简单的方案是以前将 db 的机械硬盘升级到固态硬盘,性能提升很明显。
至此,从浏览器发起请求,到从 db 中获取数据并返回的整个链路,都简要分析了一遍,希望能帮你搭建一个知识框架,贯通你过往的知识点 :)
-End-
原创作者|thinkryzhu
往期推荐
深入理解 JavaScript 的 Promise 与 async/await
最后
欢迎加我微信,拉你进技术群,长期交流学习...
欢迎关注「前端Q」,认真学前端,做个专业的技术人...
点个在看支持我吧