事务作为每个数据库提供的一层高度抽象机制,保障着数据的安全性和一致性和持久化,这时每个数据库必备的功能;只是不同类型的数据库实现可能存在某种差异化,有了事务的保障应用层程序才能简单处理一些复杂的业务逻辑,如果没有数据库的事务保障那么对于一个数据库来说这时一个很难解决的问题,程序要面对进程崩溃和网络中断,并发数据竞争问题。最重要的是数据库要面对多个客户端连接的操作,此时客户端完全在异步环境下操作的,时间是固定的,但是每个客户端操作事务可能是异步进行,当在某个时间段内处理多个事务就会面对其并行事务问题,让异步的操作变成同步操作。本篇文章对几个场景的事务处理方式举例,表明事务对于这些场景的重要性。


读提交事务

例如一个计数器场景用户1和用户2同时操作计数器,这时就可能会发生读取到脏数据的场景,防止脏读的场景如下图:

如果其中某一个用户在未提交情况下,被其他用户读取到了未提交的数据就为脏读,在前一个事务未提交的时候读取的数据只能是已经被提交的数据。


另外一个场景 Alice 和 Bob 同时去买一辆汽车,但是交易网站有一个场景就是要分为两个事务步骤才能处理完成一笔交易,分别是更新被购买汽车的主人信息,再更新汽车购置发票信息,但是这时如果是多个用户同时并行操作就会发生脏写的情况如下图:

因为整体操作依赖于时间异步操作的,可能二者都并行操作,此时当Bob结完账,系统缺将发票信息设置为了Alice,这就是出现了脏写的情况。


Read Committed 读已提交:防止脏读和脏写通常都是通过锁🔐的方式来解决相关事务隔离问题,如果事务想修改某个对象字段必须先要申请获取到一把锁,然后事务在执行直到中断或者提交才能归还锁,与此同时其他事务必须在此等待;这种方式确定很明显如果一个写事务运行时间过长,会导致很多只读的事务等待太长时间。大多数数据库读锁场景采用的双版本维护机制,在写事务未完成之前新的读操作返回其旧值,以此来缓解。


不可重复读

读提交隔离机制的实现相对于大多数的事务确实管用,但是还有一种场景存在问题,例如Alice有两个银行账户余额为 1000 块钱,这时要从其中一个银行账户想另外一个银行账户进行转账 100 元,最后数据库系统要完成两个账户数据最终更新操作,如下图:

此时因为在转账的过程中来查看其账户余额就会发生两个银行账户余额不一样的情况,发现少了 100 元,这个就是因为事务处理时间窗口异常情况,这种异常为 不可重复读 取或 读偏斜情况。


出现这些问题在当前场景是可以容忍的,在短暂几秒钟之后刷新查询数据则为最新版本。但是另外几种方式就是不能允许的,例如备份场景因为备份可能需要几小时才能完成,这样就会导致两个数据库的数据出现旧值和新值情况,如果最终数据没有弥补检测机制会出现永久不一致情况;另外一种方式就是实时数据分析场景下,定期检测数据完整性,如果不在用一时间点内这些分析结构毫无意义。


快照级别隔离

出现读倾斜和不可重复读的情况原因就是在多个事务执行过程中因为时间窗口导致的,快照级别的隔离的实现是针对某个事务在某一个时间点创建一致性快照中读取,及时在事务执行过程中被另外一个事务修改了,但是每个事务只能看到该特定时刻的旧数据,而不会出现读偏斜的情况。

实现快照级别最常见的方式为 MVCC 多版本并发控制,MVCC 实现原理为对每个执行事务分配一个事务 ID 并且是自增长的,当某个事务向数据库写入新的内容时,被写入的数据都会标记为写入者的事务 ID 。表中的每条记录都两个 created_by 字段 和 deleted_by 字段,如果事务要删除某条数据自己将其 deleted_by 字段设置为本次事务的 ID 号,仅仅是标记删除的,而谁创建此条记录则将其设置为对应事务的 ID 号,如下图:

