小白debug

一起在知识的海洋里呛水

0%

文章持续更新,可以微信搜一搜「小白 debug」第一时间阅读,回复【面试】获面试题集。本文已经收录在 GitHub https://github.com/xiaobaiTech/golangFamily , 有大厂面试完整考点和成长路线,欢迎 Star。

话说,UDP 比 TCP 快吗?

相信就算不是八股文老手,也会下意识的脱口而出:”“。

这要追问为什么,估计大家也能说出个大概。

但这也让人好奇,用 UDP 就一定比用 TCP 快吗?什么情况下用 UDP 会比用 TCP 慢?

我们今天就来聊下这个话题。


使用 socket 进行数据传输

作为一个程序员,假设我们需要在 A 电脑的进程发一段数据到 B 电脑的进程,我们一般会在代码里使用 socket 进行编程。

socket 就像是一个电话或者邮箱(邮政的信箱)。当你想要发送消息的时候,拨通电话或者将信息塞到邮箱里,socket 内核会自动完成将数据传给对方的这个过程。

基于 socket 我们可以选择使用 TCP 或 UDP 协议进行通信。

对于 TCP 这样的可靠性协议,每次消息发出后都能明确知道对方收没收到,就像打电话一样,只要”喂喂”两下就能知道对方有没有在听。

而 UDP 就像是给邮政的信箱寄信一样,你寄出去的信,根本就不知道对方有没有正常收到,丢了也是有可能的。

这让我想起了大概 17 年前,当时还没有现在这么发达的网购,想买一本《掌机迷》杂志,还得往信封里塞钱,然后一等就是一个月,好几次都怀疑信是不是丢了。我至今印象深刻,因为那是我和我哥攒了好久的钱。。。


回到 socket 编程的话题上。

创建 socket 的方式就像下面这样。

1
fd = socket(AF_INET, 具体协议,0);

注意上面的”具体协议“,如果传入的是SOCK_STREAM,是指使用字节流传输数据,说白了就是TCP 协议

TCP是什么

如果传入的是SOCK_DGRAM,是指使用数据报传输数据,也就是UDP 协议

UDP是什么

返回的fd是指 socket 句柄,可以理解为 socket 的身份证号。通过这个fd你可以在内核中找到唯一的 socket 结构。

如果想要通过这个 socket 发消息,只需要操作这个 fd 就行了,比如执行 send(fd, msg, ...),内核就会通过这个 fd 句柄找到 socket 然后进行发数据的操作。

如果一切顺利,此时对方执行接收消息的操作,也就是 recv(fd, msg, ...),就能拿到你发的消息。

udp发送接收过程

对于异常情况的处理

但如果不顺利呢?

比如消息发到一半,丢包了呢?

丢包的原因有很多,之前写过的《用了 TCP 协议,就一定不会丢包吗?》有详细聊到过,这里就不再展开。

那 UDP 和 TCP 的态度就不太一样了。


UDP 表示,”哦,是吗?然后呢?关我 x 事”

TCP 态度就截然相反了,”啊?那可不行,是不是我发太快了呢?是不是链路太堵被别人影响到了呢?不过你放心,我肯定给你补发”

TCP 老实人石锤了。我们来看下这个老实人在背后都默默做了哪些事情。

重传机制

对于 TCP,它会给发出的消息打上一个编号(sequence),接收方收到后回一个**确认(ack)**。发送方可以通过ack的数值知道接收方收到了哪些sequence的包。

如果长时间等不到对方的确认,TCP 就会重新发一次消息,这就是所谓的重传机制

TCP重传


流量控制机制

但重传这件事本身对性能影响是比较严重的,所以是下下策

于是 TCP 就需要思考有没有办法可以尽量避免重传

因为数据发送方和接收方处理数据能力可能不同,因此如果可以根据双方的能力去调整发送的数据量就好了,于是就有了发送和接收窗口,基本上从名字就能看出它的作用,比如接收窗口的大小就是指,接收方当前能接收的数据量大小发送窗口的大小就指发送方当前能发的数据量大小。TCP 根据窗口的大小去控制自己发送的数据量,这样就能大大减少丢包的概率。

流量控制机制


滑动窗口机制

接收方的接收到数据之后,会不断处理,处理能力也不是一成不变的,有时候处理的快些,那就可以收多点数据,处理的慢点那就希望对方能少发点数据。毕竟发多了就有可能处理不过来导致丢包,丢包会导致重传,这可是下下策。因此我们需要动态的去调节这个接收窗口的大小,于是就有了滑动窗口机制

看到这里大家可能就有点迷了,流量控制和滑动窗口机制貌似很像,它们之间是啥关系?我总结一下。其实现在 TCP 是通过滑动窗口机制来实现流量控制机制的

滑动窗口机制


拥塞控制机制

但这还不够,有时候发生丢包,并不是因为发送方和接收方的处理能力问题导致的。而是跟网络环境有关,大家可以将网络想象为一条公路。马路上可能堵满了别人家的车,只留下一辆车的空间。那就算你家有 5 辆车,目的地也正好有 5 个停车位,你也没办法同时全部一起上路。于是 TCP 希望能感知到外部的网络环境,根据网络环境及时调整自己的发包数量,比如马路只够两辆车跑,那我就只发两辆车。但外部环境这么复杂,TCP 是怎么感知到的呢?

TCP 会先慢慢试探的发数据,不断加码数据量,越发越多,先发一个,再发 2 个,4 个…。直到出现丢包,这样 TCP 就知道现在当前网络大概吃得消几个包了,这既是所谓的拥塞控制机制

不少人会疑惑流量控制和拥塞控制的关系。我这里小小的总结下。流量控制针对的是单个连接数据处理能力的控制,拥塞控制针对的是整个网络环境数据处理能力的控制。

1663598420295


分段机制

但上面提到的都是怎么降低重传的概率,似乎重传这个事情就是无法避免的,那如果确实发生了,有没有办法降低它带来的影响呢?

有。当我们需要发送一个超大的数据包时,如果这个数据包丢了,那就得重传同样大的数据包。但如果我能将其分成一小段一小段,那就算真丢了,那我也就只需要重传那一小段就好了,大大减小了重传的压力,这就是 TCP 的分段机制

而这个所谓的一小段的长度,在传输层叫MSSMaximum Segment Size),数据包长度大于 MSS 则会分成 N 个小于等于 MSS 的包。

MSS分包

而在网络层,如果数据包还大于MTU(Maximum Transmit Unit),那还会继续分包。

MTU分包

一般情况下,MSS=MTU-40Byte,所以TCP 分段后,到了 IP 层大概率就不会再分片了

MSS和MTU的区别


乱序重排机制

既然数据包会被分段,链路又这么复杂还会丢包,那数据包乱序也就显得不奇怪了。比如发数据包 1,2,3。1 号数据包走了其他网络路径,2 和 3 数据包先到,1 数据包后到,于是数据包顺序就成了 2,3,1。这一点 TCP 也考虑到了,依靠数据包的sequence,接收方就能知道数据包的先后顺序。

后发的数据包先到是吧,那就先放到专门的乱序队列中,等数据都到齐后,重新整理好乱序队列的数据包顺序后再给到用户,这就是乱序重排机制

乱序队列等待数据包的到来


连接机制

前面提到,UDP 是无连接的,而 TCP 是面向连接的。

这里提到的连接到底是啥?

TCP 通过上面提到的各种机制实现了数据的可靠性。这些机制背后是通过一个个数据结构来实现的逻辑。而为了实现这套逻辑,操作系统内核需要在两端代码里维护一套复杂的状态机(三次握手,四次挥手,RST,closing 等异常处理机制),这套状态机其实就是所谓的”连接”。这其实就是 TCP 的连接机制,而 UDP 用不上这套状态机,因此它是”无连接”的。


网络环境链路很长,还复杂,数据丢包是很常见的。

我们平常用 TCP 做各种数据传输,完全对这些事情无感知。

哪有什么岁月静好,是 TCP 替你负重前行。

这就是 TCP 三大特性”面向连接、可靠的、基于字节流”中”可靠“的含义。

不信你改用 UDP 试试,丢包那就是真丢了,丢到你怀疑人生。


用 UDP 就一定比用 TCP 快吗?

这时候 UDP 就不服了:”正因为没有这些复杂的 TCP 可靠性机制,所以我很快啊

嗯,这也是大部分人认为 UDP 比 TCP 快的原因。

实际上大部分情况下也确实是这样的。这话没毛病。


那问题就来了。

有没有用了 UDP 但却比 TCP 慢的情况呢?

其实也有。

在回答这个问题前,我需要先说下UDP 的用途

实际上,大部分人也不会尝试直接拿裸 udp放到生产环境中去做项目。

那 UDP 的价值在哪?

在我看来,UDP 的存在,本质是内核提供的一个最小网络传输功能

很多时候,大家虽然号称自己用了 UDP,但实际上都很忌惮它的丢包问题,所以大部分情况下都会在 UDP 的基础上做各种不同程度的应用层可靠性保证。比如王者农药用的KCP,以及最近很火的QUIC(HTTP3.0),其实都在 UDP 的基础上做了重传逻辑,实现了一套类似TCP 那样的可靠性机制。

教科书上最爱提 UDP 适合用于音视频传输,因为这些场景允许丢包。但其实也不是什么包都能丢的,比如重要的关键帧啥的,该重传还得重传。除此之外,还有一些乱序处理机制。举个例子吧。

打音视频电话的时候,你可能遇到过丢失中间某部分信息的情况,但应该从来没遇到过乱序的情况吧。

比如对方打网络电话给你,说了:”我好想给小白来个点赞在看!

这时候网络信号不好,你可能会听到”我….点赞在看”。

但却从来没遇到过”在看小白好想赞”这样的乱序场景吧?

所以说,虽然选择了使用 UDP,但一般还是会在应用层上做一些重传机制的

于是问题就来了,如果现在我需要传一个特别大的数据包

TCP里,它内部会根据MSS的大小分段,这时候进入到 IP 层之后,每个包大小都不会超过MTU,因此 IP 层一般不会再进行分片。这时候发生丢包了,只需要重传每个 MSS 分段就够了。

TCP分段

但对于UDP,其本身并不会分段,如果数据过大,到了 IP 层,就会进行分片。此时发生丢包的话,再次重传,就会重传整个大数据包

UDP不分段

对于上面这种情况,使用 UDP 就比 TCP 要慢

当然,解决起来也不复杂。这里的关键点在于是否实现了数据分段机制,使用 UDP 的应用层如果也实现了分段机制的话,那就不会出现上述的问题了


总结

  • TCP 为了实现可靠性,引入了重传机制、流量控制、滑动窗口、拥塞控制、分段以及乱序重排机制。而 UDP 则没有实现,因此一般来说 TCP 比 UDP 慢。
  • TCP 是面向连接的协议,而 UDP 是无连接的协议。这里的”连接“其实是,操作系统内核在两端代码里维护的一套复杂状态机。
  • 大部分项目,会在基于 UDP 的基础上,模仿 TCP,实现不同程度的可靠性机制。比如王者农药用的 KCP 其实就在基于 UDP 在应用层里实现了一套重传机制。
  • 对于 UDP+重传的场景,如果要传超大数据包,并且没有实现分段机制的话,那数据就会在 IP 层分片,一旦丢包,那就需要重传整个超大数据包。而 TCP 则不需要考虑这个,内部会自动分段,丢包重传分段就行了。这种场景下,其实 TCP 更快。

最后

最近原创更文的阅读量稳步下跌,思前想后,夜里辗转反侧。

我有个不成熟的请求。


离开广东好长时间了,好久没人叫我靓仔了。

大家可以在评论区里,叫我一靓仔吗?

我这么善良质朴的愿望,能被满足吗?

如果实在叫不出口的话,能帮我点下关注和右下角的点赞+收藏吗?

如果评论区没人叫我靓仔,文章也没人点赞,我感觉我下篇文章要开始收费了,价钱我都想好了,8 块 8,毕竟男人都拒绝不了这种价格以 8 结尾的项目。

你说是吧,易峰。


别说了,一起在知识的海洋里呛水吧

点击下方名片,关注公众号:【小白 debug】


不满足于在留言区说骚话?

加我,我们建了个划水吹牛皮群,在群里,你可以跟你下次跳槽可能遇到的同事或面试官聊点有意思的话题。就超!开!心!


文章推荐:

文章持续更新,可以微信搜一搜「小白 debug」第一时间阅读,回复【面试】获面试题集。本文已经收录在 GitHub https://github.com/xiaobaiTech/golangFamily , 有大厂面试完整考点和成长路线,欢迎 Star。


平时我们打开网页,比如购物网站某宝。都是点一下列表商品,跳转一下网页就到了商品详情

从 HTTP 协议的角度来看,就是点一下网页上的某个按钮,前端发一次 HTTP 请求,网站返回一次 HTTP 响应

这种由客户端主动请求,服务器响应的方式也满足大部分网页的功能场景。

但有没有发现,这种情况下,服务器从来就不会主动给客户端发一次消息。

就像你喜欢的女生从来不会主动找你一样。


但如果现在,你在刷网页的时候右下角突然弹出一个小广告,提示你【一个人在家偷偷才能玩哦】。

求知,好学,勤奋,这些刻在你 DNA 里的东西都动起来了。

你点开后发现。

长相平平无奇的古某提示你”道士 9 条狗,全服横着走”。

影帝某辉老师跟你说”系兄弟就来砍我”。

来都来了,你就选了个角色进到了游戏界面里。

创建角色页面

这时候,上来就是一个小怪,从远处走来,然后疯狂拿木棒子抽你。

你全程没点任何一次鼠标。服务器就自动将怪物的移动数据和攻击数据源源不断发给你了。

这….太暖心了。

感动之余,问题就来了,

像这种看起来服务器主动发消息给客户端的场景,是怎么做到的?

在真正回答这个问题之前,我们先来聊下一些相关的知识背景。


使用 HTTP 不断轮询

其实问题的痛点在于,怎么样才能在用户不做任何操作的情况下,网页能收到消息并发生变更。

最常见的解决方案是,网页的前端代码里不断定时发 HTTP 请求到服务器,服务器收到请求后给客户端响应消息。

这其实时一种服务器推的形式。

它其实并不是服务器主动发消息到客户端,而是客户端自己不断偷偷请求服务器,只是用户无感知而已。

用这种方式的场景也有很多,最常见的就是扫码登录

比如某信公众号平台,登录页面二维码出现之后,前端网页根本不知道用户扫没扫,于是不断去向后端服务器询问,看有没有人扫过这个码。而且是以大概 1 到 2 秒的间隔去不断发出请求,这样可以保证用户在扫码后能在 1 到 2s 内得到及时的反馈,不至于等太久

使用HTTP定时轮询

但这样,会有两个比较明显的问题

  • 当你打开 F12 页面时,你会发现满屏的 HTTP 请求。虽然很小,但这其实也消耗带宽,同时也会增加下游服务器的负担。
  • 最坏情况下,用户在扫码后,需要等个 1~2s,正好才触发下一次 http 请求,然后才跳转页面,用户会感到明显的卡顿

使用起来的体验就是,二维码出现后,手机扫一扫,然后在手机上点个确认,这时候卡顿等个 1~2s,页面才跳转。

不断轮询查看是否有扫码

那么问题又来了,有没有更好的解决方案?

有,而且实现起来成本还非常低。


长轮询

我们知道,HTTP 请求发出后,一般会给服务器留一定的时间做响应,比如 3s,规定时间内没返回,就认为是超时。

如果我们的 HTTP 请求将超时设置的很大,比如 30s,在这 30s 内只要服务器收到了扫码请求,就立马返回给客户端网页。如果超时,那就立马发起下一次请求。

这样就减少了 HTTP 请求的个数,并且由于大部分情况下,用户都会在某个 30s 的区间内做扫码操作,所以响应也是及时的。

长轮询

比如,某度云网盘就是这么干的。所以你会发现一扫码,手机上点个确认,电脑端网页就秒跳转,体验很好。

长轮询的方式来替代

真一举两得。

