摘要
翻译自 Transaction Semantics in Apache Kudu,本文将简要介绍kudu事务与一致性语义,如果想深入了解,可以查看引用文献。Kudu 的事务语义和架构受到了 Spanner 和 Calvin等最先进系统的启发。Kudu 建立在数十年的数据库研究之上。其核心理念是通过提供简单、强大的语义来使开发人员的生活变得更加容易,同时不牺牲性能或调优达到满足不同需求的能力。
Kudu 目前允许如下操作
写操作是在一个具有多个副本的tablet中在存储引擎中插入、更新或删除的一组行。写操作没有单独的“读集”,也就是说,它们在执行写操作之前不扫描现有数据。每个写操作只关心将要更改的行的以前状态。写不是由用户显式“提交”的。相反,它们在完成后由系统自动提交。
写事务是跨可能的多个tablet的写操作组,这些写操作根据用户的请求自动提交。一旦事务中的每个写操作完成,用户就会发送一个显式的“提交”请求,使事务的内容在单个时间戳内对读者可见。
扫描是可以遍历多个tablet和读取具有不同级别的一致性或正确性保证的信息的读取操作。扫描可以执行时间旅行读取,也就是说,用户可以设置过去的扫描时间戳,并返回反映当时存储引擎状态的结果。
单个tablet写操作
Kudu 使用了多版本并发控制(mvCC)和 Raft 共识算法。在库杜,每个写入操作都必须经过tablet的leader。
leader获取它将更改的行的所有锁。
在提交写操作以供复制之前,leader分配给写操作一个时间戳。这个时间戳将是 MVCC 中写入的“标记”。
只有在大多数副本确认更改之后,实际的行才被更改。
更改完成后,它们将对并发写入和读取可见,并且是原子的。
tablet的所有副本都遵循相同的操作顺序,如果一个写操作被分配了时间戳 n 并改变了行 x,那么第二次的时间戳 m > n 就可以保证会看到 x 的新值。
这种严格的锁定获取和时间戳分配顺序通过协商一致的方式在tablet的所有副本中保持一致。因此,相对于同一tablet的其他写操作,写操作对时钟分配的时间戳是完全有序的。换句话说,在公认有限的上下文中,写具有严格可序列化的语义。关于这些语义的含义,请参阅这篇博客文章(http://www.bailis.org/blog/linearizability-versus-serializability)以获得更多的上下文。
虽然在 ACID 意义上是孤立的和持久的,但多行写操作,除非它们是多tablet写事务的一部分,否则即使在单个tablet中,也不是完全原子的。因为批处理操作中单个写操作的失败不会回滚操作,而且会产生每行错误。
多tablet写操作
不管它们是否是事务的一部分,来自 Kudu 客户端的写操作都可以选择在内存中缓冲,直到刷新并发送到服务器。当客户端的会话刷新时,每个tablet行被批处理在一起,并发送到tablet副本领导的tablet服务器。在事务之外,这些批处理中的每一个都代表一个独立的写操作,它们有自己的时间戳。但是,客户端 API也 提供了对指定的时间戳以及客户端如何观察对不同tablet的写操作施加一些约束的方式。
与 Spanner 一样,Kudu 的设计也是外部一致的(https://kudu.apache.org/docs/transaction_semantics.html#consistency) ,当操作跨越多个tablet甚至多个数据中心时依旧保持一致性。实际上,这意味着,如果写入操作改变了tablet A 上的 x 项,而后面的写入操作改变了tablet B 上的 y 项,如果你希望强制执行并观察到 y 的变化,那么也必须观察到 x 项的变化。有很多例子可以说明这一点很重要。例如,如果 Kudu 存储点击流以供进一步分析,并且两次点击相互连续,但却存储在不同的tablet中,那么后续的点击应该分配后续的时间戳,以便捕获它们之间的因果关系。
CLIENT _ PROPAGATED一致性
Kudu 的默认外部一致性模式称为 CLIENT _ PROPAGATED。有关它如何工作的详细解释,请参阅https://kudu.apache.org/docs/transaction_semantics.html#ht。简而言之,此模式使来自单个客户端的写操作在外部上自动保持一致。在上面的点击流场景中,如果两次点击是由不同的客户端实例提交的,那么应用程序必须手动将时间戳从一个客户端传播到另一个客户端,以便捕获因果关系。
客户机 a 和 b 之间的时间戳可以按以下方式传播:
Java CLIENT
在客户端 a 上调用 AsyncKuduClient # getLastPublisatedTimestamp () ,将时间戳传播到客户端 b,并在客户端 b 上调用 AsyncKuduClient # setLastPublisatedTimestamp ()。
C + + CLIENT
在客户端 a 上调用 KuduClient: : GetLatestObserver edTimestamp () ,将时间戳传播到客户端 b,并在客户端 b 上调用 KuduClient: : SetLatestObserver edTimestamp ()。
COMMIT_WAIT 一致性
Kudu 还有一个实验性的外部内存一致性模型实现,它被用于 Google 的 Spanner,名为 COMMIT _ WAIT。COMMIT _ WAIT 通过紧密同步集群中所有计算机上的时钟来工作。然后,当发生写操作时,分配时间戳,直到足够长的时间过去,写操作的结果才可见,这样集群中的任何其他机器都不可能为下一次写操作分配较低的时间戳。
在使用这种模式时,写入的延迟与所有集群主机上时钟的准确性紧密相关,并且在松散时钟同步的情况下使用这种模式会导致写入花费很长时间才能完成,甚至超时。参见已知问题和局限性。
COMMIT _ WAIT 一致性模式可以选择如下:
Java Client
Call KuduSession#setExternalConsistencyMode(ExternalConsistencyMode.COMMIT_WAIT)
C++ Client
Call KuduSession::SetExternalConsistencyMode(COMMIT_WAIT)
读操作 (Scans)
扫描是客户端执行的读操作,它可能跨越一个或多个tablet的一行或多行。当服务器接收到扫描请求时,它获取 MVCC 状态的快照,然后根据用户选择的读取模式以两种方式之一进行扫描。模式可选择如下:
Java 客户端
调用 KuduScanerBuilder # readMode (...)
C + + 客户端
调用 KuduScanner: : SetReadMode ()
READ_LAEAST
这是默认的读模式。服务器获取 MVCC 状态的快照并立即继续读取。在此模式下的读只会产生“读以提交”隔离级别。
READ _ AT _ SNAPSHOT
在这种读模式下,扫描是一致的和可重复的。快照的时间戳由服务器选择,或者由用户通过 KuduScanner: : SetSnapshoMicros ()显式设置,建议显式设置时间戳。服务器等待直到时间戳“安全”(直到所有时间戳较低的写操作完成并可见)。这样的延迟,加上外部的一致性方法,最终允许 Kudu 具有完全严格的可序列化的读和写语义。这仍然是一项正在进行的工作,一些异常仍然是可能的(见已知问题和限制)。只有在这种模式下的扫描才能容错。
READ_YOUR_WRITES
这种读取模式依赖于 Kudu 客户机的状态来发出后续的扫描请求。当以这种读模式发出扫描请求时,Kudu 客户端提供它到目前为止观察到的最新时间戳。服务器选择一个高于客户端提供的时间戳的时间戳,该时间戳还保证提交所有以前的写操作并应用于数据。这转化为读写和读读行为,这在后续扫描请求应该包含客户机在当前会话期间读写时迄今为止看到的数据的场景中非常有用。如果有必要,KUDU-1704可以提供更多的细节和参考资料。总结一下,这种读模式:
确保读写和读读会话的保证
最小化因等待服务器端的未完成写操作而造成的延迟
不能保证线性化
在读取模式之间进行选择需要权衡利弊,并做出适合您工作负载的选择。例如,需要扫描整个数据库的报告应用程序可能需要执行仔细的记帐操作,以便扫描可能需要容错,但可能不需要数据库的微秒级最新视图。在这种情况下,您可以选择 READ _ AT _ SNAPSHOT ,并保存过去几秒钟扫描开始时的时间戳。另一方面,对于机器学习性质的工作负载不会摄取整个数据集,并且本质上已经是统计的,因此可能不需要扫描是可重复的,所以您可以选择 READ _ LATEST,以获得更好的扫描性能。
已知的问题和限制
目前,在某些情况下,有几个案例不能让 Kudu 完全严格序列化。下面是详细信息,接下来是一些建议。
Writes
COMMIT _ WAIT 的支持还处于试验阶段,需要仔细调优时间同步协议,例如 NTP (Network Time Protocol,网络时间协议)。在生产环境中不鼓励使用它。
目前,多tablet 事务支持仅允许平tablet在同一时间参与单笔事务。
多tablet事务支持目前只能保证“读提交”的语义。
READS
在leader更改时,时间戳超过上次写入时间的快照上的 READ _ AT _ SNAPSHOT 扫描也可能产生不可重复的读(参见 KUDU-1188)。有关解决方案,请参见建议。
Impala 扫描当前 READ _ LATEST 方式执行,并且没有一致性保证。
在 AUTO _ BACKGROEND _ FLUSH 模式下,或者在使用“异步”刷新机制时,应用于单个客户端会话的写操作可能会由于将数据刷新到服务器的并发性而被重新排序。如果快速更新单个行并连续使用不同的值,这一点可能特别明显。这种现象会影响所有客户端 API 实现,包括事务 API。社区在 FlushMode 或 AsyncKuduSession 文档中各自实现的 API 文档中进行了描述。参见 KUDU-1767。
当前不支持脏读(即读到未提交事务)。
推荐
如果需要可重复的快照读取,那么使用 READ _ AT _ SNAPSHOT且时间戳稍微早一些(理想情况下在2-5秒之间)。这将绕过 Writes 中描述的异常。即使异常已经得到处理,时间戳的回溯也总是会使扫描更快,因为它们不太可能被阻塞。
如果需要外部一致性并决定使用 COMMIT _ WAIT,则需要仔细调优时间同步协议。每个操作将等待执行时的最大时钟错误的2倍,这通常是在100毫秒。1秒。范围与默认设置,也许更多。因此,写操作至少需要200毫秒~2秒。在使用默认设置时完成,甚至可能超时。
本地服务器应该用作时间服务器。我们已经使用 Google Compute Engine 数据中心中提供的默认 NTP 时间源进行了实验,并且能够获得一个合理的严格的最大错误界限,通常在12-17毫秒之间变化。
应在/etc/ntp.conf 中调整以下参数,以减少最大错误:
server my_server.org iburst minpoll 1 maxpoll 8
tinker dispersion 500
tinker allan 0