5.1 同时性问题

让我们从一个遥远的理论开始。

程序员创建的任何信息系统(或简单地说,一个应用程序)都由几个典型的块组成,每个块都提供一部分必要的功能。例如,缓存用于记住资源密集型操作的结果以确保客户端更快地读取数据,流处理工具允许您将消息发送给其他组件进行异步处理,批处理工具用于“以一定的周期性“耙”累积的数据量。 .

在几乎每个应用程序中,数据库 (DB) 都以一种或另一种方式涉及,它们通常执行两个功能:从您那里收到数据时存储数据,然后根据请求提供给您。很少有人会想到创建自己的数据库,因为已经有很多现成的解决方案。但是如何为您的应用选择合适的呢?

所以,让我们假设您编写了一个带有移动界面的应用程序,允许您加载以前保存的房屋周围任务列表 - 即从数据库中读取,并用新任务对其进行补充,以及对每个特定任务进行优先级排序任务 - 从 1(最高)到 3(最低)。假设您的移动应用程序一次仅供一个人使用。但是现在你敢把你的创作告诉你妈妈,现在她已经成为第二个普通用户了。如果您在同一时间,就在同一毫秒内,决定将某项任务(“擦窗”)设置为不同的优先级,会发生什么情况?

用专业术语来说,你和妈妈的数据库查询可以被认为是对数据库进行查询的2个进程。进程是计算机程序中可以在一个或多个线程上运行的实体。通常,进程具有机器代码映像、内存、上下文和其他资源。换句话说,该过程可以表征为在处理器上执行程序指令。当您的应用程序向数据库发出请求时,我们谈论的是您的数据库处理通过网络从一个进程收到的请求这一事实。如果有两个用户同时坐在应用程序中,那么在任何特定时刻都可能有两个进程。

当某个进程向数据库发出请求时,它发现它处于某种状态。有状态系统是一种会记住以前的事件并存储一些信息的系统,这些信息称为“状态”。声明为 as 的变量integer可以具有 0、1、2 或 42 状态。互斥锁(互斥)有两种状态:锁定解锁,就像二进制信号量(“必需”与“已释放”)一样,通常是二进制(二进制)数据类型和变量只能有两种状态 - 1 或 0。

基于状态的概念,建立了几种数学和工程结构,例如有限自动机 - 一种具有一个输入和一个输出并且在每个时刻都处于有限状态集中之一的模型 - 以及“状态”设计模式,其中对象根据内部状态改变行为(例如,根据分配给一个或另一个变量的值)。

因此,机器世界中的大多数对象都有一些可以随时间变化的状态:我们的管道,它处理一个大数据包,抛出错误并变得失败,或者钱包对象属性,它存储用户的剩余金额帐户,工资收据后的变化。

从一种状态到另一种状态的转换(“转换”)——例如,从进行中失败——称为操作。可能每个人都知道CRUD操作- createreadupdatedelete类似的HTTP方法- POSTGETPUTDELETE。但是程序员经常在他们的代码中给操作起其他名字,因为操作可能比仅仅从数据库中读取某个值更复杂——它还可以检查数据,然后是我们的操作,它采取了函数的形式,将被称为,例如,validate()谁执行这些操作功能?已经描述的过程。

再多一点,你就会明白为什么我把这些术语描述得这么详细了!

任何操作——无论是函数,还是在分布式系统中向另一台服务器发送请求——都有 2 个属性:调用时间完成时间(completion time),这将严格大于调用时间(来自 Jepsen 的研究人员从理论假设出发,即这两个时间戳都将被赋予虚构的、完全同步的、全局可用的时钟)。

让我们想象一下我们的待办事项列表应用程序。你通过移动界面向数据库 in 发出请求14:00:00.014,你妈妈在13:59:59.678(即336毫秒之前)通过相同的界面更新了待办事项列表,将洗碗添加到其中。考虑到你的数据库的网络延迟和可能的任务队列,如果除了你和你妈妈,你妈妈的所有朋友都在使用你的应用程序,数据库处理完你的请求后可以执行妈妈的请求。也就是说,有可能你的两个请求,还有你妈妈女朋友的请求,会同时(并发)发送同一个数据。

