本篇主要为理论讲解,想看实践的即使刹车。
MySQL事务遇到的一些小坑
- 使用begin或start transaction命令,可以启动一个事务。
- 使用savepoint 保存点命令,可以在事务中创建指定名称的保存点。
- 使用rollback to 保存点命令,可以让事务回滚到指定保存点。
- 使用rollback命令,可以直接让事务回滚到最开始。
- 使用commit命令,可以提交事务,提交事务后就不能回滚了。
我们知道有一条命令:
show variables like 'autocommit';
这一条语句可以查看语句的autocommit字段的相关信息,这个字段的意思是:自动提交功能是否开启了。如果是1的话就代表开启了,如果是0的话代表没有开启。我们可以通过命令:
set autocommit = 0;
来关闭自动提交功能。或者使用:
set autocommit = 1;
来开启自动提交功能。
这里遇到的坑是,实际上我们使用每一条SQL语句都默认开启了事务,我们可以显式的使用命令开启事务和提交事务。也可以不适用命令,执行单条语句的时候数据库会默认给我们开启事务功能。我们的autocommit
自动是针对显式开启事务功能的。意思就是我们就算关闭了autocommit功能,当我们使用begin命令开启事务的时候,它仍然会自动提交事务(例如宕机的时候)。但是如果关闭这个功能的话,我们执行单挑语句的时候就需要主动commit才能提交事务了。
事务ACID
事务有4个属性:
- 原子性: 一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中如果发生错误,则会自动回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
- 持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
- 隔离性: 数据库允许多个事务同时访问同一份数据,隔离性可以保证多个事务在并发执行时,不会因为由于交叉执行而导致数据的不一致。
- 一致性:原子性,持久性,隔离性共同完成的目标就是完成一致性。
事务的隔离级别
首先问自己,事务为什么要有隔离级别?
- MySQL服务可能会同时被多个客户端进程(线程)访问,访问的方式以事务的方式进行。
- 一个事务可能由多条SQL语句构成,也就意味着任何一个事务,都有执行前、执行中和执行后三个阶段,而所谓的原子性就是让用户层要么看到执行前,要么看到执行后,执行中如果出现问题,可以随时进行回滚,所以单个事务对用户表现出来的特性就是原子性。
- 但毕竟每个事务都有一个执行的过程,在多个事务各自执行自己的多条SQL时,仍然可能会出现互相影响的情况,比如多个事务同时访问同一张表,甚至是表中的同一条记录。
- 数据库为了保证事务执行过程中尽量不受干扰,于是出现了隔离性的概念,而数据库为了允许事务在执行过程中受到不同程度的干扰,于是出现了隔离级别的概念。
数据库事务的隔离级别有以下四种:
- 读未提交(Read Uncommitted): 在该隔离级别下,所有的事务都可以看到其他事务没有提交的执行结果,实际生产中不可能使用这种隔离级别,因为这种隔离级别相当于没有任何隔离性,会存在很多并发问题,如脏读、幻读、不可重复读等。
- 读提交(Read Committed): 该隔离级别是大多数数据库的默认隔离级别,但它不是MySQL默认的隔离级别,它满足了隔离的简单定义:一个事务只能看到其他已经提交的事务所做的改变,但这种隔离级别存在不可重复读和幻读的问题。
- 可重复读(Repeatable Read): 这是MySQL默认的隔离级别,该隔离级别确保同一个事务在执行过程中,多次读取操作数据时会看到同样的数据,即解决了不可重复读的问题,但这种隔离级别下仍然存在幻读的问题。
- 串行化(Serializable): 这是事务的最高隔离级别,该隔离级别通过强制事务排序,使之不可能相互冲突,从而解决了幻读问题。它在每个读的数据行上面加上共享锁,但是可能会导致超时和锁竞争问题,这种隔离级别太极端,实际生成中基本不使用。
隔离级别基本上都是通过加锁的方式实现的,不同的隔离级别对锁的使用是不同的,常见的有表锁,行锁,写锁,间隙锁(GAP),**Next-Key(GAP+行锁)**等
Tips:
当你设置了隔离级别之后,当前会话是没有改变的,必须要采用的新的会话。
现在我们来详细的讲解一下每一个隔离级别。
读未提交(Read Uncommitted)
读未提交的意思就是在还没有提交事务的时候,执行的SQL对其他事务就已经可见了。
- 读未提交是事务的最低隔离级别,几乎没有加锁,虽然效率高,但是问题很多,严重不推荐。
- 脏读:一个事务在执行过程中,读取到另一个执行中的事务做到修改,但是该事务还没有进行提交,这种现象叫做脏读。
- 读未提交有脏读,不可重复读,幻读的问题。
读提交(Read Committed)
读提交的意思就是,必须等事务提交之后,执行的SQL才能对其他事务可见。
- 不可重复读:一个事务在执行的过程中,两个相同的
select
查询到了不同的数据,这种现象叫做不可重复读。 - 读提交有不可重复读和幻读的问题。
那么有一个问题,不可重复读到底是不是问题,它对业务会造成什么影响?
我们构想有这样一个业务:
银行根据不同的用户余额发放礼物。
if (money > 10000w) 车;
if (money > 5000w) VIP黑卡;
if (money > 1000w) 商场消费券;
if (money > 50w) 100元京东消费券;
我马云在一个事务里面转走5000w,然后另外一个发礼物的事务看到的余额就变了,成5000w了,成5000w了之后,我判断,再送vip黑卡,然后马云又转了3000w,所有我发礼物的事务又判断大于1000,又发了消费券,这导致我发了多个礼品。理论上来说,我发礼物的事务只能看到一次结果,中途是不可以发生变化的。
因此这个状态在有的时候会带来业务上的问题的,是一个需要解决的问题。
可重复读(Repeatable Read)
可重复读的意思就是在当前事务提交之前,使用同样的select
语句查询到的结果是一样的。
- 在可重复读隔离级别下,一个事务在执行过程中,相同的select查询得到的是相同的数据,这就是所谓的可重复读。
- 一般的数据库在可重复读隔离级别下,update数据是满足可重复读的,但insert数据会存在幻读问题,因为隔离性是通过对数据加锁完成的,而新插入的数据原本是不存在的,因此一般的加锁无法屏蔽这类问题。
- 一个事务在执行过程中,相同的select查询得到了新的数据,如同出现了幻觉,这种现象叫做幻读。
串行化(Serializable)
串行化是指事务之间的串行化。查询除外,查询可以并发执行,不是串行的。
如果进行其他的操作的话,那么我们的sql就会被卡住了。因为此时我的系统同时运行了两个事务,而我们的事务必须是串行化的,不可以并发运行,所以实际上不是sql被卡住了,而是这个事务被卡住了,我们必须等另一个事务提交commit之后我才可以把卡住的sql运行成功。(其中,卡住的时间mysql是有规定的,它不可能让你一直阻塞下去)。
其次,如果我们的另外一个事务也使用了insert等操作,那么就相当于我一个事务要等你退出我才会退出,而一个事务也同样需要等你退出了之后我才可以退出,因此这就造成了死锁问题。当遇到死锁问题之后,MySQL会自动检测到这个问题,于是会重新启动一个事务,在重新启动的一瞬间,我一个事务的sql就执行了,因为在那一瞬间事务变成串行化的了。
- 串行化是事务的最高隔离级别,多个事务同时进行读操作时加的是共享锁,因此可以并发执行读操作,但一旦需要进行写操作,就会进行串行化,效率很低,几乎不会使用。
总结
- 隔离级别越严格,安全性越高,但数据库的并发性能也就越低,在选择隔离级别时往往需要在两者之间找一个平衡点。
理解事务的隔离级别第二个层次 – 多版本并发控制(MVCC)
数据库并发场景有3种:
- 读 - 读:不存在任何问题,也不需要并发控制,没有什么考虑的。
- 读 - 写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
- 写 - 写:有线程安全问题,可能会存在跟新丢失问题,比如第一类更新丢失,第二类更新丢失(这个就必须要加锁了,参考线程安全问题,所以没有什么考虑的)
什么叫丢失更新问题?
丢失更新 就是两个事务在并发下同时进行更新,后一个事务的更新覆盖了前一个事务更新的情况,丢失更新是数据没有保证一致性导致的。比如,事务A 修改了一条记录,事务B 在 事务A 提交的同时也进行了一次修改并且提交。当事务A查询的时候,会发现刚才修改的内容没有被修改,好像丢失了更新。
所谓的数据库的并发问题,实际上就是主要考虑读-写操作的时候,在数据库的同一张表的同一条记录上。
那么问题就已经很明了了,读 - 读不存在并发问题,写 - 写百分百要加锁,没有什么可以谈论的。MySQL在读 - 写上面做了一些功夫,实现了多版本并发控制(MVCC)。使得不用锁,也可以实现读 - 写并发执行。
下面简要介绍一下MVCC:
- 多版本并发控制(Multi-Version Concurrency Control,MVCC)是一种用来解决读写冲突的无锁并发控制,主要依赖记录中的3个隐藏字段、undo日志和Read View实现。
- 为事务分配单向增长的事务ID,为每个修改保存一个版本,将版本与事务ID相关联,读操作只读该事务开始前的数据库快照。
- MVCC保证读写并发时,读操作不会阻塞写操作,写操作也不会阻塞读操作,提高了数据库并发读写的性能,同时还可以解决脏读、幻读和不可重复读等事务隔离性问题。
我们接下来依次对实现MVCC的三个重要的点进行讲解:
3个隐藏字段
- DB_TRX_ID:6字节,创建或最近一次修改该记录的事务ID。
- DB_ROW_ID:6字节,隐含的自增ID(隐藏主键)。
- DB_ROLL_PTR:7字节,回滚指针,指向这条记录的上一个版本。
Tips:
- 采用InnoDB存储引擎建立的每张表都会有一个主键,如果用户没有设置,InnoDB就会自动以
DB_ROW_ID
产生一个聚簇索引。- 此外,数据库表中的每条记录还有一个删除flag隐藏字段,用于表示该条记录是否被删除,便于进行数据回滚。
UNDO日志
MySQL有三大日志:
- redo log:重做日志,用于MySQL崩溃后进行数据恢复,保证数据的持久性。
- bin log:逻辑日志,用于主从数据备份时进行数据同步,保证数据的一致性。
- undo log:回滚日志,用于对已经执行的操作进行回滚,保证数据的原子性。
其中redo log和bin log需要刷盘持久化,而undo log只是一个内存级别的日志,刷盘毫无意义。
快照的概念
现在有一个事务ID为50的事务,要修改一条数据,把balance从100000修改成200000.
- 因为是要进行写操作,所以要先给该行记录进行加锁。
- 写入前先把这一行的记录拷贝到undo log中,此时undo log中就有了一行副本数据。
- 然后将原始记录中的balance修改,并将该记录的
DB_TRX_ID
改为10,回滚指针DB_ROLL_PTR
设置成undo log中副本数据的地址,从而指向该记录的上一个版本。 - 最后事务50提交后释放锁,这是最新的记录就已经变了。
图片来自于小林coding
insert和delete的记录如何维护版本链?
- 删除记录并不是真的把数据删除了,而是先将该记录拷贝一份放入undo log中,然后将该记录的删除flag隐藏字段设置为1,这样回滚后该记录的删除flag隐藏字段就又变回0了,相当于删除的数据又恢复了。
- 新插入的记录是没有历史版本的,但是一般为了回滚操作,新插入的记录也需要拷贝一份放入undo log中,只不过被拷贝到undo log中的记录的删除flag隐藏字段被设置为1,这样回滚后就相当于新插入的数据就被删除了。
当前都 VS 快照读
- 当前读:读取最新的记录,就叫做当前读。
- 快照读:读取历史版本,就叫做快照读。
事务在进行增删查改的时候,并不是都需要进行加锁保护:
- 事务对数据进行增删改的时候,操作的都是最新记录,即当前读,需要进行加锁保护。
- 事务在进行select查询的时候,既可能是当前读也可能是快照读,如果是当前读,那也需要进行加锁保护,但如果是快照读,那就不需要加锁,因为历史版本不会被修改,也就是可以并发执行,提高了效率,这也就是MVCC的意义所在。
而select查询时应该进行当前读还是快照读,则是由隔离级别决定的,在读未提交和串行化隔离级别下,进行的都是当前读,而在读提交和可重复读隔离级别下,既可能进行当前读也可能进行快照读。
undo log的版本链何时才能被清除?
- 在undo log中形成的版本链不仅仅是为了进行回滚操作,其他事务在执行过程中也可能读取版本链中的某个版本,也就是快照读。
- 因此,只有当某条记录的最新版本已经修改并提交,并且此时没有其他事务与该记录的历史版本有关了,这时该记录在undo log中的版本链才可以被清除。
Read View
Read View是事务进行快照读操作的时候产生的读视图(Read View)。
MySQL可能同时存在大量的事务吗?
当然可能,MySQL需要管理这些大量的事务吗?
当然要管理了!!!
那么MySQL怎么管理事务呢?当然是先描述再组织
而先描述再组织的前提其实就是把事务的相关信息抽象成一个结构体,所以MySQL创建一个事务,我们就可以理解成,我们的MySQL给这个事务创建了一个结构体来表示这个事务。
而这个结构体里面有一个成语:叫做Read View。
而Read View也是一个结构体。
然后让我们来看看ReadView这个类:
class ReadView {
private:
trx_id_t m_low_limit_id; // 高水位,大于等于这个ID的事务均不可见
trx_id_t m_up_limit_id; // 低水位,小于这个ID的事务均可见
trx_id_t m_creator_trx_id; // 创建该Read View的事务ID
ids_t m_ids; // 创建视图时活跃事务id列表
}
对部分字段的详细解释:
m_ids; // 一张列表,用来维护Read View生成时刻,系统正在活跃的事务ID, 也就是所有正在运行的事务
m_up_limit_id; // 记录m_ids列表中事务ID最小的ID(没有写错!!)
m_low_limit_id; // ReadView生成时刻系统尚未分配的下一个事务ID,也就是已出现过的事务ID的最大值+1(没有写错!!)
m_creator_trx_id; // 创建该ReadView的事务ID
我们在实际读取数据版本链的时候,是可以读取到每一个版本的事务ID的,即当前记录的:DB_TRX_ID。
那么现在,我们手里的东西就有:当前快照读的ReadView和版本链中的某一个记录的DB_TRX_ID。
任何时刻都有多个事务在不断的到来,事务ID在不断递增,所以事务ID大小可以决定先后顺序。
- 事务ID小于m_up_limit_id的事务,一定是生成Read View时已经提交的事务,因为m_up_limit_id是生成Read View时刻系统中活跃事务ID中的最小ID,因此事务ID比它小的事务在生成Read View时一定已经提交了。
- 事务ID大于等于m_low_limit_id的事务,一定是生成Read View时还没有启动的事务,因为m_low_limit_id是生成Read View时刻,系统尚未分配的下一个事务ID。
- 事务ID位于m_up_limit_id和m_low_limit_id之间的事务,在生成Read View时可能正处于活跃状态,也可能已经提交了,这时需要通过判断事务ID是否存在于m_ids中来判断该事务是否已经提交。
示意图如下:
- 一个事务在进行读操作时,只应该看到自己或已经提交的事务所作的修改,因此我们可以根据Read View来判断当前事务能否看到另一个事务所作的修改。
- 版本链中的每个版本的记录都有自己的DB_TRX_ID,即创建或最近一次修改该记录的事务ID,因此可以依次遍历版本链中的各个版本,通过Read View来判断当前事务能否看到这个版本,如果不能则继续遍历下一个版本。
// 使用该函数时将版本的DB_TRX_ID传给参数id,该函数的作用就是根据Read View,判断当前事务能否看到这个版本。
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));
}
RR与RC的本质区别
- 正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同
- 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事务记录起来此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见;
- 即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见
- 而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因
- 总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。
- 正是RC每次快照读,都会形成Read View,所以,RC才会有不可重复读问题。