朴素的代码,驾驭过亿次的请求

原文地址 https://notes.billmill.org/blog/2024/06/Serving_a_billion_web_requests_with_boring_code.html

当我在一家数字服务公司担任美国政府的承包商时,有幸获得了机会 -- 设计重启医疗保险计划,这是一个美国政府网站,每年有成千上万的医疗保险受益人通过它购买医疗保险计划。

经过一年多的开发,我们发布了该网站,并帮助数百万人找到并购买了医疗保险 -- 如果你在美国,你很可能认识使用过该系统的人。

虽然美国的医疗保健系统在很多方面都非常糟糕,但我还是为团队在短时间、诸多限制条件下建立的系统感到非常自豪。

与我共事的团队成员,包括经理、设计师、工程师和业务分析师,从上到下都非常优秀。我以前从未经历过如此敬业、协作、相互信任的团队,我从他们身上学到了 很多东西。

我特别想分享这个故事,因为我想告诉大家,在政府的限制下也能编写出高质量的软件。是可以的!如果我们都开始相信这点,我们就更有可能写出高质量的软件。

1 高水平

我在这个系统上工作了大约两年半,从第一次提交到经历两个开放注册期。

API 系统在正常工作日提供约 500 万次请求服务,平均请求延迟时间小于 10 毫秒,100 次测试里的 95 次延迟时间小于 100 毫秒。

它的错误率基线非常低,主要是由于漏洞扫描器造成的虚假错误。令我自豪的是,我一只手就能数出工程师被叫醒去处理紧急页面的次数。

我很惊讶地发现,依靠 postgres 和 golang,尽可能保持系统的有序性和简洁性,并在很长一段时间内每天投入工作,就能取得这么大的成就。

2 无聊至上

在构建系统时,我的首要目标是尽可能保持枯燥,就像丹·麦金利(Dan McKinley)所说的那样。(如果你还没有读过《选择无聊的技术》,就去读读吧,这是必读书目。它比这篇文章好)。

这篇文章中有一个「创新代币」的概念,我在选择用于构建网站的代币时,明确了如何去使用这些代币。

无聊的部分

  • postgres -- 我喜欢在它的基础上构建我的系统
  • golang -- 我曾在 healthcare.gov 工作中使用过,对它的可靠性和相对简单性非常有信心
  • react -- 就我们在这里讨论的意义而言,react 是枯燥的,但它已被广泛部署,而且我的队友对它非常熟悉

为什么选择 React?

