【MyDB】4-VersionManager 之 2-事务的隔离级别
【MyDB】4-VersionManager 之 2-事务的隔离级别
- 事务的隔离级别
- 读提交
- 可重复读
- 版本跳跃问题
- 参考资料
事务的隔离级别
本章涉及代码: \top\xianghua\mydb\server\vm\Transaction.java
读提交
上面提到,如果一个记录的最新版本被加锁,当另一个事务想要修改或读取这条记录时,MYDB 就会返回一个较旧的版本的数据。这时就可以认为,最新的被加锁的版本,对于另一个事务来说,是不可见的。于是版本可见性的概念就诞生了。
版本的可见性与事务的隔离度是相关的。MYDB 支持的最低的事务隔离程度,是“读提交”(Read Committed),即事务在读取数据时, 只能读取已经提交事务产生的数据。保证最低的读提交的好处,第四章中已经说明(防止级联回滚与 commit 语义冲突)。
MYDB 实现读提交,为每个版本维护了两个变量,就是上面提到的 XMIN 和 XMAX:
- XMIN:创建该版本的事务编号
- XMAX:删除该版本的事务编号
XMIN 应当在版本创建时填写,而 XMAX 则在版本被删除,或者有新版本出现时填写。
XMAX 这个变量,也就解释了为什么 DM 层不提供删除操作,当想删除一个版本时,只需要设置其 XMAX,这样,这个版本对每一个 XMAX 之后的事务都是不可见的,也就等价于删除了。
如此,在读提交下,版本对事务的可见性逻辑如下:
(XMIN == Ti and // 由Ti创建且
XMAX == NULL // 还未被删除
)
or // 或
(XMIN is commited and // 由一个已提交的事务创建且
(XMAX == NULL or // 尚未删除或
(XMAX != Ti and XMAX is not commited) // 由一个未提交的事务删除
))
若条件为 true,则版本对 Ti 可见。那么获取 Ti 适合的版本,只需要从最新版本开始,依次向前检查可见性,如果为 true,就可以直接返回。
以下方法判断某个记录对事务 t 是否可见:
private static boolean readCommitted(TransactionManager tm, Transaction t, Entry e) {
long xid = t.xid;
long xmin = e.getXmin();
long xmax = e.getXmax();
if(xmin == xid && xmax == 0) return true;
if(tm.isCommitted(xmin)) {
if(xmax == 0) return true;
if(xmax != xid) {
if(!tm.isCommitted(xmax)) {
return true;
}
}
}
return false;
}
这里的 Transaction 结构只提供了一个 XID。
可重复读
读提交会导致的问题大家也都很清楚,八股也背了不少。那就是不可重复读和幻读。这里我们来解决不可重复读的问题。
不可重复度,会导致一个事务在执行期间对同一个数据项的读取得到不同结果。如下面的结果,加入 X 初始值为 0:
T1 begin
R1(X) // T1 读得 0
T2 begin
U2(X) // 将 X 修改为 1
T2 commit
R1(X) // T1 读的 1
可以看到,T1 两次读 X,读到的结果不一样。如果想要避免这个情况,就需要引入更严格的隔离级别,即可重复读(repeatable read)。
T1 在第二次读取的时候,读到了已经提交的 T2 修改的值,导致了这个问题。于是我们可以规定:
事务只能读取它开始时, 就已经结束的那些事务产生的数据版本
这条规定,增加于,事务需要忽略:
- 在本事务后开始的事务的数据;
- 本事务开始时还是 active 状态的事务的数据
对于第一条,只需要比较事务 ID,即可确定。而对于第二条,则需要在事务 Ti 开始时,记录下当前活跃的所有事务 SP(Ti),如果记录的某个版本,XMIN 在 SP(Ti) 中,也应当对 Ti 不可见。
于是,可重复读的判断逻辑如下:
- XMIN==Ti && XMAX == NULL Ti创建且尚未被删除的记录
- XMIN is committed and XMIN < Ti and XMIN is not in SP(Ti)
- XMIN is committed 表示该版本是由一个已提交的事务创建的。
- XMIN < Ti 表示该版本是在当前事务 Ti 开始之前创建的。
- XMIN is not in SP(Ti) 表示该版本的创建事务不在当前事务 Ti 开始时的活跃事务集合中,因此该版本对当前事务是可见的。
- XMAX == NULL or (XMAX != Ti and (XMAX is not committed or XMAX > Ti or XMAX is in SP(Ti)))
- XMAX == NULL 表示该版本尚未被删除。
- XMAX != Ti 表示删除该版本的事务不是当前事务 Ti。
- XMAX is not committed 表示删除操作尚未提交。
- XMAX > Ti 表示删除操作发生在当前事务 Ti 之后。
- XMAX is in SP(Ti) 表示删除操作发生在当前事务 Ti 开始之前但未提交。
// 可重复读隔离级别下的事务可见性逻辑
(XMIN == Ti and // 由Ti创建且
(XMAX == NULL // 尚未被删除
))
or // 或
(XMIN is commited and // 由一个已提交的事务创建且
XMIN < XID and // 这个事务小于Ti且
XMIN is not in SP(Ti) and // 这个事务在Ti开始前提交且
(XMAX == NULL or // 尚未被删除或
(XMAX != Ti and // 由其他事务删除但是
(XMAX is not commited or // 这个事务尚未提交或
XMAX > Ti or // 这个事务在Ti开始之后才开始或
XMAX is in SP(Ti) // 这个事务在Ti开始前还未提交
))))
于是,需要提供一个结构,来抽象一个事务,以保存事务开始时,正在活跃的其他事务数据:
public class Transaction {
public long xid; // 事务的ID
public int level; // 事务的隔离级别
public Map<Long, Boolean> snapshot; // 事务的快照,用于存储活跃事务的ID
public Exception err; // 事务执行过程中的错误
public boolean autoAborted; // 标志事务是否自动中止
public long startTime; // 添加开始时间属性
public static Transaction newTransaction(long xid, IsolationLevel isolationLevel, Map<Long, Transaction> active) {
Transaction t = new Transaction();
t.xid = xid;
t.isolationLevel = isolationLevel;
t.startTime = System.currentTimeMillis();
// 当隔离级别等于可重复读和串行化时需要创建快照
if(isolationLevel != IsolationLevel.READ_COMMITTED && isolationLevel != IsolationLevel.READ_UNCOMMITTED) {
t.snapshot = new HashMap<>();
for(Long x : active.keySet()) {
t.snapshot.put(x, true);
}
}
return t;
}
public boolean isInSnapshot(long xid) {
if(xid == TransactionManagerImpl.SUPER_XID) {
return false;
}
return snapshot.containsKey(xid);
}
}
构造方法中的 active,保存着当前所有 active 的事务。于是,可重复读的隔离级别下,一个版本是否对事务可见的判断如下:
private static boolean repeatableRead(TransactionManager tm, Transaction t, Entry e) {
long xid = t.xid;
long xmin = e.getXmin();
long xmax = e.getXmax();
// 当前事务创建且未删除的数据版本是可见的
if (xmin == xid && xmax == 0) return true;
// 已提交事务创建的版本,且不在当前事务快照中的版本是可见的
if (tm.isCommitted(xmin) && xmin < xid && !t.isInSnapshot(xmin)) {
// 如果记录未被删除,或删除版本未提交或不在快照中,则该版本可见
if (xmax == 0 || (xmax != xid && (!tm.isCommitted(xmax) || xmax > xid || t.isInSnapshot(xmax)))) {
return true;
}
}
return false;
}
版本跳跃问题
说到版本跳跃之前,顺便提一嘴,MVCC 的实现,使得 MYDB 在撤销或是回滚事务很简单:只需要将这个事务标记为 aborted 即可。根据前一章提到的可见性,每个事务都只能看到其他 committed 的事务所产生的数据,一个 aborted 事务产生的数据,就不会对其他事务产生任何影响了,也就相当于,这个事务不曾存在过。
版本跳跃问题,考虑如下的情况,假设 X 最初只有 x0 版本,T1 和 T2 都是可重复读的隔离级别:
T1 begin
T2 begin
R1(X) // T1读取x0
R2(X) // T2读取x0
U1(X) // T1将X更新到x1
T1 commit
U2(X) // T2将X更新到x2
T2 commit
这种情况实际运行起来是没问题的,但是逻辑上不太正确。T1 将 X 从 x0 更新为了 x1,这是没错的。但是 T2 则是将 X 从 x0 更新成了 x2,跳过了 x1 版本。
读提交是允许版本跳跃的,而可重复读则是不允许版本跳跃的。解决版本跳跃的思路也很简单:
如果 Ti 需要修改 X,而 X 已经被 Ti 不可见的事务 Tj 修改了,那么要求 Ti 回滚。
上一节中就总结了,Ti 不可见的 Tj,有两种情况:
- XID(Tj) > XID(Ti):这意味着Tj在时间上晚于Ti开始,因此Ti应该回滚,避免版本跳跃。
- Tj in SP(Ti):则Tj在Ti开始之时仍在活跃,但Ti在开始之前并不能看到Tj的修改,因此Ti也应该回滚。
于是版本跳跃的检查也就很简单了,取出要修改的数据 X 的最新提交版本,并检查该最新版本的创建者对当前事务是否可见:
tm.isCommitted(xmax) && (xmax > t.xid t.isInSnapshot(xmax));
- tm.isCommitted(xmax),被不可见的事务xmax修改
- 不可见的事务xmax
- xmax > t.xid
- t.isInSnapshot(xmax)
public static boolean isVersionSkip(TransactionManager tm, Transaction t, Entry e) {
long xmax = e.getXmax();
// 读未提交隔离级别下不考虑版本跳跃问题
if(t.level == 0) {
return false;
} else {
// 读提交及以上隔离级别需要检查版本跳跃
return tm.isCommitted(xmax) && (xmax > t.xid || t.isInSnapshot(xmax));
}
}
参考资料
MYDB 6. 记录的版本与事务隔离 | 信也のブログ (shinya.click)
事务的隔离级别 | EasyDB (blockcloth.cn)