Wave是一家估值17亿美元的公司,仅有70名工程师1,其产品本质上是一个进行加减运算的CRUD应用。与此相应,我们的架构也是标准的CRUD应用架构——基于Postgres的Python单体应用。从简单架构入手,尽可能用简单方式解决问题,让我们得以扩展至当前规模,同时工程师们能专注于为用户创造价值的工作。

Stack Overflow曾成功将单体应用扩展至可观规模(2013年架构 / 2016年架构),最终以18亿美元被收购。若以流量而非市值衡量,Stack Overflow位列全球访问量最高的100个网站之一(更多基于单体应用构建的成功企业案例,参见此Twitter帖子的回复)。作为移动应用,我们的网络流量并不高,但Alexa仍将我们网站排入前7.5万名——尽管该网站基本只是用户寻找应用的入口,且多数用户并非通过网站发现应用。

某些应用对性能有特殊要求,使得基于普通数据库的简单单体架构难以胜任。但对大多数应用而言,即便达到前100名网站的流量级别,计算机的速度也足以支撑简单架构提供服务——而简单架构通常比复杂架构更经济、更易构建。

尽管简单架构有着超乎寻常的有效性,媒体却更关注复杂架构。例如,在近期一场综合性技术会议上,有六场演讲探讨如何构建或应对基于微服务的复杂架构及其副作用,却无一场涉及如何构建简单单体架构。关于量子计算的演讲(一场)都比单体架构(零场)多。大型会议亦是如此:旧金山近期一场企业级会议中,有两位数场次的演讲讨论应对复杂架构的挑战,却无一场讲解如何构建简单单体架构。上次参会时令我印象深刻的是,许多来自低流量企业应用的参会者——这些应用本可用简单架构构建——却照搬了会议和HN上流行的最新复杂技术。

我们的架构简单到无需绘制架构图。相反,我将分享几个帮助我们保持“朴素”的乏味做法。

我们目前使用的是枯燥的同步Python,这意味着我们的服务器进程在等待I/O(如网络请求)时会阻塞。之前我们尝试过Eventlet,这是一个理论上能提升Python效率的异步框架,但遇到了太多bug,以至于我们认为等待事件带来的CPU和延迟成本,并不值得为处理Eventlet问题而承受的操作负担。还有其他知名的Python异步框架,但大规模使用这些框架的用户也经常报告因大规模使用这些框架而导致的显著问题。使用同步Python成本高昂,因为我们在网络请求期间为闲置等待的CPU付费,但由于我们目前每月仅处理数十亿次请求,即使使用Python这种慢速语言并按公有云零售价付费,这种成本也很低。我们工程团队的成本完全主导了系统运营成本2

为了避免让单体架构变得异步的复杂性,我们将长时间运行的任务(那些不希望阻塞响应的任务)分流到队列中。

在本地数据中心方面,我们无法像期望的那样保持枯燥。当我们在塞内加尔和科特迪瓦运营时,完全基于云端;但随着我们扩展到乌干达(以及未来更多国家),为了遵守当地数据驻留法律法规,我们不得不拆分后端并部署本地设施。这并非简单的操作,但任何用复杂面向服务架构做过同样事情的人都知道,这比我们采用复杂面向服务架构时要简单得多。

另一个领域是我们不得不自建(而非购买)的软件。创业初期,我们强烈倾向于购买软件而非自建,因为只有几名工程师的团队无法承担自建一切的时间成本。尽管“购买”选项通常提供的工具并不好用1,但当时这是正确的选择。当无法说服供应商修复对我们至关重要的严重bug时,自建更多工具并在更多领域保持内部专业知识确实更有意义,这与“公司只应在核心能力上选择自建”的标准建议相悖。这些复杂性中有许多是我们不愿承担的,但在某些产品类别中,即使经过相当深入的研究,我们也找不到可能提供适用产品的供应商。公平地说,我们的供应商需要解决的问题比我们要复杂得多,因为他们要承担为每个客户解决问题的复杂性,而我们只需为自己这一个客户解决问题。

我们在运营头几个月犯的一个错误——至今仍带来一定成本——是没有仔细界定数据库事务的边界。在Wave的代码库中,SQLAlchemy数据库会话是一个请求全局变量;只要访问数据库对象的属性,它就会隐式地开始一个新的事务,而Wave代码库中的任何函数都可以对会话调用commit,导致所有待处理的更新被提交。这使得控制数据库更新的时机变得困难,从而增加了细微数据完整性缺陷的发生率,同时也加大了依赖数据库构建诸如幂等键事务性分阶段任务队列等功能的难度。此外,它还增加了意外持有长时间运行数据库事务的风险,这可能会使数据库模式迁移在操作上变得困难

