版本跳跃
版本跳跃问题是指在多版本并发控制(MVCC)中,一个事务要修改某个数据项时,可能会出现跳过中间版本直接修改最新版本的情况,从而产生逻辑上的错误。解决版本跳跃的关键在于检查最新版本的创建者对当前事务是否可见。如果当前事务要修改的数据已经被另一个事务修改并且对当前事务不可见,就要求当前事务回滚。具体来说,对于事务Ti要修改数据X的情况下,要检查如下两种情况:
- 如果另一个事务Tj的事务ID(XID)大于Ti的事务ID,则Tj在时间上晚于Ti开始,因此Ti应该回滚,避免版本跳跃。
- 如果Tj在Ti的快照集合(SP(Ti))中,则Tj在Ti开始之前已经提交,但Ti在开始之前并不能看到Tj的修改,因此也应该回滚。
考虑如下操作序列:
- 开启事务a
- 开启事务b
- 事务a读取x
- 事务b读取x
- 事务a将x改为y
- 事务a提交
- 事务b将x改为z
- 事务b提交
这样对于事务b来说,效果是将x改为了z,跳过了之间的y,这就是版本跳跃问题
注意,对于读已提交隔离级别来说是允许如上版本跳跃问题的,但是对于可重复读是不允许的
解决版本跳跃的思路:
如果事务a需要修改x,而x已经被a不可见的事务b修改了,那么要求a回滚
那么a不可见的事务b有哪些情况?具体是如下情况:
- 事务b的事务编号 > 事务a的事务编号(事务b在事务a之后才开启)
- 事务b 在事务a的开始前活跃事务集合内(
b in SP(a)
,即b在a开始之前已经开始,但a看不到b的修改)
版本跳跃的检查
因为读提交是允许版本跳跃的,可重复读是不允许的,所以只需要检查读提交即可
代码实现
根据上述逻辑,检查版本跳跃时,就是要取出要修改的数据x的最新提交版本,然后检查该最新版本的创建者对当前事务是否可见:
// IsVersionSkip 判断是否发生了MVCC中的版本跳跃问题
public static boolean isVersionSkip(TransactionManager tm, Transaction t, Entry e) {
// 获取条目的删除版本号
long xmax = e.getXmax();
// 如果事务的隔离级别为0,即读未提交,那么不跳过该版本,返回false
if (t.level == 0) {
return false;
} else {
// 如果事务的隔离级别不为0,那么检查删除版本是否已提交,并且删除版本号大于事务的ID或者删除版本号在事务的快照中
// 如果满足上述条件,那么跳过该版本,返回true
return tm.isCommitted(xmax) && (xmax > t.xid || t.isInSnapshot(xmax));
}
}
如果是可重复读的隔离级被别,同时检测到了版本跳跃,那么直接报错回滚
死锁检测
上文提到了在基于**2PL(两段锁协议)**的并发控制中,当一个事务(例如Tj)想要获取某个数据项的锁时,如果该锁已经被其他事务(例如Ti)持有,则Tj会被阻塞,直到Ti释放了该锁。这种等待关系可以被抽象成有向边,比如Tj在等待Ti,可以表示为Tj → Ti。通过记录所有事务之间的等待关系,就可以构建一个有向图,即等待图(Wait-for graph)。在等待图中,如果存在环路,即存在一个事务的等待序列形成了一个闭环,那么就说明存在死锁。因此,检测死锁只需要查看等待图中是否存在环即可。
由于2PL的存在,某些事务在获取某些Entry
的锁时,可能会阻塞,那么很容易地就能知道这种依赖资源形成的阻塞关系会形成一个有向图,图中的节点是事务,边代表事务的依赖关系,那么就可能出现死锁。
在抽象出来的有向图中检测是否存在死锁,其实就是检测其中是否有环
LockTable基本结构
LockTable
是用来维护有向图(后续称为依赖等待图)的,其结构如下
/**
* 维护了一个依赖等待图,以进行死锁检测
*/
public class LockTable {
// 某个XID已经获得的资源的UID列表,键是事务ID,值是该事物持有的资源ID列表。
private Map<Long, List<Long>> x2u; //我是个事务,我获取了多少资源;一对多
// UID被某个XID持有,键是资源ID,值是持有该资源的事务ID。
private Map<Long, Long> u2x;//一对一
// 正在等待UID的XID列表,键是资源ID,值是正在等待该资源的事务ID。
private Map<Long, List<Long>> wait; //我是个资源,有多少事务在等我,一对多
// 正在等待资源的XID的锁,键是事务ID,值是该事务的锁对象。
private Map<Long, Lock> waitLock; //事务的锁对象
// XID正在等待的UID,键是事务ID,值是该事务正在等待的资源ID。
private Map<Long, Long> waitU; //一对一
// 一个全局锁,用于同步。
private Lock lock;
}
判断死锁的逻辑
在每次由于获取锁而出现阻塞的情况时,就尝试向依赖等待图中增加一个有向边,然后进行死锁检测,如果发生死锁,那么返回错误并撤销异常
// Add 添加一个事务ID和资源ID的映射关系,返回一个锁对象,如果发生死锁,返回错误
// 不需要等待则返回null,否则返回锁对象或者会造成死锁则抛出异常
public Lock add(long xid, long uid) throws Exception {
lock.lock(); // 锁定全局锁
try {
// 检查x2u是否已经拥有这个资源
if (isInList(x2u, xid, uid)) {
return null; // 如果已经拥有,直接返回null
}
// 检查UID资源是否已经被其他XID事务持有
if (!u2x.containsKey(uid)) {
u2x.put(uid, xid); // 如果没有被持有,将资源分配给当前事务
putIntoList(x2u, xid, uid); // 将资源添加到事务的资源列表中
return null; // 返回null
}
// 如果资源已经被其他事务持有,将当前事务添加到等待列表中(waitU是一个map,键是事务ID,值是资源ID,代表事务正在等待的资源)
waitU.put(xid, uid);
//反过来,将资源添加到等待列表中(wait是一个map,键是资源ID,值是事务ID列表,代表正在等待该资源的事务)
putIntoList(wait, uid, xid);
// 检查是否存在死锁
if (hasDeadLock()) {
waitU.remove(xid); // 如果存在死锁,从等待列表中移除当前事务
removeFromList(wait, uid, xid);//从资源的等待列表中移除当前事务
throw Error.DeadlockException; // 抛出死锁异常
}
// 如果不存在死锁,为当前事务创建一个新的锁,并锁定它
Lock l = new ReentrantLock();
l.lock();
waitLock.put(xid, l); // 将新的锁添加到等待锁列表中
return l; // 返回新的锁
} finally {
lock.unlock(); // 解锁全局锁
}
}
判断的其他函数
isInList
// isInList 判断xid是否在x2u中,如果已经持有,返回true,否则返回false
//x2u:某个XID(事务)已经获得的资源的UID列表
private boolean isInList(Map<Long, List<Long>> listMap, long uid0, long uid1) {
List<Long> l = listMap.get(uid0);
if(l == null) return false;
Iterator<Long> i = l.iterator();
while(i.hasNext()) {
long e = i.next();
if(e == uid1) {
return true;
}
}
return false;
}
工具函数
// private Map<Long, List<Long>> wait; // 正在等待UID的XID列表
//从资源的等待列表中移除当前事务
private void removeFromList(Map<Long, List<Long>> listMap, long uid0, long uid1) {
List<Long> l = listMap.get(uid0);
if(l == null) return;
Iterator<Long> i = l.iterator();
while(i.hasNext()) {
long e = i.next();
if(e == uid1) {
i.remove();
break;
}
}
if(l.size() == 0) {
listMap.remove(uid0);
}
}
死锁演示
前言
采用一下数据实现死锁模拟:
- lockTable.add(1, 1); // 事务1请求资源1
- lockTable.add(2, 2); // 事务2请求资源2
- lockTable.add(3, 3); // 事务3请求资源3
- lockTable.add(1, 2); // 事务1请求资源2
- lockTable.add(2, 3); // 事务2请求资源3
- lockTable.add(3, 1); // 事务3请求资源1
在这些数据添加完毕之后,事务1在等待事务2,事务2在等待事务3,事务3又在等待事务1,此时就触发了死锁!
当数据添加完毕之后,LockTable类中的MAP集合对着一下元素:
- x2u
xid uid
1 1
2 2
3 3
- u2x
uid xid
1 1
2 2
3 3
- wait
uid xid
1 3
2 1
3 2
- waitU
xid uid
1 2
2 3
3 1
dfs()演示过程
当上方数据插入装载完成之后,会进行死锁校验,此处只是采用简易代码实现,可以自己根据源码进行学习!
第一遍:xidStamp = null,stamp=2
private boolean dfs(long xid) { //xid = 1
Integer stp = xidStamp.get(xid); // null
xidStamp.put(xid, stamp); // 1,2
Long uid = waitU.get(xid); // uid = 2
Long x = u2x.get(uid); // x = 2
return dfs(x); // 将2存入进去
}
第二遍:xidStamp = {1=2},stamp=2
private boolean dfs(long xid) { // xid = 2
Integer stp = xidStamp.get(xid); // null
xidStamp.put(xid, stamp); // 2,2
Long uid = waitU.get(xid); // uid = 3
Long x = u2x.get(uid); // x = 3
return dfs(x); // 将3存入进去
}
第三遍:xidStamp = {1=2,2=2},stamp=2
private boolean dfs(long xid) { // xid = 3
Integer stp = xidStamp.get(xid); // 3
xidStamp.put(xid, stamp); // 3,2
Long uid = waitU.get(xid); // uid = 1
Long x = u2x.get(uid); // x = 1
return dfs(x); // 将1存入进去
}
第四遍:xidStamp = {1=2,2=2,3=2},stamp=2
private boolean dfs(long xid) { // xid = 1
Integer stp = xidStamp.get(xid); // 此时就获取到了数据,stp = 2;
if (stp != null && stp == stamp) { // 此时条件成立,证明存在死锁
return true; // 存在死锁,返回true
}
}
检查死锁的核心函数
检查是否包含死锁
// hasDeadLock 检查是否存在死锁
private boolean hasDeadLock() {
xidStamp = new HashMap<>(); // 创建一个新的xidStamp哈希映射
stamp = 1; // 将stamp设置为1
for (long xid : x2u.keySet()) { // 遍历所有已经获得资源的事务ID
Integer s = xidStamp.get(xid); // 获取xidStamp中对应事务ID的记录
if (s != null && s > 0) { // 如果记录存在,并且值大于0
continue; // 跳过这个事务ID,继续下一个
}
stamp++; // 将stamp加1
if (dfs(xid)) { // 调用dfs方法进行深度优先搜索
return true; // 如果dfs方法返回true,表示存在死锁,那么hasDeadLock方法也返回true
}
}
return false; // 如果所有的事务ID都被检查过,并且没有发现死锁,那么hasDeadLock方法返回false
}
private boolean dfs(long xid) {
Integer stp = xidStamp.get(xid); // 从xidStamp映射中获取当前事务ID的时间戳
if (stp != null && stp == stamp) { // 如果时间戳存在并且等于全局时间戳
return true; // 存在死锁,返回true
}
if (stp != null && stp < stamp) { // 如果时间戳存在并且小于全局时间戳
return false; // 这个事务ID已经被检查过,并且没有发现死锁,返回false
}
xidStamp.put(xid, stamp); // 将当前事务ID和全局时间戳添加到xidStamp映射中
Long uid = waitU.get(xid); // 从waitU映射中获取当前事务ID正在等待的资源ID
if (uid == null) return false; // 如果资源ID不存在,表示当前事务ID不在等待任何资源,返回false
Long x = u2x.get(uid); // 从u2x映射中获取当前资源ID被哪个事务ID持有
assert x != null; // 断言这个事务ID存在
return dfs(x); // 递归调用dfs方法检查这个事务ID
}
Remove
当一个事务commit或者abort时,就会释放掉它自己持有的锁,并将自身从等待图中删除\
// Remove 当一个事务commit或者abort时,就会释放掉它自己持有的锁,并将自身从等待图中删除
public void remove(long xid) {
lock.lock(); // 获取全局锁
try {
List<Long> l = x2u.get(xid); // 从x2u映射中获取当前事务ID已经获得的资源的UID列表
if (l != null) {
while (l.size() > 0) {
Long uid = l.remove(0); // 获取并移除列表中的第一个资源ID
selectNewXID(uid); // 从等待队列中选择一个新的事务ID来占用这个资源
}
}
waitU.remove(xid); // 从waitU映射中移除当前事务ID
x2u.remove(xid); // 从x2u映射中移除当前事务ID
waitLock.remove(xid); // 从waitLock映射中移除当前事务ID
} finally {
lock.unlock(); // 解锁全局锁
}
}
selectNewXID
// 从等待队列中选择一个xid来占用uid
private void selectNewXID(long uid) {
u2x.remove(uid); // 从u2x映射中移除当前资源ID
List<Long> l = wait.get(uid); // 从wait映射中获取当前资源ID的等待队列
if (l == null) return; // 如果等待队列为空,立即返回
assert l.size() > 0; // 断言等待队列不为空
// 遍历等待队列
while (l.size() > 0) {
long xid = l.remove(0); // 获取并移除队列中的第一个事务ID
// 检查事务ID是否在waitLock映射中
if (!waitLock.containsKey(xid)) {
continue; // 如果不在,跳过这个事务ID,继续下一个
} else {
u2x.put(uid, xid); // 将事务ID和资源ID添加到u2x映射中
Lock lo = waitLock.remove(xid); // 从waitLock映射中移除这个事务ID
waitU.remove(xid); // 从waitU映射中移除这个事务ID
lo.unlock(); // 解锁这个事务ID的锁
break; // 跳出循环
}
}
// 如果等待队列为空,从wait映射中移除当前资源ID
if (l.size() == 0) wait.remove(uid);
}