事务:
事务是数据库区别与文件系统的重要特性之一,锁为事务服务,实现事务的特性,先来简单回归一下事务的各种特性。
事务的ACID特性:
事务有四种特性来保证事务能够很好地为我们服务。
原子性(atomicity) | 一个事务要么全部执行,要么全部失败 |
一致性(consistency) | 事务将数据库从一种一致的状态转变为下一种一致的状态,数据库的完整性约束没有被破坏 |
隔离性(isolation) | 不同的事务之间不应该相互干扰 |
持久性(durability) | 事务一旦提交就应该是永久改变的 |
事务三种常见问题:
事务在并发场景下会产生三种常见问题。
脏读 | 一个事务修改了数据,还没有提交就被另一个事务访问到 |
不可重复读 | 一个事务中,第一次读到的数据值和第二次读到的数据值不一致。(读到了update未提交的内容) |
幻读 | 一个事务中,第一次读到的数据量和第二次读到的数据量不一致。(读到了insert未提交的内容) |
事务的四种隔离级别:
为了解决这三种问题,提出了四种经典的事务隔离级别,其中RC和RR使用最为广泛。
Read uncommitted | 读未提交 | 毫无隔离性可言 |
Read committed | 读已提交 | 解决脏读 |
Repeatable read | 重复读 | 解决不可重复读 |
Serializable | 串形化 | 解决幻读 |
Tips:
- mysql的默认隔离级别为RR(repeatable read)
锁:
锁为事务服务,是实现事务的具体方式。与很多数据库引擎相比,innodb引擎不存在锁升级的问题,其根据事务访问的每个页来对行锁进行管理,采用位图的方式。因此无论一个事务锁住页中的一条记录还是多条记录,其开销通常是一致的。
按兼容性划分:
S锁 | 共享锁(读锁) | 并发读之间不会相互阻碍 |
X锁 | 排他锁(写锁) | 同一时间只能有一个X锁 |
IS锁 | 意向共享锁 | 表示当前表有活跃事务在给某些行上S锁 |
IX锁 | 意向排他锁 | 表示当前表有活跃事务在给某些行上X锁 |
Tips:
- 意向锁的作用是可以快速判断当前表是否有活跃的事务在给其中某些行上锁,是提高并发的一种手段。
按模式划分:
Record 记录锁 | 行锁都是加在索引上的,表示对该行数据的读锁或者写锁 |
GAP 间隙锁 | 给两个相邻索引叶子结点之间上锁,避免其他事务在此区间内插入数据 |
Next-key 下一键锁 | 下一键锁 = 两个相邻索引叶子结点之间的间隙锁 + 右边界叶子结点的记录锁 |
II-GAP 插入意向锁 | 在insert操作将要执行时首先获取该区间的插入意向锁,插入意向锁之间并不会冲突,但和间隙锁、下一键锁冲突。设计可以满足多个insert并发,但和区域select互斥 |
Auto-Inc 自增锁 | 锁定自增资源本身,在获取自增id后,立即释放 |
锁兼容矩阵:
表锁开销小、加锁快;但粒度大,并发度低。
行锁开销大、加锁慢;但粒度小,并发度高。
表兼容矩阵:
上边一行是已有的锁,左侧一列是要加的锁
S锁 | X锁 | IS锁 | IX锁 | |
S锁 | Y | Y | ||
X锁 | ||||
IS锁 | Y | Y | Y | |
IX锁 | Y | Y |
行兼容矩阵:
同样,上边一行是已有的锁,左侧一列是要加的锁
记录锁 | 间隙锁 | 下一键锁 | 插入意向锁 | |
记录锁 | Y | Y | ||
间隙锁 | Y | Y | Y | Y |
下一键锁 | Y | Y | ||
插入意向锁 | Y | Y |
死锁的条件:
锁和资源的调度有时会产生一种致命的错误,死锁。死锁的触发条件如下,缺一不可。
资源互斥 | 资源一旦分配,在没有释放之前,都不能分配给其他事务 |
占有且等待 | 一个事务占有数个资源之后,仍然在申请其他资源 |
不可抢占 | 资源分配之后,没有释放之前,不能被其他事务抢占 |
循环等待 | 事务相互占有对方申请的资源,依赖链构成一个环 |
innodb解决死锁的办法:
死锁检测的原理是构建一个以事务为顶点、锁为边的有向图,判断有向图是否存在环,存在即有死锁。
innodb在sql执行前会进行死锁的检查,如果发现某个新事务中的sql会导致死锁,会选择更新行数最少的事务进行回滚(基于INFORMATION_SCHEMA.INNODB_TRX表)中的 trx_weight字段来判断。
索引:
innodb的锁都是加在索引上的,B+树索引在数据库中有一个特点是高扇出性。
因此在数据库中,B+树的高度一般都在2~4层,这也就是说查找某一键值的行记录时最多只需要2到4次IO。
快照读:
快照读也称非锁定读取,RR下称为一致性非锁定读。快照读并不会加锁,而是通过MVCC完成事务隔离。MVCC(多版本并发控制)在RC和RR两个隔离级别下的表现会不同,通过undo log、隐藏列、读视图三个部分实现。
undo log:
Undo log 存放老版本数据,当一个事务需要读取记录行时,如果当前记录行不可见,则顺着undolog找到满足可见性的记录行版本。
- Insert undo log: 插入操作时产生的undo log,事务回滚时使用,并在事务提交后可以立即删除。
- Update undo log:更新和删除操作时产生的undo log,事务回滚和快照读使用,删除本质上也是更新标标识位,然后经过一个离线purge线程将记录删除。
隐藏列:
innodb引擎在每行数据后面添加了三个隐藏列,例如下表中后三个列。
id | no | DB_ROW_ID | DB_TRX_ID | DB_ROLL_PTR |
主键 | 编号 | 6字节的行ID,当表没有主键时聚簇索引使用这个ID | 6字节,最近一次写操作的事务ID(和是否提交无关) | 7字节,指向undo log的下一跳 |
读视图:
读视图主要用来做可见性判断。
struct read_view_t {
trx_id_t up_limit_id; // 当前活跃事务中最小id
trx_id_t low_limit_id; // 已分配的最大事务id+1
trx_id_t* trx_ids; // 当前活跃的事务id列表
// 可以通过 select * from information_schema.innodb_trx查看
}
- RC下:执行每条select快照读,都会更新读视图,判断当前数据行是否可见。
- RR下:执行第一条select快照读,创建读视图,并且之后读视图不会被刷新。
可见性判断算法:
- db_trx_id < up_limit_id,表示当前版本在当前事务创建前就提交了,所以对当前事务可见。
- db_trx_id >= low_limit_id,表示当前版本在当前事务创建后才修改,所以不可见,undo log找下一版本判断。
- up_limit_id <= db_trx_id < low_limit_id,表示当前版本在当前事务创建时还处于活跃状态,则需要在trx_ids数组里二分查找值为db_trx_id的事务,能找到则当前版本不可见,需要从undo log中找下一个版本去判断,找不到则直接可见。
例子:
数据 | 事务A | 事务B | 事务C | 事务D |
---|---|---|---|---|
data=吃饭 DB_TRX_ID=1000 |
trx_id=1001 | trx_id=1002 | trx_id = 1003 | trx_id = 1004 |
begin | ||||
begin | ||||
data=吃饭 DB_TRX_ID=1000 |
select data=? trx_ids:[1002] up_limit_id:1002 low_limit_id=1003 DB_TRX_ID=1000< up_limit_id 可见 data=吃饭 |
|||
begin | ||||
data=吃饭 DB_TRX_ID=1000 |
select data=? trx_ids:[1001,1003] up_limit_id:1001 low_limit_id=1003 DB_TRX_ID=1000< up_limit_id 可见 data=吃饭 |
|||
begin | ||||
data=睡觉 DB_TRX_ID=1003 -> data=吃饭 DB_TRX_ID=1000 |
update set data=睡觉 |
|||
commit | ||||
commit | ||||
data=睡觉 DB_TRX_ID=1003 -> data=吃饭 DB_TRX_ID=1000 |
RC select data=? RR select data=? (RC隔离级别,视图更新) trx_ids:[1004] up_limit_id:1004 low_limit_id=1005 DB_TRX_ID=1003< up_limit_id 可见 找到data=睡觉 (RR隔离级别,视图更新) trx_ids:[1001,1003] up_limit_id:1001 low_limit_id=1004 DB_TRX_ID=1003> up_limit_id 不可见 遍历undo log 找到data=吃饭 |
|||
当前读:
当前读的操作包括select for update
, update
, delete
,并且innodb在RR下,会使用next-key机制来避免幻读,常见的当前读加锁情况如下。
无索引:
select * from test where id = 5 for update
Tips:
- 更新操作的where条件最好走索引,不然锁的开销很大。
聚簇索引-值查询命中:
select * from test where id = 5 for update
Tips:
- RC和RR下,命中都会对数据行加X锁。
聚簇索引-值查询未命中:
select * from test where id = 4 for update
Tips:
- RR下未命中,则会对最后的查询区间加Gap锁。
聚簇索引-范围查询命中:
select * from test where id > 3 and id < 6 for update
Tips:
- RR下范围命中,则会对范围内的记录区间加Gap锁。
- 由于next-key锁机制会对范围最后的一个record(可能不在范围内)加X锁
二级唯一索引-值查询命中:
select * from test where no = '1005' for update
Tips:
- innodb 中唯一索引的null可以是多个行,null也可以走索引
- 当索引有唯一属性时,next-key锁会降级为record锁,所以这里即使在RR下,二级索引也不会有gap锁。
二级唯一索引-值查询未命中:
select * from test where no = '1004' for update
Tips:
- RR下二级索引未命中只会对二级索引加Gap锁。
二级非唯一索引-值查询命中:
select * from test where name = 'Alice' for update
Tips:
- RR下普通二级索引命中可能会命中多个值。由于next-key锁的机制,每一个命中的二级索引前面的gap也会被锁定。
二级非唯一索引-值查询未命中:
select * from test where name = 'Clare' for update
Tips:
- RR下普通二级索引未命中只会对最后的扫描区域加gap。