数据库事务系列-本地事务模型基础

事务, 或者更严格地说数据库事务, 可能是数据库理论中最难理解的术语之一. 要真正理解事务的内涵和其外延, 最重要的是思考和理解以下问题:

  • 为什么会出现事务? 事务是一种自然法则还是人为创造的?
  • 什么是事务, 或者说事务的定义是什么?
  • 事务有什么样的特性? 能满足什么样的应用场景?
  • 事务是否存在劣势? 有没有弥补方法?

这篇博文分为三个部分: 第一部分介绍事务的定义; 第二部分介绍事务的ACID特性; 第三部分介绍与事务中隔离性相关的概念, 主要包括常见的4中隔离级别. 读者需要注意的是, 本文所描述的事务是指本地事务, 也就是指在单一数据节点中对单一数据库资源的访问控制, 分布式事务需要在本地事务的基础之上引入额外的协议和协调机制, 本文并不涉及. 此外, 本文所描述的事务是概念层面上的, 并不涉及具体的实现方法, 不同数据库的事务实现方法可能放在之后的博文中介绍. 总的来说, 关系型数据库都完美的支持了本文所描述的事务模型.

事务定义

首先来看文章开头的第一个问题: 为什么会出现事务? 在现实生活中有很多行为需要实现一系列操作, 并且要求这一系列操作要么全部执行成功, 要么全部不执行, 不能存在其中几个操作执行成功, 其他操作执行失败的情况, 比如:

  • 从A账户转1万块钱到B账户, 需要执行两个操作: 先在A账户扣除1万, 再给B账户加1万. 这两个操作要么全部执行成功要么全部不执行. 不能说在A账户扣除1万这个操作执行成功, 但是给B账户加1万这个操作执行失败.
  • 销售一件商品时, 首先用户购物车商品删除, 然后在销售订单里增加订单信息, 再者需要将销售商品库存减1.
  • 评论增加积分操作, 首先要在评论表里面增加一条评论信息, 再者需要在积分表里面增加相应的积分.

正是因为现实生活中存在很多行为需要实现一系列操作, 这些操作要么全部执行成功, 要么全部不执行这个需求, 才使得事务应运而生. 事务将应用程序的多个读, 写操作捆绑在一起成为一个逻辑单元, 即事务中的所有读写是一个执行的整体, 整个事务要么全部成功, 要么全部失败, 不存在部分成功或失败的场景. 到这里我们也可以知道, 事务并不是天然存在的东西, 它是被人为创造出来的, 其目的是简化应用层的编程模型. 在一个高可靠的数据存储环境中, 存在许多可能出错的场景. 通常可将其分为两类, 一类是故障性问题, 比如由软硬件故障或网络故障引起的程序中断; 另一类是由并发带来的竞争条件, 即如何进行并发控制. 事务的出现正是为了解决类似上述场景中可能出现的问题, 将所有的问题都屏蔽在数据库层面, 这样应用层就不需要担心部分失败的情况, 并且可以安全地重试.

我们可以给事务下一个正式的定义: 事务是指数据库中一个不可分割的逻辑工作单元, 它允许你将许多个操作表示为一个步骤. 事务执行的操作包括读取和写入数据记录.

通常意义上的事务针对的是多个对象, 即将多个读写操作聚合为一个逻辑执行单元. 并对这个逻辑单元提供可靠的执行保证, 体现在原子性(要么全部成功, 要么全部失败), 隔离性(防止产生竞争条件). 而存储引擎对单个对象操作的可靠执行保证几乎是必须的, 通常基于日志恢复来实现原子性, 基于对象锁来实现隔离性, 甚至某些数据库还提供了更加高级的原子操作, 如原子自增操作, 原子比较-设置操作. 值的注意的是, 虽然单对象操作有时也被称为”轻量级事务”, 但是它们并不是通常意义上的事务.

多对象事务在关系型数据库中几乎是必备的, 然而许多分布式数据存储系统并不支持多对象事务, 例如HBase仅支持单行事务和Region级别的跨行事务. 当然, 并非在分布式环境下难以实现多对象事务, 在分布式环境下也有分布式协议可以实现分布式事务, 这些分布式存储系统不支持多对象事务大多是基于性能的考虑.

ACID特性

