盘点MySQL中的14种锁

239 阅读8分钟

2种锁策略

情景:有一家淘宝店铺,有100颗蛋对外出售,现在有小明和小红两人,分别同时下单了60颗蛋,问最后这家店铺剩余多少颗蛋?(答:-20颗)

namestock
egg100
int buy () {
    int stock = db.exec("select stock from goods where name = 'egg'");
    if (stock >= 60) {
        return db.exec("update goods set stock = stock - 60 where name = 'egg'");
    }
    return 0;
}

当小明和小红同时下单,两人拿到的stock可能都是100,那么最终将剩余-20颗鸡蛋。

悲观锁

直观想法是进入函数时加锁,退出函数时解锁,这样小明和小红可以同时下单,但buy函数不会同时执行。

int buy () {
    std::lock_guard<std::mutex> lock(mutex);
    int stock = db.exec("select stock from goods where name = 'egg'");
    if (stock >= 60) {
        return db.exec("update goods set stock = stock - 60 where name = 'egg'");
    }
    return 0;
}

这也是悲观锁的含义:总是认为自己使用的数据是不安全的,因此就需要加锁,其他人则需要等待锁释放。这和蹲坑要锁门是一个道理,因为你总是觉得会有其他人把门打开。

乐观锁

还有一种想法就没有那么直观了,如果更新stock时的stock的值发生了变化,那么就终止更新。

int buy () {
    int stock = db.exec("select stock from goods where name = 'egg'");
    if (stock >= 60) {
        return db.exec("update goods set stock = stock - 60 \
        				where name = 'egg' and stock = %d", stock);
    }
    return 0;
}

上面代码体现的是Compare And Swap无锁算法,对于此情景结果可能没问题,但过程就不一定没问题了。

试想如果小红下单后又立即取消,stock经历了100 - 40 - 100的变化,但小明会依旧认为stock是没有发生变化的,因为两次对stock的取值都是100。这种问题被称之为ABA问题。

小贴士:ABA并不是什么的缩写,它的含义和一二一是一样的,是100 - 40 - 100的意思。

由此可以理解乐观锁的含义:总是认为自己使用的数据是相对安全的,不加锁,但是在更新的时候会判断其他人有没有更新这个数据。就好比有些人会在自己的抽屉下面放一根头发一样。

可以引入一个version列解决ABA问题。

namestockversion
egg1000
int buy () {
    int stock, version = db.exec("select stock, version from goods where name = 'egg'");
    if (stock >= 60) {
        return db.exec("update goods set stock = stock - 60, version = version + 1 \
        	       	    where name = 'egg' and version = %d", version);
    }
    return 0;
}

但其实这样子还是有问题的,一是多出来一列增加了存储的成本,二是如果这个情境中鸡蛋的库存是120,那么就会导致有一个人下单失败的情况。

因此又需要在代码种引入循环来解决库存充足下单失败的问题,而循环的引入则又增加了CPU的开销。

int buy () {
    int stock, version = db.exec("select stock, version from goods where name = 'egg'");
    int times = 10;
    while (stock >= 60 && times--) {
        int rows = db.exec("update goods set stock = stock - 60, version = version + 1 \
        	       	        where name = 'egg' and version = %d", version);
        if (rows > 0) return row;
        stock, version = db.exec("select stock, version from goods where name = 'egg'");
    }
    return 0;
}

3种锁属性

共享锁(S锁)

共享锁即上文提到的读锁,共享锁间互相兼容,但与排他锁互斥。

select column from table lock in share mode;	# 显式加锁

在可重复读的隔离级别下,所有没有显式加锁的select都是快照读,显式加锁的select都是当前读

排他锁(X锁)

排他锁即上文提到的写锁,排他锁与任何属性锁都互斥。

select column from table for update;			# 显式加锁
update table set column = 1 where key = 1; 		# 隐式加锁
delete from table where key = 1;				# 隐式加锁
insert into table values(1);					# 隐式加锁

在可重复读的隔离级别下,所有没有显式加锁的select都是快照读,显式加锁的select都是当前读

意向锁(Intent Lock)

有如下情景:Thread A对表T中的某一行加了行锁。Thread B想要对表T加表锁,需要保证表T上没有表锁,且表T中任意一行上没有行锁,因此需要对检测表T的每一行是否有行锁。

