3.MySql锁与事务
锁
按模式分类
乐观锁
- 业务实现
1
2-- 如果更新失败则重新尝试
update value = value where version = curversion
悲观锁
- select…for update 注意weher条件要明确指定主键
- 对符合
WHERE条件的行加锁(默认行级锁) - 必须在事务中(
BEGIN或START TRANSACTION)使用,否则锁会立即释放。 - 如果
WHERE条件未命中索引,InnoDB 会升级为 表级锁(全表扫描代价高)。 - 例如库存扣减场景
1
2
3START TRANSACTION;
-- 检查库存,计算新值
COMMIT;
按加锁粒度分类
全局锁
- Flush tables with read lock (FTWRL)
- 让整个数据库处于只读状态,很危险不建议,只要数据库引擎支持事务操作就使用官方自带的逻辑备份工具是 mysqldump。当 mysqldump 使用参数–single-transaction 的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于 MVCC 的支持,这个过程中数据是可以正常更新的。
- 如果要全库只读,为什么不使用set global readonly=true的方式?
- 1.在有些系统中,readonly的值会被用来做其他逻辑,比如判断主备库。所以修改global变量的方式影响太大。
- 2.在异常处理机制上有差异。如果执行FTWRL命令之后由于客户端发生异常断开,那么MySQL会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为readonly之后,如果客户端发生异常,则数据库就会一直保持readonly状态,这样会导致整个库长时间处于不可写状态,风险较高。
表级锁
- 表锁
- lock tables … read/write
- lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。
- 元数据锁(meta-data-lock MDL)
- MDL 不需要显式使用,在访问一个表的时候会被自动加上,
- 当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。
- MDL作用是防止DDL(对表数据的增删改查)和DML(对表结构的增删改查以及新增表)并发的冲突
- 意向锁
- 当执行插入、更新、删除操作,需要先对表加上「意向独占锁」,然后对该记录加独占锁。加意向锁的目的是为了快速判断表里是否有记录被加锁。
行级锁
- 记录锁,锁住的是一条记录。而且记录锁是有S锁和X锁之分的,满足读写互斥,写写互斥
- 间隙锁,只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象。
- Next-Key Lock 称为临键锁,是 Record Lock+Gap Lock 的组合,锁定一个范围,并且锁定记录本身。
按属性分类
共享锁和排他锁
共享锁(Shared),简写为 S 锁,又称读锁。select …..lock in share mode
排他锁(Exclusive),简写为 X 锁,又称写锁 。sleect … for update
有两个规定
- 一个事务对数据对象 A 加了 X 锁,就可以对 A 进行读取和更新。加锁期间其它事务不能对 A 加任何锁。
- 一个事务对数据对象 A 加了 S 锁,可以对 A 进行读取操作,但是不能进行更新操作。加锁期间其它事务能对 A 加 S 锁,但是不能加 X 锁。
按状态分类
意向共享锁和意向排他锁
使用意向锁(Intention Locks)可以更容易支持多粒度封锁。
在存在行级锁和表级锁的情况下,事务 T 想要对表 A 加 X 锁,就需要先检测是否有其它事务对表 A 或者表 A 中的任意一行加了锁,那么就需要对表 A 的每一行都检测一次,这是非常耗时的。
意向锁在原来的 X/S 锁之上引入了 IX/IS,IX/IS 都是表锁,用来表示一个事务想要在表中的某个数据行上加 X 锁或 S 锁
规定:
- 一个事务在获得某个数据行对象的 S 锁之前,必须先获得表的 IS 锁或者更强的锁;
- 一个事务在获得某个数据行对象的 X 锁之前,必须先获得表的 IX 锁。
- 任意 IS/IX 锁之间都是兼容的,因为它们只表示想要对表加锁,而不是真正加锁;
- 这里兼容关系针对的是表级锁,而表级的 IX 锁和行级的 X 锁兼容,两个事务可以对两个数据行加 X 锁。(事务 T1 想要对数据行 R1 加 X 锁,事务 T2 想要对同一个表的数据行 R2 加 X 锁,两个事务都需要对该表加 IX 锁,但是 IX 锁是兼容的,并且 IX 锁与行级的 X 锁也是兼容的,因此两个事务都能加锁成功,对同一个表中的两个数据行做修改。)
自增锁
- 完成对自增长值插入的sql语句后立刻释放。表示为当插入一个自增数据后,无论事务是否提交,下次插入的数据均为该自增数据的下一个自增值。
按算法分类
间隙锁(GAP)
- 间隙锁是一个在索引记录之间的间隙上的锁。但不包括记录本身
- 保证某个间隙内的数据在锁定情况下不会发生任何变化。比如mysql默认隔离级别下的可重复读(RR)
- 如果Where条件全部命中,则不会用Gap锁,只会加记录锁。如果where条件部分命中或者全不命中则会加Gap锁。
记录锁(Record Lock)
- 记录锁 是直接锁定索引中的一条具体记录的锁。
- 仅锁定单行,不涉及范围。
临键锁(Next-Key Lock)
临键锁 是 记录锁(Record Lock) + 间隙锁(Gap Lock) 的组合,锁定索引记录及其前面的间隙
例如:如果表中存在
id=3和id=7的记录,临键锁可能锁定区间(3, 7](左开右闭区间)。目的
- 防止其他事务在锁定范围内插入新记录(避免幻读)。
- 阻止其他事务修改或删除锁定范围内的记录。
- 左开右闭是为了避免双重锁定,如果采用闭区间(如
[10,20]),则记录10和20会被相邻的锁重复锁定([10,20]和[20,30]会重叠锁定20)。左开右闭确保了边界值(如20)只被右侧的临键锁锁定一次,避免冲突。
MVCC 不能解决幻影读问题,Next-Key Locks 就是为了解决这个问题而存在的。在可重复读(REPEATABLE READ)隔离级别下,使用 MVCC + Next-Key Locks 可以解决幻读问题。
封锁协议
三级封锁协议
一级封锁协议
事务 T 要修改数据 A 时必须加 X 锁,直到 T 结束才释放锁。可以解决丢失修改问题,因为不能同时有两个事务对同一个数据进行修改,那么事务的修改就不会被覆盖。

