长连接负载均衡的问题

在分布式的系统中,如果服务 A 要调用服务 B,并且两个服务都部署了多个实例的话,就要解决负载均衡的问题。即,我们希望到达 B 的 QPS 在 B 的所有实例中都是均衡的。

以前的类似 HTTP/1.1 的实现中,服务 A 每发起一次请求,都需要跟 B 建立一个 TCP 连接。所以负载均衡的实现方式一般都是基于连接数的。但是每次都建立一个新的连接,性能会很低。所以就有了后来长连接的实现方式:建立一个 TCP 连接,在这上面发送很多请求,复用这个 TCP 连接。gRPC 就是基于 HTTP/2 实现的,用了这种长连接的方式。

用长连接会提高性能,因为不用每次都去重新建立一个 TCP 连接。但是也有一些问题。

第一个问题就是负载均衡。Kubernetes 这篇博客讲了为什么 gRPC 需要特殊的负载均衡。很显然,HTTP/1.1 的方式,每次随机选择一个实例去调用,负载是均衡的。但是 HTTP/2 这种一直用一个连接的方式,一旦连接上了就会一直用,使用哪一个实例就取决于最开始选择的谁。

即使是一开始有办法让它连接均衡,但是有一些情况会打破这种均衡。比如说一台一台重启 service instance。

在每重启了一个 instance 之后,原本连接这个 instance 的 client 就会与其断开连接,转而去连接其他的可用 instance。所以,第一台被重启的 instance 重启完成之后是不会有连接的。其他的 instance 会增加:(1/n)/(n-1) * total connections 的连接。n 是总实例数。

因为每一个 instance 重启之后都会增加其他 instance 的连接数,就有两个问题:

  1. 第一个重启的 instance 到头来会有最多的连接数,最后一个重启的,不会有连接,非常不均衡
  2. 最后一个重启的 instance 在重启的时候会造成大量的 client 去重连

第二个问题就是增加服务端 instance 的时候,不会有 client 去连接它。即服务端迁移/上线下线的问题。因为所有的 client 都使用原来已经建立好的连接,不会知道有新的 instance 可用了。其实说到底和第一个问题差不多。

解决的方法,想到 3 个。

第一个就是如同上文博客中提到的那样,在 client 和 server 之间加一个 load balancer,来维护到后端的连接。可以完美解决上面两个问题。缺点是资源会比较高,架构增加复杂性。

第二个方法是从服务端解决:服务端可以不定时给客户端发送 GOAWAY 指令,示意客户端去连接别的 server instance。api-server 有一个选项是可以指定用多少的概率去给 client 发送这个指令:–goaway-chance float.

To prevent HTTP/2 clients from getting stuck on a single apiserver, randomly close a connection (GOAWAY). The client’s other in-flight requests won’t be affected, and the client will reconnect, likely landing on a different apiserver after going through the load balancer again. This argument sets the fraction of requests that will be sent a GOAWAY. Clusters with single apiservers, or which don’t use a load balancer, should NOT enable this. Min is 0 (off), Max is .02 (1/50 requests); .001 (1/1000) is a recommended starting point.

这样还有一个好处,就是下线的时候,不是粗暴地退出,而是可以对自己当前所有的连接都发送 GOAWAY 指令。然后无损地退出。

第三个方法就是从客户端解决:客户端不使用单一连接去连接服务端,而是使用一个连接池:

  1. 客户端每次要发送请求的时候,需要先向自己的连接池请求一个可用连接:
    1. 这时候,如果有,就返回一个连接
    2. 如果没有,就发起建立连接
  2. 使用完成之后,将连接放回连接池
  3. 连接池支持设置一些参数,比如:
    1. 如果 idle 一定的时间,就关闭连接
    2. 一个连接 serve 了多少个 request 之后,或者被使用了多少次之后,就关闭它,不再使用。

这样,一来可以解决一个连接被无限使用的问题,而来关闭连接也是无损的,因为连接池里面的连接没有给任何人使用,由连接池自己管理。其实,像数据库客户端,比如 jdbc,以及 Redis 客户端,都是这么实现的。

 

无法 Patch

最近在试图实现 pdir2 彻底 disable color 的 feature, 让它和其他 cli 的做法一样:在 stdout 是 TTY 的时候默认开启带有颜色的输出,在 stdout 不是 TTY 的时候不输出颜色,即没有颜色相关的 escape code. 这个 pull request 在这里

实现比较简单,在非 TTY 的情况下,新建了一个 Fake 的 Color Render,没有做任何渲染,直接输出。

