一次出人意料的数据库迁移

Brad 加入一家创业公司

将近一年前,我刚加入 Tailscale 时,问 Crawshaw 的第一个问题之一是:“你们用什么数据库?MySQL、PostgreSQL,也许是 SQLite?”我知道他很喜欢 SQLite。

“一个文本文件。”他回答。

“啊?”

“对,我们把一个很大的 JSON 对象写到文本文件里。”

“怎么写?什么时候写?你说什么?”

“对,只要有东西变了,我们就在单个进程里拿到一把锁,然后重写整个文件!”他高兴地笑着说。

这听起来很疯狂,实际上也确实疯狂。它容易测试,但无法扩展,我们都知道。不过它能工作。

直到它不能。

即使使用快速的 NVMe 磁盘,并把数据库拆成两半——重要数据,以及可以放在 tmpfs 上、丢失也无妨的临时数据——系统仍越来越慢。我们知道这一天会来。文件最大达150 MB,而我们已经按照磁盘 I/O 所允许的最高速度在写它。这可真是“太妙了”。

所以,该迁移到 MySQL 或 PostgreSQL 了吧?也许 SQLite?

不,Crawshaw 有别的想法。

David 讲一点背景

Tailscale 的协调服务器,也就是我们的“控制平面”,被称为 CONTROL。当时,它是在单台虚拟机上运行的单个 Go 进程。CONTROL 最早的原型使用 SQLite。最初的设计与最终结果相差很大,涉及把配置数据库同步到客户端机器,以及各种后来发现不需要的概念。在这个过程中,我们每周都大幅调整 SQL 数据模型,需要写惊人数量的代码。SQL 应用广泛、持久可靠、有效,但把它接入几乎任何编程语言,都需要写一大堆烦人的胶水代码。试图用 ORM 避免这种情况,通常只是把大量烦人的代码,换成大量令人困惑的魔法和效率损失。

有一天,我受够了重构,便把它全部扔掉,建立一个用于实验的内存数据模型。迭代速度因此快了很多。几周后,一位客户想试用。我还没准备好把数据模型定下来并用 SQL 正规实现,所以走了一条捷径:用 sync.Mutex 包住保存全部数据的对象,所有访问都经过这把锁;编辑时,把整个结构传给 json.Marshal,再写入磁盘。这样只用约20行 Go 代码,就实现了数据模型持久化。

我们始终计划迁移到别的东西,不过,呃,后来忙于其他工作,就有点忘了。

JSONMutexDB 之后是什么

显而易见的下一步是迁移到 SQL。我最喜欢的仍是 SQLite,但实在无法说服自己,为把快速增长的服务迁往 SQLite 找出理由。它当然可能胜任,尤其是我们控制平面的设计并不要求典型 Web 服务那样的高可用性:短暂故障的结果是新节点无法登录,已经工作的网络仍会继续工作。

接下来是 MySQL 或 PostgreSQL。我不太熟悉1998年之后的 MySQL,但相信它可以工作。不过,开源数据库的高可用性方案有点出人意料:你可以选择传统的、存在复制滞后的副本,也可以投入没有主副本的集群,而后者的事务语义可能非常意外。我不太想在这样的语义之上设计稳定 API 或优秀的网络图计算。CockroachDB 看起来很有前景,而且至今仍然如此!但对数据库来说它还较新,我有点担心依赖某个新 DBMS 中的特性后,必要时很难迁走。

让控制服务器依赖 MySQL 或 PostgreSQL,也会使测试变得缓慢而难看。Brad 在 Perkeep 上已经打过这场仗,还曾编写 perkeep.org/pkg/test/dockertest。它可以工作,但我们不想让未来员工也经历这种麻烦:机器上要装 Docker,测试也不特别快。

后来有一天,我们看到一份关于 etcd 的 Jepsen 报告。与惯常不够令人满意的报告不同,这份报告对 etcd 的评价很好。再加上 Dave Anderson 的一些良好使用经验,我们开始考虑能否直接使用 etcd。它由 Go 编写,可以直接链接到测试中使用,无须 Docker,也无须模拟,测试的就是生产环境实际使用的系统。

我们发现,写到磁盘的核心数据模型很接近以下结构:

