场景介绍,用户有100块钱,同时给100个人转账,每个收款用户1快钱。在高并发下,付款方扣减金额时,客户资金安全不可控制,且容易发生死锁。
数据表:账户表account、流水表txnlog
create table account (
'custname' varchar(32) not null comment '姓名',
'acct' varchar(32) not null comment '账号',
'amt' bigdecimal not null comment '账户金额',
'identify' varchar(32) not null comment '证件号',
'identifynum' varchar(32) not null comment '证件类型'
primary key 'acct',
unique index idx('identify','identifynum'),
unique index idx_name('custname')
)
comment='账户表'
collate='uft8_bin'
engine='InnoDB'
)
create table txnlog (
'id' int(11) not null comment 'id',
'custname' varchar(32) not null comment '姓名',
'acct' varchar(32) not null comment '账号',
'amt' bigdecimal not null comment '账户金额',
'skacct' varchar(32) not null comment '收款账号',
'skcustname' varchar(32) not null comment '收款姓名'
primary key 'id',
)
comment='流水表'
collate='uft8_bin'
engine='InnoDB'
高并发资金安全问题分析
两个转账请求同时到达并处理,对应A、B两个事务。存在问题:脏读、可重复读、不可重复读(官方定义为幻像/幻读问题)、丢失更新。
事务 | 动作 |
---|---|
事务A |
1、查询账户表账户金额; 2、扣减账户金额更新账户表; 3、生成转出流水; 4、查询账户表账户金额
|
事务B |
1、查询账户表账户金额; 2、扣减账户金额更新账户表; 3、生成转出流水; 4、查询账户表账户金额
|
A、B事务的处理顺序如下 :
步骤 | A | B |
---|---|---|
1 | 查询账户金额 | |
2 | 查询账户金额 | |
3 | 扣减账户金额更新账户表 | |
4 | 生成转出流水 | |
5 | 查询账户金额 | |
6 | 扣减账户金额更新账户表 | |
7 | 生成转出流水 | |
8 | 查询账户金额 |
innodb存储引擎默认隔离级别repeatable read(可重复读),解决了脏读问题,保证了在同一事务中,多次读取同样事务的结果是一致的。 但是理论上,可重复读隔离级别还是无法解决另一个幻读(phantom read)问题。所谓幻读,指当前事务在读取某个范围内的记录时,另一个事务又在该范围内插入了新的记录。当之前的事务又在该范围内插入了新的记录,会产生幻行。更通俗点,phantom problem是指在同一事务下,连续执行两次同样的sql语句可能导致不同的结果,第二次的sql语句可能会返回之前不存在的行。A中若增加统计流水总数 select count(*) from txnLog;,可模拟出幻读问题
。 为解决幻读问题,innodb存储引擎引入了多版本并发控制(MVCC)解决幻读问题。
附:mvcc的实现,是通过保存数据在某个时间点的快照来实现的,也就是说,不管需要执行多长时间,每个事务内看到的数据都是一致的。根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。 innodb的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存了行的过期时间(或删除时间),当然存储的并不是实际的时间值,而是系统版本号,每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。
innodb存储引擎默认隔离级别repeatable read (可重复读),mvcc的具体操作:
select, innodb会个根据以下两个条件检查每行记录。 1、innodb只查找版本早于当前事务版本的数据行(行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在,要么是事务自身插入或者修改过的。 2、行的删除版本要么未定义(未执行过删除操作),要么大于当前事务版本号,可确保事务读取到的行,在事务开始之前未被删除。
insert , innodb为新插入的每一行保存当前系统版本号作为行版本号
delete, innodb为删除的每一行保存当前系统版本号作为行删除标示
update, innodb为插入一行新数据,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标示。(更新一条记录,分为两步,新增一条记录,同时更新原记录的删除标示)
在这个场景中,由于mvcc的作用,A无法看到B的修改,A中扣减账户金额是在A开始的快照上进行的。若A修改成功,会导致B对账户表的修改丢失。
解决方案:
- 方案一,修改隔离级别为read committed(提交读),一个事务开始时,只能看见已经提交的事务所做的修改。即A事务能看到B事务的修改。
生产环境不允许修改隔离级别,本方案不可用
- 方案二,接受innodb默认隔离级别repeatable read(可重复读),
因innodb存储引擎采用了next-key locking的算法避免phantom problem,如select * from t where a>2 for update,其锁住的是(2,+无穷)这个范围加了X锁,任何对这个范围的插入都是不允许的,从而避免phantom problem
。 对于账户表、流水表在一个事务中的操作。第一步对账户表加X锁(select * from account where 账号='123456' for update),后续再进行账户修改、流水修改。 使得对同一账户的操作,其他事务需要等待X锁。此外,对于不可变值的查询,可以提到事务开始前进行。
并发下innodb主键索引与非主键索引同时使用死锁问题分析
情景一、A事务,主键索引取的X锁,使用非主键索引更新。B事务,使用非主键索引更新,如下
步骤 | A | B |
---|---|---|
1 |
主键索引 set autocommit=false; begin; select * from account where acct='123456' for update;
|
|
2 |
非主键索引 set autocommit=false; begin; update account set amt=amt+1 where identify='456789' and identifynum='01';
|
|
3 |
非主键索引 update account set amt=amt+1 where identify='456789' and identifynum='01' ;
|
|
4 |
mysql>update account set amt=amt+1 where identify='456789' and identifynum='01' ; ERROR 1213 (40001):Deadlock found when trying to get lock; try restarting transaction
|
执行到A3,B4会话报错死锁。
- 之所以会造成B死锁,在innodb存储引擎下,行级锁并不是直接锁记录,而是锁索引。
- A1 的sql语句用的主键作为条件,锁的是主键索引,A事务占用了主键。
- B2的sql语句,用的是非主键作为条件,mysql会先锁非主键索引,再锁主键索引,此时B事务占用了非主键索引,等待主键索引。
- A3的sql语句,使用了非主键索引进行update操作,会等待B的非主键索引释放。A/B两个事务间形成死锁。
- innodb通过死锁检测机制wait-for graph 等待图, 检测到死锁回路,选择回滚undo量最小的事务B,对其进行回滚,让A继续执行。
解决方案:
- 1、在所有账户表修改操作开始前,使用主键索引对账户表加锁
select * from account where acct='123456' for update
。 再进行账户修改、流水操作、其他。 - 2、对账户表的查询和修改,统一使用主键索引或非主键索引。避免主键索引与非主键索引共用时造成的死锁。
附:测试代码中死锁的发生,1、A会话为eclipse编译器执行,代码 select * from account where acct='123456' for update 下一行打断点,执行到断点出。 2、打开mysql的会话窗口,执行B2。 3、eclipse继续往下走,A3执行后,查看B会话被中断,能看到死锁提示。
死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。解决死锁问题最简单的方法是超时,即两个事务互相等待时,当一个等待时间超过设置的某一阈值,其中一个事务进行回滚,另一个等待的事务就能继续进行。但是从上述案例中,并不是等待超时,而是死锁后直接触发了事务回滚,显然不是等待超时的解决方案。
innodb存储引擎采用wait-for graph(等待图)
的方式来进行死锁检测,较之超时的方案,这是一种更为主动的死锁检测方式。在每个事务请求锁并发生等待时都会判断是否存在回路,若存在则有死锁,通常来说innodb存储引擎会选择回滚undo量最小的事务。
情景二、模拟两个非主键索引 unique index idx('identify','identifynum')、unique index idx_name('custname') 混用发生死锁,
步骤 | A | B |
---|---|---|
1 |
主键索引 set autocommit=false; begin; update account set amt=amt+1 where custname=‘张三’;
|
|
2 |
非主键索引 identify + identifynum set autocommit=false; begin; update account set amt=amt+1 where identify ='45678' and identifynum='01';
|
|
3 |
非主键索引 custname update account set amt=amt+1 where identify ='45678' and identifynum='01';
|
|
4 |
死锁中断 mysql>update account set amt=amt+1 where identify='456789' and identifynum='01' ; ERROR 1213 (40001):Deadlock found when trying to get lock; try restarting transaction
|
死锁发生过程分析
- A1获取了非主键索引custname,然后获取了主键索引acct
- B2获取了非主键索引identify + identifynum ,等待主键索引acct
- A3需要获取非主键索引identify + identifynum,但该索引已被B获取,A3等待非主键索引identify + identifynum的释放,死锁发生。
- innodb的死锁检测机制wait-for graph 等待图,检测到锁等待回路,判断死锁发生,选择回滚undo量最小的事务。innodb判断B为undo最小的事务,中断并回滚B事务。A事务继续往下执行。
情景三、模拟A、B会话均为相同主键索引
步骤 | A | B |
---|---|---|
1 |
主键索引 set autocommit=false; begin; select * from account where acct='123456' for update;
|
|
2 |
主键索引 set autocommit=false; begin; update account set amt=amt+1 where acct='123456';
|
|
3 | 停下来不继续往下走 | |
4 |
等待超时 mysql>update account set amt=amt+1 where acct='123456'; ERROR 1205 (HY000):Lock wait timeout exceeded; try restarting transaction
|
A3停下来,不往下走,则B4会提示等待锁超时,会话中断。从A、B会话都使用主键索引可知,会话只会等待锁超时,不会立即中断事务。因此在编码中,尽可能避免在对同一条记录操作的事务中,混用主键索引和非主键索引。