于是我们就来到了数据库和分布式应用领域最重要的术语——并发。两个操作的同时性到底意味着什么?如果给出了一些操作 T1 和一些操作 T2,那么:

  • T1可以在T2的开始时间之前开始,在T2的开始和结束时间之间结束
  • T2可以在T1的开始时间之前开始,在T1的开始和结束之间完成
  • T1可以在T1执行的开始和结束时间之间开始和结束
  • 以及 T1 和 T2 具有共同执行时间的任何其他场景

很明显,在本讲的框架内,我们主要讨论进入数据库的查询以及数据库管理系统如何感知这些查询,但是术语并发很重要,例如,在操作系统的上下文中。我不会偏离本文的主题太远,但我认为重要的是要提一下,我们在这里谈论的并发与并发和并发的困境及其区别无关,这在上下文中讨论操作系统和高性能计算。并行是在具有多个内核、处理器或计算机的环境中实现并发的一种方法。我们在不同进程同时访问公共数据的意义上谈论并发。

事实上,纯理论上会出错的是什么?

在处理共享数据时,可能会出现许多与并发相关的问题,也称为“竞争条件”。第一个问题发生在进程收到它不应该收到的数据时:不完整的、临时的、已取消的或其他“不正确”的数据。第二个问题是当进程接收到陈旧数据时,即与数据库上次保存的状态不对应的数据。假设某个应用程序从余额为零的用户账户中取款,因为数据库将账户状态返回给应用程序,没有考虑最后一次取款,这发生在几毫秒前。情况是这样,不是吗?

5.2 交易来拯救我们

为了解决这类问题,出现了事务的概念——对数据库进行的某组顺序操作(状态变化),在逻辑上是一个单一的操作。我将再次举一个银行的例子——这不是偶然的,因为交易的概念显然恰好出现在与货币打交道的背景下。交易的经典例子是从一个银行账户向另一个银行账户转账:您需要先从源账户中提取金额,然后将其存入目标账​​户。

要执行此交易,应用程序需要在数据库中执行多项操作:检查发送方的余额、冻结发送方账户中的金额、将金额添加到接收方的账户以及从发送方扣除金额。这样的交易会有几个要求。例如,应用程序无法接收有关余额的过时或不正确的信息——例如,如果同时并行交易在中途以错误结束,资金未从账户中扣除——而我们的应用程序已经接收到信息资金被注销。

为了解决这个问题,调用了事务的“隔离”属性:我们的事务执行时就好像没有其他事务在同一时刻执行一样。我们的数据库执行并发操作,就好像它是一个接一个地按顺序执行它们——事实上,最高的隔离级别称为Strict Serializable。是的,最高的,也就是说有好几个等级。

“停下来,”你说。抓住你的马,先生。

让我们记住我是如何描述每个操作都有调用时间和执行时间的。为了方便起见,您可以考虑将调用和执行作为 2 个操作。那么所有调用和执行动作的排序列表就可以称为数据库的历史。那么事务隔离级别就是一组历史。我们使用隔离级别来确定哪些故事是“好”的。当我们说一个故事“破坏可序列化”或“不可序列化”时,我们的意思是该故事不在可序列化故事集中。

为了弄清楚我们在谈论什么样的故事,我将举例说明。比如有这么一种history- intermediate read。当允许事务 A 从已被另一个正在运行的事务 B 修改且尚未提交(“未提交”)的行中读取数据时发生 - 也就是说,实际上,更改尚未最终提交事务 B,它可以随时取消它们。而且,例如,中止读取只是我们取消取款交易的示例

有几种可能的异常。也就是说,异常是在对数据库的竞争访问期间可能发生的某种不良数据状态。并且为了避免某些不需要的状态,数据库使用不同级别的隔离 - 即针对不需要的状态的不同级别的数据保护。这些级别(4 个)列在 ANSI SQL-92 标准中。

对一些研究人员来说,这些级别的描述似乎含糊不清,他们提供了自己的、更详细的分类。我建议您关注已经提到的 Jepsen,以及 Hermitage 项目,该项目旨在准确阐明特定 DBMS(例如 MySQL 或 PostgreSQL)提供的隔离级别。如果你打开这个存储库中的文件,你可以看到他们使用什么样的 SQL 命令序列来测试数据库的某些异常,你可以对你感兴趣的数据库做类似的事情)。这是存储库中的一个示例,可让您感兴趣:

