您的浏览器过于古老 & 陈旧。为了更好的访问体验, 请 升级你的浏览器
Ready 发布于2020年05月08日 01:47 最近更新于 2020年05月09日 16:25

原创 使用数据库事务悲观锁时的一些注意事项

3298 次浏览 读完需要≈ 20 分钟 MySQL数据库

内容目录

前面,我们在《面向开发者的 Web 应用安全入门指南(9):事务并发安全》一文中提到了数据库的悲观锁。使用悲观锁,我们可以较为简单方便地解决事务并发的冲突问题。

但是,在使用数据库的悲观锁之前,我们也必须掌握关于数据库悲观锁的一些关键细节,才能让我们在使用悲观锁的过程中尽量少「踩雷」。

在这里,我们以 MySQL 数据库 InnoDB 引擎的悲观锁为例进行讲解,其他的数据库在这方面可能略有不同,但基本大同小异,请自行查阅相关数据库的官方文档资料。

注意事务的隔离级别

大多数开发人员应该都知道:在SQL标准中定义了四种隔离级别,每一种级别都规定了一个事务中所作的修改,哪些在当前事务内和(其他)事务之间是可见的,哪些是不可见的。较低级别的隔离通常可以执行更高的并发,系统的开销也更低。

下面我们简单地介绍一下四种隔离级别:

READ UNCOMMITTED(读未提交)
在 READ UNCOMMITTED 级别中,事务中的修改,即使没有提交,对其他事务也都是可见的。事务可以读取(其他事务)未提交的数据,这也被称为脏读(Dirty Read)。这个级别会导致很多问题,从性能上来说,READ UNCOMMITTED不会比其他的级别好太多,但却缺乏其他级别的很多好处,除非真的有非常必要的理由,在实际应用中一般很少使用。
READ COMMITTED(读已提交)
大多数数据库系统的默认隔离级别都是 READ COMMITTED(但 MySQL 不是)。它满足数据隔离性的简单定义:一个事务开始时,只能「看见」已经提交的事务所作的修改。 换句话说,一个事务从开始直到提交之前,所做的任何修改对其他事务都是不可见的。这个级别有时候也叫做不可重复读(Nonrepeatable Read),因为在同一个事务内执行两次同样的查询,可能会得到不一样的结果。
REPEATABLE READ(可重复读)
REPEATABLE READ 解决了脏读的问题。该级别保证了在同一事务中多次执行读取相同数据的查询,在本次查询和上一次查询中读取到的同一行数据记录,其结果也是一致的。但是理论上,可重复读隔离级别还是无法解决另一个幻读(Phantom Read)的问题。所谓幻读,指的是当某个事务读取某个范围内的记录时,另一个事务又在该范围内插入了新的记录。当之前的事务再次读取该范围内的记录时,会包含该新的记录,这种情况我们称之为产生了幻行(Phantom Row)。
SERIALIZABLE(可串行化)
SERIALIZABLE 是最高的隔离级别。它通过强制事务串行执行,避免了前面说的幻读问题。简单来说,SERIALIZABLE 会在读取到的每一行数据上都加锁,所以可能导致大量的超时和锁争用问题。实际应用中也很少用到这个隔离级别,只有在非常需要确保数据的一致性而且可以接受没有并发的情况下,才考虑采用这一隔离级别。
ANSI SQL 标准的隔离级别
隔离级别 脏读可能性 不可重复读可能性 幻读可能性
READ UNCOMMITTED
READ COMMITTED ×
REPEATABLE READ × ×
SERIALIZABLE × × ×

然后,对于MySQL而言,它的隔离级别稍微有点不一样:在默认的REPEATABLE READ隔离级别中,MySQL的 InnoDB 引擎通过 多版本并发控制(MVCC)和 Next-Key Lock(新版本) 等机制解决了幻读问题。

请务必注意:不同的数据库版本、不同的事务隔离级别对SQL语句的事务并发执行结果影响很大。讨论锁的时候不指明数据库版本和隔离级别,都是耍流氓!

MySQL事务的默认隔离级别为Repeatable-Read(可重复读)。我们在MySQL可以执行如下SQL语句,来查询当前会话(session)的隔离级别设置:

SELECT @@TX_ISOLATION;


-- 也可以使用如下语句:

-- 显示当前会话的隔离级别设置。"SESSION"可以省略,默认即为"SESSION"
SHOW SESSION VARIABLES LIKE 'tx_isolation';

-- 显示全局的隔离级别设置。
SHOW GLOBAL VARIABLES LIKE 'tx_isolation';

此外,我们也可以使用如下语句,重新调整隔离级别:

-- 括号"[]"或"<>"中的内容按照"|"分隔,多选一
-- "[]"中的内容也可以省略,默认为"SESSION"

SET [ SESSION | GLOBAL ] TRANSACTION ISOLATION LEVEL < READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE >;
调整隔离级别的相关语句细节,请参考MySQL官方文档:https://dev.mysql.com/doc/refman/8.0/en/set-transaction.html

优先使用基于主键的悲观锁查询

在MySQL中,我们可能听说过许多与「锁」相关的名词:共享锁、排它锁、意向锁、记录锁、gap锁……

MySQL InnoDB-Locking
MySQL InnoDB-Locking

限于篇幅,我们在此并不对这些锁进行展开讲解(在本文中也无需深入理解)。如果想要进一步了解,建议查阅MySQL的官方文档:https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html,此外也可以查阅第三方的中文版详细介绍:https://zhuanlan.zhihu.com/p/31875702

