记一次线上事务问题 - 加深对mvcc的理解记录事务并发引起的一次线上npe问题,结合实际排查过程,进一步理解mvcc原
问题回顾
核心步骤介绍如下:
- 开启本地事务,使用spring注解 @Transactional
- 数据校验,根据不同业务场景,校验数据合法性,并完成数据准备
- 通过rpc框架调用微服务接口实现某类业务实体的新建,注意:微服务和本地应用连接的是同一个数据库
- 通过步骤3返回的id从数据库查询,获取完整实体信息,并对部分字段进行更新 部分业务场景在步骤4获取新增实体的时候失败,即对应id无法获取到数据库记录,抛NPE
排查思考
从上图中可以看到本质问题是事务并发引起的,一个是本地事务1,第二个是通过远程调用其他模块触发的事务2。其中,事务2新增一条记录,事务1中查询新纪录。根据对innodb mvcc和间隙锁的理解,事务1中查询新纪录应该查不到任何结果,理应抛出NPE。 但这里的问题是:部分场景抛异常,部分场景又能够查到(即出现了幻读)。
本地模拟
- datagrip IDE 打开两个连接,自动提交关闭,事务隔离基本为 REATED READ
- 使用到的msyql命令如下
# 查看 自动提交 是否已经关闭
SHOW VARIABLES LIKE 'AUTOCOMMIT';
# 查看事务隔离级别
SELECT @@tx_isolation;
# 开启事务
START TRANSACTION;
# 查看事务ID
SELECT * FROM information_schema.innodb_trx;
# 快照读
select * from category_info;
# 当前读
select * from category_info for update;
# 新增记录
INSERT INTO category_info ( category_id, addtime) VALUES (1, now());
# 事务提交
commit
- 具体模拟如下
操作顺序按照图中的序号,从小到大依次执行。 几个观察结论记录下:
- 对于快照读,在 2 和 6 这两个时间点查询,都看不到事务B插入的新纪录。在提交事务A后,再查询(即时间点8)可以查到新纪录
- 对于当前读,在2、6两个时间点都能查询到事务B插入的新纪录 上述2点与认知相符,也与排查思考中的推断一致。难道在线服务步骤4中获取记录采用的是当前读?带着这个怀疑从头到尾看了一遍代码,确认是快照读。那么到底为什么会出现幻读呢?
一路走到这里后,就没有头绪了,为什么线上一部分场景出现幻读,一部分场景没有呢?
无意间在执行上面左右屏的sql命令后,突然发现事务A出现了幻读,和之前执行的区别在于,事务A执行开启事务命令后,没有执行查询操作,即没有序号2的操作。如下图:
于是大概知道问题所在了,innodb开启事务后不会立即分配事务ID,而是等到第一句查询sql执行后,才会有事务ID,可以通过命令 SELECT * FROM information_schema.innodb_trx 来验证。
回过来带着这个发现再去看线上代码,线上出现幻读场景在步骤2中都是没有查询sql,即事务A的ID分配是在事务B提交后,所以自然会看到事务B提交的记录。
原理解析
事务隔离级别
大家都知道,innodb的事务隔离级别有4种
- Read uncommitted,会出现脏读、不可重复读、幻读 脏读就是一个事务读取到另一个事务未提交的数据。出现脏读的本质就是因为操作(修改)完该数据就立马释放掉锁,导致读的数据就变成了无用的或者是错误的数据。
- Read committed,会出现不可重复读、幻读 避免脏读的做法很简单:就是把释放锁的位置调整到事务提交之后,此时在事务提交前,其他进程是无法对该行数据进行读取的。即读写是串行的。但Read committed会出现不可重复读,即一个事务读取到另外一个事务已经提交的数据,也就是说一个事务可以看到其他事务所做的修改。多次查询数据库的结果都不一样。
- Repeatable read,会出现幻读 和不可重复读类似,但虚读(幻读)会读到其他事务的插入的数据,导致前后读取不一致。可以把不可重复读理解为数据更新,幻读是数据插入。 innodb通过MVCC解决了不可重复读的问题,MVCC的具体原理下面介绍。同时结合间隙锁,避免了幻读。即innodb的Repeatable read其实不会出现幻读的问题,innodb的事务默认级别就是Repeatable read
- Serializable,串行,避免以上所有问题 那么MVCC究竟是一种什么机制,能够解决不可重复读的问题?
MVCC
MVCC即多版本并发控制,可以简单认为 是行级锁的一个升级版。前面提到,只有读-读场景是不阻塞的,其他只有要写(排他锁)场景,都是阻塞的,一定程度上影响了读写效率。基于提升并发性能的考虑,MVCC一般读写是不阻塞的,所以说MVCC很多情况下避免了加锁的操作。
InnoDB中的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存行的删除时间。当然存储的并不是实际的时间值,而是系统版本号。没开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为此事务的版本号,用来和查询到的每行记录的版本号进行比较。
举个select的例子,InnoDB会根据以下两个条件检查每行记录:
- InnoDB只查找版本早于当前事务版本的数据行,这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的
- 行的删除版本要么未定义,要么大于当前事务版本号,这可以确保事务读取到的行,在事务开始之前未被删除。
简单总结下,多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。 这样在读操作不用阻塞写操作,写操作不用阻塞读操作的同时,避免了脏读和不可重复读。(写写操作可以结合乐观锁/悲观锁实现)
MVCC虽然解决了不可重复读问题,但是无法解决幻读,需要配合间隙锁。
间隙锁(解决幻读)
首先我们看个例子,初始表如下:
id | x | y | 创建时间 | 删除时间 |
---|---|---|---|---|
1 | 30 | 10 | 1 | undefined |
很简单,一个自增id,一列x,一列y,假设有个限制条件:x+y <= 100。然后两个事务同时并发执行:
- T2(事务id=2):set y=60,事务隔离级别是不可重复度,所以此事务内,x=30, y=10,更新y后,x+y=30+60 = 90,满足条件
- T3(事务id=3):set x=50,事务隔离级别是不可重复度,所以此事务内,x=50, y=10,更新y后,x+y=50+10 = 60,满足条件
T2和T3提交后,x+y=50+60=110 不符合小于100的要求。
Update的本质是 read --> write,MySQL(innodb)为了解决这个问题,强行把 read 分成了 snapshot read(快照读)和 locking read (当前读)。在 UPDATE 或者 SELECT ... FOR UPDATE 的时候,innodb 引擎实际执行的是当前读。 在一个支持MVCC的并发系统中, 我们需要支持两种读, 一个是快照读, 一个是当前读。 快照读:简单的select操作,属于快照读,不加锁。 当前读:特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁, 读取的是最新数据。
给一个幻读的例子:
- 事务A种执行修改
update user set col1='new_val' where id=1;
结果:
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0 Changed: 0 Warnings: 0
- 事务B插入一条数据,并提交
begin;
insert into user values('A');
commit;
- 事务A种再次执行修改
update user set col1='new_val' where id=1;
结果:
Query OK, 1 rows affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
问题的本质是因为 update语句的查找阶段相当于select ... for update,这会更新事务A的 ReadView,从而可以读到「其他事务已提交的修改」。即出现了幻读
把上面的例子用事务版本号来解释:
- 事务A(事务版本号=1)种执行修改,此时是空表,所以update的select结果是0
- 事务B(事务版本号=2)插入一条数据,并提交
id | col1 | 创建时间 | 删除时间 |
---|---|---|---|
1 | A | 2 | undefined |
- 此时如果事务A进行快照读(snapshot select),那么按照刚才mvcc的解释,由于库中id=1的记录的创建时间(事务版本号)为2,大于当前事务的版本号1,所以不会被查找出来,符合Repeatable read。但是如果此时执行的是update操作,执行的读是当前读(select for update)(InnoDB执行UPDATE,实际上是新插入了一行记录,并保存其创建时间为当前事务的ID,同时保存当前事务ID到要UPDATE的行的删除时间),成功更新id=1的记录,即幻读
id | col1 | 创建时间 | 删除时间 | |
---|---|---|---|---|
1 | A | 2 | 1 | |
1 | A | 1 | undefined |
InnoDB 通过加间隙锁的方式,解决幻读。innodb对于键值在条件范围内但并不存在的记录(叫做『间隙』)加锁,这种锁机制就是所谓的间隙锁。 相对的,可以把上面不同的行锁称为记录锁。
转载自:https://juejin.cn/post/7406169434841153574