【数据库】关于数据库事务ACID的理解笔记

本文深入探讨了数据库事务的ACID特性,包括原子性、一致性、隔离性和持久性。强调了不同数据库对这些特性的实现可能存在的差异,以及一致性与隔离性在实现上的挑战。同时,讨论了锁、数据异常、排序保证、分片、自动递增、过时数据和延迟的影响。此外,还提到了嵌套事务的风险和事务性能要求,以及在线迁移和数据库增长的不可预测性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

ACID的含义

ACID 表示原子性(atomicity)、一致性(consistency)、隔离性(isolation)、持久性(durability)。ACID 是数据库事务(database transaction)需要向用户确保有效的属性——即使在出现崩溃、错误、硬件故障等情况时也需要保证这些属性。如果没有 ACID 或类似的保证,应用开发者将难以区分他们自己的职责与数据库能够提供的保证。大多数关系事务数据库都会尽力符合 ACID 指标,但 NoSQL 等新方法催生了许多没有 ACID 事务的数据库,这些这些事务的实现成本比较高。

并非每个数据库都符合 ACID,而在符合 ACID 的数据库中,ACID 的解读方式也可能不同。==为什么 ACID 会有不同的实现方式?==一个原因是在实现 ACID 时,需要权衡的东西太多了。数据库在做广告宣传时可能会说自己符合 ACID,但在许多边缘案例上仍可能有不同的解释或在处理不太可能发生的事件时的方法不同。为了适当地理解故障模式和设计权衡,开发者至少可以在高层面上了解数据库实现各项功能的方式。

不同的一致性和隔离性

在 ACID 属性中,一致性和隔离性的不同实现细节的范围是最广的,因为其涉及的权衡因素更多。一致性和隔离性都是实现成本较高的属性。为了保持数据一致,它们需要协调而且正得到越来越多的讨论。当必须以水平方式扩展数据中心时(尤其是对于不同的地区),这些问题会变得更加困难。因为此时可用性会下降且网络分区会越来越普遍,这会导致很难实现高层面的一致性。CAP 定理为这一现象给出了更普适的解释。需要指出的是,即使有一些不一致性,一般应用也能处理,或者程序开发者对这一问题有足够的认知,让他们能为该应用添加用于处理这一情况的逻辑,从而无需过于依赖他们的数据库

数据库往往会提供多种不同的隔离层,这样应用开发者就可以基于自己的权衡策略来选择最具成本效益的。当隔离更弱时,速度可能更快,但也可能导致数据竞争(data race)。当隔离更强时,不会出现某些潜在的数据竞争,但速度会更慢,而且还可能出现争用(contention)情况,这甚至可能将数据库的速度拖慢到中断的程度。
现有并发模型及它们之间的关系概况
SQL 标准仅定义了 4 种隔离层级,但理论上和实践中的层级都更多。jepson.io 总结了现有并发模型的情况。举个例子,谷歌的 Spanner 使用了时钟同步来保证外部可串行化,即使这是一种更严格的隔离层,但标准隔离层中却并没有这样的定义。

SQL 标准中提及的隔离层级包括:

  • 可串行化(最严格,成本最高):可串行化执行(serializable execution)得到的效果与这些事务的某些序列执行的效果一样。序列执行(serial execution)是指在每个事务执行完成之后再执行下一个事务。关于可串行化执行,需要注意的一点是:由于解释的差异性,它往往被实现为快照隔离(snapshot isolation),比如 Oracle,而快照隔离并不在 SQL 标准中。
  • 可重复的读取:当前事务中未提交的读取对当前事务来说是可见的,但其它事务做出的改变(比如新插入的行)不是可见的
  • 已提交的读取:未提交的读取对事务来说不可见。只有已提交的写入是可见的,但可能出现幻象读取(phantom read)。如果另一个事务插入和提交了新的行,则当前事务在查询时可以看到它们。
  • 未提交的读取(最不严格,成本最低)允许脏读(dirty read)事务可以看到其它事务做出的尚未提交的更改。在实践中,这个层级可用于返回近似聚合结果,比如对一个表格的 COUNT(*) 查询。

可串行化层级出现数据竞争的情况最少,但成本也最高,而且会让系统出现最多争用。其它隔离层级的成本更低一些,但也更可能出现数据竞争问题。某些数据库允许自行设置隔离层级,某些数据库则在这方面更为固执一点,并不一定支持所有这些层级。

相关的锁

锁的成本非常高,不仅是因为它们会为数据库引入更多争用,而且还需要你的应用服务器与数据库之间存在一致的连接。网络分区可能会更显著地影响排它锁(exclusive lock),这会导致难以识别和解决的死锁(deadlock)。如果有些案例无法很好地使用排它锁,可以选择乐观锁(optimistic locking)