但是在测试的时候遇到了难题:之前的测试都是按照默认输出颜色来写的,而 pytest 运行的时候,显然是没有 TTY 的,我的代码改变了这个行为,导致 pytest 运行测试的时候,颜色都消失了。

我试图用 patch 来解决这个问题,设定一个全局的 fixture,让 pytest 运行的时候,sys.stdout.isatty() 返回的是 True, 这样所有之前的 test case 都可以依然 pass. 然后就发现了一个非常难解决的问题。

使用 sys.stdout.isatty() 的地方,是在一个 module 的全局的地方,判断之后设置了一个全局变量,类似如下:

而这个 module 在 pytest collecting tests 的时候就已经 import 了,所以这个 use_color 缓存在了 module 的全局中,即使我去 patch isatty() , 实际上也不会调用到了。

 

于是我想到删除这个缓存。在 Python3 里面做这件事很简单,用 importlib.reload("pdir") 就可以了。

然而又出现了一个棘手的问题:pdir 这个 module,其实在 import 之后就已经不存在了。作者为了想让用户这么使用:import pdir; pdir(foo) 而不需要 from pdir import pdir; pdir(foo),用了一个 trick:即,在 pdir.__init__ 中,直接将 sys.module['pdir'] 替换掉了: sys.modules[__name__] = PrettyDir,这样的好处是:import 进来的不再是一个 module,而是一个 class,直接可以调用了。但是坏处是,我们再也找不到 pdir 这个 module 了,也就无法使用 importlib 进行 reload.

为了能够让它在 patch 之后进行 reload,我尝试了很多 hack,比如直接 patch 它的全局变量,发现会失败,因为 patch 也找不到 target 了,因为 patch 也找不到 pdir 这个 module,会提示 class 没有你要 patch 的这个属性;另外尝试过,在源代码中,sys.modules[__name__] = PrettyDir替换之前先保存原来的 module 到一个新的名字,发现也不行,这个 import 机制貌似必须让 module 的名字和 module 对应。

睡了一觉之后,我又在思考为什么 pytest 在收集测试的时候就运行了 module 的 init 呢?为什么不让我先 patch 好,它再去 import?

又仔细看了代码,发现,有一些 test.py 文件,在文件的开头就 import pdir 了。这时候,无论我怎么 patch,后面运行测试的时候都会使用已经 init 好的。

所以要解决这个问题,其实很简单:

  1. test 文件不能再任何地方 import pdir,必须要在 test case 里面进行 import,这样,收集测试的时候不会初始化 module
  2. 然后我设置一个全局的 fixture 去 patch isatty()。这样,执行的逻辑就变成了:收集测试(没有 pdir init)-> 全局 fixture 执行, isatty() patch 为 True -> 执行测试 -> 测试内部 import pdir -> pdir 认为 stdout 是一个 TTY

 

这样还有一个不好的地方,就是全局初始化好了,测试中就无法再测试不是 TTY 的逻辑了。

最后在代码中看到一段 sys.modules 的删除逻辑,好像作者也遇到过类似全局变量在测试中需要重新初始化的需求。把这段代码放到 fixture,发现居然神奇的工作了。原理很简单,就是我 patch 了之后,需要删除所有 pdir 的缓存,这样 import 的时候,就会重新 init 一遍。需要注意的是,不能只删除 pdir, pdir.* 都需要删除。

这样,只需要在测试 TTY 的时候,使用 tty 这个 fixture,不使用的话就默认不是一个 TTY。

 

要解决这个问题, 还有其他一些可能的思路有:

  1. 让 pytest 为每一个测试(或者测试文件)重新开启一个 python 解释器,这样就完全干净了。但是没看到 pytest 有这样的 feature
  2. 减少全局变量的使用,每一次调用都判断是否是 TTY 的逻辑,这样就是为了测试去修改原来的逻辑了,不太喜欢
 

像设计 UI 一样去设计配置项

在蚂蚁金服工作的时候,见到和使用了很多设计糟糕的系统,其中涉及最糟糕的叫做一个  AntX 的东西,现在想起来还会吓得发抖。

具体的实现已经记不太清了,因为自己从来也没有真正掌握过。只是记得这个其实是一个动态配置的系统,要使用它,在你的应用中要写入3个模板文件,其中一个模板会先进行 render,再去用这些变量 render 另外一个模板;render 的过程用到的变量是在一个 web 界面上配置的,并不存在于代码中;实际 render 出来的结果,有一些是程序编译的时候注入实际变量,貌似有一部分还是在运行时进行注入的。

