Levy's ink.
Doodles, whimsy & life.
About
Blog
Mess
Catalog

MySQL中查找-验证-插入操作的一些问题

毫无疑问,Transaction是关系型数据库最大的利器,MySQL默认的“Repeatable Read”一致性级别能够让绝大部分数据库并发操作的结果和用户“直观想象”得到的一样。然而,在一些极端情况下数据库可能并不如预期一般工作,甚至带来数据不一致、活锁等并发Bug。在工作中我碰到了一个有趣的问题,在此分享一下。

“查找-验证-插入”是一个常见的开发逻辑,即,查找某记录,如果其存在并通过验证则继续Transaction;如果其存在但不通过验证则终止Transaction;如果其不存在则插入一个记录。以一个简单的数据表 test为例,假设该数据表定义如下:

CREATE TABLE test (
    r BIGINT PRIMARY KEY
);

那么上述操作用伪代码描述如下:

db.startTransaction();
results = db.runQuery("SELECT * FROM test WHERE r = 1");
if (results.length == 0) {
    db.runQuery("INSERT INTO test VALUES (1)");
} else if (!validate(results[0])) {
    db.abortTransaction();
    throw "Invalid Record!";
}
// We are sure that the record is as expected
// Do other Operations
// ...
db.commitTransaction();

Looks Good,那么上述代码出了什么问题呢?

0x01 访问竞争导致的数据不一致

假设数据库中原本有能通过验证的记录,在上述代码片段(T1)执行操作时,另一段代码(T2)删除又增加了另一个 r=1的记录,而他们恰好以下列方式交错在了一起:

# r = 1 exists
SELECT * FROM test WHERE r = 1;              # [T1] Return valid record A
DELETE FROM test WHERE r = 1;                # [T2]
INSERT INTO test VALUES (1) ;                # [T2]
"We are sure that the record is as expected" # [T1] Wrong statement!!!

则造成了开发者没有预料到的结果:T1原本认为应该合法的记录A已被T2替换,而T1全然不知地继续接下来的操作——这是非常危险的,假设T1接下来插入了对记录A有依赖的信息,则产生了数据不一致,大事休矣。

0x02 解决数据不一致问题

对于上述问题,大部分人会想到的第一方案是 SELECT FOR UPDATE关键词,即,在查找该记录时,顺便拿上写锁,防止其他Transaction修改(详情请见MySQL文档)。这么一来,上述代码片段修改如下:

db.startTransaction();
results = db.runQuery("SELECT * FROM test WHERE r = 1 FOR UPDATE");
if (results.length == 0) {
    db.runQuery("INSERT INTO test VALUES (1)");
} else if (!validate(results[0])) {
    db.abortTransaction();
    throw "Invalid Record!";
}
// We are sure that the record is as expected
// Do other Operations
// ...
db.commitTransaction();

从而顺利解决了0x01提到的问题:

# r = 1 exists
SELECT * FROM test WHERE r = 1 FOR UPDATE;   # [T1] Return valid record A
DELETE FROM test WHERE r = 1;                # [T2] FAIL!! Lock Timeout!
"We are sure that the record is as expected" # [T1] True statement

0x03 奇怪的活锁

事情并没有到此为止。0x02中的代码虽然能避免数据不一致问题,却带来了活锁:当两个Transaction在执行相同的操作时,有一定概率均无法成功,如果他们俩都孜孜不倦地重试,那么在极端情况下无人能最终成功。这是如何发生的?假设查找的记录并不事先存在,运行序列如下:

# r = 1 doesn't exist
SELECT * FROM test WHERE r = 1 FOR UPDATE;   # [T1] No record
SELECT * FROM test WHERE r = 1 FOR UPDATE;   # [T2] No record
INSERT INTO test VALUES (1) ;                # [T1] FAIL!! Lock Timeout!
INSERT INTO test VALUES (1) ;                # [T2] FAIL!! Lock Timeout!

即,T1、T2均拿到了 FOR UPDATE的写锁,从而导致对方在插入记录环节失败。

嗯,怎么回事??众所周知,写锁是排他(Exclusive)的,怎么会有两个Transaction同时拿到写锁??

0x04 排他锁、空隙锁

对上述问题的直接答案是:“因为这个记录不存在。” 假如我们对一个已经存在的记录做上述操作,则一切都和我们预计的一样:

# r = 1 exists
SELECT * FROM test WHERE r = 1 FOR UPDATE;   # [T1] Valid record
SELECT * FROM test WHERE r = 1 FOR UPDATE;   # [T2] FAIL!! Lock Timeout!

很好,第二个试图拿到写锁的Transaction失败了。那么回到之前的情况,问题出在哪儿?

事实上,MySQL在执行 FOR UPDATE操作时,如果该记录不存在,会给搜索用到的索引加上“空隙锁(Gap Lock)”,即“锁住对r=1记录的所有写操作”。但因为InnoDB内部实现的原因(见附1),空隙锁尽管能防止写操作,但无法阻止其他Transaction获取一个一样的空隙锁,到这一步,两个事务已经实际死锁,任何一方对该空隙的任何操作都势必失败。

0x05 解决方案

既然了解了问题原理和发生机制,我们只能试图规避 SELECT FOR UPDATE在该场景下的应用。 INSERT IGNORE能完美地从另一个角度满足我们最初的需求:

db.startTransaction();
db.runQuery("INSERT IGNORE INTO test VALUES (1)");
results = db.runQuery("SELECT * FROM test WHERE r = 1");
if (!validate(results[0])) {
    db.abortTransaction();
    throw "Invalid Record!";
}
// We are sure that the record is as expected
// Do other Operations
// ...
db.commitTransaction();

因为插入操作总能获得一个真正的排他锁,这个Transaction从第一步开始就防止了任何竞争访问的介入、从而杜绝了数据不一致;而真正的排他锁又能保证之后的操作一路绿灯,即使有两个Transaction同时进行,也能保证至少一个一定成功。

Fin.

附1

MySQL在其文档中明确写出了空隙锁的性质:

Gap locks in InnoDB are “purely inhibitive”, which means that their only purpose is to prevent other transactions from inserting to the gap. Gap locks can co-exist. A gap lock taken by one transaction does not prevent another transaction from taking a gap lock on the same gap. There is no difference between shared and exclusive gap locks. They do not conflict with each other, and they perform the same function.