遭遇lj_str_new

话说前几天我刚通过 mlcache 优化了热数据的问题,屁股还没坐热乎呢,就发现系统性能又下降了,本着自己挖的坑含泪也要填上的原则,我再一次开始了性能调优之旅。

对某个 nginx 进程执行 perf top

对某个 nginx 进程执行 perf top

毫无疑问,从 perf top 结果来看,lj_str_new 已经成为了性能最大的短板。不过我们还是要搞一个 lua 语言级别的火焰图看着才靠谱,于是有了下图:

优化前的火焰图

优化前的火焰图

不出所料,lj_str_new 非常宽,不过没有更详细的调用栈信息,不方便判断问题到底出在哪,而且我的代码在优化前并没有遇到类似的问题,到底 lj_str_new 是什么玩意?还得从 lua 中的字符串说起,引自「如何编写正确且高效的 OpenResty 应用」:

Lua 跟其他大部分语言有一点不一样,就是它的字符串是不可变的。需要对实际的字符串内容做 hash,然后用它查找该内容是否已经创建了对应的实例。既然说到做 hash,那么自然得提到 hash 碰撞。对于那些 hash 值一样的字符串,LuaJIT 把它们存储在链表里。如果许多字符串有着一样的 hash 值,那么这个链表就会很长,原来 O(1) 的开销会退化为 O(n)。这就是所谓的 hash 碰撞。不幸的是,LuaJIT 的默认的字符串 hash 函数就有这样的问题。

看到这里,我已经猜到了问题的原因是我错误的使用了 mlcache,前面提到我通过 mlcache 优化了热数据的问题,实际上当时我为了多缓存一些数据,把 lru_size 设置为了一百万,可这一百万个 key 就是一百万个字符串啊,可想而知随着字符串越来越多,hash 碰撞就会越来越严重,也难怪火焰图中看不到 lj_str_new 详细的调用栈信息,因为任何一个字符串操作都可能有问题,系统性能必然下降。

解决方法很简单,把 lru_size 改小点儿,本例中我设置为 1000,只缓存最热的数据:

优化后的火焰图

优化后的火焰图

仔细对比优化前后两张火焰图,你会发现 lj_str_new 几乎看不到了,收工。

记一次性能调优

面对性能调优问题,很多人往往只是单纯的套用既往的经验:先试试一个,不行再试试另一个。面对简单的问题,如此通常能事半功倍;但是当面对复杂问题的时候,单凭经验往往并不能达到立竿见影的效果,此时我们需要更精准的判断性能短板在哪里。

继续阅读

我的Golang热重载工具Air不好使了

我使用 VSCode Remote-Containters 作为 golang 开发环境,因为生产环境使用的镜像主要是 alpine,所以开发环境自然而然使用了 golang:alpine,对应 Dockerfile 的内容如下:

FROM golang:alpine
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.cloud.tencent.com/g' /etc/apk/repositories
RUN apk add alpine-sdk
RUN go env -w GOPROXY=https://goproxy.cn,direct
RUN go get github.com/cosmtrek/air

如上所示,出于众所周知的原因,我设置了 GOPROXY,并且安装了一个名为 air 的工具,熟悉 golang 的朋友都知道,它是用来实现热重载的,本来一切都正常,结果突然报错:「Setctty set but Ctty not valid in child」:

air

air

在 air 的 issue 里没找到对应的报告,不过在 golang 的 issue 里倒是发现了一些线索:

If tty is going to be open in the child process, then it must have a file descriptor in the child process. When using os/exec.Cmd as the creack/pty code does, tty must be in Stdin or Stdout or Stderr or ExtraFiles. If it isn’t in any of those, then it will be closed when the child process runs. If it is in one of those, that gives you the file descriptor number that you should use in Ctty.

Right now the creack code is setting Ctty to the parent’s file descriptor number. That is working (assuming that it is working) by accident. The correct fix is to ensure that tty is passed to the child somewhere, and set Ctty to the appropriate descriptor number. If it is possible for all of Stdin, Stdout, Stderr to be set, the simplest approach would be to always add tty to ExtraFiles, and set Ctty to 3. That will work for old versions of Go and also for the upcoming 1.15 release.