被删除的数据记录当没有被其他事务引用的时候,就会被数据库的垃圾回收进程真正的删除掉,来释放存储空间占用。事务 ID 其实是一个为 32 位的整数,大约可以在处理 40 亿次事务之后会溢出,这个设计的看具体不同数据库实现。在不同关系型数据库产品中都已经实现多版本并行控制的事务隔离级别,在不同版本中只是叫法不一样,Oracle 为可串行化,PostgreSQL 和 MySQL 则称之为可重复读。在 SQL 标准中并没有定义如何去实现快照隔离级别,较老的数据库也没有快照级别这种概念。


事务可视化规则: 多版本并发控制在一致性情况下要考虑到每笔事务之前开始时要检测当前系统中正在执行中的事务,即尚未提交或终止的事务,这些事务部分写入数据不应该被展示出来,即不可见,即使这些事务之后会被提交。所有的终止的事务操作的修改全部不可见,较晚的事务 ID 所操作的修改也不可见,不管是否成功提交事务。除此之外的其他所有操作的写入的数据查询可见。

快照隔离级别还有另外一种实现为通过索引快照隔离,将索引指向多个不同的数据版本,然后想办法过滤掉对当前事务不可见的那些版本,后台的垃圾回收进程决定删除那些旧对象,只保留最新的数据版本,这实现可以查看之前我这篇文章 Wisckey索引的方式。这种实现方式类似于 OS 内存管理所采用的写时复制技术一样,采用一种追加的方式当需要更新数据时,不需要更新现有的页面而是创建一个新的修改副本,让索引指向新的数据页面,这种在 B-Tree 索引的数据库中实现比较多。


写偏斜和幻读

快照隔离级别在多对象事务更新操作场景下可能会出现写偏斜,通常不同的事务可能更新不同的对象,则可能发生写倾斜,而不同的事务如果更新的同一个对象则可能发生脏写和更新失败情况。常见的例子就是医生换岗的场景,不管在什么时候医生换岗的时候必须前提是还有一个医生在岗位上,都要同时去操作自己排班表记录,如果是同时去查询这个条件可能就发生幻读和写偏斜情况。

这种场景就是因为是在数据库中操作,而如果是在现实中肯定是两个医生口头上协商才能到达换班目的,但是如果是在数据库中,那么整个操作都是异步进行的,那么协商条件依赖于数据库的隔离级别实现,这种操作是完全不可靠的,针对这种问题的最为有效的解决办法就是显示加锁串行化事务。另外一种场景就是注册用户名不能重复被使用,这时就需要通过采用唯一性约束来解决这个问题,如果其中一个事务违反约束就会终止创建相同的用户名。

实体化冲突解决方法,例如开发一个酒店房间预定系统,可以提前建立一张时间表,在事务执行操作的时候先检测对应表实体行是否被其他事务锁占用,通过这个条件来解决实体化冲突问题。例如下面这种为领克汽车用户保养服务的 App 预约功能,其后台实现就可能会出现写偏斜问题,如下图:

出现这种写偏斜问题原因就是因为在执行某个事务之前要检测某个条件是否满足,才能执行下一步,因为这次事务是分为两个步骤不能原子完成,导致多个事务可能某一时刻来查询前置条件都对其进行修改,影响到另外一个事务执行结果,写偏斜由于幻读引起的。


串行化

引发数据竞争最大问题就是多个事务同时去操作某一条数据所引发的问题,读提交和快照级别的隔离可以防止一些问题,但是例如幻读和写偏斜目前还没有很好的办法解决。说到数据竞争问题在编程语言竞争都通过锁来控制,或者想使用一些静态分析工具来检测存在数据竞争问题,例如在 Go 语言中提供了静态数据竞争检查问题 Race Detector , 另外一种方案就是最为时髦的编程语言 Rust 官方号称为无畏的并发,其数据竞争问题在编译阶段就可以被提前发现,这个就利用到 Rust 编译器静态分析相关的功能,所有权规则和生命周期相关的东西来避免数据竞争。静态分析相关研究大部分还在学术界,目前也有一些成熟的商业产品 DeepSource 这种 SaaS 服务的公司,可以将其集成到代码仓库中帮助开发者审查代码的安全,已经支持多种编程语言相关静态分析实现,未来这种安全相关的服务会越来越多,也不可能是哪个特定语言的优势。