意向锁就是为了解决上面的情景,意向锁是一种与表锁冲突但不与行锁、页锁冲突的表级锁。在上面的情景中,Thread A只需要对表T加上意向锁和行锁,那么Thread B只需要判断表T上是否有意向锁。

意向锁分为意向共享锁和意向排他锁.

意向共享锁(IS锁)

会在执行select column from table lock in share mode;时自动对表加意向共享锁。

事务要获取某些行的共享锁,必须先获得表的意向共享锁。

意向排他锁(IX锁)

会在执行select column from table for update;时自动对表加意向锁。

事务要获取某些行的排他锁,必须先获得表的意向排他锁。

4种锁粒度

库锁

一般叫全局锁,我更喜欢称其为库锁,能够对整个数据库实例加锁,加锁后整个数据库处于只读状态。

flush tables with read lock;	# 加锁
unlock tables;					# 解锁,客户端断开后也会解锁

库锁应用于全库逻辑备份(mysqldump)时。

表锁

表锁用于为单个表加锁,分为读锁(共享锁)、写锁(互斥锁)两种类型。

lock tables tb read(write);		# 加锁
unlock tables;					# 解锁,客户端断开后也会解锁
Thread A 加读锁Thread A 加写锁
Thread A 读数据成功成功
Thread A 写数据失败成功
Thread B 读数据成功阻塞
Thread B 写数据阻塞阻塞
Thread A 加读锁覆盖覆盖
Thread A 加写锁覆盖覆盖
Thread B 加读锁成功阻塞
Thread B 加写锁阻塞阻塞

行锁(亦称记录锁)

行锁会在需要时自动添加,事务提交时释放(和MDL一样,这种方式称为两段锁协议)。行锁也是分读锁、写锁的,也遵从读写锁之间的互斥原则。行锁的粒度小,并发度高,但加锁慢(MVCC),易死锁(两段锁)。

需要注意的是,行锁锁的是索引,如果操作的是主键索引,那么锁主键索引上相应的条目;如果操作的是非主键索引,那么先后锁住非主键索引、主键索引上相应的条目。如果没有用到索引,那么行锁会变表锁

页锁

页级锁的粒度介于表锁与行锁之间,性能也介于两者之间。和行锁一样也会发生死锁。

5种锁模式

元数据锁(MetaData Lock)

有如下场景:一个查询正在遍历数据,另一个线程修改了表结构,那就会导致查询的表结构发生变化。元数据锁就是为了避免这种情况发生。

元数据:描述数据的数据。元数据锁用于保证Table MetaData在增删改查前后的一致性。

元数据锁也是表级锁,在事务开始时自动加锁,在事务提交时自动解锁。DQL、DML对应读锁,DDL对应写锁。

加锁是在DQL、DML、DDL中进行的,使用start transaction就是取消了DQL、DML、DDL的自动提交。

这里同样遵循上面表格中覆盖、阻塞的原则。

间隙锁(Gap Lock)

当where子句使用范围条件并请求共享锁或排他锁时,会对符合范围条件的记录加锁,对符合范围但没有记录的间隙加间隙锁。间隙锁控制一段左闭右闭的区间。

间隙锁只会在可重复读隔离级别生效。

间隙锁是加在索引上的。

间隙锁是可以共存的,一个事务持有的间隙锁不会阻止另一个事务对相同的间隙进行锁定。

在获取间隙锁的事务提交前,其他事务不允许插入间隙内的记录。避免了幻读。

临键锁(Next-Key Lock,亦称后码锁)

是记录锁和记录之前的间隙的间隙锁的组合。临键锁控制一段左开右闭的区间。

对非唯一索引加记录锁时,会同时加该记录的临键锁,和下一区间的间隙锁。

自增锁(Auto-INC Lock)

自增锁是对含有自增列的表进行插入时,产生的特殊的表级锁。

如果一个事务正在插入一个表,且该表存在自增列,则其他事务对该表进行插入会被阻塞。

需要注意的是,自增锁是在语句执行结束后释放,而不是事务提交后释放。

插入意向锁(Insert-Intent Lock)

插入意向锁是一种特殊的间隙锁。在某个索引区间上插入一条记录时,会对该区间加插入意向锁。其他事务在该区间上插入记录时,只有满足唯一索引且位置相同时才会阻塞。

同时插入意向锁还会阻塞该对该间隙加间隙锁的操作。