type AllTheData struct {
	BigLock    sync.Mutex
	Somethings map[string]Something
	Widgets    map[string]Widget
	Gadgets    map[string]Gadget
}

它与键值存储的映射关系出乎意料地好。因此,我们选择 etcd 作为“最小可行数据库”。它能完成眼下所需的关键事情:一是把 BigLock 拆成更像 sync.RWMutex 的机制;二是减少 I/O,每次只写改变的数据,而不是任何一次写入都重写整个世界。

我们谨慎地避免使用难以映射到 CockroachDB 的 etcd 特性。

缺点是:etcd 在 Kubernetes 中很流行,但作为数据库系统,用户相对较少。Tailscale 因此花掉了一枚“创新令牌”。不过,这个数据库在概念上足够小,我们无须把它当作黑箱。我们碰到 etcd 3.4 中一个键分页异常缓慢的边缘情况时,我读完相关源码,一小时内就写出修复。随后发现 etcd 的下一版本已经有等效修复,于是我们改为回移那份补丁。

tailetc:我们的 etcd 客户端包装器

我们的 etcd 客户端开源于 github.com/tailscale/tailetc。它围绕两个前提构建:数据库总数据量小到可以放进服务器内存,而且读操作远多于写操作。在这些前提下,我们希望让读取足够便宜。

实现方式是在 etcd 上注册 watch。每次变更都会发送给客户端,客户端在 sync.RWMutex 保护下维护一个很大的 map[string]interface{} 缓存。创建 Tx 并调用 Get 时,值从缓存中读出。缓存可能落后于 etcd,但通过跟踪 modrev 维持事务一致性;它是 etcd 用于定义键值对修订版本的全局递增 ID。为避免缓存别名引起的错误,我们把对象复制出来;同时在缓存对象上实现更高效的 Clone 调用,避免每次 Get 都进行 JSON 解码。

结果是,从 etcd 获取一个值不需要任何网络流量。

这是我编写 Go 时,少数几次在设计包的过程中感受到类型系统限制的经历。如果使用拥有各种高级特性的语言,也许可以在离开缓存的对象上加某种 const 限定符,避免克隆内存。不过,服务器性能分析显示复制并不是性能问题。因此,这或许是一个例子:我觉得更复杂的类型系统有吸引力,却没有真正的需求。和很多时候一样,假设有危险,性能分析让人看清事实。

一个难点:索引

选择最小可行“nosql”方案的最大问题,是缺少标准 SQL DBMS 所提供的优秀索引系统。我们只能把索引存进 etcd,或者在客户端内存中管理。

使用 JSONMutexDB 时,我们在内存中生成索引,因为更容易改变数据模型。采用 etcd 后,简单的选项本来是把索引写进数据库,但会使数据模型复杂很多。不幸的是,如果想为了高可用性和更好的发布管理而同时运行多个 CONTROL 进程,就不再只有一个进程管理数据,索引必须理解事务及回滚。因此,我们大概投入了两到三周工程时间,设计具有事务一致性的内存索引。这里有点难以讲清,会留到未来文章再说;也希望某天能把代码整理到足以开源。

迁移

迁移没有什么特别值得一提,这总是件好事。我们并行运行两个系统一段时间,然后在某个时刻停止使用旧系统。最令人兴奋的事情,是关闭 JSON 写入后,提交延迟大幅下降。在管理控制台编辑网络时,这一点尤其明显。我们本想放些漂亮的 Grafana 图,但切换发生在我们修改 Prometheus 配置以保存更长历史之前。无论如何,写入耗时从接近一秒,有时更差,降到了毫秒级。最初写入当然没有一秒那么慢。永远别低估你的“临时”补丁会在生产环境待多久!

未来

除了确保 Tailscale 控制平面在可预见的未来能够扩展,这项工作最令人兴奋的,是改进发布流程。一个一致、且容易连接多个控制平面实例的数据库,意味着可以转向蓝绿部署。这样,Tailscale 工程师便能尝试部署新功能,并确信变更的最坏结果是有限的。目标是尽可能保留 JSONMutexDB 早期的开发速度:在几分之一秒内完成本地重新编译和运行,一天部署十次。

文中第一人称经历、示例与结果均为原作者的记述。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容