本文选自“字节跳动基础架构实践”系列文章。
“字节跳动基础架构实践”系列文章是由字节跳动基础架构部门各技术团队及专家倾力打造的技术干货内容,和大家分享团队在基础架构发展和演进过程中的实践经验与教训,与各位技术同学一起交流成长。
自从 Google 发布 Spanner 论文后,国内外相继推出相关数据库产品或服务来解决数据库的可扩展问题。字节跳动在面对海量数据存储需求时,也采用了相关技术方案。本次分享将介绍我们在构建此类系统中碰到的问题,解决方案以及技术演进。
前情回顾
关键技术
下面我们继续展开对于关键技术中的分布式事务、分区自动分裂和合并、负载均衡这几个技术点的讨论。
分布式事务
前面在介绍接口部分时,提到了 ByteKV 原子性的 WriteBatch 和满足分布式一致性快照读的 MultiGet。WriteBatch 意味着 Batch 内的所有修改要么都成功,要么都失败,不会出现部分成功部分失败的情况。MultiGet 意味着不会读取到其他已提交事务的部分数据。
ByteKV 大致采用了以下几种技术来实现分布式事务:
集群提供一个全局递增的逻辑时钟,每个读写请求都通过该模块分配一个时间戳,从而给所有请求都分配一个全局的顺序。
一个 Key 的每次更新都在系统中产生一个新的版本,保证新的写入不会影响到旧的读的快照。
在写请求的流程中引入两阶段提交,保证写入可以有序、原子性的提交。
全局授时服务
毫无疑问,给所有的事件定序,能让分布式系统中的很多问题都得以简化。我们也总是见到各种系统在各种各样的物理时钟、逻辑时钟、混合逻辑时钟中取舍。ByteKV 从性能、稳定性和实现难度的角度综合考虑,在 KVMaster 服务中实现了一个提供全局递增时间戳分配的接口,供集群所有的读写模块使用,该接口保证吐出的时间戳是全局唯一且递增的。
之所以采用这样的架构,是因为我们觉得:
时钟分配的逻辑非常简单,即便是由一个单机模块来提供,也能得到稳定的延时和足够的吞吐。
我们可以使用 Raft 协议来实现时钟分配模块的高可用,单机的失败绝不会成为系统的单点。
在具体实现上,为了保证时钟的稳定、高效和易用,我们也做了一些工程上的努力和优化:
同一个客户端拿时钟的逻辑是有 Batch 的,这样可以有效减少 RPC 的次数。
时钟的分配要用独立的 TCP Socket,避免受到其他的 RPC 请求的干扰。
时钟的分配用原子操作,完全规避锁的使用。
时钟要尽量接近真实的物理时间,非常有利于一些问题的调试。
多版本
几乎所有的现代数据库系统都会采用多版本机制来作为事务并发控制机制的一部分,ByteKV 也不例外。多版本的好处是读写互不阻塞。对一行的每次写入都会产生一个新的版本,而读取通常是读一个已经存在的版本。逻辑上的数据组织如下:
相同的 Key 的多个版本会连续存储在一起,方便具体版本的定位,同时版本降序排列以减少查询的开销。
为了保证编码后的数据能够按我们期望的方式排序,对 RocksDB Key 我们采用了内存可比较编码[2],这里之所以没有自定义 RocksDB 的 compare 函数,是因为:
Key 比较大小是在引擎读写中非常高频的,而默认的 memcmp 对性能非常友好。
减少对 RocksDB 的特殊依赖,提高架构的灵活性。
为了避免同一个 Key 的多个版本持续堆积而导致空间无限膨胀,ByteKV 有一个后台任务定期会对旧版本、已标记删除的数据进行清理。在上篇中,存储引擎章节做了一些介绍。
两阶段提交
ByteKV 使用两阶段提交来实现分布式事务,其大致思想是整个过程分为两个阶段:第一个阶段叫做 Prepare 阶段,这个阶段里协调者负责给参与者发送 Prepare 请求,参与者响应请求并分配资源、进行预提交(预提交数据我们叫做 Write Intent);第一个阶段中的所有参与者都执行成功后,协调者开始第二个阶段即 Commit 阶段,这个阶段协调者提交事务,并给所有参与者发送提交命令,参与者响应请求后把 Write Intent 转换为真实数据。在 ByteKV 里,协调者由 KVClient 担任,参与者是所有 PartitionServer。接下来我们从原子性和隔离性角度来看看 ByteKV 分布式事务实现的一些细节。
首先是如何保证事务原子性对外可见?这个问题本质上是需要有持久化的事务状态,并且事务状态可以被原子地修改。业界有很多种解法,ByteKV 采用的方法是把事务的状态当作普通数据,单独保存在一个内部表中。我们称这张表为事务状态表,和其他业务数据一样,它也分布式地存储在多台机器上。事务状态表包括如下信息:
事务状态:包括事务已开始,已提交,已回滚等状态。事务状态本身就是一个 KV,很容易做到原子性。
事务版本号:事务提交时,从全局递增时钟拿到的时间戳,这个版本号会被编码进事务修改的所有 Key 中。
事务 TTL:事务的超时时间,主要为了解决事务夯死,一直占住资源的情况。其他事务访问到该事务修改的资源时,如果发现该事务已超时,可以强行杀死该事务。
在事务状态表的辅助下,第二阶段中协调者只需要简单地修改事务状态就能完成事务提交、回滚操作。一旦事务状态修改完成,即可响应客户端成功, Write Intent 的提交和清理操作则是异步地进行。
第二个问题是如何保证事务间的隔离和冲突处理?ByteKV 会对执行中的事务按照先到先得的原则进行排序,后到的事务读取到 Write Intent 后进行等待,直到之前的事务结束并清理掉 Write Intent 。Write Intent 对于读请求不可见,如果 Write Intent 指向的事务 Prepare 时间大于读事务时间,那么 Write Intent 会被忽略;否则读请求需要等待之前的事务完成或回滚,才能知道这条数据是否可读。等待事务提交可能会影响读请求的延迟,一种简单的优化方式是读请求将还未提交的事务的提交时间戳推移到读事务的时间戳之后。前面说了这么多 Write Intent,那么 Write Intent 到底是如何编码的使得处于事务运行中还没有提交的数据无法被其他事务读到?这里也比较简单,只需要把 Write Intent 的版本号设置为无穷大即可。
除了上述问题外,分布式事务需要解决容错的问题。这里只讨论协调者故障的场景,协调者故障后事务可能处于已经提交状态,也可能处于未提交状态;部分 PartitionServer 中的 Write Intent 可能已经提交或清理,也可能还保留在那里。如果事务已经提交,随后的读写事务