该文章基于 8.0.22 版本 mysql innodb 进行分析;理论上对于较新版本 mysql 都可适用。
为什么需要事务隔离?
当多个事务在并发运行的时候,可能出现以下问题:脏读:事务1 读取到了 事务2 还未提交的数据,如果此时 事务2 进行了回滚,那么 事务1 读取到的数据将会是脏数据;
不可重复读:事务1 在过程中多次读取数据,同时 事务2 对数据进行了修改(包括新增,修改,删除)并进行了提交,那么 事务1 可能多次读取到的数据不一致;
幻影行(Phantom Rows):事务1 在过程中更新了id 在 [1,5] 区间的数据 a 字段为 "trx1",假设此时表中没有id = 4 这个条数据,此时 事务2 插入了 id = 4 这条数据且 a 字段为 "trx2"并进行了提交,当执行完 事务1 的一瞬间,在 1-5 区间内居然还存在 a 字段不等于 "trx1" 的数据;(注:很多博客会把 幻影行 叫做幻读,但我认为对于 innodb 那不是一种准确的叫法,因为在该问题中已经不再关注事务中的读,而是关注事务中进行的对数据的改变操作)
对于以上的问题,mysql 实现了 SQL 1992 标准中的四个隔离级别:事务隔离级别脏读不可重复读幻影行
读未提交(read uncommitted)允许出现允许出现允许出现
读已提交(read committed)不允许出现允许出现允许出现
可重复读(repeatable read)不允许出现不允许出现标准:允许出现
innodb:不会出现
串行化(serializable)不允许出现不允许出现不允许出现
mysql 怎么实现的事务隔离级别?
读未提交(read uncommitted)
因为可能导致的问题在该隔离级别中都被允许出现,所以无需做特别的处理;
串行化(serializable)
在该事务级别中,innodb 会为所有的读写操作加上对于的共享锁或者独占锁以 next-key 锁;保证读写操作串行化运行,从而达到该级别的要求;
读已提交(read committed)AKA RC
innodb 使用 MVCC 来解决脏读的问题;
在 innodb 内部,会为每一行添加多余三个字段;6-byte DB_TRX_ID :标识改行是由哪个事务进行修改的
7-byte DB_ROLL_PTR :回滚指针,用于记录 undo log
6-byte DB_ROW_ID :行 id,如果 innoDB 表没有指定聚簇索引,将会自动生成该数据并用于二级索引;如果指定,将不会;
可以大致参考下图帮助理解:
tips:删除在内部被视为更新,会设置行中的一个特殊位代表该行已删除。
二级索引与主索引处理上有差别,主索引接地更新 undoLog;而二级索引将会标记新增索引并标记删除久的索引,对于标记删除或者是二级索引被更新时,将会从主索引查询数据;
ReadView
有了以上的储存结构,我们还需要在查询的时候融入逻辑才能进行正确的数据读取,所以就有了 ReadView 的实现;
ReadView 主要包含以下字段:
/** The read should not see any transaction with trx id >= thisvalue. In other words, this is the "high water mark". */
trx_id_t m_low_limit_id;
/** The read should see all trx ids which are strictlysmaller (
trx_id_t m_up_limit_id;
/** trx id of creating transaction, set to TRX_ID_MAX for freeviews. */
trx_id_t m_creator_trx_id;
/** Set of RW transactions that was active when this snapshotwas taken */
ids_t m_ids;
在进行读取行数据的时候会使用 ReadView 对行数据 DB_TRX_ID 进行判断,如果符合要求将会返回,不符合要求将会在 undoLog 中进行查找;
/** Check whether the changes by id are visible.@param[in]idtransaction id to check against the view@param[in]n ametable name@return whether the view sees the modifications of id. */
bool changes_visible(trx_id_t id, const table_name_t &name) const
MY_ATTRIBUTE((warn_unused_result)) {
ut_ad(id > 0);
// 小于最小trx_id或者等于当前id将会可见 if (id < m_up_limit_id || id == m_creator_trx_id) {
return (true);
}
check_trx_id_sanity(id, name);
// 大于生成 readView 时的最大系统id将会不可见 if (id >= m_low_limit_id) {
return (false);
} else if (m_ids.empty()) {
return (true);
}
const ids_t::value_type *p = m_ids.data();
// 在生成 readView 时正在执行的事务 id 中将会不可见 return (!std::binary_search(p, p + m_ids.size(), id));
}
void ReadView::prepare(trx_id_t id) {
ut_ad(mutex_own(&trx_sys->mutex));
// 设置生成 readView 的 id m_creator_trx_id = id;
// 将对最大 id 与最小id 设置为当前系统事务最大id m_low_limit_no = m_low_limit_id = m_up_limit_id = trx_sys->max_trx_id;
if (!trx_sys->rw_trx_ids.empty()) {
// 复制当前事务id到 m_ids 数组 同时会重新设置 m_up_limit_id copy_trx_ids(trx_sys->rw_trx_ids);
} else {
m_ids.clear();
}
ut_ad(m_up_limit_id <= m_low_limit_id);
if (UT_LIST_GET_LEN(trx_sys->serialisation_list) > 0) {
const trx_t *trx;
trx = UT_LIST_GET_FIRST(trx_sys->serialisation_list);
if (trx->no < m_low_limit_no) {
m_low_limit_no = trx->no;
}
}
ut_d(m_view_low_limit_no = m_low_limit_no);
m_closed = false;
}
通过上面的逻辑我们可以知道,我们可以通过 ReadView 实现对于没有提交的数据不进行读取,即解决了脏读的问题;
同时对于该隔离级别,ReadView 会在每次执行 select 的时候重新生成一次 ReadView;
可重复读(repeatable read)AKA RR
对于该隔离级别,复用 read committed 相同的方式来解决脏读,同时该级别下 ReadView 的生成时机发生了变化,该级别下 ReadView 会在事务中第一进行 select 时生成 ReadView,之后的查询都会复用第一次生成的 ReadView 从而实现可重复读;从实现方式来看,RC 每次生成 ReadView 的代价比 RR 的代价还高,且每次生成还会导致不可重复读的问题,为什么 RC 还要这样子设计呢?我认为可能是能在事务进行时获取到别的事务的提交数据在某些业务场景下是需要的,所以这样子设计,你觉得呢?欢迎讨论!
innodb 怎么解决幻影行问题?
我们在数据库开启两个事务并执行如下操作测试:
创建表并插入数据
CREATE TABLE IF NOT EXISTS `test`(
`id` INT UNSIGNED AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`balance` INT NOT NULL,
PRIMARY KEY ( `id` )
)ENGINE=InnoDB DEFAULT CHARSET=utf8;
insert into test values(1, "test1", 1);
insert into test values(2, "test2", 1);
insert into test values(3, "test3", 1);
insert into test values(4, "test4", 1);
事务1
// step 1;
begin;
// step 2;
update test set balance = 1000 where id > 2;
事务2
// step 3
begin;
// step 4
// 这一步将会被堵塞,知道锁超时或者 事务1 commit;
insert into test values(5, "test5", 1);
由上可知,innodb 使用了某种锁机制阻止了 事务2 中对于在 事务1 更新范围内但是不在数据库中的数据的写操作;这就是 Next-Key Lock;
Next-Key Lock 由 Record Lock(行级锁) 与 Gap Lock(间歇锁)组成:Record Lock:锁住相应的操作的行数据;
Gap Lock:锁住某两条数据间的间隙,间隙锁的锁定范围由已存在数据库中的数据决定,不完全等同于执行操作的范围条件;例如数据库中存在 [1,5,6,10]数据,我们在事务1使用条件 id > 2 & id < 8 的条件更新时,将会被锁定的间歇是 [(1,5),(6,10)],即如果此时在事务中写 id = 9 的操作也将会被堵塞;