二级封锁协议
在一级的基础上,要求读取数据 A 时必须加 S 锁,读取完马上释放 S 锁。可以解决读脏数据问题,因为如果一个事务在对数据 A 进行修改,根据 1 级封锁协议,会加 X 锁,那么就不能再加 S 锁了,也就是不会读入数据。

三级封锁协议
在二级的基础上,要求读取数据 A 时必须加 S 锁,直到事务结束了才能释放 S 锁。可以解决不可重复读的问题,因为读 A 时,其它事务不能对 A 加 X 锁,从而避免了在读的期间数据发生改变。

两段锁协议(2PL)
- 一个事务中一旦开始释放锁,就不能再申请新锁了。事务的加锁和解锁严格分为两个阶段,第一阶段加锁,第二阶段解锁。引入2PL是为了保证事务的隔离性,保证并发调度的准确性,多个事务在并发的情况下等同于串行执行。
- 在事务中只有提交(commit)或者回滚(rollback)时才是解锁阶段,其余时间为加锁阶段。
Mysql隐式和显示锁定
- MySQL 的 InnoDB 存储引擎采用两段锁协议,会根据隔离级别在需要的时候自动加锁,并且所有的锁都是在同一时刻被释放,这被称为隐式锁定。
- InnoDB 也可以使用特定的语句进行显示锁定:
Mysql如何处理死锁
- 等待,直到超时
- 发起死锁检测,主动回滚一条事务,让其他事务继续执行
死锁检测
- 死锁检测的原理是构建一个以事务为顶点、锁为边的有向图,判断有向图是否存在环,存在即有死锁。
回滚
- 检测到死锁之后,选择插入更新或者删除的行数最少的事务回滚,基于 INFORMATION_SCHEMA.INNODB_TRX 表中的 trx_weight 字段来判断。
如何安全的给小表加字段
- ,在 alter table 语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。之后开发人员或者 DBA 再通过重试命令重复这个过程。
- MariaDB 已经合并了 AliSQL 的这个功能,所以这两个开源分支目前都支持 DDL NOWAIT/WAIT n 这个语法。
- ALTER TABLE tbl_name NOWAIT add column …
- ALTER TABLE tbl_name WAIT N add column …
如何避免发生死锁
- 使用事务,不使用
lock tables。 - 保证没有长事务。
- 操作完之后立即提交事务,特别是在交互式命令行中。
- 如果在用
(SELECT ... FOR UPDATE or SELECT ... LOCK IN SHARE MODE),尝试降低隔离级别。 - 修改多个表或者多个行的时候,
将修改的顺序保持一致。 - 创建索引,可以使创建的锁更少。
- 最好不要用
(SELECT ... FOR UPDATE or SELECT ... LOCK IN SHARE MODE)。 - 如果上述都无法解决问题,那么尝试使用
lock tables t1, t2, t3锁多张表
并发一致性问题
丢失修改
丢失修改指一个事务的更新操作被另外一个事务的更新操作替换。一般在现实生活中常会遇到,例如:T1 和 T2 两个事务都对一个数据进行修改,T1 先修改并提交生效,T2 随后修改,T2 的修改覆盖了 T1 的修改。

脏读
读脏数据指在不同的事务下,当前事务可以读到另外事务未提交的数据。例如:T1 修改一个数据但未提交,T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据

