本页面介绍了 Spanner 中的事务,并介绍了 Spanner 的读写、只读和分区 DML 事务接口。
Spanner 中的事务是一组读写操作,它们跨数据库中的列、行和表,在单个逻辑时间点以原子方式执行。
会话用于在 Spanner 数据库中执行事务。会话代表一种与 Spanner 数据库服务的逻辑通信渠道。会话一次可以执行一个或多个事务。如需了解详情,请参阅会话。
事务类型
Spanner 支持以下事务类型,每种类型均针对特定的数据互动模式而设计:
乐观和悲观并发控制
- 读写快照隔离事务:这些事务不使用阻塞,而是让事务按照事务开始时存在的数据库“快照”进行。 只有在提交时才会检测和解决冲突。
- 锁定读写:这些事务使用悲观锁定,并在需要时使用两阶段提交。它们可能会失败并需要重试。虽然它们仅限于单个数据库,但可以修改该数据库内多个表中的数据。
只读:这些事务可保证在多次读取操作中数据的一致性,但不允许修改数据。它们会在系统确定的时间戳(为了一致性)或用户配置的过去时间戳执行。与读写事务不同,它们不需要提交操作或锁定,但可能会暂停等待正在进行的写入操作结束。
分区 DML:此事务类型将 DML 语句作为分区 DML 操作来执行。其针对大规模数据更新和删除(例如数据清理或批量数据插入)进行了优化。对于不需要原子事务的大量写入,请考虑使用批量写入。如需了解详情,请参阅使用批量写入修改数据。
读写事务
使用锁定读写事务,以原子方式读取、修改和写入数据库中的任何位置的数据。这种类型的事务在外部是一致的。
尽可能缩短事务处于活跃状态的时间。事务时长越短,成功提交的可能性就越大,争用情况就越少。
只要事务继续执行读取操作且未通过 sessions.commit
或 sessions.rollback
操作终止,Spanner 就会尝试保持读取锁定处于活跃状态。
如果客户端长时间处于非活跃状态,Spanner 可能会释放事务锁定并取消事务。
从概念上讲,读写事务由零个或多个读取或 SQL 语句(后跟 sessions.commit
)组成。在 sessions.commit
之前的任何时间,客户端都可以发送 sessions.rollback
请求来取消事务。
如需执行依赖于一项或多项读取操作的写入操作,请使用锁定读写事务:
- 如果您必须以原子方式提交一项或多项写入操作,请在同一读写事务内执行这些写入。例如,如果您将 200 美元从账号 A 转到账号 B,请在同一事务中执行两项写入操作(将账号 A 减少 200 美元,并将账号 B 增加 200 美元)和初始账号余额读取。
- 如果您想将账号 A 的余额翻倍,请在同一事务内执行读取和写入操作。这可确保系统在将余额翻倍并更新之前读取余额。
- 如果您可能执行一项或多项写入操作,而这些写入操作依赖于一项或多项读取操作的结果,请在同一读写事务中执行这些写入和读取操作,即使写入操作未执行也是如此。例如,如果您希望仅在账号 A 的当前余额大于 500 美元时将 200 美元从账号 A 转到账号 B,请在同一事务内添加对 A 余额的读取和条件写入操作,即使转账操作未发生也是如此。
如需执行读取操作,请使用单次读取方法或只读事务:
- 如果您只执行读取操作,并且可以使用单次读取方法来表示读取操作,请使用该单次读取方法或只读事务。与读写事务不同,单次读取不会获取锁定。
接口
Spanner 客户端库提供了一个接口,用于在读写事务内执行操作,并在事务中止的情况下重试。Spanner 事务可能需要多次重试才能提交。
有几种情况可能会导致事务中止。例如,如果两项事务尝试同时修改数据,则可能会发生死锁。在此类情况下,Spanner 会中止一项事务,以让另一项事务可以继续进行。更少见的情况是,Spanner 内的暂时性事件也可能导致事务中止。
由于事务具有原子性,因此中止的事务不会影响数据库。在同一会话内重试事务,以提高成功率。每次导致 ABORTED
错误的重试都会提高事务的锁定优先级。
在 Spanner 客户端库中使用事务时,您可以将事务的主体定义为函数对象。此函数封装了对一个或多个数据库表执行的读写操作。Spanner 客户端库会反复执行此函数,直到事务成功提交或遇到无法重试的错误。
示例
假设您在 Albums
表中有一个 MarketingBudget
列:
CREATE TABLE Albums ( SingerId INT64 NOT NULL, AlbumId INT64 NOT NULL, AlbumTitle STRING(MAX), MarketingBudget INT64 ) PRIMARY KEY (SingerId, AlbumId);
您的营销部门要求您将 20 万美元从 Albums
(2, 2)
的预算划拨给 Albums (1, 1)
,但前提是该专辑的预算资金充裕。您应该为此操作使用锁定读写事务,因为该事务可能会根据读取结果执行写入。
下面演示了如何执行读写事务:
C++
C#
Go
Java
Node.js
PHP
Python
Ruby
语义
本部分介绍了 Spanner 中读写事务的语义。
属性
Spanner 中的读写事务以原子方式执行一组读写操作。读写事务执行的时间戳与经过的时间匹配。序列化顺序与此时间戳顺序一致。
读写事务可提供关系型数据库的 ACID 属性。Spanner 读写事务提供比典型 ACID 更强大的属性。
得益于 Cloud Spanner 的这些特性,应用开发者可以专注于每项事务本身的正确性,而不必想方设法让其执行不受可能同时执行的其他事务干扰。
读写事务的隔离
在成功提交包含一系列读取和写入的事务后,您会看到以下内容:
- 事务返回的值可反映事务提交时间戳的一致快照。
- 空的行或范围在提交时保持为空。
- 事务在事务的提交时间戳提交所有写入。
- 在事务提交之前,任何事务都看不到写入。
Spanner 客户端驱动程序包含事务重试逻辑,以遮盖暂时性错误,方法是重新运行事务并验证客户端观察到的数据。
其结果是所有读写操作似乎是在 (无论是从事务本身还是从事务 并分析 Spanner 数据库的其他读取者和写入者的角度。 这意味着读取和写入发生在同一时间戳。如需查看示例,请参阅可序列化和外部一致性。
读取事务的隔离
当读写事务仅执行读取操作时,它会提供与只读事务类似的一致性保证。事务内的所有读取都会返回来自一致时间戳的数据,包括确认不存在的行。
一个区别是,当读写事务在未执行写入操作的情况下提交时。在这种情况下,无法保证在读取操作和事务提交之间,事务内读取的数据在数据库中保持不变。
为了确保数据新鲜度,并验证数据自上次检索后未修改,需要进行后续读取。此重读可以在另一个读写事务内执行,也可以通过强读取执行。
为了达到最佳效率,如果事务专门执行读取,请使用只读事务,而不是读写事务。
原子性、一致性、可治性
除了隔离之外,Spanner 还提供其他 ACID 属性保证:
- 原子性。如果事务的所有操作都成功完成,或者一个都没有完成,则该事务视为原子事务。如果事务内的任何操作失败,整个事务都会回滚到原始状态,以确保数据完整性。
- 一致性。事务必须保持数据库规则和约束条件的完整性。事务完成后,数据库应处于有效状态,并遵循预定义的规则。
- 耐用性。事务提交后,其更改会永久存储在数据库中,并在系统发生故障、停电或其他中断时持续存在。
可序列化和外部一致性
Spanner 提供强大的事务保证,包括可序列化和外部一致性。这些属性可确保数据保持一致,并且操作按可预测的顺序进行,即使在分布式环境中也是如此。
可序列化可确保所有事务看起来都是按单一、连续的顺序依次执行,即使它们是同时处理也是如此。Spanner 通过为事务分配提交时间戳来实现这一点,反映事务的提交顺序。
Spanner 提供更强的保证,称为外部一致性。这意味着,事务不仅按提交时间戳反映的顺序进行提交,而且这些时间戳也与实际时间一致。这样一来,您就可以将提交时间戳与实时时间进行比较,从而以全球一致的顺序查看数据。
从本质上讲,如果事务 Txn1
实时地在另一个事务 Txn2
之前提交,则 Txn1
的提交时间戳会早于 Txn2
的提交时间戳。
请参考以下示例:
在此场景中,在时间轴 t
期间:
- 事务
Txn1
读取数据A
,暂存写入A
,然后成功提交。 - 事务
Txn2
在Txn1
启动后开始。它会读取数据B
,然后读取数据A
。
即使 Txn2
在 Txn1 完成之前就已启动,Txn2
也会观察 Txn1
对 A
所做的更改。这是因为 Txn2
在 Txn1
提交对 A
的写入后读取 A
。
虽然 Txn1
和 Txn2
的执行时间可能会重叠,但它们的提交时间戳(分别为 c1
和 c2
)会强制执行线性事务顺序。这意味着:
Txn1
内的所有读取和写入似乎都发生在单一时间点c1
。Txn2
内的所有读取和写入似乎都发生在单一时间点c2
。- 重要的是,对于已提交的写入,
c1
比c2
更早,即使写入发生在不同的机器上也是如此。如果Txn2
仅执行读取,则c1
会在c2
之前或与其同时执行。
这种强有序性意味着,如果后续读取操作观察到 Txn2
的影响,则也会观察到 Txn1
的影响。对于所有成功提交的事务,此属性都为 true。
事务失败时的读取和写入保证
如果执行事务的调用失败,那么您所获得的读取和写入保证取决于底层的提交调用是因什么错误而失败。
例如,“未找到行”或“行已存在”等错误意味着写入缓冲的数据变动时遇到了一些错误,例如客户端尝试更新的行不存在。在这种情况下,读取保证一致,写入不会得到执行,行不存在的情形也保证与读取一致。
事务失败时的读取和写入保证
当 Spanner 事务失败时,您对读取和写入所获得的保证取决于 commit
操作期间遇到的特定错误。
例如,“未找到行”或“行已存在”等错误消息表示在写入缓冲的数据变更时遇到了问题。例如,如果客户端尝试更新的行不存在,则可能会出现这种情况。在以下情况下:
- 读取是一致的:在事务期间读取的任何数据均保证在出现错误之前是一致的。
- 未应用写入:事务尝试的变更未提交到数据库。
- 行一致性:触发错误的行不存在(或存在状态)与在事务内执行的读取一致。
您可以随时取消 Spanner 中的异步读取操作,而不会影响同一事务内的其他正在进行的操作。如果更高级别的操作已取消,或者您决定根据初始结果中止读取,这种灵活性会非常有用。
不过,请务必了解,请求取消读取并不保证立即终止。在取消请求后,读取操作仍可能会:
- 成功完成:读取可能会在取消生效之前完成处理并返回结果。
- 因其他原因失败:读取可能会因其他错误(例如取消)而终止。
- 返回不完整的结果:读取可能会返回部分结果,然后作为事务提交流程的一部分进行验证。
还值得注意的是与事务 commit
操作的区别:取消 commit
会中止整个事务,除非事务已提交或因其他原因而失败。
性能
本部分介绍了会影响读写事务性能的问题。
锁定并发控制
Spanner 允许多个客户端同时与同一个数据库进行互动。为了在这些并发事务中保持数据一致性,Spanner 具有一种同时使用共享锁定和独占锁定的锁定机制。
当事务执行读取操作时,Spanner 会获取相关数据的共享读取锁定。这些共享锁定允许其他并发读取操作访问同一数据。此并发会一直保持,直到事务准备提交更改。
在提交阶段,随着写入的应用,事务会尝试将其锁定升级为独占锁定。为此,它会执行以下操作:
- 阻止对受影响的数据发出任何新的共享读取锁定请求。
- 等待释放对该数据的所有现有共享读取锁定。
- 在清除所有共享读取锁定后,它会施加独占锁定,在写入期间授予其对数据的独占访问权限。
有关锁定的注意事项:
- 粒度:Spanner 会在行和列的粒度上应用锁定。这意味着,如果事务
T1
持有行albumid
的列A
的锁定,事务T2
仍然可以同时写入同一行albumid
的列B
而不会发生冲突。 - 不需要读取的写入:对于不需要读取的写入,Spanner 不需要独占锁定。而是使用写入者共享锁定。这是因为,对于不需要读取的写入,其应用顺序由提交时间戳决定,从而允许多个写入者可以同时对同一项进行操作而不会发生冲突。只有当您的事务首先读取要写入的数据时,才需要独占锁定。
- 用于行查询的二级索引:在读写事务内执行行查询时,使用二级索引可以显著提高性能。通过使用二级索引将扫描的行限制在较小的范围内,Spanner 锁定表中较少的行,从而允许对该特定范围之外的行进行更大的并发修改。
- 外部资源独占访问:Spanner 的内部锁定旨在确保 Spanner 数据库本身的数据一致性。请勿使用它们来确保对 Spanner 外部的资源进行独占访问。Spanner 可能会出于多种原因而取消事务,包括内部系统优化(例如跨计算资源的数据移动)。如果重试事务(无论是通过应用代码明确进行,还是通过 Spanner JDBC 驱动程序等客户端库隐式进行),则只能保证在成功提交的尝试期间持有锁定。
- 锁定统计信息:如需诊断和调查数据库内的锁定冲突,您可以使用锁定统计信息内省工具。
死锁检测
Spanner 会检测多项事务可能导致死锁的情况,并强制除一项事务以外的所有其他事务取消。设想以下场景:Txn1
持有记录 A
的锁定,并正在等待记录 B
的锁定,而 Txn2
持有记录 B
的锁定并正在等待记录 A
的锁定。为了解决此问题,必须取消其中一个事务,释放其锁定,并允许另一个事务继续进行。
Spanner 使用标准的“受伤-等待”算法来检测死锁。在后台,Spanner 会跟踪请求冲突锁定的每个事务的存在时间,并允许较早的事务取消较晚的事务。较早的事务是指最早的读取、查询或提交发生的时间更早的事务。
通过优先处理较早的事务,Spanner 可确保每项事务最终都有机会获取锁定,只要其存在时间足够长,使其优先级高于其他事务。例如,需要写入者共享锁定的较早事务可以取消持有读取者共享锁定的较晚事务。
分布式执行
Spanner 可以对跨多台服务器的数据执行事务,但与单服务器事务相比,此功能需要付出性能代价。
哪些类型的事务可能是分布式的?Spanner 可以将数据库行的责任分摊到多个服务器。通常,某行及其对应的交错表行由同一服务器处理,就像同一个表中键邻近的两行。Spanner 可以跨不同服务器上的行执行事务。然而,一般来说,相比那些涉及分散在整个数据库或大型表中的许多行的事务,只涉及相邻的许多行的事务通常执行速度更快、开销更小。
Spanner 中最高效的事务仅包括应以原子方式应用的读取和写入。当所有读取和写入操作均访问位于键空间同一部分的数据时,事务处理速度最快。
只读事务
除了锁定读写事务外,Spanner 还提供只读事务。
当您需要在同一时间戳执行多项读取时,请使用只读事务。如果您可以使用 Spanner 的某个单次读取方法来表达您的读取,则应该使用该单次读取方法。使用这种单次读取调用的性能应该与在只读事务中执行单次读取的性能相当。
如果要读取大量数据,请考虑使用分区来并行读取数据。
由于只读事务不会写入,它们不会持有锁定,也不会阻止其他事务。只读事务会观察到一致的事务提交历史记录前缀,因此您的应用始终可获得一致的数据。
接口
Spanner 提供了一个接口,用于在只读事务的情境中执行操作,并在事务中止的情况下重试。
示例
以下示例展示了如何使用只读事务,为同一时间戳的两次读取获取一致的数据:
C++
C#
Go
Java
Node.js
PHP
Python
Ruby
语义
本部分介绍了只读事务的语义。
快照只读事务
当只读事务在 Spanner 中执行时,它会在单个逻辑时间点执行所有读取。这意味着,只读事务和任何其他并发读取者和写入者都会在特定时刻看到数据库的一致快照。
与锁定读写事务相比,这些快照只读事务为一致读取提供了更简单的方法。原因如下:
- 无锁:只读事务不会获取锁定。相反,它们通过选择 Spanner 时间戳,并针对该数据的历史版本执行所有读取来运行。由于它们不会使用锁定,因此不会阻止并发读写事务。
- 无取消:这些事务永远不会取消。虽然如果其选择的读取时间戳被垃圾回收,它们可能会失败,但 Spanner 的默认垃圾回收策略通常足够宽松,因此大多数应用都不会遇到此问题。
- 无提交或回滚:只读事务不需要调用
sessions.commit
或sessions.rollback
,并且实际上会被阻止这样做。
为了执行快照事务,客户端会定义时间戳边界,以指示 Spanner 如何选择读取时间戳。时间戳边界有以下几种类型:
- 强读:这些读取可保证您在读取开始之前看到已提交的所有事务的影响。单次读取内的所有行都是一致的。不过,强读不可重复,尽管强读确实会返回时间戳,并且在同一时间戳再次读取是可重复的。由于并发写入,两个连续强只读事务可能会产生不同的结果。对变更数据流的查询必须使用此边界。如需了解详情,请参阅 TransactionOptions.ReadOnly.strong。
- 精确过时:此选项会在您指定的时间戳(以绝对时间戳或相对于当前时间的过时时长表示)执行读取。它可确保您观察到该时间戳之前的全局事务历史记录的前缀一致,并阻止可能以小于或等于读取时间戳的时间戳提交的冲突事务。虽然比有界限过时模式稍快,但它可能会返回较旧的数据。如需了解详情,请参阅 TransactionOptions.ReadOnly.read_timestamp 和 TransactionOptions.ReadOnly.exact_staleness。
- 有界限过时:Spanner 在用户定义的过时限制范围内选择最新的时间戳,允许在最近的可用副本上执行操作而不会阻塞。返回的所有行都是一致的。与强读取一样,有界限过时不可重复,因为不同的读取即使使用相同的边界,也可能会在不同的时间戳执行。这些读取分为两个阶段(先进行时间戳协商,然后进行读取),通常比精确过时读取稍慢,但它们通常会返回更新的结果,并且更有可能在本地副本上执行。此模式仅适用于一次性只读事务,因为时间戳协商需要事先知道要读取哪些行。如需了解详情,请参阅 TransactionOptions.ReadOnly.max_staleness 和 TransactionOptions.ReadOnly.min_read_timestamp。
分区 DML 事务
您可以使用分区 DML 执行大量 UPDATE
和 DELETE
语句,并且不会遇到事务限制或锁定整个表。Spanner 通过对键空间进行分区,并在单独的读写事务中对每个分区执行 DML 语句来实现这一点。
如需使用非分区 DML,您可以在代码中明确创建的读写事务中执行语句。如需了解详情,请参阅使用 DML。
接口
Spanner 提供了 TransactionOptions.partitionedDml 接口,用于执行单个分区 DML 语句。
示例
以下代码示例更新 Albums
表的 MarketingBudget
列。
C++
您可以使用 ExecutePartitionedDml()
函数来执行分区 DML 语句。
C#
您可以使用 ExecutePartitionedUpdateAsync()
方法来执行分区 DML 语句。
Go
您可以使用 PartitionedUpdate()
方法来执行分区 DML 语句。
Java
您可以使用 executePartitionedUpdate()
方法来执行分区 DML 语句。
Node.js
您可以使用 runPartitionedUpdate()
方法来执行分区 DML 语句。
PHP
您可以使用 executePartitionedUpdate()
方法来执行分区 DML 语句。
Python
您可以使用 execute_partitioned_dml()
方法来执行分区 DML 语句。
Ruby
您可以使用 execute_partitioned_update()
方法来执行分区 DML 语句。
以下代码示例根据 SingerId
列从 Singers
表中删除行。
C++
C#
Go
Java
Node.js
PHP
Python
Ruby
语义
本部分介绍了分区 DML 的语义。
了解分区 DML 执行
无论是使用客户端库方法还是使用 Google Cloud CLI,一次只能执行一个分区 DML 语句。
分区事务不支持提交或回滚。Spanner 会立即执行并应用 DML 语句。如果您取消操作或操作失败,Spanner 将取消所有正在执行的分区,并且不会启动其余任何分区。不过,Spanner 不会回滚已执行的任何分区。
分区 DML 锁定获取策略
为减少锁定争用,分区 DML 仅对与 WHERE
子句匹配的行获取读取锁定。针对每个分区使用的较小的独立事务也会占用更少的时间来持有锁定。
会话事务限制
Spanner 中的每个会话一次只能有一个活跃事务。其中包括独立读取和查询,它们在内部使用事务并计入此限制。完成一项事务后,会话可以立即重新用于下一项事务;无需为每项事务创建新的会话。
旧读取时间戳和版本垃圾回收
Spanner 执行版本垃圾回收,以收集已删除或已覆盖的数据并回收存储空间。默认情况下,系统会回收超过一小时的数据。Spanner 无法在配置的 VERSION_RETENTION_PERIOD
之前的时间戳执行读取,默认值为 1 小时,但最多可配置为 1 周。如果读取在执行期间变得过旧,则会失败并返回 FAILED_PRECONDITION
错误。
对变更数据流的查询
变更数据流是一种架构对象,您可以将其配置为监控整个数据库、特定表或数据库内定义的一组列中的数据修改情况。
当您创建变更数据流时,Spanner 会定义一个对应的 SQL 表值函数 (TVF)。您可以使用此 TVF 通过 sessions.executeStreamingSql
方法查询关联变更数据流中的更改记录。TVF 的名称是根据变更数据流的名称生成的,并且始终以 READ_
开头。
对变更数据流 TVF 的所有查询都必须在具有强烈只读 timestamp_bound
的一次性只读事务中使用 sessions.executeStreamingSql
API 执行。借助变更数据流 TVF,您可以为时间范围指定 start_timestamp
和 end_timestamp
。您可以使用此强只读 timestamp_bound
访问保留期限内的所有变更记录。所有其他 TransactionOptions
对变更数据流查询都无效。
此外,如果 TransactionOptions.read_only.return_read_timestamp
设置为 true
,则描述事务的 Transaction
消息会返回 2^63 - 2
的特殊值,而不是有效的读取时间戳。您应舍弃此特殊值,不要将其用于任何后续查询。
如需了解详情,请参阅变更数据流查询工作流。
空闲事务
如果事务没有未完成的读取或 SQL 查询,并且在过去 10 秒内未启动,则会视为空闲。Spanner 可以取消空闲事务,以防止它们无限期地持有锁定。如果空闲事务已中止,则提交会失败并返回 ABORTED
错误。在事务内定期执行小型查询(例如 SELECT 1
)可以防止其变为空闲状态。