像这种发起一个请求,在较长时间内等待服务器响应的机制,就是所谓的长轮询机制。我们常用的消息队列 RocketMQ 中,消费者去取数据时,也用到了这种方式。

RocketMQ的消费者通过长轮询获取数据

像这种,在用户不感知的情况下,服务器将数据推送给浏览器的技术,就是所谓的服务器推送技术,它还有个毫不沾边的英文名,comet技术,大家听过就好。


上面提到的两种解决方案,本质上,其实还是客户端主动去取数据。

对于像扫码登录这样的简单场景还能用用。

但如果是网页游戏呢,游戏一般会有大量的数据需要从服务器主动推送到客户端。

这就得说下websocket了。


websocket 是什么

我们知道 TCP 连接的两端,同一时间里双方都可以主动向对方发送数据。这就是所谓的全双工

而现在使用最广泛的HTTP1.1,也是基于 TCP 协议的,同一时间里,客户端和服务器只能有一方主动发数据,这就是所谓的半双工

也就是说,好好的全双工 TCP,被 HTTP 用成了半双工。

为什么?

这是由于 HTTP 协议设计之初,考虑的是看看网页文本的场景,能做到客户端发起请求再由服务器响应,就够了,根本就没考虑网页游戏这种,客户端和服务器之间都要互相主动发大量数据的场景。

所以为了更好的支持这样的场景,我们需要另外一个基于 TCP 的新协议

于是新的应用层协议websocket就被设计出来了。

大家别被这个名字给带偏了。虽然名字带了个 socket,但其实 socket 和 websocket 之间,就跟雷峰和雷峰塔一样,二者接近毫无关系

websocket在四层网络协议中的位置


怎么建立 websocket 连接

我们平时刷网页,一般都是在浏览器上刷的,一会刷刷图文,这时候用的是HTTP 协议,一会打开网页游戏,这时候就得切换成我们新介绍的websocket 协议

为了兼容这些使用场景。浏览器在TCP 三次握手建立连接之后,都统一使用 HTTP 协议先进行一次通信。

  • 如果此时是普通的 HTTP 请求,那后续双方就还是老样子继续用普通 HTTP 协议进行交互,这点没啥疑问。
  • 如果这时候是想建立 websocket 连接,就会在 HTTP 请求里带上一些特殊的 header 头
1
2
3
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: T2a6wZlAwhgQNqruZ2YUyg==\r\n

这些 header 头的意思是,浏览器想升级协议(Connection: Upgrade),并且想升级成 websocket 协议(Upgrade: websocket)

同时带上一段随机生成的 base64 码(Sec-WebSocket-Key),发给服务器。

如果服务器正好支持升级成 websocket 协议。就会走 websocket 握手流程,同时根据客户端生成的 base64 码,用某个公开的算法变成另一段字符串,放在 HTTP 响应的 Sec-WebSocket-Accept 头里,同时带上101状态码,发回给浏览器。

1
2
3
4
HTTP/1.1 101 Switching Protocols\r\n
Sec-WebSocket-Accept: iBJKv/ALIW2DobfoA4dmr3JHBCY=\r\n
Upgrade: websocket\r\n
Connection: Upgrade\r\n

http 状态码=200(正常响应)的情况,大家见得多了。101 确实不常见,它其实是指协议切换

base64转为新的字符串

之后,浏览器也用同样的公开算法base64码转成另一段字符串,如果这段字符串跟服务器传回来的字符串一致,那验证通过。

对比客户端和服务端生成的字符串

就这样经历了一来一回两次 HTTP 握手,websocket 就建立完成了,后续双方就可以使用 webscoket 的数据格式进行通信了。

建立websocket连接.drawio


websocket 抓包

我们可以用 wireshark 抓个包,实际看下数据包的情况。

客户端请求升级为websocket

上面这张图,注意画了红框的第2445行报文,是 websocket 的第一次握手,意思是发起了一次带有特殊Header的 HTTP 请求。

服务器同意升级为websocket协议

上面这个图里画了红框的4714行报文,就是服务器在得到第一次握手后,响应的第二次握手,可以看到这也是个 HTTP 类型的报文,返回的状态码是 101。同时可以看到返回的报文 header 中也带有各种websocket相关的信息,比如Sec-WebSocket-Accept

两次HTTP请求之后正式使用websocket通信

上面这张图就是全貌了,从截图上的注释可以看出,websocket 和 HTTP 一样都是基于 TCP 的协议。经历了三次 TCP 握手之后,利用 HTTP 协议升级为 websocket 协议。

你在网上可能会看到一种说法:”websocket 是基于 HTTP 的新协议”,其实这并不对,因为 websocket 只有在建立连接时才用到了 HTTP,升级完成之后就跟 HTTP 没有任何关系了

这就好像你喜欢的女生通过你要到了你大学室友的微信,然后他们自己就聊起来了。你能说这个女生是通过你去跟你室友沟通的吗?不能。你跟 HTTP 一样,都只是个工具人

这就有点”借壳生蛋“的那意思。

HTTP和websocket的关系


websocket 的消息格式

上面提到在完成协议升级之后,两端就会用 webscoket 的数据格式进行通信。

数据包在 websocket 中被叫做

我们来看下它的数据格式长什么样子。

websocket报文格式

这里面字段很多,但我们只需要关注下面这几个。

opcode 字段:这个是用来标志这是个什么类型的数据帧。比如。

  • 等于 1 时是指 text 类型(string)的数据包
  • 等于 2 是二进制数据类型([]byte)的数据包
  • 等于 8 是关闭连接的信号

payload 字段:存放的是我们真正想要传输的数据的长度,单位是字节。比如你要发送的数据是字符串"111",那它的长度就是3

另外,可以看到,我们存放payload 长度的字段有好几个,我们既可以用最前面的7bit, 也可以用后面的7+16bit或7+64bit。

那么问题就来了。

我们知道,在数据层面,大家都是 01 二进制流。我怎么知道什么情况下应该读 7bit,什么情况下应该读 7+16bit 呢?

websocket 会用最开始的 7bit 做标志位。不管接下来的数据有多大,都先读最先的 7 个 bit,根据它的取值决定还要不要再读个 16bit 或 64bit。

  • 如果最开始的7bit的值是 0~125,那么它就表示了 payload 全部长度,只读最开始的7个bit就完事了。

payload长度在0到125之间

  • 如果是126(0x7E)。那它表示 payload 的长度范围在 126~65535 之间,接下来还需要再读 16bit。这 16bit 会包含 payload 的真实长度。

payload长度在126到65535之间

  • 如果是127(0x7F)。那它表示 payload 的长度范围>=65536,接下来还需要再读 64bit。这 64bit 会包含 payload 的长度。这能放 2 的 64 次方 byte 的数据,换算一下好多个 TB,肯定够用了。

payload长度大于等于65536的情况

payload data 字段:这里存放的就是真正要传输的数据,在知道了上面的 payload 长度后,就可以根据这个值去截取对应的数据。

大家有没有发现一个小细节,websocket 的数据格式也是 数据头(内含 payload 长度) + payload data 的形式。

之前写的《既然有 HTTP 协议,为什么还要有 RPC》提到过,TCP 协议本身就是全双工,但直接使用纯裸 TCP去传输数据,会有粘包的”问题”。为了解决这个问题,上层协议一般会用消息头+消息体的格式去重新包装要发的数据。

消息头里一般含有消息体的长度,通过这个长度可以去截取真正的消息体。

HTTP 协议和大部分 RPC 协议,以及我们今天介绍的 websocket 协议,都是这样设计的。

消息边界长度标志


websocket 的使用场景

websocket 完美继承了 TCP 协议的全双工能力,并且还贴心的提供了解决粘包的方案。它适用于需要服务器和客户端(浏览器)频繁交互的大部分场景。比如网页/小程序游戏,网页聊天室,以及一些类似飞书这样的网页协同办公软件。

回到文章开头的问题,在使用 websocket 协议的网页游戏里,怪物移动以及攻击玩家的行为是服务器逻辑产生的,对玩家产生的伤害等数据,都需要由服务器主动发送给客户端,客户端获得数据后展示对应的效果。

websocket的使用场景


总结

  • TCP 协议本身是全双工的,但我们最常用的 HTTP1.1,虽然是基于 TCP 的协议,但它是半双工的,对于大部分需要服务器主动推送数据到客户端的场景,都不太友好,因此我们需要使用支持全双工的 websocket 协议。
  • 在 HTTP1.1 里。只要客户端不问,服务端就不答。基于这样的特点,对于登录页面这样的简单场景,可以使用定时轮询或者长轮询的方式实现服务器推送(comet)的效果。
  • 对于客户端和服务端之间需要频繁交互的复杂场景,比如网页游戏,都可以考虑使用 websocket 协议。
  • websocket 和 socket 几乎没有任何关系,只是叫法相似。
  • 正因为各个浏览器都支持 HTTP 协议,所以 websocket 会先利用 HTTP 协议加上一些特殊的 header 头进行握手升级操作,升级成功后就跟 HTTP 没有任何关系了,之后就用 websocket 的数据格式进行收发数据。


最后

最近原创更文的阅读量稳步下跌,思前想后,夜里辗转反侧。

我有个不成熟的请求。


离开广东好长时间了,好久没人叫我靓仔了。

大家可以在评论区里,叫我一靓仔吗?

我这么善良质朴的愿望,能被满足吗?

如果实在叫不出口的话,能帮我点下关注和右下角的点赞+在看吗?


别说了,一起在知识的海洋里呛水吧

点击下方名片,关注公众号:【小白 debug】


不满足于在留言区说骚话?

加我,我们建了个划水吹牛皮群,在群里,你可以跟你下次跳槽可能遇到的同事或面试官聊点有意思的话题。就超!开!心!


文章推荐:

文章持续更新,可以微信搜一搜「小白 debug」第一时间阅读,回复【面试】获面试题集。本文已经收录在 GitHub https://github.com/xiaobaiTech/golangFamily , 有大厂面试完整考点和成长路线,欢迎 Star。


平时,我们想要知道,自己的机器到目的机器之间,网络通不通,一般会执行ping 命令

一般对于状况良好的网络来说,你能看到它对应的loss丢包率为0%,也就是所谓的能 ping 通。如果看到丢包率100%,也就是ping 不通

ping正常

ping不通


那么问题来了,假设我能ping通某台机器,那这时候如果我改用TCP 协议去发数据到目的机器,也一定能通吗?

或者换个问法,ping 和 tcp 协议走的网络路径是一样的吗?


这时候第一反应就是不一定,因为 ping 完之后中间链路里的某个路由器可能会挂了(断电了),再用 TCP 去连就会走别的路径。

也没错。但假设,中间链路没发生任何变化呢?

我先直接说答案。

不一定,走的网络路径还是有可能是不同的。

今天就来聊聊为什么。


我之前写过一篇《断网了,还能 ping 通 127.0.0.1 吗?》,里面提到过ping 数据包和 tcp 数据包的区别

ping和TCP发消息的区别


我们知道网络是分层的,每一层都有对应协议。

五层网络协议对应的消息体变化分析

而这网络层就像搭积木一样,上层协议都是基于下层协议搭出来的。

不管是 ping(用了 ICMP 协议)还是 tcp 本质上都是基于网络层 IP 协议的数据包,而到了物理层,都是二进制 01 串,都走网卡发出去了。

如果网络环境没发生变化,目的地又一样,那按道理说他们走的网络路径应该是一样的,什么情况下会不同呢?

我们就从路由这个话题聊起吧。


网络路径

在我们的想象中,当我们想在两台机器之间传输数据。本机和目的机器之间会建立一条连接,像一条管道一样,数据从这头到那头。这条管道其实是我们为了方便理解而抽象出来的概念。

实际上,我们将数据包从本地网卡发出之后,会经过各种路由器(或者交换机),才能到达目的机器。

这些路由器数量众多,相互之间可以互连,连起来之后就像是一张大网,所以叫**”网络”**可以说是非常的形象。

路由器构成的网络

考虑到交换机有的功能,路由器基本上都支持,所以我们这边只讨论路由器。

那么现在问题来了,路由器收到数据后,怎么知道应该走哪条路径,传给哪个路由器?


路径由什么决定?

在上面的那么大一张网络中,随便一个路由器都有可能走任何一个路径,将数据发到另外一个路由器上,

但路由和路由之间距离,带宽啥的可能都不同。

于是就很需要知道,两点之间走哪条路才是最优路径

于是问题就变成了这样一个图状结构。每条边都带有成本或权重,算这上面任意两点的最短距离

路由器和Dijkstra

这时候想必大家回忆压不住要上来了。

这题我熟,这就是大学时候刷的Dijkstra 算法。菊花厂的 OJ 笔试题集里也经常出现,现在终于明白为什么他们家的笔试题里图类题目比别的大厂貌似要多一些了吧,因为菊花厂就是搞通信的,做路由器的老玩家了。


路由表的生成

基于Dijkstra 算法,封装出了一个新的协议,OSPF 协议Open Shortest Path First, 开放最短路径优先)。

有了 OSPF,路由器就得到了网络图里自己到其他点之间的最短距离,于是就知道了数据包要到某个点,该走哪条最优路径

将这些信息汇成一张表,也就是我们常说的路由表

路由表里记录了到什么 IP 需要走什么端口,以及走这条路径的成本(metric)。

可以通过 route 命令查看到。

route表


路由表决定数据包路径

数据包在发送的过程中,会在网络层加入目标地址 IP

路由器会根据这个IP路由表去做匹配。

然后路由表,会告诉路由器,什么样的消息该转发到什么端口。

举个例子。

通过路由表转发数据

假设 A 要发消息到 D。也就是192.168.0.105/24要发消息到192.168.1.11/24

那么 A 会把消息经发到路由器。

路由器已知目的地 IP192.168.1.11/24 ,去跟路由表做匹配,发现192.168.1.0/24, 就在 e2 端口,那么就会把消息从 e2 端口发出,(可能还会经过交换机)最后把消息打到目的机器。

当然,如果路由表里找不到,那就打到默认网关吧,也就是从 e1 口发出,发到 IP192.0.2.1这个路由器的路由表不知道该去哪,说不定其他路由器知道


路由表的匹配规则

上面的例子里,是只匹配上了路由表里的一项,所以只能是它了。

但是,条条大路通罗马。实际上能到目的地的路径肯定有很多。

如果路由表里有很多项都被匹配上了,会怎么选?


如果多个路由项都能到目的地,那就优先选匹配长度更长的那个。比如,还是目的地192.168.1.11,发现路由表里的192.168.1.0/24192.168.0.0/16都能匹配上,但明显前者匹配长度更长,所以最后会走 192.168.1.0/24对应的转发端口。

但如果两个表项的匹配长度都一样呢?

那就会看生成这个路由表项的协议是啥,选优先级高的,优先级越高也就是所谓的管理距离ADAdministrativeDistance)越小。比如说优先选手动配的静态(static)路由,次优选OSPF动态学习过来的表项。

如果还是相同,就看度量值 metrics,其实也就是路径成本 cost,成本越小,越容易被选中。

路由器能选的路线有很多,但按道理,最优的只有”一条”,所以到这里为止,我们都可以认为,对于同一个目的地,ping 和 TCP 走的路径是相同的。

但是。

如果连路径成本都一样呢?也就是说有多条最优路径呢。

那就都用

这也就是所谓的等价多路径,ECMPEqual Cost MultiPath)。

我们可以通过traceroute看下链路是否存在等价多路径的情况。

可以看到,中间某几行,有好几个 IP,也就是说这一跳里同时可以选好几个目的机器,说明这段路径支持 ECMP


ECMP 有什么用

利用等价多路径,我们可以增加链路带宽

举个例子。

没有ECMP时只能选择某一条路径

从 A 点到 B 点,如果这两条路径成本不同,带宽都是1千兆。那数据包肯定就选成本低的那条路了,如果这条路出故障了,就走下面那条路。但不管怎么样,同一时间,只用到了一条路径。另外一条闲置就有些浪费了,有没有办法可以利用起来呢?

