MySQL-事务

MySQL-事务

1.什么是事务?

事务(Transaction) 是数据库操作的最小逻辑工作单元。它由一个或多个 SQL 语句组成,这些语句要么全部成功,要么全部失败。

如果没有事务,会有什么影响吗?

举一个很经典的例子:转账案例。A 的账户里有 1000 元,B 的账户里有 500 元,现在 A 要将自己的 200 元转给 B,这个操作需要两步:

  • 第一步:A 的余额 = A 的余额 - 200,A 的余额 = 800 元
  • 第二步:B 的余额 = B 的余额 + 200,B 的余额 = 700 元

理想状态下,这两步正常执行,可以完成转账。但是,还有一些不理想的情况:

  • 第一步正常执行:A 的余额 = A 的余额 - 200,A 的余额 = 800 元
  • 第二步出现错误,可能是突然断电、MySQL 服务不可用,服务器宕机等等,导致 B 的余额 = B 的余额 + 200 这个操作没有被执行,B 的余额 = 500 元

这个时候,出现了数据的不一致,A 明明转了 200 元,账户余额从 1000 元减少到了 800 元,但是 B 始终没有收到这笔存款。如果是你作为 A 这个角色给别人转账,你能忍受这种错误吗?

有了事务,就可以避免这种错误:将这两个步骤看作一个事务,这两步要么全部成功,要么全部失败。如果执行成功,没有任何问题;即使中间出现错误,就意味着 A - 200B + 200 这两个操作失败,A 的余额和 B 的余额不会减少也不会增加,恢复到事务开启前的状态。

2.事务的状态

active 状态:事务正在进行

partically committed 状态:部分提交,事务已经提交,但是数据还没有完全持久化到磁盘,还有部分数据遗留在内存中

committed 状态:事务已经提交,数据持久化到磁盘

failed 状态:事务在 active 状态或者 partically committed 状态中出现错误

aborted 状态:中止

  • 事务从 active 变成 failed,需要回滚数据,还原到事务开启前的状态

  • 事务从 partically committed 变成 failed,因为事务已经提交,不需要回滚数据

    active ---> partically committed ---> committed
    | |
    | |
    | |
    ↓ ↓
    ( failed ) ----active---> aborted(回滚)

3.事务的 ACID 特性

ACID 都是什么意思:

  • Atomicity(原子性):事务是一个不可分割的整体,就像原子一样。事务中的操作要么全部成功,要么全部失败,没有部分操作成功,部分操作失败这种中间状态。
  • Consistency(一致性):事务的执行结果,必须使数据库从一个一致性状态 变换到另一个一致性状态 。至于什么状态是一致性状态,这个标准是人为规定的。
    • 以上面的转账为例:A 的账户扣钱了,但是其他用户的账户没有收到这笔钱,这就是不一致状态。
    • 或者说,系统中有 user 表和 user_info 表,user 表保存用户记录,user_info 表保存用户的个人信息。一个用户注销账号后,该用户在 user 表的记录被删除,但是在 user_info 表中的记录没有被删除,有遗留数据,这也可以是不一致状态。
    • 在视频网站上,你点赞或者收藏了一些视频,然后你在查阅自己的点赞列表或者收藏列表的时候,打开一个视频,结果提示你"这个视频已经被作者删除或者下架,无法查看"。如果你认为"作者下架或者删除视频,这些视频不应该出现在其他用户的点赞或者收藏中",那么这就是不一致状态;如果你认为"作者下架或者删除视频,不需要同步所有点赞、收藏该视频的用户的记录,只需要在用户试图查看视频的时候提示他们即可,反正最后的结果都是看不到",那么这就是一致性的状态。
  • Isolation(隔离性):多个事务并发执行时,一个事务的执行不应影响其他事务。这里的不影响指的是数据不会互相污染,因为并发事务是一定会互相影响的。并发事务会争抢锁,一个事务获得锁,其他事务必须等待这个事务释放才能继续执行;并发事务数量太多,内存空间不足,CPU 高压环境性能降低等等。以此类推,并发事务在进程、线程、内存、CPU、磁盘中都会互相影响,所以这里的隔离性指的是事务的数据不会互相污染。
  • Durability(持久性):一旦事务提交成功,它对数据库的修改就是永久性的,即使系统发生故障(如断电、崩溃),数据也不会丢失。