对 React 的批评很多是有道理的;这篇文章(https://infrequently.org/2021/03/the-performance-inequality-gap/)就是一个例子,我在 2018 年建设网站时就已经意识到了这些问题。主要是它倾向于使用大型应用捆绑包,下载和执行需要很长时间,尤其是在廉价的手机上,而手机是许多人连接互联网的主要方式。

在为政府建设基础设施的过程中,我们尤其希望尽可能多的人都能使用该应用。我们非常重视网站的可访问性,既要为残疾用户提供适当的设计,也要考虑到人们需要使用多种设备来连接网站。

尽管如此,我还是为网站选择了 SPA(单页面)架构和 react。

我很想采用不同的方法,但我担心使用多页面架构或不同的库会拖慢我们的进度,导致我们无法在短时间内完成。当时,我对任何可用的替代方案都没有足够的信心,以至于我不相信我们可以安全地选择它们。

结果,几年后,我们就陷入了 react 应用的常见故障模式,系统变得相当笨重,加载速度也有点慢。

我仍然认为我当时的选择是正确的,但不幸的是,我觉得我必须做出这样的选择,我希望我当时知道有一种干净利落的方法可以避免这种情况。

Golang

总体而言,使用 Golang 来构建这个项目非常愉快。无论是在构建时还是在运行时,它都能高效运行,而且快速构建的二进制可执行工件使其易于快速部署。

刚接触这种语言的开发人员(我们的工程师团队从 2 人增加到了 15 人)能够很快上手,并轻松理解。

在我看来,即时和详细的错误处理是构建弹性系统的一大特色。每当你做一件可能会失败的事情时,你都会面临处理错误的情况,而一旦你形成了模式,它们就会是一致的、可预测的。(我知道这是一个很大的话题,也许我应该写更多关于它的文章)。

就在我们能够转用模块的那天,我们的一大痛点消失了。作为早期采用者,我们在路上遇到了一些坎坷,但这是值得的。

我对 golang 生态系统最大的不满是,非公开项目的文档生成非常糟糕。有很长一段时间,文档生成器甚至不支持使用模块的项目。

尽管如此,我对自己的选择非常满意,从未后悔过。

3 创新代币

我在架构方面下了两个信心不太足的赌注:

  • 模块化后端 -- 我既没有把后端设计成微服务,也没有设计成单体,而是设计成了三个大模块
  • gRPC - 后端服务通过 gRPC 相互通信,并通过 grpc-gateway 与网络客户端通信

模块化后端

我将后端分成了三个部分;它们都位于同一个资源库中,但在设计上,它们可以拆分开来,在必要时交给新的团队使用。

每个组件都有自己的 postgres 数据库(物理上位于同一地点,但从未交织在一起),它们之间严格使用 gRPC 进行通信。

拆分主要是基于数据访问模式:

  • 药物定价(又名药物信息) 该网站需要做的一件事是估算任何医疗保险计划下任何药店的任何包装药品的成本。很简单吧?

这种组合爆炸(我曾经计算过这有多少万亿种可能性)需要一个非常仔细索引的数据库、大量的预处理,以及对工程性能的投入。

我花了很长时间,通过大量的政府卫生系统逆向工程,才想出了如何将这部分工作做到最好。我永远感谢我的同事们,他们深入 CMS(https://www.cms.gov/) 的体系,然后将其转化为文档和代码。

  • 计划搜索(又名计划信息) 该网站的主要目的是让人们搜索和购买医疗保险 C 部分和 D 部分的医疗保健计划。

每天,我们都会从 CMS 获得新的医疗计划详细信息;该模块会将信息加载到一个新的 postgres 数据库中,然后我们会部署一个新版本,指向新一天的数据。

通过这种方式,计划信息和药物信息都拥有了完全不可变的数据库;它们唯一的工作就是根据各自的最新数据提供 API 服务。

  • 受益人信息 在保险术语中,参加医疗保健计划的人就是该计划的「受益人」。在我看来,这听起来有点自以为是,但我想这就是事实。 (在整个项目过程中,我努力做到的一件事就是尽可能使用正确的行业术语,而不是我自己更熟悉的术语。我觉得把行话的摩擦降到最低也是「无趣 」承诺的一部分)。

受益人信息模块的任务是存储计划客户的信息,这也是应用程序中数据库唯一长期存在且可变的部分。

我们努力在这里存储尽可能少的数据,以便在数据泄漏时将风险降到最低,但存储大量的个人身份信息(PII)是无法避免的。我们尽可能认真对待这些数据,失去控制的风险让我时刻感到紧张。

gRPC

总的来说,gRPC 对我们来说并不像我在项目开始时希望的那么好。

使用 gRPC 的最大好处,也是我选择使用 gRPC 的原因,是它可以在代码中指定每个接口。这非常有用;我们可以生成同步变化的工具和界面。

最大的痛点都与工具有关。我维护了一套非常毛糙的 makefile,其中包含了大量的 protoc 命令,用于将所有接口构建成我们可以使用的 go 文件,而调试这些文件总是非常痛苦。

grpcurl 虽然存在,我们也用过,但还不如 JSON API 好用。

grpc-gateway 是我使用过的生态系统中最好的部分,它为我们提供了超过十亿次请求,而且从未出现过问题。它让我们完成了 gRPC 从一开始就应该完成的任务,为网络客户端提供请求服务。

我喜欢使用接口模式,但我们使用的 gRPC 功能太少,代码生成也非常复杂,如果没有它,我们可能会过得更好。

4 严格的向后兼容性

我们严格遵守向后兼容性要求,只在接口中添加字段,从不删除。一旦某个字段在公共应用程序接口中公开,它就会永远公开,除非它成为一个安全问题(值得庆幸的是,在我参与这个项目的这些年里,从未遇到过这种情况)。

我们对数据库的处理方式与 API 相同;列只会被添加,很少会被删除。如果某一列确实需要移除,我们的做法是添加一列,移除对旧列的所有引用,等待几周以确保不需要回滚,最后再从数据库中移除该列。

我们对向后兼容性的严格要求使我们能够自由地保持较高的更改速度,并确信本地更改不会对下游产生负面影响。

5 分面搜索

该应用的核心原则是尽可能依赖 postgres,同时尽可能愚蠢而不是聪明。

分面搜索就是这两种特性的极佳例证。我们本可以使用 elasticsearch,也可以尝试使用 ORM 或构建一个小的分面语言。

我们实现分面搜索的方法是拥有一个索引良好的计划表,并根据一长串条件建立 SQL 查询字符串。

实现该方案核心部分的 buildQuery 函数是一个 250 行的函数,有大量注释,以近乎扁平的方式列出了逻辑。重点干脆地放在业务需求上,而不是花哨的代码上。

6 数据库

创建

我们将数据库 schema 存储在一系列带前导数字的 .sql 文件中,以便在创建数据库时按顺序加载。

对于计划信息和受益人信息,由于数据库每天都要重新创建,因此没有进行迁移。相反,数据库和应用程序中都存储了版本号。鉴于我们(极力避免)对数据库进行向后不兼容的更改,应用程序在启动时会检查其数据库模式版本号是否大于或等于数据库中存储的数据库版本号,如果不一致,则拒绝启动。

这是一般模式:如果应用程序遇到任何意外或缺失的配置,它就会拒绝启动,并抛出明显的错误,希望这些错误是明确的。 我努力使应用程序在实际启动时,拥有正常运行所需的一切。

偶尔我们也会不小心对数据库进行了向后不兼容的变更,在这种情况下,我们通常会回滚数据更新并重新构建。

ETL

系统中最令我自豪,也是我花费精力最多的部分是 ETL 流程。

我们为每个数据源(有很多)编写了一系列 shell 脚本,这些脚本会提取数据并将其放入一个 s3 bucket。

然后,在清晨,一个 cron job 会启动一个 EC2 实例,该实例会调入最新的 ETL 代码和所有数据文件。它会在我们的 RDS 实例中建立一个新数据库,然后开始 ETL 流程。

如果一切顺利,在东岸人上班的时候,新数据库就会投入使用。

我的记忆并不准确,但从几千兆字节的各种格式文本文件中生成一个拥有超过 2.5 亿行的新数据库大概需要 2 到 4 个小时。

向数据库中插入数据的代码大量使用了 postgres 的 COPY 语句,尽可能避免 INSERT,而是生成一批可以 COPY 到数据库中的集合。

模型

我们使用 xo 库(https://github.com/xo/xo)连接数据库并生成模型以及大量定制的模板。

模板本身以及根据模板创建模型的代码都很繁琐。所幸的是,这些代码只需编写一次,偶尔进行编辑即可。

测试

这是我最大的失误:我投入了大量的时间和精力为经常变化的数据创建 sql-mock(https://github.com/DATA-DOG/go-sqlmock)测试。这些测试需要持续、繁琐的维护。

相反,我应该针对实时数据库进行测试,尤其是考虑到我们主要使用的是不可变数据库,这样就不必为每次测试重新创建数据库了。

用于开发的本地数据库

由于最终数据库太大,无法在开发人员的机器上运行,因此数据库中的每个表都有一个附带脚本,用于生成本地开发所需的数据子集。

这样,每个开发人员都可以使用数据库的本地实时副本,并能高效地开发更改。

我强烈建议从一开始就建立这种工具,这样可以避免在数据库变大后再添加,或者让团队连接到远程数据库,从而降低开发速度。

7 杂项工具

我们有一个 CLI 工具,主要是以一堆 shell 脚本的形式编写的,有大量可用的命令,可以执行与可观测性和操作相关的各种实用功能。

它主要是由一位优秀的同事编写的,在使用它的过程中,我学会了如何编写有效的 shell 脚本。(感谢 Nathan 和 shellcheck https://www.shellcheck.net/ 为我提供了这项重要技能)。

从一开始,这个工具就为实用功能的归集提供了一个非常有用的场所;如果没有它,这些功能往往会分散到很远的地方,或者只是活在开发者的 shell 历史中。 我创建的一个有趣的工具是通过 slack 命令从 splunk(我们的日志聚合服务)生成图表的能力,这对事件处理特别有帮助,因为你可以轻松地与同事分享图表。

8 日志记录

每一个进入后台的请求在进入系统后都会得到一个请求 ID,这个请求 ID 会随请求一同进入系统。中间件为请求提供 ID,然后另一个中间件构建一个子日志,将请求 ID 嵌入其中,并附加到请求上下文中,这样所有日志都会附加请求 ID。

系统会记录进入和退出日志,在安全的情况下记录尽可能多的细节。任何其他调试级别以上的日志都应视为特殊,尽管我们对此并不严格。

我们使用的是 zerolog(https://pkg.go.dev/github.com/rs/zerolog),效果非常好。

9 文档

后来,我使用 sphinx-book-theme(https://sphinx-book-theme.readthedocs.io/en/stable/tutorials/get-started.html)将我和其他人在 github 上写的 Markdown 文档转换成了一本关于系统如何工作的书。

神奇的是,这本书得到了广泛的关注,队友们为我提供了很多帮助,每个人都知道去哪里可以找到系统文档。

我曾为许多其他项目创建过文档网站,但没有一个网站能像它一样成功,我希望我能知道这是为什么,但我不知道。

它以我们的吉祥物(柯基犬)为特色,自豪地展示着它最显著的特点:

file

10 运行时集成

我们的客户经常希望我们添加可在浏览器上运行的查询,而我很幸运能够讲这些需求退回,将其中一部分转为构建时请求。

在客户的要求下,我们的性能遭到破坏的一个地方是渲染阻塞分析脚本;似乎每个团队都希望运行不同的脚本来获得他们 "需要 "的分析结果。我建议他们不要这样做,并试图证明这样做会带来的性能和下载大小问题,但客户对我的论点不感兴趣。

--

像这样的系统还有很多我在这里没有提及到;我主要是想在我还记得一些的时候,记录一下。

我很幸运能与这样一个积极、有创造力和参与性的团队合作,他们为这样一个成功的项目创造了可能。如果要写一篇关于是怎样的社会因素和个人性格让这个项目进行,那么将是另一篇类似这篇篇幅的文章。


💡 更多资讯,请关注 Bytebase 公号:Bytebase

  • 14
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值