有,将它们两条路径的成本设置成一样,那它们就成了等价路由,然后中间的路由器开启ECMP特性,就可以同时利用这两条链路了。带宽就从原来的1千兆变成了2千兆。数据就可以在两条路径中随意选择了。

利用ECMP可以同时使用两条链路

但这也带来了另外一个问题。加剧了数据包乱序

原来我只使用一条网络路径,数据依次发出,如无意外,也是依次到达。

现在两个数据包走两条路径,先发的数据包可能后到。这就乱序了。

那么问题又又来了。


乱序会有什么问题?

对于我们最最最常使用的 TCP 协议来说,它是个可靠性网络的协议,这里提到的可靠,不仅是保证数据要能送到目的地,还要保证数据顺序要跟原来发送端的一样。

实现也很简单,TCP 为每个数据包(segment)做上编号。数据到了接收端后,根据数据包编号发现是乱序数据包,就会扔到乱序队列中对数据包进行排序。如果前面的数据包还没到,哪怕后面的数据包先到了,也得在乱序队列中一直等,到齐后才能被上层拿到。

举个例子,发送端发出三个数据包,编号1,2,3,假设在传输层2和3先到了,1还没到。那此时应用层是没办法拿到2和3的数据包的,必须得等1来了之后,应用层才能一次性拿到这三个包。因为这三个包原来可能表示的是一个完整的消息,少了 1, 那么消息就不完整,应用层拿到了也毫无意义。

像这种,由于前面的数据丢失导致后面的数据没办法及时给到应用层的现象,就是我们常说的TCP 队头阻塞

乱序队列等待数据包的到来

乱序发生时2和3需要待在乱序队列中,而乱序队列其实用的也是接收缓冲区的内存,而接收缓冲区是有大小限制的。通过下面的命令可以看到接收缓冲区的大小。

1
2
3
4
# 查看接收缓冲区
$ sysctl net.ipv4.tcp_rmem
net.ipv4.tcp_rmem = 4096(min) 87380(default) 6291456(max)
# 缓冲区会在min和max之间动态调整

乱序的情况越多,接收缓冲区的内存就被占用的越多,对应的接收窗口就会变小,那正常能收的数据就变少了,网络吞吐就变差了,也就是性能变差了。

因此,我们需要尽量保证所有同一个 TCP 连接下的所有 TCP 包都走相同路径,这样才能最大程度避免丢包


ECMP 的路径选择策略

当初开启 ECMP 就是为了提升性能,现在反而加重了乱序,降低了 TCP 传输性能。

这怎么能忍。

为了解决这个问题,我们需要有一个合理的路径选择策略。为了避免同一个连接里的数据包乱序,我们需要保证同一个连接里的数据包,都走同样的路径。

这好办。我们可以通过连接的五元组(发送方的IP端口,接收方的IP端口,以及通信协议)信息定位到唯一一条连接。

五元组

然后对五元组信息生成哈希键,让同一个哈希键的数据走同一条路径,问题就完美解决了。

五元组映射成hash键

根据五元组选择ECMP路径


TCP 和 Ping 走的网络路径一样吗

现在我们回到文章开头的问题。

对于同样的发送端和接收端,TCP 和 Ping 走的网络路径一样吗?

不一定一样,因为五元组里的信息里有一项是通信协议。ping 用的是ICMP 协议,跟TCP 协议不同,并且 ping 不需要用到端口,所以五元组不同,生成的哈希键不同,通过 ECMP 选择到的路径也可能不同。

TCP和ping的五元组差异


同样都用 TCP 协议,数据包走的网络路径一样吗

还是同样的发送端和接收端,同样是 TCP 协议,不同 TCP 连接走的网络路径是一样的吗?

跟上面的问题一样,其实还是五元组的问题,同样都是 TCP 协议,对于同样的发送端和接收端,他们的 IP 和接收端的端口肯定是一样的,但发送方的端口是可以随时变化的,因此通过 ECMP 走的路径也可能不同。

不同TCP连接的五元组差异


但问题又来了。

我知道这个有什么用呢?我做业务开发,又没有设置网络路由的权限。


利用这个知识点排查问题

对于业务开发,这绝对不是个没用的知识点。

如果某天,你发现,你能 ping 通目的机器,但用 TCP 去连,却偶尔连不上目的机器。而且两端机器都挺空闲,没什么性能上的瓶颈。实在走投无路了。

你就可以想想,会不会是网络中用到了ECMP,其中一条链路有问题导致的。

ping能成功但部分TCP连接失败

排查方法也很简单。

你是知道本机的 IP 以及目的机器的 IP 和端口号的,也知道自己用的是 TCP 连接。

只要你在报错的时候打印下错误信息,你就知道了发送端的端口号了。

这样五元组是啥你就知道了。

下一步就是指定发送端的端口号重新发起 TCP 请求,同样的五元组,走同样的路径,按理说如果链路有问题,就肯定会复现。

如果不想改自己的代码,你可以用nc 命令指定客户端端口看下能不能正常建立 TCP 连接。

1
nc -p 6666 baidu.com 80

-p 6666是指定发出请求的客户端端口是6666,后面跟着的是连接的域名80 端口

通过nc成功建立tcp连接

假设用了6666端口的五元组去连接总是失败,改用6667或其他端口却能成功,你可以带着这个信息去找找负责网络的同事。


总结

  • 路由器可以通过 OSPF 协议生成路由表,利用数据包里的 IP 地址去跟路由表做匹配,选择最优路径后进行转发。
  • 当路由表一个都匹配不上时会走默认网关。当匹配上多个的时候,会先看匹配长度,如果一样就看管理距离,还一样就看路径成本。如果连路径成本都一样,那等价路径。如果路由开启了 ECMP,那就可以同时利用这几条路径做传输。
  • ECMP 可以提高链路带宽,同时利用五元组做哈希键进行路径选择,保证了同一条连接的数据包走同一条路径,减少了乱序的情况。
  • 可以通过 traceroute 命令查看到链路上是否有用到 ECMP 的情况。
  • 开启了 ECMP 的网络链路中,TCP 和 ping 命令可能走的路径不同,甚至同样是 TCP,不同连接之间,走的路径也不同,因此出现了连接时好时坏的问题,实在是走投无路了,可以考虑下是不是跟 ECMP 有关。
  • 当然,遇到问题多怀疑自己,要相信绝大部分时候真的跟 ECMP 无关

参考资料

《网络排查案例课》 ——极客时间


最后

兄弟们。

按照惯例,我应该在这里唯唯诺诺的求大家叫我两声靓仔的。

但我今天不想。

因为越是这样,评论区里叫我 diao 毛的兄弟就越多。

上海快 40° 的天气,你们竟然能说出如此冰冷的话。

但是。

只要你们还能给我文章右下角来个点赞和在看的话。

这口气,我还能忍。


别说了,一起在知识的海洋里呛水吧

点击下方名片,关注公众号:【小白 debug】


不满足于在留言区说骚话?

加我,我们建了个划水吹牛皮群,在群里,你可以跟你下次跳槽可能遇到的同事或面试官聊点有意思的话题。就超!开!心!

文章推荐:

文章持续更新,可以微信搜一搜「小白 debug」第一时间阅读,回复【面试】获面试题集。本文已经收录在 GitHub https://github.com/xiaobaiTech/golangFamily , 有大厂面试完整考点和成长路线,欢迎 Star。

搬运一个在某乎的回答,水一篇文章吧。

TCP四次挥手

正常情况下。只要数据传输完了,不管是客户端还是服务端,都可以主动发起四次挥手,释放连接。

就跟上图画的一样,假设,这次四次挥手是由客户端主动发起的,那它就是主动方。服务器是被动接收客户端的挥手请求的,叫被动方

客户端和服务器,一开始,都是处于ESTABLISHED状态。

第一次挥手:一般情况下,主动方执行close()shutdown()方法,会发个FIN报文出来,表示”我不再发送数据了“。

第二次挥手:在收到主动方的FIN报文后,被动方立马回应一个ACK,意思是”我收到你的 FIN 了,也知道你不再发数据了”。

上面提到的是主动方不再发送数据了。但如果这时候,被动方还有数据要发,那就继续发。注意,虽然第二次和第三次挥手之间,被动方是能发数据到主动方的,但主动方能不能正常收就不一定了,这个待会说。

第三次挥手:在被动方在感知到第二次挥手之后,会做了一系列的收尾工作,最后也调用一个 close(), 这时候就会发出第三次挥手的 FIN-ACK

第四次挥手:主动方回一个ACK,意思是收到了。

其中第一次挥手和第三次挥手,都是我们在应用程序中主动触发的(比如调用close()方法),也就是我们平时写代码需要关注的地方。

第二和第四次挥手,都是内核协议栈自动帮我们完成的,我们写代码的时候碰不到这地方,因此也不需要太关心。

另外不管是主动还是被动,每方发出了一个 FIN 和一个ACK 。也收到了一个 FIN 和一个ACK

回到题主的问题。

TCP 四次挥手中如果服务端没收到第四次挥手请求,服务端会一直等待吗?

第四次挥手是第三次挥手触发的。如果第四次挥手服务端一直没收到,那服务端会认为是不是自己的第三次挥手丢了,于是服务端不断重试发第三次挥手(FIN).重发次数由系统的 tcp_orphan_retries 参数控制。重试多次还没成功,服务端直接断开链接。所以结论是服务端不会一直等待第四次挥手。

TCP第四次挥手丢失

1
2
# cat /proc/sys/net/ipv4/tcp_orphan_retries
0

另外,你会发现tcp_orphan_retries参数是 0,但其实并不是不重试的意思。为 0 时,默认值为 8. 也就是重试 8 次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* Calculate maximal number or retries on an orphaned socket. */
static int tcp_orphan_retries(struct sock *sk, int alive)
{
int retries = sysctl_tcp_orphan_retries; /* May be zero. */

/* We know from an ICMP that something is wrong. */
if (sk->sk_err_soft && !alive)
retries = 0;

/* However, if socket sent something recently, select some safe
* number of retries. 8 corresponds to >100 seconds with minimal
* RTO of 200msec. */
if (retries == 0 && alive)
retries = 8;
return retries;
}

当然如果服务端重试发第三次挥手 FIN 的过程中,还是同样的端口和 IP,起了个新的客户端,这时候服务端重试的 FIN 被收到后,客户端就会认为是不正常的数据包,直接发个 RST 给服务端,这时候两端连接也会断开。


参考资料

查资料的时候发现小林大佬已经写过,而且写的巨好,感兴趣的可以看下他的这篇文章。

《 如何优化 TCP?》https://xiaolincoding.com/network/3_tcp/tcp_optimize.html#%E4%B8%BB%E5%8A%A8%E6%96%B9%E7%9A%84%E4%BC%98%E5%8C%96

链接太长,懒得复制的话,点击阅读原文可以直接跳转。


最后

新的文章快写好了,就缺个开头了。

我有个不成熟的请求。


离开广东好长时间了,好久没人叫我靓仔了。

大家可以在评论区里,叫我一靓仔吗?

我这么善良质朴的愿望,能被满足吗?

如果实在叫不出口的话,能帮我点下右下角的点赞和在看吗?


别说了,一起在知识的海洋里呛水吧

点击下方名片,关注公众号:【小白 debug】


不满足于在留言区说骚话?

加我,我们建了个划水吹牛皮群,在群里,你可以跟你下次跳槽可能遇到的同事或面试官聊点有意思的话题。就超!开!心!

文章推荐:

文章持续更新,可以微信搜一搜「小白 debug」第一时间阅读,回复【面试】获面试题集。本文已经收录在 GitHub https://github.com/xiaobaiTech/golangFamily , 有大厂面试完整考点和成长路线,欢迎 Star。


表面上我是个技术博主

但没想到今天成了个情感博主

我是没想到有一天,我会通过技术知识,来挽救粉丝即将破碎的感情。

掏心窝子的说。这件事情多少是沾点功德无量了。

事情是这样的。

最近就有个读者加了我的绿皮聊天软件,女生,头像挺好看的,就在我以为她要我拉她进群发成人专升本广告的时候。

画风突然不对劲。

她说她男朋友也是个程序员,异地恋,也关注了我,天天研究什么TCP,UDP 网络。一研究就是一晚上,一晚上都不回她消息的那种。

话里有话,懂。

不出意外的出了意外,她发出了灵魂拷问

“你们程序员真的有那么忙吗?忙到连消息都不知道回。”

没想到上来就是一记直拳。

但是,这一拳,我接住了。

我很想告诉她”分了吧,下一题“。

但我不能。因为这样我就伤害了我的读者兄弟。

沉默了一下。

单核 cpu 都快转冒烟了,才颤颤巍巍在九宫格键盘上发出消息。

再回慢一点,我就感觉,我要对不起我这全日制本科学历了。

“其实,他已经回了你消息了,但你知道吗?网络是会丢包的。”

“我来帮他解释下,这个话题就要从数据包的发送流程聊起”


数据包的发送流程

首先,我们两个手机的绿皮聊天软件客户端,要通信,中间会通过它们家服务器。大概长这样。

聊天软件三端通信

但为了简化模型,我们把中间的服务器给省略掉,假设这是个端到端的通信。且为了保证消息的可靠性,我们盲猜它们之间用的是TCP 协议进行通信。

聊天软件两端通信


为了发送数据包,两端首先会通过三次握手,建立 TCP 连接。

一个数据包,从聊天框里发出,消息会从聊天软件所在的用户空间拷贝到内核空间发送缓冲区(send buffer),数据包就这样顺着传输层、网络层,进入到数据链路层,在这里数据包会经过流控(qdisc),再通过 RingBuffer 发到物理层的网卡。数据就这样顺着网卡发到了纷繁复杂的网络世界里。这里头数据会经过 n 多个路由器和交换机之间的跳转,最后到达目的机器的网卡处。

此时目的机器的网卡会通知DMA将数据包信息放到RingBuffer中,再触发一个硬中断CPUCPU触发软中断ksoftirqdRingBuffer收包,于是一个数据包就这样顺着物理层,数据链路层,网络层,传输层,最后从内核空间拷贝到用户空间里的聊天软件里。

网络发包收包全景图

画了那么大一张图,只水了 200 字做解释,我多少是有些心痛的。


到这里,抛开一些细节,大家大概知道了一个数据包从发送到接收的宏观过程。


可以看到,这上面全是密密麻麻的名词

整条链路下来,有不少地方可能会发生丢包。

但为了不让大家保持蹲姿太久影响身体健康,我这边只重点讲下几个常见容易发生丢包的场景


建立连接时丢包

TCP 协议会通过三次握手建立连接。大概长下面这样。

TCP三次握手

在服务端,第一次握手之后,会先建立个半连接,然后再发出第二次握手。这时候需要有个地方可以暂存这些半连接。这个地方就叫半连接队列

如果之后第三次握手来了,半连接就会升级为全连接,然后暂存到另外一个叫全连接队列的地方,坐等程序执行accept()方法将其取走使用。

半连接队列和全连接队列

是队列就有长度,有长度就有可能会满,如果它们满了,那新来的包就会被丢弃

可以通过下面的方式查看是否存在这种丢包行为。

1
2
3
4
5
6
7
# 全连接队列溢出次数
# netstat -s | grep overflowed
4343 times the listen queue of a socket overflowed

# 半连接队列溢出次数
# netstat -s | grep -i "SYNs to LISTEN sockets dropped"
109 times the listen queue of a socket overflowed

从现象来看就是连接建立失败。

这个话题在之前写的《没有 accept,能建立 TCP 连接吗?》有更详细的聊过,感兴趣的可以回去看下。


流量控制丢包

应用层能发网络数据包的软件有那么多,如果所有数据不加控制一股脑冲入到网卡,网卡会吃不消,那怎么办?让数据按一定的规则排个队依次处理,也就是所谓的qdisc(Queueing Disciplines,排队规则),这也是我们常说的流量控制机制。

排队,得先有个队列,而队列有个长度

我们可以通过下面的ifconfig命令查看到,里面涉及到的txqueuelen后面的数字1000,其实就是流控队列的长度。

