MySQL【事务下】

一、一致性

事务的作用:状态的安全切换

  • 事务的本质就是把数据库从一个合法的一致性状态,安全地切换到另一个合法的一致性状态
    • 事务开始前:数据库是状态 A(合法)
    • 事务执行中:可能暂时出现中间状态(比如钱已扣、未到账),但这些中间状态对其他事务不可见
    • 事务提交后:数据库变成状态 B(依然合法)
    • 如果事务失败回滚:数据库会回到状态 A,不会停在中间的不一致状态

一致性的规则不是数据库天生自带的,而是由你的业务逻辑定义的:

  • 数据库只提供技术手段(比如原子性、隔离性、持久性)来保证规则不被破坏
  • 真正的 "什么是一致",比如 "转账必须金额相等""库存不能为负",是由业务代码和约束来定义的

二、数据并发的场景有三种

2.1 读 - 读(Read-Read)

  • 场景:多个事务同时读取同一份数据,比如多个用户同时查询商品库存。
  • 特点 :读操作不会修改数据,彼此之间没有冲突,不会破坏数据一致性。
  • 结论不存在安全问题,不需要任何并发控制,可以直接并行执行,效率最高。

2.2 读 - 写(Read-Write)

  • 场景:一个事务在读取数据,另一个事务在修改同一份数据,比如用户 A 查看余额,同时用户 B 在转账扣款。
  • 风险会破坏事务的隔离性,引发三类经典问题:
    • 脏读:读到了其他事务未提交的修改(如果对方回滚,数据就无效了)。
    • 不可重复读:同一事务内,两次读取同一份数据,结果却不一样(因为中间被其他事务修改并提交了)。
    • 幻读:同一事务内,两次范围查询得到的行数不一样(因为中间有其他事务插入 / 删除了符合条件的记录)。
  • 处理 :需要通过事务隔离级别 (如读已提交、可重复读)或锁机制来控制。

2.3 写 - 写(Write-Write)

  • 场景:多个事务同时修改同一份数据,比如两个用户同时下单扣减同一件商品的库存。
  • 风险 :会导致更新丢失 (Lost Update),分为两类:
    • 第一类更新丢失一个事务回滚时,覆盖了另一个事务已提交的修改。
    • 第二类更新丢失 :两个事务同时读取同一份数据,各自修改后提交,后提交的事务会覆盖先提交的结果,导致先提交的修改丢失。
  • 处理 :需要通过悲观锁 (如行锁、表锁)或乐观锁(如版本号、时间戳)来保证写操作的互斥。

三、读写

3.1 3个记录隐藏列字段

1. DB_TRX_ID(事务 ID)

  • 大小:6 字节
  • 作用 :记录最后一次创建 / 修改这条记录的事务 ID
  • 通俗理解 :**每条数据都有一个 "最后操作人" 标记,这个 "操作人" 就是事务 ID。**比如你用事务 A 修改了这条数据,那这条数据的DB_TRX_ID就会被更新为事务 A 的 ID。
  • 核心用途:配合 MVCC,判断当前事务能否看见这条数据版本。

2. DB_ROLL_PTR(回滚指针)

  • 大小:7 字节
  • 作用 :指向这条记录的上一个历史版本历史版本数据存在undo log(回滚日志)里
  • 通俗理解 :可以把它想象成一条 "时光机" 指针,指向这条数据修改前的样子。比如你把数据从100改成200,新记录的DB_ROLL_PTR就会指向undo log里保存的100版本,方便回滚或其他事务读取旧版本。
  • 核心用途:实现 MVCC 多版本,支持事务回滚和一致性读。

对指针的理解不要理解得太肤浅了 , 数组下标,特定内存空间的地址都可以看成指针

3. DB_ROW_ID(隐藏主键)

  • 大小:6 字节
  • 作用 :当数据表没有显式定义主键时,InnoDB 会自动生成这个自增 ID 作为聚簇索引
  • 通俗理解 :如果你没给表设主键,InnoDB 会 "偷偷" 给每条数据加一个自增序号,用它来组织聚簇索引**(因为 InnoDB 必须有聚簇索引)**。如果你已经设了主键,这个字段就不会生成,直接用主键当聚簇索引。
  • 核心用途:保证聚簇索引存在,优化数据存储和查询。

4. 补充:删除 flag(删除标记)

数据页是内存级别的

  • 作用:记录是否被逻辑删除,更新 / 删除操作不会立刻物理删除数据,只是修改这个标记
  • 通俗理解 :InnoDB 的删除是 "软删除",比如你执行DELETE,它不会立刻把数据从磁盘抹掉,只是把删除 flag 设为 "已删除",之后在后台慢慢清理。这样做是为了配合 MVCC,让其他事务还能读到旧版本,同时保证事务安全。
  • 核心用途:实现 MVCC 的一致性读,避免物理删除导致历史版本丢失。

