数据库的事务隔离
前段时间,公司内部遇到了一个问题,就是我们创建的同一批任务,别分配给了不同的实例去执行,导致线上的结果出现问题。
另一个组的leader说没有开启事务,设置下事务就可以。
数据库一出现一致性问题,就说开启事务,我就有点疑惑,数据库的事务到底是怎么保证一致性的。
在看下面的内容,我们可以先思考几个问题。
数据库的隔离级别都有什么?数据库的MVVC视图是怎么实现的?
数据库的隔离级别是为了解决什么问题的?
看完上面三个问题,自己能回答上来几个呢?不急。我们继续往下看
数据库的事务
数据库的事务我们简单来说就是用来保证数据的正确性,它只有两个操作:事务要么成功,要么失败并进行回滚。
为什么这么做呢?这是因为一般我们进行事务操作,都会进行一组操作。比如你常见的金融转账。
在这个转账事务里面包含2个操作:
- 扣自己银行账户的钱
- 给对应的账户添加收到的钱。
现在思考下,如果我们没有添加事务,那么会出现什么样的情况呢?
- 如果先扣钱成功,执行给别人加钱失败。而钱已经扣了,对方没收到钱,你说怎么办?
- 如果先给对方加钱,而扣你钱的时候没扣成功。这钱银行给的补助吗?嘿嘿,那银行肯定不开心。
所以了我们只能在这种操作中使用事务,来保证执行的成功与失败,失败了要进行回滚,保证扣钱的操作也不执行。
事务的ACID
事务具有四个特性,这四个特性简称为ACID
- 原子性Atomicity:同一组操作,要么做,要么不做,一组中的一两个执行成功不代表成功,所有成功才可以。这就是原子性,做或者不做(失败进行回滚)。
- 一致性Consistency:数据的一致性,就像上面的举例说的,你扣钱了,对方没加钱,那肯定不行。
- 隔离性Isolation:多个数据库操作同一条数据时,不能互相影响。不能你这边变动,那边数据空间就变换了。
- 持续性Durability: 事务结果提交后,变动就是永久性的,接下来的操作或者系统故障不能让这个记录丢失。
今天主要说的事务就是隔离。看看事务是怎么保证数据之间的隔离
事务的隔离级别
不同的事务隔离级别对应的不同的数据执行效率,隔离的越严格,那么执行的效率就约低下,下面的四个隔离级别是原来越严格。
- 读未提交(read uncommitted):指数据在事务执行时,还没有提交,其他事务就可以看到结果
- 读提交(read committed):指数据在其事务提交后,其他事务才能看到结果。视图是在执行sql语句的时候进行创建,具体视图看下面的数据隔离是怎么实现的
- 可重复读(repeatable read):一个事务在执行过程中,看到的结果与其启动的时候看到的内容是一致的。启动的时候会创建一个视图快照,该事务状态下,会看的一致是这个视图快照内容,其他事务变更是看不到的。注意是读取的过程,如果是更新,那么会采用当前读,就是其他事务的更新操作会拿到结果,用来保证数据一致性
- 串行化(serializable):顾名思义,就是将多个事务进行串行化(读写过程中加锁,读写冲突,读读冲突),一个事务结束后,另外一个事务才能执行,带来的效果就是并行化不好,效率低下。
Mysql中默认的事务隔离级别是可重复读,使用下面这个命令进行查看当前的事务级别,
show variables like 'transaction_isolation';
# 下面的语句进行修改事务的级别。
SET session TRANSACTION ISOLATION LEVEL Serializable;(参数可以为:Read uncommitted,Read committed,Repeatable,Serializable)
事务的启动方式
在程序中,我们很多时候都是默认的自动提交,也就是一个sql操作就是一条事务,但有时候需要的是多个SQL进行组合,我们就要显式的开启事务。
显示开启的语句是用 begin或者 start transaction.同样在事务结束的时候使用commit进行提交,失败使用rollbakc进行回滚。
当然如果不想让SQL进行自动提交,我们就将自动提交进行关闭set autocommit=0
,这样事务就不会自动提交,需要我们手动的执行commit.
如何避免长事务
关闭自动提交事务后,就需要我们来自己提交事务,这时候每个语句执行都是这样的。
begin
sql语句
commit
如果我们在程序编写中,本来一个sql解决的操作,结果忘记进行事务的提交,到下下下一个SQL才进行commit,这样就会出现长事务。
而长事务往往会造成大量的堵塞与锁超时的现象,事务中如果有读写(读读不冲突,读写冲突,写写冲突)操作,那么会将数据进行锁住,其他事务也要进行等待。
所以在程序中,我们应该尽量避免使用大事务,同样也避免我们写程序的时候出现偶然的大事务(失误😁)。
解决办法是我们将自动提交打开,当需要使用事务的时候才会显示的开启事务。
程序中出现大量的事务等待怎么办
在MySQL中想定位一个长事务问题还是很方便的。
首先我们先找到正在执行的长事务是什么。
select t.*,to_seconds(now())-to_seconds(t.trx_started) idle_time from INFORMATION_SCHEMA.INNODB_TRX t G
该语句会展示出事务的执行的开始时间,我们可以很简单的算出,当前事务执行了多久,其中上面的idle_time就是执行的事务时间
假设我们现在设定的超过30s执行的事务都是长事务,可以使用下面语句进行过滤30s以上的长事务。
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>30
通过information_schema.innodb_trx 我们就能定位到长事务。
从而决定长事务是否要被杀死,还是继续等待。如果出现死锁的情况,处理方式也是类似,查找到死锁的语句,然后进行杀死某个语句的执行(有点暴力)。
数据隔离是怎么实现的
注意:本次数据隔离是建立在可重复读的场景下
在可重复读的场景下,我们了解每次启动事务的时候,会在当前启动一个视图,而这个视图是整个数据库的视图快照。
嘿嘿,是不是想数据库那么大,为啥我们没有感觉到创建快照时间的消耗呢?
这是因为数据库创建的视图快照利用了所有数据都有多个版本的特性,来实现快速创建视图快照的能力。
那数据多个版本是怎么回事呢?
准备下数据
先别急,我们准备下数据。
现在创建一个表,并且插入三条数据。
create table scores
(
id int not null
primary key,
score float null
);
INSERT INTO scores (id, score) VALUES (1, 3.5);
INSERT INTO scores (id, score) VALUES (2, 3.65);
INSERT INTO scores (id, Score) VALUES (3, 4);
在开始使用前我们要了解两个小知识点。begin/start transaction 与 start transaction with consistent snapshot。
- begin/start transaction 视图的创建是建立在begin/ start transaction 之后SQL语句才会创建视图, 比如 下面案例
begin
select source from scores; //视图是在这里开始创建 而不是在begin那里创建
commit
- start transaction with consistent snapshot:则是该语句执行后,就创建视图。
了解上面两个创建事务的区别后,我们来看下视图是怎么创建出来多个数据版本的. 以下SQL在两个窗口打开。
事务A | 事务B | 结果
start transaction with consistent snapshot | 开启事务,并创建视图 |
--| start transaction with consistent snapshot |开启事务,并创建视图
select score from scors where id =2 | -- | 事务A中的值为3.65
-- | update scores set scores = 10 where id =2 | 事务B修改为10
--| select score from scores where id =2 | 事务B显示为10
select score from scores where id =2 | --| 事务A显示为3.65
select score from scores where id =2 for update | --| 会被锁住,等待事务B释放锁(间隙锁)
-- |commit | 提交事务B
select score from scores where id =2 for update | --| 这个语句可以看到变成了10(利用了当前读)
select score from scores where id =2 | --| 不加 for update 那么结果还是3.65
commit|---|---| 提交A的结果
上述流程就是两个不同的请求过来,对数据库同一个表的不同操作。
当事务A执行start transaction with consistent snapshot之后,A的视图就开始被创建了,这时候是看不到事务B对其中的修改,就算事务Bcommit之后,只要事务A不结束,它看到的结果就是它启动时刻的值。
这就与不重复提交,执行过程中看到的结果与启动的时候看到的结果是一致的这句话对应上了。
快照多版本
前面说了,快照是事务的启动的时候是基于整个数据库的,而整个数据库是很大,那MYSQL是怎么让我们无感并快速创建一个快照呢。
快照多版本你可以认为是由以下两部分构成。
- 事务id(transaction id):这个是由事务启动的时候向InnoDB启动时申请的。并且一定注意哦它是递增的。
- row trx_id:这个id其实就是事务ID,每次事务更新数据的时候回将事务ID赋值给这个数据版本的事务ID上,将这个数据版本的事务ID称为 row trx_id.
当一行记录存在多个数据版本的时候,那么就有多个row trx_id 。举个例子
版本 | 值|事务ID| 对应的语句操作
v1 | score =3 | 89| --
v2 | score =5| 90| update scores set score = 5 where id =3; select score from scores where id =3;|
v3 | score = 6 | 91 | update scores set score = 6 where id =3;|
v1->v2->v3 这里面涉及了三个版本的迭代。中间是通过undo log 日志来保存更新的记录的。
注意启动快照之后,可重复读隔离情况下,获取到v1的值,不是说MYSQL直接存储的该值,而是利用现在这条记录的最新版本与undo log日志计算出来的,比如通过v3 ->v2—>v1 计算出v1中score值。
版本计算
上面简单说了下版本的计算规则,但是在MYSQL中,版本并不是那么简单的计算的,我们现在来看下到底怎么计算的,
这个两点我们在注意一下:
- 事务在启动的时候会向InnoDB的事务系统申请事务ID,这个事务ID是严格递增的。
- 每行数据是多个版本,这个版本的id就是row trx_id,而事务更新数据(更新数据的时候才会生成一个新的版本)的时候会生成一个新的数据版本,并把事务ID赋值给这个数据的事务ID==row trx_id,
- 事务启动的时候,能看到所有已经提交事务的结果,但是他启动之后,其他事务的变更是看不到的。
- 当事务启动的瞬间,除了已经提交的事务,创建的瞬间还会存在正在运行的事务,MYSQL是把这些正在运行的事务ID放入到一个数组中。数组中最小的事务ID记为低水位,当前系统中创建过的事务ID最大值+1记为高水位。
举个简单的例子。
a. 注意一点:获取事务ID与创建数组不是一个原子操作,所以存在事务id为8,然后又存在当前MYSQL中存在活跃事务ID为9 10的事务。
b. 事务ID低于低水位那么对于当前事务肯定是可见的,事务ID高于高水位的事务ID值,则对当前事务不可见.
c. 事务ID 位于低水位与高水位之间分为两种情况。
- 如果事务id是在活跃的数组中表示这个版本是正在执行,但是结果还没有提交,所以这些事务的变更是不会让当然事务看到的。
- 事务id如果没有在活跃数组中,代表这个事务是已经提交了,所以可见。比如现在创建了90,91,92三个事务,91执行的比较快,提交完毕,90和92还没有提交.这时候创建了一个新的事务id为93,那么在活跃的数组中的事务就是90,92,93,你看91是已经提交了,它的事务还在这个低水位与高水位之间,但结果对于93是可见。
总的上面来说就是你在我创建的时候事务结果已经提交,那么是可见的,之后提交那么就是不可见的。
读取流程
上面简单说了下老版本视图中的数据是通过最新的版本与undo log 计算出来的,那到底怎么就算的呢?
事务A | 事务B | 结果
start transaction with consistent snapshot 事务 id 89| 开启事务,并创建视图 |
--| start transaction with consistent snapshot 事务id 92 |开启事务,并创建视图
select score from scors where id =2 | -- | 事务A中的值为3.65
-- | update scores set scores = 10 where id =2 | 事务B修改为10
--| select score from scores where id =2 | 事务B显示为10
select score from scores where id =2 | --| 事务A显示为3.65
commit|---|---| 提交A的结果
还是看这个事务操作。
下面是数据变动的流程。
- 假设开始之前有两个活跃的事务ID为 78,88.
- 事务A启动的时候会将78 88,包含它自己放入到活跃数组中。
- 事务A 操作的语句
select score from scors where id =2
将其看到的结果认为是v1版本数据比如其现在row trx_id(注意:row trx_id是数据行被更新后事务id才会赋值给row trx id上)是86,并且保存好。 - 事务B启动时,会发现在活跃数组是78,88,89,自己的92.
- 事务B 执行更新语句语句后,会生成一个新的版本V2,数据变换就是V1-->V2。记录中间变化的是undo log日志。这样ID 89存储的数据就变成了历史数据。数据版本row trx_id则是92
- 事务A 查询score数据,就会通过先查到现在的V2版本视图,找到对应的row trx_id = 92,发现row trx_id 位于高水位上,则抛弃这个值,通过V2找到V1,row trx_id为86,而86大于低水位,而低于高水位89+1.但是由于86没有在活跃数组中,而且属于已经提交的事务,则当前事务是能看到该结果的,所以事务A能拿到读取的值。
你看经过简单的几步,我们就拿到了想要读取的事务数据,所以不论事务A什么时候查询,它拿到的结果都是跟它读取的数据是一致的。
你看有了MVCC(多版本并发控制)计算别的事务更改了值也不会影响到当前事务读取结果的过程。
我们经常说不要写一个长事务,通过上面的读取流程可以看到,长事务存在时间长的话,数据版本就会有很多,那么undo log日志就需要保存好久,这些回滚日志会占用大量的内存存储空间。
当没有事务需要读取该日志与版本数据的时候,这个日志才可以删除,从而释放内存空间。
更新流程
事务A | 事务B | 结果
start transaction with consistent snapshot 事务 id 89| 开启事务,并创建视图 |
--| start transaction with consistent snapshot 事务id 92 |开启事务,并创建视图
select score from scors where id =2 | -- | 事务A中的值为3.65
-- | update scores set scores = 10 where id =2 | 事务B修改为10
--| select score from scores where id =2 | 事务B显示为10
select score from scores where id =2 | --| 事务A显示为3.65
select score from scores where id =2 for update | --| 会被锁住,等待事务B释放锁(间隙锁)
-- |commit | 提交事务B
select score from scores where id =2 for update | --| 这个语句可以看到变成了10(利用了当前读)
select score from scores where id =2 | --| 不加 for update 那么结果还是3.65
commit|---|---| 提交A的结果
上面说了读取的过程,其实在事务中,我们还有更新流程,更新流程比较简单,更新过程我们需要保证数据的一致性,不能说别人修改了,我们还看不到,那样就会造成数据的不一致。
为了保证看到最新的数据,会对更新行的操作加锁(行锁),加锁之后,其他事务对行进行更新操作,必须等待其他事务commit之后才能获取到最新的值,这个过程被称为当前读。
想要读取过程中获得最新的值可以使用 上面的语句select score from scores where id =2 for update ,就可以看到当前最新值。
总结
本小节主要梳理了事务的隔离级别,事务的MVCC多版本并发控制实现原理。
事务在面试中是比较多的一个点,这样的题目可以多种变换,我们刚开始题目提到的三个问题已经可以解答了。
你来尝试回答下?
下期会说下数据库中的幻读,幻读也是面试中经常遇到的问题哦。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。