乐观锁这种方法是指当读取某行时会记录版本号、上次修改的时间戳或其校验和(checksum)。然后可以在更改记录之前检查原子方面并无修改的版本。

UPDATE products
SET name = 'Telegraph receiver', version = 2
WHERE id = 1 AND version = 1

如果另一项更新之前已经修改了这一行,那么对 products 表的更新将影响 0 行。如果没有更早的更新,则它会影响 1 行,则我们可以说更新成功了。

奇怪的异常

当在探讨数据一致性时,主要关注的是可能导致脏读和数据丢失的竞争问题。但数据方面的异常并不止这两种。

举个例子,还有一种异常是写偏序(write skew)。写偏序更难以识别认定,因为我们不会主动地去查找这个问题。导致写偏序的原因不是发生在写入上的脏读或数据丢失,而是因为数据上的逻辑约束损坏

比如,假设一个监控应用需要一个人类操作员始终处于待命状态。

BEGIN tx1;                      BEGIN tx2;SELECT COUNT(*)
FROM operators
WHERE oncall = true;
0                               SELECT COUNT(*)
                                FROM operators
                                WHERE oncall = TRUE;
                                0UPDATE operators                UPDATE operators
SET oncall = TRUE               SET oncall = TRUE
WHERE userId = 4;               WHERE userId = 2;COMMIT tx1;                     COMMIT tx2;