假设测试表结构是:

3.2 undo日志

  • MySQL 将来是以服务进程的方式,在内存中运行。我们之前所讲的所有机制:索引,事务,隔离性,日志等,都是在内存中完成的,即在 MySQL 内部的相关缓冲区 中,保存相关数据,完成各种判断操作。然后在合适的时候,将相关数据刷新到磁盘当中的。
  • 所以,我们这里理解undo log,简单理解成,就是****MySQL 中的一段内存缓冲区,用来保存日志数据的就行。 一般事务提交之后 , 这个缓冲区会被释放 (提交后的事务,rollback 没有用了)。

MySQL 的运行方式:内存优先

  • MySQL 是一个服务进程,运行在内存里。
  • 索引、事务、隔离性、日志(包括 undo log)这些核心机制,都先在内存缓冲区里完成
    • 读写数据、判断事务可见性、记录回滚信息...... 都在内存中高效执行。
    • 不会每次操作都直接写磁盘,因为磁盘 I/O 太慢,会严重拖慢性能。
  • 等时机合适(比如事务提交、内存满了、后台定时任务),再把内存里的数据批量刷新到磁盘持久化。

undo log 的简单理解

  • 你可以把 undo log 直接理解为:MySQL 内存里的一段专用缓冲区
  • 它的作用是:保存数据修改前的旧版本,用来:
    • 事务失败时回滚(把数据恢复到修改前的样子)。
    • 实现 MVCC(多版本并发控制),让其他事务能读到历史版本的数据,保证隔离性和一致性读。
  • 本质上,undo log 就是 MySQL 在内存里为了 "反悔" 和 "读旧数据" 而准备的一份 "备份日志"。

3.3 模拟MVCC

现在有一个事务10(仅仅为了好区分),对student表中记录进行修改(update):将name(张三)改成name(李四)。

  1. 事务10 , 因为要修改 , 先要对记录加锁
  2. 修改前 , 先将需要改的 行记录 拷贝到 undo log 中 , 所以 , undo log 中就有了一行副本数据**(原理就是 : 写时拷贝)**
  3. 所以现在Mysql 中有两行同样的记录 。 现在 修改原始记录中 的 name , 改成 '李四' 。并且修改原始记录的隐藏字段 DB_TRX_ID 为当前事务 10 的ID, 默认从10开始 , 之后递增 。而原纪录的回滚指针 DB_ROLL_PTR列 , 里面写入 undo log 中副本数据 的地址, 从而指向副本记录 , 即表示我的上一个版本就是它 。
  4. 事务10 提交 , 释放锁 。

现在又有一个事务11,对student表中记录进行修改(update):将age(28)改成age(38)。
这个时候我们修改的数据是当前的数据!!! 历史的 , 在undo log 里面的数据 不可改 ,因为它是稳定的 , 历史的 , 陈旧的

  1. 事务 11 , 因为也要修改 , 所以要先给该记录加行锁
  2. 修改前,现将改行记录拷贝到****undo log中,所以,undo log **中就又有了一行副本数据。**此时,新的 副本,我们采用头插方式,插入undo log
  3. 先修改原始记录中的age,改成 38。并且修改原始记录的隐藏字段****DB_TRX_ID 为当前 事务11 的ID 。而原始记录的回滚指针DB_ROLL_PTR 列,里面写入undo log 中副本数据的地址,从而指向副 本记录,既表示我的上一个版本就是它。
  4. 事务11提交,释放锁。


这样,我们就有了一个基于链表记录的 历史版本链 。所谓的回滚,无非就是用历史数据,覆盖当前数据。

上面的一个一个版本,我们可以称之为一个一个的快照。

3.3.1 不同操作对版本链的影响

  • UPDATE/DELETE :都会生成历史版本,形成版本链

    • DELETE 不是物理删除,只是打删除标记,同样会进入版本链。
    • 这两种操作会生成历史版本 ,通过 DB_ROLL_PTR 指针串联成版本链
  • INSERT没有历史版本,只在 undo log 里留一条记录用于回滚,事务提交后可清理。

虽然没有历史本版 ,但是 一般为了回滚操作,insert的数据也是要被放入undo log **中,**如果当前事务commit了,那么这个undo log 的历史insert记录就可以被清空了。回滚 -> 还会记录这个insert 相对的语句 delete , 就相当于你insert 数据 , 你想回滚 , 就会执行这个对应的delete 语句。

总结一下 : update 个 delete 可以形成版本链 , insert 暂时不考虑 , INSERT 只用于回滚,不参与版本链。

  • SELECT:不修改数据,不生成新版本,但会决定读哪个版本。

