SQL高级技巧写入和冲突
一 概念
为了解决多事务并发问题,早期数据库不论读取还是写入,都用锁来实现,但是锁会带来性能的问题。人们尝试各种优化方案,写入和读取的优化方式不同。对于数据库写入操作,没有特别好的办法,因为无论如何要避免并发修改一个数据,就得靠锁。不同的数据库对于写入操作都会加悲观锁(比如MySQL是X锁)。为了避免X锁带来的性能问题,人们在合适的场合会选择用乐观锁来优化。有的数据库内建乐观锁,但是有的没有(比如MySQL就没有),所以需要开发人员自己在数据表里加version列,自己写业务代码实现。
顺便提一句,乐观锁并不一定总是比悲观锁性能表现更好,这要看竞争的程度。如果数据访问竞争的非常厉害,乐观锁只会让CPU和IO白白浪费而已。
MVCC(Multi-Version Concurrency Control)多版本并发控制。
MySQL的大多数事务型(如InnoDB,Falcon等)存储引擎实现的都不是简单的行级锁。基于提升并发性能的考虑,他们一般都同时实现了MVCC。当前不仅仅是MySQL,其它数据库系统(如Oracle,PostgreSQL)也都实现了MVCC。值得注意的是MVCC并没有一个统一的实现标准,所以不同的数据库,不同的存储引擎的实现都不尽相同。
MVCC的意思用简单的话讲就是对数据库的任何修改的提交都不会直接覆盖之前的数据,而是产生一个新的版本与老版本共存,使得读取时可以完全不加锁
二 MVCC解决了什么问题
请牢记这句话,MVCC是为了解决并发事务处理中的读写冲突问题。
简单列举下并发事务带来的问题:
- 更新丢失(
Lost Update
):当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题 —— 最后的更新覆盖了其他事务所做的更新。如何避免这个问题呢,最好在一个事务对数据进行更改但还未提交时,其他事务不能访问修改同一个数据。 - 脏读(
Dirty Reads
):一个事务正在对一条记录做修改,在这个事务并提交前,这条记录的数据就处于不一致状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些尚未提交的脏数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象地叫做 “脏读”。 - 不可重复读(
Non-Repeatable Reads
):一个事务在读取某些数据已经发生了改变、或某些记录已经被删除了!这种现象叫做“不可重复读”。 - 幻读(
Phantom Reads
):一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为 “幻读”。
解决更新丢失可以交给应用,但是后三者需要数据库提供事务间的隔离机制来解决。实现隔离机制的方法主要有两种:
- 加读写锁
- 一致性快照读,即
MVCC
但本质上,隔离级别是一种在并发性能和并发产生的副作用间的妥协,通常数据库均倾向于采用 Weak Isolation(mvcc是弱隔离,读写锁认为是一种强隔离)。
MVCC只在REPEATABLE READ和READ COMMITTED两个隔离级别下工作。其他两个隔离级别都和MVCC不兼容,因为READ UNCOMMITTED总是读取最新的数据行,而不是符合当前事务版本的数据行,而SERIALIZABLE会对所有读取到的行都加锁。
三 实现原理
数据行隐藏列
InnoDB
中每行记录都有两个隐藏列:DATA_TRX_ID
、DATA_ROLL_PTR
(如果没有主键,则还会多一个隐藏的主键列DB_ROW_ID),另外头信息还会有个删除标记位DELETE_BIT。
DATA_TRX_ID
记录最近更新这条行记录的事务 ID
,大小为 6
个字节
DATA_ROLL_PTR
表示指向该行回滚段(rollback segment)
的指针,大小为 7
个字节,InnoDB
便是通过这个指针找到之前版本的数据。该行记录上所有旧版本,在 undo
中都通过链表的形式组织。
DB_ROW_ID
行标识(隐藏单调自增 ID
),大小为 6
字节,如果表没有主键,InnoDB
会自动生成一个隐藏主键,因此会出现这个列。
DELETE_BIT
每条记录的头信息(record header
)里都有一个专门的 bit
(deleted_flag
)来表示当前记录是否已经被删除。
Undo Log 链
在多个事务并行操作某行数据的情况下,不同事务对该行数据的 UPDATE 会产生多个版本,然后通过回滚指针组织成一条 Undo Log
链,这节我们通过一个简单的例子来看一下 Undo Log
链是如何组织的,DATA_TRX_ID
和 DATA_ROLL_PTR
两个参数在其中又起到什么样的作用。
事务 A
对值 x
进行更新之后,该行即产生一个新版本和旧版本。假设之前插入该行的事务 ID
为 100
,事务 A
的 ID
为 200
,该行的隐藏主键为 1
。
事务 A
的操作过程为:
- 对
DB_ROW_ID = 1
的这行记录加排他锁 - 把该行原本的值拷贝到
undo log
中,DB_TRX_ID
和DB_ROLL_PTR
都不动 - 修改该行的值这时产生一个新版本,更新
DATA_TRX_ID
为修改记录的事务ID
,将DATA_ROLL_PTR
指向刚刚拷贝到undo log
链中的旧版本记录,这样就能通过DB_ROLL_PTR
找到这条记录的历史版本。如果对同一行记录执行连续的UPDATE
,Undo Log
会组成一个链表,遍历这个链表可以看到这条记录的变迁 - 记录
redo log
,包括undo log
中的修改
那么 INSERT
和 DELETE
会怎么做呢?其实相比 UPDATE
这二者很简单,INSERT
会产生一条新纪录,它的 DATA_TRX_ID
为当前插入记录的事务 ID
;DELETE
某条记录时可看成是一种特殊的 UPDATE
,其实是软删,真正执行删除操作会在 commit
时,DATA_TRX_ID
则记录下删除该记录的事务 ID
。
ReadView
在 RU
隔离级别下,直接读取版本的最新记录就 OK,对于 SERIALIZABLE
隔离级别,则是通过加锁互斥来访问数据,因此不需要 MVCC
的帮助。因此 MVCC
运行在 RC
和 RR
这两个隔离级别下,当 InnoDB
隔离级别设置为二者其一时,在 SELECT
数据时就会用到版本链
核心问题是版本链中哪些版本对当前事务可见?
InnoDB
为了解决这个问题,设计了 ReadView
(可读视图)的概念。
对于使用READ UNCOMMITTED
隔离级别的事务来说,直接读取记录的最新版本就好了,对于使用SERIALIZABLE
隔离级别的事务来说,使用加锁的方式来访问记录。对于使用READ COMMITTED
和REPEATABLE READ
隔离级别的事务来说,就需要用到我们上边所说的版本链
了,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。所以设计InnoDB
的大叔提出了一个ReadView
的概念,这个ReadView
中主要包含当前系统中还有哪些活跃的读写事务,把它们的事务id放到一个列表中,我们把这个列表命名为为m_ids
。这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
-
如果被访问版本的
trx_id
属性值小于m_ids
列表中最小的事务id,表明生成该版本的事务在生成ReadView
前已经提交,所以该版本可以被当前事务访问。 -
如果被访问版本的
trx_id
属性值大于m_ids
列表中最大的事务id,表明生成该版本的事务在生成ReadView
后才生成,所以该版本不可以被当前事务访问。 -
如果被访问版本的
trx_id
属性值在m_ids
列表中最大的事务id和最小事务id之间,那就需要判断一下trx_id
属性值是不是在m_ids
列表中,如果在,说明创建ReadView
时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView
时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本,如果最后一个版本也不可见的话,那么就意味着该条记录对该事务不可见,查询结果就不包含该记录。
在MySQL
中,READ COMMITTED
和REPEATABLE READ
隔离级别的的一个非常大的区别就是它们生成ReadView
的时机不同,RC
在每一次 SELECT
语句前都会生成一个 ReadView
,事务期间会更新,因此在其他事务提交前后所得到的 m_ids
列表可能发生变化,使得先前不可见的版本后续又突然可见了。而 RR
只在事务的第一个 SELECT
语句时生成一个 ReadView
,事务操作期间不更新。
参考文章
https://juejin.im/post/5c9b1b7df265da60e21c0b57 MySQL事务隔离级别和MVCC
https://zhuanlan.zhihu.com/p/73078137 MVCC解决了什么问题?
https://zhuanlan.zhihu.com/p/64576887 MySQL InnoDB MVCC 机制的原理及实现
- 点赞
- 收藏
- 关注作者
评论(0)