学MySQL的第二座大山---锁,事务

792 阅读7分钟

image.png 要想弄明白锁,就要明白为什么要有锁。----为了数据一致性。

为什么数据会不一致?服务端的一生之敌:并发。

什么是锁

很简单:

一个数据,大家一起看,很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 ):顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

image.png

口语化一点:

  • 读未提交:别人没提交你也可以看到,这就是脏读。如上图V1就是2。这就容易导致事务B回滚了,A就凉了。
  • 读提交:别人提交了你才能读到,引发不可重复读。就是一个事务内两次读到的不一样。如上图,V1是1,V2是2,事务A直接懵逼了。
  • 可重复读:就是一个事务内,我读到的都是一致的。如上图,V1、2、3都是1,但是,还剩一个坑,就是幻读,就是有人插入的时候我读到的数据会增加。幻读解决:间隙锁(比较复杂,后面专门写一篇)。
  • 串行化:解决并发的最好办法就是队列,不并发就完事了,哈哈哈。
事务隔离级别脏读不可重复读幻读
读未提交(read-uncommitted)
读提交(read-committed)
可重复读(repeatable-read)
串行化(serializable)

细说锁

MDL读锁导致DDL锁阻塞

image.png

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 语句会执行以下步骤:

  1. 由于用到了非主键索引,首先需要获取 idx_1 上的行级锁
  2. 紧接着根据主键进行更新,所以需要获取主键上的行级锁
  3. 更新完毕后,提交,并释放所有锁。

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可以点赞让更多人看到它。
  • 想看更多关于锁的内容的欢迎评论。

相关阅读: