![408882940f20a78de4cea417c78bef8e.png](https://i-blog.csdnimg.cn/blog_migrate/4ea62ed7764794bc40e0c137632db18a.jpeg)
上篇文章讨论了如何实现一个内存中的BTree,实现了BTree基本的功能,但是该BTree的实现并不是线程安全的,不支持多个线程同时访问。在现实情况中,数据库存储引擎需要支持多个线程同时访问,以提高访问的并发度。本文将介绍如何实现一个线程安全的内存版BTree,以及实现过程中的一些注意事项。
线程不安全
上篇文章中实现的BTree是线程不安全的,它不支持多个线程同时访问(可以同时读)。下面介绍两种可能的线程不安全情况(实际上,有多种不同的线程不安全的情况)。
访问越限
以下的访问可能导致访问越限,LeafNode节点只有N-1个key,但是会出现访问到第N个key的情况,这样就可能出现不可知的错误(具体错误取决于实现)。
- A线程访问LeafNode节点,先取得LeafNode中key的个数为N;
- B线程访问同一个LeafNode节点;
- B线程删除LeafNode节点中的一个key(delete操作);
- A线程遍历第1到N个key,查找到对应的ValueNode,访问第N个key时,出现错误,因为第N个key已经不存在了。
下图为访问越限的错误流程:
![3252e667e6bf7f8847b36216333a41c4.png](https://i-blog.csdnimg.cn/blog_migrate/a0eabb0f2218e5ee77ddb4d9d04ca5d6.jpeg)
![6def194934684e7e65786ff78d4a5cb2.png](https://i-blog.csdnimg.cn/blog_migrate/4bd1eb1dc842b0b71798a5821dda229e.jpeg)
节点丢失
节点分裂时,如果有其他线程访问分裂的节点,可能导致找不到正确的节点,读不到正确的ValueNode,也就是出现ValueNode丢失的情况。ValueNode节点丢失发生的访问顺序如下:
- 线程A访问一个节点,正准备查询key=5的节点;
- 线程B访问该节点,发现节点已经满了N=4,尝试分裂该节点
- 线程B分裂该节点,key=5复制到新的节点中;
- 线程A在就节点上找不到key为5的子节点,返回找不到key=5的值,出现节点丢失的情况,key=5的值在BTree是存在的。
下图为该情况的流程:
![b2768d8b0f9e6227bf3446df39dc65e2.png](https://i-blog.csdnimg.cn/blog_migrate/bf0dfe20ca9887d60122daba964b5fde.jpeg)
![0994983712a348b9c318102487a90777.png](https://i-blog.csdnimg.cn/blog_migrate/9dd44b0c1a3ea1276e9474e8278836df.jpeg)
提问:还有没有其他线程不安全的情况?可以一一列出来。
线程安全实现途径
通过分析上面两种线程不安全,可以发现线程不安全的原因是因为出现了多个线程交替访问的情况。换句话说,就是访问的原子性被打破了。为了实现线程安全,可以通过一些手段保持访问的原子性。比较常见的方式,是通过锁来实现访问的原子性,本文将只讨论使用锁来实现线程安全。
提问:还有没有其他方式可以实现线程安全?如果有,是什么方法?哪些系统上使用了这些方法?它的优缺点是什么?
Java实现锁有两种方式,一种是lock,也就是常规意义上的锁;另外一种是synchronized,同步块或者同步方法,其作用和lock基本相同,使用{}来标识加锁和解锁。本文为了考虑各种语言的不同,选择使用lock,不使用synchronized。
线程安全实现之全局锁
最直接的线程安全方式就是在每一个操作开始的时候加锁,在操作结束的时候解锁。实现如下:
private final ReentrantLock treeLocker = new ReentrantLock(); //在BTree中增加一个锁
public void put(byte[] key, byte[] value) {
try {
treeLocker.lock();
//put逻辑代码
} finally {
treeLocker.unlock();
}
}
public byte[] get(byte[] key) {
try {
treeLocker.lock();
//get逻辑代码
} finally {
treeLocker.unlock();
}
}
public byte[] delete(byte[] key) {
try {
treeLocker.lock();
//delete逻辑代码
} finally {
treeLocker.unlock();
}
}
线程安全实现之全局读写锁
上面的实现,实际上是让所有的操作排队,然后一个个按照顺序访问BTree。这样的方式是可以保证访问的原子性,杜绝了线程不安全,但是降低了访问的并发性。如果所有的访问都是查询,依然需要排队,但是此时一起访问并不会出错(只有查询不改变BTree,访问不会出现任何异常)。下图展示的是两个查询操作访问时的情景:
![b123b53dd678e9e2e9b8c5daf11c0c78.png](https://i-blog.csdnimg.cn/blog_migrate/cabfd78adf2cae1c747910ab35f7dc57.jpeg)
Java还提供一种读写锁,这种锁有一个读锁模式,读锁模式相互之间不阻塞,但是读写模式以及写模式相互直接阻塞。这样就可以让两个执行读操作的线程不用阻塞等待,从而提高并发度。读写模式阻塞表如下:
![1d9324dfbd54067aaf7b9d58ea69dea0.png](https://i-blog.csdnimg.cn/blog_migrate/f0495acd33fea82ada894a59b229f506.png)
使用读写锁以后,get操作使用读锁,delete和put操作使用写锁,因为delete和put操作会修改BTree的状态。
两个读操作的图示如下:
![77aac67cf7aafe030734547c2266b365.png](https://i-blog.csdnimg.cn/blog_migrate/e5770a42a5a3afba1bb92da482ddc9a8.jpeg)
修改后的代码如下:
private final ReentrantReadWriteLock treeLocker = new ReentrantReadWriteLock();
public void put(byte[] key, byte[] value) {
try {
treeLocker.writeLock().lock();
//put逻辑代码
} finally {
treeLocker.writeLock().unlock();
}
}
public byte[] get(byte[] key) {
try {
treeLocker.readLock().lock();
//get逻辑代码
} finally {
treeLocker.readLock().unlock();
}
}
public byte[] delete(byte[] key) {
try {
treeLocker.writeLock().lock();
//delete逻辑代码
} finally {
treeLocker.writeLock().unlock();
}
}
线程安全实现之节点锁
以上的线程安全实现都是锁住了整个BTree,第二个实现虽然有一定的优化,但是依然是锁住整个BTree。当一个读操作先取得了读锁,后面的其他写操作依然等待该操作结束时,才能取得写锁,然后才能对BTree进行操作。
![208c9a917a20f1d22722eed065a38c65.png](https://i-blog.csdnimg.cn/blog_migrate/21d32f0b0c3793bd187eb56da32deac8.jpeg)
即使A线程访问到非根节点,B线程依然在根节点等待。
![66792cd04a464997adfc871742686a1b.png](https://i-blog.csdnimg.cn/blog_migrate/bf4c21032c97d1056d2342250d993da1.jpeg)
从B线程未来需要访问的节点来看,可以很容易知道,B线程和A线程在接下来不会访问同一个节点了,二者未来也不会发生冲突,A线程在BTree上加的锁可以释放,这样B线程就不用等到A线程结束以后,才能访问BTree,从而提高了访问的并发度。不过直接释放A线程的锁,会出现一些并发的问题。为了提高BTree访问的并发度,加锁粒度从BTree级别缩小为node级别(也有叫page级别),需要遵循一定的协议:
- 对某个节点加锁前,需要对其父节点加锁
- 对子节点加锁后,才能释放父节点的锁
- 加锁从根节点开始
- 不能逆向加锁,即不能先锁子节点,再锁父节点
这种加锁协议一般称作ladder locking或者coupling locking,大致过程如下:
![2832bbb7f501c19d8c534952e1e6e2a2.png](https://i-blog.csdnimg.cn/blog_migrate/07c8fa7eeee49fe48467108c43d42fe4.jpeg)
提问:除了这种加锁方式,是否存在其他方式可以提高并发度的?有哪些系统使用了这种加锁方式,有哪些系统使用了其他的方式?
数据结构修改
因为需要对具体某个节点加锁,所以需要在给所有TreeNode增加一个锁的字段。如果一个操作是查询操作,它对节点可以只加读锁;而如果一个操作是写入操作,它可能需要对节点加写锁,所以给TreeNode增加的锁字段,应该是一个读写锁,另外需要增加三个新方法,分别用于加读锁、加写锁以及释放锁,如下所示:
public abstract class TreeNode extends Node {
//最大子节点数目
public static final int MAX_CHILDREN = 256;
protected final ReentrantReadWriteLock locker = new ReentrantReadWriteLock();
public abstract Node findChild(DataItem key);
public abstract void insertChild(DataItem key, Node child);
public abstract Node removeChild(DataItem key);
public abstract TreeNode split(TreeNode father);
public abstract boolean needSplit();
public void writeLock() {
locker.writeLock().lock();
}
public void readLock() {
locker.readLock().lock();
}
public void releaseLock() {
if (locker.writeLock().isHeldByCurrentThread()) {
locker.writeLock().unlock();
return;
}
locker.readLock().unlock();
}
}
Root的父节点
Coupling Locking的规则要求对某节点加锁时,需要先对其父节点加锁,但是Root节点没有父节点,无法对父节点加锁。但是如果不加锁会出现一些并发的问题,比如两个写操作同时发现root不存在,都尝试建立一个root节点,这样就可能出现两个root节点,最后有一个操作的写入会丢失。为了解决这个问题,需要引入一个root节点的“父节点”,这个父节点就是BTree本身,使用BTree本身的读写锁作为root节点的父节点的锁。
读操作的Coupling Locking
读操作的Coupling Locking全程使用读锁,其流程如下:
- 对整个BTree加读锁;
- 找到root节点以后,对root节点一个读锁,然后释放BTree上的读锁;如果root不存在,直接返回;
- 接下来,每次找到子节点,在子节点上加上读锁以后才能释放父节点上的读锁;
- 如果最后的子节点是LeafNode,从上面读取目标key对应的Value,然后在释放LeafNode上的锁
下图是查询key=1的过程:
![1538366d54fc91b3343199de3a2cba8e.png](https://i-blog.csdnimg.cn/blog_migrate/6d9e0f6dad28bcdfb866b8fb701ffed0.jpeg)
代码如下
public byte[] get(byte[] key) {
DataItem searchKey = new DataItem(key);
treeLocker.readLock().lock();
if (root == null) {
treeLocker.readLock().unlock();
return null;
}
TreeNode parent = root;
parent.readLock();
treeLocker.readLock().unlock();
while (parent instanceof InterNode) {
TreeNode child = (TreeNode)parent.findChild(searchKey);
child.readLock();
parent.releaseLock();
parent = child;
}
ValueNode valueNode = (ValueNode)parent.findChild(searchKey);
parent.releaseLock();
if (valueNode == null) {
return null;
}
byte[] value = valueNode.getValue().getData();
if (value == null) {
return null;
}
return Arrays.copyOf(value, value.length);
}
删除操作的Coupling Locking
删除操作的Coupling Locking中,需要对InterNode使用读锁,对LeafNode使用写锁,其流程如下:
- 对整个BTree加读锁;
- 找到root节点以后,如果root是InterNode,对root节点一个读锁,如果root是LeafNode,则加写锁,然后释放BTree上的读锁;如果root不存在,直接返回;
- 接下来,每次找到子节点,在子节点上加上读锁(InterNode)或者写锁(LeafNode),以后才能释放父节点上的锁;
- 如果最后的子节点是LeafNode,则删除目标key对应的Value,然后在释放LeafNode上的锁
下图是删除key=1的过程:
![9cbfc2565b3d0d5ee5de28e81e70bad6.png](https://i-blog.csdnimg.cn/blog_migrate/23b75cda290fffc0521e1ed28cee5419.jpeg)
代码如下:
public byte[] delete(byte[] key) {
DataItem deleteKey = new DataItem(key);
treeLocker.readLock().lock();
if (root == null) {
treeLocker.readLock().unlock();
return null;
}
TreeNode parent = root;
if (parent instanceof LeafNode) {
parent.writeLock();
} else {
parent.readLock();
}
treeLocker.readLock().unlock();
while (parent instanceof InterNode) {
TreeNode child = (TreeNode)parent.findChild(deleteKey);
if (child instanceof LeafNode) {
child.writeLock();
} else {
child.readLock();
}
parent.releaseLock();
parent = child;
}
ValueNode valueNode = (ValueNode)parent.removeChild(deleteKey);
parent.releaseLock();
if (valueNode == null) {
return null;
}
byte[] value = valueNode.getValue().getData();
if (value == null) {
return null;
}
return Arrays.copyOf(value, value.length);
}
写操作的Coupling Locking
写操作的Coupling Locking全部为写锁,这是因为在写操作的过程可能会出现分裂的情况,其具体过程如下:
- 对整个BTree加写锁;
- 查找root,然后加写锁;没有找到root,创建一个新的root,再对root加写锁
- 检查root是否需要分裂;如果需要,先创建一个新的root,然后把旧root插入新root,然后分裂旧root,接着释放旧root上的写锁,在新root上加一个写锁
- 接下来,每次找到子节点,在子节点上加上写锁(LeafNode),检查子节点是否需要分裂,如果需要,先分裂子节点,然后释放子节点上的写锁,然后跳回父节点上,重新查找子节点,在新字节上加写锁。最后释放父节点上的写锁;
- 如果最后的子节点是LeafNode,插入key=3和value=w,然后在释放LeafNode上的写锁
下图是插入key=3,value=w的过程:
![b8299afe23a02068f98a34164588ecc5.png](https://i-blog.csdnimg.cn/blog_migrate/2d8b12d6a22018fc584dd7dc64d229f9.jpeg)
代码如下:
//插入key/value对
public void put(byte[] key, byte[] value) {
DataItem insertKey = new DataItem(key);
DataItem insertValue = new DataItem(value);
treeLocker.writeLock().lock();
if (root == null) {
root = new LeafNode();
}
root.writeLock();
if (root.needSplit()) {
InterNode newRoot = new InterNode();
newRoot.insertChild(DataItem.MAX_VALUE, root);
root.split(newRoot);
root.releaseLock();
root = newRoot;
root.writeLock();
}
TreeNode parent = root;
treeLocker.writeLock().unlock();
while (parent instanceof InterNode) {
TreeNode child = (TreeNode)parent.findChild(insertKey);
child.writeLock();
if (child.needSplit()) {
child.split(parent);
child.releaseLock();
} else {
parent.releaseLock();
parent = child;
}
}
ValueNode valueNode = new ValueNode(insertValue);
if (parent.needSplit()) {
System.out.println("Something Wrong");
if (parent == root) {
System.out.println("Root is not splitted, werid!!!");
}
}
parent.insertChild(insertKey, valueNode);
parent.releaseLock();
}
提问:写操作的Coupling Locking还能进一步优化吗?在什么场景下,能够提高性能?
代码
本章中提及的完整代码如下:(全部代码将在下一章提供)
总结
到本章为止,我们完整实现了内存BTree,包括增删查功能以及线程安全功能,但是这些功能都是在内存中,一旦断电或者内存不够,就会出现数据丢失,所以需要把内存中的数据写入文件中,这样才能保证数据不丢失(硬盘不损坏的情况下)。下章中,我们将讨论如何把BTree中的数据写入文件。