当发送数据过快,流控队列长度txqueuelen又不够大时,就容易出现丢包现象。

qdisc丢包

可以通过下面的ifconfig命令,查看 TX 下的 dropped 字段,当它大于 0 时,则有可能是发生了流控丢包。

1
2
3
4
5
6
7
8
9
# ifconfig eth0
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.21.66.69 netmask 255.255.240.0 broadcast 172.21.79.255
inet6 fe80::216:3eff:fe25:269f prefixlen 64 scopeid 0x20<link>
ether 00:16:3e:25:26:9f txqueuelen 1000 (Ethernet)
RX packets 6962682 bytes 1119047079 (1.0 GiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 9688919 bytes 2072511384 (1.9 GiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

当遇到这种情况时,我们可以尝试修改下流控队列的长度。比如像下面这样将 eth0 网卡的流控队列长度从 1000 提升为 1500.

1
# ifconfig eth0 txqueuelen 1500

网卡丢包

网卡和它的驱动导致丢包的场景也比较常见,原因很多,比如网线质量差,接触不良。除此之外,我们来聊几个常见的场景。


RingBuffer 过小导致丢包

上面提到,在接收数据时,会将数据暂存到RingBuffer接收缓冲区中,然后等着内核触发软中断慢慢收走。如果这个缓冲区过小,而这时候发送的数据又过快,就有可能发生溢出,此时也会产生丢包

RingBuffer满了导致丢包

我们可以通过下面的命令去查看是否发生过这样的事情。

1
2
# ifconfig
eth0: RX errors 0 dropped 0 overruns 0 frame 0

查看上面的overruns指标,它记录了由于RingBuffer长度不足导致的溢出次数。


当然,用ethtool命令也能查看。

1
# ethtool -S eth0|grep rx_queue_0_drops

但这里需要注意的是,因为一个网卡里是可以有多个 RingBuffer的,所以上面的rx_queue_0_drops里的 0 代表的是第 0 个 RingBuffer的丢包数,对于多队列的网卡,这个 0 还可以改成其他数字。但我的家庭条件不允许我看其他队列的丢包数,所以上面的命令对我来说是够用了。。。

当发现有这类型丢包的时候,可以通过下面的命令查看当前网卡的配置。

1
2
3
4
5
6
7
8
9
10
11
12
#ethtool -g eth0
Ring parameters for eth0:
Pre-set maximums:
RX: 4096
RX Mini: 0
RX Jumbo: 0
TX: 4096
Current hardware settings:
RX: 1024
RX Mini: 0
RX Jumbo: 0
TX: 1024

上面的输出内容,含义是RingBuffer 最大支持 4096 的长度,但现在实际只用了 1024。

想要修改这个长度可以执行ethtool -G eth1 rx 4096 tx 4096将发送和接收 RingBuffer 的长度都改为 4096。

RingBuffer增大之后,可以减少因为容量小而导致的丢包情况。


网卡性能不足

网卡作为硬件,传输速度是有上限的。当网络传输速度过大,达到网卡上限时,就会发生丢包。这种情况一般常见于压测场景。

我们可以通过ethtool加网卡名,获得当前网卡支持的最大速度。

1
2
3
# ethtool eth0
Settings for eth0:
Speed: 10000Mb/s

可以看到,我这边用的网卡能支持的最大传输速度speed=1000Mb/s

也就是俗称的千兆网卡,但注意这里的单位是Mb,这里的b 是指 bit,而不是 Byte。1Byte=8bit。所以 10000Mb/s 还要除以 8,也就是理论上网卡最大传输速度是1000/8 = 125MB/s

我们可以通过sar命令从网络接口层面来分析数据包的收发情况。

1
2
3
4
5
# sar -n DEV 1
Linux 3.10.0-1127.19.1.el7.x86_64 2022年07月27日 _x86_64_ (1 CPU)

08时35分39秒 IFACE rxpck/s txpck/s rxkB/s txkB/s rxcmp/s txcmp/s rxmcst/s
08时35分40秒 eth0 6.06 4.04 0.35 121682.33 0.00 0.00 0.00

其中 txkB/s 是指当前每秒发送的字节(byte)总数,rxkB/s 是指每秒接收的字节(byte)总数

当两者加起来的值约等于12~13w字节的时候,也就对应大概125MB/s的传输速度。此时达到网卡性能极限,就会开始丢包。

遇到这个问题,优先看下你的服务是不是真有这么大的真实流量,如果是的话可以考虑下拆分服务,或者就忍痛充钱升级下配置吧。


接收缓冲区丢包

我们一般使用TCP socket进行网络编程的时候,内核都会分配一个发送缓冲区和一个接收缓冲区

当我们想要发一个数据包,会在代码里执行send(msg),这时候数据包并不是一把梭直接就走网卡飞出去的。而是将数据拷贝到内核发送缓冲区就完事返回了,至于什么时候发数据,发多少数据,这个后续由内核自己做决定。之前写过的《代码执行 send 成功后,数据就发出去了吗?》里有比较详细的介绍。

tcp_sendmsg逻辑

接收缓冲区作用也类似,从外部网络收到的数据包就暂存在这个地方,然后坐等用户空间的应用程序将数据包取走。

这两个缓冲区是有大小限制的,可以通过下面的命令去查看。

1
2
3
4
5
6
7
# 查看接收缓冲区
# sysctl net.ipv4.tcp_rmem
net.ipv4.tcp_rmem = 4096 87380 6291456

# 查看发送缓冲区
# sysctl net.ipv4.tcp_wmem
net.ipv4.tcp_wmem = 4096 16384 4194304

不管是接收缓冲区还是发送缓冲区,都能看到三个数值,分别对应缓冲区的最小值,默认值和最大值 (min、default、max)。缓冲区会在 min 和 max 之间动态调整。


那么问题来了,如果缓冲区设置过小会怎么样?

对于发送缓冲区,执行 send 的时候,如果是阻塞调用,那就会等,等到缓冲区有空位可以发数据。

send阻塞

如果是非阻塞调用,就会立刻返回一个 EAGAIN 错误信息,意思是 Try again 。让应用程序下次再重试。这种情况下一般不会发生丢包。

send非阻塞

当接受缓冲区满了,事情就不一样了,它的 TCP 接收窗口会变为 0,也就是所谓的零窗口,并且会通过数据包里的win=0,告诉发送端,”球球了,顶不住了,别发了”。一般这种情况下,发送端就该停止发消息了,但如果这时候确实还有数据发来,就会发生丢包

recv_buffer丢包

我们可以通过下面的命令里的TCPRcvQDrop查看到有没有发生过这种丢包现象。

1
2
3
cat /proc/net/netstat
TcpExt: SyncookiesSent TCPRcvQDrop SyncookiesFailed
TcpExt: 0 157 60116

但是说个伤心的事情,我们一般也看不到这个TCPRcvQDrop,因为这个是5.9版本里引入的打点,而我们的服务器用的一般是2.x~3.x左右版本。你可以通过下面的命令查看下你用的是什么版本的 linux 内核。

1
2
# cat /proc/version
Linux version 3.10.0-1127.19.1.el7.x86_64

两端之间的网络丢包

前面提到的是两端机器内部的网络丢包,除此之外,两端之间那么长的一条链路都属于外部网络,这中间有各种路由器和交换机还有光缆啥的,丢包也是很经常发生的。

这些丢包行为发生在中间链路的某些个机器上,我们当然是没权限去登录这些机器。但我们可以通过一些命令观察整个链路的连通情况。


ping 命令查看丢包

比如我们知道目的地的域名是 baidu.com。想知道你的机器到 baidu 服务器之间,有没有产生丢包行为。可以使用 ping 命令。

ping查看丢包

倒数第二行里有个100% packet loss,意思是**丢包率 100%**。

但这样其实你只能知道你的机器和目的机器之间有没有丢包。

那如果你想知道你和目的机器之间的这条链路,哪个节点丢包了,有没有办法呢?

有。


mtr 命令

mtr 命令可以查看到你的机器和目的机器之间的每个节点的丢包情况。

像下面这样执行命令。

mtr_icmp

其中**-r 是指 report**,以报告的形式打印结果。

可以看到Host那一列,出现的都是链路中间每一跳的机器,Loss的那一列就是指这一跳对应的丢包率。

需要注意的是,中间有一些是 host 是???,那个是因为mtr 默认用的是 ICMP 包,有些节点限制了ICMP 包,导致不能正常展示。

我们可以在 mtr 命令里加个-u,也就是使用udp 包,就能看到**部分???**对应的 IP。

mtr-udp

ICMP 包和 UDP 包的结果拼在一起看,就是比较完整的链路图了。

还有个小细节,Loss那一列,我们在 icmp 的场景下,关注最后一行,如果是 0%,那不管前面 loss 是 100%还是 80%都无所谓,那些都是节点限制导致的虚报

但如果最后一行是 20%,再往前几行都是 20%左右,那说明丢包就是从最接近的那一行开始产生的,长时间是这样,那很可能这一跳出了点问题。如果是公司内网的话,你可以带着这条线索去找对应的网络同事。如果是外网的话,那耐心点等等吧,别人家的开发会比你更着急。


发生丢包了怎么办

说了这么多。只是想告诉大家,丢包是很常见的,几乎不可避免的一件事情

但问题来了,发生丢包了怎么办?

这个好办,用TCP 协议去做传输。

TCP是什么

建立了 TCP 连接的两端,发送端在发出数据后会等待接收端回复ack包ack包的目的是为了告诉对方自己确实收到了数据,但如果中间链路发生了丢包,那发送端会迟迟收不到确认 ack,于是就会进行重传。以此来保证每个数据包都确确实实到达了接收端。

假设现在网断了,我们还用聊天软件发消息,聊天软件会使用 TCP 不断尝试重传数据,如果重传期间网络恢复了,那数据就能正常发过去。但如果多次重试直到超时都还是失败,这时候你将收获一个红色感叹号

这时候问题又来了。

假设某绿皮聊天软件用的就是 TCP 协议。

那文章开头提到的女生,她男朋友回她的消息时为什么还会丢包?毕竟丢包了会重试,重试失败了还会出现红色感叹号。


于是乎,问题就变成了,用了 TCP 协议,就一定不会丢包吗?


用了 TCP 协议就一定不会丢包吗

我们知道 TCP 位于传输层,在它的上面还有各种应用层协议,比如常见的 HTTP 或者各类 RPC 协议。

四层网络协议

TCP 保证的可靠性,是传输层的可靠性。也就是说,TCP 只保证数据从 A 机器的传输层可靠地发到 B 机器的传输层。

至于数据到了接收端的传输层之后,能不能保证到应用层,TCP 并不管。

假设现在,我们输入一条消息,从聊天框发出,走到传输层 TCP 协议的发送缓冲区,不管中间有没有丢包,最后通过重传都保证发到了对方的传输层 TCP 接收缓冲区,此时接收端回复了一个ack,发送端收到这个ack后就会将自己发送缓冲区里的消息给扔掉。到这里 TCP 的任务就结束了。

TCP 任务是结束了,但聊天软件的任务没结束。

聊天软件还需要将数据从 TCP 的接收缓冲区里读出来,如果在读出来这一刻,手机由于内存不足或其他各种原因,导致软件崩溃闪退了。

发送端以为自己发的消息已经发给对方了,但接收端却并没有收到这条消息。

于是乎,消息就丢了。

使用TCP协议却发生丢包

虽然概率很小,但它就是发生了

合情合理,逻辑自洽。


所以从这里,我铿锵有力的得出结论,我的读者已经回了这位女生消息了,只是因为发生了丢包所以女生才没能收到,而丢包的原因是女生的手机聊天软件在接收消息的那一刻发生了闪退。

到这里。女生知道自己错怪她男朋友了,哭着表示,一定要让她男朋友给她买一台不闪退的最新款 iphone。

额。。。

兄弟们觉得我做得对的,请在评论区扣个”正能量“。


这类丢包问题怎么解决?

故事到这里也到尾声了,感动之余,我们来聊点掏心窝子的话

其实前面说的都对,没有一句是假话

但某绿皮聊天软件这么成熟,怎么可能没考虑过这一点呢。

大家应该还记得我们文章开头提到过,为了简单,就将服务器那一方给省略了,从三端通信变成了两端通信,所以才有了这个丢包问题。

现在我们重新将服务器加回来。

聊天软件三端通信

大家有没有发现,有时候我们在手机里聊了一大堆内容,然后登录电脑版,它能将最近的聊天记录都同步到电脑版上。也就是说服务器可能记录了我们最近发过什么数据,假设每条消息都有个 id,服务器和聊天软件每次都拿最新消息的 id进行对比,就能知道两端消息是否一致,就像对账一样。

对于发送方,只要定时跟服务端的内容对账一下,就知道哪条消息没发送成功,直接重发就好了。

如果接收方的聊天软件崩溃了,重启后跟服务器稍微通信一下就知道少了哪条数据,同步上来就是了,所以也不存在上面提到的丢包情况。

可以看出,TCP 只保证传输层的消息可靠性,并不保证应用层的消息可靠性。如果我们还想保证应用层的消息可靠性,就需要应用层自己去实现逻辑做保证。


那么问题叒来了,两端通信的时候也能对账,为什么还要引入第三端服务器?

主要有三个原因。

  • 第一,如果是两端通信,你聊天软件里有1000个好友,你就得建立1000个连接。但如果引入服务端,你只需要跟服务器建立1个连接就够了,聊天软件消耗的资源越少,手机就越省电

  • 第二,就是安全问题,如果还是两端通信,随便一个人找你对账一下,你就把聊天记录给同步过去了,这并不合适吧。如果对方别有用心,信息就泄露了。引入第三方服务端就可以很方便的做各种鉴权校验。

  • 第三,是软件版本问题。软件装到用户手机之后,软件更不更新就是由用户说了算了。如果还是两端通信,且两端的软件版本跨度太大,很容易产生各种兼容性问题,但引入第三端服务器,就可以强制部分过低版本升级,否则不能使用软件。但对于大部分兼容性问题,给服务端加兼容逻辑就好了,不需要强制用户更新软件。

所以看到这里大家应该明白了,我把服务端去掉,并不单纯是为了简单


总结

  • 数据从发送端到接收端,链路很长,任何一个地方都可能发生丢包,几乎可以说丢包不可避免

  • 平时没事也不用关注丢包,大部分时候 TCP 的重传机制保证了消息可靠性。

  • 当你发现服务异常的时候,比如接口延时很高,总是失败的时候,可以用 ping 或者 mtr 命令看下是不是中间链路发生了丢包。

  • TCP 只保证传输层的消息可靠性,并不保证应用层的消息可靠性。如果我们还想保证应用层的消息可靠性,就需要应用层自己去实现逻辑做保证。


最后给大家留个问题吧,mtr 命令是怎么知道每一跳的 IP 地址的


参考资料

《Linux 内核技术实战》– 极客时间

《云网络丢包故障定位全景指南》–极客重生


最后

我一想到读者里还有不少兄弟还是单身,我就夜不能寐。

手心手背都是肉,一碗水要端平

犹豫了很久,为她指了条明路。

“我读者里有很多微信不丢包的兄弟,他们都喜欢在我的文章底下点赞和再看。你可以考虑下他们”

“还有经常在我评论区叫我靓仔的那些个兄弟,一看就是深情种,请重点考虑“。

只能帮到这里了,懂?


我知道,这时候肯定就有兄弟要说我了,**”故事汇都不敢这么编!”**

嗯。

他们不敢,我敢。


别说了,一起在知识的海洋里呛水吧

点击下方名片,关注公众号:【小白 debug】


不满足于在留言区说骚话?

加我,我们建了个划水吹牛皮群,在群里,你可以跟你下次跳槽可能遇到的同事或面试官聊点有意思的话题。就超!开!心!


文章推荐:

文章持续更新,可以微信搜一搜「小白 debug」第一时间阅读,回复【面试】获面试题集。本文已经收录在 GitHub https://github.com/xiaobaiTech/golangFamily , 有大厂面试完整考点和成长路线,欢迎 Star。


我想起了我刚工作的时候,第一次接触 RPC 协议,当时就很懵,我 HTTP 协议用的好好的,为什么还要用 RPC 协议?

于是就到网上去搜。

不少解释显得非常官方,我相信大家在各种平台上也都看到过,解释了又好像没解释,都在用一个我们不认识的概念去解释另外一个我们不认识的概念,懂的人不需要看,不懂的人看了还是不懂。

这种看了,又好像没看的感觉,云里雾里的很难受,我懂

为了避免大家有强烈的审丑疲劳,今天我们来尝试重新换个方式讲一讲。


从 TCP 聊起

作为一个程序员,假设我们需要在 A 电脑的进程发一段数据到 B 电脑的进程,我们一般会在代码里使用 socket 进行编程。

这时候,我们可选项一般也就TCP 和 UDP 二选一。TCP 可靠,UDP 不可靠。除非是马总这种神级程序员(早期 QQ 大量使用 UDP),否则,只要稍微对可靠性有些要求,普通人一般无脑选 TCP 就对了。

类似下面这样。

1
fd = socket(AF_INET,SOCK_STREAM,0);

其中SOCK_STREAM,是指使用字节流传输数据,说白了就是TCP 协议

在定义了 socket 之后,我们就可以愉快的对这个 socket 进行操作,比如用bind()绑定 IP 端口,用connect()发起建连。

握手建立连接流程

在连接建立之后,我们就可以使用send()发送数据,recv()接收数据。

光这样一个纯裸的 TCP 连接,就可以做到收发数据了,那是不是就够了?

不行,这么用会有问题。


使用纯裸 TCP 会有什么问题

八股文常背,TCP 是有三个特点,面向连接可靠、基于字节流

TCP是什么

这三个特点真的概括的非常精辟,这个八股文我们没白背。

每个特点展开都能聊一篇文章,而今天我们需要关注的是基于字节流这一点。

字节流可以理解为一个双向的通道里流淌的数据,这个数据其实就是我们常说的二进制数据,简单来说就是一大堆 01 串。纯裸 TCP 收发的这些 01 串之间是没有任何边界的,你根本不知道到哪个地方才算一条完整消息。

01二进制字节流

正因为这个没有任何边界的特点,所以当我们选择使用 TCP 发送**”夏洛”和”特烦恼”的时候,接收端收到的就是“夏洛特烦恼”,这时候接收端没发区分你是想要表达“夏洛”+”特烦恼”还是“夏洛特”+”烦恼”**。

消息对比

这就是所谓的粘包问题,之前也写过一篇专门的文章聊过这个问题。

说这个的目的是为了告诉大家,纯裸 TCP 是不能直接拿来用的,你需要在这个基础上加入一些自定义的规则,用于区分消息边界

于是我们会把每条要发送的数据都包装一下,比如加入消息头消息头里写清楚一个完整的包长度是多少,根据这个长度可以继续接收数据,截取出来后它们就是我们真正要传输的消息体

消息边界长度标志

而这里头提到的消息头,还可以放各种东西,比如消息体是否被压缩过和消息体格式之类的,只要上下游都约定好了,互相都认就可以了,这就是所谓的协议。

每个使用 TCP 的项目都可能会定义一套类似这样的协议解析标准,他们可能有区别,但原理都类似

于是基于 TCP,就衍生了非常多的协议,比如 HTTP 和 RPC。


HTTP 和 RPC

我们回过头来看网络的分层图。

四层网络协议

TCP 是传输层的协议,而基于 TCP 造出来的 HTTP 和各类RPC 协议,它们都只是定义了不同消息格式的应用层协议而已。

HTTP协议(Hyper Text Transfer Protocol),又叫做超文本传输协议。我们用的比较多,平时上网在浏览器上敲个网址就能访问网页,这里用到的就是 HTTP 协议。

HTTP调用

RPCRemote Procedure Call),又叫做远程过程调用。它本身并不是一个具体的协议,而是一种调用方式

举个例子,我们平时调用一个本地方法就像下面这样。

1
res = localFunc(req)

如果现在这不是个本地方法,而是个远端服务器暴露出来的一个方法remoteFunc,如果我们还能像调用本地方法那样去调用它,这样就可以屏蔽掉一些网络细节,用起来更方便,岂不美哉?

1
res = remoteFunc(req)

RPC可以像调用本地方法那样调用远端方法

基于这个思路,大佬们造出了非常多款式的 RPC 协议,比如比较有名的gRPCthrift

值得注意的是,虽然大部分 RPC 协议底层使用 TCP,但实际上它们不一定非得使用 TCP,改用 UDP 或者 HTTP,其实也可以做到类似的功能。

基于TCP协议的HTTP和RPC协议


到这里,我们回到文章标题的问题。

既然有 HTTP 协议,为什么还要有 RPC?

其实,TCP70 年代出来的协议,而HTTP90 年代才开始流行的。而直接使用裸 TCP 会有问题,可想而知,这中间这么多年有多少自定义的协议,而这里面就有80 年代出来的RPC

所以我们该问的不是既然有 HTTP 协议为什么要有 RPC,而是为什么有 RPC 还要有 HTTP 协议


那既然有 RPC 了,为什么还要有 HTTP 呢?

现在电脑上装的各种联网软件,比如 xx 管家,xx 卫士,它们都作为客户端(client)需要跟服务端(server)建立连接收发消息,此时都会用到应用层协议,在这种**client/server (c/s)**架构下,它们可以使用自家造的 RPC 协议,因为它只管连自己公司的服务器就 ok 了。

但有个软件不同,浏览器(browser),不管是 chrome 还是 IE,它们不仅要能访问自家公司的服务器(server),还需要访问其他公司的网站服务器,因此它们需要有个统一的标准,不然大家没法交流。于是,HTTP 就是那个时代用于统一 browser/server (b/s) 的协议。

也就是说在多年以前,HTTP 主要用于 b/s 架构,而 RPC 更多用于 c/s 架构。但现在其实已经没分那么清了,b/s 和 c/s 在慢慢融合。很多软件同时支持多端,比如某度云盘,既要支持网页版,还要支持手机端和 pc 端,如果通信协议都用 HTTP 的话,那服务器只用同一套就够了。而 RPC 就开始退居幕后,一般用于公司内部集群里,各个微服务之间的通讯。

那这么说的话,都用 HTTP 得了,还用什么 RPC?

仿佛又回到了文章开头的样子,那这就要从它们之间的区别开始说起。


HTTP 和 RPC 有什么区别

我们来看看 RPC 和 HTTP 区别比较明显的几个点。


服务发现

首先要向某个服务器发起请求,你得先建立连接,而建立连接的前提是,你得知道IP 地址和端口。这个找到服务对应的 IP 端口的过程,其实就是服务发现

HTTP中,你知道服务的域名,就可以通过DNS 服务去解析得到它背后的 IP 地址,默认 80 端口。

RPC的话,就有些区别,一般会有专门的中间服务去保存服务名和 IP 信息,比如consul 或者 etcd,甚至是 redis。想要访问某个服务,就去这些中间服务去获得 IP 和端口信息。由于 dns 也是服务发现的一种,所以也有基于 dns 去做服务发现的组件,比如CoreDNS

可以看出服务发现这一块,两者是有些区别,但不太能分高低。


底层连接形式

以主流的HTTP1.1协议为例,其默认在建立底层 TCP 连接之后会一直保持这个连接(keep alive),之后的请求和响应都会复用这条连接。

RPC协议,也跟 HTTP 类似,也是通过建立 TCP 长链接进行数据交互,但不同的地方在于,RPC 协议一般还会再建个连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保。

connection_pool

由于连接池有利于提升网络请求性能,所以不少编程语言的网络库里都会给 HTTP 加个连接池,比如go就是这么干的。

可以看出这一块两者也没太大区别,所以也不是关键。


传输的内容

基于 TCP 传输的消息,说到底,无非都是消息头 header 和消息体 body。

header是用于标记一些特殊信息,其中最重要的是消息体长度

body则是放我们真正需要传输的内容,而这些内容只能是二进制 01 串,毕竟计算机只认识这玩意。所以 TCP 传字符串和数字都问题不大,因为字符串可以转成编码再变成 01 串,而数字本身也能直接转为二进制。但结构体呢,我们得想个办法将它也转为二进制 01 串,这样的方案现在也有很多现成的,比如json,protobuf。

这个将结构体转为二进制数组的过程就叫序列化,反过来将二进制数组复原成结构体的过程叫反序列化

序列化和反序列化


对于主流的 HTTP1.1,虽然它现在叫超文本协议,支持音频视频,但 HTTP 设计初是用于做网页文本展示的,所以它传的内容以字符串为主。header 和 body 都是如此。在 body 这块,它使用json序列化结构体数据。

我们可以随便截个图直观看下。

HTTP报文

可以看到这里面的内容非常多的冗余,显得非常啰嗦。最明显的,像header里的那些信息,其实如果我们约定好头部的第几位是 content-type,就不需要每次都真的把”content-type”这个字段都传过来,类似的情况其实在body的 json 结构里也特别明显。

而 RPC,因为它定制化程度更高,可以采用体积更小的 protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因。

HTTP原理

RPC原理

当然上面说的 HTTP,其实特指的是现在主流使用的 HTTP1.1HTTP2在前者的基础上做了很多改进,所以性能可能比很多 RPC 协议还要好,甚至连gRPC底层都直接用的HTTP2

那么问题又来了。


为什么既然有了 HTTP2,还要有 RPC 协议?

这个是由于 HTTP2 是 2015 年出来的。那时候很多公司内部的 RPC 协议都已经跑了好些年了,基于历史原因,一般也没必要去换了。


总结

  • 纯裸 TCP 是能收发数据,但它是个无边界的数据流,上层需要定义消息格式用于定义消息边界。于是就有了各种协议,HTTP 和各类 RPC 协议就是在 TCP 之上定义的应用层协议。
  • RPC 本质上不算是协议,而是一种调用方式,而像 gRPC 和 thrift 这样的具体实现,才是协议,它们是实现了 RPC 调用的协议。目的是希望程序员能像调用本地方法那样去调用远端的服务方法。同时 RPC 有很多种实现方式,不一定非得基于 TCP 协议
  • 从发展历史来说,HTTP 主要用于 b/s 架构,而 RPC 更多用于 c/s 架构。但现在其实已经没分那么清了,b/s 和 c/s 在慢慢融合。很多软件同时支持多端,所以对外一般用 HTTP 协议,而内部集群的微服务之间则采用 RPC 协议进行通讯。
  • RPC 其实比 HTTP 出现的要早,且比目前主流的 HTTP1.1性能要更好,所以大部分公司内部都还在使用 RPC。
  • HTTP2.0HTTP1.1的基础上做了优化,性能可能比很多 RPC 协议都要好,但由于是这几年才出来的,所以也不太可能取代掉 RPC。

最后留个问题吧,大家有没有发现,不管是 HTTP 还是 RPC,它们都有个特点,那就是消息都是客户端请求,服务端响应。客户端没问,服务端肯定就不答,这就有点僵了,但现实中肯定有需要下游主动发送消息给上游的场景,比如打个网页游戏,站在那啥也不操作,怪也会主动攻击我,这种情况该怎么办呢?


参考资料

https://www.zhihu.com/question/41609070


最后

按照惯例,我应该在这里唯唯诺诺的求大家叫我两声靓仔的。

但还是算了。因为我最近一直在想一个问题,希望兄弟们能在评论区告诉我答案。

最近手机借给别人玩了一下午,现在老是给我推荐练习时长两年半的练习生视频。

每个视频都在声嘶力竭的告诉我,鸡你太美

所以我很想问,兄弟们。

鸡,到底美不美?

头疼。


右下角的点赞和再看还是可以走一波的。

先这样。

我是小白,我们下期见。


别说了,一起在知识的海洋里呛水吧

点击下方名片,关注公众号:【小白 debug】


不满足于在留言区说骚话?

加我,我们建了个划水吹牛皮群,在群里,你可以跟你下次跳槽可能遇到的同事或面试官聊点有意思的话题。就超!开!心!


文章推荐:

文章持续更新,可以微信搜一搜「小白 debug」第一时间阅读,回复【面试】获面试题集。本文已经收录在 GitHub https://github.com/xiaobaiTech/golangFamily , 有大厂面试完整考点和成长路线,欢迎 Star。

兄弟们!

出来讲骚话啊。


大家都是打工人,尤其是我们互联网打工人,一般很少在一家公司待十年八年的。

一两年一跳很正常。

尤其是现在很多公司都没有普调,薪水入职即巅峰,所以在很长一段时间里,要涨薪只能靠跳槽,这话没啥毛病。

但问题来了。

什么情况下你该考虑离职?


之前听马老师提到过,一般离职就两个原因钱给少了心受委屈了

可以说概括的相当精辟了,但这个偏主观因素多一点。

今天我想说的是离职的第三个原因:见势不妙。这个偏客观因素多一点。

很多时候,如果业务越做越凉,是有迹可循的。今天我们就来沉浸式体验一下这个过程。

看完你大概能知道什么情况下你该考虑离职了。


业务起步

很多公司在某些业务上小赚了一笔之后,都会考虑开辟新的业务线,以期待这个新的业务线会成为新的收入增长点。而这时候,老板们就会观察当前行业的风口,大一点的公司还会有专门的部门做各种研究。

在多次 ppt 会议之后,最后也不知道是谁成功忽悠了谁。总之,老板显得有些深思熟虑,并在 ppt 的某页上重点画了一个圈,你以为这是个重点经济增长圈?

但其实,它只是个饼,圆一点的饼。

于是,一个新的业务线就这样,带着宏伟使命和伟大愿景来了。

干活,首先得先摇人,新的事业线放出了大量的 HC(head count,人头数),那段时间,HR 电话都打爆了。

而你也是在这次摇人中,加入到了这个具有伟大使命和宏大愿景的业务线里。

这之后,老板开了好几次动员会,一遍又一遍的重复着他做这番事业的初心,这个事情的社会价值,以及你们未来会是一个有多少万个小目标的公司。

老板这么说不要紧,那关键是你看到的网页新闻也是这么说。

公司放出更多 HC 疯狂扩招,显示出他们的决心。

各种晨会周会上,老板不断强调我们要加强内推,多搞点简历过来,太缺人了。

“这件事,太有搞头了!”

你心想,你就是下一个风口上的猪。闭上眼睛,你都能看到,自己以后跟各界互联网大佬手搭着肩,在 ktv 里哭着唱朋友一生一起走的画面了。


业务中期

搞钱的事,怎么能在一棵树上干吊着呢?要多搞几棵树吊吊。

于是本着养蛊的思路,公司开了更多同类型的细分业务,比如教育还能细分为小学教育,初中教育,再细分还能分为语文数学英语。这时候你会发现,业务变得越来越多了,你手上的需求也越来越多了。

你每天都在多线程切换,除了写代码,等待你的还有开不完的会,搞不完的 oncall,有时候新来的产品还会天真无邪的找你一对一咨询各种产品细节。白天一晃而过,晚上 8 点之后,你才能开始安心写代码。

在这种高压环境下待个一年,恍如隔世,照个镜子,原来人间已经过了三年。相对论诚不欺我,爱因斯坦棺材板的压不住了。

一般这种时候,你负责的老服务会越来越多,但新服务和新需求还在不停开发中。你在写新需求的同时还得处理各种老服务的问题和咨询。

产品开始吐槽你们开发越来越慢,为了加快需求的吞吐量,项目组从双周迭代改为了单周迭代

这直接就是煽风点火了。

只要老服务出啥问题了,你原本绷紧的开发排期就得变得更脆。只要有一个需求延期了,那你后面就等着加班到天明吧。

很多项目在不断试错的过程中,过程中需要查看各种业务指标数据,产品、运营都会轮番要你写脚本算数据,只为说明他们拍大腿想出来的需求,是有数据支撑的,是 reasonable 的。

教育行业就更古怪,连教研老师都能给你提需求。

这种情况,在每个季度结束的时候会变得特别严重,你在用生命在为他们完成 kpi,你可真是互联网活雷锋。

雷锋做好事还知道写日记,你做的这些个事情,到底要不要写进周报好呢?

下班的路上,你拖着疲惫的身体,背着个电脑,开着小电瓶,在路灯的晃射下,你看到了路边的狗,你都一度怀疑,是不是连它,都能给你提需求。

你回想起,刚毕业的那会,那时候你虽然很穷,但你很快乐,现在不一样了,你还是很穷,但你不快乐了

你总有开不完的会,做不完的需求,你一度想着要不离职算了。但每次这种时候,大老板就正好调整一波组织架构,然后发表下他这次调整架构的思考和决心,会议的最后再次回到诉说初心,然后展望愿景和理想的环节。

这是这一年里,第 4 次调整组织架构和方向了,你开始在想老板是不是连自己都没想清楚,但你看老板回答各种问题时,那笃定睿智的神情,你又感觉你行了,坚持下吧,说不定这次真的能行呢!


业务后期

古人说,公司内的消息要在公司外的八卦平台上才能看到。

古人诚不欺你,某天,你在某知名互联网茶水间 app 某脉上看到了自家公司正在裁员的消息,而裁员的对象,正好是你所在的业务线方向。

虽然身边陆续有同事在开始慢慢离职,之前合作对接过的几个开发老哥内部账号也变成了离职状态。

甚至连竞品的股价都开始在暴跌。

但你都没在意,因为你现在做的事情挺多的,哪有时间管这些。

唯一让你感到痛心疾首的是,坐门口的爱穿黑丝的小姐姐,突然有天也不见了

那天傍晚,你的领导找组里的小伙伴们出去吃顿饭。吃到一半,你左手韭菜,右手羊腰子,领导却站起来说他要离职的事情,你突然愣神。反应过来时,大家正说着祝福的话,举起酒杯,好言相送,你看着杯子里的加多宝,又再一次陷入了慌神。

老领导走了,新领导上来第一件事就是盘点项目组的资源使用情况,每个服务使用了多少 cpu 和内存,能缩容就缩容。是的,他要搞降本提效

降着降着,可能发现原来才是最大的支出。于是你发现,不少业务线都消失了,不少人也走了。你手上接了越来越多别人交接过来的项目,从前五个人干的活,现在让你一个人干,你有些吃不消。老板说后面肯定会招人。但你很清楚,很长时间部门好像都没有面试了,以前周会每次都会提一下让大家内推一些简历,现在也不再提了。再后来,你听说业务线HC 被锁了,不再招人,甚至连转岗都不让转了


再后来,你发现业务的需求越来越少了,你以为终于可以闲下来摸鱼了,但这时候你的新领导开始推大家开始重构服务了,他说”之前我们跑太快了,一直在堆屎山,现在业务的活少了,正是我们重新思考架构,降本提效的好时候!”。

于是你们又开始了一轮新的折腾,你听老员工说: “以前完成业务的需求,给业务提供价值就是老板的 KPI,那现在业务都没了,老板不折腾下重构,那哪来的 kpi“。

知道真相的你眼泪掉下来。

这时候,你终于想走了,可一想到再坚持下就发年终奖了,这么辛苦都过来了,再忍几个月吧。

发年终奖可是个大开支啊,降本提效可是老板的 kpi 啊,于是你发现身边的同事慢慢变少。

直到那天你收到老板发来的消息:”空吗?我们来聊下绩效“。

如无意外,他觉得你绩效不好,要你签一份**PIP协议,你很清楚这玩意签了就等于承认自己不行,离职连 N+x 都没有**。但你也无力反抗,你很清楚什么叫”欲加之罪,何患无辞”。


什么时候该离职

你开始脑袋放空,过往发生的每一件事都像碎片那样串联了起来。明明有那么多迹象告诉你,快跑。但你都视而不见。

“早知道我半年前就跑了”

可是问题来了。

如果再让你回到半年前,你身处在一个温水煮青蛙的环境,你怎么知道该不该跑。

我们重新梳理一遍过往发生的事情。

大环境舆论热议风口 → 开新业务,领导鼓励拉人内推 → 业务变多,开发很累但人员不断在补充 → 架构不断调整 → 工作主要以完成需求支持业务为主 → 大环境变差,政策变更,竞品或自家股票暴跌 → 业务可见的萎缩 ,不再强调内推 → 架构调整,信心鼓励 → 活很多,但就是不招人 → hc 锁死,人员只出不进 → 领导跑路,换新领导 → 资源盘点,提倡降本提效 → 业务量变少,重构之类的活排上日程 → 身边的人陆续离职 → 年终奖将近,身边出现大批人员离职 → 轮到你了 → 留下来的人接手离职人员的活,过得更苦了 → 团队裁员或部门打包转岗。

这里其实涉及到一个业务线从 0 到 0.7 再到 0 的完整过程,任何一个时间节点,在会议上都是一片欣欣向荣的场面,就算是最后团队裁员,说的也是充满信心的话。

但你不必看老板们说什么,你看老板们做什么就够了,行动永远比话语诚实。

  • 公司业务组织架构疯狂调整,一年能折腾个三四次,说明老板都没想清楚一件事要怎么做,所以想要拍大腿疯狂试错。不赚钱的业务才会不断折腾,赚钱的业务永远以稳定盈利为主要目标。这时候你就该明白这个业务线大概率不太能做出来了,如果你加入这个公司的目的是妄想暴富的话,那该醒过来了,该考虑刷题了

  • 领导离职。这个要分情况,如果项目赚钱了,那可能只是宫斗,这种情况不考虑。但如果是不赚钱的项目,不管是领导是主动还是被动离职,这都不是什么好事情。如果是主动离职,如果一件事有搞头,你会想要跑吗?领导永远比你更接近第一手消息,而且能做到领导位置,那肯定目光和判断力要比你更强,连他都觉得没搞头,那你还不快跑?被动离职,这个更明显,搞事业,最忌讳中途换帅,但凡有点希望,也不至于这么搞。这时候你该明白,老板的老板已经慢慢失去耐心。这时候,八股文该背起来了

  • HC 锁死,说明从公司层面上,就不会再继续加大投入人力,对这个业务已经慢慢失去信心。如果现在离年终奖还远,简历改起来啊,你该考虑转岗或跑路了

  • 锁死转岗,这种时候多发生在后半年,大部分有求生经验的人,不想失去年终奖,于是选择活水到其他业务线,这样还能保住年终奖,走不走明年再做打算。但这样的转岗太多了,会导致原来就可能要凉的业务线凉的更快,于是大老板就会选择冻结转岗。这时候如果离年终还远,那球球了,投简历吧。如果临近年终,那我劝你苟住,但如果不得不得跑,对面公司出于人道主义关怀,可能会有一笔签字费作为损失年终奖的激励 or 补偿,记得谈一谈。

如果你在转岗锁死前,能成功转岗或离职,那你一般损失会小一些。在这之后,走运些的老哥能被辞退拿个 N+X 赔偿,体面离开,但这个纯纯看运气。不走运的,等待你的只有超多离职老哥留下的活,以及老板的PUA 或 PIP 关怀套餐


最后

我之前写过一篇关于 PIP 的文章,发在了某乎上,让我意外的是,最近时不时会有老哥看到后私信问我该怎么办,貌似最近大行情变差了,用这种方式劝退的公司越来越多了。

这是个屁股决定脑袋的世界,在老板视角里,用 pip 劝退员工可以省下赔款,如果他不能辞退足够多的人,他的绩效和年终就不好看,自己的利益当然比他人的利益重要。在员工视角里,用 pip 劝退员工的老板真实丧良心,但记住,没有人可以逼你签任何协议。大家做的都没错,都是各自系统的最优解。


很多行业,你去之前都说是风口,去了之后就凉了,你以为你拿的是主角的剧本,结果连跑龙套都算不上。你也不想当行业冥灯,可人生如戏。

还真是应了《桃花扇》里的那句唱词 “眼看他起高楼,眼看他宴宾客,眼看他楼塌了”。

深夜网抑云,破防了兄弟们。

但发牢骚并不能解决问题,该想想自己能从这次经历中学到什么?

一个要凉的业务,它总是会有一些苗头和规律的。今天这篇文章就是讲的这个,不过我相信,就算我告诉你,你也不会信的,每个人都总觉得自己是例外,每个人都觉得自己不会在厕所里边吃边哭。

就像每个舔狗追女神的时候,总感觉自己在她心里是不一样的。这里涉及到一个叫沉没成本的概念,不再展开。

你执意要去山的对面看看海,我很想告诉你山的对面没有海,但我知道,就算我说了,你也是不会信的,你需要亲自去看看。


多说两句

按照惯例,我应该在这里唯唯诺诺的求大家叫我两声靓仔的。

但我今天不想。

点一个,愿世界和平。

点一个在看,愿所有的伤痛都由发 pip 的那个人承担。

我是小白,我们下期见。


别说了,一起在知识的海洋里呛水吧

点击下方名片,关注公众号:【小白 debug】


不满足于在留言区说骚话?

加我,我们建了个划水吹牛皮群,在群里,你可以跟你下次跳槽可能遇到的同事或面试官聊点有意思的话题。就超!开!心!

文章推荐:

文章持续更新,可以微信搜一搜「小白 debug」第一时间阅读,回复【面试】获面试题集。本文已经收录在 GitHub https://github.com/xiaobaiTech/golangFamily , 有大厂面试完整考点和成长路线,欢迎 Star。

兄弟们。

浅浅的炫个富吧。

说出来你们可能不信。

手机你们有吗?我有。

短信,知道吧?一条一毛钱,我天天发

你敢想吗?

所以说,年轻人,有钱是真的好。

今天,我们就以短信为话题聊起。

短信,它又叫 SMS。


比如说,你有一张**短信表(sms)**,里面放了各种需要发送的短信信息。

sms建表sql

sms表

需要注意的是state 字段,为 0 的时候说明这时候短信还未发送。

此时还会有一个异步线程不断的捞起未发送(state=0)的短信数据,执行发短信操作,发送成功之后 state 字段会被置为 1(已发送)。也就是说未发送的数据会不断变少

异步线程发送短信


假设由于某些原因,你现在需要做一些监控,比如监控的内容是,你的 sms 数据表里还有没有 state=0(未发送)的短信,方便判断一下堆积的未发送短信大概在什么样的一个量级。

为了获取满足某些条件的行数是多少,我们一般会使用count()方法

这时候为了获取未发送的短信数据,我们很自然就想到了使用下面的 sql 语句进行查询。

1
select count(*) from sms where state = 0;

然后再把获得数据作为打点发给监控服务。


当数据表小的时候,这是没问题的,但当数据量大的时候,比如未发送的短信到了百万量级的时候,你就会发现,上面的 sql 查询时间会变得很长,最后 timeout 报错,查不出结果了


为什么?


我们先从count()方法的原理聊起。


count()的原理

count()方法的目的是计算当前 sql 语句查询得到的非 NULL 的行数

我们知道 mysql 是分为server 层和存储引擎层的

Mysql架构

存储引擎层里可以选择各种引擎进行存储,最常见的是 innodb、myisam。具体使用哪个存储引擎,可以通过建表 sql 里的ENGINE字段进行指定。比如这篇文章开头的建表 sql 里用了ENGINE=InnoDB,那这张表用的就是 innodb 引擎。

虽然在 server 层都叫 count()方法,但在不同的存储引擎下,它们的实现方式是有区别的。

比如同样是读全表数据 select count(*) from sms;语句。

使用 myisam 引擎的数据表里有个记录当前表里有几行数据的字段,直接读这个字段返回就好了,因此速度快得飞起。

而使用innodb 引擎的数据表,则会选择体积最小的索引树,然后通过遍历叶子节点的个数挨个加起来,这样也能得到全表数据。

因此回到文章开头的问题里,当数据表行数变大后,单次 count 就需要扫描大量的数据,因此很可能就会出现超时报错。


那么问题就来了。


为什么 innodb 不能像 myisam 那样实现 count()方法

myisam 和 innodb 这两个引擎,有几个比较明显的区别,这个是八股文常考了。

其中最大的区别在于 myisam 不支持事务,而 innodb 支持事务。

而事务,有四层隔离级别,其中默认隔离级别就是可重复读隔离级别(RR)

四层隔离级别

innodb 引擎通过 MVCC 实现了可重复隔离级别,事务开启后,多次执行同样的select 快照读,要能读到同样的数据。

于是我们看个例子。

为什么innodb不单独记录表行数

对于两个事务 A 和 B,一开始 sms 表假设就2 条数据,那事务 A 一开始确实是读到 2 条数据。事务 B 在这期间插入了 1 条数据,按道理数据库其实有 3 条数据了,但由于可重复读的隔离级别,事务 A 依然还是只能读到 2 条数据。

因此由于事务隔离级别的存在,不同的事务在同一时间下,看到的表内数据行数是不一致的,因此 innodb,没办法,也没必要像 myisam 那样单纯的加个 count 字段信息在数据表上。

那如果不可避免要使用 count(),有没有办法让它快一点?


各种 count()方法的原理

count()的括号里,可以放各种奇奇怪怪的东西,想必大家应该看过,比如放个星号*,放个 1,放个索引列啥的。

我们来分析下他们的执行流程。

count 方法的大原则是 server 层会从 innodb 存储引擎里读来一行行数据,并且只累计非 null 的值。但这个过程,根据 count()方法括号内的传参,有略有不同。


count(*)

server 层拿到 innodb 返回的行数据,不对里面的行数据做任何解析和判断,默认取出的值肯定都不是 null,直接行数+1。


count(1)

server 层拿到 innodb 返回的行数据,每行放个 1 进去,默认不可能为 null,直接行数+1.


count(某个列字段)

由于指明了要 count 某个字段,innodb 在取数据的时候,会把这个字段解析出来返回给 server 层,所以会比 count(1)和 count(*)多了个解析字段出来的流程。

  • 如果这个列字段是主键 id,主键是不可能为 null 的,所以 server 层也不用判断是否为 null,innodb 每返回一行,行数结果就+1.
  • 如果这个列是普通索引字段,innodb 一般会走普通索引,每返回一行数据,server 层就会判断这个字段是否为 null,不是 null 的情况下+1。当然如果建表 sql 里字段定义为 not null 的话,那就不用做这一步判断直接+1。
  • 如果这个列没有加过索引,那 innodb 可能会全表扫描,返回的每一行数据,server 层都会判断这个字段是否为 null,不是 null 的情况下+1。同上面的情况一样,字段加了 not null 也就省下这一步判断了。

理解了原理后我们大概可以知道他们的性能排序是

1
count(*) ≈ count(1) > count(主键id) > count(普通索引列) > count(未加索引列)

所以说 count(*),已经是最快的了。


知道真相的我眼泪掉下来。

那有没有其他更好的办法?


允许粗略估计行数的场景

我们回过头来细品下文章开头的需求,我们只是希望知道数据库里还有多少短信是堆积在那没发的,具体是 1k 还是 2k 其实都是差不多量级,等到了百万以上,具体数值已经不重要了,我们知道它现在堆积得很离谱,就够了。 因此这个场景,其实是允许使用比较粗略的估计的。

那怎么样才能获得粗略的数值呢?

还记得我们平时为了查看 sql 执行计划用的explain 命令不。

其中有个rows,会用来估计接下来执行这条 sql 需要扫描和检查多少行。它是通过采样的方式计算出来的,虽然会有一定的偏差,但它能反映一定的数量级。

explain里的rows

有些语言的 orm 里可能没有专门的 explain 语法,但是肯定有执行 raw sql 的功能,你可以把 explain 语句当做 raw sql 传入,从返回的结果里将 rows 那一列读出来使用。

一般情况下,explain 的 sql 如果能走索引,那会比不走索引的情况更准 。单个字段的索引会比多个字段组成的复合索引要准。索引区分度越高,rows 的值也会越准。

这种情况几乎满足大部分的监控场景。但总有一些场景,它要求必须得到精确的行数,这种情况该怎么办呢?


必须精确估计行数的场景

这种场景就比较头疼了,但也不是不能做。

我们可以单独拉一张新的数据库表,只为保存各种场景下的 count。

1
2
3
4
5
6
7
CREATE TABLE `count_table` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`cnt_what` char(20) NOT NULL DEFAULT '' COMMENT '各种需要计算的指标',
`cnt` tinyint NOT NULL COMMENT 'cnt指标值',
PRIMARY KEY (`id`),
KEY `idx_cnt_what` (`cnt_what`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

count_table表保存各种场景下的count

当需要获取某个场景下的 cout 值时,可以使用下面的 sql 进行直接读取,快得飞起

1
select cnt from count_table where cnt_what = "未发送的短信数量";

那这些 count 的结果值从哪来呢?

这里分成两种情况。


实时性要求较高的场景

如果你对这个 cnt 计算结果的实时性要求很高,那你需要将更新 cnt 的 sql 加入到对应变更行数的事务中

比如我们有两个事务 A 和 B,分别是增加未发送短信和减少未发送短信。

将更改表行数的操作放入到事务里

这样做的好处是事务内的 cnt 行数依然符合隔离级别,事务回滚的时候,cnt 的值也会跟着回滚。

坏处也比较明显,多个线程对同一个 cnt 进行写操作,会触发悲观锁,多个线程之间需要互相等待。对于高频写的场景,性能会有折损。


实时性没那么高的场景

如果实时性要求不高的话,比如可以一天一次,那你可以通过全表扫描后做计算。

举个例子,比如上面的短信表,可以按 id 排序,每次取出 1w 条数据,记下这一批里最大的 id,然后下次从最大 id 开始再拿 1w 条数据出来,不断循环。

对于未发送的短信,就只需要在捞出的那 1w 条数据里,筛选出 state=0 的条数。

batch分批获取短信表

当然如果有条件,这种场景最好的方式还是消费 binlog 将数据导入到 hive 里,然后在 hive 里做查询,不少公司也已经有现成的组件可以做这种事情,不用自己写脚本,岂不美哉。

mysql同步hive


总结

  • mysql 用 count 方法查全表数据,在不同的存储引擎里实现不同,myisam 有专门字段记录全表的行数,直接读这个字段就好了。而 innodb 则需要一行行去算。

  • 性能方面 count(*) ≈ count(1) > count(主键id) > count(普通索引列) > count(未加索引列),但哪怕是性能最好的 count(*),由于实现上就需要一行行去算,所以数据量大的时候就是不给力。

  • 如果确实需要获取行数,且可以接受不那么精确的行数(只需要判断大概的量级)的话,那可以用 explain 里的 rows,这可以满足大部分的监控场景,实现简单。

  • 如果要求行数准确,可以建个新表,里面专门放表行数的信息。

    • 如果对实时性要求比较高的话,可以将更新行数的 sql 放入到对应事务里,这样既能满足事务隔离性,还能快速读取到行数信息。
    • 如果对实时性要求不高,接受一小时或者一天的更新频率,那既可以自己写脚本遍历全表后更新行数信息。也可以将通过监听 binlog 将数据导入 hive,需要数据时直接通过 hive 计算得出。

参考资料

《丁奇 mysql45 讲》


最后

兄弟们,最近有点没出息,沉迷在刘亦菲的新剧里,都快忘了写文这件事了。

按照惯例,我应该在这里唯唯诺诺的求大家叫我两声靓仔的。

但今天,我感觉我不配。

所以先这样。


但右下角的点赞和再看还是可以走一波的。

我是小白,我们下期见。


别说了,一起在知识的海洋里呛水吧

点击下方名片,关注公众号:【小白 debug】


不满足于在留言区说骚话?

加我,我们建了个划水吹牛皮群,在群里,你可以跟你下次跳槽可能遇到的同事或面试官聊点有意思的话题。就超!开!心!


文章推荐:

文章持续更新,可以微信搜一搜「小白 debug」第一时间阅读,回复【面试】获面试题集。本文已经收录在 GitHub https://github.com/xiaobaiTech/golangFamily , 有大厂面试完整考点和成长路线,欢迎 Star。


我们平时建表的时候,一般会像下面这样。

1
2
3
4
5
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` char(10) NOT NULL DEFAULT '' COMMENT '名字',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

出于习惯,我们一般会加一列id 作为主键,而这个主键一般边上都有个AUTO_INCREMENT, 意思是这个主键是自增的。自增就是 i++,也就是每次都加 1。

但问题来了。

主键 id 不自增行不行?

为什么要用自增 id 做主键?

离谱点,没有主键可以吗?

什么情况下不应该自增?


被这么一波追问,念头都不通达了?

这篇文章,我会尝试回答这几个问题。


主键不自增行不行

当然是可以的。比如我们可以把建表 sql 里的AUTO_INCREMENT去掉。

1
2
3
4
5
CREATE TABLE `user` (
`id` int NOT NULL COMMENT '主键',
`name` char(10) NOT NULL DEFAULT '' COMMENT '名字',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

然后执行

1
INSERT INTO `user` (`name`)  VALUES	('debug');

这时候会报错Field 'id' doesn't have a default value。也就是说如果你不让主键自增的话,那你在写数据的时候需要自己指定 id 的值是多少,想要主键 id 是多少就写多少进去,不写就报错。

改成下面这样就好了

1
INSERT INTO `user` (`id`,`name`)  VALUES	(10, 'debug');

为什么要用自增主键

我们在数据库里保存的数据就跟 excel 表一样,一行行似的。

user表

而在底层,这一行行数据,就是保存在一个个16k 大小的页里。

每次都去遍历所有的行性能会不好,于是为了加速搜索,我们可以根据主键 id,从小到大排列这些行数据,将这些数据页用双向链表的形式组织起来,再将这些页里的部分信息提取出来放到一个新的 16kb 的数据页里,再加入层级的概念。于是,一个个数据页就被组织起来了,成为了一棵B+树索引

B+树结构

而当我们在建表 sql 里声明了PRIMARY KEY (id)时,mysql 的 innodb 引擎,就会为主键 id 生成一个主键索引,里面就是通过 B+树的形式来维护这套索引。

到这里,我们有两个点是需要关注的:

  • 数据页大小是固定 16k
  • 数据页内,以及数据页之间,数据主键 id 都是从小到大排序

由于数据页大小固定了是 16k,当我们需要插入一条新的数据,数据页会被慢慢放满,当超过 16k 时,这个数据页就有可能会进行分裂

针对 B+树叶子节点如果主键是自增的,那它产生的 id 每次都比前一次要大,所以每次都会将数据加在 B+树尾部,B+树的叶子节点本质上是双向链表,查找它的首部和尾部,**时间复杂度 O(1)**。而如果此时最末尾的数据页满了,那创建个新的页就好。

主键id自增的情况

如果主键不是自增的,比方说上次分配了 id=7,这次分配了 id=3,为了让新加入数据后B+树的叶子节点还能保持有序,它就需要往叶子结点的中间找,查找过程的时间复杂度是 O(lgn),如果这个页正好也满了,这时候就需要进行页分裂了。并且页分裂操作本身是需要加悲观锁的。总体看下来,自增的主键遇到页分裂的可能性更少,因此性能也会更高。

主键id不自增的情况


没有主键可以吗

mysql 表如果没有主键索引,查个数据都得全表扫描,那既然它这么重要,我今天就不当人了,不声明主键,可以吗?

嗯,你完全可以不声明主键。

你确实可以在建表 sql 里写成这样。

1
2
3
CREATE TABLE `user` (
`name` char(10) NOT NULL DEFAULT '' COMMENT '名字'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

看起来确实是没有主键的样子。然而实际上,mysql 的 innodb 引擎内部会帮你生成一个名为ROW_ID列,它是个 6 字节的隐藏列,你平时也看不到它,但实际上,它也是自增的。有了这层兜底机制保证,数据表肯定会有主键和主键索引

跟 ROW_ID 被隐藏的列还有trx_id字段,用于记录当前这一行数据行是被哪个事务修改的,和一个roll_pointer字段,这个字段是用来指向当前这个数据行的上一个版本,通过这个字段,可以为这行数据形成一条版本链,从而实现多版本并发控制(MVCC)。有没有很眼熟,这个在之前写的文章里出现过。

隐藏的row_id列

有没有建议主键不自增的场景

前面提到了主键自增可以带来很多好处,事实上大部分场景下,我们都建议主键设为自增。

那有没有不建议主键自增的场景呢?


mysql 分库分表下的 id

聊到分库分表,那我就需要说明下,递增和自增的区别了,自增就是每次都+1,而递增则是新的 id 比上一个 id 要大就行了,具体大多少,没关系。

之前写过一篇文章提到过,mysql 在水平分库分表时,一般有两种方式。

一种分表方式是通过对 id 取模进行分表,这种要求递增就好,不要求严格自增,因为取模后数据会被分散到多个分表中,就算 id 是严格自增的,在分散之后,都只能保证每个分表里 id 只能是递增的。

根据id取模分表

另一种分表方式是根据 id 的范围进行分表(分片),它会划出一定的范围,比如以 2kw 为一个分表的大小,那 02kw 就放在这张分表中,2kw4kw 放在另一张分表中,数据不断增加,分表也可以不断增加,非常适合动态扩容,但它要求id 自增,如果id 递增,数据则会出现大量空洞。举个例子,比如第一次分配 id=2,第二次分配 id=2kw,这时候第一张表的范围就被打满了,后面再分配一个 id,比如是 3kw,就只能存到 2kw4kw(第二张)的分表中。那我在 02kw 这个范围的分表,也就存了两条数据,这太浪费了。

根据id范围分表

但不管哪种分表方式,一般是不可能继续用原来表里的自增主键的,原因也比较好理解,原来的每个表如果都从 0 开始自增的话,那好几个表就会出现好几次重复的 id,根据 id 唯一的原则,这显然不合理。


所以我们在分库分表的场景下,插入的 id 都是专门的 id 服务生成的,如果是要严格自增的话,那一般会通过 redis 来获得,当然不会是一个 id 请求获取一次,一般会按批次去获得,比如一次性获得 100 个。快用完了再去获取下一批 100 个。

但这个方案有个问题,它严重依赖 redis,如果 redis 挂了,那整个功能就傻了。

有没有不依赖于其他第三方组件的方法呢?


雪花算法

有,比如Twitter 开源的雪花算法。

雪花算法通过 64 位有特殊含义的数字来组成 id。

雪花算法

首先第 0 位不用。

接下来的41 位时间戳。精度是毫秒,这个大小大概能表示个69年左右,因为时间戳随着时间流逝肯定是越来越大的,所以这部分决定了生成的 id 肯定是越来越大的。

再接下来的10 位是指产生这些雪花算法的工作机器 id,这样就可以让每个机器产生的 id 都具有相应的标识。

再接下来的12 位序列号,就是指这个工作机器里生成的递增数字。

可以看出,只要处于同一毫秒内,所有的雪花算法 id 的前 42 位的值都是一样的,因此在这一毫秒内,能产生的 id 数量就是 2的10次方✖️2的12次方,大概400w,肯定是够用了,甚至有点多了。


但是!

细心的兄弟们肯定也发现了,雪花算法它算出的数字动不动就比上次的数字多个几百几万的,也就是它生成的 id 是趋势递增的,并不是严格**+1 自增**的,也就是说它并不太适合于根据范围来分表的场景。这是个非常疼的问题。

还有个小问题是,那 10 位工作机器 id,我每次扩容一个工作机器,这个机器怎么知道自己的 id 是多少呢?是不是得从某个地方读过来。

那有没有一种生成 id 生成方案,既能让分库分表能做到很好的支持动态扩容,又能像雪花算法那样并不依赖 redis 这样的第三方服务。

有。这就是这篇文章的重点了。


适合分库分表的 uuid 算法

我们可以参考雪花算法的实现,设计成下面这样。注意下面的每一位,都是十进制,而不是二进制。

适合分库分表的uuid算法

开头的12 位依然是时间,但并不是时间戳,雪花算法的时间戳精确到毫秒,我们用不上这么细,我们改为yyMMddHHmmss,注意开头的 yy 是两位,也就是这个方案能保证到 2099 年之前,id 都不会重复,能用到重复,那也是真·百年企业。同样由于最前面是时间,随着时间流逝,也能保证 id 趋势递增。

接下来的10 位,用十进制的方式表示工作机器的 ip,就可以把 12 位的 ip 转为 10 位的数字,它可以保证全局唯一,只要服务起来了,也就知道自己的 ip 是多少了,不需要像雪花算法那样从别的地方去读取 worker id 了,又是一个小细节。

在接下来的6 位,就用于生成序列号,它能支持每秒钟生成 100w 个 id。

最后的4 位,也是这个 id 算法最妙的部分。它前 2 位代表分库 id,后 2 位代表分表 id。也就是支持一共100*100=1w张分表。


举个例子,假设我只用了 1 个分库,当我一开始只有 3 张分表的情况下,那我可以通过配置,要求生成的 uuid 最后面的 2 位,取值只能是[0,1,2],分别对应三个表。这样我生成出来的 id,就能非常均匀的落到三个分表中,这还顺带解决了单个分表热点写入的问题。

如果随着业务不断发展,需要新加入两张新的表(3 和 4),同时第 0 张表有点满了,不希望再被写了,那就将配置改为[1,2,3,4],这样生成的 id 就不会再插入到对应的 0 表中。同时还可以加入生成 id 的概率和权重来调整哪个分表落更多数据。

有了这个新的 uuid 方案,我们既可以保证生成的数据趋势递增,同时也能非常方便扩展分表。非常 nice。


数据库有那么多种,mysql 只是其中一种,那其他数据库也是要求主键自增吗?


tidb 的主键 id 不建议自增

tidb 是一款分布式数据库,作为 mysql 分库分表场景下的替代产品,可以更好的对数据进行分片。

它通过引入Range的概念进行数据表分片,比如第一个分片表的 id 在 02kw,第二个分片表的 id 在 2kw4kw。这其实就是根据 id 范围进行数据库分表

它的语法几乎跟 mysql 一致,用起来大部分时候是无感的。

但跟 mysql 有一点很不一样的就是,mysql 建议 id 自增,但tidb 却建议使用随机的 uuid。原因是如果 id 自增的话,根据范围分片的规则,一段时间内生成的 id 几乎都会落到同一个分片上,比如下图,从3kw开始的自增 uuid,几乎都落到range 1这个分片中,而其他表却几乎不会有写入,性能没有被利用起来。出现一表有难,多表围观的场面,这种情况又叫写热点问题。

写热点问题

所以为了充分的利用多个分表的写入能力,tidb 建议我们写入时使用随机 id,这样数据就能被均匀分散到多个分片中。


用户 id 不建议用自增 id

前面提到的不建议使用自增 id 的场景,都是技术原因导致的,而下面介绍的这个,单纯是因为业务。

举个例子吧。

如果你能知道一个产品每个月,新增的用户数有多少,这个对你来说会是有用的信息吗?

对程序员来说,可能这个信息价值不大。

但如果你是做投资的呢,或者是分析竞争对手呢?

那反过来。

如果你发现你的竞争对手,总能非常清晰的知道你的产品每个月新进的注册用户是多少人,你会不会心里毛毛的?

如果真出现了这问题,先不要想是不是有内鬼,先检查下你的用户表主键是不是自增的。

有内鬼

如果用户 id 是自增的,那别人只要每个月都注册一个新用户,然后抓包得到这个用户的 user_id,然后跟上个月的值减一下,就知道这个月新进多少用户了。

同样的场景有很多,有时候你去小店吃饭,发票上就写了你是今天的第几单,那大概就能估计今天店家做了多少单。你是店家,你心里也不舒服吧。

再比如说一些小 app 的商品订单 id,如果也做成自增的,那就很容易可以知道这个月成了多少单。

类似的事情有很多,这些场景都建议使用趋势递增的 uuid 作为主键。

当然,主键保持自增,但是不暴露给前端,那也行,那前面的话,你当我没说过


总结

  • 建表 sql 里主键边上的AUTO_INCREMENT,可以让主键自增,去掉它是可以的,但这就需要你在 insert 的时候自己设置主键的值。

  • 建表 sql 里的 PRIMARY KEY 是用来声明主键的,如果去掉,那也能建表成功,但 mysql 内部会给你偷偷建一个 ROW_ID的隐藏列作为主键。

  • 由于 mysql 使用B+树索引,叶子节点是从小到大排序的,如果使用自增 id 做主键,这样每次数据都加在 B+树的最后,比起每次加在 B+树中间的方式,加在最后可以有效减少页分裂的问题。

  • 在分库分表的场景下,我们可以通过 redis 等第三方组件来获得严格自增的主键 id。如果不想依赖 redis,可以参考雪花算法进行魔改既能保证数据趋势递增,也能很好的满足分库分表的动态扩容。

  • 并不是所有数据库都建议使用自增 id 作为主键,比如tidb 就推荐使用随机 id,这样可以有效避免写热点的问题。而对于一些敏感数据,比如用户 id,订单 id 等,如果使用自增 id 作为主键的话,外部通过抓包,很容易可以知道新进用户量,成单量这些信息,所以需要谨慎考虑是否继续使用自增主键。


最后

我比较记仇,最近有不少兄弟们在评论区叫我 diao 毛。

我都记住了。

但是,只要兄弟们还能给右下角的点赞和在看来上那么一下的话。

我觉得,这口气,也不是不能忍。

按照惯例,我应该在这里唯唯诺诺的求大家叫我两声靓仔的。

但我今天不想。

所以先这样。

我是小白,我们下期见。


别说了,一起在知识的海洋里呛水吧

点击下方名片,关注公众号:【小白 debug】


不满足于在留言区说骚话?

加我,我们建了个划水吹牛皮群,在群里,你可以跟你下次跳槽可能遇到的同事或面试官聊点有意思的话题。就超!开!心!


文章推荐:

文章持续更新,可以微信搜一搜「小白 debug」第一时间阅读,回复【面试】获免费面试题集。本文已经收录在 GitHub https://github.com/xiaobaiTech/golangFamily , 有大厂面试完整考点和成长路线,欢迎 Star。

我们先来说下标题是什么意思。


为了更好的理解我说的是啥,我们来举个例子。

假设你现在在做一个类似 B 站的系统,里面放了各种视频。

用户每天在里头上传各种视频。

按理说每个视频都要去审查一下有没有搞颜色,但总不能人眼挨个看吧。

毕竟唐老哥表示这玩意看多了,看太阳都是绿色的,所以会有专门训练过的算法服务去做检测。

但也不能上来就整个视频每一帧都拿去做审查吧,所以会在每个视频里根据时长视频类型随机抽出好几张图片去做审查,比如视频标签是美女的,算法爱看,那多抽几张。标签是编程的,狗都不看,就少抽几张。

将这些抽出来的图片,送去审查。


为了实现这个功能,我们会以视频为维度去做审核,而每个视频里都会有 N 张数量不定的图片,下游服务是个使用GPU去检测图片的算法服务

现在问题来了,下游服务的算法开发告诉你,这些个下游服务,它不支持很高的并发,但请求传参里给你加了个数组,你可以批量(batch)传入一个比较大的图片数组,通过这个方式可以提升点图片处理量。


于是,我们的场景就变成。

上游服务入参一个视频和它的 N 张图片,出参是这个视频是否审核通过。

下游服务入参是 N 张图片的,出参是这个视频是否审核通过。

batch_call上下游


现在我们想要用上游服务接入下游服务。该怎么办?

看上去挺好办的,一把梭不就完事了吗?

当一个视频进来,就拿着视频的十多张图片作为一个 batch 去进行调用。

有几个视频进来,就开几个这样的并发。

这么做的结果就是,当并发大一点时,你会发现性能很差,并且性能非常不稳定,比如像下面的监控图一样一会 3qps,一会 15qps。处理的图片也只支持 20qps 左右。

狗看了都得摇头。

图1-直接调用时qps很低

这可如何是好?


为什么下游需要 batch call

本着先问是不是,再问为什么的精神,我们先看看为啥下游的要求会如此别致。

为什么同样都是处理多张图片,下游不搞成支持并发而要搞成批量调用(batch call)?

这个设定有点奇怪?

其实不奇怪,在算法服务中甚至很常见,举个例子你就明白了。

同样是处理多张图片,为了简单,我就假设是三张吧。如果是用单个 cpu去处理的话。那不管是并发还是 batch 进来,由于 cpu 内部的计算单元有限,所以你可以简单理解为,这三张图片,就是串行去计算的。

cpu处理图片时的流程

我计算第一张图片是否能审核通过,跟第二张图片是否能审核通过,这两者没有逻辑关联,因此按道理两张图片是可以并行计算。

奈何我 CPU 计算单元有限啊,做不到啊。

但是。

如果我打破计算单元有限的这个条件,给 CPU 加入超多计算单元,并且弱化一些对于计算没啥用处的组件,比如 cache 和控制单元。那我们就有足够的算力可以让这些图片的计算并行起来了。

并行处理图片

是的,把 CPU 这么一整,它其实就变成了 GPU。

GPU和CPU的区别

上面的讲解只是为了方便理解,实际上,gpu 会以更细的粒度去做并发计算,比如可以细到图片里的像素级别。

这也是为什么如果我们跑一些 3d 游戏的时候,需要用到显卡,因为它可以快速的并行计算画面里每个地方的光影,远近效果啥的,然后渲染出画面。


回到为什么要搞成 batch call 的问题中。

其实一次算法服务调用中,在数据真正进入 GPU 前,其实也使用了 CPU 做一些前置处理。

因此,我们可以简单的将一次调用的时间理解成做了下面这些事情。

GPU处理图片时的流程

服务由 CPU 逻辑和 GPU 处理逻辑组成,调用进入服务后,会有一些前置逻辑,它需要 CPU 来完成,然后才使用 GPU 去进行并行计算,将结果返回后又有一些后置的 CPU 处理逻辑。中间的 GPU 部分,管是计算 1 张图,还是计算 100 张图,只要算力支持,那它们都是并行计算的,耗时都差不多。

如果把这多张图片拆开,并发去调用这个算法服务,那就有 N 组这样的 CPU+GPU 的消耗,而中间的并行计算,其实没有利用到位。

并且还会多了前置和后置的 CPU 逻辑部分,算法服务一般都是 python 服务,主流的一些 web 框架几乎都是以多进程而不是多线程的方式去处理外部请求,这就有可能导致额外的进程间切换消耗

当并发的请求多了,请求处理不过来,后边来的请求就需要等前边的处理完才能被处理,后面的请求耗时看起来就会变得特别大。这也是上面图 1 里,接口延时(latency)像过山车那样往上涨的原因。

还是上面的图1的截图,一张图用两次哈哈

按理说减少并发,增大每次调用时的图片数量,就可以解决这个问题。

这就是推荐 batch call 的原因。

但问题又来了。

每次调用,上游服务输入的是一个视频以及它的几张图片,调用下游时,batch 的数量按道理就只能是这几张图片的数量,怎么才能增大 batch 的数量呢?

这里的调用,就需要分为同步调用和异步调用了。


同步调用和异步调用的区别

同步调用,意思是上游发起请求后,阻塞等待,下游处理逻辑后返回结果给上游。常见的形式就像我们平时做的 http 调用一样。

同步调用

异步调用,意思是上游发起请求后立马返回,下游收到消息后慢慢处理,处理完之后再通过某个形式通知上游。常见的形式是使用消息队列,也就是 mq。将消息发给 mq 后,下游消费 mq 消息,触发处理逻辑,然后再把处理结果发到 mq,上游消费 mq 的结果。

异步调用


异步调用的形式接入

异步调用的实现方式

回到我们文章开头提到的例子,当上游服务收到一个请求(一个视频和它对应的图片),这时候上游服务作为生产者将这个数据写入到 mq 中,请求返回。然后新造一个 C 服务,负责批量消费 mq 里的消息。这时候服务 C 就可以根据下游服务的性能控制自己的消费速度,比如一次性消费 10 条数据(视频),每个数据下面挂了 10 个图片,那我一次 batch 的图片数量就是 10*10=100 张,原来的 10 次请求就变为了 1 次请求。这对下游就相当的友好了。

下游返回结果后,服务 C 将结果写入到 mq 的另外一个 topic 下,由上游去做消费,这样就结束了整个调用流程。


当然上面的方案,如果你把 mq 换成数据库,一样是 ok 的,这时候服务 C 就可以不断的定时轮询数据库表,看下哪些请求没处理,把没处理的请求批量捞出来再 batch call 下游。不管是 mq 还是数据库,它们的作用无非就是作为中转,暂存数据,让服务 C 根据下游的消费能力,去消费这些数据。

这样不管后续要加入多少个新服务,它们都可以在原来的基础上做扩展,如果是 mq,加 topic,如果是数据库,则加数据表,每个新服务都可以根据自己的消费能力去调整消费速度。

mq串联多个不同性能的服务

其实对于这种上下游服务处理性能不一致的场景,最适合用的就是异步调用。而且涉及到的服务性能差距越大,服务个数越多,这个方案的优势就越明显。


同步调用的方式接入

虽然异步调用在这种场景下的优势很明显,但也有个缺点,就是它需要最上游的调用方能接受用异步的方式去消费结果。其实涉及到算法的服务调用链,都是比较耗时的,用异步接口非常合理。但合理归合理,有些最上游他不一定听你的,就是不能接受异步调用。

这就需要采用同步调用的方案,但怎么才能把同步接口改造得更适合这种调用场景,这也是这篇文章的重点。


限流

如果直接将请求打到下游算法服务,下游根本吃不消,因此首先需要做的就是给在上游调用下游的地方,加入一个速率限制(rate limit)。

这样的组件一般也不需要你自己写,几乎任何一个语言里都会有现成的。

比如 golang 里可以用golang.org/x/time/rate库,它其实是用令牌桶算法实现的限流器。如果不知道令牌桶是啥也没关系,不影响理解。

限流器逻辑

当然,这个限制的是当前这个服务调用下游的 qps,也就是所谓的单节点限流。如果是多个服务的话,网上也有不少现成的分布式限流框架。但是,还是那句话,够用就好

限流只能保证下游算法服务不被压垮,并不能提升单次调用 batch 的图片数量,有没有什么办法可以解决这个问题呢?


参考 Nagle 算法的做法

我们熟悉的 TCP 协议里,有个算法叫 Nagle 算法,设计它的目的,就是为了避免一次传过少数据,提高数据包的有效数据负载。

当我们想要发送一些数据包时,数据包会被放入到一个缓冲区中,不立刻发送,那什么时候会发送呢?

数据包会在以下两个情况被发送:

  • 缓冲区的数据包长度达到某个长度(MSS)时。
  • 或者等待超时(一般为200ms)。在超时之前,来的那么多个数据包,就是凑不齐 MSS 长度,现在超时了,不等了,立即发送。

这个思路就非常值得我们参考。我们完全可以自己在代码层实现一波,实现也非常简单。

1.我们定义一个带锁的全局队列(链表)。

2.当上游服务输入一个视频和它对应的 N 张图片时,就加锁将这 N 张图片数据和一个用来存放返回结果的结构体放入到全局队列中。然后死循环读这个结构体,直到它有结果。就有点像阻塞等待了。

3.同时在服务启动时就起一个线程 A专门用于收集这个全局队列的图片数据。线程 A负责发起调用下游服务的请求,但只有在下面两个情况下会发起请求

  • 当收集的图片数量达到 xx 张的时候

  • 距离上次发起请求过了 xx 毫秒(超时)

    4.调用下游结束后,再根据一开始传入的数据,将调用结果拆开来,送回到刚刚提到的用于存放结果的结构体中。

    5.第 2 步里的死循环因为存放返回结果的结构体,有值了,就可以跳出死循环,继续执行后面的逻辑。

batch_call同步调用改造

这就像公交车站一样,公交车站不可能每来一个顾客就发一辆公交车,当然是希望车里顾客越多越好。上游每来一个请求,就把请求里的图片,也就是乘客,塞到公交车里,公交车要么到点发车(向下游服务发起请求),要么车满了,也没必要等了,直接发车。这样就保证了每次发车的时候公交车里的顾客数量足够多,发车的次数尽量少。


大体思路就跟上面一样,如果是用 go 来实现的话,就会更加简单。

比如第 1 步里的加锁全局队列可以改成有缓冲长度的 channel。第 2 步里的”用来存放结果的结构体“,也可以改成另一个无缓冲 channel。执行 res := <-ch, 就可以做到阻塞等待的效果。

而核心的仿 Nagle 的代码也大概长下面这样。当然不看也没关系,反正你已经知道思路了

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
27
28
29
30
31
32
33
34
35
36
37
38
39
func CallAPI() error {
size := 100
// 这个数组用于收集视频里的图片,每个 IVideoInfo 下都有N张图片
videoInfos := make([]IVideoInfo, 0, size)
// 设置一个200ms定时器
tick := time.NewTicker(200 * time.Microsecond)
defer tick.Stop()
// 死循环
for {
select {
// 由于定时器,每200ms,都会执行到这一行
case <-tick.C:
if len(videoInfos) > 0 {
// 200ms超时,去请求下游
limitStartFunc(videoInfos, true)
// 请求结束后把之前收集的数据清空,重新开始收集。
videoInfos = make([]IVideoInfo, 0, size)
}
// AddChan就是所谓的全局队列
case videoInfo, ok := <-AddChan:
if !ok {
// 通道关闭时,如果还有数据没有去发起请求,就请求一波下游服务
limitStartFunc(videoInfos, false)
videoInfos = make([]IVideoInfo, 0, size)
return nil
} else {
videoInfos = append(videoInfos, videoInfo)
if videoInfos 内的图片满足xx数量 {
limitStartFunc(videoInfos, false)
videoInfos = make([]IVideoInfo, 0, size)
// 重置定时器
tick.Reset(200 * time.Microsecond)
}
}
}
}
return nil
}


通过这一操作,上游每来一个请求,都会将视频里的图片收集起来,堆到一定张数的时候再统一请求,大大提升了每次 batch call 的图片数量,同时也减少了调用下游服务的次数。真·一举两得

优化的效果也比较明显,上游服务支持的 qps 从原来不稳定的 3q~15q 变成稳定的 90q。下游的接口耗时也变得稳定多了,从原来的过山车似的飙到 15s 变成稳定的 500ms 左右。处理的图片的速度也从原来 20qps 提升到 350qps。

到这里就已经大大超过业务需求的预期(40qps)了,够用就好,多一个 qps 都是浪费。

可以了,下班吧。


总结

  • 为了充分利用GPU并行计算的能力,不少算法服务会希望上游通过加大batch的同时减少并发的方式进行接口调用。
  • 对于上下游性能差距明显的服务,建议配合mq采用异步调用的方式将服务串联起来。
  • 如果非得使用同步调用的方式进行调用,建议模仿Nagle 算法的形式,攒一批数据再发起请求,这样既可以增大 batch,同时减少并发,真·一举两得,亲测有效

最后

讲了那么多可以提升性能的方式,现在需求来了,如果你资源充足,但时间不充足,那还是直接同步调用一把梭吧。

性能不够?下游加机器,gpu 卡,买!

然后下个季度再提起一个技术优化,性能提升 xx%,cpu,gpu 减少 xx%。

有没有闻到?

这是 kpi 的味道。

又是一个小细节,学到了的兄弟们评论区打个【学到了】。



最近原创更文的阅读量稳步下跌,思前想后,夜里辗转反侧。

我有个不成熟的请求。


离开广东好长时间了,好久没人叫我靓仔了。

大家可以在评论区里,叫我一靓仔吗?

我这么善良质朴的愿望,能被满足吗?

如果实在叫不出口的话,能帮我点下右下角的点赞和在看吗?


别说了,一起在知识的海洋里呛水吧

点击下方名片,关注公众号:【小白 debug】


不满足于在留言区说骚话?

加我,我们建了个划水吹牛皮群,在群里,你可以跟你下次跳槽可能遇到的同事或面试官聊点有意思的话题。就超!开!心!

文章推荐: