Mydb自定义数据库

架构

项目分为四个主要模块:

  • backend:数据库核心功能实现
  • client:客户端实现
  • transport:网络传输层
  • common:公共工具和异常处理

后端

dm/数据管理:实现数据页面的管理和持久化

  • DataManager.java:数据管理器接口
  • DataManagerImpl.java:数据管理器实现
  • PageOne.java:特殊页面,存储元数据
  • PageX.java:数据页面
  • Logger.java:日志管理

im/索引管理:实现B+树索引

  • BPlusTree.java:B+树实现
  • Node.java:B+树节点
  • LeafNode.java:叶子节点
  • InternalNode.java:内部节点

tbm/表管理:实现表的创建、删除和管理

  • Table.java:表的抽象
  • TableManager.java:表管理器接口
  • TableManagerImpl.java:表管理器实现
  • Field.java:字段定义和管理

tm/事务管理:实现事务的ACID特性

  • ransactionManager.java:事务管理器接口
  • TransactionManagerImpl.java:事务管理器实现

vm/MVCC管理:

  • VersionManager.java:版本管理器接口
  • VersionManagerImpl.java:版本管理器实现
  • Entry.java:数据项
  • Transaction.java:事务实现

parser/sql语句的解析:

  • Parser.java:SQL解析器
  • Tokenizer.java:词法分析器
  • statement/*.java:各类SQL语句的数据结构

server/服务器功能:

  • erver.java:服务器实现
  • Executor.java:SQL执行器

客户端

  • Client.java:客户端实现
  • Shell.java:命令行交互
  • Launcher.java:启动器

传输

  • Package.java:通信包
  • Packager.java:包处理器
  • Transporter.java:传输器

公共模块

  • Error.java:错误定义
  • Config.java:配置管理

解析

Logger模块

实现日志的持久化、校验、读取、校正和截断非法数据,确保数据库的崩溃恢复能力(crash recovery)

日志的文件格式:

1
[Size(4字节)][Checksum(4字节)][Data(N字节)]

文件头部还有一个全局 xChecksum(4字节)作为整个日志序列的校验值,存储在文件开头,用于崩溃恢复期间检查文件是否完整

位置 字节数 含义
0-3 4B xChecksum 全局校验
4+ 多条日志 每条日志如下结构:[Size][Checksum][Data]

LoggerImpl:核心实现类

Logger:接口 + 工厂方法

1
LogEntry`:封装完整日志条目 `[fullBytes, data]

init方法:

1
2
3
4
5
6
ByteBuffer headerBuf = ByteBuffer.allocate(HEADER_SIZE);
fc.position(0);
fc.read(headerBuf);
this.xChecksum = Parser.parseInt(headerBuf.array());
validateAndTruncateTail();

先读取 xChecksum,然后进入日志校验逻辑 validateAndTruncateTail()

日志校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
rewind();
int calcXCheck = 0;
long validEnd = HEADER_SIZE;

while (true) {
LogEntry entry = readNextLog();
if (entry == null) break;
calcXCheck = calChecksum(calcXCheck, entry.fullBytes);
validEnd += entry.fullBytes.length;
}
if (calcXCheck != xChecksum) {
Panic.panic(Error.BadLogFileException);
}
truncate(validEnd);


文件头部开始读取每条日志。

校验每条日志的 checksum 是否匹配。

若都匹配,则将尾部位置 validEnd 更新为新的合法文件末尾

如果算出的 calcXCheck 不一致,说明日志文件可能被破坏,抛出异常。否则,截断非法尾部

log写入:

1
2
3
4
5
byte[] logEntry = wrapLog(data);
fc.position(fc.size());
fc.write(ByteBuffer.wrap(logEntry));
updateXChecksum(logEntry);

先计算 data 的 checksum

size + checksum + data 拼接为一条日志

将新的 xChecksum 写入文件头部,用于下次校验。

读取逻辑:

先读 [size][checksum] 共 8 字节

再根据 size 读取 data,并进行校验

如果不匹配说明日志损坏,返回 null。

update校验日志:

设置文件取到文件头,然后写入新的校验和,调用策略接口,执行具体的策略,增加了提示,累计写入数据

实现了刷盘,采用策略类,实现了同步刷盘,定时异步刷盘,和不刷盘。根据不同的情况进行选择

用来处理不同的情况。

定时大小双阈值异步刷盘:

满足任一条件即可刷盘,提升灵活性与性能保障

多次 flush() 只设置标志,合并后统一刷盘

使用原子变量 + synchronized 保证数据一致性

可通过 shutdown() 停止任务,或动态调整周期

可通过参数设定周期,如每 100ms 刷一次

最长可能延迟 flushIntervalMillis,适合非关键日志

索引模块

node节点类:

1
2
3
4
5
6
7
8
9
10
11
12
// B+树节点结构
// [LeafFlag][KeyNumber][SiblingUid] 是头部信息
// 后面是 [Son0][Key0][Son1][Key1]...[SonN][KeyN]
// 每个 son 是一个子节点 UID;key 是关键字。
static final int IS_LEAF_OFFSET = 0; // 是否为叶子节点(1字节)
static final int NO_KEYS_OFFSET = IS_LEAF_OFFSET+1; // 当前 key 数量(2字节)
static final int SIBLING_OFFSET = NO_KEYS_OFFSET+2; // 兄弟节点 UID(8字节)
static final int NODE_HEADER_SIZE = SIBLING_OFFSET+8;

static final int BALANCE_NUMBER = 32; // 节点最多存储 64 个 key
static final int NODE_SIZE = NODE_HEADER_SIZE + (2*8)*(BALANCE_NUMBER*2+2); // 最大容量

  • setRawIsLeaf() / getRawIfLeaf(): 设置/获取是否为叶子节点
  • setRawNoKeys() / getRawNoKeys(): 设置/获取 key 数
  • setRawSibling() / getRawSibling(): 设置/获取兄弟节点 UID
  • setRawKthSon() / getRawKthSon(): 设置/获取第 k 个子节点 UID
  • setRawKthKey() / getRawKthKey(): 设置/获取第 k 个 key
  • shiftRawKth(): 从第 k 位右移 key/son,为插入腾位置
  • copyRawFromKth(): 将后半部分节点数据拷贝出来,用于分裂
  • newRootRaw():构造新根节点(中间节点)
  • newNilRootRaw():构造空的叶子节点

读取逻辑:

boolean isLeaf(): 判断是否为叶子节点

SearchNextRes searchNext(long key): 在中间节点中查找适合 key 所在的子节点 UID,如果 key 超过最大 key,则返回 sibling 节点 UID。

LeafSearchRangeRes leafSearchRange(long leftKey, long rightKey):

  • 仅在叶子节点中执行,从 leftKey 找到 rightKey 范围内的所有 UID。
  • 如果未覆盖完全,返回 siblingUid 供上层继续搜索。

插入分裂逻辑:

InsertAndSplitRes insertAndSplit(long uid, long key):

  • 调用 insert() 插入一个键值对 [key, uid]
  • 若插入后 key 数超限,调用 split() 分裂节点

内部插入逻辑,根据是叶子节点或内部节点采取不同插入策略:

  • 叶子节点插入:直接插入 [key, uid]
  • 中间节点插入:先保存原 key,覆盖当前 key,再右移一位插入 [uid, oldKey]

节点分裂操作:

  • 创建新节点并复制后半部分 key/son
  • 新节点插入 DM 中生成新 UID
  • 当前节点更新 siblingUid 指向新节点
  • 返回分裂后新节点和新 key(新 key 是新节点的最小 key

缓存:

实现了LRU缓存策略,简单的hashmap+sychronized实现

和concurrenthashmap+readwirtelock实现

B+tree索引实现:

新增 NodeCache 来缓存热点节点,减少频繁磁盘读。

BPlusTree 里新增 loadNode 方法,用缓存优先加载节点。

联合索引:

问题文档

1.”bug”(sql解释器):修复了sql语句会有<<的问题

运行代码:

1
2
3
mvn exec:java --% -Dexec.mainClass=com.mengnankk.mydatabase.backend.Launcher -Dexec.args="-create ./data"
mvn exec:java --% -Dexec.mainClass=com.mengnankk.mydatabase.backend.Launcher -Dexec.args="-open ./data"
mvn exec:java --% -Dexec.mainClass=com.mengnankk.mydatabase.client.Launcher -Dexec.args="./data"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 ikeife    MYDB   master ≡  ~1     1.602s⠀   mvn exec:java --% -Dexec.mainClass=top.guoziyang.mydb.client.Launcher -Dexec.args="./data"
[INFO] Scanning for projects...
[INFO]
[INFO] -------------------------< top.guoziyang:MyDB >-------------------------
[INFO] Building MyDB 1.0-SNAPSHOT
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- exec:3.5.1:java (default-cli) @ MyDB ---
:> create table test1
Invalid command!
:> create table test1;
Invalid statement: create table test1<< ;
:> create table test1 (id int, name varchar);
Invalid statement: create table test1 (id << int, name varchar);
:> create table test1 (id int);
Invalid statement: create table test1 (id << int);
:> show tables;
Invalid statement: show tables<< ;
:>

运行之后这个发现总是多了个<<感觉应该是命令行接收输入或者读取输入流的方式出现了问题。

第一步:修复SQL解析器(Parser.java)

  • 修改了isType方法,使其能识别标准SQL类型

  • 新增了combineType方法,用于处理带括号的类型声明(如varchar(20))

  • 修改了parseCreate方法,改进了类型声明的处理逻辑

  • 使索引定义成为可选项,不再强制要求必须有索引

第二步:改进字段类型处理(Field.java)

  • 添加了convertType方法,实现SQL类型到内部类型的映射:

  • 改进了typeCheck方法,增加了类型转换步骤

  • 修改了Field构造函数,在构造时就进行类型转换

主要解决的问题

  • 支持了标准SQL语法

  • 处理了带长度的类型声明(如varchar(20))

  • 实现了SQL类型到内部类型的自动转换

  • 修复了索引相关的语法解析

最终效果

现在可以使用标准SQL语句创建表,也可以不带索引的创建sql语句

2.”feat”(Server): 替换成了NIO,单线程实现IO多路复用

问题在于客户端的Socket创建方式。在NIO模式下,我们需要使用SocketChannel来创建非阻塞的套接字。让我修改客户端的Launcher类:

  1. 使用SocketChannel替代Socket

  2. 配置SocketChannel为非阻塞模式

  3. 使用connect和finishConnect方法来建立连接

  4. 添加了连接等待逻辑,因为非阻塞模式下连接可能不会立即完成

  5. 更新了错误处理逻辑

3.”feat”(Encoder):使用中文的话,使用UTF-8编码

  1. 字符串编码问题
  • 问题表现:中文字符显示为乱码(”锟斤拷”)

  • 原因:数据库在处理字符串时没有统一使用UTF-8编码

解决方案:

在Parser类中修改字符串处理:

  • 使用StandardCharsets.UTF_8进行字符串编码和解码

  • 修改string2Byte和parseString方法,确保正确处理UTF-8编码

在DataManagerImpl类中修改数据存储:

  • 在insert方法中确保数据使用UTF-8编码

  • 在存储前将数据转换为UTF-8编码的字符串

在Table类中修改数据读取和显示:

  • 在parseEntry方法中确保字符串使用UTF-8编码

  • 在entry2Raw方法中确保字符串使用UTF-8编码

  • 在printEntry方法中确保字符串使用UTF-8编码

Tokenizer访问权限问题

  • 问题表现:Parser类无法访问Tokenizer类的方法

  • 原因:Tokenizer类中的方法访问权限设置不正确

解决方案:

  • 将Tokenizer类中的peek和pop方法改为public

  • 添加了isAlphaBeta和isDigit静态方法

  • 修复了字符串解析中的边界条件问题

数据存储问题

  • 问题表现:数据存储后读取出现异常

  • 原因:数据存储和读取的编码不一致

  • 解决方案:

  • 统一使用UTF-8编码进行数据存储和读取

  • 在数据转换过程中保持编码一致性

  • 确保数据在存储前进行正确的编码转换

主要修改点

  • 添加了StandardCharsets.UTF_8的导入

  • 在所有涉及字符串操作的地方统一使用UTF-8编码

  • 在数据存储、读取和显示三个环节都进行了编码处理

  • 确保字符串在转换过程中不会丢失编码信息

  • 修改了类的访问权限,确保正确的封装性

  • 修复了字符串解析的边界条件问题

经验教训

  • 在处理多语言(特别是中文)时,必须统一使用UTF-8编码

  • 需要在数据处理的各个环节(存储、读取、显示)都进行编码处理

  • 字符串编码问题往往表现为乱码,需要从数据流转的各个环节排查

  • 在数据库系统中,编码问题需要从底层(存储)到上层(显示)都进行统一处理

  • 类的访问权限设计需要仔细考虑,确保正确的封装性

  • 字符串处理时需要注意边界条件

4.”feat”(bug):异常:

1.空指针:

AbstractCache.release(uid) 里从某个 Map 结构中 get(uid) 后没有判空,直接 .intValue() 了。

加上

1
2
3
4
5
6
Integer ref = references.get(uid);
if(ref == null) {
throw new IllegalStateException("Release called on unknown UID: " + uid);
}
ref = ref - 1;

使用 Map.computeIfPresent() 来减少并发风险,或者:

1
2
3
4
5
6
7
references.compute(uid, (k, v) -> {
if (v == null) {
throw new IllegalStateException("Release called on unknown UID: " + uid);
}
return v - 1;
});

2.NegativeArraySizeException: -16

说明你尝试创建一个长度为 负数 的数组

数据文件未完整写入或写入错位

  • 日志校验失败后仍尝试加载,导致读取偏移错误。

日志恢复不完整或逻辑错误

  • 如果你的日志采用 [Size][Checksum][Data] 格式,可能在读取 Size 时读取了非法值(如内存残留数据)。

Parser.parseSize(raw) 实现错误

  • 可能字节序错、偏移错、字段解码逻辑错。

添加 size 合法性检查

那么 Size 是前 4 个字节,你恢复时必须按顺序读

检查磁盘是否已被写入错位内容,加了边界校验代码。

3.数据库文件未正确关闭,导致恢复后残留了无效 Entry