事务的隔离性是通过锁机制实现的,而事务的原子性、一致性和持久性是通过 redo log 和 undo log 实现的。

  • redo log:重做日志,保证一致性。

    • 记录事务对数据页的物理修改(比如:在某个页的某个偏移量处写了什么数据)
    • 对数据的修改先写入 redo log,再写入磁盘,日志写入成功才算更新成功
    • 当事务提交时,所有更新操作都保证已经写入 redo log
    • 当发生宕机等故障时,即使有些数据还没来得及从内存写入磁盘,但是所有的更新操作已经记录到 redo log 中,重启后 MySQL 也能根据 redo log 重新执行这些修改,防止数据丢失
  • undo log:回滚日志,保证原子性和持久性。

    • 对数据的更新不仅要记录到 redo log,还要记录到 undo log
    • undo log 记录的是逻辑修改。例如,执行插入操作,在 undo log 中就会记录相反的删除操作;执行更新操作,在 undo log 中就会记录相反的更新操作
    • 当事务执行过程中出现异常,需要回滚数据时,就可以根据 undo log 将更新的数据修正回来,将事务中插入的记录删除,将更新的记录修改成原来的值,将删除的记录重新插入

事务的 ACID 特性中,原子性是基础,没有原子性就不能称为事务,一致性是约束条件,隔离性是主要手段,持久性是最终目的。

4.事务的隔离级别

并发事务存在的问题,这些问题的严重程度由低到高排列:

  • 脏写:一个事务可以修改另一个事务未提交的数据
  • 脏读:一个事务可以读取另一个事务未提交的数据
  • 不可重复读:同一个事务中多次读取同一个数据,结果不一致
  • 幻读:同一个事务中,多次用相同条件查询,返回的记录集行数不一样

幻读强调的是增加和删除,导致查询的记录集不同。

不可重复读强调的是数据的修改,导致查询得到的数据不同。

MySQL 提供的事务隔离级别:

  • 读未提交(Read Uncommitted):可以读取其他事务未提交的数据,可以解决脏写,但是还是会出现脏读、不可重复读、幻读问题。
  • 读已提交(Read Committed):只能读取其他事务已经提交的数据,可以解决脏读,但是还是会出现不可重复读、幻读问题。
  • 可重复读(Repeatable Read):在同一个事务中,多次读取同一个数据的结果一致,可以解决不可重复读以及部分幻读问题。
  • 可串行化(Serializable):所有事务按顺序一个一个执行,完全隔离,可以解决幻读,但是因为事务只能串行执行,所以并发性能也是最差的。

事务隔离级别越高,数据一致性越高,但是并发性能越差,MySQL 默认的隔离级别是可重复读。

5.MVCC

MVCC(Multi-Version Concurrency Control):多版本并发控制,利用数据行的多个版本实现数据库的并发控制。

在读已提交、可重复读隔离级别中使用到了 MVCC。在这两个级别中,都可以避免脏读问题,在读取一个事务未提交的数据时,不需要读取最新的数据,而是读取这个数据之前的值,也就是旧版本的值,不需要等待事务释放锁。

MVCC 三剑客:隐藏字段、undo log、ReadView。

在 InnoDB (只有 InnoDB 支持 MVCC)的表中,每个聚簇索引的记录都包含两个字段:

  • trx_id:事务对这个记录更新时,会记录这个事务的 id 到 trx_id 中

  • roll_pointer:每次更新记录,都会用新值替换旧值,将旧值写入 undo log,roll_pointer 是聚簇索引记录中的一个指针,通过这个指针可以找到 undo log 中旧版本的数据

    A事务插入一条记录,A事务id是1777:insert into user (id,name,age) values (100,'李华',18)
    B事务将age改成20,B事务id是1888:(100,'李华',20), trx_id=1888, roll_pointer----->(100,'李华',18), trx_id=1777, roll_pointer=null
    C事务将age改成100,C事务id是2000:(100,'李华',100), trx_id=2000, roll_pointer----->(100,'李华',20), trx_id=1888, roll_pointer----->(100,'李华',18), trx_id=1777, roll_pointer=null

在读已提交、可重复读隔离级别开启事务时,会生成当前数据库系统的一个 ReadView,也就是快照。

在 ReadView 中,有四个非常重要的属性:

  • creator_trx_id:创建这个 ReadView 的事务 id
  • trx_ids:在生成 ReadView 时数据库中活跃的事务的 id 列表
  • up_limit_id:活跃的事务中 id 最小的事务
  • low_limit_id:在生成 ReadView 时,系统应该为下一个事务分配的 id

已知读已提交、可重复读这两个隔离级别可以在读取其他事务未提交的数据时读取这个数据以前的旧值,那么,在 undo log 中记录的历史版本中,哪些是可以读取的,哪些是不能读取的呢?

