最初,我们把一个文件当作数据库,将数据转化为 JSON 大对象写入进去,后来,它的速度越来越慢,我们决定进行数据库的迁移,这个过程中我们遇到了一些问题和障碍,但最终我们成功完成了这一次不太可能的数据库迁移。
大约一年前,当我刚加入 Tailscale(https://tailscale.com/)时,我问 Crawshaw(
https://github.com/crawshaw)的第一件事是:“嗯……你们使用的是什么数据库呢?MySQL、PostgreSQL、SQLite?“我知道他喜欢 SQLite。
“一个文本文件,”他回答道。
“嗯?”
“是的,我们将一个大型 JSON 对象写入一个文本文件。”
“怎么写?什么时候写?写什么?”
“嗯,无论什么时候,只要有什么东西一变,我们都会在我们的单进程中获取一个锁,然后重写这个文件!”他高兴地笑着说。
听起来很疯狂。其实就是很疯狂。的确,它很容易测试,但是,不能扩展。这些,我们都知道。但是,当时它还行得通。
后来,它不行了。
即使使用高速 NVMe 驱动器,并且将数据库分成两部分(重要数据和 tmpfs 上的可能丢失的临时数据),有些事情也会变得越来越慢。我们知道这一天终将到来。文件达到了 150MB 的峰值,我们正在以磁盘 I/O 允许的最快速度写入它。这已经到了极限。
那么,迁移到 MySQL 或 PostgreSQL,如何?或者是 SQLite?
不,Crawshaw 另有主意。
Tailscale 的协调服务器(我们的“控制面板”)以控制 CONTROL(
https://getsmart.fandom.com/wiki/CONTROL)闻名遐迩。它目前是单个 VM 上的单个 Go 进程。它最早的原型使用的是 SQLite。我们最初的设计与最终的设计有非常大的差异,包括同步到客户端机器上的配置数据库,以及所有我们最终不再需要的其他概念。在这个过程中,我们每周都要对 SQL 数据模型进行非常大规模的重组,这需要大量的键盘输入工作。SQL 已经得到了广泛使用,它持久、有效,但将其引入到任何编程语言中几乎都需要做大量的粘合。(通常大家都试图用 ORM 来避免这种情况,用令人生厌的大量魔法字和效率损失来取代那些同样令人生厌的键盘输入工作。)
一天,我厌倦了重构,就把它彻底丢在一边,建了一个内存数据模型进行实验。这样,迭代速度更快了。几周后,一位客户想要试用一下。我还没有做好提交数据模型和用 SQL 来完成它的准备,所以我选择了一条捷径:将持有所有数据的对象包装在一个 sync.Mutex 中。所有访问都要经过它,在编辑时,将整个结构传递给 json.Marshal,然后写入磁盘。这就是我们用大约 20 行 Go 实现的数据模型持久层。
我们本来计划要迁移为别的语言的,但忙着忙着就给忘记了。
下一步显然是迁移到 SQL。我最喜欢的仍然是 SQLite,但是我不能说服自己把一个快速增长的服务迁移到它上面。它当然是可行,尤其是我们的控制面板的设计并不需要典型的 web 服务高可用性:短时间停机的无非就是使新节点无法登录而已,正在工作的网络可以保持正常工作。
其后是 MySQL(或 PostgreSQL)。我对 1998 年以后的 MySQL 不是特别熟悉,但我确信它是可以的。不过,开源数据库的 HA 情况有些令人惊讶:您可以使用传统的滞后副本,也可以提交到具有令人非常惊讶的事务语义的无主副本集群。我对试图在这些语义之上设计一个稳定的 API 或良好的网络图计算并不感兴趣。CockroachDB(
https://github.com/cockroachdb/cockroach#what-is-cockroachdb) 曾经看起来很有前途,而实际上现在仍然很有前途!但对于一个数据库来说,它还是相对比较新的,我不太放心把一些特性附着在一个新的 DBMS 上,因为如果我们需要将这些特性中迁移出来时,可能很难做得到。
让我们的控制服务器依赖于 MySQL 或 PostgreSQL 还意味着我们对控制服务器的测试将变得缓慢和丑陋。Brad 与 Perkeep(https://perkeep.org/)曾就此有过争论,他之前写过
perkeep.org/pkg/test/dockertest,它的确可行,但我们不想要求未来的员工都这么做。它需要在你的机器上部署 Docker 环境,速度不是特别快。
后来有一天我们看到一份 Jepsen 写的 etcd 报告(
https://jepsen.io/analyses/etcd-3.4.3)。这篇报告不似 Jepsen 之前那种满篇吐槽的风格,里面还指出了一些 etcd(https://etcd.io/)的优点。结合 Dave Anderson(
https://github.com/danderson)的一些正面体验,我们开始考虑是否可以直接使用 etcd。由于是它用 Go 编写的,我们可以直接将它连接到我们的测试中,并直接使用它。无需 Docker,无需 mock,就可以测试我们在生产环境中实际使用的东西。
事实上,我们写入到磁盘的核心数据模型严格遵循了以下模式:
type AllTheData struct { BigLock sync.Mutex Somethings map[string]Something Widgets map[string]Widget Gadgets map[string]Gadget}
复制代码
这很好地映射到了 KV-store 上。因此,我们将 etcd 作为一个“最小可行的数据库”。它做了我们当前所需要的最关键的事情,那就是 1)将 BigLock 拆解成更类似于 sync.RWMutex 的东西。2)减少 I/O,只写改变的数据,而不是整体都写。
(我们会谨慎避免使用任何难以映射到 CockroachDB 的 etcd 特性。)
这样做的缺点是,etcd 虽然在 Kubernetes 中很流行,但是数据库系统的用户相对较少。作为一家公司,Tailscale 正致力于在其上打造一款创新代币(
https://mcfunley.com/choose-boring-technology)。但这款数据库从概念上讲非常小,以致于我们不必把它当作一个黑盒。当我们在 etcd 3.4 中遇到一个异常缓慢的主键分页的极端情况时,我能够阅读它的源代码并在一个小时内编写出一个修复程序。(后来,我发现 etcd 的下一个版本也已经做了一样的修复(
https://github.com/etcd-io/etcd/commit/26c930f27d46776da5fedae69267ba0b69c31185),所以我们将其反向移植了过来。)
我们用于 etcd 的客户端是开放源码的,网址是
github.com/tailscale/tailetc(
https://github.com/tailscale/tailetc)。它围绕了两个概念:1)DB 中的总数据量足够小,可以放入服务器的内存中;2)读比写更常见。鉴于这一点,我们希望降低读取成本。
我们的方法是对 etcd 注册一个监控。每次更改都被发送到这个客户端,这个客户端在一个 sync.RWMutex 后面维护一个庞大的缓存 map[string] interface{}。当你创建一个 Tx 并且做一次 Get 时,这个值从这个缓存中读出(这个缓存可能在 etcd 之后,但是通过跟踪 modrev 来保持事务一致性:即一个全局递增的 ID, etcd 使用它来界定键-值对的修订)。为了避免缓存中的混叠错误,我们将对象复制出来,但是通过对缓存中的对象实现更有效的克隆调用,避免了每次 Get 时的 JSON 解码。
最终结果是,从 etcd 获取一个值不需要任何网络流量。
当我在设计一个包时,我感受到了编写 Go 时它的类型系统的局限性,这样的感受并不多,它是其中之一。如果我使用的是一种具有各种花哨功能的语言,那么我可以在离开缓存的对象上放置某种 const 限定符,从而避免对内存进行克隆。即便如此,在我们的服务器上执行的性能分析却表明,复制并不是一个性能问题,所以该例可能说明,我实际上并不需要那些心心念念的更复杂的类型系统。通常情况下,假设很可能并不正确,性能分析才更具启发意义。
选择最小可行的“nosql”的最大问题是缺乏每个标准 SQL DBMS 所提供的出色的索引系统。我们要么在 etcd 中存储索引,要么在客户端的内存中管理索引。
我们使用 JSONMutexDB 在内存中生成它们,因为更改数据模型要容易得多。使用 etcd 的一个简单做法是将它们写入数据库,但这将产生非常复杂的数据模型。不幸的是,如果我们想要同时运行多个控制进程以实现高可用性和更好的发布管理,就意味着我们不再只有一个管理数据的进程,因此我们的索引需要支持事务(以及回滚)。因此,我们投入了大约两到三周的工程时间来设计事务一致的内存索引。这一点描述起来有些复杂,所以笔者将在后续的博客文章中专题解释,敬请期待。
而迁徙本身却没什么特别值得注意的,这其实件好事。我们这两个系统并行运行了一段时间,并在某个时间点停止了旧系统的使用。最令人兴奋的是,当我们关闭 JSON 写入时,提交延迟降低了很多。在管理面板中编辑网络时这一点尤为明显。我们有漂亮的 Grafana 图表,在切换之前我们就调整了 Prometheus 配置以保持更多的历史纪录。不论在哪种情况下,写操作都能从几乎一秒(有时更糟!)的时间缩短到毫秒级。刚开始的时候,写入并不是我们的第二目标。永远不要低估“临时”起意会产生多么长久的影响!
在这项工作中,除了确保 Tailscale 控制面板可以在可预见的未来扩展外,最令人兴奋的事情是我们发布过程的改进。我们可以轻松地将多个控制面板实例附加到一个一致的数据库中,这意味着我们可以切换为蓝绿部署(
https://en.wikipedia.org/wiki/Blue-green_deployment)。这将让 Tailscale 的工程师们有信心去尝试部署特性,因为变更所能造成的最差结果是有限的。我们的目标是将开发速度保持在接近 JSONMutexDB 早期的水平,当时我们可以在不到一秒的时间内重新编译并在本地运行,每天部署上 10 几次。