目前常用的串行化实现就是采用单线程来执行事务的请求,采用在一个线程中按照顺序方式每次只执行一个事务,这就就可以避免数据竞争和上下文切换的相关的,也不需要解决事务冲突,这种方式事务处理目前 Redis 所采用在内存中处理的事务,使用单线程循环执行事务。

事务本身就是要用户的多个操作变成一个操作序列,例如在电商网站上搜索商品和下单商品就要将多个步骤捆绑成一个事务去操作,但是如果事务中一个操作要等待其他因素就会导致这个事务等待,在大并发需求之后那么整个系统大部分时间将处于空闲的状态,数据库就无法高效的运行,Redis 的做法是将其使用 IO 多路复用。采用单线程这种模式不支持交互式的多语句事务,传统的做法是通过把事务代码打包成存储过程发送到数据库中,数据库会读取到相关的数据读取内存中高效执行,无需等待磁盘IO操作。

存储过程缺点: 不同版本的关系数据库,Oracle 和 SQL 等商业数据库都有不同的实现规则和方言,没有一个共同的标准,这就使得如果想迁移数据到不同的数据库上很难,和特定的数据库绑定到了一起。存储过程的代码是在数据库中运行调试更加困难并且版本控制也不容易维护,并且运行存储过程要求高的性能。像 Redis 这种采用的是 Lua 脚本语言来做,而 VoltDB 采用的 Java 或者 Groovy 来实现存储过程,使用特定的编程语言来做,例如 Oracle 实验型项目 GraalVM 来支持多语言做。如果是使用串行化的方式要考虑的因素,事务必须简短不用影响到其他事务;将仅限于活动的数据集完全可以加载到内存中,少量数据可以存储在磁盘中,单线程访问会造成严重性能问题;写的吞吐量必须足够低,读事务可以使用快照隔离级别进行读取,而写事务必须在单核上处理,否则需要采用分区,分区处理可以提高写的吞吐量,但是最好不能有跨区事务处理。


两阶段加锁

使用两阶段锁作为事务的隔离机制是目前大多数关系数据库中用的最多方式,两阶段锁分为两种锁,第一种是共享锁针对是多个事务读取一个数据记录时必须要申请共享锁,第二种占用锁针对的是某个事务要修改和创建一条记录时必须先持有占有锁,否则可以直接拒绝事务,让客户端应用重试。

两阶段锁和传统的串行化锁存在某种分歧,传统的锁针对事务操作的某个对象上锁,如果这个事务存在多对象数据操作时锁不应该立即执行事务,如果立即事务执行操作改数据记录,被阻塞的事务会被唤醒会在前一个事务执行基础之上执行,结果会受到前一个事务影响;所以必须逐步加锁将每次事务锁需要用到数据记录上锁,直到此处事务被提交再将其全部解锁。

两阶段锁一般都是锁管理器或者事务管理器所管理着,对受保护的资源进行权限控制,每个事务加锁时,锁管理器会检测这个锁是否被其他事务占用或排他模式所占用,如果被其他事务占用则需要等待其他事务提交或者终止再次尝试。

其两阶段锁实现事务隔离可能会出现有死锁问题,死锁的问题在前面同步原语控制并发的博文中讲过因为不满足串行化的 3 个条件。两个事务都在等待对方锁住的资源才能正常执行,这样就产生了死锁,事务 A 正在等待 B ,而 B 又在等待 A ,解决办法有在锁管理器加一个定时检查机制检查有没有环形等待图,如果出现锁的环路情况,可以让锁管理器主动中断其中一个事务,来打破死锁情况。另外一种方式是基于事务的时间戳来判断事务之间的优先级,较低的时间通常意味着有较高优先级,这特性和之前的基于 LSM-Tree 存储引擎设计很相似,如果两个事务尝试获取锁,事务 A 想获取事务 B 所持有的锁,事务 A 有较高的优先级,则 A 进阻塞等待,事务只能被时间戳更高的事务阻塞。


