最近有个简单的需求,作为mysql轻度使用者,得以一窥其复杂程度。
问题背景
- 用户购买了多套不同region下的对象存储。从后端来讲,每个region都是一套独立的对象存储集群,相互之间并不直接通信,因此会出现多个集群被创建相同名字的桶的结果(这在使用上当然是没有问题的,访问域名的定义会通过region隔离开来,将流量引入不同的集群,因此用户无需感知桶名是否相同)。
- 用户出于管理考量,希望能够实现后端的强制校验,实现全局桶名唯一的效果。即:若一个桶名bucket-0已经在region-A中被创建,那么在region-B中也不允许创建名称为bucket-0的桶。
问题分析
那这个需求也很简单,在全局增加一个http server,暴露增加、删除的API,使用mysql持久化和事务处理。在后端存储端增加流程,在创建桶的时候,先查询是否表中已经存在该(app_id, bucket_name)的row,若存在,则不去创建,否则才允许创建。删除桶的流程中,调用这些API在表中进行删除。
这样便有一个集中的地方来管理每个region级别的桶信息。很简单的实现。
将数据库表定义如下:
CREATE TABLE IF NOT EXISTS `test` (
id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
create_time BIGINT NOT NULL,
appId BIGINT(20) UNSIGNED NOT NULL DEFAULT '0' COMMENT '应用Id',
bucket_name VARCHAR(255) NOT NULL COMMENT 'bucket 名称',
region_name VARCHAR(32) NOT NULL DEFAULT '####' COMMENT '地域名称',
PRIMARY KEY (id),
UNIQUE KEY bucket_index (appId, bucket_name, region_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
因为同时还需考虑兼容场景,用户已经在多个region下有同名的桶,想要继续沿用,新创建的桶再遵循全局桶名唯一的约束,因此不能直接用直接定义为UNIQUE KEY bucket_index (appId, bucket_name),而是要定义为UNIQUE KEY bucket_index (appId, bucket_name, region_name)。
若无需考虑这种场景,那直接通过前者的unique key定义就能避免竞争条件了。
默认mysql innodb使用的事务隔离级别为REPEATABLE_READ:
MariaDB [test]> select @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ |
+-------------------------+
1 row in set (0.00 sec)
若不考虑锁,则在并发场景下会出现竞争条件,模拟如下:
# 数据库为空:
# session A,开启一个事务,查询是否有appId=1和bucket_name=bucket-1的条目。此时数据库为空,因此没有查到,可以进入代码的insert流程:
start transaction;
select * from test where appId=1 and bucket_name='bucket-1';
# session B,开启一个事务,查询是否有appId=1和bucket_name=bucket-1的条目。此时数据库为空,并且session A的select语句没有加锁,因此也没有查到,可以进入代码的insert流程:
start transaction;
select * from test where appId=1 and bucket_name='bucket-1';
# session A,插入 (1,'bucket-1','region-1',1):
INSERT INTO test (appId,bucket_name,region_name,create_time) VALUES (1,'bucket-1','region-1',1);
# session B, 插入 (1,'bucket-1','region-2',1):
INSERT INTO test (appId,bucket_name,region_name,create_time) VALUES (1,'bucket-1','region-2',1);
# session A:
commit;
select * from test;
# session B:
commit;
select * from test;
此时可以查到数据库中被插入了两条数据,这自然是不符合预期的:
MariaDB [test]> select * from test;
+----+-------------+-------+-------------+-------------+
| id | create_time | appId | bucket_name | region_name |
+----+-------------+-------+-------------+-------------+
| 21 | 1 | 1 | bucket-1 | region-1 |
| 22 | 1 | 1 | bucket-1 | region-2 |
+----+-------------+-------+-------------+-------------+
根本原因是select语句没有加锁,两个事务查询时,对方都没有commit时,都认为没有符合的row,因此执行了插入流程。
将流程改为如下,便解决了这个问题:
# session A:
start transaction;
select * from test where appId=1 and bucket_name='bucket-1' for update;
# session B:
start transaction;
select * from test where appId=1 and bucket_name='bucket-1' for update;
# session A:
INSERT INTO test (appId,bucket_name,region_name,create_time) VALUES (1,'bucket-1','region-1',1);
## 由于session B也加了锁,所以此时会阻塞。
# session B:
INSERT INTO test (appId,bucket_name,region_name,create_time) VALUES (1,'bucket-1','region-2',1);
## 此时会报错:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction,此时便会由死锁检测介入,释放session B的锁,session A中的insert会成功(未超时的前提下)。
# session A:
commit;
select * from test;
# session B:
select * from test;
那多出来的这一个for update,数据库引擎后面做了些什么,从而实
现对select的查询的锁保护呢?
当前要处理的核心问题在于:插入某个(bucektName,appId,regionName)前,要先检查(bucketName,appId)是否存在。只有不存在,才允许插入,否则不允许插入。
在最初的实现中未加锁,可能出现多个session都查询,发现没有找到,然后便会插入,此时造成例如(bucketName,appId, regionName1), (bucketName,appId, regionName2)的效果。
此处便需要加锁。需要加什么等级的锁才能保证这样的效果呢?此时要先考虑事务的隔离级别。四大隔离级别如下所示:
-
读未提交(Read Uncommitted):一个事务可以读取另一个未提交事务的数据。这样便会有脏读的问题。
-
读已提交(Read Committed):一个事务要等另一个事务提交后才能读取数据。这样可以解决脏读的问题,但是却会造成一个事务范围内的两个相同的查询返回不同的数据,即不可重复读的问题。
-
可重复读(RepeatableRead):一个事务开启时,不再允许修改操作。这样便可以保证一个事务范围内的两次查询都能返回相同的数据,可以解决上条“不可重复读”的问题。但还会造成“幻读”的问题。
所谓的“幻读”,本质是一个事务在某次查询中读取到的行,在后续的查询中发现发生了变化。通常是因为另一个事务插入了新的行或删除了行,而这些行满足原先的查询条件,造成当一个事务前后两次查询的结果集,结果竟然不相同,仿佛产生了幻觉。
这会有什么问题呢?
-
语义被破坏。本身查询加上for update,预期是把符合条件的行锁住,不让别的事务进行读写操作,但幻读破坏了这一语义,造成同一个事物同一个查询条件,多次查询到的结果不同。
-
数据一致性问题。例如session A通过select for update锁住某个条件的行,然后去update其内容。此时若有session B事务对有部分重合(但又不至于被select for update条件限制)的表进行update或insert,且session B的事务提交生效后,session A的事务才提交,此时sessionA的事务可能一把将session B的结果都给改掉。
这个问题的本质是什么?
- 其实就是select for update时,锁的范围不够大,导致其他事务钻了空隙,能够有机会插入和删除,而这些插入和删除的行,刚好可能和select for update条件有部分重合。
这样有部分重合还限制不住,自然会有问题。
本质是行锁只能锁住符合条件的行,但是插入的动作要更新的可能是记录之间的“空隙”,而这些“空隙”没法只通过行锁来锁定。
因此解决的方法也好理解,就是在select for update时扩大锁的范围,把可能的“空隙”也给锁上,避免由其他事务插入。由此mysql引出了一个“间隙锁”的概念。
- 什么是间隙锁,什么场景下会生成间隙锁?
不是直接锁定某一行,而是锁定几条记录之间的空间(或是在索引的边界以外的间隙)。然后通过一些定义,通过每个间隙分别管理来实现对锁范围的扩张,来实现防止有交叉修改的效果。
具体又有不同的场景需要考虑:
-
对唯一键索引等值查询
-
对唯一索引范围查询
-
对一般索引等值查询
-
对一般索引范围查询
-
对无索引等值查询
-
对无索引范围查询