从 15,000 个数据库连接减少到 100 个以下:DigitalOcean 的技术债务故事

2025-06-04

从 15,000 个数据库连接减少到 100 个以下:DigitalOcean 的技术债务故事

最近,一位新员工在午餐时问我:“DigitalOcean 的技术债务是什么样的?”

听到这个问题,我不禁笑了。软件工程师询问一家公司的技术债务,就好比询问信用评分一样。这是他们衡量一家公司过去可疑经历和背负的包袱的方式。而 DigitalOcean 对技术包袱并不陌生。

作为一家自行管理服务器和硬件的云服务提供商,我们面临着许多其他初创公司在云计算新时代从未遇到过的复杂情况。这些艰难的处境最终导致我们在成立之初就不得不做出权衡。正如任何快速发展的公司都知道的那样,早期做出的技术决策往往会在日后带来负面影响。

我盯着桌子对面的新员工,深吸一口气,开口说道:“我来给你讲讲我们数据库直接连接数达到 15,000 的时候……”

我给新员工讲的故事,是 DigitalOcean 迄今为止规模最大的技术架构重构。这是一项全公司范围、持续多年的努力,也教会了我们很多经验教训。我希望这个故事能对未来的 DigitalOcean 开发者,或者任何陷入棘手技术债务困境的开发者有所帮助。

一切的开始

DigitalOcean 自成立之初就始终坚持极简主义。这是我们的核心价值观之一:力求简洁优雅的解决方案。这不仅适用于我们的产品,也体现在我们的技术决策中。这一点在我们最初的系统设计中体现得淋漓尽致。

与 GitHub、Shopify 和 Airbnb 一样,DigitalOcean 于 2011 年以 Rails 应用程序的形式问世。Rails 应用程序(内部称为Cloud)管理着 UI 和公共 API 中的所有用户交互。两个 Perl 服务(SchedulerDOBE,DigitalOcean BackEnd)为 Rails 服务提供支持。Scheduler 负责调度 Droplet 并将其分配给虚拟机管理程序,而 DOBE 则负责创建实际的 Droplet 虚拟机。Cloud 和 Scheduler 作为独立服务运行,而 DOBE 则运行在集群中的每台服务器上。

Cloud、Scheduler 和 DOBE 之间并不直接通信。它们通过 MySQL 数据库进行通信。该数据库承担着两个角色:存储数据和代理通信。这三个服务都使用同一个数据库表作为消息队列来传递信息。

每当用户创建新的 Droplet 时,Cloud 都会将一条新的事件记录插入队列。调度程序每秒都会持续轮询数据库,查找新的 Droplet 事件,并在可用的虚拟机管理程序上安排它们的创建。最后,每个 DOBE 实例都会等待新的已调度 Droplet 创建并执行任务。为了让这些服务器检测到任何新的更改,它们都需要轮询数据库,查找表中的新记录。

虽然无限循环和让每台服务器直接连接到数据库在系统设计方面可能还很初级,但它很简单而且有效——特别是对于面临紧迫期限和快速增长的用户群的人手短缺的技术团队来说。

四年来,数据库消息队列一直是 DigitalOcean 技术栈的支柱。在此期间,我们采用了微服务架构,用 gRPC 取代了内部流量的 HTTPS,并在后端服务中使用 Golang 取代了 Perl。然而,所有道路仍然通向 MySQL 数据库。

需要注意的是,仅仅因为某些东西是“遗留的”并不意味着它功能失调,需要被取代。彭博和IBM都有用Fortran和COBOL编写的遗留服务,它们创造的收入比整个公司还要多。另一方面,每个系统都有扩展的极限。而我们即将达到这个极限。

从 2012 年到 2016 年,DigitalOcean 的用户流量增长了 10,000% 以上。我们增加了产品目录,并向基础设施添加了更多服务。这增加了数据库消息队列的事件入口。Droplet 需求的增加意味着 Scheduler 需要加班加点地将所有 Droplet 分配到服务器上。不幸的是,对于 Scheduler 来说,可用服务器的数量并不是固定的。

为了满足日益增长的 Droplet 需求,我们不断添加服务器来处理流量。每个新的虚拟机管理程序都意味着与数据库建立新的持久连接。到 2016 年初,数据库的直接连接已超过 15,000 个,每个连接每隔 1 到 5 秒就会查询一次新事件。更糟糕的是,每个虚拟机管理程序用于获取新 Droplet 事件的 SQL 查询也变得越来越复杂。它已经变得异常庞大,超过 150 行代码,并且需要连接 18 个表。这不仅令人印象深刻,而且也非常不稳定,难以维护。

不出所料,就在这段时间,问题开始显现。单点故障导致数千个依赖项抢占共享资源,不可避免地引发了一段时间的混乱。表锁和查询积压导致中断和性能下降。

由于系统高度耦合,没有一个清晰或简单的解决方案来解决问题。云、调度器和 DOBE 都成了瓶颈。只修补一两个组件只会将负载转移到其余的瓶颈上。因此,经过深思熟虑,工程人员提出了一个三管齐下的方案来解决这个问题:

  1. 减少数据库上的直接连接数
  2. 重构 Scheduler 的排序算法以提高可用性
  3. 免除数据库的消息队列职责

重构开始

为了解决数据库依赖问题,DigitalOcean 工程师创建了Event Router。Event Router 充当区域代理,代表每个数据中心的每个 DOBE 实例轮询数据库。无需数千台服务器分别查询数据库,只需少数几个代理即可进行查询。每个 Event Router 代理将获取特定区域内的所有活动事件,并将每个事件委托给相应的虚拟机管理程序。Event Router 还将庞大的轮询查询分解为更小、更易于维护的查询。

