0%

在 macOSBig Sur及更高版本上,使用以下命令:

1
sudo lsof -i -P | grep LISTEN | grep :$PORT

或者只查看 IPv4:

1
sudo lsof -nP -i4TCP:$PORT | grep LISTEN

在旧版本上,使用以下形式之一:

1
2
sudo lsof -nP -iTCP:$PORT | grep LISTEN
sudo lsof -nP -i:$PORT | grep LISTEN

替换 $PORT 为端口号或以逗号分隔的端口号列表。

如果您需要关于 #1024 以下端口的信息,请在前面添加 sudo(后跟空格)。

-n 标志用于显示 IP 地址而不是主机名。这使得命令执行得更快,因为获取主机名的 DNS 查找可能很慢(对于许多主机来说是几秒钟或一分钟)。

-P 标志用于显示原始端口号,而不是像解析的名称 httpftp 或更深奥的服务名称,如 dpserve, socalia

-i 用于 IPv4 和 IPv6 协议。

本文其实是结合业务做了一些很简单又非常有效的优化,并没有涉及什么高大上 MySQL 底层原理。

搜索优化

在应用中,往往会有很多需要搜索的时候,比如订单里面根据订单号搜索,客户管理里面根据客户的编号搜索等。

场景:根据订单号搜索

背景:搜索的字段只有 “关键字” 一个字段,会使用这个字段去模糊匹配很多不同表的字段。

在订单搜索里面,在查看慢查询的时候,发现大多数的慢查询来自于对订单号的查询。因此可以猜测:用户搜索订单的时候很多情况下是通过订单号来搜索的,而订单号本来有索引,如果通过订单号精确匹配的话,可以将搜索时长缩短到 10ms 级,而原来起码需要 5s 以上,甚至 10s 20s。

优化方法:

订单搜索的时候,由于订单号的格式比较固定,可以通过简易的判断就可以几乎 100% 确定是一个订单号,因此在搜索其他字段之前。

先单独通过订单号查找是否有关键字对应的订单,有的话,直接返回相应订单即可,不再需要搜索其他字段了。

效果:原来几秒几十秒的搜索 => 100ms 左右完成。

类似场景,客户搜索里面:管理员搜索的时候,因为绝大多数情况下是通过客户编号或者手机号或客户企业名称来搜索的,因此优化为:先通过客户编号、手机号、客户企业名称精确匹配,匹配到了,直接返回对应数据,不再搜索其他字段。效果:几秒十几二十秒 => 100ms。

场景:搜索中文不匹配纯英文字母的字段

在客户列表里面,有很多慢查询,但是查看慢查询语句发现,明明是中文的字段,却在查找过程中匹配了一些没有必要匹配的字段,比如:用中文去匹配只保存拼音的字段,或者用中文去匹配手机号。

优化方法:客户搜索的时候,如果检查到客户搜索的关键字为中文,则不再 like 匹配拼音字段、手机号字段。

效果:原来几秒甚至十几二十秒的请求 => 2s 内可以完成。

场景:不必要的 order by

还是搜索,在列表搜索的时候,有些地方的搜索很多时候搜索结果只有 1 条,又或者几条,反正几乎 100% 的情况不会超过 50 条。但是尽管如此,MySQL 语句里面还是有 order by 子句。而这个 order by 是有点多余的,甚至会对性能造成很严重的影响。比如,只有一条搜索结果数据的情况下,order by 的开销完全是多余的。

优化方法:如果判断关键字长度达到一定长度,基本上能搜索出来的结果只有那么一两条了,这个时候直接去除 order by,然后获取到查询结果后再在代码里面做排序(就是对搜索的结果做排序,但实际上很多情况下都是没有必要的,因为只有一条数据)。

效果:原来十几二十秒的请求 => 1s 内可以完成。

场景:order by id

