Mydb自定义数据库

Mydb自定义数据库
mengnankkzhou架构
项目分为四个主要模块:
- 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 | ByteBuffer headerBuf = ByteBuffer.allocate(HEADER_SIZE); |
先读取 xChecksum,然后进入日志校验逻辑 validateAndTruncateTail()。
日志校验:
1 | rewind(); |
文件头部开始读取每条日志。
校验每条日志的 checksum 是否匹配。
若都匹配,则将尾部位置 validEnd 更新为新的合法文件末尾
如果算出的 calcXCheck 不一致,说明日志文件可能被破坏,抛出异常。否则,截断非法尾部。
log写入:
1 | byte[] logEntry = wrapLog(data); |
先计算 data 的 checksum
用 size + checksum + data 拼接为一条日志
将新的 xChecksum 写入文件头部,用于下次校验。
读取逻辑:
先读 [size][checksum] 共 8 字节
再根据 size 读取 data,并进行校验
如果不匹配说明日志损坏,返回 null。
update校验日志:
设置文件取到文件头,然后写入新的校验和,调用策略接口,执行具体的策略,增加了提示,累计写入数据
实现了刷盘,采用策略类,实现了同步刷盘,定时异步刷盘,和不刷盘。根据不同的情况进行选择
用来处理不同的情况。
定时大小双阈值异步刷盘:
满足任一条件即可刷盘,提升灵活性与性能保障
多次 flush() 只设置标志,合并后统一刷盘
使用原子变量 + synchronized 保证数据一致性
可通过 shutdown() 停止任务,或动态调整周期
可通过参数设定周期,如每 100ms 刷一次
最长可能延迟 flushIntervalMillis,适合非关键日志
索引模块
node节点类:
1 | // B+树节点结构 |
setRawIsLeaf()/getRawIfLeaf(): 设置/获取是否为叶子节点setRawNoKeys()/getRawNoKeys(): 设置/获取 key 数setRawSibling()/getRawSibling(): 设置/获取兄弟节点 UIDsetRawKthSon()/getRawKthSon(): 设置/获取第 k 个子节点 UIDsetRawKthKey()/getRawKthKey(): 设置/获取第 k 个 keyshiftRawKth(): 从第 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 | mvn exec:java --% -Dexec.mainClass=com.mengnankk.mydatabase.backend.Launcher -Dexec.args="-create ./data" |
1 | ikeife MYDB master ≡ ~1 1.602s⠀ mvn exec:java --% -Dexec.mainClass=top.guoziyang.mydb.client.Launcher -Dexec.args="./data" |
运行之后这个发现总是多了个<<感觉应该是命令行接收输入或者读取输入流的方式出现了问题。
第一步:修复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类:
使用SocketChannel替代Socket
配置SocketChannel为非阻塞模式
使用connect和finishConnect方法来建立连接
添加了连接等待逻辑,因为非阻塞模式下连接可能不会立即完成
更新了错误处理逻辑
3.”feat”(Encoder):使用中文的话,使用UTF-8编码
- 字符串编码问题
问题表现:中文字符显示为乱码(”锟斤拷”)
原因:数据库在处理字符串时没有统一使用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 | Integer ref = references.get(uid); |
使用 Map.computeIfPresent() 来减少并发风险,或者:
1 | references.compute(uid, (k, v) -> { |
2.NegativeArraySizeException: -16
说明你尝试创建一个长度为 负数 的数组
数据文件未完整写入或写入错位
- 日志校验失败后仍尝试加载,导致读取偏移错误。
日志恢复不完整或逻辑错误
- 如果你的日志采用
[Size][Checksum][Data]格式,可能在读取Size时读取了非法值(如内存残留数据)。
Parser.parseSize(raw) 实现错误
- 可能字节序错、偏移错、字段解码逻辑错。
添加 size 合法性检查
那么 Size 是前 4 个字节,你恢复时必须按顺序读
检查磁盘是否已被写入错位内容,加了边界校验代码。
3.数据库文件未正确关闭,导致恢复后残留了无效 Entry