了解了事务的含义, 我们再来看事务的特性.

数据库事务必须遵守原子性(Atomicity), 一致性(Consistency), 隔离性(Isolation)和持久性(Durability), 也就是我们常说的ACID特性. 虽然ACID特性早已被大家所熟知, 但是这四个词在计算机的不同领域有着不同的含义. 这一节将逐一剖析ACID中各个词的具体含义.

原子性: 事务所包含的一组操作是不可分的, 事务中所有的操作要么全部执行成功, 要么全都不执行. 事务中的操作不应当被部分应用, 每个事务要么成功提交(使事务内写操作产生的所有更改变得可见), 要么中止(回滚所有尚不可见的事务副作用). 中止后可以重试该事务.

原子性一词常出现在并发编程中, 如果某个线程执行一个原子操作, 这意味着其他线程无法看到该操作的中间结果. 它只能处于操作之前或操作之后的状态, 而不是两者之间的状态. 然而, ACID中的原子性与多个操作的并发性无关, 这实际上是由隔离性所定义的.

ACID中的原子性目的在于屏蔽一个包含多个写操作的请求中可能出现的故障性问题. 例如在完成了一部分写入之后, 发生了故障. 如果没有原子性保证, 则必须由应用层记录哪些操作已经生效, 哪些没有生效, 这将使得应用层的业务逻辑格外复杂. 原子性则大大简化了这个问题: 只要事务没有成功提交, 应用程序可以确保没有发生任何更改, 所以可以安全地重试. 从中我们可以看出, ACID中原子性的特征是: 在出错时中止事务, 并将部分完成的写入丢弃. 也许在这里使用可中止性比原子性更加准确.

一致性: 事务只能将数据库从一个有效状态转移到另一个有效状态, 而不能打破与应用程序业务相关的各种约束, 例如: 银行账户之间无论如何转账其总额不变等现实约束; 性别最多只有男, 女, 跨性别者三种选项等完整性约束.

一致性同样在计算机的不同领域有不同的含义. ACID中的一致性主要是指对数据有特定的预期状态, 任何数据更改必须满足这些状态约束. 值的注意的是, 不同于原子性, 隔离性和持久性完全由数据库实现, 一致性虽然在一定程度上依赖数据库实现(原子性, 持久性, 隔离性也是为了保证一致性), 但其主要由应用程序实现, 应用程序有责任定义正确的事务来保持一致性.

隔离性: 多个并发执行的事务应当能够互不干扰地运行, 每个事务都像没有其他事务在同时执行一样. 隔离性定义了何时以及哪些对数据库的状态的更改可以对并发事务可见.

隔离性是ACID中最复杂的一个特性, 它定义了数据库中多个事务并发执行时的可见性规则(根据经验, 一旦涉及到并发, 问题似乎就变得复杂了). 为了更好的性能, 数据库通常提供了多种隔离级别, 每个隔离级别提供了不同程度的并发事务可见性保证, 本文将在隔离级别这一小节进行详细描述. 从技术角度讲, 每种隔离级别都需要不同的技术手段来保证, 通常来说涉及各种锁和MVCC机制, 后面两篇文章会重点解释隔离性.

持久性: 一旦事务被提交, 所有数据库状态的修改都必须被持久化到磁盘上, 即使之后发生故障性问题, 如断电, 系统故障或崩溃等也不受影响.

持久性通常意味着数据已经被写入非易失性存储, 如硬盘或SSD. 当然, 事实上并不存在完美的持久性, 如果所有磁盘都被销毁, 那么数据库也无能为力.

现在, 我们对ACID有个准确的认识, 并可以作如下总结:

  • 原子性主要用于解决可能遇到的故障性问题, 如断电, 系统崩溃等;
  • 隔离性主要用于解决并发问题, 并且存在多种隔离级别, 提供了不同程度的并发保证;
  • 持久性几乎是数据库必备的特性, 如果没有持久性那么数据库也就没有了存在的必要;
  • 一致性更多的是一种应用层的属性, 数据库很难检测到业务上的不一致, 而原子性, 隔离性, 持久性则是数据库本身的属性, 这些属性可以帮助应用层更好地保证一致性.

隔离级别