在有些 sql 中,往往会需要根据 id 做逆序查询,但是如果我们的语句中有几个条件的话,只 order by id 会很慢,但是如果我们有另外的 where 字段,可以考虑将 id 和 where 的字段做一个联合索引,然后 order by 这个索引中的字段,这样一来 MySQL 排序就可以直接用索引的排序的。

效果:几秒的查询 => 几百毫秒

其他:查询尽量覆盖索引

我们在做过滤的时候,如果 where 条件的时候有某些字段不存在索引里面的话,会需要进行回表,数据量大的时候效率很低。

后记

从上周某一天开始到今天,持续做了一些优化性的工作。其中有一些自己以前未曾尝试过的做法,但是效果奇好。也有一些值得记下来,对自己以后做优化有参考价值的。还有一些跟优化关系不大的,比如应用的可观测性,说到底关系还是有,只有先发现了性能问题才能进一步去优化。

所以,也就有了本文。

本来上周想写的,但是上周到现在一直在做一些优化的事情,所以一直拖到了现在。

标题也写了,这真的是一次 “一言难尽” 的优化,因为本来以为是一个非常高大上的技术问题,以为需要做很多很多优化才能解决的问题,但背后原因令人咋舌。

技术栈

开始之前,有必要说一下项目用到的技术栈:

  • MySQL
  • MongoDB
  • RabbitMQ
  • laravel-s(基于 swoole 开发的一个框架)
  • redis

背景

从项目上线几年以来,偶尔会出现一种情况,php worker 进程(也就是处理 http 请求的那些进程)全部被占用,导致用户所有后续的请求一直处于 pending 的状态,这个时候从用户的角度来看,其实就是服务器宕机了。

但是很诡异的是,虽然进程全部被占满了,但是服务器的 CPU、内存、磁盘,数据库的 CPU、内存、磁盘都处于一个相对正常的状态,甚至可以说毫无波澜。就是明显不是资源不足导致的请求无法被及时处理。

之前一直的应对方法:遇到这种情况的时候,很多次都是通过重启进程来解决。有时候重启进程之后,问题就不再出现了。有时候需要经历前前后后好几次的重启问题才不再出现。

然后每经历一次,都能找到可以优化的一个或者几个接口,所以 “宕机” 的情况多了之后,接口也逐渐优化过不少了。

上周以前的优化

从一开始到上周以前,基于出现的慢请求,团队成员对应用也做了很多的优化。比如:

  • 加了两台应用的服务器,然后 nginx 负载均衡
  • mongo 分库分表(也加了 2 台服务器)
  • 导入导出的优化
  • 使用 elasticsearch 来搜索
  • 优化了很多其他的慢请求对应的接口,当然是实实在在的优化,并不是自己感性的感觉

优化的结果: 优化到最后,已经没有非常明显的慢请求了。但是结果呢?还是偶尔来一次 “宕机”,但我们能看到的慢请求里面,去看代码好像都已经优化得没有办法再优化了。所以这个是最诡异的

新的发现: 因为常规的 MySQL、MongoDB 都没有慢查询,后面联想到了会不会是推队列导致的,因为项目里面也用到了 RabbitMQ。然后在代码里面加上了对推队列耗时的监测,最后发现偶尔的情况下,统计到推队列的时间会长达十几二十秒。

上周的优化

基于这一新的发现,对需要推队列的请求做了一些针对性优化,比如原来是通过 for 循环做了一些推队列的操作,然后针对这些情况,改成了批量去推,然后在消息消费的时候再去循环处理。

优化之后,还是偶尔会有 “宕机” 的情况。后面怀疑是另外一个系统请求频繁导致的,因为这个系统的请求都需要推队列,因此怀疑是 RabbitMQ 的那台机无法承受得住这么高的请求量。(但在产生这个怀疑的时候,MQ 所在服务器在 “宕机” 的时候资源其实也是处于正常状态。)但因为 “宕机” 的时候刚好推队列也慢了,所以有这样的怀疑。