首先 , select 不会对数据做任何修改 ,所以 , 为select 维护版本 , 并没有意义 。

SELECT 不修改数据,所以不会产生新版本,但它需要决定读最新版 还是历史版

① 当前读(Current Read):读取最新的记录 , 增删改 , 都叫当前读。

  • 定义:读取最新提交的版本,会加锁(共享锁 / 排他锁)。
  • 场景:
    • 增删改操作本身就是当前读
    • select 也有可能当前读 ----> 带锁的查询**:SELECT ... LOCK IN SHARE MODE(共享锁)SELECT ... FOR UPDATE**
  • 特点:保证读到最新数据,但会阻塞其他写操作,性能较低。

② 快照读(Snapshot Read):读历史版本

  • 定义:读取历史版本不加锁可以和写操作并行执行。
  • 场景:普通 SELECT 语句(在 READ COMMITTED / REPEATABLE READ 隔离级别下)。
  • 意义:这就是 MVCC 的核心价值------ 不加锁实现并发读写,大幅提升数据库性能。
  1. 之前读写并发的原因写是当前读数据 , 读是历史数据 , 所以不会出现访问同一个位置的情况 , 就不需要加锁,不会相互夯住 ,不会影响 , 就可以并发进行操作 。
  2. 事务A把数据修改之后 , 事务B读取的数据依旧是老数据 , 是因为有隔离性的存在 ,隔离性本质是在版本上做隔离
  3. ,在多个事务同时删改查的时候,都是当前读,是要加锁的。那同时有select过来,如果也要**读 取最新版(**当前读),那么也就需要加锁,这就是串行化。

3.3.2 总结:

  • UPDATE/DELETE 生成版本链,INSERT 只用于回滚。
  • SELECT当前读(加锁读最新)快照读(无锁读历史)
  • MVCC 通过快照读实现无锁并发 ,而隔离级别决定了读哪个版本。

为什么快照读能提升效率?

  • 如果所有读都用当前读:读写会互相加锁,变成串行执行,性能差。
  • 如果用快照读:读操作去读历史版本,完全不受写操作加锁的影响,读写可以并行,效率更高。

谁决定了 SELECT 是当前读还是快照读?

隔离级别决定了 SELECT 的读取模式:

  • 不同隔离级别会控制:事务能看到哪些历史版本、什么时候生成快照(Read View)。
  • 比如 MySQL 默认的 REPEATABLE READ 隔离级别:
    • 事务第一次执行普通 SELECT 时生成一个快照(Read View)。
    • 之后整个事务期间,所有 SELECT 都复用这个快照,保证 "可重复读"。
  • READ COMMITTED 隔离级别:
    • 每次 SELECT 都会生成新的快照,只能看到其他事务已提交的修改。

为什么要有隔离级别呢?

因为事务是原子的 , 无论如何 , 事务都有先后。 但是经过上面的操作我们发现,事务从begin->CURD->commit,是有一个阶段的。也就是事务有执行前,执 行中,执行后的阶段。但,不管怎么启动多个事务,总是有先有后的。那么多个事务在执行中,CURD****操作是会交织在一起的。那么,为了保证事务的"有先有后",是不是应该让不同 的事务看到它该看到的内容,这就是所谓的隔离性与隔离级别要解决的问题。

3.4 Read View

  • read View , 是一个对象 , 值初始化之后 , 不变
  • read View 事务是可见性的一个类 , 不是事务创建出来 , 就会有read View , 而是当前这个事务(已经存在)首次进行快照读的时候 , mysql 形成Read View!!!

