Thoughts, stories and ideas.

事故带来的技术思考

事故说明

2016 年 3 月 4 日下午 14 点 46 左右,BearyChat 服务器突然无法连接,很快就处于不可用状态,30 分钟之后服务恢复,具体的时间点:

14:46: 线上在线数开始急剧下降,聊天服务器异常报告增多(此时越来越多的用户处于无法连接的状态)

14:50: 通过日志发现聊天服务器触发了一个概率极小的 Bug,导致当前节点启动了自恢复流程,主动断开了用户连接

14:55: 大概经过 5 分钟的恢复,有部分用户已经可以连上,这个时候 RDS 集群发出了报警,CPU 超过阈值

14:55: 在监控看到,当前的 QPS 达到了之前峰值的 20 倍,而且大部分请求直接到达了 RDS,导致雪崩

15:00: 工程师提交聊天服务器修复 Patch,同时断开所有流量,提高 RDS 配置

15:14: RDS 恢复完毕,所有服务启动完毕

15:15: 在线数逐步恢复,BearyChat 服务逐渐恢复

15:23: 监控到所有请求和服务都已正常

从经过看起来,那个概率极小的 Bug 是始作俑者,但其实后面的问题更应该被揭露,首先说一下这个 Bug 引起的第一个反应:聊天服务器节点自恢复流程

Erlang Supervisor

BearyChat 的聊天服务器是 Erlang 实现的,Erlang 的一个特点是 fault-tolerance,它倡导 「Let it crash」。不熟悉 Erlang 的朋友可能觉得这个哲学很吓人,其实并不是那样的,在 OTP 框架下,通常一个基于 Erlang 的系统,都是由 Supervisor 和 Worker 进程组成的一棵监督树,一个典型的实例图如下:

Supervisor 不但负责启动子进程,同时会监控子进程的运行状态。那 Supervisor 怎么做到「Let it crash」呢?Supervisor 启动的有一个 strategy 参数,它决定了这个 Supervisor 的几种工作模式:

  • one_for_one: 如果 ASupervisor 发现其中 W1 停止运行了,它会自动重启 W1。
  • one_for_all: 如果 ASupervisor 发现其中 W1 停止运行了,那它会让 W2 和 W3 也停止运行,然后重启他们(W1~3)。
  • rest_for_one: 如果 ASupervisor 发现其中 W1 停止运行了,那它会让在 W1 之后启动的 Worker 停止运行,然后重启他们。

嗯,通过上面,大家可能觉得有点似曾相识,也就是当年我们当「电脑修理工」时回答最多的问题:「电脑坏了?重启一下试试」。不过大家可能会发现如果选择了 one_for_all 或者 rest_for_one,在大部分场景里面都不适用,BearyChat 也是采用了 one_for_one 模式,但还有一个决定了 Worker 重启策略的参数 -- restart:

  • permanent: 停止的 Worker 总会重启
  • transient: 只有异常停止的 Worker 才会重启
  • temporary: Worker 不会重启

问题就出在这里,我们在一个不该选择 transient 的 Supervisor 上选择了 transient 策略,Worker 触发 Bug 之后就会不断重启,在短时间内超过了 Supervisor 的最大重启次数,会让该 Supervisor 的其他 Worker 都终止,并且导致 Supervisor 停止工作,引发到更上层的 Supervisor 活动,中断了用户连接。虽然聊天服务器本身在这个过程中最终恢复了正常的运行,但却因为主动中断了大量用户连接,导致其他系统的崩溃。我们依然觉得 Erlang/OTP 的 Let it crash 很强大,只是在使用的时候需要更仔细,尤其是要考虑到整套系统的 fault-tolerance 能力。

聊天服务的自恢复引发了故障的第二个环节:RDS 倒下了

服务雪崩

在聊天服务自恢复之后,大量的用户请求到达了 API 层面(20 倍平时的峰值流量),而且很多都越过了缓存, RDS 服务没有扛下来,最后不得已停止了所有服务,调整 RDS 集群的配置。这里问题更明显一点,需要做 流控,在完成并测试通过之前就先不说了。

Tl;Dr,这次事故踩了几个可能大部分朋友也会踩到的坑,所以也在这里分享一下,我们也会在今后规避这样的常见问题,改善服务稳定性,给大家提供一个靠谱的 BearyChat。(圣斗士之所以强大,是因为不会败在同一招下两次

comments powered by Disqus