记一次内存爆炸的经历


(Frederic Chan) #1

最近有做一个程序,内容其实很简单:调用几百万次一个api,然后把结果保存到 MongoDB 数据库。

一开始我没有想到用 coroutine 等高级方法,就简单采用了“生产者消费者”的模式:第一组线程负责分配任务,第二组线程负责从api获取结果,然后保存到一个队列,第三组线程负责从队列中拿任务并保存到数据库。

程序很快就写出来了,在本机上跑也没有出现问题,但是后来部署到正式机器上后就出现了问题:程序启动后,内存一直拼命增长,直到把内存占满,然后被内核 OOM。

一开始,出于不自信,我想的是会不会是我的程序哪个地方内存泄漏了。但是经过资料的查找,发现Python的内存是实时回收的,而且我的程序也着实看不出哪里可能内存泄露了。

后来我把程序 log level 开到最高,并且不同线程的输出使用不同的颜色,才发现问题在于:生产环境中配置文件中,生产者和消费者的线程数被改得面目全非——原来是15/20(其实这个数字是我随便写的),后来被别人改成了120/240。

其实我一开始就发现了这个数字被改了,只是我认为这个数字应该是对方经过调试之后试出来的,而不是拍脑门想出来的,所以没有考虑是这个上面的问题。然而实际上,这么大的线程数就是拍脑门想出来的… 而我在本机上一直使用的自己的 development 配置,没有开这么高的线程,所以一直没有办法复现这个问题。

其实仔细思考一下就会发现,Python 的多线程受制于 GIL 这把大锁,线程越多,上下文切换花的时间反倒是越多。因此即使不出现爆内存的问题,设置这么大的线程数在执行效率上也是有问题的(所有线程都是同一类型的IO密集型除外)。

再说说为什么线程数被改成这样之后就导致爆内存:

初期我自己的假设是调取一次 API 的时间会比存一次数据库的时间慢很多,因此生产者应该比消费者线程数量多一些。然而这个假设没有任何实验基础,完全是自己的猜测。事实上,因为api服务器在本机,所以调用api反而比存数据库更快。因此『待存数据库』队列里的数据越来越多,逐渐占满内存,然后被系统 OOM。

不仅生产者生产快,更为可怕的是,而且由于生产者和消费者在一个进程内,因此无论是生产者还是消费者,每个thread被execute的时间是大体相等的(我并不清楚 Python 是怎么选择线程执行的,假设是时间片轮转吧),因此 消费者线程/生产者线程 比例过低会导致消费者的执行时间不够,从而无法消耗生产者的产品。

通过这件事,我得出了以下教训:

  1. 队列要设置最大数量限制。生产者/消费者的比例实际上是很难准确估计出来的,设置最大数量限制,反馈调节生产者速度才不会出现队列太大爆内存的问题
  2. Python的多线程是残废。不妨使用 multiprocessing / concurrent.future
  3. 开始学习协程吧!