一些我们尚不确定的选择(即我们正在考虑改变,或会建议其他从零开始的团队考虑不同方案的事情)包括:使用RabbitMQ(就我们的用途而言,Redis作为任务队列可能同样好用,仅使用Redis还能降低运维负担)、使用Celery(对我们的用例来说过于复杂,并且曾因版本升级时的向后兼容性问题导致多次宕机)、使用SQLAlchemy(这使得开发者难以理解其代码会生成哪些数据库查询,导致各种难以调试且带来不必要运维痛苦的情况,尤其与上述数据库事务边界问题相关),以及使用Python(由于我们创始CTO的技术背景,这最初是正确的选择,但其并发支持、性能以及广泛的动态特性让我们质疑它是否适合大规模后端代码库)。这些都不是重大错误,对于某些选择(如Python),其缺点足够小,以至于我们继续承担增加的维护成本比投资迁移到理论上更好的方案更划算;但如果今天我们从零开始构建类似的代码库,我们会认真思考这些选择是否恰当。

一些我们对自己的选择感到满意(尽管它们听起来可能不是最简单的可行方案)的领域包括:我们的API(使用GraphQL)、传输协议(我们曾使用自定义协议一段时间),以及主机管理(使用Kubernetes)。关于传输协议,我们曾使用基于UDP的自定义协议,并辅以SMS和USSD作为备用方案,原因如本演讲中描述的性能考量。随着HTTP/3的推出,我们已经能够用HTTP/3替换自定义协议,通常仅在类似马里近期互联网中断等事件中才需要USSD。

至于使用GraphQL,我们认为其优点对我们而言大于缺点:

优点:

  • 精确返回类型的自我文档化
  • 精确返回类型的代码生成使客户端更安全
  • GraphiQL 交互式浏览器提升生产力
  • 我们的各种应用(用户应用、支持应用、Wave 代理应用等)大多可共享一个 API,降低复杂性
  • 可组合的查询语言允许客户端在单次往返中精确获取所需数据,无需构建大量专用端点
  • 消除了关于什么算作 RESTful API 的无谓争论

缺点:

  • 我们采用 GraphQL 时,其库并不理想(基础 Python 库是 JavaScript 库的移植,不够 Pythonic;Graphene 需要大量样板代码;Apollo-Android 生成的代码优化极差)
  • 默认的 GQL 编码冗余,而我们非常关注数据大小限制,因为许多客户带宽较低

至于 Kubernetes,我们使用它是因为知道,如果业务成功(事实也确实如此)并持续扩张,我们最终会扩展到要求我们在当地运营服务的国家。具体法规因国家而异,但我们已经在向一个重要的非洲市场扩张,该市场要求我们在该国运营“主数据中心”,还有其他国家有类似法规,例如要求我们能够故障转移到该国境内的数据中心。

对我们来说,不可避免的复杂性领域是电信集成。理论上,我们会为所有需求使用 SaaS 短信提供商,但主要的 SaaS 短信提供商并未覆盖非洲所有地区,而且全面使用它们的成本将高得令人望而却步3。之前关于工程师薪酬成本主导系统成本的评论,如果我们将所有短信需求都交给 SaaS 短信提供商,就不成立了;提供电信集成的团队自身创造的效益远超其成本。

通过尽可能保持应用架构简单,我们可以将复杂性(和人员编制)预算投入到那些能为业务带来益处的复杂领域。坚持“除非有充分理由增加复杂性,否则尽可能简单行事”的理念,使我们能够以不算多的工程师团队建立起一个相当规模的业务,尽管运营的是非洲金融业务——这通常被认为是一个难以进入的行业,我们将在未来的文章中讨论这一点(我们最早且最有帮助的顾问之一,曾为 Wave 的成功提供关键建议,最初认为 Wave 是个糟糕的商业想法,建议创始人另选项目,因为他预见到了许多潜在困难)。

感谢 Ben Kuhn、Sierra Rotimi-Williams、June Seif、Kamal Marhubi、Ruthie Byers、Lincoln Quirk、Calum Ball、John Hergenroeder、Bill Mill、Sophia Wisdom 和 Finbarr Timbers 的评论/更正/讨论。


  1. 如果要计算比例,我们上次融资时大约有40名工程师,估值达到17亿美元。[返回]
  2. 有些商业模式不适用这种情况,例如,如果我们是一家依赖广告的社交媒体公司,随着公司发展所需支撑的流量规模会大到让我们不得不将大量工程时间投入优化和成本削减工作,否则将承担巨大的财务成本。但作为一家对应用内大部分交互收取实际费用的公司,我们每单位收入对应的计算负载远低于社交媒体公司,很可能在我们规模扩大一个数量级以上之前,这都不会成为主要问题;即使规模扩大两个数量级,也不清楚这会不会成为重大关切,但若增长三个数量级,则肯定会成为问题。[返回]
  3. 尽管有经典建议称不应在价格上竞争,但我们(除其他方面外)确实在价格上竞争,因此必须关注成本。我们已大幅降低了非洲移动支付的费用,我们的竞争对手不得不大幅降价以匹配我们的价格,我们认为这对世界具有积极价值[返回]