我只想让 air 正常工作,并不想深究工作原理,好在里面提到了 creack/pty,而 air 正好依赖它,于是顺藤摸瓜找到了对应的 issue,发现此问题是新版 golang 1.15 才出现的,并且已经修复了,可惜 air 没有升级 pty 版本,于是遇到新版 golang 后,问题就出来了。

恰好前几天 Golang 放出来 1.15 的正式版,因为我在 Dockerfile 里使用 golang:alpine 作为标签,并没有明确版本,相当于是 latest,也就是最新版 1.15,所以触发了问题。知道了问题的缘由后,解决方法就简单了,两种方法:

  • 改用 golang:1.14-alpine3.12 这种有版本的标签绕开问题版本。
  • 使用 go get 命令的时候,应该尽可能加上 -u 选项,以便能自动升级版本。

最好的方式莫过于继续使用新版 golang 1.15,同时给 go get 加上 -u 选项:

FROM golang:1.15-alpine3.12
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.cloud.tencent.com/g' /etc/apk/repositories
RUN apk add alpine-sdk
RUN go env -w GOPROXY=https://goproxy.cn,direct
RUN go get -u github.com/cosmtrek/air

不过当我们这么干的时候,发现我安装的 air 依然有问题,为了验证问题,我在一个干净的容器里手动安装,结果搞出一个匪夷所思的 v1.21.2 的版本来:

shell> go get -u github.com/cosmtrek/air
go: github.com/cosmtrek/air upgrade => v1.21.2

我在官网上根本查不到这个版本,直觉告诉我可能和 GOPROXY 有关,于是禁用后再执行,发现一切都正常了,好在 goproxy.cn 支持查询各个版本的下载量,于是我就查询了一下 v1.21.2 这个匪夷所思的版本,结果发现从 2020-08-07 开始一直有数据:

goproxy

goproxy

如此看来,问题的来龙去脉大概是这样的:2020-08-07 之前的某天,官方在升级打包的时候搞错了标签(v1.21.2),尽管很快删掉了,但是却被 goproxy.cn 给缓存了下来,之后发布的版本(v.1.12.X)虽然名义上是新版本,但是由于数字上都小于问题版本,结果导致是用 goproxy.cn 的用户在 go get 安装的时候加 -u 选项也得不到新版本。

让各个代理都删除错误版本显然并不现实,毕竟除了 goproxy.cn 还有 goproxy.io 等很多代理都可能有问题,其实只要重新发布一个保证大于 v1.21.2 的新版本(比如 v1.21.3)就可以了,在此之前,我们可以通过「go get -u github.com/cosmtrek/air@v1.12.4」这样的方式来固定主版本并升级依赖版本的权宜之计来缓解问题。

手把手教你用ETCD

一句话概括的话:ETCD 是一个基于 RAFT 的分布式 KV 存储系统。一个 ETCD 集群通常是由 3、5、7 之类奇数个节点组成的,为什么不选择偶数个节点?在集群系统中为了选出 LEADER 节点,至少要有半数以上的节点达成共识,举例说明:

  • 当集群有 3 个节点的时候,至少要有 2 个节点达成共识,最多容灾 1 个节点。
  • 当集群有 4 个节点的时候,至少要有 3 个节点达成共识,最多容灾 1 个节点。
  • 当集群有 5 个节点的时候,至少要有 3 个节点达成共识,最多容灾 2 个节点。

如上可见当节点数是偶数个的时候,系统的容灾能力并没有得到提升,所以节点数一般选择 3、5、7 之类的奇数,至于具体选择多少的话,一般推荐 3 或者 5 比较合适,太少的话系统没有容灾能力,太多的话 LEADER 节点的通信任务会变得繁重,影响性能。

继续阅读

白话布隆过滤器

日常开发中,一个常见需求是判断一个元素是否在一个集合中。比如当你在浏览器中输入一个网址的时候,浏览器会判断网址是否在黑名单里。通常的解决方案是直接查询数据库,看看是否存在相关的记录,不过这往往会比较慢,于是我们又会引入缓存来提升速度,可是当数据比较多的时候,缓存会消耗大量的内存。有没有既速度快又节省内存的解决方案呢?本文介绍一种算法:布隆过滤器(Bloom filter)。

继续阅读