【MyDB】5-索引管理之 3-索引实现之Node
[!tip]
代码位于top/xianghua/mydb/server/im/Node.java中
前言
刚刚说过,我们在**Node
封装单个节点的数据结构与本地操作(**插入、分裂、查询)
Node节点结构设计
Node 类的核心是 紧凑的字节级内存布局,直接操作原始字节数组以实现高效存储。以下是节点的二进制结构:
// 节点内存布局(单位:字节)
[LeafFlag(1)][KeyNumber(2)][SiblingUid(8)][Son0(8)][Key0(8)]...[SonN(8)][KeyN(8)]
字段 | 字节数 | 描述 |
---|---|---|
LeafFlag | 1 | 是否为叶子节点(1: 是,0: 否) |
KeyNumber | 2 | 当前节点存储的键值对数量(最大为 2*BALANCE_NUMBER ) |
SiblingUid | 8 | 兄弟节点的 UID(仅叶子节点有效,形成链表结构) |
SonX /KeyX | 8+8 | 子节点 UID 和对应的键值对(非叶子节点)或数据 UID 和键(叶子节点) |
具体实现
常量定义
在Node.java中,先定义了以下基础常量
静态常量 | 值 | 描述 |
---|---|---|
IS_LEAF_OFFSET | 0 | 标识节点是否为叶子节点(1字节) |
NO_KEYS_OFFSET | 1 | 存储节点当前键值对数量(2字节,最大 65535) |
SIBLING_OFFSET | 3 | 兄弟节点 UID(8字节,仅叶子节点有效) |
NODE_HEADER_SIZE | 11 | 节点头部总大小(1+2+8=11字节) |
BALANCE_NUMBER | 32 | 平衡因子,决定节点分裂阈值(键数达到 64 时分裂) |
NODE_SIZE | 1075 | 节点总大小(11 + (8+8)(322+2) = 11 + 16*66 = 1075 字节) |
static final int IS_LEAF_OFFSET = 0;
static final int NO_KEYS_OFFSET = IS_LEAF_OFFSET+1;
static final int SIBLING_OFFSET = NO_KEYS_OFFSET+2;
static final int NODE_HEADER_SIZE = SIBLING_OFFSET+8;
static final int BALANCE_NUMBER = 32;
static final int NODE_SIZE = NODE_HEADER_SIZE + (2*8)*(BALANCE_NUMBER*2+2);
BPlusTree tree;
DataItem dataItem; // 指向dataItem的data()
SubArray raw; // 指向dataItem的data()
long uid;
对节点中内容读取和操作的方法
Node.java中是通过字节来存储Node节点的数值的,因此获取Node的相关属性不能够直接通过node.isLeaf来获得,而是要计算在共享数组中的偏移量来获得。
因此提供以下方法来读取/设置Node节点的值
设置是叶子节点/读取是否为叶子节点:setRawIsLeaf/getRawIfLeaf
设置节点键个数/读取节点键个数 setRawNoKeys/getRawNoKeys
设置节点兄弟节点uid/读取节点兄弟节点uid setRawSibling/getRawSibling
设置第k个key的uid / 读取第k个key的uid setRawKthSon/getRawKthSon
设置第k个key/读取第k个key setRawKthKey/getRawKthKey
static void setRawIsLeaf(SubArray raw, boolean isLeaf) {
if(isLeaf) {
raw.raw[raw.start + IS_LEAF_OFFSET] = (byte)1;
} else {
raw.raw[raw.start + IS_LEAF_OFFSET] = (byte)0;
}
}
static boolean getRawIfLeaf(SubArray raw) {
return raw.raw[raw.start + IS_LEAF_OFFSET] == (byte)1;
}
static void setRawNoKeys(SubArray raw, int noKeys) {
System.arraycopy(Parser.short2Byte((short)noKeys), 0, raw.raw, raw.start+NO_KEYS_OFFSET, 2);
}
static int getRawNoKeys(SubArray raw) {
return (int)Parser.parseShort(Arrays.copyOfRange(raw.raw, raw.start+NO_KEYS_OFFSET, raw.start+NO_KEYS_OFFSET+2));
}
static void setRawSibling(SubArray raw, long sibling) {
System.arraycopy(Parser.long2Byte(sibling), 0, raw.raw, raw.start+SIBLING_OFFSET, 8);
}
static long getRawSibling(SubArray raw) {
return Parser.parseLong(Arrays.copyOfRange(raw.raw, raw.start+SIBLING_OFFSET, raw.start+SIBLING_OFFSET+8));
}
static void setRawKthSon(SubArray raw, long uid, int kth) {
int offset = raw.start+NODE_HEADER_SIZE+kth*(8*2);
System.arraycopy(Parser.long2Byte(uid), 0, raw.raw, offset, 8);
}
static long getRawKthSon(SubArray raw, int kth) {
int offset = raw.start+NODE_HEADER_SIZE+kth*(8*2);
return Parser.parseLong(Arrays.copyOfRange(raw.raw, offset, offset+8));
}
static void setRawKthKey(SubArray raw, long key, int kth) {
int offset = raw.start+NODE_HEADER_SIZE+kth*(8*2)+8;
System.arraycopy(Parser.long2Byte(key), 0, raw.raw, offset, 8);
}
// 从subArray数组中获取第k-th个键值
static long getRawKthKey(SubArray raw, int kth) {
int offset = raw.start+NODE_HEADER_SIZE+kth*(8*2)+8; // (8*2)每个键值对占用的字节数 +8(因为键值对中键的偏移量是在值的偏移量之后)
// raw中复制出从offset开始的8个字节解析为一个long类型的值
return Parser.parseLong(Arrays.copyOfRange(raw.raw, offset, offset+8));
}
}
其他字节操作
static void copyRawFromKth(SubArray from, SubArray to, int kth) {
int offset = from.start+NODE_HEADER_SIZE+kth*(8*2);
System.arraycopy(from.raw, offset, to.raw, to.start+NODE_HEADER_SIZE, from.end-offset);
}
static void shiftRawKth(SubArray raw, int kth) {
int begin = raw.start+NODE_HEADER_SIZE+(kth+1)*(8*2);
int end = raw.start+NODE_SIZE-1;
for(int i = end; i >= begin; i --) {
raw.raw[i] = raw.raw[i-(8*2)];
}
节点创建与加载方法
-
创建新节点:
newRootRaw
该根节点的初始两个子节点为 left 和 right, 初始键值为 key。static byte[] newRootRaw(long left, long right, long key) // 新建根节点 static byte[] newNilRootRaw() // 新建空叶子节点
-
示例:调用
newRootRaw(100, 200, 50)
生成根节点,包含两个子节点(UID=100 和 200),分裂键为 50。 -
完整代码
/** * 根据传入的左右节点的uid和当前节点的key,创建一个新的根节点 * @param left * @param right * @param key * @return */ static byte[] newRootRaw(long left, long right, long key) { SubArray raw = new SubArray(new byte[NODE_SIZE], 0, NODE_SIZE); setRawIsLeaf(raw, false); setRawNoKeys(raw, 2); // 设置节点的键的数量为2 setRawSibling(raw, 0); // 设置节点的兄弟节点的UID为0 setRawKthSon(raw, left, 0); // 设置第0个子节点的UID为left setRawKthKey(raw, key, 0); // 设置第0个键的值为key setRawKthSon(raw, right, 1); // 设置第1个子节点的UID为right setRawKthKey(raw, Long.MAX_VALUE, 1); // 最后一个key return raw.raw; } /** * 创建一个空的叶子节点 * @return */ static byte[] newNilRootRaw() { SubArray raw = new SubArray(new byte[NODE_SIZE], 0, NODE_SIZE); setRawIsLeaf(raw, true); setRawNoKeys(raw, 0); // 没有key setRawSibling(raw, 0); // 没有兄弟节点 return raw.raw; }
-
-
加载节点:
static Node loadNode(BPlusTree bTree, long uid) // 从 DataManager 加载节点
-
流程:根据 UID 读取
DataItem
,解析字节数组生成 Node 对象。 -
完整代码
static Node loadNode(BPlusTree bTree, long uid) throws Exception { DataItem di = bTree.dm.read(uid); assert di != null; Node n = new Node(); n.tree = bTree; n.dataItem = di; n.raw = di.data(); n.uid = uid; return n; } public void release() { dataItem.release(); }
-
查询
Node 类有两个方法,用于辅助 B+ 树做插入和搜索操作,分别是 searchNext 方法和 leafSearchRange 方法。
searchNext 寻找对应 key 的 UID, 如果找不到, 则返回兄弟节点的 UID。
-
单键搜索 (
searchNext
):public SearchNextRes searchNext(long key)
-
逻辑:遍历节点的键,找到第一个大于
key
的子节点 UID;若未找到,返回兄弟节点 UID。 -
示例:节点键为 [20, 40, 60],搜索
key=30
返回 UID 对应键 40 的子节点。 -
具体代码:
-
存储搜索到的结果
SearchNextRes
// 存储搜索到的结果 class SearchNextRes { long uid; long siblingUid; }
-
searchNext
,找到第一个大于key
的子节点 UID/** * 在B+树中查找大于给定key的下一个节点 * @param key * @return */ public SearchNextRes searchNext(long key) { // 1.获取读锁 dataItem.rLock(); try { SearchNextRes res = new SearchNextRes(); int noKeys = getRawNoKeys(raw); // 获取key的数量 // 2.遍历key,找到第一个大于key的节点 for(int i = 0; i < noKeys; i ++) { // 2.1 获取第i个key long ik = getRawKthKey(raw, i); // 2.2 如果key小于ik,则返回第ik个节点 if(key < ik) { res.uid = getRawKthSon(raw, i); res.siblingUid = 0; return res; } } // 3.如果遍历完所有的key都没有找到大于key的节点,则返回兄弟节点 // 3.1 设置uid为0,表示没有找到 res.uid = 0; // 3.2 设置siblingUid为当前节点的兄弟节点 res.siblingUid = getRawSibling(raw); return res; } finally { dataItem.rUnLock(); } }
-
-
-
范围查询 (
leafSearchRange
):leafSearchRange 方法在当前节点进行范围查找,范围是 [leftKey, rightKey],这里约定如果 rightKey 大于等于该节点的最大的 key, 则还同时返回兄弟节点的 UID,方便继续搜索下一个节点。
public LeafSearchRangeRes leafSearchRange(long leftKey, long rightKey)
-
流程:
- 定位第一个
>= leftKey
的键。 - 收集所有
<= rightKey
的 UID。 - 若遍历完所有键,通过
siblingUid
跳转到下一叶子节点。
- 定位第一个
-
结果存储 uids存储找到的左右节点uid,如果 rightKey 大于等于该节点的最大的 key, 则返回兄弟节点的 UID,siblingUid,方便继续搜索下一个节点。
class LeafSearchRangeRes { List<Long> uids; long siblingUid; }
-
leafSearchRange
,在B+树的叶子节点中搜索一个键值范围/** * 在B+树的叶子节点中搜索一个键值范围的方法 */ public LeafSearchRangeRes leafSearchRange(long leftKey, long rightKey) { dataItem.rLock(); try { int noKeys = getRawNoKeys(raw); // 获取key的数量 int kth = 0; // 2.遍历key,找到第一个大于leftKey的节点 while(kth < noKeys) { long ik = getRawKthKey(raw, kth); if(ik >= leftKey) { break; } kth ++; } // 3.循环将所有小于等于rightKey对应的子节点的uid添加到uids列表中 List<Long> uids = new ArrayList<>(); while(kth < noKeys) { long ik = getRawKthKey(raw, kth); if(ik <= rightKey) { uids.add(getRawKthSon(raw, kth)); kth ++; } else { break; } } // 4.返回uids列表和siblingUid long siblingUid = 0; // if(kth == noKeys) { // 如果遍历完了所有的key,则返回兄弟节点 siblingUid = getRawSibling(raw); } LeafSearchRangeRes res = new LeafSearchRangeRes(); res.uids = uids; res.siblingUid = siblingUid; return res; } finally { dataItem.rUnLock(); } }
-
插入与分裂
-
插入键值对 (
insert
):private boolean insert(long uid, long key)
-
叶子节点插入:
- 移动后续键值对腾出位置,插入新键和 UID。
- 示例:原键 [10, 30, 50],插入
key=20
后变为 [10, 20, 30, 50](需分裂判断)。
-
非叶子节点插入:
- 更新分裂键,插入新子节点 UID 和键(如更新中间节点的路由键)。
-
-
节点分裂 (
split
):private SplitRes split() throws Exception
- 触发条件:键数达到
2 * BALANCE_NUMBER
(默认 64)。 - 步骤:
- 创建新节点,复制原节点后半部分键值。
- 原节点保留前
BALANCE_NUMBER
个键。 - 更新原节点的
siblingUid
指向新节点。 - 将新节点插入 DataManager,返回其首键和 UID。
- 触发条件:键数达到
插入
先定义一个InsertAndSplitRes类存储返回结果
class InsertAndSplitRes {
long siblingUid, newSon, newKey;
}
insertAndSplit
方法,调用insert
方法直接插入uid和key,插入后会根据当前节点数量判断是否需要分裂
public InsertAndSplitRes insertAndSplit(long uid, long key) throws Exception {
boolean success = false; // 标志位,用于判断是否插入成功
Exception err = null; // 用于存储异常信息
InsertAndSplitRes res = new InsertAndSplitRes(); // 存储插入结果
dataItem.before(); // 数据项上设置一个保存点
try {
success = insert(uid, key); // 调用insert方法插入键值对,返回插入结果
if(!success) { // 如果插入失败,则设置兄弟节点的uid,并返回结果
res.siblingUid = getRawSibling(raw);
return res;
}
if(needSplit()) { // 如果需要分裂,则调用split方法进行分裂
try {
SplitRes r = split(); // 调用split方法进行分裂,返回分裂结果
res.newSon = r.newSon; // 设置新节点的uid
res.newKey = r.newKey; // 设置新节点的键值
return res; // 返回分裂结果
} catch(Exception e) {
err = e;
throw e;
}
} else {
return res; // 不需要分裂则直接返回结果
}
} finally {
if(err == null && success) {
// 没有发生错误并且插入成功,提交数据项的修改
dataItem.after(TransactionManagerImpl.SUPER_XID);
} else {
// 发生错误或者插入失败,回滚数据项的修改
dataItem.unBefore();
}
}
}
insert
方法
插入方法包含两种情况
-
叶子节点:找到该节点应该在的位置(k),后移k后面的所有节点
-
非叶子节点:找到该节点应该在的位置k,保存位置k的key为
kk
,更新位置k的key为要插入的key之后后移k+1的所有节点
将k+1位置设置成
原来的位置k的key,kk
k+1位置的uid设置成
当前的uid
感觉这里有点莫名其妙,我暂时还没有看懂为什么要这样设计
/**
* 向B+树中插入一个键值对
* @param uid
* @param key
* @return
*/
private boolean insert(long uid, long key) {
// 1.获取节点中key的数量
int noKeys = getRawNoKeys(raw);
int kth = 0;
// 2.遍历key,找到第一个大于key的节点
while(kth < noKeys) {
long ik = getRawKthKey(raw, kth);
if(ik < key) {
kth ++;
} else {
break;
}
}
// 3.如果kth等于key的数量,并且兄弟节点不为空,则插入失败,返回false
if(kth == noKeys && getRawSibling(raw) != 0) return false;
// 4.如果是叶子节点
if(getRawIfLeaf(raw)) {
shiftRawKth(raw, kth); // 将kth之后的key和子节点向后移动一位
// 在插入位置插入新的key和子节点uid
setRawKthKey(raw, key, kth);
setRawKthSon(raw, uid, kth);
setRawNoKeys(raw, noKeys+1); // 更新key的数量
} else {
// 5.如果不是叶子节点
// 5.1 获取插入位置的key
long kk = getRawKthKey(raw, kth);
setRawKthKey(raw, key, kth); // 更新插入位置的key
shiftRawKth(raw, kth+1); // 将kth+1之后的key和子节点向后移动一位
setRawKthKey(raw, kk, kth+1);
setRawKthSon(raw, uid, kth+1);
setRawNoKeys(raw, noKeys+1);
}
return true;
}
分裂
判断是否需要分裂
private boolean needSplit() {
return BALANCE_NUMBER*2 == getRawNoKeys(raw);
}
存储分裂结果
class SplitRes {
long newSon, newKey;
}
分裂逻辑
/**
* 分裂B+树节点的方法
* 可以理解为将一个节点分裂成两个节点
* 举例:node = [10,40,50,60] -> [10,40] [50,60]
* @return
* @throws Exception
*/
private SplitRes split() throws Exception {
// 1.创建一个新的节点
SubArray nodeRaw = new SubArray(new byte[NODE_SIZE], 0, NODE_SIZE);
// 2.设置新节点的属性
setRawIsLeaf(nodeRaw, getRawIfLeaf(raw)); //设置新节点的叶子节点属性,和当前节点相同
setRawNoKeys(nodeRaw, BALANCE_NUMBER); // 设置新节点的key数量为平衡因子,即原节点键数的一半
setRawSibling(nodeRaw, getRawSibling(raw));// 设置新节点的兄弟节点为原节点的兄弟节点
//3. 将原节点的后半部分复制到新节点
copyRawFromKth(raw, nodeRaw, BALANCE_NUMBER);
// 4.将节点插入到DM中,并返回新节点的uid
long son = tree.dm.insert(TransactionManagerImpl.SUPER_XID, nodeRaw.raw);
// 5.更新原节点的属性
// 5.1 设置原节点的key数量为平衡因子
setRawNoKeys(raw, BALANCE_NUMBER);
// 5.2 设置原节点的兄弟节点为新节点的uid
setRawSibling(raw, son);
// 6.返回新节点的uid和key
SplitRes res = new SplitRes();
res.newSon = son;
res.newKey = getRawKthKey(nodeRaw, 0);
return res;
}