MySQL 锁模型
前言:何为锁?
锁是计算机协调多个进程或线程并发访问某一资源的机制
像 CPU, 内存, I/O 这些都是资源,在现在这种多用户的操作系统中,都会被共享到,那势必涉及到用锁来协调。
区别于上述的物理硬件,数据亦是一种共享资源,同样需要通过锁来解决多用户,并发访问的问题,而数据库这样的应用恰恰帮我们用锁来实现上面的问题。
不难发现,想避免数据竞争,加锁实现串行化,一个个去操作数据是最好的方法,但是这样相当于就没有了并发,是要数据安全,还是要并发性能,这又是一个计算机领域的难题。
MySQL 锁机制
这里以 MySQL 为例(因为我只接触过它:P),先大致了解下锁的种类,后面再看如何解决数据安全跟性能之间的权衡。
按锁定的数据范围,MySQL 的锁可分为:
范围 | 粒度 | 开销 | 加锁速度 | 锁冲突概率 | 死锁 | 并发 |
---|---|---|---|---|---|---|
表级 | 大 | 小 | 快 | 高 | 没有 | 最低 |
页级 | 中 | 中 | 中 | 中 | 存在 | 一般 |
行级 | 小 | 大 | 慢 | 低 | 存在 | 最高 |
我个人还有三个小疑问:
- MySQL 不是号称实现了锁机制,为何还要开发人员去管理锁?
- 如果开发人员对操作加锁,该加什么类型的锁?
- 加锁的数据范围及时间范围又该怎么定夺?
MyISAM 和表锁
表锁分为两种:
- Table Read Lock
- Table Write Lock
锁定关系
锁定关系 | Read Lock | Write Lock |
---|---|---|
Read Lock | 并行 | 互斥 |
Write Lock | 互斥 | 互斥 |
简单说,除了读读是并发的,其他锁组合都是串行,需要等待上一个锁释放才能进行上锁。
如何上锁
MyISAM 会自动操作上锁(隐式)
- SELECT 自动加 Read Lock
- UPDATE/INSERT/DELETE 自动加 Write Lock
那为何还要用语句 LOCK TABLE (显式)加锁呢?无非是为了模拟事务,MyISAM 是不支持事务的。
事务是由一组 SQL 语句组成的逻辑处理单元,事务具有4属性,通常称为事务的ACID属性。
因为事务一组 SQL 语句可能涉及多表,所以语句级别的隐式锁定不能满足,必须显式地 LOCK TABLE,例如有 orders 及 products 两张表,里面的 SUM(price) 正常情况下是一致的。
|
|
但如果不 LOCK TABLE,可能你读出 orders 的 SUM(price) 时,products 的 SUM(price) 已经改变了,可见 LOCK TABLE 在这种场景下是必要的。
|
|
特别强调:
- local 是一个 LOCK TABLE 的一个选项,作用就是在满足 MyISAM 并发插入条件的情况下,允许其他用户在表尾插入记录
- LOCK TABLE 要一次性 LOCK 住所有要操作的表,不能访问其他一切表,如果加的是读锁,就只能执行读,而不能执行写,写锁同理
- 其他 Session 对表的锁定关系不变
锁并发
在一定的条件下,MyISAM 也支持 r/w 并发,涉及到一个引擎的系统参数 (concurrent_insert),这里不展开讲了,有兴趣自己去查文档。
锁调度
上面已经讲了锁定关系,读写锁是互斥的,串行的,那是不是按照先来后到的顺序?并不是!写锁的优先级要远远高过读锁!申请写锁的进程会优先上锁,这也正是 MyISAM 不太适合于有大量更新操作和查询操作应用的原因,大量的更新操作会造成查询操作很难获得读锁,读操作可能永远阻塞。
好在有两个系统参数可以调整读锁的优先级,这里不展开讲了,有兴趣自己去查文档。
- low-priority-updates
- max_write_lock_count
特别强调:
- 避免时间长的查询语句,这样可以避免锁冲突,减少其他进程超时的情况
- 如果无法避免,就在低峰期进行操作,例如统计报表数据可放在深夜去跑
InnoDB 和行锁
InnoDB 相比 MyISAM 最大的不同是:
- 支持行锁
- 支持事务
先说事务,上面已经提到了事务具备 ACID 属性,这几个属性势必跟并发性能是想冲突的,如果是表锁实现的事务,其实还是把事务变成串行事务,可想而知并发性能是非常之低,那么 InnoDB 是如何去权衡这两者的关系?
事务隔离级别
为了解决“隔离”与“并发”的矛盾,ISO/ANSI SQL92 定义了4个事务隔离级别:
隔离级别/并发副作用 | 读取一致性 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|---|
Read Uncommited | 只能保证读取物理数据正确 | 存在 | 存在 | 存在 |
Read Commited | 语句级别 | 没有 | 存在 | 存在 |
Repeatable Read | 事务级别 | 没有 | 没有 | 存在 |
Serializable | 事务级别 | 没有 | 没有 | 没有 |
隔离级别及对应的副作用这篇文章写得非常浅显易懂,这里就不作展开,就看他这篇即可。
其中,Serializable 也就是表级的串行,隔离级别最高,基本没有并发可言,而 Read Uncommited 根本不适用数据库这样的有一致性要求的场景,也基本用不到,本文不对这两种隔离级别进行讨论,主要针对 Read Commited 和 Repeatable Read 两个隔离等级进行讨论。当然还是那句老话,具体选用哪个隔离级别要结合你的应用场景!
为了实现上面的事务隔离级别,InnoDB 提供了四种行锁来实现:
- 共享锁(S)
- 排它锁(X)
- 意向共享锁(IS)
- 意向排它锁(IX)
锁定关系
锁定关系 | S | X | IS | IX |
---|---|---|---|---|
S | 并行 | 互斥 | 并行 | 互斥 |
X | 互斥 | 互斥 | 互斥 | 互斥 |
IS | 并行 | 互斥 | 并行 | 并行 |
IX | 互斥 | 互斥 | 并行 | 并行 |
同样,InnoDB 也是自动加锁,但也可以显式地加锁
- 共享锁(S):WHERE … LOCK IN SHARE MODE
- 排它锁(X):WHERE … FOR UPDATE
很多人容易搞混不可重复读和幻读,确实这两者有些相似:
- 不可重复读在于 UPDATE 和 DELETE
- 幻读在于 INSERT。
如果使用锁机制来实现这两种隔离级别,在可重复读中,该 SQL 第一次读取到数据后,就将这些数据加锁,其它事务无法修改这些数据,就可以实现可重复读了。但这种方法却无法锁住 INSERT 的数据,所以当事务A先前读取了数据,或者修改了全部数据,事务B还是可以 INSERT 数据提交,这时事务A就会发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。需要 Serializable 隔离级别 ,读用读锁,写用写锁,读锁和写锁互斥,这么做可以有效的避免幻读、不可重复读、脏读等问题,但会极大的降低数据库的并发能力。
所以说不可重复读和幻读最大的区别,就在于如何通过锁机制来解决他们产生的问题。上文说的,是使用悲观锁机制来处理这两种问题,MySQL 出于性能考虑,使用了以乐观锁为理论基础的 MVCC(多版本并发控制)来避免这两种问题。
MVCC
人们一般把基于锁的并发控制机制称成为悲观机制,而把 MVCC 机制称为乐观机制。这是因为锁机制是一种预防性的,读会阻塞写,写也会阻塞读,当锁定粒度较大,时间较长时并发性能就不会太好;而MVCC是一种后验性的,读不阻塞写,写也不阻塞读,等到提交的时候才检验是否有冲突,由于没有锁,所以读写不会相互阻塞,从而大大提升了并发性能。
声明一点:Multi-Version Concurrency Control 是一种无锁事务机制,而不是一种具体的实现。
这里讨论的是 InnoDB 对 MVCC 的实现:
- SELECT时,读取创建版本号 <= 当前事务版本号,删除版本号为空或 > 当前事务版本号
- INSERT时,保存当前事务版本号为行的创建版本号
- DELETE时,保存当前事务版本号为行的删除版本号
- UPDATE时,插入一条新纪录,保存当前事务版本号为行创建版本号,同时保存当前事务版本号到原来删除的行
这样读不阻塞写,写不阻塞读,并发自然就高了,当然也取决与资源读写竞争频率,如果是高频数据,那势必会导致很多重试,万一重拾的成本非常高,那还不如用锁机制去实现。
首先看下 MVCC 引入的两种读:
snapshot read (快照读): 顾名思义读取的是数据的某个历史版本,有可能不是最新的
- select * from table … ;
current read (当前读): 读取的是最新的数据,在 MVCC 中需要显式去加锁
- select * from table … lock in share mode;
- select * from table … for update;
- insert/update/delete;
这里有个疑问,为何 insert/update/delete 这类写操作也归类为当前读?那是因为在 MySQL 内部,一个写操作都是需要先读取记录,再对这些记录进行操作。
对于读操作(快照读):
RC 级别,读取的是 fresh snapshot,也就是最新的数据版本,确实存在重复读的情况。
RR 级别,但读取的是某个版本的 snapshot (事务中第一个读取语句发生那一刻的 snapshot),由于历史数据是按版本为单位的,所以快照读是不存在幻读的。
对于写操作(当前读):
回到事务隔离级别的概念上,定义中带来的副作用指的是当前读下产生的,跟 MVCC 引入的快照读不能一概而谈,当前读的隔离性必须得通过上锁来实现,而此时单单行锁是满足不了需求的,InnoDB 实现了一个叫 Next-Key 的锁来保障 RR 级别不会幻读。
所以 InnoDB 的 RR 隔离级别是消除了幻读吗?怎么跟 ISO 的说法不一样,当然了,标准是标准,具体实现又是另一回事,这里有篇文章有说,很有意思,强烈推荐看一下!
Next-Key Lock
Next-Key Lock = X Lock + Gap Lock
RC 级别,当前读会对数据加 X Lock,存在幻读。
RR 级别,当前读会对记录加 X Lock,同时保证对读取的范围加 Gap Lock,新的满足查询条件的记录不能够插入,不存在幻读现象。
再详细说明一下 Next-Key 是避免 RR 的幻读,我们知道 Record Lock 只能锁定到当前的记录,如果其他事务 Insert 了数据,就会造成幻读
方法一:加表锁,锁住其他事务不给 Insert (相当变为 Serialazable 级别,抛弃)
方法二:
Where 条件走普通索引的情况
- 如图所示先定位到索引的记录,加上 X 锁
- 再把索引记录两边的间隙分别加上 Gap 锁
- 再由索引定位到主键记录,再加上 X 锁
WHERE 条件走 Primary Key/Unique Key 的情况
- 由于这两种索引本身保证了Unique,所以新记录插不进去
- 所以只需要加上对应记录的 X 锁即可
WHERE 条件不走任何索引
- 扫全表,在每条主键记录上加上 Gap 锁
- 阻塞所有需要加锁的操作
- 当然 MySQL 本身会优化这种情况,对于不满足的记录会提前释放,有兴趣的可以参考这篇blog
死锁
占坑,也许新开一篇来探讨…
MetaData Lock
本文讨论的都是 CURD Data Manipulate Language (DML) 的锁,而对表结构的操作 Data Definition Language (DDL),MySQL 会加上 MetaData Lock(MDL)。MySQL 5.3 版本后引入了MDL,只有在事务结束后才会释放 MDL,因此在事务提交或回滚前,是无法进行DDL操作的。这里先点一下,下次再写一片慢慢探讨。
总结
先解答文初我的三个疑问:
- MySQL 实现的是语句级别自动加锁,事务是涉及特定业务的多条语句,所以有自己加锁的需求
- 加锁类型也是根据事务要达成的目的,在一致性要求强的情况下可以加X锁,其他情况考虑并发可以加S锁
- 手动加锁范围会直到事务结束(不敢确定,因为还没详细了解锁升级的内容)
简单来说 InnoDB 干了下面几件事:
- 为了提高并发性能
- 提出了粒度更小的行锁
- 提出了基于事务隔离级别来平衡事务的一致性读
- 利用 MVCC 来实现隔离级别中产生的读取副作用
- 从而提高了并发性能
终于对 MySQL 锁机制/事务隔离级别有了个大概的了解,数据一致性及并发始终是一个难题,一个关于权衡的难题,只有深刻认识业务才能作出合适的选型。其中事务级别 RU 及 Serializable 分别是并发及一致性的两个极端,更好地帮我们去理清两个边界,最终权衡出 RC/RR 两个用处比较广泛的事务场景。
另外关于锁/MVCC,这两者就像是连接数据一致性和并发性能的绳子,特别是 MVCC 让我学习了另一种提升并发性能的方法。
最后,再称赞下 MySQL 设计的精妙,无论是提出当前读跟快照读,还是违反两段提交的提前 unlock 行数据,都是在整个理论模型上的有效补充。
参考
https://coolshell.cn/articles/6790.html
http://blog.csdn.net/zyz511919766/article/details/49450849
https://tech.meituan.com/innodb-lock.html
http://www.cnblogs.com/chenqionghe/p/4845693.html
http://www.cnblogs.com/renolei/p/5910060.html
http://www.jianshu.com/p/4e3edbedb9a8
https://github.com/Yhzhtk/note/issues/42
http://blog.sina.com.cn/s/blog_499740cb0100ugs7.html
http://hedengcheng.com/?p=771#_Toc374698306