无法 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. 单元测试从来没有运行成功过;

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

 

系统的过载(Overload)以及处理思路

本文所谈到的过载,容易和雪崩混淆。雪崩指的是在分布式系统中,某一个依赖无法正常提供服务,但是系统无法屏蔽掉这个依赖,最终导致整个系统都无法提供服务了。

过载指的是某一个服务所收到的请求量原大于它能处理的请求。

所以后续的讨论使用过载这个词,虽然有的时候我们在讨论相同的问题,但是用的是“雪崩”这个词。

对于过载,似乎有一个很简单的解决方法:现在很多系统的设计都已经考虑好限流了。即,假如某一个系统能处理的 QPS 是 1000,那么它只会同时允许 1000 个请求进入处理,当第 1001 个请求来的时候,会迅速得到错误,直接返回。这样保护系统总是可以成功处理 1000 个请求的。

这就是 Rate limit, 一个典型的 overload protector. Rate limit 有很多实现的方法:Token bucket, Leaky bucket, 等等。

然而这样就足够了吗?

想象这样一种情况:有一个网站(比如您当前正在阅读的这个博客),所能够处理的 HTTP 请求是 1k/s. 但是目前有 1万 个访客正在访问,然后触发了网关(比如说是 Nginx)的限流机制,对于每秒钟第 1000 个之后的请求都直接访问 429 Too many requests.

这样的话,网站本身每秒都在处理 1000 个请求,看起来是在正常工作的,但是对于终端的用户来说呢?他们能正常浏览网页吗?答案是不能的。因为,拿博客的首页来说,一共要发送 61 个 HTTP 请求才能正常显示。也就是说,每一个用户都需要成功拿到 61 个 HTTP 的响应才能看到一个完整的网页,否则,可能缺失一些 css 文件导致网站的格式显示不正确;或者缺失了某些图片导致不知道内容在说的什么;或者无法请求某些 js 导致部分功能缺失。为了看到完整的网页,他们可能重新刷新网页,进一步加剧了当前网站承受的总流量。

再考虑这样一种情况。在一个微服务的系统中,为了展示一个商品的详细信息,“商品微服务” 需要调用3次“用户微服务”,目的分别是:

  1. 拿到用户所在的地区,用来计算运费;
  2. 拿到用户的会员等级,渲染优惠;
  3. 拿到用户当前账户中的积分信息,渲染出来如果使用积分的话可以优惠多少钱;

假如“用户微服务”过载的话,用户微服务对于所有的请求来源进行限流,那么现在这三个请求每一个都可能失败,每一个失败了都无法成功渲染出来商品详细信息。

还有一种情况。假设某一个服务发现系统使用 Etcd 这种带 lease 的数据库作为后端依赖。一个服务注册上之后,需要不断 renew lease, 才能保持在线上。如果它挂了,不再 renew lease,那么当 lease 过期之后,Etcd 就会将其从数据库中删除掉。

某一天,这个系统重启了,所有的服务都开始重新注册自己,请求量太大导致 Etcd 过载了。为了保护自己,Etcd 也有 Rate limit 机制,它只允许 3000 个请求同时处理,对于第 3001 个请求也会直接返回错误。这时候,有一些服务能够注册成功, 但是后续的 renew lease (keepalive) 请求可能失败。这样,即使 Etcd 能够成功处理 3000 个注册请求,但是这些注册功能的服务因为 keepalive 请求无法被接受,所以不得不重新注册自己。就会导致 Etcd 一直处于一种 overload 的状态,永远无法恢复。

以上几种情况,都是在有 Rate limiter 的情况下,系统依然会被 overload。

Overload 的时候,总请求量是比容量要大的,那么我们的解决思路就是,要保证总请求量随时间不断减少,最终,总请求量在能够处理的容量之内。虽然有些请求可能失败,但是他们最终的重试是会成功的。换句话说,要保证我们处理的请求都是“有效的”请求。

举一个反例:网站使用队列来缓存住超过容量限制的 HTTP 请求,然后不断从队列中取出来请求处理。这之所以是一个糟糕的方案,是因为在 HTTP 的场景中,如果一个用户打不开网页,那么他会刷新而不是等在这里。所以网站服务器从队列中拿出来的请求总是用户已经放弃的请求,那么一直在处理“无效”的请求了。

一些可以考虑的思路如下。

按照上下文进行限流

当过载的时候,我们要保证依然在容量限制内的数量的用户是可以正常服务的。但是不能单纯的从请求的角度来进行限流。而是应该加入业务上下的维度。比如:

  • 假如一个用户打开了某一个页面,要保证后续的操作都是成功的,至少可以完成一次交易;假如某一个用户已经被 rate limiter 失败了,那么这个用户的请求我们都让它失败掉,不要再处理他的请求浪费资源了;
  • 或者按照用户 id 进行 sharding,只保护某一部分用户;过一段时间保护另一部分用户;

原子化调用

在上文中第二个例子中,可以考虑将三次请求变成一次请求。这样按照请求维度的限流依然是有效的。

切换成异步链路

对于某些情况,可以考虑使用上面的“反例”。比如说用户的付款请求,如果遇到了失败,那么用户很可能放弃这笔交易。我们可以考虑使用队列来让付款请求排队,尽可能让付款请求有更大的能够成功处理的机会:

  • 显示明确的处理中的字样,尽量给与用户安抚;
  • 如果用户尝试取消,弹出确认提示可以取消付款,交易尚未发生,但是鼓励继续等待;
  • 对于信用好的用户,或者单价低的订单,考虑先推进交易,异步从账户中扣款;

临时的降级(断路)

在雪崩的情况下能够有应急的方式临时屏蔽掉一些消耗资源的特性。

比如在上述 Etcd 的场景中,我们可以考虑,当服务首次注册成功的时候,将 lease 的时间设置为 20min,在第15min 的时候开始尝试 renew,如果失败,以指数级的时间退避重试。如果重试的次数比较少就成功 renew 了,就将 lease 的时间设置为 10min;下一次再缩短 lease,直到 lease 是一个足够短,满足服务健康检查的时间。这样可以在短期内给与 Etcd 足够的时间去恢复,保证它当前正在处理的请求都是最终的要求(指的是,把服务注册上去是第一要务,健康检查踢掉异常服务是次要任务,可以暂时降级)。