在这里,我们以如下的member表为例来展示不同查询的悲观锁(排它锁)开销:

CREATE TABLE `member` (
`id`  int NOT NULL AUTO_INCREMENT COMMENT '主键ID' ,
`phone_no`  varchar(20) NOT NULL COMMENT '手机号码' ,
`name`  varchar(10) NOT NULL COMMENT '名称' ,
`company`  varchar(255) NOT NULL COMMENT '所在公司' ,
PRIMARY KEY (`id`),
UNIQUE INDEX `uk_phone_no` (`phone_no`) ,
INDEX `idx_name` (`name`) 
);

然后,我们在member表中分别执行以下查询:

-- 基于 主键列 的条件查询
SELECT * FROM member WHERE id = 1 FOR UPDATE;

-- 基于 唯一索引列 的条件查询
SELECT * FROM member WHERE phone_no = '13800013800' FOR UPDATE;

-- 基于 普通索引列 的条件查询
SELECT * FROM member WHERE `name` = '张三' FOR UPDATE;

-- 基于 无索引的列 的条件查询
SELECT * FROM member WHERE company = 'CodePlayer' FOR UPDATE;
MySQL中基于不同列的悲观锁查询的加锁情况
条件查询的列类型 加锁情况(括号中为加锁类型,X表示排它锁)
主键 对应数据行的聚集索引(X)
唯一索引 对应数据行的聚集索引(X) + 对应数据行的辅助索引(X)
普通索引 对应数据行的聚集索引(X) + 对应数据行的辅助索引(next-key)
无索引 全表的聚集索引(next-key)

从上表我们可以看出:只有基于主键列的条件查询,其加锁开销是最小的。

因此,我们在执行悲观锁FOR UPDATE查询时,尽量通过WHERE 主键列 = 对应值这样的方式进行条件查询。这样可以只在对应的单个数据行记录上加锁,从而可以更好地保证数据库的事务并发处理能力。

减少加锁时间

与线程同步锁的性能优化原理类似,我们也需要尽量减少悲观锁对数据库资源的锁定占用时间。

因此,在确保不影响业务逻辑正确性和数据一致性的前提下,我们应该将那些不依赖锁定的数据资源、只读数据的逻辑处理、能够让业务方法快速失败的相关代码尽量前置。

避免事务死锁

如果我们需要在一个事务中执行多个悲观锁查询,那么我们务必需要高度警惕,尽量确保不会产生死锁。

那么,什么是「死锁」呢?我们先来看一个具体的例子:

模拟事务死锁的发生场景(一)
时间顺序 事务A 事务B
1 SELECT * FROM table_product WHERE id = 1 FOR UPDATE
2 SELECT * FROM table_order WHERE id = 1 FOR UPDATE
3 SELECT * FROM table_order WHERE id = 1 FOR UPDATE
4 SELECT * FROM table_product WHERE id = 1 FOR UPDATE

如上表所示,我们有两个事务(A和B)可能会并发执行。事务A会先给table_productid=1的数据行添加排它锁;事务B会先给table_orderid=1的数据行添加排它锁。接着,两个事务又准备分别抢占对方已经占用了的排它锁,由于对应数据行的排它锁都正在被对方占用,因此它们都只能等待对方先释放锁。

很显然,两个事务都分别依赖并需要等待对方先释放正在占用中的排它锁,这就不得不陷入一个互相无限等待的恶性循环——这就是典型的死锁。

死锁,是指多个事务在同一资源上互相占用,并请求锁定对方占用中的资源,从而导致恶性循环的现象。当多个事务试图以不同的顺序锁定资源时,就可能会产生死锁。

不仅仅是显式的FOR UPDATE查询,当我们执行UPDATE语句时,数据库也会隐式地为对应的数据行添加类似的排它锁。

因此,如下场景也可能会导致死锁:

模拟事务死锁的发生场景(二)
时间顺序 事务A 事务B
1 UPDATE table_product SET price = 125 WHERE id = 1
2 UPDATE table_product SET status = 0 WHERE id = 2
3 UPDATE table_product SET status = 1 WHERE id = 2
4 UPDATE table_product SET in_stock = 0 WHERE id = 1

如果凑巧,两个事务都先执行了各自的第一条UPDATE语句,更新了一行数据,同时也锁定了这一行数据。接着,两个事务都尝试去执行第二条UPDATE语句,结果却发现对应的数据行已经被对方锁定。因此,和前面的场景(一)一样,又陷入了死循环。除非有外部因素介入,才可能解除死锁。

为了解决这种问题,数据库系统实现了各种死锁检测和死锁超时机制。在 MySQL 的 InnoDB 引擎中,如果数据库检测到死锁,它会优先将持有较少行级排它锁的事务进行回滚。

当然,我们也不能全指望数据库的死锁检测机制,毕竟它会给出现事务回滚的用户带来不好的操作体验。因此,当我们在一个事务中需要对多个数据资源同时加锁时,需要保持一致的加锁顺序,否则可能会造成死锁。例如:事务一 需要对资源 A、B、C 依次全部加锁后才可以进行更新操作,那么事务B的加锁顺序也必须是 A、B、C,否则可能出现死锁。

  • CodePlayer技术交流群1
  • CodePlayer技术交流群2

0 条评论

撰写评论