文章目录
7.1 总结
事务通常被定义为保证“一组操作要么一起成功,要么一起失败”的机制。
本文从错误处理的角度来看,将事务看作一种简化系统错误处理的机制,通过事务,应用程序中错误处理的流程得到了简化。基于错误处理的角度,作者在本章节中很关注当出现错误时,事务是如何表现的。
最后总结一下,本文中对基于事务中 ACID 属性进行介绍,主要是介绍隔离性 && 原子性。尤其是 ACID 中最难理解的隔离性,隔离性的介绍主要依照 提供什么保证、这些保证解决了那些问题,有那些问题无法解决、如何实现这种级别的保证 进行。
7.2 事务中的概念
ACID
ACID 是事务最突出的特性,分别是 原子性、一致性、隔离性、持久性。
- 原子性
- 能够在错误时中止事务,丢弃事务进行的所有写入变更的能力。大家听的更多的就是“一次要么全部成功,要么全部失败,不存在局部成功”
- 原子性并不是针对并发场景,原子性解决的是局部失败场景。
- 一致性
- 对数据的一组特定约束必须始终成立。
- 事实上,一致性的保证通常是应用程序进行,数据库无法阻止。 因为对数据的约束,例如所有账户之间总额不变,数据库很难对此进行保证(数据库通常有对外键等的约束,但约束有限)。
- 隔离性
- 同时执行的事务之间是相互隔离的。
- 隔离性针对的是多个事务并发场景可能出现的问题
- 持久性:
- 一旦事务完成,即使发生硬件故障或者数据库崩溃,写入的数据不会丢失。
- 不同结构下,持久性的的具体方式是不同的
- 单机:持久性代表写入磁盘,也包括写入日志等文件
- 分布式:分布式中,持久性意味着复制到一部分的节点中
持久性 && 复制
- 在分布式中,复制本身也是实现持久性的手段之一
另外,ACID 中一致性 C 其实是目的, AID 是手段。一致性本身也不是数据库的属性,有些论文中也提到 C 最开始也是为了凑字数,很多人并不关心 C。(笑
多对象事务
多对象事务:用户操作过程中涉及到的数据对象包含多个。
多对象事务写入方法:
- 需要确定那些读写操作属于同一个事务
- 关系型数据库中,通常是一个连接中 begin transaction 到 commit 之间的
- 非关系型数据中,有很多并没有将这些操作组合的方法,可能会让数据库处在部分更新的状态
分布式系统下放弃多对象事务原因:
- 多对象事务很难跨分区实现
- 高可用或者高性能的需求更迫切
为什么要提及多对象事务?
事务中原子性和隔离性对单对象是有效的,但是事务本身应该被理解为:将多个对象上的多个操作合并成一个执行单元的机制。
7.3 隔离级别
当多个事务之间涉及不同数据时,可以安全并行。如果一个事务需要读取另一个事务修改的数据或者多个事务同时修改相同的数据时,就会出现并发问题。
隔离级别中,串行化成为强隔离级别;读已提交 && 可重复读称为弱隔离级别。
下面表述每个隔离级别时,会从每个隔离级别提供的保证开始介绍,并且介绍这个保证解决了那些问题,另外就是这些隔离级别的具体实现方法。
7.3.1 读已提交
保证
- 数据库读取时,只能读取已经提交的数据(无脏读)
- 脏读问题
用户读取到处在更新状态的数据,如果事务回滚,那么用户体验非常差
- 脏读问题
- 数据库写入时,只能覆盖已经写入的数据(无脏写)
- 脏写问题
多个事务之间没有按照事务开始的顺序对同一个数据进行写入,最终数据不符合预期
- 脏写问题
下图中展示的就是脏写问题,两个用户购买同一个物件,最后的购买成功方竟然是 Bob!如果这是你的公司,那么恭喜你,你被客诉了。
如何实现读已提交?
- 通过行锁,锁定事务需要处理的所有行数据(写入操作)
- 可以解决脏读和脏写
获取到锁的事务,可以对该行的数据进行读取和写入,如果多个事务同时涉及到一个行,先加锁者先获取数据,没有加锁者无法进行
对于写入操作,通过锁可以让事务之间有序;对于读操作,锁操作会导致性能过低,所以实际上并不会使用。
- 可以解决脏读和脏写
- 解决脏读的具体方法
- 对于写入的每个对象,数据库都会记住旧的已经提交的值,和由当前持有写入锁的事务设置的新值。
- 任何其他事务读取对象都会拿到旧值
- 新值提交后,事务才会读取到新值。
这里原文写的解决脏读的方法过于粗糙,与其说是做法,不如说是要求。具体的实现方法后边在可重复读级别按照 mysql 具体描述。
7.3.2 可重复读
保证
一个事务只能读取到当前事务开始时看到的信息。
为什么需要可重复读?
可重复读和读已提交而言,解决的问题只有“不可重复读”,但是这个不可重复读场景似乎是短期的,数据最终会一致(一个事务成功更新或者失败回滚)。
目前的理解来说,不可重复读损耗的是用户的体验,根据业务的不同来衡量是否需要可重复读,如果是银行转账等敏感业务,也许一次不可重复读会动摇用户对功能的信心或者遭到用户的客诉。
如何实现基于快照的隔离?
基于快照的隔离,基本原则是:读操作不阻塞写操作,写操作不阻塞读操作,具体实现:
- 针对写操作,通过写锁保证
- 针对读操作,通过快照保证
基于快照的可见性规则:
- 每个事务开始时(称为当前事务),数据库列出当时所有其他事务(已经提交/未提交)清单
- 当前未提交的事务,后续提交的写操作对当前事务不可见
- 被中止的事务执行的写操作忽略
- 对于当前事务之后创建的事务,所有写入操作忽略
- 其余所有写入操作对当前事务是可见的
这里以 MySql 为例,看一下 Read View + MVCC 的组合如何实现两种隔离性。
在 mysql 中,读已提交和可重复读的唯一区别在于,可重复读一直读取开启事务时数据库已提交的快照信息,而对于读已提交而言,执行每一行语句时,都需要获取当前数据库最新的提交数据。从这个角度来看,两者的区别在于快照的获取时机不同。
首先简单描述一下用到的概念:
- Read View mysql 的一种数据结构,暂时可以理解为当前数据库的一个快照信息,Read View 中有几个关键属性
- creator_trx_id 创建该视图的事务 ID
- m_ids:创建时,数据库中活跃&&未提交的事务 ID 列表
- min_trx_id:创建时,数据库中已经提交的最小事务 ID
- max_trx_id:创建事务时,当前数据库中给下一个事务的 id
- MVCC 多版本并发控制:mysql 中对每行数据的每次提交都会进行记录。每行记录中会记录当前正在修改记录的事务 ID
简单来说,读取数据的过程就是当前准备读取的记录的事务 ID (记作 cur_record_trx_id) 和 当前事务 ID(cur_trx_id) 进行比较的过程,比较规则如下:
- 如果 cur_record_trx_id <= min_trx_id:说明当前记录已经被提交了,直接读取当前记录的值并返回
- 如果 cur_record_trx_id ∈ [min_trx_id, max_trx_id]
- 如果 cur_record_trx_id ∈ m_ids:说明当前记录正在被一个活跃的事务操作中,无法读取,按照版本链查看下一个版本记录
- 如果 cur_record_trx_id ∉m_ids:说明当前记录已经被提交,可以直接读取当前记录的数据并返回
- 如果 cur_record_trx_id >= max_trx_id:记录被当前事务之后的事务创建,当前事务不能读取记录的值
索引如何在多版本数据库工作?
一种方式:索引指向对象的所有版本,并且索引查询时可以过滤到当前事务不可见的任何版本。(mysql 的选择)
另一种方式:例如 CouchDB 中,虽然使用 B 树,但是每次更新时都会创建一个副本,从父页面到树的根节点都会级联更新。
可重复读和命名混淆
很多数据库都实现了快照隔离的隔离级别,但是在不同数据库中名称并不相同。Oracle 中成为 可序列化,在mysql 和 postgreSql 中称为 可重复读。
原因是 SQL 标准中并没有快照隔离的概念,在建立这个标准时,快照隔离的概念还没有提出来。并且 SQL 标准中设置的可重复读标准非常模糊,导致不同数据库实现的可重复读提供的保证有很多差异。
7.3.3 写写冲突 && 解决方案
在串行化之前,需要了解一下,还有那些问题是上述两个隔离级别所没有解决的。
总结一下可重复读和读已提交,两者解决的主要目标在于:并发写入时,不同事务应该读取到什么信息,对于并发写入并没有过多的考虑。
丢失更新 && 解决方案
什么是丢失更新?
简单来说,用户的读取-修改-更新操作在并发写入过程中并非原子的。在并发写入时,一个事务的写入信息可能被另一个事务覆盖了。
方案1:通过锁实现原子写入
很多数据库提供了原子更新操作。例如 mysql 中 update 语句,会自动对数据进行加锁,保证数据更新过程中不会有其他操作干扰。
方案2:自动检测丢失机制
事务管理器检测丢失更新时,中止事务并强制它们重新“读取-修改-写入”,数据库可以通过结合快照隔离执行这个检查。
-
PostgreSQL 可重复读、Oracle 可串行化、SQL Server 快照隔离级别都实现了自动检测丢失机制。
-
MySql(InnoDB)的可重复读机制并不会执行这个检查。
方案3:CAS 比较&&设置
乐观锁机制
多节点复制下的丢失更新
分布式结构下,多个节点中可能并不存在一个拥有最新数据版本的节点(多主 or 无主),这种场景下丢失更新问题的解决方案如第六章所述:
- 最后写入胜利
- 保存所有冲突,应用代码中进行解决
写偏差 && 幻读 && 范围锁
写偏差:如果两个事务读取相同对象,然后更新其中一些对象,可能出现写偏差。
问题:
- 涉及多个对象,单对象原子操作不能解决问题
- 当前一些主流数据库的自动检测机制并不会检测写偏差( PostgreSQL 可重复读、Oracle 可串行化、SQL Server 快照隔离级别、 MySql(InnoDB)的可重复读)
幻读:如果一个事务的写入操作影响了另一个事务读取的结果**(读写事务)**。
范围锁(间隙锁)是解决幻读的一种方式,它锁定的是查询对象的索引,并且锁定的是一个范围。
7.3.4 可序列化
保证
可序列化提供的保证:事务可以并行执行,最终结果完全一致。
实现可序列化的三种方式
串行化
直接单个线程按照顺序依次执行,该手段在 2007 年得到实现,原因如下:
- RAM 足够便宜,很多场景的所有数据都可以放到内存中
- 有些事务(例如分析)大部分操作都是只读操作,只读操作可以在快照隔离的基础上并发,写操作串行即可
串行执行的每个事务必须小而快,如果存在一个缓慢事务,那么所有事务都会拖慢。
写入吞吐量必须低到能在单核 CPU 执行,如果不可以,事务需要能划分到单个分区而且不需要跨分区协调
两阶段锁定(2PL)
-
两阶段锁:
- 事务执行时,获取锁
- 事务提交后,释放锁
两阶段锁定原则:读操作会阻塞其他写入,写操作会阻塞其他的写 & 读操作。
-
实现方式:通过锁实现,锁包括共享锁 && 独享锁,规则如下:
- 如果事务要读取对象,需要在对象上加共享锁。多个事务可以施加多个共享锁,但是如果存在独享锁,不可加共享锁。
- 如果事务要写入对象,需要在对象上加独享锁。一个对象上只能有一个独享锁。
- 如果事务先读取再写入,需要将共享锁升级为独享锁。
- 事务获取锁后,直到事务提交后,才会释放锁。
-
性能不好的原因
- 获取和释放锁的开销
- 如果两个并发事务试图做任何导致竞争条件的事情,必须等待另一个完成
乐观并发控制技术
可序列化的快照隔离 SSI 技术
- 事务中所有读取来自数据库的一致性快照
- 快照基础上添加算法检测写入之间的冲突