复杂的模板文件、每一个模板还是使用不同的语法、需要在 web 配置、配置区分编译时和运行时变量、区分不同的环境变量。这听起来就是一个灾难了,我相信整个公司也找不到几个人能把这个最终配置生成的过程说的明明白白。最终大家也承认这套系统无法继续维护了,程序中已经存在有很多变量其实是没有用到的,但是程序跑的好好的,谁也没有动力去梳理一遍。最后,大家决定写一个新的配置系统来替代它。

Config 是面向用户的东西,应该像 UI 一样追求简洁,易懂,避免歧义。因为配置错误而导致的事故数不胜数,其实,很多都是由配置的作者以及使用者的理解有代沟造成的。这篇博客就来讲一讲我觉得不错的配置实践。

 

There should be one– and preferably only one –obvious way to do it.

软件的输入有很多种,命令行参数、环境变量、stdin、配置文件等都可以作为配置项去控制软件的行为。很多软件对同一个配置提供多种配置方式,增加了复杂度。

比如 Etcd 的这个 BUG:grpc gateway 在使用 –config-file 的时候默认是 false,不使用 –config-file 的时候是 true。即使 –config-file=/dev/null 也会变成 false。当时花了很长时间去排查。

我觉得对于服务端的软件,其实大部分配置都要求从配置文件中配置就可以。在 lobbyboy 这个项目中验证了一下自己的想法。lobbyboy 作为一个 server 端软件,所有的配置只能从配置文件输入,除了 -c 可以改变配置文件的 Path 之外没有别的命令行参数。

Nginx 的命令行也没有多少参数,大部分配置都是通过配置文件来控制的。

当然对于 Client 端软件来说,就比较复杂了,很多参数同时支持环境变量、args、xxxrc 文件配置,会比较用户友好一些。比如 iredis 的配置文件读取顺序是:

  • Options from command line
  • $PWD/.iredisrc
  • ~/.iredisrc (this path can be changed with iredis --iredisrc $YOUR_PATH)
  • /etc/iredisrc
  • default config in IRedis package.

客户端软件的配置最好遵守 XDG Base Directory Specification

 

Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.

让配置更加简单一些,在设计的时候,可以考虑这几个问题:

  1. 这个配置是否有必要?用户在大部分场景下是否用默认值就足够了?
  2. 多个配置是否可能用一个配置没有歧义地讲清楚?
  3. 可以这样考虑:添加一个新的配置的时候,如何使用最少的语言将这个配置解释清楚?

在 UI 的设计中,每一个像素都是重要的,每一个空间都要想办法争取节省,添加太多没必要的东西,将会把UI变得不直观。配置也是,配置不是一个无限大的文件,配置的添加是会带来成本的,不是程序运行的成本,而是人的成本,这比运行的性能更加重要。

说到动态配置,很多时候我都在怀疑这到底是不是一个伪需求。现在的应用都被设计成无状态的,可以随时重启的。那么我修改配置的时候,是否可以改一下配置文件然后重新部署?当然,如果有上千个实例的话,修改速度可能会成为一个问题。

有动态配置功能的时候,一个误区是开发者会将所有“感觉将来可能会修改”的配置都放到动态配置中。其实我觉得所有的配置都可以先作为 hardcode,如果发现需要修改的多了,再移动到配置文件或者动态配置中。

Json 绝对是一个糟糕的配置语言。它的优点在于机器读取和解析没有歧义,不像 Yaml 那样。

Why JSON isn’t a Good Configuration Language

但是它对于人类编辑来说,实在太不友好了:

  • json 只是易于解析但是太难编辑,非常容易出错。比如 [] 不允许在末位添加 ,,否则会出现格式错误。这样,你每次编辑的时候,添加一行会出现两行 diff.
  • json in json 更是噩梦。我发现一个规律,就是在有 json 的地方,就会有 json in json(指的是 json 里面有一些 value,是字符串,字符串只是添加了转义的 json)。甚至有可能有 json in json in json.
  • 比如很多list都设计成 items:[{key: name, value:jack}],一个list 这已经就已经有两层的嵌套了。
  • Json 对于 line editor,比如 sed,awk 也不够友好。
  • 不支持注释,导致无法在文档里面给出一个 self-explain 的 example.

 

If the implementation is hard to explain, it’s a bad idea.
If the implementation is easy to explain, it may be a good idea.