对比规则(简化版):

  1. 如果 DB_TRX_ID < 最小活跃事务 ID → 这个修改事务已经提交了,可见

  2. 如果 DB_TRX_ID > 最大事务 ID → 这个修改是在当前事务之后才开始的,不可见

  3. 如果 DB_TRX_ID 在活跃事务列表里 → 这个事务还没提交,不可见 ;不在列表里 → 已经提交,可见

    class ReadView {
    // 省略...
    private:
    /** 高水位,大于等于这个ID的事务均不可见*/
    trx_id_t m_low_limit_id
    我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID的,即:当前记录的
    DB_TRX_ID 。
    那么,我们现在手里面有的东西就有,当前快照读的 ReadView 和 版本链中的某一个记录的
    DB_TRX_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
    up_limit_id; //记录m_ids列表中事务ID最小的ID(没有写错)
    low_limit_id; //ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的
    最大值+1(也没有写错)
    creator_trx_id //创建该ReadView的事务ID

  • 我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID的,即:当前记录的 DB_TRX_ID 。
  • 那么,我们现在手里面有的东西就有,当前快照读的 ReadView 和 版本链中的某一个记录的 DB_TRX_ID 。
  • 所以现在的问题就是,当前快照读,应不应该读到当前版本记录。

Read View 是事务在快照读那一刻对系统事务状态拍的 "照片",把时间线分成三段:

  • 已经提交的事务DB_TRX_ID < up_limit_id(这些事务的修改对当前事务可见)
  • 正在操作的事务(活跃事务) :所有未提交的事务 ID 都存在 m_ids 列表里(这些事务的修改对当前事务不可见)
  • 快照后新来的事务DB_TRX_ID >= low_limit_id(这些事务是快照之后才开始的,修改对当前事务不可见)

关键定义

  • up_limit_id:当前系统中最小的活跃事务 ID
  • low_limit_id:当前系统中最大的事务 ID + 1(代表快照之后新事务的起点)
  • m_ids:当前所有活跃(未提交)事务 ID 的集合

对应策略:


如果查到不应该看到当前版本,接下来就是遍历下一个版本,直到符合条件,即可以看到。上面的
readview 是当你进行select的时候,会自动形成。

3.5 整体流程

  • 事务4:修改name(张三) 变成name(李四)
  • 当 事务2 对某行数据执行了****快照读 ,数据库为该行数据生成一个 Read View 读视图
  • 只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务。
  • 我们的事务2在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去up_limit_id,low_limit_id和活跃事务ID列表(trx_list) 进行比较,判断当前事务2能看到该记
    录的版本。

四、RR与RC的本质区别

4.1 当前读和快照读在RR级别下的区别

下面的代码经过测试,是完全没有问题的。

select * from user lock in share mode ,以加共享锁方式进行读取,对应的就是当前读。

测试表:

复制代码
--设置RR模式下测试
mysql> set global transaction isolation level REPEATABLE READ;
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)
--依旧用之前的表
create table if not exists user(
id int primary key,
age int not null,
name varchar(50) not null default ''
)ENGINE=InnoDB DEFAULT CHARSET=UTF8;
--插入一条记录,用来测试
mysql> insert into user (id, age, name) values (1, 15,'黄蓉');
Query OK, 1 row affected (0.00 sec)


测试用例1-表1:

测试用例2-表2:

  • 用例1与用例2:唯一区别仅仅是 表1 的事务B在事务A修改age前 快照读 过一次age数据
  • 而 表2 的事务B在事务A修改age前没有进行过快照读。

我们看事务 B 的行为:

  1. 事务 B 先开启 ,但还没执行任何 SELECT
  2. 事务 A 执行了更新并提交 :把 age 从 18 改成 28。
  3. 事务 B 第一次执行快照读(select * from user
    • 此时才生成 Read View,这个 Read View 已经能看到事务 A 提交的修改,所以读到 age=28
    • 之后事务 B 再执行任何快照读,都会复用这个 Read View,结果永远是 age=28
  4. 如果事务 B 在事务 A 更新前就执行了第一次快照读
    • 生成的 Read View 里看不到事务 A 的修改,之后就算事务 A 提交了,事务 B 后续快照读依然会读到 age=18,这就是 "可重复读" 的效果。

!!! read view 形成的时机不同 , 会影响事务的可见性!!!

1. 为什么 "首次快照读" 这么关键?

REPEATABLE READ 隔离级别下:

  • 事务第一次执行普通 SELECT(快照读)时,会生成一个 Read View(读视图),也就是对当前系统事务状态拍了一张 "快照"。
  • 这个 Read View 会被整个事务复用,直到事务提交。
  • 后续所有快照读,都会基于这张 "快照" 去判断可见性,不会再感知到其他事务新提交的修改。

4.2 RR与RC的本质区别

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才会有不可重复读问题。

五、读-读

不讨论

六、写-写

现阶段,直接理解成都是当前读,当前不做深究

相关推荐
2301_776508722 小时前
Python日志记录(Logging)最佳实践
jvm·数据库·python
东小黑2 小时前
WordPress问题
数据库·wordpress
奕成则成2 小时前
面试被问:MySQL 与 Doris/SelectDB 的架构区别。 大数据为什么禁止select *。
mysql·面试·架构
2401_879693872 小时前
用Python批量处理Excel和CSV文件
jvm·数据库·python
gjc5922 小时前
踩坑实录:MySQL服务器CPU爆高,元凶竟是SELinux的setroubleshootd?
运维·服务器·数据库·mysql·adb
2401_846341652 小时前
Python Lambda(匿名函数):简洁之道
jvm·数据库·python
2401_879693872 小时前
进阶技巧与底层原理
jvm·数据库·python
知识分享小能手2 小时前
Redis入门学习教程,从入门到精通, Redis Stack 完整语法知识点及使用指南(7)
数据库·redis·学习
小仓桑2 小时前
【Agent智能体项目实战三】LangChain调用通义千问保姆级教程
数据库·阿里云·langchain·agent