Erlang 之禅
本文是在原作者 Fred Hebert 先生的许可下, 对 The Zen of Erlang 的简体中文翻译.
我从原文中获益良多, 也曾受其启发在 Elixir Shanghai 的第二次聚会上分享了一个题为 Defensive Programming vs. Let It Crash 的演讲, 从另一个角度切入分享了我的一些感悟.
本文适合所有想了解 Erlang 的人. 而如果你已经接触了 Erlang 或 Elixir 一段时间, 却觉得没有以 Erlang/OTP 的思路在架构程序, 本文更是不容错过.
原作者所使用的一些比喻也许有些冗长 - 我的翻译也深受文字功底所限, 很是拙劣生硬, 所以那些部分随便看看就好不必较真 - 但有关 Erlang/OTP 的部分都很有仔细研读的价值.
衷心希望这篇译文能对中文世界的「编程者」们有所帮助.
原文发表于 2016年02月08日
Erlang 之禅
我在 Genetec 组织的 ConnectDev’16 上做过一个演讲, 本文是那次演讲不怎么精确的文本化(或许该说是一篇更详尽的注解?)
那时我假定听讲的人大多从没用过 Erlang, 也许有人听说过这门语言, 又或者就知道有这么个名字. 所以这篇演讲只涵盖了 Erlang 一些高层次的概念, 不过其中的一些「禅理」或许也会对你们的日常工作或业余项目有所帮助, 哪怕你并不使用这门语言.
假如你曾经看过 Erlang, 你肯定听说过那句「随他崩溃」(“Let it crash”, 下文保持英文)的座右铭. 我第一次听到这句话的时候就在想这什么鬼. Erlang 不是强在高并发, 高容错性等等这些方面吗, 怎么能允许随便崩溃这种事呢, 这应该我最不希望看到的事故呀. 的确, 这句话听起来很出人意料, 不过 Erlang 之「禅」还真和它脱不开干系.
在某些方面 Erlang 的 “Let it crash” 听起来就和搞火箭科学的人说「让它爆炸吧」一样滑稽. 那本该是你最不希望发生的事 - 想想「挑战者」号曾发生的悲剧吧. 话又说回来, 若从另一个角度来看, 火箭及其依赖的推进原理正是关于如何控制那些极其危险的易燃易爆物质, 进而才能利用它们驱动太空旅行和运输.
其中的关键就在于控制; 你可以将火箭科学看作是一门通过合理利用爆炸 - 起码有推力 - 来达成目的的学问. “Let it crash” 也是同样的道理: 这正是关乎容错的信条. 我们真正的目标不是去随处制造那些不受控制的崩溃, 而是将故障, 异常还有崩溃等等这些变成我们可以利用的工具.
向后引燃法和受控引燃法 1 是现实世界中以火制火的实例. 在我的家乡, 人们会定期燃烧蓝莓田, 用一种可控的方式来促进其生长. 为了预防森林大火, 树林中不健康的部分也会时常被人们主动烧掉, 当然也是在专门的监管和控制之下进行的. 这样就算发生了大火也会因为缺少可燃物而无法扩散太远.
在上面所有这些情况里, 火焰那本来极具破坏性的能量却可以用于保障作物的健康, 或是预防规模更大, 亦无法控制的森林灾害.
我想这差不多就是 “Let it crash” 的含义. 如果我们能以一种更有掌控力的姿态来面对故障, 异常或是崩溃, 它们便不再是什么让人害怕的事件. 我们便无需竭力避免其发生, 而是以其为基石, 构建更大并且更稳定的系统.
那我们怎么让崩溃之类的「弃恶从善」呢? Erlang 提供了最基本的拼板: Erlang 进程 2. Erlang 进程彼此完全独立, 不共享任何数据. 一个进程无法查看或是篡改其他进程的内存, 也就无法干扰其他进程正在进行的工作. 这样的好处是基本上保证了一个进程出问题时只会影响到自己, 同时这就为你的系统提供了很强的故障隔离性.
Erlang 进程也非常轻量, 启动成千上万个稀松平常. 重点是你想用多少进程就能用多少, 而不是受限于系统可以承受多少. 你可以这样试着比较一下, 假如在一门面向对象的语言中你最多只能同时有 32 个对象, 你肯定很快就会觉得完全不够用了. 使用大量且轻量的进程使我们能以更细的粒度处理故障, 对于我们想要反过来利用故障和崩溃之类的也非常有用.
你可能会对这么多进程究竟是如何一起工作的有所疑问. 比方说用 C 语言写程序的时候, 常常会在一个大的 main()
函数里面写很多代码. 我们称之为程序入口. Erlang 里面没有这样的概念. 没有哪个进程是控制整个系统的. 虽然每个进程还是在运行某个函数, 但这个函数只在那个进程本身当中扮演 main()
函数的角色.
那么我们现在有了「蜂群」, 但如果它们无法彼此交流就很难指挥它们一同构筑蜂巢. 蜜蜂通过舞蹈来交流, Erlang 进程则通过传递「消息」.
消息传递是并发环境下最为直观的通讯方式. 这也是人类最古老的通讯方式, 从骑着马的信使带着我们的信件奔向目的地, 到一些更酷的像是图片里面的「拿破仑旗语」之类的方法都是一种消息传递的方法. 马匹终究会疲惫, 但旗语却可以快速地将一条消息传递到远方. 当然了, 后来这些方法渐渐被电报所取代, 然后是无线电和电话, 如今我们有了更多更先进的技术, 能够以前所未有的速度向难以想像的远方传递消息.
这种消息传递的方式中很重要的一点 - 特别是在旧时代 - 就是大家都是「异步」的, 而消息被「复制」了. 一般没人会一直站在门口等待回信, 也不会有人一直守在信号塔上等待回复. 你发出一条消息, 接着干平常的事情, 当有回复的时候自会有人通知你.
这种方式的好处在于如果对方迟迟没有回应, 你不会什么也不做一直等到世界末日. 同样的, 即使发信人突然死掉了, 收信人所收到的信息也不会因此消失或者被什么魔法篡改. 这两条准则可以保证通信时的故障不会产生损坏的或无法恢复的状态. Erlang 将这两条准则都实现了.
每个进程都会从自己的「信箱」中读取消息. 一个进程可以向任意其他进程的信箱中写入消息, 但只能从自己的信箱中读取. 默认情况下进程会按照消息被收到的顺序依次读取, 但也可以通过模式匹配 3 之类的方式来重点关注某些消息.
可能有人已经从我刚刚所讲过的内容中听出了端倪. 我一直反复地讲隔离性和独立性多么多么的好, 它们能让一个系统的各个组件崩溃挂掉却不会影响到其他部分. 但同时我又讲了这么多进程之间是如何通信的.
每当两个进程之间有所通信的时候, 我们实际上就已经隐式地在两者之间创建了「依赖」. 这种依赖可以说在系统中也留下了隐式的「状态」. 如果进程 A 发给进程 B 一条消息, 然而 B 还没回应就挂掉了, 这时候 A 有两种选择, 要么一直等下去, 要么等超时后放弃. 一般来说后面这种比较可取, 但这种策略还不够明确: 即我们无法确切地得知究竟 B 真的挂掉了还是 B 要花很久才能够回复, 如果只是处理较慢, 那么可能在 A 放弃了之后 B 又发来回复, 这种情况下这条消息就会永远留在 A 的信箱里无人处理了.
不过无需担心, Erlang 提供了两种机制来处理这种情况: 监控 (monitor) 和链接 (link).
监控诚如其名, 就是让一个进程变成观察者. 你可以一直关注某个进程, 一旦它挂掉了, 你就会收到一条包含具体原因的消息. 之后你就可以根据消息中提供的信息决定要采取些什么对策. 被监控的进程完全不会知晓监控者的行为. 所以如果你只是在意某个进程的死活, 监控是一种蛮不错的手段.
链接则是双向的, 建立了链接的两个进程的「生死」将紧密相连. 一旦其中一个进程挂掉, 所有与其建立了链接的进程都会收到退出信号, 从而跟随这个进程一同死掉.
于是我们可以用监控来快速检测故障, 可以用链接作为一种组建架构的手段, 将若干个进程绑定在一起, 有故障时一齐杀掉. 当系统中原本独立的模块开始彼此产生依赖时, 我可以将这种依赖写入程序代码中. 而这可以防止系统由于意外的崩溃进入不稳定的或不完整的状态. 链接作为一种工具可以让开发者确保当系统的一部分有故障时, 这一部分会统一关闭而不会污染整个系统的状态, 同时也不会殃及与这部分本不相关的其他组件.
这一页的图片是几个以绳子互相绑在一起的登山者们. 假如说这些登山者们只有链接的话那就太悲剧了. 因为一旦一个人打滑了可能整个团队都要死于非命. 这听起来可不怎么好.
实际上 Erlang 可以让你指定一些特殊的进程, 通过一个叫 trap_exit
的选项. 这些特殊进程可以捕获链接所导致的退出信号, 并把它们转换为消息. 因而它们可以做一些故障修复的工作, 比如说启动新的进程来继续挂掉的进程的工作. 不像真正的登山者那样, 这样的特殊进程并不能阻止与其链接的进程崩溃 - 这需要在会崩溃的那个进程里写类似 try ... catch
之类的语句 - 一个激活了「捕获退出信号」的特殊进程不能进入其他进程的内存然后阻止其崩溃, 但却可以避免它「灭亡」.
这是实现「监督者」 (supervisor) 的一个重要特性. 别着急我们马上就会聊到它们了.
讲解监督者之前, 我们还需要再聊其他几样东西, 少了它们还是没法真正地「化崩溃为神奇」. 首先是进程如何调度, 我就拿阿波罗11的月球登陆来举例吧.
阿波罗11是69年的一项登月任务. 图片中我们看到巴兹·奥尔德林和尼尔·阿姆斯特朗所乘坐的登月舱, 照片本身应该是留在指令舱的迈克尔·科林斯拍下的.
登月舱飞往月球时应该是由 Apollo PGNCS (Primary Guidance, Navigation and Control System) 所引导的. 导航系统要同时运行若干个任务, 每个任务都有严格分配的循环数. NASA 特别指定了处理器只能用85%的运算力, 剩余15%以应对紧急情况.
宇航员们想制订一个比较稳妥的备用计划来应对任务中止的情况, 他们预留了一个交会雷达. 那家伙用掉了 CPU 剩余性能的一大部分. 当巴兹·奥尔德林开始输入命令的时候, 各种关于溢出和资源耗尽的错误信息层出不穷. 假如这时候系统失去控制的话, 可能其他组件就无法完成它们的任务, 最后我们就要面对两具宇航员的尸体了.
这主要是因为那个雷达有已知的硬件问题, 导致其频率和导航电脑的不符, 进而使用了远多于预计的 CPU 循环. 不过 NASA 的人也不是笨蛋, 他们没有为这种重量级的任务去开发过多的新技术, 而是再利用了以前的组件, 那些就算是稀有故障他们也了如指掌的东西. 更重要的是, 他们发明了「优先级调度」.
这意味着导致处理器过载的不论是这个有故障的雷达还是宇航员所输入的命令, 只要其优先级与那些绝对重要的东西比起来足够低的话, 系统就会杀掉这些任务, 而给那些真正需要的任务腾出 CPU 循环. 要知道那还是 1969 年; 而时至今日还有好多语言或框架只提供了协同调度.
虽然你不应该用 Erlang 去编写这些关乎性命的系统 - Erlang 只遵循了软实时系统的约束, 而非硬实时, 所以这些情景下不要用 Erlang. 但 Erlang 确实提供了抢占式调度, 以及进程优先级. 这意味着对于系统设计者来说你无需仔细确认每个组件(包括你用到的库)所使用的 CPU 资源, 它们不会导致整个系统挂起. 同时假如你真的需要, 也可以指定一些重要的任务优先运行.
这些听起来可能不是什么很重要的需求, 确实有很多成功的项目构建在只有协同调度的并发任务之上, 但抢占式调度也有很大的价值, 它可以从别人乃至你自己的错误中保护整个系统. 它还可以帮助你构建类似自动负载均衡, 好坏进程的惩罚奖赏或是提高那些任务繁重的进程的优先级等等. 这些功能可以让你的系统更加从容地面对生产环境中变幻莫测的负载以及其他无法预见的问题.
有关构建高容错性系统我最后想说的一点是「网络认知」 (network awareness). 在我们想要构建的任何要保持长期在线的系统中, 由多台机器构建的网络(集群)都可说已成为了前提条件. 哪怕你有一台性能超强的机器, 当它出现问题的时候也就不可避免地会影响到你的用户.
所以你最少也要两台机器, 这样一台有问题的时候另一台可以顶上. 若是考虑到部署的时候就有坏掉的机器, 那就要三台才能保证系统的持续运转.
这一页上的飞机是一架 F-82 「双生野马」, 这种二战时期设计出来的重型战斗机用于掩护执行长距离任务的轰炸机, 当时绝大多数战斗机都没法飞这么长的距离. 而 F-82 有两个驾驶舱, 因此飞行员们可以轮流休息; 或者可以一组操纵飞机, 另一组操作雷达, 变成类似拦截机的角色. 现代的飞机其实也有类似之处, 它们有数不胜数的故障转移机制, 也常常有可以换班的飞行员在舱内休息, 以便随时有清醒的人来应对紧急情况.
尽管人们都知道写服务器的话怎么也要几台机器, 很多语言或平台依然忽略着分布式的功能. 基本上所有语言都有标准库之类的可以直接进行比如文件操作, 然而其中的大多数对于网络功能最多就是提供一个套接字库或者 HTTP 客户端.
Erlang 认识到了分布式的重要性, 并提供了一整套文档完善代码透明的实现. 你可以在此之上控制如何进行故障转移, 或是由另一个节点接管某个崩溃的应用等等从而实现更高的容错性. 甚至还可以让其他语言接入 Erlang 的分布式系统中, 构建一个多种语言混合的系统.
以上是要达成 Erlang 「禅宗」的一些基本要素. 整个语言构建在处理崩溃之上, 将它们变的如此可控从而可以当作一种组建系统的工具. Let it crash 开始有那么点儿道理了, 这里面的一些原理也可以给其他非 Erlang 系统提供些灵感.
如何将所有这些要素融合在一起是下一个挑战.
监督树 Erlang 程序组织结构的方式. 监督树从监督者这个简单的概念开始, 监督者唯一的工作就是启动并监视子进程, 当它们出故障时重启. 4
监督树的用处在于构建这样的一个层级关系, 那些非常重要的, 必须十分可靠的东西靠近树根, 而那些易变的部分聚集在树叶附近. 这其实跟现实世界中的树很像: 树叶是活动的, 树叶很多, 树叶秋天的时候全都掉下来了, 然而树本身一直活着.
同样地, 在你组织 Erlang 程序时, 那些你感觉不稳固的和可以出错的部分要尽可能放在较低的层级, 在较高的层级上放那些需要稳定性的重要部分.
监督者通过使用链接和捕获退出信号工作. 它们的任务首先是从左至右, 以深度优先的顺序逐次启动其子进程. 只有前一个子进程完全启动成功之后它才会启动下一个子进程. 每一个子进程都会自动与其监督者建立链接.
当一个子进程挂掉时, 你有三种策略可以选择. 首先是 one_for_one
, 具体方法是只重启挂掉的那一个进程. 这种策略适合子进程相互独立的监督者使用.
第二种策略是 one_for_all
. 子进程互相都有依赖时就应当使用这种策略. 任何一个子进程挂掉时, 监督者首先杀掉其他所有的子进程, 然后再重新启动它们. 具体来说, 当一个子进程出问题会导致其他进程进入异常状态的时候就应当用这种策略. 比方说三个进程在讨论和投票. 我们可能没有编写处理投票时某个进程挂掉的代码. 这时如果仅仅重启挂掉的那个进程, 新的进程根本无法继续之前的任务.
假如我们没有定义投票时一个进程搞破坏的情况, 类似这样的不一致状态将是非常危险的. 这时杀掉所有相关的进程, 从已知的稳定状态开始会更安全. 同时这也可以限制出错的范围: 尽可能早的崩溃要比让坏掉的进程慢慢破坏数据要好的多.
最后一种策略适用于进程间的依赖与启动顺序相关的情况. 名字是 rest_for_one
, 当某个子进程挂掉时, 只有那些在这个进程之后启动的进程才会被杀掉重启.
每个监督者还可以单独配置容错度. 比如一些重要层级上的监督者可能一天最多只允许出现一次故障, 而其他的也许一秒种出现150次也没关系.
通常我解释了监督者之后第一个问题都类似这种: 「可是如果我的配置文件坏了, 重启也无济于事啊!」
这么说当然没错. 重启之所以有用关系到生产环境中所遇到的问题的本质. 为了解释这个问题, 我需要引用詹姆斯·格雷先生5于1985年提出的一组概念: 「玻尔 Bug」 和 「海森堡 Bug」 (Bohrbug, Heisenbug, 下文保持英文6)
简单的说, Bohrbug 是那种会固定发生的, 比较明显的, 很容易重现的错误. 通常也很容易找出错误的原因. 相对的, Heisenbug 的行为很不规律, 往往只有在特殊条件下才会发生, 当你想「观测」它们的时候又消失不见了. 举例来说, 当你用调试器想要调试一个并发错误时往往它就不发生了, 因为调试器可能强制让整个系统序列化地运行.
Heisenbug 就是这种发生率只有千万甚至亿万分之一的错误. 假如你看到有人带着一叠打印出来的代码, 上面还有成堆的标记, 那十有八九这个人就是在调试一个 Heisenbug.
现在你知道它们的含义了, 我们先来看看找出他们的难易程度7.
这里我把 Bohrbug 标记成可重复的 (repeatable), Heisenbug 则是临时的 (transient).
存在于系统主要特性中的 Bohrbug 往往在部署到生产环境之前就能很容易地发现. 因为它们很容易重现, 又在整个系统的关键代码中, 你早晚都会在发布之前遇到它们并一一修复.
那些在次要特性中的错误, 更多的要看碰不碰的上. 人们都承认要修复一个软件中所有的错误是不太可能的; 那些越是最后剩下的细微处的问题, 修复它们所需的时间越是会成倍上升. 通常这些次要特性中的错误不会那么受人关注, 可能是用户数量很少, 也可能是对整体体验的影响不大. 又或者这些问题本来优先级就没那么高.
不管怎么说, 这些错误都还是很简单就可以找到的, 只是我们没打算耗费很多时间或精力去修复它们.
相对的, Heisenbug 基本上在开发环境中完全不会出现. 类似公式证明, 模型检查, 极其详尽的测试或者属性测试这样的高级技术或许能提高发现这类问题的概率, 不过坦白地讲, 我们通常都不会用刚刚提过的任何一种方法, 除非是写什么特别特别重要的东西. 一个亿万分之一概率的问题需要写大量的测试和验证才能发现, 而且很多情况下就算你偶尔看见了一次, 再跑一次也不一定能重现同样的问题.
接下来我想讨论(根据我的经验)这两种错误在生产环境中发生的频率. 虽然没办法证明, 直觉告诉我发现错误的难易和它们在生产环境中发生的频率确实有着某种联系.
首先, 主要特性中反复出现的错误根本不应该出现在生产环境中. 如果有, 就等于是发布了一个有本质缺陷的产品, 不论怎么重启都没用的. 因为这类错误就是代码质量的问题, 也或许是系统架构中的深层次问题.
次要特性中我们还是常常会看到一些 Bohrbug. 我得承认这是没有花足够的时间充分测试所导致的, 但也有很大可能是这些次要特性在重构的时候没人关注, 或者设计它们的人并没有完整地考虑过在各种情况下它们如何与系统的其他部分协作而导致的.
另一方面, Heisenbug 什么时候都有可能发生. 提出这两个概念的格雷先生曾表示在某个客户遇到的132个错误中, 只有一个是 Bohrbug. 在这个生产环境里, 总共132个错误里131个都是 Heisenbug. 你很难捕捉这类错误, 假如说统计学上它们发生的概率是几百万分之一, 那么当你的负载达到一定的量级就会时常发生这类问题; 一个十亿分之一概率的错误, 每三个小时就会在一个每秒处理十万次请求的系统中发生一次, 而一个百万分之一概率的错误每10秒就会在这样的系统中发生一次, 然而这样的频率在(绝对数量没那么多的)测试中可以说太稀有了.
听起来这可是很多很多的错误, 并且如果没有正确处理的话每一个都有可能导致系统故障.
那么回到本来的问题, 重启进程这种策略能不能解决这些问题呢?
首先对于主要特性中的重复性错误, 重启应该是没什么用的. 对于次要特性中的这类问题, 可能要取决于具体情况; 如果这个功能对于一小部分用户来说很重要, 重启进程同样是没什么用的. 如果大多数人都不在乎这个功能好不好用, 那么重启或者忽略这个错误就还好. 比方说, 假如 Facebook 的「戳」功能失效了(假如现在还有这么个功能的话), 可能大多数人不会觉得他们的体验受到了什么影响.
而对于临时性的错误来说, 重启是十分有效的, 并且往往这类错误才是你在生产环境中主要会遇到的. 这类错误之所以难以重现, 通常是因为它们只有在非常特定的条件下或者是系统处于什么中间状态时才会触发. 同时它们只会在很少的一部分操作里才会发生, 重启可以彻底「解决」(或者说隐藏)这一类问题.
从一个已知的稳定的状态重新开始, 很可能这个操作就不会遇到发生了问题的那种上下文. 重启可以让这种本来可能导致系统级灾难的错误变成一个小问题, 用户也不会注意到什么不同.
在这之后, 你可以通过使用日志, 追踪, 或者其他 Erlang 提供的分析工具来查找, 理解并修复这一类问题. 假如这个错误不值得花那么多时间去修复, 你也完全可以选择就让监督者负责重启.
这个问题8是我在某个论坛上讨论编程相关的话题和 Erlang 模型时有人问我的. 我直接把原文贴过来, 因为很多人在听了 Erlang 的特性和重启进程之后都会问类似的问题.
我想通过一个用 Erlang 设计的系统的实际例子来回答这个问题, 这可以突出 Erlang 一些独特的地方.
我们可以通过监督者(图中以圆角矩形表示)将进程组织成很深的层次关系. 这里我们看到一个选举系统, 大体上分成两个树: 一个管理选票, 另一个负责实时报告. 选票树负责计数和储存结果, 实时报告树将结果展示给人们.
按照子进程定义的顺序, 只有当选票树完成启动并开始工作之后实时报告的服务才会启动. 负责分区计数的地区子树要等存储层可用了才能启动. 存储层中的缓存也要等实际连接到数据库的存储工作池可以用了才会启动.
我前面讲过的监督策略让我们可以把这些需求直接转化为程序的结构, 无论启动时还是运行时系统都会遵循这些规则. 例如, 分区树的根监督者可能会用 one_for_one
的策略, 这样某一个分区出了问题不会影响到其他的分区子树. 而具体一个分区的监督者可以用 rest_for_one
的策略, 这样可以保证 OCR 进程在检测选票并发送给 count
进程时, 自身的崩溃不会影响到计数. 而一旦计数进程出现错误时, 监督者会同时关闭 OCR 进程, 保证没有进一步的崩溃.
OCR 进程可能只是一段监控代码, 具体的工作可以是用 C 写的另一个程序. 这可以进一步将 C 代码中的错误与 Erlang VM 分离.
正如前面提过的, 每个监督者可以配置不同的容忍度; 分区的监督者可以很宽松, 允许一分钟内出现10次崩溃, 而存储层为了保证正确性就会更严格, 比如一个小时内如果有3次崩溃我们就要关闭整个程序去除错了.
像这个程序里, 关键的部分靠近树根, 不会变动. 他们不会受其他进程影响, 但如果他们自己出问题了会影响所有人(子进程等). 叶子上的进程负责实际的工作, 有些就算失败或崩溃了也没关系, 只要他们「吸收」了数据, 完成了「光合作用」就好.
通过定义这些层级关系等等, 我们就可以将较为危险的代码放入容忍度较高的工作进程中, 把处理得出的数据存放到更加稳定的进程中. 如果用 C 写的 OCR 代码不够稳定, 我们可以允许这些进程失败并重启它们. 当它们成功的时候会将数据传给 Erlang 系统中的 OCR 进程. 这个进程可能会做一些验证, 也可能在这一部分崩溃. 不过只要数据没问题, 它就会将其传给计数进程, 这个进程只要维护一个很简单的状态, 并在最后(通过存储层的进程)写入到数据库中即可, 这样数据就安全存放在系统之外了.
如果 OCR 进程挂掉了, 它会被重启. 如果它挂的太频繁, 它的监督者会挂掉, 然后这一部分子树也一样会被重启 - 注意这些都不会影响到系统的其他部分. 如果重启后没问题了, 那万事大吉. 如果还不行, 进程就会一层一层地向上重复崩溃重启的步骤, 直到系统恢复正常, 当然如果真的遇到无法解决的问题, 最后系统会整个关掉.
这种架构系统的方式有着巨大的价值, 因为我们是使用结构进行错误处理. 这意味着在叶子节点上我们无需再去写那些粗暴的「防御式」代码, 出了问题时让其他的进程(或是程序的结构)来决定采取什么措施. 如果我们确定地知道某些特定错误需要如何处理, 我们依然可以去编写针对这些情况的代码. 但除此之外, 让他崩溃就好了!
这也会影响你所写的代码. 慢慢地你会发现代码里不再有成堆的 if/else
或是 try/catch
之类的语句. 相反, 你的代码只会关注当一切正常时怎样完成它的工作. 你不再写各式各样的代码试图去处理你无法预测的错误, 也就意味着代码可读性的显著提高.
再退开一步观察我们的程序结构, 你或许会发现黄色圈出来的这几个子树在功能上基本是彼此独立的; 他们的依赖关系只是逻辑上的, 比方说报告系统总是要从某个存储层里查询数据.
如果能这样组织还会有其他的好处, 比如说很方便地更换存储层的实现或者单独将这个存储层用于其他的系统. 又假如我们能把实时报告系统放到另一个节点上或者是提供更多通知方法比如短信啊之类的就更棒了.
我们需要某种可以将这些子树拆分更若干个逻辑单元的方法, 使它们可以独立开发, 配置, 重启, 同时又可以任意组合或重用.
Erlang 为此提供的方案是 OTP 应用 (Application). OTP 应用基本上只包括构建这样的子树的一点代码以及一些元数据. 元数据中包括了一些基本信息, 比如版本号, 一段关于这个应用的描述, 也包括了这个应用和其他应用的依赖关系等等. 从而保证虽然存储应用是与系统的其他部分独立的, 但其他应用依然可以将其编入自己的依赖列表中, 保证运行时这些应用都可用. 通过 OTP 应用构建整个系统的方法其实差不多, 不过现在每一部分更加独立, 也更便于管理和分析.
事实上人们常常把 OTP 应用看作是 Erlang 世界中的「库」. 如果你的代码没有组织成一个 OTP 应用, 那就无法在其他系统中重用. 9
有了这些, 我们的 Erlang 系统可以定义下面的这些属性:
- 对于整个系统来说哪些是绝对重要的, 哪些是不太重要的
- 哪些是允许出错的, 又允许以何种频率出错
- 程序的启动有何需求, 又应当以何种顺序
- 程序应如何应对故障, 即一部分出错时怎样的状态是合法的, 以及如何回滚到一个已知的稳定状态
- 程序应如何升级 (通过监督结构我们可以实现热升级)
- 各个组件之间如何相互依赖
所有这些都很有价值. 比这些更有价值的是促使每个开发者尽早地以这种方式思考(系统构建的方式). 你减少了「防御式」的代码, 而系统出错时依然可以继续运行. 如果你觉得某个错误值得花时间修复, 你可以查看日志或是直接观察生产环境上系统的状态, 而且没有时间上的压力.
如果我做到了这些, 我就能睡个安稳觉了吧? 说不定真的可以哟. 这张图来自若干年前我们在 Heroku 部署的一个新程序.
这张图最左边大概是九月份. 那个时候我们新写的代理层 (vegur) 已经在生产环境使用了快3个月了, 我们也差不多修复了所有的已知问题. 用户没再反映什么问题, 迁移过程很平稳, 新的特性开始投入使用.
后来有一天, 一个同事从我们使用的日志服务那儿收到了一笔高额账单. 那时候我们才关注到这张图, 发现每天都有50万到120万左右的异常发生! 天啊, 竟然有那么多. 但这个数量真的很多么? 如果这是个 Heisenbug, 然后我们的系统比如说每秒会处理10万次左右的请求, 那么它发生的概率是多少? 也就是 1/17000 到 1/7000 左右. 也可以说蛮频繁的, 但因为它对主要的服务没什么影响, 我们看到带宽和存储相关的账单之前都没注意到这个问题.
修复这个问题花了点时间. 你可以看到那之后其实每天还会发生几千个异常左右. 这是已知问题, 而且不会影响到我们的服务. 两年过去了我们也不曾再耗费精力去完全修复这个问题, 因为整个系统依然运转良好.
可惜的是, 你不能真的什么都不管. 有些故障源自你无法控制的东西, 即便是再好的设计也无法幸免.
若干年前我坐飞机去温哥华, 飞机开始下降时机长通过广播说了类似这样的内容: 「我是机长, 我们即将着陆. 我们需要在跑道上等待消防部门对飞机进行检查, 到时别惊慌. 有几个液压组件失灵了, 他们需要确保飞机没有起火的危险. 我们还有两套备用的系统, 不会有事的.」
我们确实没事. 这再次表明了飞机的设计多么完备.
不过这张照片并不是那次航班, 而是两周前我飞往积雪24寸的美国东部时所乘坐的飞机. 那架 - 我敢说一样可靠的 - 飞机开始在跑道上降落. 然而刹车时, 飞机发出了巨大的声响, 我猜是飞机上类似 ABS 的机制, 然而飞机没有及时停下来.
我们冲出了标志跑道尽头的红灯, 飞机一路滑出跑道, 错过了斜坡弯道, 最后前轮停在了草地里. 大家都没事, 但这正说明了即使是杰出的工程设计也无法保证绝对的安全.
事实上, 运维10工作总是会极大地影响一个系统是否成功. 这张图来自理查德·库克先生的一次演讲. 假如你没听说过他, 我强烈建议你去 YouTube 上看看他的视频, 都很不错.
良好的系统架构和开发实践依然无法完全取代运维工作, 或者说不好的运维会直接毁掉一个设计和实现良好的系统; 你所依赖的开发, 监控, 自动化工具等等, 它们的可用性和效率全都会受到运维状况的影响(带宽, 负载, 过载管理等等). 只有当你了解这些运维限制时才能预测什么时候系统要开始出问题了, 以及什么时候系统恢复正常了.
问题在于运维人员会慢慢习惯于这些, 当偶尔破坏了某些限制而没有影响的时候他们会渐渐开始忽略这些条件, 就好像一点点地逼近危险范围的临界点, 超过了这个临界点就可能会产生复杂的大规模系统故障. 在这个过程里你对于高危情况的嗅觉也被慢慢侵蚀, 最后你或许只能无力地目睹系统的各个部分持续崩溃.
所以我们还是要注意这些事情, 并且那些使用和运维这些软件的人也必须有所警觉. 想要扩大一个好的团队要远远比给一个软件扩容要难. 在紧急情况出现之前就有所准备, 这样当问题真的出现时你就有现成的方案来修复整个系统.
回到我刚刚的例子, 就像我说的并没有人受伤什么的. 但是机场仍然派出了所有这些装备来确保万无一失: 护送乘客返回航站楼的大巴. 护送大巴的车辆. 警车, 很多辆消防车, 还有我猜同样相当重要的那台黑色车辆.
尽管人和飞机都没事, 他们依然采取了这么多应对措施. 这个做法值得借鉴.
这一页 11 列出了 Erlang 其他的一些特性. 这次我不会具体解释它们, 不过有些也许会引起你的兴趣也说不定.
关于最后一点我想多说几句. 在那些更为灵活的语言里, 常常遇到的问题是你想使用的某个库和已有代码的「风格」不符, 结果就是要么不用这个库, 要么就得忍受同一个代码库中存在着不一致的设计. Erlang 中不存在这样的情况, 因为大家都遵循同样的准则 12 去解决问题.
总的来说, 所谓 Erlang 之禅和 “Let it crash” 实际上就是要理解各个组件之间究竟是如何互动的, 理解哪些是绝对重要和不那么重要的, 哪些状态可以被保存, 或是可以暂时保留, 或是可以重新计算, 又或者是可以扔掉的. 对于任何问题, 你都要设想出最坏可能的情况, 然后思考如何从中恢复. 通过使用由进程独立, 监控与链接以及监督者所构建的尽早崩溃的机制, 你可以控制那些最坏情况所影响的范围, 进而有可能将其转化为很普通的故障.
这一切听起来很简单, 却也非常实用; 如果你觉得管理自己已经完全理解的, 有规律可循的崩溃是一条可行之路, 那么你可以把所有的错误处理都归结于此. 你不再需要担心未曾处理的意外情况, 不再去写「防御式」的代码. 你只需写真正干活的那部分代码, 其他的情况交给程序的结构来处理. 不要惧怕崩溃, Let it crash.
这便是 Erlang 之禅: 首先构建整个系统如何交互, 保护好可能发生的最坏的情况. 系统中不再有大量需要你担心的错误(当真的出现问题时, 你也可以直接查看运行时的所有状态!) 如此你就能放轻松了.
本译文谢绝商业转载.
-
实在是不了解这些术语是怎么翻译的… ↩
-
Erlang 的进程不同于一般概念中由操作系统提供的「进程」, 下文若非明确提及, 「进程」皆特指 Erlang 进程 ↩
-
Pattern Matching 是 Erlang 很「独特」同时也非常强大的一个特性, 其直接导致了 Erlang 中函数的写法有别于很多更为常见的语言. 可以从 Elixir 的官方指南中了解一二. ↩
-
监督者是 OTP 的一个核心组件, OTP 是 Erlang/OTP 这个常常写在一起的名字里面表示一个通用开发平台的那部分. (虽然全称是 Open Telecom Platform, 但现在一般不在意这层意思, 只称为 OTP) ↩
-
对量子力学有所了解的读者看到这两个名字应该会会心一笑吧 :) ↩
-
这里原文的逻辑似乎有误 ↩
-
「我喜欢静态类型的语言. 遇到没有处理的异常时我会直接重启整个 daemon. Erlang 有什么更好的方案来提供高容错性么?」 ↩
-
OTP 应用完全可以不包含需要运行起来的监督树, 而只包含一些模块代码 ↩
-
Operation, 我一直觉得翻译成「运营」或者是「运维」都怪怪的… ↩
-
- 模式匹配
- 函数式编程
- 可选的类型检查
- 基于属性的测试工具
- 代码热升级
- 遵循同一套准则的社区
-
OTP ↩