MySQL的锁按两个维度分,刚好对应开发中最常遇到的问题:
按解决并发冲突的思路分,是悲观锁和乐观锁——这俩决定了“你是先占坑再干活,还是先干活最后再检查”;
按锁的范围大小分,是表锁、行锁,还有 InnoDB 特有的间隙锁 + 临键锁 —— 这三类锁决定了 “你是锁整个仓库(表锁)、只锁你要的那一件商品(行锁),还是锁仓库里的某个货架区间(间隙锁 + 临键锁)”。
先建一张最简单的电商商品表 product,插几条测试数据,等会更好理解举的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
CREATE TABLE `product` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(100) NOT NULL COMMENT '商品名称',
`stock` int(11) NOT NULL DEFAULT '0' COMMENT '库存',
`version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
-- 插入测试数据:id=1是iPhone,库存100;id=3是华为,库存200;id=5是小米,库存150
INSERT INTO `product` (`id`, `name`, `stock`, `version`) VALUES
(1, 'iPhone 17', 100, 0),
(3, 'Mate 70', 200, 0),
(5, 'Xiaomi 15', 150, 0);
|
悲观锁
举个电商秒杀扣库存的真实例子,也是悲观锁最典型的应用:
比如有两个用户同时秒杀iPhone 17,库存100。
如果不用锁,俩人同时查出来库存100,都扣减1,就超卖了。
用悲观锁的话,就是在查库存的时候用 SELECT ... FOR UPDATE 显式加排他锁——
第一个用户的事务先把这行数据锁住,第二个用户的查询就会阻塞,等第一个用户扣减完库存、提交事务释放锁,第二个用户才能拿到最新的库存,这样就保证了不超卖。
乐观锁
这也是开发中很常见的场景:两个运营同时修改同一件商品的标题。
这种场景并发冲突概率很低(因为很少会有两个人同时改同一个商品的标题),如果用悲观锁,每次修改都要加锁,太影响性能了,所以用乐观锁更合适。
乐观锁的意思就是:我假设没人跟我抢,所以我先直接修改,最后提交的时候再检查一下:
在我修改的这段时间里,有没有别人也改过这条数据?
如果没有,就修改成功;
如果有,就提示用户“数据已被修改,请刷新后重试”。
举个具体的SQL操作实例
我们打开两个命令行窗口,分表代表两个会话。
1.会话A(运营A先查商品)
1
2
|
-- 1. 运营A查出来iPhone的信息,此时 version = 0
SELECT * FROM product WHERE id = 1;
|
2.会话B(运营B同时查商品)
1
2
|
-- 1. 运营B也查出来iPhone的信息,此时 version 也是 0
SELECT * FROM product WHERE id = 1;
|
3.会话A(运营A先提交修改)
1
2
3
4
5
|
-- 1. 运营A把标题改成“iPhone 17 Pro Max”,WHERE条件带上 version = 0
UPDATE product
SET name = 'iPhone 17 Pro Max', version = version + 1
WHERE id = 1 AND version = 0;
-- 现象:返回更新行数为 1,修改成功!此时数据库里的 version 变成了 1
|
4.会话B(运营B后提交修改)
1
2
3
4
5
6
7
|
-- 1. 运营B把标题改成“iPhone 17 特价版”,WHERE条件也带上 version = 0
UPDATE product
SET name = 'iPhone 17 特价版', version = version + 1
WHERE id = 1 AND version = 0;
-- 现象:返回更新行数为 0,修改失败!
-- 因为数据库里的 version 已经变成 1 了,找不到 id=1 AND version=0 的行
-- 此时业务代码就可以提示用户:“商品已被其他运营修改,请刷新页面后重试”
|
表锁
直接锁整张表,其他会话对这张表的任何读写(不管是查哪一行、改哪一行)都会被阻塞。
举个例子
1
2
3
4
5
6
7
8
9
|
-- 会话A:给全表商品库存+10,显式加表锁
LOCK TABLES product WRITE;
UPDATE product SET stock = stock + 10;
-- 此时会话B想查id=1的商品,会直接阻塞!
SELECT * FROM product WHERE id = 1;
-- 会话A提交事务并释放表锁后,会话B才能执行
UNLOCK TABLES;
|
表锁的优点是加锁开销小、速度快,不会死锁,但缺点就是并发度极低,一锁全表,电商场景下用它会直接让网站卡死。
行锁
只锁具体的某一行数据,其他行完全不受影响,是InnoDB支持高并发的核心。
需要注意的是行锁的前提是【走索引】。
如果你的查询没走索引,InnoDB会退化成表锁。
举两个例子对比一下
先看走索引的情况(行锁生效):
1
2
3
4
5
6
7
8
9
10
|
-- 会话A:用主键id=1查,加排他锁(走主键索引,只锁id=1这行)
BEGIN;
SELECT * FROM product WHERE id = 1 FOR UPDATE;
-- 会话B:查id=3的商品,完全不阻塞!直接拿到锁
BEGIN;
SELECT * FROM product WHERE id = 3 FOR UPDATE; -- 执行成功
-- 会话B:查id=1的商品,会阻塞!需要等会话A释放锁
SELECT * FROM product WHERE id = 1 FOR UPDATE; -- 阻塞
|
再看没走索引的情况(退化成表锁):
1
2
3
4
5
6
7
8
|
-- 先确认name字段没加索引(前面建表的时候只给id加了主键)
-- 会话A:用name='iPhone 17'查,加排他锁(没走索引,锁全表!)
BEGIN;
SELECT * FROM product WHERE name = 'iPhone 17' FOR UPDATE;
-- 会话B:查id=3的商品,居然也阻塞了!因为锁了全表
BEGIN;
SELECT * FROM product WHERE id = 3 FOR UPDATE; -- 阻塞
|
行锁的优点是并发度极高,只锁需要的行,不影响其他操作;但缺点是加锁开销大、速度慢,可能产生死锁。
间隙锁+临键锁
这俩是InnoDB特有的,专门用来解决【幻读】问题的。
先举个电商场景的例子说明一下什么是幻读?
1
2
3
4
5
6
7
8
9
10
|
-- 会话A:查id>1的所有商品(此时表里只有id=1、3、5)
BEGIN;
SELECT * FROM product WHERE id > 1 FOR UPDATE; -- 第一次查:查到id=3、5两条
-- 会话B:插了一条id=2的商品,提交事务
INSERT INTO product (id, name, stock, version) VALUES (2, 'OPPO Find X8', 100, 0);
COMMIT;
-- 会话A:再查id>1的商品,居然查到了id=2、3、5三条!
SELECT * FROM product WHERE id > 1 FOR UPDATE; -- 第二次查:多了一条,像产生了幻觉
|
这就是幻读:同一个事务里,两次相同的范围查询,结果却是不一样的。
间隙锁(Gap Lock)
间隙锁,间隙锁,顾名思义,它不锁具体的行,锁的是两个索引值之间的“间隙”,也就是只锁两个相邻索引值之间的空白区间,以防止别人在这个区间里插数据。
以刚开始建的那个product表为例:
id=1、3、5,间隙就是:(-∞,1)、(1,3)、(3,5)、(5,+∞)。
临键锁(Next-Key Lock)
临键锁其实是【行锁】+【间隙锁】的组合,锁的是左开右闭的区间。
比如(1,3](锁id=3这一行,同时锁1到3之间的间隙)。
还是以刚开始建的那个product表为例,临键区间就是:(-∞,1]、(1,3]、(3,5]、(5,+∞)。
举个例子
1
2
3
4
5
6
7
8
9
|
-- 会话A:用id=3查,加排他锁(走主键索引,加临键锁(1,3])
BEGIN;
SELECT * FROM product WHERE id = 3 FOR UPDATE;
-- 会话B:想插id=2的商品,直接阻塞!因为间隙(1,3)被锁了
INSERT INTO product (id, name, stock, version) VALUES (2, 'OPPO Find X8', 100, 0); -- 阻塞
-- 会话B:想插id=6的商品,不阻塞!因为(5,+∞)没被锁
INSERT INTO product (id, name, stock, version) VALUES (6, 'Vivo X200', 100, 0); -- 执行成功
|
小贴士
间隙锁 + 临键锁只在 InnoDB 的默认隔离级别【可重复读(RR)】下生效。
如果是【读提交(RC)】隔离级别,会关闭间隙锁,只保留行锁。
夺命连环问:什么是死锁?又怎么避免呢?
两个或多个会话,互相持有对方需要的锁,都在等待对方释放,导致谁都执行不下去,形成“死循环”,这就是死锁。
举个例子
1
2
3
4
5
6
7
8
9
10
11
12
13
|
-- 会话A:先锁id=1,再想锁id=3
BEGIN;
SELECT * FROM product WHERE id = 1 FOR UPDATE; -- 持有id=1的锁
-- 此时会话A想拿id=3的锁,但被会话B持有了,阻塞
SELECT * FROM product WHERE id = 3 FOR UPDATE; -- 阻塞
-- 会话B:先锁id=3,再想锁id=1
BEGIN;
SELECT * FROM product WHERE id = 3 FOR UPDATE; -- 持有id=3的锁
-- 此时会话B想拿id=1的锁,但被会话A持有了,阻塞
SELECT * FROM product WHERE id = 1 FOR UPDATE; -- 阻塞
-- 结果:死锁!
|
死锁的四个必要条件
只要破坏其中任意一个,就能避免死锁:
- 互斥条件:一个锁只能被一个会话持有(行锁就是这样,没法破坏);
- 请求与保持条件:持有一个锁的同时,又请求另一个锁;
- 不剥夺条件:锁只能由持有者主动释放,不能被别人强行抢走;
- 循环等待条件:会话之间形成循环等待链(A等B,B等A)。
怎么避免死锁?
① 按固定顺序加锁
所有会话都按相同的顺序加锁,比如都先锁id小的,再锁id大的,这样就不会形成循环等待。
② 减少锁的范围和持有时间
尽量用行锁不用表锁;
事务尽量小,不要在事务里做无关操作(比如不要在事务里调第三方接口、查无关表),锁持有时间越短,死锁概率越低。
③ 避免大事务
大事务持有锁的时间长,涉及的行多,更容易死锁,把大事务拆成多个小事务。
小贴士
InnoDB 有自动死锁检测机制,它会实时扫描锁等待链,一旦发现死锁,会自动回滚其中一个,一般是代价较小的那个事务,比如更新行数少的那个,让另一个事务继续执行,不会让系统无限期卡死。