事务隔离级别主要保证ACID中的I, 即隔离性. 上文中提到过, 数据库通常都提供了多种隔离级别, 以适用于不同的业务需求. 然而, 差强人意的是各个数据库之间所实现的隔离级别都存在微小的差异. 所以, 我们也没必要纠结于隔离级别的具体定义, 重点是要了解在并发事务下可能出现的异常, 及其相应的解决方案. 本文讲述常用的4中隔离级别以及它们能避免的异常, 在使用具体数据库时关注其可能存在的细微差别即可.

读未提交

读未提交是最弱的一种隔离级别, 它几乎不提供任何保证. 从字面意思也可以看出, 它允许事务读取到其他并发事务未提交的更改, 这种现象称为脏读. 如下图所示, 开始时, 1号事务和2号事务看到的A都是A0, 接着1号事务将A0更新为A1, 再接着2号事务就读到A1新值, 然而1号事务将A1回滚回了A0. 这样1号事务就读到了一个不存在的值A1, 这就是脏读.

读已提交

读已提交和读未提交是相对的, 读已提交表示事务只能读到其他并发事务已经提交的结果. 也就是说1号事务可以看到2号事务提交之后的结果, 但是看不到2号事务提交之前对任何数据的更改. 这样读已提交就避免了脏读, 但是还存在一个问题: 不可重复读.

上图中2号事务在1号事务更新完成之后提交之前读取A的值依然是A0, 避免了脏读; 但在1号事务提交之后再次读取时发现读到的值变成了A1, 出现了不同时间点对同一数据进行多次读取, 会读到不同的值的现象. 这种现象就称为不可重复读.

可重复读

从字面意思中就可看出, 可重复读修复了读已提交隔离级别中存在的不可重复读问题. 如下图所示, 无论1号事务如何更新A, 2号事务在随后的进程中看到的A值都是事务开始第一次看到的A值, 即A0.

可重复读隔离级别虽然解决了不可重复读的问题, 但是还存在一个问题: 幻读. 上述三种隔离级别解决的都是对单个对象(或单行)的并发控制, 而幻读是在对多个对象(多行)进行操作时出现的问题. 以下图为例, 1号事务在事务过程中插入了一个大于B0的新值B2, 2号事务在插入操作前后读取B>0的时候读到的值却不同.

可串行化

可串性化是最严格的一种隔离级别, 要求有读写冲突的事务必须严格串行执行. 如下图所示, 2号事务要读取1号事务修改的记录A, 这就导致2号事务必须等待1号事务提交之后才能开启执行. 通过这种形式可以避免之前所提到脏读, 不可重复读和幻读. 虽说如此, 几乎所有数据库业务都不会开启这种隔离级别, 因为这会带来严重的锁冲突.

不同隔离级别为并发事务提供了不同的保证, 总结如下.

脏读 不可重复读 幻读
读未提交 允许 允许 允许
读已提交 - 允许 允许
可重复读 - - 允许
可串行化 - - -

总结

上文已经回答了博文开头的前三个问题, 那么第四个问题呢, 事务有什么劣势? 其实答案已经隐含在前文中了. 事务的劣势就是它所带来的性能开销, 这也是数据库需要实现多种隔离级别的原因, 越弱的隔离级别其性能通常越好. 另外, 分布式事务的实现通常具有更高的复杂度和性能开销, 因此很多数据库都选择放弃了强一致性的分布式事务.

本文主要讲述了本地数据库事务的概念和其ACID特性, 重点分析了不同隔离级别所解决的事务并发问题. 正如文中所说, 不同地方对事务, ACID和隔离级别的定义都不尽相同, 我们也不必纠结与具体概念, 只要理解其本质内涵, 知道其能解决什么现实问题即可. 对于各个数据库在隔离级别方面的差异, 在使用具体数据库时再查阅即可.

参考

[1] 纯干货 | 一篇讲透如何理解数据库并发控制
[2] 开发者都应该了解的数据库隔离级别
[3] Designing Data-Intensive Application
[4] Database Internals: A Deep Dive into How Distributed Data Systems Work

Logistic Regression (LR) 编译原理实践 - JavaCC解析表达式并生成抽象语法树

本博客所有文章除特别声明外, 均采用CC BY-NC-SA 3.0 CN许可协议. 转载请注明出处!



关注笔者微信公众号获得最新文章推送

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×