上一篇关于事务与锁的文章最后其实留下了一个悬而未决的问题。像SELECT ... LOCK IN SHARE MODE;
这种可能会扫表的操作,明确会添加的是IS锁,剩下会加哪些锁其实和具体的WHERE
子句有关。那么到底有几个锁实例的存在怎么确定呢?
mysql> SELECT * FROM account;
+----+------------+---------+
| id | account_id | balance |
+----+------------+---------+
| 1 | 10 | 3000 |
| 2 | 20 | 3000 |
| 3 | 30 | 3000 |
+----+------------+---------+
3 rows in set (0.01 sec)
mysql> SELECT * FROM account WHERE account_id < 10 LOCK IN SHARE MODE;
Empty set (0.04 sec)
mysql> SHOW ENGINE INNODB STATUS \G;
---TRANSACTION 476C0DC, ACTIVE 29 sec
3 lock struct(s), heap size 376, 2 row lock(s)
MySQL thread id 43477964, OS thread handle 0x7f162ccf6700, query id 485892583 127.0.0.1 root
这篇博文对WHERE
子句限定的是PRIMARY
、UNIQUE
和普通字段的情况进行了分析。可以看出我们的例子中的3种锁结构:其中一个是IS锁对应的结构,另外两个是记录锁和区间锁吗?而记录锁有两个(实例),这是为什么呢?按照前文的分析,Next-Key锁和插入意向锁可以实现为同一个结构、X锁和S锁可以实现为同一个结构。那么实际究竟是不是这样的,本文就来一探究竟。
锁的互斥关系
其实,这一节的标题,我思索良久。因为通常意义下,我们讲互斥锁,会自然联系到传统操作系统下的mutex,也就是独占锁。但我想表达的其实是另一层关系,介于读锁和写锁之间的那层屏障。
很明显,如果所有锁都是独占式的实现方式,问题讨论会变得简单。从引入了读锁(共享锁)、以及读锁和写锁(独占锁)的互斥关系,把锁的讨论变复杂了。试想,如果记录只支持独占锁,不管你要读还是要写,都需要获取一个独占锁,此时整个系统退化为顺序一致性模型,在SQL里也被称作可串行化(Serializable),这也SQL标准定义的最严格的隔离级别。可串行化的原则就一个,每份数据同一时刻只能有一个事务可以访问。这显然是十分安全的,这个安全是以牺牲性能为代价的;同时如果我们要分析互斥关系也变的简单,甚至说没有锁的互斥关系,原因很简单,只有一把锁:
- 每行数据只有一把记录锁,它是独占锁,所有读、写该记录的操作需要先获取这把锁。
- 每个区间只有一把区间锁,它是独占锁,所有读、写该区间的操作需要先获取这把锁。
那么在我们最常用的隔离级别可重复读(Repeatable Read) 下,情况是怎么变复杂的?因为每个共享单元(区间、行)有了多把锁:
- 每个区间上,我们需要插入意向锁和Next-Key锁的联动,它们之间是互斥的。这是为了提高性能,同时防止幻读(phantom read)。
- 每个记录上,我们需要X锁和S锁的联动,它们之间也是互斥的。这还是为了提高性能,同时防止脏读(dirty read)。
既然同一个记录上有多把锁,就很自然地涉及到锁之间的互斥关系:两把锁是否可以同时被征用,即是否兼容。比如同一个记录上的读锁和写锁就不兼容(Conflict),而不同记录上的读锁和写锁就相互兼容(Compitable)。这么讲不是很直观,我们看一下死锁的例子:
在可串行化(Serializable) 下,因为所有的锁都是独占锁,所以两个事务只要存在ABBA的锁关系,就构成潜在的死锁;而 可重复读(Repeatable Read) 下,相比而言则更加泛化,在ABCD中,如果明确A和D互斥、B和C互斥,就构成了死锁的潜在条件——我们可以另,A为记录1的写锁,B为记录2的读锁,C为记录2的写锁,D为记录2的读锁,死锁条件构成了;但这个假设下,BDDB这种翻转结构却不构成死锁了,因为都是共享锁,B和D并不互斥。可以看到,锁之间的互斥关系引入,为性能带来提升的同时,也把模型变得更复杂,“死锁”一不小心就会发生。如果明确到锁的互斥关系上,可重复读(Repeatable Read) 下有以下互斥关系:
Lock v.s. Lock | 插入意向锁 | Next-Key锁 | 行X锁 | 行S锁 |
---|---|---|---|---|
插入意向锁 | 兼容 | 互斥 | - | - |
Next-Key锁 | 互斥 | 兼容 | - | - |
行X锁 | - | - | 互 斥 \color{red}{互斥} 互斥 | 互斥 |
行S锁 | - | - | 互斥 | 兼容 |
其中,“-”表示没有必然联系,因为锁定的目标根本不是一种东西。其中,标 红 \color{red}{红} 红的互斥是可串行化(Serializable) 下也要面对的。
锁的实现
之所以要探讨锁的互斥关系,其实是为了探讨锁实现的一种可能性。我们知道,同一条记录的读锁和写锁是互斥的,所以如果用一个实例来实现,是最直观的。我们参考Java里的ReadWriteLock给出一种实现:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Console output:
* read get lock time 1567930998.354000.
* write get lock time 1567931008.391000.
*/
public class LockTest {
public static void main(String[] args) throws Exception {
final ReadWriteLock lock = new ReentrantReadWriteLock();
Thread readThd = new Thread(new LockTask(lock.writeLock()), "read");
Thread writeThd = new Thread(new LockTask(lock.readLock()), "write");
// 由于没有做同步,每次执行的结果可能不同
// 但readThd和writeThd需要获取的是一对互斥锁,所以只能有一个可以获取到临界区的进入权限
readThd.start();
writeThd.start();
}
public static class LockTask implements Runnable {
private Lock lock;
public LockTask(Lock lock) {
this.lock = lock;
}
public void run() {
lock.lock();
// 临界区开始
System.out.println(String.format("%s get lock time %f.", Thread.currentThread().getName(), System.currentTimeMillis() / 1000.0));
try {
Thread.currentThread().sleep(10000);
} catch (Exception e) {
}
// 临界区结束
lock.unlock();
}
}
}
如果把线程看作事务、把ReentrantReadWriteLock看成某条记录上的X锁和S锁的实现,那上面这个过程就是在读已提交(Read Committed) 及以上隔离级别下,两个事务对同一行进行并发读写产生互斥等待的典型过程。
对于数据库索引,我知之甚少。有一个普遍的共识是,索引是通过减少磁盘I/O来达到提升存取速度的。