
一、一致性事务的作用状态的安全切换事务的本质就是把数据库从一个合法的一致性状态安全地切换到另一个合法的一致性状态事务开始前数据库是状态 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(李四)。事务10 因为要修改 先要对记录加锁修改前 先将需要改的 行记录 拷贝到 undo log 中 所以 undo log 中就有了一行副本数据原理就是 : 写时拷贝所以现在Mysql 中有两行同样的记录 。 现在 修改原始记录中 的 name , 改成 李四 。并且修改原始记录的隐藏字段 DB_TRX_ID 为当前事务 10 的ID 默认从10开始 之后递增 。而原纪录的回滚指针 DB_ROLL_PTR列 里面写入 undo log 中副本数据 的地址从而指向副本记录 即表示我的上一个版本就是它 。事务10 提交 释放锁 。现在又有一个事务11对student表中记录进行修改(update)将age(28)改成age(38)。这个时候我们修改的数据是当前的数据 历史的 在undo log 里面的数据 不可改 因为它是稳定的 历史的 陈旧的事务 11 因为也要修改 所以要先给该记录加行锁修改前现将改行记录拷贝到undo log中所以undo log中就又有了一行副本数据。此时新的 副本我们采用头插方式插入undo log先修改原始记录中的age改成 38。并且修改原始记录的隐藏字段DB_TRX_ID为当前事务11的ID。而原始记录的回滚指针DB_ROLL_PTR列里面写入undo log中副本数据的地址从而指向副 本记录既表示我的上一个版本就是它。事务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 的核心价值—— 不加锁实现并发读写大幅提升数据库性能。之前读写并发的原因写是当前读数据 读是历史数据 所以不会出现访问同一个位置的情况 就不需要加锁不会相互夯住 不会影响 就可以并发进行操作 。事务A把数据修改之后 事务B读取的数据依旧是老数据 是因为有隔离性的存在 隔离性本质是在版本上做隔离在多个事务同时删改查的时候都是当前读是要加锁的。那同时有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 Viewread View 是一个对象 值初始化之后 不变read View 事务是可见性的一个类 不是事务创建出来 就会有read View 而是当前这个事务已经存在首次进行快照读的时候 mysql 形成Read View!!!对比规则简化版如果DB_TRX_ID 最小活跃事务 ID → 这个修改事务已经提交了可见。如果DB_TRX_ID 最大事务 ID → 这个修改是在当前事务之后才开始的不可见。如果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当前系统中最小的活跃事务 IDlow_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 )ENGINEInnoDB DEFAULT CHARSETUTF8; --插入一条记录用来测试 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 的行为事务 B 先开启但还没执行任何SELECT。事务 A 执行了更新并提交把age从 18 改成 28。事务 B 第一次执行快照读select * from user此时才生成 Read View这个 Read View 已经能看到事务 A 提交的修改所以读到age28。之后事务 B 再执行任何快照读都会复用这个 Read View结果永远是age28。如果事务 B 在事务 A 更新前就执行了第一次快照读生成的 Read View 里看不到事务 A 的修改之后就算事务 A 提交了事务 B 后续快照读依然会读到age18这就是 “可重复读” 的效果。 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才会有不可重复读问题。五、读-读不讨论六、写-写现阶段直接理解成都是当前读当前不做深究