共计 5317 个字符,预计需要花费 14 分钟才能阅读完成。
事务隔离级别对于最高性能的影响
SQL 数据库无法同时处理多个连接,因为这会严重影响系统的性能。我们期望数据库能够同时接受多个调用者的请求,并尽可能快地执行这些请求。当调用者请求不同的数据时,例如第一个调用者从表 1 读取数据,而第二个调用者从表 2 读取数据,我们可以很容易地处理这种情况。然而,很多时候,不同的调用者希望对同一张表进行读写操作。那么我们应该如何处理这些查询?操作的顺序和最终结果应该是什么样的?这就是事务隔离级别发挥作用的地方。
事务是一组查询(例如 SELECT、INSERT、UPDATE、DELETE)发送到数据库以执行的操作,它们应该作为一个工作单元完成。这意味着它们要么全部执行,要么全部不执行。执行事务需要时间。例如,一条 UPDATE 语句可能会修改多行数据。数据库系统需要修改每一行,这需要时间。在执行更新操作的同时,可能会有另一个事务开始并尝试读取当前正在被修改的行。在这里,我们可以问一个问题:另一个事务应该读取行的新值(即使其中有些行尚未更新),还是应该读取行的旧值(即使其中一些行已经更新),或者另一个事务是否应该等待?如果第一个事务以后需要取消,那么另一个事务应该怎么处理?
事务隔离级别决定了我们如何确保事务之间的数据一致性。它们决定了事务应该如何执行、何时应该等待以及允许出现哪些异常情况。为了提高系统的性能,我们可能希望允许某些异常情况的出现。
读取现象
根据我们在数据库中控制并发性的方式,可能会出现不同的读取现象。标准的 SQL 92 定义了三种读取现象,描述了在没有进行事务隔离时两个事务并发执行时可能发生的各种问题。
我们将使用以下 People 表来举例说明:
ID | NAME | SALARY |
---|---|---|
1 | John | 150 |
2 | Jack | 200 |
脏读
当两个事务访问相同的数据,并且我们允许读取尚未提交的值时,可能会出现脏读。假设我们有两个事务分别执行以下操作:
事务 1 | 事务 2 |
---|---|
UPDATE People SET salary = 180 WHERE id = 1 | |
SELECT salary FROM People WHERE id = 1 | |
ROLLBACK |
事务 2 修改了 id 为 1 的行,然后事务 1 读取该行并获取到一个值为 180 的结果,接着事务 2 回滚了操作。
事实上,事务 1 使用了数据库中不存在的值。在这里,我们期望事务 1 使用在某个时间点上成功提交的值。
重复读
当一个事务对同一数据进行两次读取,并且每次得到的结果都不同,则存在可重复读问题。假设两个事务按照以下方式执行:
事务 1 | 事务 2 |
---|---|
SELECT salary FROM People WHERE id = 1 | |
UPDATE People SET salary = 180 WHERE id = 1 | |
COMMIT | |
SELECT salary FROM People WHERE id = 1 |
事务 1 读取一行并得到一个值为 150 的结果。事务 2 修改了同一行。然后事务 1 再次读取该行,得到了一个不同的值(这次是 180)。
在这里,我们期望能够两次读取到相同的值。
幻读
当一个事务两次以相同的方式查找行,但得到的结果却不同,则出现了幻读。考虑以下情况:
事务 1 | 事务 2 |
---|---|
SELECT * FROM People WHERE salary | |
INSERT INTO People(id, name, salary) VALUES (3, Jacob, 120) | |
COMMIT | |
SELECT * FROM People WHERE salary |
事务 1 读取行,并发现有两行满足条件。事务 2 插入了符合事务 1 使用的条件的另一行。
当事务 1 再次读取时,它得到了不同的行集。我们希望在事务 1 的两个 SELECT 语句中获取相同的行集。
隔离级别
SQL 92 标准定义了几种隔离级别,用于定义可能发生的读取现象。标准定义了四个常见的级别:READ UNCOMMITTED(未提交读)、READ COMMITTED(已提交读)、REPEATABLE READ(可重复读)和 SERIALIZABLE(串行化)。
READ UNCOMMITTED 允许事务读取尚未提交到数据库的数据。这样可以获得最高的性能,但也会导致最不希望出现的读取现象。
READ COMMITTED 允许事务只读取已提交的数据。这避免了读取“后来消失”的数据的问题,但不能保护其免受其他读取现象的影响。
REPEATABLE READ 级别试图避免多次读取相同数据时得到不同结果的问题。
最后,SERIALIZABLE 级别试图避免所有读取现象。
下表显示了允许出现哪些现象:
隔离级别 | 脏读(DIRTY READ) | 重复读(REPEATABLE READ) | 幻读(PHANTOM) |
---|---|---|---|
READ UNCOMMITTED | 允许 | 允许 | 允许 |
READ COMMITTED | 不允许 | 允许 | 允许 |
REPEATABLE READ | 不允许 | 不允许 | 允许 |
SERIALIZABLE | 不允许 | 不允许 | 不允许 |
隔离级别是针对每个事务定义的。例如,一个事务可以使用 SERIALIZABLE 级别运行,而另一个事务可以使用 READ UNCOMMITTED 级别运行。
内部工作原理
数据库需要实现机制来保证不存在特定的读取现象。一般有两种解决方案:悲观锁和乐观锁。
悲观锁
第一种方法称为悲观锁。在这种方法中,我们希望通过确保事务不会引入问题性更改来避免问题。我们通过锁定数据库的特定部分来实现这一点。当某个部分被一个事务锁定时,另一个事务根据事务隔离级别不能读取或写入数据,以避免问题发生。
数据库中有各种级别的锁:可以存储在行级、页级(对于本文而言,我们将其视为一组行)、表级和整个数据库级别上。还有各种类型的锁:用于读取的锁、用于写入的锁、可以在事务之间共享的锁或不能共享的锁、意图锁等等。本文主要关注通用的 SQL 数据库,因此不会详细介绍实际实现的细节。
从概念上讲,为了避免特定的读取现象,事务需要以一种方式锁定数据库的特定部分,以确保其他事务不会引入导致特定类型的读取现象的更改。例如,为了避免脏读,我们需要锁定所有修改或读取的行,以防其他事务读取或修改它们。
这种方法有多个优点。首先,它可以实现对可以修改的内容进行细粒度控制,并确定哪些事务可以安全地继续执行。其次,在多个事务同时处理不同数据时,它能够良好地扩展并且带来较低的开销。第三,事务不需要回滚。
然而,这可能会显著降低性能。例如,如果两个事务想要读取和修改同一张表中的数据,并且这两个事务都使用 SERIALIZABLE 级别运行,那么它们将需要等待对方完成。即使它们操作表中的不同行。
大多数数据库管理系统都使用这种方式。例如,MS SQL 在其四个主要隔离级别中使用了该方法。
乐观锁
另一种方法称为乐观锁。这种方法也被称为快照隔离或多版本并发控制(MVCC)。表中的每个实体都有一个与之关联的版本号。当我们修改一行时,还会增加其行版本,以便其他事务可以观察到它的变化。
当事务开始时,它记录版本号以了解行的状态。在从表中读取时,它只提取在事务开始之前已经修改的行。接下来,当事务修改数据并试图将其提交到数据库时,数据库会验证行的版本。如果在此期间行被其他事务修改过,则更新被拒绝,事务必须从头开始。
在事务涉及不同行时,这种方法效果很好,因为它们可以无问题地提交。这样可以实现更好的扩展性和更高的性能,因为事务不需要获取锁。然而,当事务经常修改相同的行时,其中一些事务将经常需要回滚,这会导致性能下降。另一个缺点是需要保留行版本,这增加了数据库系统的复杂性。
各种数据库管理系统使用这种方法。例如,启用了快照隔离的 Oracle 或 MS SQL。
实际考虑事项
虽然隔离级别似乎定义得很好,但有各种微小的细节会影响数据库系统的内部工作方式。让我们看看其中一些细节。
隔离级别不是强制的
虽然 SQL 92 标准定义了多个隔离级别,但它们并非强制要求。这意味着在给定的数据库管理系统中,所有级别都可以被实现为 SERIALIZABLE。我们使用其他隔离级别来提高性能,但并没有以任何方式强制执行。这意味着如果我们依赖于特定优化在一个数据库管理系统中发生,相同的优化可能在另一个数据库管理系统中不会被使用。我们不应该依赖于具体实现的细节,而应坚持使用标准。
默认隔离级别没有标准化
默认隔离级别是按事务配置的。这通常由您用于连接到数据库的库或连接技术所决定。根据默认设置,您可能在不同的隔离级别上操作,并且这可能导致不同的结果或不同的性能。典型的库使用 SERIALIZABLE 或 READ COMMITTED 级别。
READ COMMITTED 存在问题
虽然 READ COMMITTED 保证事务只读取已提交的数据,但它不能保证读取的数据是最新的。有可能它读取了过去某个时间点已提交的值,但后来被另一个事务覆盖。
READ COMMITTED 级别还存在另一个问题。由于实体在内部存储方式的原因,可能导致事务两次读取同一行或跳过该行。让我们看看为什么会出现这个问题。
典型的数据库管理系统以有序方式在表中存储行,通常使用表的主键在 B 树上进行排序。这是因为主键通常会强制实施聚簇索引,导致数据在磁盘上物理排序。现在 READ COMMITTED 级别下,当一个事务正在读取一张表时,如果另一个事务同时向该表插入新的行,那么读取操作可能会跳过刚刚插入的行,或者重复读取之前已经读过的行。这就是所谓的“幻读”现象。
为了解决幻读问题,数据库管理系统通常使用一种叫做“间隙锁”的机制。当一个事务开始读取某个范围内的数据时,它会对该范围内的间隙(即尚未存在的行)进行加锁,以阻止其他事务在这些间隙中插入新的行。这样可以保证在该事务读取期间,不会有新的行插入到被读取的范围内。
然而,间隙锁也会带来一些开销。首先,它可能导致并发性能下降,因为多个事务无法同时插入新的行。其次,由于间隙锁的存在,可能导致死锁的情况出现,需要进行额外的处理来解决。
因此,在实际应用中,我们需要根据具体的需求和性能要求来选择合适的隔离级别。如果需要避免脏读、不可重复读和幻读等读取现象,并且可以容忍一定的性能损失和并发性能下降,可以选择较高的隔离级别,如 REPEATABLE READ 或 SERIALIZABLE。如果对实时性要求较高,且可以容忍一些读取现象的出现,可以选择较低的隔离级别,如 READ COMMITTED。
需要注意的是,隔离级别只是保证了特定类型的读取现象不会发生,但并不能解决所有并发问题。在设计数据库应用程序时,还需要考虑其他并发控制机制,如锁粒度、事务的执行顺序、死锁检测和处理等,以确保系统的正确性和性能。
基于同样的想法,我们可能会错过一排。假设我们总共有 10 行,并且我们已经读取了从 1 到 4 的行。接下来,另一个事务更改 id 为 10 的行,并将其 id 设置为 3。由于订单原因,我们找不到这一行。
白色和黑色弹珠问题
我们提到了两种不同的锁实现方式。悲观锁会锁定行,并在行被锁定时禁止其他事务对其进行修改。乐观锁会存储行版本,并允许其他事务在它们使用最新数据的情况下继续进行。
当使用乐观锁实现 SERIALIZABLE 级别时,还存在一个问题,即所谓的白色和黑色弹珠问题。假设我们有以下 Marbles 表:
ID | COLOR | ROW_VERSION |
---|---|---|
1 | black | 1 |
2 | white | 1 |
现在,假设我们想要运行两个事务。第一个尝试将所有黑色石头变成白色,另一个尝试相反 - 将所有白色变成黑色。我们有以下内容:
事务 1 | 事务 2 |
---|---|
UPDATE Marbles SET color = ‘white’ WHERE color = ‘black’ | UPDATE Marbles SET color = ‘black’ WHERE color = ‘white’ |
如果我们使用悲观锁来实现 SERIALIZABLE,典型的实现方式将锁定整个表。运行这两个事务后,我们最终会得到两个黑色的石头(如果我们先执行事务 1,然后执行事务 2),或者两个白色的石头(如果我们先执行事务 2,然后执行事务 1)。
然而,如果我们使用乐观锁,我们将得到以下结果:
ID | COLOR | ROW_VERSION |
---|---|---|
1 | white | 2 |
2 | black | 2 |
由于这两个事务涉及不同的行集,它们可以并行运行。这导致了一个意外的结果。
现在该怎么办?
我们学习了事务隔离级别是如何工作的。现在,我们可以使用它们来提高性能。为此,我们需要了解在数据库中执行的 SQL 查询以及它们对性能的影响。最简单的方法之一是使用 Metis Observability 仪表板:
Metis Observability 仪表板:metisdata.io/metis-demo-dashboard
Metis 仪表板可以显示有关执行的所有查询的见解,以及通过更改数据库配置来改善其性能的方法。这样,我们可以看到是否获得了预期的结果,并使用正确的隔离级别。 文章来源:https://www.toymoban.com/diary/system/514.html
总结
在本文中,我们了解了事务隔离级别以及它们如何允许不同的读取现象。我们还了解了它们在数据库系统中的概念实现方式以及它们可能导致意外结果的情况。 文章来源地址 https://www.toymoban.com/diary/system/514.html
到此这篇关于如何通过事务隔离级别实现最佳性能的文章就介绍到这了, 更多相关内容可以在右上角搜索或继续浏览下面的相关文章,希望大家以后多多支持 TOY 模板网!