针对推消息到队列很慢的情况,上周又做了一些优化,具体来说就是将处理另外一个系统的 php 进程跟 RabbitMQ 服务器隔离开来。

上周优化的结果

将另一个系统的请求处理的进程跟 MQ 服务器隔离开了之后,第二天,“宕机” 的情况还是出现了。

万万没想到,本以为能做的都做了,总不至于还会 “宕机” 吧,但事实就是这样。

想起了之前在 qa 环境中,会出现建立到 MQ 的连接的时候耗时过长的情况,因此在生产环境中,在建立 MySQL、MongoDB、RabbitMQ 连接的地方都加上了埋点,监测一下是否有建立连接耗时过长的情况。

结果:并没有出现过一次连接耗时过长的情况,毕竟本来都是走的内网,平时 ping 一下大概 <= 1ms。

发现问题

上面都没有提到的一点是 redis,因为 redis 这个东西个人是对它的性能一点也不怀疑的,所以之前虽然即使想到了会不会是 redis 导致的时候,又马上否定自己的想法,不可能,绝对不可能。

但是那一天看到一个请求,一段代码里面本来没有做什么特别的操作,但是耗时很长。但是里面没有 MySQL、MongoDB 慢查询,没有推队列的操作,但是有 redis 的操作,但是那个操作也不是什么需要耗时很长的操作,就是一个 set nx 的操作。讲道理应该是非常快的。

由于没有其他可以怀疑的地方了,然后给这个 set nx 的操作加上耗时监测。第二天起来的时候,先去看了看慢请求日志,真的发现有一次 set nx 耗时长达 16s,到这个时候基本可以肯定是 redis 导致的问题了。

然后联想到好像平时用的数据库都有慢查询日志,想了想 redis 是否也有慢查询日志,搜索一番,真的有。然后去服务器上执行一下 slowlog,发现服务器 “宕机” 的时间点有很多 keys xx* 的操作,所以到此为止,其实问题基本上等于解决了,接下来只需要去找到对应的代码,改掉 keys * 的操作就行了。因为 keys * 是会阻塞整个 redis 线程的,导致其他 redis 操作完全无法进行。所以也就出现了 set nx 也需要十几秒的情况。

至此,几年以来,偶尔来一次的 “宕机” 总算是找到根本原因了。而这么久才找到仅因为对 redis 太过于相信了,总是感性地认为 redis 太过于强劲,完全不需要管它的性能问题。但事实证明,还是需要对外部依赖长点心,要不然偶尔恶心你一次。

问题代码

slowlog 里面得到的一些命令,搜索代码之后,发现有如下几行代码,这是将近 4 年前的代码:

1
2
3
4
5
6
7
8
9
10
11
// function A
$keys = array_merge(
// 每个 keys * 操作 600ms
app('redis')->keys($this->getKeyName($companyId, $this->skuKey, $skuId) . '*'),
app('redis')->keys($this->getKeyName($companyId, $this->giftKey, $skuId) . '*'),
app('redis')->keys($this->getKeyName($companyId, $this->skuGiftKey, $skuId) . '*')
);

if (count($keys) > 0) {
app('redis')->del($keys);
}

我们可以发现,这里做了 keys * 操作,而且还做了三次。

然后去看看调用这个函数的地方,一个 for 循环来调用这个函数:

1
2
3
4
5
// function B
foreach ($skuIds as $skuId) {
// 一次调用 1.8s
$this->cleanCacheBySkuId($companyId, $skuId);
}
1
2
// 接口 c
$service->b();

然后调用 B 函数的地方是一个接口(我们称作 C 吧),突然想起了之前 php 进程被占满的时候,都会显示几个或者多个 C 接口正在处理。但是之前看过这个接口,好像也没有很多复杂的操作,只是 for 循环做了一些数据库的操作,但是一般来说 for 循环的次数比较少。所以就一直不太在意。