很多配置的名字都和实现绑定,可能是一个思维定式。为一个配置想出来一个好的名字,可以尝试忘记实现的细节,想一想如何能够直白、没有歧义地、容易被记住地解释这个配置。

一个典型的例子是,配置尽量不要使用双重否定。比如 disallow_publish,最好使用 allow_publish.

uWSGI 里面有一个配置叫做 harakiri,是日语的“切腹自尽”的意思,表示一个进程在规定的时间内如果没有完成请求的处理,就会退出。比较贴切。

Redis 的 save 触发时间的配置我觉得设计的很好:

可惜的是,这种设计好像没有什么模式可以遵循。

后记:其实这篇文章放在草稿箱里面很久了,只是有一些想法,但是不知道怎么描述比较合适。配置如同给变量起名字一样,是一个难以说清的话题。本文也只是潦草的表达了一些凌乱的想法,看了设计服务端软件配置的 4 条建议 这篇文章,决定将草稿箱里面沉睡已久的文章发出来。欢迎读者交流沟通。

 

心动网络宣传片的翻译

心动公司的网站有一段很好看的宣传片,可以在 https://2400.hk/ 观看。这段影片有中文版本,在 https://www.xd.com/ 可以观看。相比于英文,我觉得中文在刻意避免翻译痕迹,反而看起来表达没有英文有力一些。

如果减少一些意译的力度,听起来可能更加自然一些。如下:

 

做完需要多久?

等做完的时候,我会告诉你。

没有 deadline 吗?

我的原则,高于 deadline.

你的产品不是为了赚钱吗?

先做好产品,钱自然会赚到。

什么时候才能做完?

当我们接近完美的时候。

他们说完美是不存在的。

所以我们要更加努力。

你为什么选择游戏?

单纯是为了它的纯粹。

如今,什么都是 Pay to win。

所以有人必须站出来,倡导 Play to win。

为什么不遵守规则?

规则是给玩家的,我们需要原则。

你不应该更现实一点吗?

满足于现实,我们就不会选择这个行业。

 

我的删库经历

看了一篇文章讲自己删库经历,感同身受的一点是:

But, as far as I know, no one ever asked why a junior developer had access to a production database. I probably did need read access, but why write access? And if I did need write access, did I really need permission for a destructive command like TRUNCATE?

Blameless 的事故 review 如此重要,以至于如果没有它,我们会更加注重在人犯的错误上,而不会关注到系统上的漏洞:为什么初级工程师会有 write 权限?为什么 TRUNCATE 没有禁止?为什么无法从一张表恢复备份?

我唯一的一次删库经历是删除了一个线下的测试库。事情是这样的。

我刚入职上一家公司不久,pull 下来项目的代码,发现是一篇狼藉,几乎无法维护(不是我个人品味上认为的无法维护,是客观上的无法维护的代码,如果你读完,就可以想象这些代码的质量了)。

于是我的计划是:

  1. 先修复好单元测试,让每一次提交都通过单元测试;
  2. 逐步和团队重构代码;

那是一个基于 Django 的项目,之前的单元测试因为配置问题,都无法启动。在修复完这些问题的时候,我运行了测试命令:python manage.py test.

几分钟之后,发现测试库被删除了。这才发现,之前的代码中数据库的配置也有一个问题,测试环境连接的数据库,和单元测试定义(本来不需要定义,django 自己会生成)的名字是同一个数据库的名字。Django 运行测试的时候,如果不指定 --keepdb,Django 运行完测试之后会删除掉测试用的库。

虽然是一个线下的测试库,但是导致了很多的问题:

  1. 大家把很多配置数据直接通过 shell 写到了数据库里面,代码中没有备份;
  2. DBA 不负责维护线下库,无法恢复备份;

所以最后只能通过凭借记忆恢复数据。

这个也暴露出来很多问题:

  1. 为了图快,这个项目的开发有很多没有遵守规范的地方,比如直接将重要的数据保存在了一个没有 SLA 保证的数据库里面;(后来我发现很多这个公司内内部项目都是这么做的,以线下环境为主,因为线上环境每次发布和修改审批流程太复杂了,只改动一行代码发布到线上需要至少 40分钟。在很多人眼里,内部项目的质量是不重要的,只需要晋升答辩的时候能够有一些系统的截图就可以。)
  2. 数据修改应该使用 Django 的 Data migration,这样非业务写入的数据都会在代码库中持久化,还有历史记录;
  3. 项目有危险的配置错误;
  4. 单元测试从来没有运行成功过;

这个项目最后的命运也以无法维护而告终,我们最后重新写了一个项目来取代它。