要想弄明白锁,就要明白为什么要有锁。----为了数据一致性。
为什么数据会不一致?服务端的一生之敌:并发。
什么是锁
很简单:
一个数据,大家一起看,很OK。但是我要修改数据,你别动了,我就加个锁。
那么,我不动能不能看?那自然是不能的,因为大概率你读到的是错的。
所以:
读锁(也叫共享锁,shared lock,S锁)和读锁不互斥;写锁(也叫排他锁,exclusive locks,X锁)和读锁、其他写锁互斥。
那么,你只改一行,我读其他的行可以么?
锁的粒度:行锁、表锁、页锁、全局锁
粒度越细,越支持并发,但是开销越大。
那么,我看了马上就要改了,你能不能不读了。
意向锁:意向共享锁(intention+锁,IS锁)和意向排他锁(IX锁),更多的是为了引擎的优化。
根据操作:
增删改:DML锁(分读写);加字段等该表操作:DDL锁。
但是,一个业务操作往往没有这么单纯,如果我要先读10秒再写1秒,其他人这10秒都不让读了么?
事务
为了防止要做的事情被打断而导致意外,添加一个逻辑,要么做完,要么撤销,这就是事务。
人人都知道的ACID:
- 原子性 automatic:事务是原子工作单位,要么全部执行、要么全部不执行
- 一致性 consistency:数据库通过事务完成状态转变
- 隔离性 isolation:事务提交前,对数据的影响是不可见的
- 持久性 duration:事务完成后,对数据的影响是持久的
他来了他来了,他带着bug走来了。万恶的四种隔离级别(有多少初学者觉得这个是噩梦的):
- 读未提交(read uncommitted):一个事务还没提交时,它做的变更就能被别的事务看到。
- 读提交(read committed):一个事务提交之后,它做的变更才会被其他事务看到。
- 可重复读(repeatable read):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
- 串行化(serializable ):顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
口语化一点:
- 读未提交:别人没提交你也可以看到,这就是脏读。如上图V1就是2。这就容易导致事务B回滚了,A就凉了。
- 读提交:别人提交了你才能读到,引发不可重复读。就是一个事务内两次读到的不一样。如上图,V1是1,V2是2,事务A直接懵逼了。
- 可重复读:就是一个事务内,我读到的都是一致的。如上图,V1、2、3都是1,但是,还剩一个坑,就是幻读,就是有人插入的时候我读到的数据会增加。幻读解决:间隙锁(比较复杂,后面专门写一篇)。
- 串行化:解决并发的最好办法就是队列,不并发就完事了,哈哈哈。
| 事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交(read-uncommitted) | 是 | 是 | 是 |
| 读提交(read-committed) | 否 | 是 | 是 |
| 可重复读(repeatable-read) | 否 | 否 | 是 |
| 串行化(serializable) | 否 | 否 | 否 |
细说锁
MDL读锁导致DDL锁阻塞
A、B本来加MDL读锁很自在,然后C来了,要等读锁释放,阻塞了,但是会导致后面的查询也阻塞了。如果B很耗时,可能业务直接受影响。这就是线上改表结构比较难受的地方。
解决:从库改好了再主备切换。或者耗时比较短就给ALTER语句加个等待时间。
行锁冲突
举个例子,双11网购,先扣用户钱,还是先加商家的钱?
我们知道扣钱和加钱肯定会写到一个事务里面,大概会有三个步骤:
- 1.先扣用户的钱
- 2.再加商家的钱
- 3.写交易流水表
理论上是不是商家操作比较多,也就是锁冲突会比较多,那么我们按照132的顺序,就可以最短持有商家的锁。
当然一般我都是从业务层处理了:
- 队列处理
- 将商家的账号变成10个,加钱的时候随机加,扣钱的时候随机扣,求余额就sum,只是扣钱需要判断为0的话要切换账号。
减少大事务
比如删除1000万条,就不要一次删除了,分批次删除。
比如更新1000个人,就分成每次100人甚至10个人。
意向锁的好处
注意:意象锁是表级别的锁,只是用于预判,也只和表级锁冲突。
意向锁是有数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在在数据表的对应意向锁。
1:
SELECT * FROM A WHERE id = 1 lock in share mode;(加表IS锁和行S锁)
T2:
LOCK TABLES A READ; (加表锁)
T3:
SELECT * FROM A WHERE id = 5 FOR UPDATE;(加表IX锁和行X锁)
T1执行时候,对id=1这行加上了S锁(行级读锁),T2执行前,需要获取全表的更新锁进行判断,即:
- step1:判断表A是否有表级锁
- step2:判断表A每一行是否有行级锁 当数据量较大时候(我们一张表一般500-5000万数据),step2这种判断极其低效。
意向锁: 发现表A有IS锁,说明表肯定有行级的S锁,因此,T2申请表锁阻塞等待,不需要判断全表,判断效率极大提高
那么T3会不会成功?
T3首先拿IX锁,虽然有IS锁,但是id = 5还没有行锁,依然可以拿到。
先查询再修改
最典型的,秒杀。
我们要先查询这个商品的剩余量,再扣库存。
最容易想到的就是select for update,从查询开始就上锁,但是这样就会导致锁的时间非常长,后面查询就会失败。(这也叫悲观锁)
另一种就是查询不锁,更新的时候加判断,失败就失败。
update 商品 set 库存 = 库存 -1 where id = 1 and 库存 -1 > 0
所以后面执行的就会都报错,业务上就是没抢到呗。
死锁
行锁锁的是索引。
栗子:
CREATE TABLE `user_item` (
`id` BIGINT(20) NOT NULL,
`user_id` BIGINT(20) NOT NULL,
`item_id` BIGINT(20) NOT NULL,
`status` TINYINT(4) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_1` (`user_id`,`item_id`,`status`)
) ENGINE=INNODB DEFAULT CHARSET=utf-8
update user_item set status=1 where user_id=? and item_id=?
这个 update 语句会执行以下步骤:
- 由于用到了非主键索引,首先需要获取 idx_1 上的行级锁
- 紧接着根据主键进行更新,所以需要获取主键上的行级锁
- 更新完毕后,提交,并释放所有锁。
update user_item .....where id=? and user_id=?,这条语句会先锁住主键索引,然后锁住 idx_1
A占用了非主键索引,等待主键索引,B占用了主键索引,等非主键索引,造成死锁。
死锁怎么办
一种是超时,事务回滚;另一种是死锁检测,将持有最少行锁的事务回滚。
但是死锁检测是有代价的,还有一种可以减少死锁的办法,就是先查询获得主键ID,再根据主键Id更新。就转化成了行锁冲突。
select id from user_item where user_id=? and item_id=?
update user_item set status=1 where id = ?
结语
- 如果有不对的地方欢迎指正。
- 如果有不理解的地方欢迎指出我来加栗子。
- 如果感觉OK可以点赞让更多人看到它。
- 想看更多关于锁的内容的欢迎评论。