-- Database: MySQL

-- Setup before test
create table test (id int primary key, value int) engine=innodb;
insert into test (id, value) values (1, 10), (2, 20);

-- Test the "read uncommited" isolation level on the "Intermediate Reads" (G1b) anomaly
set session transaction isolation level read uncommitted; begin; -- T1
set session transaction isolation level read uncommitted; begin; -- T2
update test set value = 101 where id = 1; -- T1
select * from test; -- T2. Shows 1 => 101
update test set value = 11 where id = 1; -- T1
commit; -- T1
select * from test; -- T2. Now shows 1 => 11
commit; -- T2

-- Result: doesn't prevent G1b

重要的是要了解,对于同一个数据库,通常您可以选择几种隔离类型中的一种。为什么不选择绝缘最强的呢?因为,就像计算机科学中的一切一样,选择的隔离级别应该对应于我们准备做出的权衡——在这种情况下,执行速度的权衡:隔离级别越强,请求将越慢处理。要了解您需要什么级别的隔离,您需要了解您的应用程序的要求,并了解您选择的数据库是否提供此级别,您将必须查看文档 - 对于大多数应用程序来说这就足够了,但是如果你有一些特别严格的要求,最好像 Hermitage 项目的人那样安排一个测试。

5.3 ACID中的“I”等字母

隔离基本上就是人们谈论 ACID 时的一般意思。正是出于这个原因,我开始孤立地分析这个首字母缩略词,并没有像那些试图解释这个概念的人通常那样按顺序进行。现在让我们看看剩下的三个字母。

再次回顾我们的银行转账示例。将资金从一个账户转移到另一个账户的交易包括从第一个账户提取操作和对第二个账户进行补充操作。如果第二个账户的充值操作失败,您可能不希望第一个账户的取款操作发生。换句话说,要么交易完全成功,要么根本没有发生,但不能只对某部分进行交易。此属性称为“原子性”,它是 ACID 中的“A”。

当我们的事务被执行时,就像任何操作一样,它将数据库从一个有效状态转移到另一个有效状态。一些数据库提供所谓的约束——即适用于存储数据的规则,例如,关于主键或辅助键、索引、默认值、列类型等。因此,在进行交易时,我们必须确保所有这些约束都得到满足。

这种保证在 ACID 中称为“一致性”和一个字母C(不要与分布式应用程序世界中的一致性混淆,我们将在后面讨论)。我将给出一个关于 ACID 意义上的一致性的明确示例:在线商店的应用程序想要orders向表中添加一行,并且表中的IDproduct_id将在列中指示- typical 。productsforeign key

例如,如果产品已从分类中移除,并相应地从数据库中移除,则行插入操作不应发生,我们将收到错误消息。在我看来,与其他保证相比,这种保证有点牵强 - 如果仅仅是因为主动使用数据库的约束意味着转移数据的责任(以及业务逻辑的部分转移,如果我们正在谈论从应用程序到数据库的诸如 CHECK 之类的约束,正如他们现在所说的那样。

最后,它仍然是D- “阻力”(耐用性)。系统故障或任何其他故障不应导致交易结果或数据库内容丢失。也就是说,如果数据库回复交易成功,那么这意味着数据被记录在非易失性存储器中——例如,在硬盘上。顺便说一句,这并不意味着您将立即看到下一个读取请求的数据。

就在前几天,我正在使用来自 AWS(亚马逊网络服务)的 DynamoDB,并发送了一些数据进行保存,在收到答案HTTP 200(OK)或类似的东西后,我决定检查它 - 但没有看到这个数据库中接下来 10 秒的数据。也就是说,DynamoDB 提交了我的数据,但并非所有节点都立即同步以获取数据的最新副本(尽管它可能已经在缓存中)。在这里,我们再次进入了分布式系统上下文中一致性的领域,但是谈论它的时候还没有到来。

所以现在我们知道什么是 ACID 保证了。我们甚至知道它们为什么有用。但是我们真的在每个应用程序中都需要它们吗?如果不是,具体是什么时候?是否所有数据库都提供这些保证,如果没有,它们提供什么?