最近,在一次 mysql 死锁的生产事故中,我发现,关于 mysql 的锁、事务等等,我所知道的东西太碎了,所以,我试着用几个例子将它们串起来。具体做法就是通过不断地问问题、回答问题,再加上“适当”的比喻,来逐步构建脑子里的“知识树”。
需要提醒一下,这篇博客并不适合小白,因为你需要先了解排它锁、共享锁、事务,最重要的是你需要知道事务中的锁是什么时候加上、什么时候打开的。而这篇博客更多的是希望把这些碎片化的知识给连接起来。
项目环境
mysql 版本:5.7.28-winx64
OS:win 10
数据库脚本:
hljs
DROP TABLE IF EXISTS demo_user
;
CREATE TABLE demo_user
(id
varchar(32) NOT NULL COMMENT '用户id',name
varchar(16) NOT NULL COMMENT '用户名',gender
tinyint(1) DEFAULT '0' COMMENT '性别',age
int(3) unsigned DEFAULT NULL COMMENT '用户年龄',gmt_create
datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录创建时间',gmt_modified
timestamp NULL DEFAULT NULL COMMENT '记录最近修改时间',deleted
tinyint(1) DEFAULT '0' COMMENT '是否删除',phone
varchar(11) NOT NULL COMMENT '电话号码',
PRIMARY KEY (id
),
KEY idx_phone
(phone
),
KEY idx_name
(name
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
insert into demo_user
(id
,name
,gender
,age
,gmt_create
,gmt_modified
,deleted
,phone
) values ('222','zzs001',0,18,'2021-12-13 15:11:03','2021-12-13 09:59:12',0,'18826');
insert into demo_user
(id
,name
,gender
,age
,gmt_create
,gmt_modified
,deleted
,phone
) values ('111','zzf001'0,18,'2001-08-27 11:00:11','2001-08-27 11:00:13',0,'18822');
脏读
准备工作
在讲脏读之前,我们先开启两个会话,并把事务隔离级别更改为读未提交(read uncommitted)。这时,id 为 222 的用户初始年龄为 18。
mysql_lock_01.png
万事俱备,我们开始吧。
什么是脏读
脏读,就是读到了其他会话还没有提交的修改。下面用例子说明:
mysql_lock_02.png
可以看到,会话 2 修改了 id 为 222 的用户,在还没提交或回滚事务之前,会话 1 就读到了这些改动。
脏读的本质就是,还没结束的写操作被读操作分割了。所以,为了解决脏读,就必须让写操作不可被读操作分割(当然,也不能被其他写操作分割),即保证所谓的原子性。
如何解决脏读
那么,应该如何实现呢?这里给出两种方案。
第一种,给读增加锁。为了保证写操作的原子性,从更新操作开始到事务结束(注意,不是事务开始到事务结束),会话 2 都应该锁着 id 为 222 的记录,会话 1 的读操作要等会话 2 的事务结束后才能执行。上面的例子中,我们理所当然地会认为是会话 2 的写操作没有加排它锁导致的脏读,然而并非如此,通过SELECT * FROM information_schema.INNODB_TRX;可以发现,会话 2 已经锁住了 id 为 222 的记录,但会话 1 的读操作并没有等待,为什么呢?根本原因在于会话 1 的读是无锁读,在读未提交的事务隔离级别中,无锁读不需要等待写操作。所以,我们需要给读加上锁(共享锁和排它锁均可,但为了并发读,建议用共享锁),如下:
mysql_lock_03.png
可以看到,因为会话 2 的更新操作还没结束,所以,会话 1 需要一直等待,直到会话 2 的事务结束,这就避免了脏读的问题。你可能会觉得奇怪,实际项目好像不是这样的吧?没错,因为我们用的更多的是第二种方案。
第二种方案,将事务隔离级别更改为读已提交(read committed)。第一种方案中,读写是串行的,然而,我们既要读写并行,又不想出现脏读。需求刁钻但合理,于是,就有了第二种方案。如下:
mysql_lock_04.png
可以看到,会话 2 的更新操作还没结束,会话 1 就读到了同一条记录,结果却没有产生脏读。如何实现的呢?
首先,我说说自己以前的理解:逻辑上有点像 java 中的CopyOnWriteArrayList,当事务隔离级别为已提交时,不会在实际记录上进行写操作,而是将需要修改的记录缓存一份进行更改,事务提交时才把这部分缓存刷入实际记录,而这个过程,其他会话可以正常读实际记录,而不会读到修改中的数据。
后来了解 MVCC 才知道我是错的,就 id 为 222 的这行数据,mysql 会同时保留多个版本,而此时的会话 1 只能看到更早的已提交版本。具体见文末参考资料:什么是 MVCC。
不可重复读
准备工作
在讲不可重复读之前,我们可以把事务隔离级别设置为读未提交(read uncommitted),也可以设置为读已提交(read committed)。
什么是不可重复读
不可重复读,就是在同一个事务中,多次读相同的记录但读到了不同的结果。下面用例子说明:
mysql_lock_05.png
可以看到,会话 1 第一次读 id 为 222 的用户年龄为 18,在事务还没结束之前,会话 2 将他的年龄更改为 19,会话 1 再次读就会出现前后不一致的情况。
不可重复读的本质就是,还没结束的读操作被写操作分割了。所以,为了解决不可重复读,就必须让读操作不可被写操作分割,即保证所谓的原子性。
如何解决不可重复读
那么,应该如何实现呢?和解决脏数据一样,这里也给出两种方案。
第一种方案,给读增加锁来。为了保证读操作的原子性,从读操作开始到事务结束(注意,不是事务开始到事务结束),会话 1 都应该锁着 id 为 222 的记录,会话 2 的写操作要等会话 1 的事务结束后才能执行。所以,我们需要给读加上锁(共享锁和排它锁均可,但为了并发读,建议用共享锁),如下:
mysql_lock_06.png
可以看到,会话 2 的写操作需要等待会话 1 的事务结束才能执行,在事务结束之前,会话 1 读几次数据都不会出现不可重复读。
第二种方案,将事务隔离级别更改为可重复读(repeatable read)。第一种方案中,读写是串行的,然而,我们既要读写并行,又不想出现不可重复读。于是,就有了第二种方案。如下:
mysql_lock_07.png
可以看到,会话 1 的读操作并没有加锁,会话 2 的写操作也不需要等待,最终却没有产生不可重复读。如何实现的呢?
首先,我说说自己以前错误的理解:当第一次读到 id 为 222 的记录时,mysql 会把这条记录放在当前事务的缓存区里,下次读这条数据的时候直接从缓存拿就好,不需要去读实际记录,所以,其他会话的写操作并不需要等待。
不过,和解决脏读一样,这里也是用到了 MVCC。有人可能会问,同样是 MVCC 为什么,RR 可以解决可重复读,而 RC 不行?具体的原理见文末参考资料:什么是 MVCC。