【mysql】事务

发布于:2025-04-01 ⋅ 阅读:(17) ⋅ 点赞:(0)

事务的概念

事务由一条或者多条sql语句组成,这些语句具有逻辑性,共同完成一个任务。事务主要用于处理操作量大,复杂度高的数据。

比如转账就涉及多条SQL语句,包括查询余额(select)、在当前账户上减去指定金额(update)、在指定账户上加上对应金额(update)等,将这多条SQL语句打包便构成了一个事务。

mysql服务器端肯定会被多个客户端连接,所以mysql可能存在大量的事务,mysql肯定要对事务进行管理

如何管理?六字真言!——先描述,再组织(封装成结构体,用链表相连)

事务的四个属性

MySQL同一时刻可能存在大量事务,如果不对这些事务加以控制,在执行时就可能会出现问题。比如单个事务内部的某些SQL语句执行失败,或是多个事务同时访问同一份数据导致数据不一致的问题

  • 原子性: 一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中如果发生错误,则会自动回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
  • 持久性: 事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
  • 隔离性: 数据库允许多个事务同时访问同一份数据,隔离性可以保证多个事务在并发执行时,不会因为由于交叉执行而导致数据的不一致。
  • 一致性: 在事务开始之前和事务结束以后,数据库的完整型没有被破坏,这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联型以及后续数据库可以自发性地完成预定的工作。

这四个属性简称ACID

为什么要有事务?为了方便我们使用,因为日常生活中使用mysql的场景千变万化,肯定不是一条sql就能解决了的,mysql中只有InnoDB支持事务

接下来我们要仔细了解一下ACID

事务的提交

分为自动提交和手动提交

begin手动开启事务,commit手动提交事务,这中间的部分都是事务

中间可以留下保存点——savepoint 名字,如果结果不满意可以rollback to 名字,回滚到对应的位置。如果直接rollback,则直接回滚到事务的最开始

begin开始的事务,无论是否开启自动提交,都要自己手动提交

自动提交其实是针对一条sql的,我们平常敲得一句句sql,都被封装成了一个事务,敲完mysql就帮我们提交了

原子性

如果mysql启动了一个事务进行了一半突然卡死或者断线了,则将自动回滚到事务最开始,这体现了原子性——要么不做,要么就做完!

持久性

如果提交后,即使此时断线事务也不能再回滚了,因为提交后数据被持久化了

我们之前输入的每一条单独的指令也是一个事务,如果自动提交开启,他们也就默认自动提交

隔离性

隔离性是最难理解,也是非常重要的一个概念。

隔离性就如同人一样,能看见自己活着的期间的事务,也能知道一些历史事件,但是无法预知未来,每个事务也是如此,只能看见一段时期的事物

  • MySQL服务可能会同时被多个客户端进程(线程)访问,访问的方式以事务的方式进行。
  • 一个事务可能由多条SQL语句构成,也就意味着任何一个事务,都有执行前、执行中和执行后三个阶段,而所谓的原子性就是让用户层要么看到执行前,要么看到执行后,执行中如果出现问题,可以随时进行回滚,所以单个事务对用户表现出来的特性就是原子性。
  • 但毕竟每个事务都有一个执行的过程,在多个事务各自执行自己的多条SQL时,仍然可能会出现互相影响的情况,比如多个事务同时访问同一张表,甚至是表中的同一条记录。
  • 数据库为了保证事务执行过程中尽量不受干扰,于是出现了隔离性的概念,而数据库为了允许事务在执行过程中受到不同程度的干扰,于是出现了隔离级别的概念。

数据库事务的隔离级别有以下四种:

  • 读未提交(Read Uncommitted): 在该隔离级别下,所有的事务都可以看到其他事务没有提交的执行结果,实际生产中不可能使用这种隔离级别,因为这种隔离级别相当于没有任何隔离性,会存在很多并发问题,如脏读、幻读、不可重复读等。
  • 读提交(Read Committed): 该隔离级别是大多数数据库的默认隔离级别,但它不是MySQL默认的隔离级别,它满足了隔离的简单定义:一个事务只能看到其他已经提交的事务所做的改变,但这种隔离级别存在不可重复读和幻读的问题。
  • 可重复读(Repeatable Read): 这是MySQL默认的隔离级别,该隔离级别确保同一个事务在执行过程中,多次读取操作数据时会看到同样的数据,即解决了不可重复读的问题,但这种隔离级别下仍然存在幻读的问题。
  • 串行化(Serializable): 这是事务的最高隔离级别,该隔离级别通过强制事务排序,使之不可能相互冲突,从而解决了幻读问题。它在每个读的数据行上面加上共享锁,但是可能会导致超时和锁竞争问题,这种隔离级别太极端,实际生成中基本不使用。