在上面的情况中,如果这些事务中有两个成功提交,就会出现写偏序。即使此时没有出现脏读或数据丢失,数据也失去了完整性,因为其指定了两个待命的人。???(未理解

可串行化隔离、模式设计或数据库约束有助于消除写偏序。开发者需要在开发过程中识别这样的异常,以避免生产过程中出现数据异常。话虽如此,识别代码库中的写偏序却非常之难。尤其是在大型系统中,如果负责基于同一表格构建功能的不同团队之间没有沟通且没有互相检查他们存取数据的方式,那么就会出现这种问题。

排序保证

数据库提供的一大核心能力是排序保证,但排序结果可能会出乎应用开发者的预料。数据库查阅事务的顺序就是它们接收这些事务的顺序,而不是开发者查看它们时的程序设计顺序。事务执行的顺序难以预测,尤其是在高容量的并发系统中。

==在开发时,尤其是在使用非阻塞软件库进行开发时,较差的样式和可读性可能会导致用户认为事务是按顺序执行的,即使它们可能以任何顺序抵达数据库。==下面的程序看起来像是 T1 和 T2 将按顺序调用,但如果这些函数是非阻塞的,则它们将立即带着 promise 返回,调用的顺序将取决于它们在数据库中接收到的时间。

result1 = T1() // results are actually promises
result2 = T2()

如果需要原子性(以便完全提交或放弃所有操作)且序列很重要,则 T1 和 T2 中的操作应该运行在单个数据库事务中

分片

分片(Sharding)是一种水平划分数据库的方法。有的数据库可以自动地对数据进行水平分区,有的数据库则不支持这种功能或做得不好。当数据架构师 / 开发者可以预测访问数据的方式时,他们可能会在用户区域创建水平分区,而不是将这项工作委托给他们的数据库。这种方式称为应用级分片(application-level sharding)。应用层面的分片可以存在于该应用之外。

应用级分片这个名称往往会给人带来一种错误印象,让人以为这种分片应该存在于应用服务之中。分片功能可以实现为数据库的前面一层。取决于数据增长和架构迭代情况,分片的要求可能会变得非常复杂。如果能在无需重新部署应用服务器的前提下对某些策略进行迭代,则会大有裨益。
应用服务器与分片服务分离的架构示例
如果将分片作为一个单独的服务,你就能更好地在不重新部署应用服务器的前提下迭代分片策略。Vitess 就是应用级分片系统的一个例子。Vitess 为 MySQL 提供了水平分片,并允许客户端通过 MySQL 协议连接它;Vitess 会将数据分片到多个互相之间无联系的 MySQL 节点上。

自动递增AUTOINCREMENT

AUTOINCREMENT(自动递增)是生成主键(primary key)的一种常用方法。数据库被用作 ID 生成器以及数据库中有 ID 生成指定表格的情况其实并不少见。但使用自动递增生成主键的方式其实并不理想,原因有几点:

  • 分布式数据库系统中,自动递增很困难。为了生成 ID,需要使用全局锁才行。而如果你可以生成 UUID,那么就不需要数据库节点之间有任何合作。**使用锁的自动递增可能导致争用,并可能导致分布式情况中插入性能显著下降。**MySQL 等一些数据库可能需要特定的配置和更多的注意才能正确地完成 master-master 复制。这样的配置容易混乱而且可能导致写入中断。
  • 某些数据库有基于主键的分区算法。按顺序排布的 ID 可能导致无法预测的热点,从而使得某些分区过于繁忙,另一些则一直空闲。
  • 访问数据库中某行的最快方式是通过主键。如果你有更好的标识记录的方式,那么顺序 ID 可能会让表中最显著的列成为无意义的值。请尽可能地选择全局独一的自然主键(比如用户名)。

考虑自动递增 ID 与 UUID 对索引、分区和分片的影响,然后再决定哪种方式对自己而言最好。

过时的数据

过时的数据可能有用而且是无锁的。**多版本并发控制(MVCC)**能实现我们上面简要讨论过的很多一致性。Postgres 和 Spanner 等一些数据库使用 MVCC 以让每个事务都能看到一个快照,即该数据库的一个更旧版本。参照快照的事务仍然可以串行化以实现一致性。当读取一个旧快照时,实际读取的是过时的数据。但即使读取的是稍微过时的数据,也会很有用处,比如当在生成数据分析结果或计算近似聚合值时。

读取过时数据的第一大优势是延迟(尤其是当你的数据库分布在不同的地区时)。MVCC 数据库的第二大优势是其允许只读事务是无锁的。在需要大量读取的应用中,一个优势是用过时的数据也是可行的。
分布式数据库设计
即便太平洋另一端有某个数据的最新版本,但也可以从本地读取 5 秒前的过时副本。

数据库会自动清除旧版本,而在某些情况下,数据库也支持按需清理。举个例子,Postgres 允许用户按需执行 VACUUM 操作或每隔一段时间自动执行 VACUUM,而 Spanner 则是通过运行一个垃圾收集器来丢弃时间超过 1 小时的版本。

时钟偏移

在计算领域,隐藏得最好的秘密是所有时间 API 都在说谎。我们的机器并不能准确地知道当前的时间是多少。我们的计算机全都包含一个用以产生计时信号的石英晶体。但石英晶体并不能准确计时和计算时间偏移量,要么比实际时钟快,要么就更慢。一天的偏移量甚至可达 20 秒。为了准确,我们的计算机时间必须不时地与实际时间保持同步。

NTP 服务器可用于同步,但同步本身却可能由于网络的原因而出现延迟。与同一数据中心的 NTP 服务器同步况且需要时间,与公共 NTP 服务器同步更是可能产生更大的偏移。

原子钟和 GPS 时钟是更好的确定当前时间的信息源,但它们的部署成本更高,而且需要复杂的设置,不可能在每台机器上都安装。由于存在这些限制条件,数据中心通常使用的是多层方法。即在使用原子钟和 / 或 GPS 时钟提供准确计时的同时,再通过辅助服务器将时间信息广播给其它机器。这意味着所有机器都与实际的当前时间存在一定程度的偏移。

不仅如此,应用和数据库往往搭建在不同的机器中,甚至还可能位于不同的数据中心。因此,不仅分散在不同机器上的不同数据库节点之间无法统一时间,应用服务器时钟和数据库节点时钟也无法统一。

谷歌的 TrueTime 为此采用了一种不同的方法。大多数人认为谷歌在时钟上的成果可以归功于他们使用了原子钟和 GPS 时钟,但那其实仅仅是部分原因。TrueTime 实际上是这样工作的:

  • TrueTime 使用了两个不同的时间信号源:GPS 时钟和原子钟。这些时钟存在不同的故障模式,因此同时使用两者可以提升可靠性。
  • TrueTime 的 API 并不是常规型的。它会以区间的形式返回时间。因此实际时间事实上处于这个时间区间的上界和下界之间。因此,谷歌的分布式数据库 Spanner 就可以等到它确定了当前时间超过了特定时间之后才执行事务。这种方法会给系统带来一些延迟,尤其是当主机通告的不确定性很高时;但这种方法能保证正确性,即使数据库分布在全球也是如此。
    TrueTime时间同步原理
    使用 TrueTime 的 Spanner 组件,其中 TT.now() 会返回一个时间区间,这样 Spanner 就可以插入睡眠时间以确保当前时间已超过特定时间戳

当当前时间的置信度下降时,Spanner 执行操作可能会耗费更多时间。因此,即使不可能获得精准的时钟,保证时钟的置信度对性能而言也是非常重要的。

延迟的意义

在数据库中,延迟通常称为“数据库延迟”,而不是客户端感知的延迟。客户端将看到数据库延迟和网络延迟的延迟。在调试不断升级的问题时,能够识别客户端和数据库延迟至关重要。

事务交易的性能要求

有时,数据库会在写入和读取吞吐量以及延迟方面公布其性能特征和限制。尽管这可以从总体上概述主要的阻止因素,但是在评估新数据库的性能时,更全面的方法是分别评估关键操作(每个查询和/或每个事务)。例如:

  • 在具有给定约束的表X中插入新行(具有5000万行)并在相关表中填充行时,写入吞吐量和延迟。
  • 平均好友数为500时,查询用户好友的时延。
  • 当用户订阅了每小时X个条目的500个帐户时,检索用户时间轴的前100条记录的延迟。

在收集每个操作的指标时,请注意高基数。如果需要高基数调试数据,请使用日志,甚至收集或分布式跟踪。

嵌套事务可能的危害

并非每个数据库都支持嵌套事务,但是当嵌套数据库支持嵌套事务时,嵌套事务可能会导致令人惊讶的编程错误,这些错误通常很难被发现,除非清楚看到异常。

如果您想避免嵌套事务,则客户端库可以进行工作来检测和避免嵌套事务。如果无法避免,则必须注意避免出现意外情况,在这种情况下,已提交的事务由于子事务而意外中止。

将事务封装在不同的层中可能会导致令人惊讶的嵌套事务案例,并且从可读性的角度来看,可能很难理解其意图。例如:

with newTransaction():
    Accounts.create("609-543-222")
    with newTransaction():
        Accounts.create("775-988-322")
        throw Rollback();

上面代码的结果是什么?是要回滚这两个事务还是仅回滚内部事务?如果我们依赖封装了我们创建的事务的多层库,将会发生什么。我们是否能够识别和改善此类情况?

想象一个具有多个操作(例如newAccount)的数据层已经在它们自己的事务中实现了。当您在自己的事务中运行的高级业务逻辑中运行它们时,会发生什么?隔离和一致性特征将是什么?

function newAccount(id string) {
   with newTransaction():
       Accounts.create(id)
}

不要处理此类开放式问题,而应避免嵌套事务。数据层仍然可以执行高级操作,而无需创建自己的事务。然后,业务逻辑可以启动事务,在事务上运行操作,提交或中止。

function newAccount(id string) {
    Accounts.create(id)
}
// In main application:
with newTransaction():
    // Read some data from database for configuration.
    // Generate an ID from the ID service.
    Accounts.create(id)
    Uploads.create(id) // create upload queue for the user.

事务&应用程序状态

应用程序开发人员可能希望在事务中使用应用程序状态来更新某些值或调整查询参数。要考虑的一件关键事情是考虑作用域/范围界限(scope)。

发生网络问题时,客户通常会重试交易。如果事务依赖于在其他地方发生了变异的状态,则它可能会选择错误的值,具体取决于问题中数据争用的可能性。事务处理应谨慎对待应用程序内数据竞争。

var seq int64
with newTransaction():
     newSeq := atomic.Increment(&seq)
     Entries.query(newSeq)
     // Other operations...

上面的事务每次运行时都会增加序列号,无论最终结果如何。如果提交由于网络而失败,则在第二次重试时,它将使用不同的序列号进行查询。

因此,事务不应维持应用程序状态。

Query planner

Query planner确定如何在数据库中执行查询。他们还分析查询并在运行之前对其进行优化。planner只能根据其拥有的信号提供一些可能的估计。那么如何找到以下查询的结果:

SELECT * FROM articles where author = "rakyll" order by title;

有两种方法来检索结果:

  • 全表扫描:我们可以浏览表上的每个条目,并返回与作者姓名匹配的文章,然后进行排序。
  • 索引扫描:我们可以使用索引来查找匹配的ID,检索那些行然后进行排序。

Query planner的作用是确定哪种策略是最佳选择。

在线迁移

在线,实时或实时迁移意味着从一个数据库迁移到另一个数据库而不会造成停机,并且不会影响数据的正确性。如果要迁移到相同的数据库/引擎,则实时迁移会更容易,但是当迁移到具有不同性能特征和架构要求的新数据库时,实时迁移会变得更加复杂。

在线迁移有多种模式,以下是其中一种:

  • 开始对两个数据库进行双重写入。在此阶段,新数据库将不会拥有所有数据,但会开始查看新数据。一旦对这一步充满信心,就可以继续进行第二步。
  • 开始启用读取路径以同时使用两个数据库。
  • 新数据库主要用于读取和写入。
  • 尽管继续从旧数据库读取数据,但请不要继续写入旧数据库。此时,新数据库仍不具有所有新数据,您可能需要回退到旧数据库以获取旧记录。
  • 此时,旧数据库是只读的。用旧数据库中缺少的数据回填新数据库。迁移完成后,所有读写路径都可以使用新数据库,并且旧数据库可以从系统中删除。

数据库增长的不可预测性

随着数据增长,以前对数据大小和网络容量要求的假设或期望可能会过时。这是大型方案重写,大规模操作改进,容量问题,部署重新考虑或迁移到其他数据库以避免中断的时候。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

镰刀韭菜

看在我不断努力的份上,支持我吧

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值