震惊!程序员对 Elixir 竟有这样的误解,背后细节令人揪心
Let it crash?
Let it crash – Elixir 宣传口号之一,进程发生崩溃后,supervisor 会对其进行重启。 那么只要系统中有 supervisor 保护,系统永远不会停止运作吗?并不是。
children = [
Hello.Worker
]
opts = [strategy: :one_for_one, name: Hello.Supervisor]
Supervisor.start_link(children, opts)
上面是最常见的一段 Elixir 代码,常见于 application.ex
主文件。该 Application 主进程启动了一个 supervisor。该 supervisor 携带了 worker 子进程。启动时应用了一种策略(strategy: :one_for_one
),并且给 supervisor 注册了名字(name: Hello.Supervisor
)。
然而,启动 supervisor 还有两个重要选项未被提及:
max_restarts
: 在一段时间内允许某个子进程重启的最大次数,默认是 3(次);max_seconds
: 就是上面所说的 “一段时间”,默认是 5(秒)。
默认情况下,如果某个子进程在 5 秒内因故崩溃超过 3 次,supervisor 将不再对其重启,而任由其崩溃。子进程奔溃后,即又导致该 supervisor 自身崩溃(如果 supervisor 调用 start_link
启动子进程)。而且在上述代码中,启动 supervisor 的方式也是 start_link
,所以 supervisor 崩溃后,也会导致执行该代码的进程崩溃。
因此,即使对整个系统进行了优良的设计,用 supervisor 树隔离了子系统,但依然会在某个情形下,整个系统停止工作。一旦系统停止工作,即使之后触发故障的问题消失,系统也将无法自动回复。这个特性设计上是为了防止无休止的重启,从而让程序员及早发现问题,解决问题。但在生产环境下,它严重影响了系统的可恢复性(resiliency),谁也不想半夜爬起来重启挂掉的系统。
若干处理方案或思路:
- Supervisor 完全不用操心去重启子进程(将 restart 策略设为
:temporary
)。但这种方法不能应用于所有情况,有的业务需求无法将子进程视为一次性、失败后可以安全丢弃的操作。 - 将
max_restarts
和max_seconds
调整到足够大。如果可以预见某种故障发生频率,这种方法倒是可以考虑。
或者不要依赖 supervisor 的魔法,而给进程代码提供更好的防御:
- 处理可预期的错误:
- 比如小心使用第三方带有
!
后缀的函数,尽可能用不带!
号的版本,然后匹配其不同的返回模式。注意有些第三方函数使用了会抛出错误的代码,但函数名却没有用!
标记出来。 - 检查是不是为所有可能的模式都提供了对应的匹配?如果不确定,加上万能匹配
_
兜底。 - 检查那些隐蔽的能够抛出错误的地方,比如
GenServer.call
调用超时等。这需要多读读常用函数的文档。
- 比如小心使用第三方带有
- 给可疑代码加上
rescue
或者try/catch
,或者干脆给进程的核心函数整个儿套上rescue
逻辑。 - 等等等等
结论
- 即使有 supervisor 的隔离保护,某个进程的崩溃依然可以导致整个系统停止工作。
- 在写需要长期稳定运行系统的时候,不要盲目笃信 “let it crash”,而是仍然要小心翼翼去发现和处理可能触发异常的代码。