事务
就是一组DML语句组成,这些语句在逻辑上存在相关性,这一组DML语句要么全部成功,要么全部失败,是一个整体。MySQL提供一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。事务就是要做的或所做的事情,主要用于处理操作量大,复杂度高的数据,比如转账就涉及多条SQL语句,包括查询余额(select)、在当前账户上减去指定金额(update)、在指定账户上加上对应金额(update)等,将这多条SQL语句打包便构成了一个事务。
MySQL同一时刻可能存在大量事务,如果不对这些事务加以控制,在执行时就可能会出现问题。比如单个事务内部的某些SQL语句执行失败,或是多个事务同时访问同一份数据导致数据不一致的问题。
因此一个完整的事务绝对不是简单的SQL集合,还需要满足如下四个 特性
:
原子性:
一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中如果发生错误,则会自动回滚到事务开始前的状态,就像这个事务从来没有执行过一样。持久性:
事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。隔离性:
数据库允许多个事务同时访问同一份数据,隔离性可以保证多个事务在并发执行时 ,不会因为由于交叉执行而导致数据的不一致。一致性:
在事务开始之前和事务结束之后,数据库的完整性没有被破坏,这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库的可以自发性地完成预设的工作。上面的四个属性简称ACID
:
其中原子性、隔离性和持久性是手段,一致性是目的。只要原子性、隔离性和持久性能够保证,那么一致性一定能够保证。
十五被MySQL编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题。如果MySQL只是单纯的提供数据存储服务,那么用户在访问数据库时就需要自行考虑各种潜在问题,包括网络异常、服务器宕机等。因此事务本质是为了应用服务的,而不是伴随着数据库系统天生就有的。
MySQL中只有使用了 InnoDB
数据库引擎的数据库或表才支持事务,MyISAM
不支持。
Engine
: 表示存储引擎的名称。Support
: 表示服务器对存储引擎的支持级别,YES表示支持,NO表示不支持,DEFAULT表示数据库默认使用的存储引擎,DISABLED表示支持引擎但已将其禁用。Comment
: 表示存储引擎的简要说明。Transactions
: 表示存储引擎是否支持事务,可以看到InnoDB存储引擎支持事务,而MyISAM存储引擎不支持事务。XA
: 表示存储引擎是否支持XA事务。Savepoints
: 表示存储引擎是否支持保存点。💕 查看事务的提交方式
show variables like 'autocommit';
💕 设置事务的提交方式
set autocommit=0/1;
💕 查看隔离级别
mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ | -- 可重复读
+-----------------+
1 row in set, 1 warning (0.00 sec)
💕 设置读未提交的隔离级别
mysql> set global transaction isolation level READ UNCOMMITTED;
Query OK, 0 rows affected (0.00 sec)
mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)
当我们设置完隔离级别后需要重新登录才能生效。
准备测试表
创建一个银行用户表,表中包含用户的id、姓名和账户余额。如下:
💕 事务的开始与回滚
说明一下:
begin
或start transaction
命令,可以启动一个事务。savepoint
保存点命令,可以在事务中创建指定名称的保存点。rollback to
保存点命令,可以让事务回滚到指定保存点。rollback
命令,可以直接让事务回滚到最开始。commit
命令,可以提交事务,提交事务后就不能回滚了。💕 原子性
如果左终端中的事务在提交之前因为某些原因与MySQL断开连接,那么MySQL会自动让事务回滚到最开始,这时右终端中就看不到之前插入的记录了。
💕 持久性
左终端中的事务在提交后与MySQL断开连接,这时右终端中仍然可以看到之前插入的记录,因为事务提交后数据就被持久化了。
💕 begin会自动更改提交方式
如果左终端中的事务在提交之前与MySQL断开连接,那么MySQL依旧会自动让事务回滚到最开始,这时右终端中就看不到之前新插入的记录了。也就是说,使用begin或start transaction命令启动的事务,都必须要使用commit命令手动提交,数据才会被持久化,与是否设置autocommit无关。
实际全局变量autocommit是否被设置影响的是单条SQL语句,InnoDB中的每一条SQL都会默认被封装成事务。autocommit
为ON,则单条SQL语句执行后会自动被提交,如果为OFF,则SQL语句执行后需要使用 commit
进行手动提交。 实际我们之前一直都在使用单SQL事务,只不过autocommit默认是打开的,因此单SQL事务执行后自动就被提交了。
数据库的隔离级别有以下四种:
读未提交(Read Uncommitted)
: 在该隔离级别下,所有的事务都可以看到其他事务没有提交的执行结果,实际生产中不可能使用这种隔离级别,因为这种隔离级别相当于没有任何隔离性,会存在很多并发问题,如 脏读、幻读、不可重复读 等。读提交(Read Committed)
: 该隔离级别是大多数数据库的默认隔离级别,但它不是MySQL默认的隔离级别,它满足了隔离的简单定义:一个事务只能看到其他已经提交的事务所做的改变,但这种隔离级别存在 不可重复读 和 幻读 的问题。可重复读(Repeatable Read)
: 这是MySQL默认的隔离级别,该隔离级别确保同一个事务在执行过程中,多次读取操作数据时会看到同样的数据,即解决了不可重复读的问题,但这种隔离级别下仍然存在 幻读 的问题。串行化(Serializable)
: 这是事务的最高隔离级别,该隔离级别通过强制事务排序,使之不可能相互冲突,从而解决了幻读问题。它在每个读的数据行上面加上共享锁,但是可能会导致超时和锁竞争问题,这种隔离级别太极端,实际生成中基本不使用。虽然数据库事务的隔离级别有以上四种,但一个稳态的数据库只会选择这其中的一种,作为自己的默认隔离级别。但数据库默认的隔离级别有时可能并不满足上层的业务需求,因此数据库提供了这四种隔离级别,可以让我们自行设置。隔离级别基本上都是通过加锁的方式实现的,不同的隔离级别对锁的使用是不同的,常见的有表锁、行锁、写锁、间隙锁(GAP)、Next-Key锁(GAP+行锁)等。
查看全局隔离级别
select @@global.tx_isolation;
查看会话隔离级别
select @@session.tx_isolation;
此外,通过 select @@tx_isolation
命令,也可以查看当前会话的隔离级别。如下:
设置会话隔离级别
通过 set session transaction isolation level 隔离级别
命令,可以设置当前会话的隔离级别。
说明一下: 设置会话的隔离级别只会影响当前会话,新起的会话依旧采用全局隔离级。
设置全局隔离级别
通过 set global transaction isolation level 隔离级别
命令,可以设置全局隔离级别。
设置全局隔离级别会影响后续的新会话,但当前会话的隔离级别没有发生变化,如果要让当前会话的隔离级别也改变,则需要重启会话。
启动两个终端,将隔离级别设置为读未提交,并查看此时银行用户表中的数据。如下:
在两个终端各自启动一个事务,左终端中的事务所作的修改在没有提交之前,右终端中的事务就已经能够看到了。
说明一下:
启动两个终端,将隔离级别都设置为读提交,并查看此时银行用户表中的数据。
左边的事务所做的修改在没有提交之前,右边终端是无法看到的。
当左终端中的事务提交后,右终端中的事务才能看到修改后的数据。
一个事务在执行过程中,两个相同的select查询得到了不同的数据,这种现象叫做 不可重复读
。
启动两个终端,将隔离级别都设置为可重复读,并查看此时银行用户表中的数据。如下:
两个终端各自启动一个事务,左终端中的事务所作的修改在没有提交之前,右终端中的事务无法看到。
并且当左终端中的事务提交后,右终端中的事务仍然看不到修改后的数据。
只有当右终端中的事务提交后再查看表中的数据,这时才能看到修改后的数据。
说明一下:
启动两个终端,将隔离级别都设置为串行化,并查看此时银行用户表中的数据
在两个终端各自启动一个事务,如果这两个事务都对表进行的是读操作,那么这两个事务可以并发执行,不会被阻塞。
但如果这两个事务中有一个事务要对表进行写操作,那么这个事务就会立即被阻塞。
直到访问这张表的其他事务都提交后,这个被阻塞的事务才会被唤醒,然后才能对表进行修改操作。
串行化是事务的最高隔离级别,多个事务同时进行读操作时加的是共享锁,因此可以并发执行读操作,但一旦需要进行写操作,就会进行串行化,效率很低,几乎不会使用。
总结:
事务执行的结果,必须使数据库从一个一致性状态,变到另一个一致性状态,当数据库只包含事务成功提交的结果时,数据库就处于 一致性状态
。
也就是说,一致性实际是数据库最终要达到的效果,一致性不仅需要原子性、持久性和隔离性来保证,还需要上层用户编写出正确的业务逻辑。
数据库并发的场景有三种:
读-读
:不存在任何问题,也不需要并发控制读-写
:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读写-写
:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失说明一下:
多版本并发控制
多版本并发控制(Multi-Version Concurrency Control,MVCC)
是一种用来解决读写冲突的无锁并发控制,主要依赖记录中的 3个隐藏字段、undo日志和Read View 实现。
数据库中的每条记录都会有如下3个隐藏字段:
DB_TRX_ID
:6字节,创建或最近一次修改该记录的事务ID。DB_ROW_ID
:6字节,隐含的自增ID(隐藏主键)。DB_ROLL_PTR
:7字节,回滚指针,指向这条记录的上一个版本。说明一下:
示例
创建一个学生表,表中包含学生的姓名和年龄。如下:
当向表中插入一条记录后,该记录不仅包含name和age字段,还包含三个隐藏字段。如下:
MySQL的三大日志如下:
redo log
:重做日志,用于MySQL崩溃后进行数据恢复,保证数据的持久性。bin log
:逻辑日志,用于主从数据备份时进行数据同步,保证数据的一致性。undo log
:回滚日志,用于对已经执行的操作进行回滚,保证事务的原子性。MySQL会为上述三大日志开辟对应的缓冲区,用于存储日志相关的信息,必要时会将缓冲区中的数据刷新到磁盘。
MVCC的实现主要依赖三大日志中的undo log,记录的历史版本就是存储在undo log对应的缓冲区中的。
现在有一个事务ID为10的事务,要将刚才插入学生表中的记录的学生姓名改为“李四”:
DB_TRX_ID
改为10,回滚指针DB_ROLL_PTR
设置成undo log中副本数据的地址,从而指向该记录的上一个版本。现在又有一个事务ID为11的事务,要将刚才学生表中的那条记录的学生年龄改为38:
修改后的示意图如下:
此时我们就有了一个基于链表记录的历史版本链,而undo log中的一个个的历史版本就称为一个个的快照。
说明一下:
insert
和delete
的记录如何维护版本链?
当前读 VS 快照读
事务在进行增删查改的时候,并不是都需要进行加锁保护。事务对数据进行增删改的时候,操作的都是最新记录,即当前读,需要进行加锁保护。事务在进行select查询的时候,既可能是当前读也可能是快照读,如果是当前读,那也需要进行加锁保护,但如果是快照读,那就不需要加锁,因为历史版本不会被修改,也就是可以并发执行,提高了效率,这也就是MVCC的意义所在。
undo log中的版本链何时才会被清除?
说明一下:
事务在进行快照读操作时会生成读视图 Read View
,在该事务执行快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃的事务ID。Read View在MySQL源码中就是一个类,本质是用来进行可见性判断的,当事务对某个记录执行快照读的时候,对该记录创建一个Read View,根据这个Read View来判断,当前事务能够看到该记录的哪个版本的数据。
class ReadView {
// 省略...
private:
/** 高水位:大于等于这个ID的事务均不可见*/
trx_id_t m_low_limit_id;
/** 低水位:小于这个ID的事务均可见 */
trx_id_t m_up_limit_id;
/** 创建该 Read View 的事务ID*/
trx_id_t m_creator_trx_id;
/** 创建视图时的活跃事务id列表*/
ids_t m_ids;
/** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,
* 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
trx_id_t m_low_limit_no;
/** 标记视图是否被关闭*/
bool m_closed;
// 省略...
};
部分成员说明:
m_ids
: 一张列表,记录Read View生成时刻,系统中活跃的事务ID。m_up_limit_id
: 记录m_ids列表中事务ID最小的ID。m_low_limit_id
: 记录Read View生成时刻,系统尚未分配的下一个事务ID。m_creator_trx_id
: 记录创建该Read View的事务的事务ID。由于事务ID是单向增长的,因此根据Read View中的m_up_limit_id和m_low_limit_id,可以将事务ID分为三个部分:
源码策略
如下:
bool changes_visible(trx_id_t id, const table_name_t& name) const
MY_ATTRIBUTE((warn_unused_result))
{
ut_ad(id > 0);
//1、事务id小于m_up_limit_id(已提交)或事务id为创建该Read View的事务的id,则可见
if (id < m_up_limit_id || id == m_creator_trx_id) {
return(true);
}
check_trx_id_sanity(id, name);
//2、事务id大于等于m_low_limit_id(生成Read View时还没有启动的事务),则不可见
if (id >= m_low_limit_id) {
return(false);
}
//3、事务id位于m_up_limit_id和m_low_limit_id之间,并且活跃事务id列表为空(即不在活跃列表中),则可见
else if (m_ids.empty()) {
return(true);
}
const ids_t::value_type* p = m_ids.data();
//4、事务id位于m_up_limit_id和m_low_limit_id之间,如果在活跃事务id列表中则不可见,如果不在则可见
return (!std::binary_search(p, p + m_ids.size(), id));
}
使用该函数时将版本的DB_TRX_ID传给参数id,该函数的作用就是根据Read View,判断当前事务能否看到这个版本。
启动两个终端,将隔离级别都设置为可重复读,并查看此时银行用户表中的数据。如下:
在两个终端各自启动一个事务,在左终端中的事务操作之前,先让右终端中的事务查看一下表中的信息。如下:
左终端中的事务对表中的信息进行修改并提交,右终端中的事务看不到修改后的数据。如下:
在右终端中使用 select ... lock in share mode
命令进行当前读,可以看到表中的数据确实是被修改了,只是右终端中的事务看不到而已。如下:
但如果修改一下SQL的执行顺序,在两个终端各自启动一个事务后,直接让左终端中的事务对表中的信息进行修改并提交,然后再让右终端中的事务进行查看,这时右终端中的事务就直接看到了修改后的数据。如下:
在右终端中使用select ... lock in share mode
命令进行当前读,可以看到刚才读取到的确实是最新的数据。如下:
RR与RC的本质区别