并发控制
并发的事务对同一行记录进行读写操作, 如果不采取措施, 可能会导致数据不一致, 所以必须进行并发控制(Concurrency Control).
InnoDB实现并发控制的方法是: 锁(Locking)和多版本并发控制(Multiversion Concurrency Control).
锁主要是共享锁和排他锁, 读操作时加共享锁, 写操作时加排他锁, 两者的兼容关系如下:
是否兼容 | 共享锁 | 排他锁 |
---|---|---|
共享锁 | 是 | 否 |
排他锁 | 否 | 否 |
从中可以看到, 当一个事务对记录进行写操作时, 存储引擎会自动加个排他锁, 其他事务要想读取该记录就只能等待第一个事务写操作完成, 这对并发度有较大的影响, 那有没有可能写操作和读操作并发进行呢? 这就引出了多版本并发控制.
多版本并发控制(MVCC)
当写任务发生时, 将旧版本的数据copy一份作为新版本, 写任务操作新版本的数据, 并发的读任务操作旧版本的数据, 这样就可以实现读写任务并发的进行.
旧版本的数据存储undo日志中, undo日志储存在回滚段(rollback segment)中, 当事务提交时会清空undo日志.
InnoDB给表中的每行数据添加了三个属性:
- DB_TRX_ID
6字节, 最近一次修改该行的事务ID. - DB_ROLL_PTR
7字节, 指向回滚段undo日志的指针. - DB_ROW_ID
6字节, 单调递增的行ID. 用于构建聚集索引, 当不存在主键和唯一非空索引时.
undo日志的作用
- 回滚未提交的事务.
- 快照读(也可以说用于实现MVCC).
undo日志里的旧版本数据也叫快照(snapshot), 所以从undo日志里读数据也叫快照读.
什么样的select是快照读?
普通的select语句都是快照读, 快照读是不加锁的, 例如:
select * from t where id>2;
非快照读是指显示加锁的select, 例如:
select * from t where id>2 lock in share mode;
select * from t where id>2 for update;
redo日志
提到undo日志就要说下redo日志, redo日志用来保证事务的持久性.
数据库事务提交后,并不会立即将更新后的数据刷到磁盘上, 而是先缓存在内存中(Buffer Pool), 再定期的刷到磁盘上. 如果这个时候数据库崩溃, 就会导致这部分修改后的数据丢失, 为了保证事务的持久性, 就引出了redo日志.
当用户开启一个数据库事务时, redo日志缓冲区(redo log buffer)会缓存修改后的数据, 定期的写入到磁盘的redo日志文件中, 并且保证在持久化数据文件前,之前的redo日志已经写到磁盘. 这样当数据库崩溃时, 如果内存(Buffer Pool)中的数据没有持久化到磁盘上, 只需重启数据库, 就能从redo日志文件中恢复数据.
InnoDB的七种锁
InnoDB共有七种类型的锁:
- 共享/排他锁(Shared and Exclusive Locks)
- 意向锁(Intention Locks)
- 记录锁(Record Locks)
- 间隙锁(Gap Locks)
- 临键锁(Next-key Locks)
- 插入意向锁(Insert Intention Locks)
- 自增锁(Auto-inc Locks)
其中意向锁和自增锁是表锁, 其余都是行锁. 记录锁、间隙锁和临键锁是共享/排他锁的三种实现方式. 插入意向锁是间隙锁的一种. 下面是七种锁的详细介绍:
共享/排他锁
InnoDB实现了两种标准的行锁, 分别是共享锁和排他锁.
- 事务拿到某一行记录的共享锁,才可以读取这一行.
- 事务拿到某一行记录的排它锁,才可以修改或者删除这一行.
共享锁和排他锁的兼容关系如下:
是否兼容 | 共享锁 | 排他锁 |
---|---|---|
共享锁 | 是 | 否 |
排他锁 | 否 | 否 |
意向锁
InnoDB支持多重粒度锁, 允许行锁和表锁共存. 意向锁是表锁, 包括意向共享锁(intention shared lock)和意向排他锁(intention exclusive lock).
意向锁仅仅表明意向, 并不会锁住数据. 当事务对一行记录加共享锁之前必须先添加意向共享锁, 同样, 在加排他锁之前要先添加意向排他锁. 他们的兼容关系如下:
是否兼容 | 共享锁 | 排他锁 | 意向共享锁 | 意向排他锁 |
---|---|---|---|---|
共享锁 | √ | × | √ | × |
排他锁 | × | × | × | × |
意向共享锁 | √ | × | √ | √ |
意向排他锁 | × | × | √ | √ |
记录锁
记录锁封锁索引记录, 例如:
SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE
它会在c1=10的索引记录上加锁,以阻止其他事务插入, 更新, 删除这一行.
注意, 普通的查询是快照读, 不加锁:
SELECT c1 FROM t WHERE c1 = 10
间隙锁
间隙锁封锁索引记录之间的间隔, 或者第一条索引记录之前的范围, 或者最后一条索引记录之后的范围. 例如一个索引包含10, 20:
SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE
上面这条语句封锁了c1在10和20之间的这个范围, 插入一个c1=15的数据就会失败.
临键锁
临键锁是记录锁和间隙锁的组合, 临键锁会封锁索引记录和该索引记录之前的间隙. 例如有一个id索引包含10, 11, 13 和20, 临键锁可能封锁的区间如下:
(negative infinity, 10] --封锁索引10时
(10, 11] --封锁索引11时
(11, 13] --封锁索引13时
(13, 20] --封锁索引20时
(20, positive infinity) --封锁索引20时
当使用临建锁封锁20时, 因为20是最后一个索引值, 所以会封锁两个区间: (13, 20] 和 (20, positive infinity).
在REPEATABLE READ隔离级别下, InnoDB使用临键锁解决幻读的问题, 如下所示
SELECT * FROM t WHERE id > 12 FOR UPDATE;
上面的这条语句会锁定(11, 13], (13, 20], (20, positive infinity) 三个区间, 这样其他事务就不能在这个范围内插入行了.
插入意向锁
插入意向锁是间隙锁的一种, 是专门针对insert操作的. 在进行insert操作时, 会先在插入间隙加上插入意向锁, 然后对具体的插入行加上排他锁.
插入意向锁仅仅表示一种意向, 当多个事务在同一索引间隔内插入数据时, 如果插入的位置不冲突就不会阻塞彼此. 例如有一索引间隔[10,15]:
先执行事务A, 未提交:
insert into t values(11);
再执行事务B:
insert into t values(12);
因为两个事务插入的位置不冲突, 所以事务B并不会被阻塞.
自增锁
自增锁是表锁, 针对AUTO_INCREMENT类型的列. 例如主键ID是AUTO_INCREMENT类型的, 如果一个事务正在往表中插入记录, 其他所有事务的插入必须等待, 以便第一个事务插入的行拥有连续的主键值.
并发控制在SQL语句上的应用
在MySQL默认的隔离级别(REPEATABLE READ)下:
下面实例用到的表结构:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`flag` int(11) DEFAULT NULL,
`num` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `t_flag` (`flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
SELECT
(1)普通select
普通的select都是快照读, 不加锁, 如:
select * from t where id>2;
(2)加锁select
-
select … lock in share mode
加共享锁. 在唯一索引(PRIMARY KEY和UNIQUE KEY) 上使用唯一的查询条件时, 加共享记录锁, 如:select * from t where id=1 lock in share mode;
其他情况下加共享临键锁. 如:
select * from t where id >1 lock in share mode; select * from t where flag =1 lock in share mode;
-
select … for update
加排他锁. 在唯一索引(PRIMARY KEY和UNIQUE KEY) 上使用唯一的查询条件时, 加排他记录锁. 其他情况下加排他临键锁.
UPDATE/DELETE
和加排他锁的select类似, 在唯一索引(PRIMARY KEY和UNIQUE KEY) 上使用唯一的查询条件时, 加排他记录锁. 其他情况下加排他临键锁.
INSERT
在插入索引间隔内加插入意向锁, 对具体的插入行加排他记录锁. 例如有一索引间隔[10,15], 用户将要插入两行记录11和12. InnoDB首先在[10,15]的索引间隙内加插入意向锁, 然后再对11和12这两行记录加排他记录锁.
注意:
InnoDB存储引擎的行锁是通过索引实现的, 当语句没有用到索引时, InnoDB会放弃使用行锁而改用表锁.
上面这句话其实是有点问题的, 当语句没有用到索引时, InnoDB并没有把行锁升级为表锁, 仍然使用的是行锁, 只是把表中的所有行都锁定了.
如下面这条UPDATE语句会给整张表的所有记录都锁定:
update t set num = 5 where num =3;