keys * 是如何影响到全平台的?

  • 很久以前代码里面加入了一个记录活跃用户的 redis 操作,所以每次请求都会需要 redis,所以一旦 redis 阻塞了,所有的其他请求都得等待了
  • 队列使用了 horizon,而这个 horizon 里面也是有很多 redis 操作的,很多状态信息都是通过 redis 记录的。因此 redis 阻塞也会导致推队列的操作耗时过长(准确来说并不是推队列耗时过长,只是推队列的时候附带的 redis 操作耗时过长,所以上面以为的推队列耗时过长的怀疑是错误的)。

“宕机” 是怎么发生的

看过了 redis 的慢日志之后,发现一次 keys * 的操作要 600ms,也就是说一次 for 循环需要 1.8s 以上,如果参数传了 10 个,那就是 18s 起步了。(也就是这个时候开始,服务器已经开始卡顿了,又还没有完全 “宕机”)。然后这个时候肯定绝大多数用户应该等不及了,接口居然没有响应了,这谁能忍。然后大家开始刷新,不刷不要紧,这一刷就导致更多请求在等待处理了,但是这些请求基本上都需要在 18s 以后才能正常处理。(所以有时候会出现一种情况,客户用着用着反馈卡顿,但是过了一会等开发去看的时候,又不卡了)

这个时候绝大多数人的请求影响不会很大,但是还有一个人,那就是等待 C 接口返回的人。他也等不及了,然后又发起了一次新的请求,这下好了,大家的等待时间要 +18s 了。em... 那就大家一起等 36s 吧,停下来喝杯茶就好了。但是等待 C 接口返回的人发起的这个新的请求又等了好久,实在是忍不住了,再动起了鼠标,再发了几次请求。

em... 这下可好了,完完全全 “宕机” 了,救火的人出现了。拿起键盘一把梭,重启服务器的 php 进程,重启之后,发起 C 接口请求的人正在喝茶,这个时候其他人的请求得以正常处理了一会,让大家稍微喘了口气。

“宕机” 是如何恢复的

但是好景不长,那个等待 C 接口返回的人,杯里的茶喝完了,回到浏览器看了一眼,但是发现服务器返回了错误。这下彻底坐不住了,键盘一摔,拿起鼠标又继续之前的操作,又来了几次 C 接口请求,然后服务器又开始 “宕机”,再次重启。如此来回几次之后,那个人最后发现自己的请求终于生效了,也就不再发起新的请求了。

至此,“宕机” 得以暂时的恢复了。直到下一个请求 C 接口的人出现...

解决问题

打开问题代码所在源文件之后,删除了一些几年前写的不再使用的代码之后。发现上面的 B 函数也是不再需要的了,也就是说 A 和 B 函数都是不再需要的了。

所以,解决问题的方法极其简单,将 B 函数的调用去掉就解决了。有点难以置信困扰这么久的就这么简单的被解决了,之前一度以为要做很多很多优化才能不会再出现类似的 “宕机” 的问题,但现在看来似乎并不是这样。

去掉这函数调用之后,服务器算是真正的稳定下来了,持续了几年的间歇性 “宕机” 问题至此总算得到解决。

启示

  • 那段代码是将近四年前的,然后后面有人修改过之后,其实那个函数不需要再调用,但是调用并没有去除。所以,是否可以考虑一下,修改过后,将那些不再需要的代码删除。
  • 几年以来,对 php 的进程的监控做了很多完善,到后面都可以看到当前正在处理什么请求,正在处理的请求 MySQL 操作花费了多少时间、MongoDB 操作花了多少时间,但是 redis 被完完全全地忽略了。事实证明,应该对所有的外部依赖(简单来说就是一切 io 操作)都做相应的监测,如果能监测当前在什么操作上阻塞那就最好不过了。因为排除了这些网络、磁盘的操作之外,剩余的问题就没那么难发现了,无非就是 CPU、内存。
  • 当问题出现的时候,如果找不到很明显的原因,可以逐一进行排查,不应该对某些外部依赖太过于信任,也许这些外部的依赖本身没有问题,但是使用不当就会造成问题。
  • em... 不要在线上环境使用 keys *