当 Event Router 上线时,它将数据库连接的数量从 15,000 多个削减到不足 100 个。

接下来,工程师们将目光投向了下一个目标:Scheduler。如前所述,Scheduler 是一个 Perl 脚本,用于确定哪个虚拟机管理程序将托管已创建的 Droplet。它通过使用一系列查询对服务器进行排名和排序来实现这一点。每当用户创建 Droplet 时,Scheduler 都会将表中的行更新为最佳的机器。

Scheduler 听起来很简单,但其实存在一些缺陷。它的逻辑复杂,难以操作。它是单线程的,在高峰流量期间性能会受到影响。最后,Scheduler 只有一个实例,而且它必须服务于整个集群。这是一个不可避免的瓶颈。为了解决这些问题,工程团队创建了Scheduler V2

更新后的 Scheduler 彻底革新了排名系统。它不再从数据库查询服务器指标,而是从虚拟机管理程序中聚合数据并将其存储在自己的数据库中。此外,Scheduler 团队还使用了并发和复制技术,以确保新服务在高负载下保持高性能。

事件路由器和调度程序 v2 都是伟大的成就,解决了许多架构上的弱点。即便如此,仍然存在一个明显的障碍。到 2017 年初,集中式 MySQL 消息队列仍在使用中,甚至非常繁忙。它每天处理多达 40 万条新记录,每秒更新 20 次。

遗憾的是,移除数据库的消息队列并非易事。第一步是阻止服务直接访问它。数据库需要一个抽象层,并且需要一个 API 来聚合请求并代表它执行查询。如果任何服务想要创建新事件,就需要通过 API 来执行。于是,Harpoon诞生了。

然而,构建事件队列的接口其实很容易,获得其他团队的支持则更加困难。与 Harpoon 集成意味着团队必须放弃数据库访问权限,重写部分代码库,并最终改变他们一直以来的工作方式。这并非易事。

一个团队一个团队、一个服务一个服务,Harpoon 工程师们最终将整个代码库迁移到了他们的新平台上。这花了将近一年的时间,但到 2017 年底,Harpoon 成为了数据库消息队列的唯一发布者。

现在真正的工作开始了。完全掌控事件系统意味着 Harpoon 可以自由地重新设计 Droplet 的工作流程。

Harpoon 的首要任务是将消息队列的职责从数据库中抽离出来。为此,Harpoon 创建了一个内部消息队列,由 RabbitMQ 和异步工作进程组成。当 Harpoon 将新事件推送到一端的队列时,工作进程会从另一端拉取这些事件。由于 RabbitMQ 取代了数据库的队列,工作进程可以直接与调度程序和事件路由器通信。因此,Harpoon 无需调度程序 V2 和事件路由器轮询数据库中的新更改,而是直接将更新推送给它们。截至 2019 年撰写本文时,Droplet 事件架构的现状就是这样的。

向前

在过去的七年里,DigitalOcean 从车库乐队的雏形发展成为如今成熟的云服务提供商。与其他转型中的科技公司一样,DigitalOcean 也经常处理遗留代码和技术债务。无论是拆分单体应用、创建多区域服务,还是消除单点故障,DigitalOcean 的工程师们始终致力于打造优雅而简洁的解决方案。

我希望这篇关于我们的基础设施如何随着用户群规模增长的故事能够引人入胜且富有启发性。欢迎在下面的评论区留言,分享您的想法!

文章来源:https://dev.to/digitalocean/from-15-000-database-connections-to-under-100-digitalocean-s-tale-of-tech-debt-43bj
PREV
How to Code in Go (Tutorial Series) How To Install Go and Set Up a Local Programming Environment on Ubuntu 18.04 How To Install Go and Set Up a Local Programming Environment on macOS How To Install Go and Set Up a Local Programming Environment on Windows 10 How To Write Your First Program in Go Understanding the GOPATH How To Write Comments in Go Understanding Data Types in Go An Introduction to Working with Strings in Go How To Format Strings in Go An Introduction to the Strings Package in Go How To Use Variables and Constants in Go How To Convert Data Types in Go How To Do Math in Go with Operators Understanding Boolean Logic in Go Understanding Maps in Go Understanding Arrays and Slices in Go Handling Errors in Go Creating Custom Errors in Go Handling Panics in Go Importing Packages in Go How To Write Packages in Go Understanding Package Visibility in Go How To Write Conditional Statements in Go How To Write Switch Statements in Go How To Construct For Loops in Go Using Break and Continue Statements When Working with Loops in Go How To Define and Call Functions in Go How To Use Variadic Functions in Go Understanding defer in Go Understanding init in Go Customizing Go Binaries with Build Tags Understanding Pointers in Go Defining Structs in Go Defining Methods in Go How To Build and Install Go Programs How To Use Struct Tags in Go How To Use Interfaces in Go Building Go Applications for Different Operating Systems and Architectures Using ldflags to Set Version Information for Go Applications How To Use the Flag Package in Go
NEXT
高效的 React + TailwindCSS + Styled Components 工作流程通知 2020 年 12 月 2 日动机我们需要一个 Create React App 从 Create React App 中删除标准样板创建 tailwind.config.js 创建 Tailwind.css 创建 babel-plugin-macros.config.js 创建 postcss.config.js 更新您的 package.json 创建 App.jsx 干得好 & 最后的想法一个 Create-React-App 模板。