读未提交就是没有加任何的锁,没有对数据做任何的保护,任何一个事务改变数据其他访问该数据的事务都能看到 

串行化不是sql语句的串行化,而是事务的串行化。先来的事务提交后,后来的事务才可以开始

一般的数据库在可重复读隔离级别下,update数据是满足可重复读的,但insert数据会存在幻读问题

  • 读未提交:A,B开始事务且看一张表,A插入数据但没提交,Bselect的时候已经可以看到改变
  • 读提交(不可重复读):A,B开始事务且看一张表,A插入数据但没提交,Bselect的时候看不到改变。A提交后,B没提交,此时Bselect的时候可以看到改变
  • 可重复读:A,B开始事务且看一张表,A插入数据但没提交,Bselect的时候看不到改变。A提交后,B没提交,此时B仍然看不到变化。当B提交后,才能看到变化

本文一些地方引用了一位大佬的博客:https://blog.csdn.net/chenlong_cxy/article/details/128919989

一个事务在执行过程中,相同的select查询得到的是相同的数据,这就是所谓的可重复读

一个事务在执行过程中,相同的select查询得到的是不同的数据,这就是所谓的不可重复读

一个事务在执行过程中,相同的select查询得到了新的数据,如同出现了幻觉,这种现象叫做幻读。(MySQL是通过Next-Key锁(GAP+行锁)——间隙锁 + 行锁来解决幻读问题的)

如何区分不可重复读和幻读?可重复读查询的结果的行数一致,幻读查询结果的行数不一致

注意:事务虽然有前后,但是先来的可能晚结束,后来的也可能早结束(非串行下)

一致性

事务执行的结果,必须使数据库从一个一致性状态,变到另一个一致性状态,当数据库只包含事务成功提交的结果时,数据库就处于一致性状态

出现故障的时候,要通过原子性进行回滚,仿佛什么都没发生一样

提交成功后,要通过持久性将数据永久存储

多个事务同时访问同一份数据时,必须保证这多个事务在并发执行时,不会因为由于交叉执行而导致数据的不一致,即一致性需要隔离性来保证

一致性与用户的业务逻辑强相关,如果用户本身的业务逻辑有问题,最终也会让数据库处于一种不一致的状态

注意:一致性实际是数据库最终要达到的效果,一致性不仅需要原子性、持久性和隔离性来保证,还需要上层用户编写出正确的业务

多版本并发控制

数据库存在三种并发

  • 读-读并发:不存在任何问题,也不需要并发控制。
  • 读-写并发:有线程安全问题,可能会存在事务隔离性问题,可能遇到脏读、幻读、不可重复读。
  • 写-写并发:有线程安全问题,可能会存在两类更新丢失问题。

注意:

  • 写-写并发场景下的第一类更新丢失又叫做回滚丢失,即一个事务的回滚把另一个已经提交的事务更新的数据覆盖了,第二类更新丢失又叫做覆盖丢失,即一个事务的提交把另一个已经提交的事务更新的数据覆盖了。

  • 读-读并发不需要进行并发控制,写-写并发实际也就是对数据进行加锁,这里最值得讨论的是读-写并发,读-写并发是数据库当中最高频的场景,在解决读-写并发时不仅需要考虑线程安全问题,还需要考虑并发的性能问题。

为了解决读写的并发问题,mysql提出了多版本并发控制(MVCC)解决读写冲突的无锁并发控制

MVCC保证读写并发时,读操作不会阻塞写操作,写操作也不会阻塞读操作,提高了数据库并发读写的性能,同时还可以解决脏读、幻读和不可重复读等事务隔离性问题

怎么做到的?

为事务分配单向增长的事务ID,为每个记录修改保存一个版本,将版本与事务ID相关联,读操作只读该事务开始前的数据库快照

mvcc使读写并发时可以不加锁,因为访问的是不同版本的数据!!!

mvcc=记录中的三个隐藏字段+undo log+read view

每条记录的三个隐藏字段

  • DB_TRX_ID:6字节,创建或最近一次修改该记录的事务ID。
  • DB_ROW_ID:6字节,隐含的自增ID(隐藏主键)。
  • DB_ROLL_PTR:7字节,回滚指针,指向这条记录的上一个版本。

若以InnoDB为默认存储引擎,如果表中没有主键,以隐藏主键为主键建表

数据库表中的每条记录还有一个删除flag隐藏字段,用于表示该条记录是否被删除,便于进行数据回滚。