不可重复读
不可重复读指在一个事务内多次读取同一数据集合。在这一事务还未结束前,另一事务也访问了该同一数据集合并做了修改,由于第二个事务的修改,第一次事务的两次读取的数据可能不一致。例如:T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。

幻影读
幻读本质上也属于不可重复读的情况,T1 读取某个范围的数据,T2 在这个范围内插入新的数据,T1 再次读取这个范围的数据,此时读取的结果和和第一次读取的结果不同。

不可重复读和幻影读的区别
- 不可重复读是读取同样的数据,前后读取结果不一致,重点是数据的修改,幻影读是同样的条件,检索的结果集不一致,重点是新增或者删除
- 解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表
并发一致性问题总结
- 产生并发不一致性问题的主要原因是破坏了事务的隔离性,解决方法是通过并发控制来保证隔离性。并发控制可以通过封锁来实现,但是封锁操作需要用户自己控制,相当复杂。数据库管理系统提供了事务的隔离级别,让用户以一种更轻松的方式处理并发一致性问题。
数据库事务
ACID
- 原子性(Atomicity):事务被视为不可分割的最小单元,事务的所有操作要么全部提交成功,要么全部失败回滚。回滚可以用回滚日志来实现,回滚日志记录着事务所执行的修改操作,在回滚时反向执行这些操作即可。
- 一致性(Consistency):数据库在事务的执行前后都保持着一致性的状态,在一致性的状态下,所有事务对一条数据的读取结果都时相同的。
- 隔离性(Isolation):一个事务在提交事务前的所有修改,对其他事务来说是不可见的
- 持久性(Durability):一旦事务提交,则其所作的修改将会永久的保存到数据库中,即使系统发生崩溃,事务执行的结果也不能丢失。使用重做日志来保证持久性。系统发生崩溃可以用重做日志(Redo Log)进行恢复,从而实现持久性。与回滚日志记录数据的逻辑修改不同,重做日志记录的是数据页的物理修改。
- 解释
- 只有满足一致性,事务的执行结果才是正确的。
- 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。
- 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
- 事务满足持久化是为了能应对系统崩溃的情况。
隔离级别
- 未提交读(READ UNCOMMITED)
事务中的修改,即使不提交,对其他事务也是可见的 - 读已提交(READ COMMITED)(默认)
一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。 - 可重复读(REPEATABLE READ)
保证在同一个事务中多次读取同一数据的结果是一样的。 - 可串行化(SERIALIZABLE)
强制事务串行执行,这样多个事务互不干扰,不会出现并发一致性问题。 隔离级别 脏读 不可重复读 幻读 锁策略 读未提交 (Read Uncommitted) 可能 可能 可能 无锁(或极弱锁) 读已提交 (Read Committed) 不可能 可能 可能 行级锁(短暂持有) + MVCC 可重复读 (Repeatable Read) 不可能 不可能 不可能 临键锁(Next-Key Lock) + MVCC 串行化 (Serializable) 不可能 不可能 不可能 表级锁或严格的行级锁 - RR级别下并没有完全解决幻读问题,但通过使用Next-Key Locks(一种Gap Lock和Record Lock的组合锁)的机制,它在大多数情况下可以防止幻读的发生。以下是RR级别下,幻读会发生的情况
- 快照读(非锁定读):在RR隔离级别下,同一个事务内混合“快照读”和“当前读”,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15-- 事务 A (RR 隔离级别)
START TRANSACTION;
-- 快照读 (基于 Read View V1), 假设返回记录数: 5
SELECT * FROM users WHERE age BETWEEN 20 AND 30;
-- 此时事务 B 插入一条 age=25 的新记录并提交
-- COMMIT; (事务 B)
-- 当前读 (读取最新已提交数据, 获取 Next-Key Locks)
SELECT * FROM users WHERE age BETWEEN 20 AND 30 FOR UPDATE;
-- 这个 SELECT FOR UPDATE 看到了事务 B 新插入的 age=25 的记录!
-- 事务 A 的第二次查询比第一次查询多出了一行 (幻读发生)
COMMIT; -- 事务 A - UPDATE/DELETE 操作“看到”幻影行并修改它们
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21-- 事务 A (RR 隔离级别)
START TRANSACTION;
-- 快照读 (基于 Read View V1), 假设返回记录数: 5
SELECT COUNT(*) FROM users WHERE age BETWEEN 20 AND 30; -- 结果: 5
-- 此时事务 B 插入一条 age=25 的新记录并提交
-- COMMIT; (事务 B)
-- UPDATE 是当前读! 它会看到事务 B 提交的新记录 (age=25)
UPDATE users SET status = 'active' WHERE age BETWEEN 20 AND 30;
-- 这个 UPDATE 语句实际影响了 6 行记录 (包括事务 B 插入的那一行)
-- 再次快照读 (基于 Read View V1), 仍然看不到新行
SELECT COUNT(*) FROM users WHERE age BETWEEN 20 AND 30; -- 结果: 5 (快照读不变)
-- 但是! 执行当前读:
SELECT COUNT(*) FROM users WHERE age BETWEEN 20 AND 30 FOR UPDATE; -- 结果: 6 (看到新行)
-- 或者提交后看数据,会发现 6 行被更新了
COMMIT; -- 事务 AUPDATE和DELETE语句在 RR 级别下也是当前读。它们在查找要修改的行时,会读取数据库的最新已提交状态(并获取 Next-Key Locks)。因此,它们能“看到”并修改在事务开始之后、但在UPDATE/DELETE执行之前由其他已提交事务插入的“幻影行”。
- 快照读(非锁定读):在RR隔离级别下,同一个事务内混合“快照读”和“当前读”,
MVCC(多版本并发控制)
- 多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读的可重复读以及快照读的幻读问题这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,要求很低,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。
- 读不加锁,读写不冲突。MVCC 在不加锁的情况下解决了脏读、不可重复读
MVCC基础概念
- 版本号
- 系统版本号 SYS_ID:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
- 事务版本号 TRX_ID :事务开始时的系统版本号。
- 隐藏的列
- MVCC在每行记录后面都保存着两个隐藏的列,
- DB_TRX_ID:最后修改改行的事务ID
- DB_ROLL_PTR(roll_pointer):指向旧版本数据的指针。
- 每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到
undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息
- 每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到
- MVCC在每行记录后面都保存着两个隐藏的列,
- undo日志
MVCC 使用到的快照存储在 Undo 日志中,该日志通过回滚指针把一个数据行(Record)的所有快照连接起来。