MySQL隔离等级

最理想的事务状态是互不干扰,事务之间的操作没有依赖关系,事务之间可以以任何顺序进行执行这样对数据记录就没有任何的影响,结果也是完全独立的,这是数据库事务最理想的状态。但是这在多个客户端对数据库操作时是不可能实现的,因为物理世界的时间是直线运动的,多个事务并行操作绝对不可能出现同步情况,想要同步就得隔离让事务之间互不干扰。读未提交 < 读已提交 < 可重复读 < 串行化 这些是 MySQL 中的几种默认隔离级别,目前默认使用的可重复读级别,如果使用的串行化可能数据库处理的事务吞吐量会下降,但是能防止写偏斜和幻读的情况,各个级别的隔离效果对比图如下:

在数据库隔离等级都是针对的是读异常和写异常情况进行定级的,读异常包含 dirty readnonrepeatable readfuzzy read 同属于读异常级别的问题,而 lost updatedirty writewrite skew 属于写异常级别的。

下面为一个数据库的事务执行的生命周期,一个事务从创建到结束的执行过程状态转换图:

  • Active: 活跃状态的事务表示正在执行中事务,也就是处于活跃状态的事务。
  • Failed:事务处于活跃状态时可能遇到错误终止,断电或者操作系统错误导致事务终止,称之为事务失败状态。
  • Aborted:事务处于活动状态或者部分提交状态,执行某个事务时发生错误导致之前的状态回滚被撤销,当回滚完毕之后次状态称之为中止状态。
  • Partially Committed:当一个事务完成最后一个操作时,由于操作在内存中执行的,如果还没有刷盘持久化就可以说该事务处于部分提交状态。
  • Committed:当一个处于提交状态的事务被成功刷入到磁盘数据持久化时之后称之为事务提交状态。

在 MySQL 数据库中可以设置全局的隔离级别和会话隔离级别,配置会话级别的命令如下:

select @@session.tx_isolation;

会话级别的隔离等级针对的是某个客户端连接级别的隔离级别,这种可以针对某种业务场景来设置隔离级别,而另外全局级别针对是数据库服务器上所有的会话链接,配置如下:

select @@global.tx_isolation;

还可以使用 set tx_isolation 命令直接修改当前 session 的事务隔离级别,SQL 语句和运行结果如下:

mysql> set tx_isolation='READ-COMMITTED';
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql> select @@session.tx_isolation;
+------------------------+
| @@session.tx_isolation |
+------------------------+
| READ-COMMITTED         |
+------------------------+
1 row in set, 1 warning (0.00 sec)

使用 SET TRANSACTION 语句分别修改会话和全局的事务隔离级别 SQL 语句和运行结果如下:

mysql>  select @@session.tx_isolation;
+------------------------+
| @@session.tx_isolation |
+------------------------+
| SERIALIZABLE           |
+------------------------+
1 row in set, 1 warning (0.00 sec)

mysql> SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)

mysql>  select @@global.tx_isolation;
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| REPEATABLE-READ       |
+-----------------------+
1 row in set, 1 warning (0.00 sec)

小 结

最理想的数据库事务设计是多个事务可以并行的执行,提高效率,但是并行执行事务要保证事务之间的正确性,事务必须严格按照 ACID 性质。数据库的事务隔离级别就是用对并行执行事务可能会引发的写异常和读异常做的描述,每个级别针对的问题都不一样,并发控制策略决定了如何调度事务和执行事务。

其他资料

便宜 VPS vultr
最后修改:2023 年 07 月 05 日
如果觉得我的文章对你有用,请随意赞赏 🌹 谢谢 !