mysql的三大日志

  • redo log:重做日志,用于MySQL崩溃后进行数据恢复,保证数据的持久性。
  • bin log:逻辑日志,用于主从数据备份时进行数据同步,保证数据的一致性。
  • undo log:回滚日志,用于对已经执行的操作进行回滚,保证事务的原子性。

MySQL会给每个日志都提供一份缓冲区,存放对应的数据

MVCC主要依赖undo log

快照

当表里存在一条数据时,接下来事务id为10和11对该记录进行修改

undo log存储每条记录的增删改,上面为改

undo log中数据以消息为单元,各个版本用链表相连

此时我们就有了一个基于链表记录的历史版本链,而undo log中的一个个的历史版本就称为一个个的快照

  • 数据的增删其实是将对应的删除字段设为true,这在很多场景都出现
  • 记录的增删就是将对应的删除字段设为是/否,放进undo log中,这样回滚的时候就实现了删/增 
  • 什么是回滚?就是通过最近版本记录的回滚指针找到上一个版本的,用之前版本覆盖当前版本
  • 这个事务首次进行快照读的时候,mysql形成read view
  • 这种技术实际就是基于版本的写时拷贝,当需要进行写操作时先将最新版本拷贝一份到undo log中,然后再进行写操作,和父子进程为了保证独立性而进行的写时拷贝是类似的。

当一条记录的最新版本已经修改并提交,并且此时没有其他事务与该记录的历史版本有关了,这时该记录在undo log中的版本链才可以被清除。

当前读VS快照读

  • 当前读:读取最新的记录,就叫做当前读
  • 快照读:读取历史版本,就叫做快照读
  • 读未提交,串行化——当前读
  • 读提交,不可重复读——可能是当前读,也可能是快照读

事务在进行增删查改的时候,并不是都需要进行加锁保护:

事务对数据进行增删改的时候,操作的都是最新记录,即当前读,需要进行加锁保护。
事务在进行select查询的时候,既可能是当前读也可能是快照读,如果是当前读,那也需要进行加锁保护,但如果是快照读,那就不需要加锁,因为历史版本不会被修改,也就是可以并发执行,提高了效率,这也就是MVCC的意义所在。

MVCC支持了快照读(将每个记录的历史版本保存起来),如果是快照读就不用加锁进行并发控制,但如果是当前读或者写还是需要加锁并发控制,但减少了加锁的场景

视图

MVCC 为每个事务生成一个“一致性视图”(Read View),基于该视图决定事务能看到哪些版本的数据

  • 版本链管理:每条记录的隐藏字段(如 DB_TRX_IDDB_ROLL_PTR)指向旧版本数据,形成版本链

  • 可见性判断:事务根据版本链和 Read View 判断哪些数据版本对当前事务可见(如已提交的事务版本)

事务在进行快照读操作时会生成读视图Read View,在该事务执行快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃的事务ID。

当事务对某个记录执行快照读的时候,对该记录创建一个Read View,根据这个Read View来判断,当前事务能够看到该记录的哪个版本的数据。

快照读是读取记录的历史版本,但是能读哪些历史版本我们要进行判断,通过什么判断,通过视图来判断

Read View在MySQL源码中就是一个类,本质是用来进行可见性判断的

这里借助了事务id单调增长的特性

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_up_limit_id的事务,一定是生成Read View时已经提交的事务
  • 大于等于m_low_limit_id的事务,一定是生成Read View时还没有启动的事务
  • 位于m_up_limit_id和m_low_limit_id之间的事务,在生成Read View时可能正处于活跃状态,也可能已经提交了,这时需要通过判断事务ID是否存在于m_ids中来判断该事务是否已经提交。

版本链中的每个版本的记录都有自己的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));
}

事务对一条记录进行快照读,对该记录生产快照读,然后根据这条记录的历史版本,从新往旧将每条记录的DB_TRX_ID(创建或最近一次修改该记录的事务ID)传参进去,判断当前事务能否看到该版本快照,若在上图的左区域或者中间区域且不在活跃列表中则可见,否则不可见继续遍历

RR与RC的本质区别

RR级别下,某个事务第一次快照读会创建一个快照和read view,此后的快照读使用的是同一个read view,RR只有一个read view且不更新——可见性不变

而在RC级别下,事务每次进行快照读时都会创建一个Read View,然后根据这个Read View进行可见性判断,因此每次快照读时都能读取到被提交了的最新的数据,所以可见性变化

所以MVCC也是实现 可重复读(Repeatable Read) 和 读已提交(Read Committed) 隔离级别的关键技术

MVCC:核心思想是通过保留数据的多个版本来实现读操作不阻塞写操作、写操作不阻塞读操作,从而显著提高数据库的并发性能,且也是隔离级别实现的关键技术

但高并发情况下,mysql还是存在写冲突的问题