当前读和快照读
- 快照读
- MVCC 中的 SELECT 操作是快照中的数据,不需要进行加锁操作。
- 每一次修改数据都会在undo-log 中存有快照记录,快照读就是读取某一个版本的快照
- 当前读
- 当前读是给读操作加上共享锁、排它锁,DML 操作加上排它锁,读取记录的最新版本。
- 快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;
- MVCC 其它会对数据库进行修改的操作(INSERT、UPDATE、DELETE)隐式的加上排他锁操作,从而读取最新的数据。可以看到 MVCC 并不是完全不用加锁,而只是避免了 SELECT 的加锁操作。
特性 当前读 快照读 数据版本 读取最新已提交数据 读取历史快照数据(基于事务/语句时间点) 加锁 是(排他锁或共享锁) 否 并发性能 低(锁竞争可能阻塞其他事务) 高(无锁,读写并行) 适用操作 FOR UPDATE、UPDATE、DELETE、INSERT普通 SELECT隔离级别依赖 所有级别(行为一致) 读已提交和可重复读(行为不同) 触发条件 显式加锁的 SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE。
任何数据修改语句(UPDATE/DELETE/INSERT)在执行前会隐式触发当前读。所有普通 SELECT语句默认使用快照读。
事务首次读取数据时生成快照,后续读取沿用该快照。
MVCC适用的隔离级别
操作类型 RC 隔离级别 RR 隔离级别 普通 SELECT动态快照读(每次生成新 Read View)固定快照读(复用首次 Read View)写操作/加锁读 当前读(读取最新数据) 当前读(读取最新数据,加间隙锁防幻读)
read-view
- 进行快照读时,会产生read-view,RC在每一次进行普通SELECT操作前都会生成一个ReadView,而RR只在第一次进行普通SELECT操作前生成一个ReadView,数据的可重复读其实就是read-view的重复使用
- read-view有四个重要变量
m_ids:活跃事务id列表,当前系统中所有活跃的(也就是没提交的)事务的事务id列表。min_trx_id:m_ids 中最小的事务id。max_trx_id:生成 ReadView 时,系统应该分配给下一个事务的id(注意不是 m_ids 中最大的事务id),也就是m_ids 中的最大事务id + 1 。creator_trx_id:生成该 read-view 的事务的事务id。
- trx_id是版本链中的各个id, 遍历版本链,通过比较trx_id和m_ids中的数的关系来确认当前可见的版本是哪个
- trx_id == creator_trx_id,版本链中的这个版本是当前事务修改的,所以该快照记录对当前事务可见。
- trx_id<min_trx_id,表示此版本是已经提交的事务生成的,由于事务已经提交所以数据是可见的。
- trx_id>max_trx_id,表示此版本是由将来启动的事务生成的,是肯定不可见的。
- min_trx_id<=trx_id<=max_trx_id会存在俩种情况
- trx_id 在数组中,表示此版本是由还没提交的事务生成的,不可见,但是当前自己的事务是可见的。
- row 的 trx_id 不在数组中,表明是提交的事务生成了该版本,可见。