复制代码
if(undo log记录的trx_id == creator_trx_id){
	说明事务正在访问它自己修改的记录,数据是可见的
}else if(undo log记录的trx_id < up_limit_id){
	说明这个记录的事务在生成ReadView前已提交,既然是已经提交的数据,那么数据是可见的
}else if(undo log记录的trx_id >= low_limit_id){
	说明这个记录的事务是在ReadView生成后开启的,有可能这个事务已经提交,当然也有可能未提交,数据不可见
}else if(undo log记录的trx_id ∈ [up_limit_id, low_limit_id)){
	// 遍历trx_ids列表
	if(undo log记录的trx_id in trx_ids){
		在生成ReadView时,这些事务都是活跃的,无法确定是否提交,数据不可见
	}else{
		数据可见
	}
}

如果是我的话,即使看完上面的 if-else,整个人还是懵的,还是不够直观,不够通俗易懂。

首先,事务的 id 是单调递增的,但不是自增长,事务 id 的大小反映了事务的先后顺序,数字越小的事务越早开始。既然事务 id 是单调递增的,那么就画一个 x 轴分析一下吧。

复制代码
       up_limit_id                  creator_trx_id                              low_limit_id
            ↑                             ↑                                           ↑
            |                             |                                           |
            |                             |                                           |
            |                             |                                           |
            |                             |                                           |
            |                             |                                           |
0------------------------------------------------------------------------------------------------------------>
            [<--------------------------------trx_ids-------------------------------->)						

在undo log的记录中保存的trx_id的值只能落在三个区间内:[0, up_limit_id)、[up_limit_id, low_limit_id)、[low_limit_id, +∞)
if(trx_id < up_limit_id){

	up_limit_id:活跃的事务中id最小的事务
	说明修改这个记录的事务已经提交,这些已经提交的记录是可见的
	
}else if(trx_id >= low_limit_id){

	low_limit_id:在生成ReadView时,系统应该为下一个事务分配的id
	说明修改这个记录的事务是生成ReadView之后开启的,不能确定是否提交,记录不可见
	
}else if(trx_id ∈ [up_limit_id, low_limit_id)){
	
	trx_ids:在生成ReadView时数据库中活跃的事务的id列表
	[up_limit_id, low_limit_id)就是trx_ids的范围
	在这个范围内的事务无法确定是否提交,记录不可见
	
	if(trx_id == creator_trx_id){
	
		creator_trx_id:创建这个ReadView的事务id
		唯一的可能性:当前事务读取的是自己修改的记录,记录可见
	}
}

6.事务隔离级别实现

MySQL 使用不同的策略实现四种不同的隔离级别:读未提交、串行化跟锁有关;读已提交、可重复读跟 MVCC 有关。

  • 读未提交:读不加锁,写加 X 锁。
    • 加 X 锁用来避免脏写。一个事务修改数据,记录被加上 X 锁,其他事务修改同一条记录时,需要加 X 锁,但是由于该记录已经有 X 锁了,所以其他事务只能等待 X 锁释放,避免了脏写。
    • 读不加锁,只要加上 X 锁,则这条记录不能再添加 S 锁和 X 锁。所以事务读取数据不会加锁,这样才能保证随时都能读取数据,同时也导致了脏读问题,会读到其他事务未提交的数据。
  • 读已提交:每次读生成一个 ReadView,写加 X 锁。
    • 每次读取数据都会生成一个 ReadView,保证读取的是最新的已提及的数据。但是,由于数据被不同的事务修改,同一次查询在不同时间的查询结果不一定相同,也就是"不可重复读"。
  • 可重复读:只生成一次 ReadView,写加 X 锁。
    • 事务开启时生成 ReadView,之后一直复用这个 ReadView,保证每次读取的数据是相同的。
  • 串行化:读加 S 锁,写加 X 锁,同一个事务才能读写数据。
    • 读加 S 锁,事务可以并发读。
    • 写加 X 锁,其他事务只能等待锁释放才能读、写。
相关推荐
DemonAvenger5 小时前
MySQL视图与存储过程:简化查询与提高复用性的利器
数据库·mysql·性能优化
戴誉杰6 小时前
mysql5.7.44安装遇到登录权限问题
mysql
小码农叔叔8 小时前
【AI智能体】Dify 实现自然语言转SQL操作数据库实战详解
人工智能·sql·mysql
共享家952710 小时前
MySQL-视图与用户管理
数据库·mysql
ifanatic10 小时前
[每周一更]-(第158期):构建高性能数据库:MySQL 与 PostgreSQL 系统化问题管理与优化指南
数据库·mysql·postgresql
小楓120111 小时前
MySQL數據庫開發教學(四) 後端與數據庫的交互
前端·数据库·后端·mysql
DemonAvenger11 小时前
分区表实战:提升大表查询性能的有效方法
数据库·mysql·性能优化
吃饭最爱13 小时前
mysql中的通用语法及分类
mysql
麦麦大数据20 小时前
F010 Vue+Flask豆瓣图书推荐大数据可视化平台系统源码
vue.js·mysql·机器学习·flask·echarts·推荐算法·图书