开放 80 端口

1
firewall-cmd --permanent --zone=public --add-port=80/tcp

查看开放的端口

1
firewall-cmd --zone=public --list-ports

开放端口范围

1
firewall-cmd --zone=public --add-port=4990-4999/udp

reload firewalld

1
firewall-cmd --reload

push ack 是什么意思?

这里面的 P. 代表 push ack 的数据包,那 push ack 具体是什么意思呢?

1
11:53:56.748105 IP 40.100.29.194.https > 10.79.98.55.62947: Flags [P.], seq 5758:5814, ack 6948, win 2052, length 56

在这里有详细的说明

Understanding Push ACK TCP Flags

  • PSH(push) flag indicates that the incoming data should be passed on directly to the application instead of getting buffered.
  • ACK(acknowledgment) flag is used to confirm that the data packets have been received, also used to confirm the initiation request and tear down requests. Once a TCP session has been created, every packet contains an ACK flag.

push 和 ack 是常用的两种 tcp flag。tcp 协议中有六种 tcp flag。下面的文章会讲解的非常的详细。

Understanding TCP Flags

用 tcpdump 命令可以非常方便的查看每个 packet 的 tcp flag 状态。

Tcpdump: Filter Packets with Tcp Flags

tcp flag 详解

我们通常用的 tcp flag 有六种。

在 TCP 层,有个 FLAGS 字段,这个字段有以下几个标识:SYN,FIN,ACK,PSH,RST,URG。

其中,对于我们日常的分析有用的就是前面的五个字段。

它们的含义是:

  • SYN 表示建立连接
  • FIN 表示关闭连接
  • ACK 表示响应
  • PSH 表示有 DATA 数据传输
  • RST 表示连接重置

push ack 是通用的组合。

其中,ACK 是可能与 SYN,FIN 等同时使用的,比如 SYN 和 ACK 可能同时为 1,它表示的就是建立连接之后的响应(响应建立连接的那一个包)。

如果只是单个的一个 SYN,它表示的只是建立连接(连接发起的第一个包)。

TCP 的几次握手就是通过这样的 ACK 表现出来的。

但 SYN 与 FIN 是不会同时为 1 的,因为前者表示的是建立连接,而后者表示的是断开连接。

RST 一般是在 FIN 之后才会出现为 1 的情况,表示的是连接重置。

一般地,当出现 FIN 包或 RST 包时,我们便认为客户端与服务器端断开了连接;而当出现 SYN 和 SYN+ACK 包时,我们认为客户端与服务器建立了一个连接。

PSH 为 1 的情况,一般只出现在 DATA 内容不为 0 的包中,也就是说 PSH 为 1 表示的是有真正的 TCP 数据包内容被传递。

TCP 的连接建立和连接关闭,都是通过请求-响应的模式完成的。

TCP三次握手

第一次握手:主机 A 发送 SYN=1,随机产生 seq number 的数据包到服务器,主机 B 由 SYN=1 知道,A 要求建立连接。

第二次握手:主机 B 收到请求后要确认连接信息,向 A 发送 ack number=(主机 A 的 seq+1),SYN=1,ACK=1,随机产生 seq 的包

第三次握手:主机 A 收到后检查 ack number 是否正确,即第一次发送的 seq number + 1,以及 ACK 是否为 1,若正确,主机 A 会再发送 ack number=(主机 B 的 seq+1),主机 B 收到后确认 seq 值与 ack = 1 则连接建立成功。

完成三次握手,主机 A 与主机 B 开始传送数据。

可以通过 tcpdump 来查看每个数据包的状态位。

tcpdump capture tcp flags