1. 从零理解SimpleDB存储引擎架构
第一次接触数据库存储引擎的实现时,我盯着MIT6.830的Lab1文档发了半小时呆。直到把咖啡洒在键盘上才突然明白:原来数据库就像个超级文件柜,而SimpleDB要做的就是设计这个文件柜的抽屉怎么放、标签怎么贴。让我们用开超市的比喻来理解这几个核心组件:
- **Tuple(元组)**就是货架上的商品,比如"可口可乐330ml"
- TupleDesc是商品标签,标明"饮料/碳酸/330ml"这些属性
- Catalog是超市的总库存清单,记录所有货架位置
- BufferPool像售货员的手推车,临时存放要上架的商品
- HeapFile则是货架本身,决定商品具体摆放位置
我最早实现的Tuple类闹过笑话——把字段值直接存成字符串,测试时发现比较操作要遍历字符数组,性能差得离谱。后来改成Field接口体系才明白,类型系统设计要像超市商品分类一样严格:
// 正确定义Tuple字段存储 private CopyOnWriteArrayList<Field> fields;2. Tuple模块:数据库的细胞单元
2.1 元组结构设计
Tuple就像数据库的"细胞",每条记录都是它的实例。在SimpleDB中实现时,我踩过三个坑:
- 序列化问题:第一次没加serialVersionUID,数据文件升级后全部报错
- 字段匹配:setField时没检查类型兼容性,导致字符串误存入整型字段
- 空值处理:未初始化的Field应该返回null还是抛异常?
最终我的解决方案是:
public Field getField(int i) { if(fields == null || i >= fields.size()){ return null; // 允许空值访问 } return fields.get(i); }2.2 元组描述符的妙用
TupleDesc让我想起第一次装修房子时的材料清单。它用TDItem存储每个字段的类型和名称,核心是这两个数据结构:
| 字段 | 类型 | 说明 |
|---|---|---|
| fieldType | Type | INT_TYPE/STRING_TYPE |
| fieldName | String | 可选的字段名 |
合并两个TupleDesc时有个精妙设计:
public static TupleDesc merge(TupleDesc td1, TupleDesc td2) { TupleDesc result = new TupleDesc(); // 使用线程安全的CopyOnWriteArrayList result.tdItems.addAll(td1.tdItems); result.tdItems.addAll(td2.tdItems); return result; }3. Catalog:数据库的全局目录
3.1 表注册机制
Catalog的实现让我想起图书馆的管理系统。最初我用ArrayList直接存储,在多线程环境下差点翻车。后来改用ConcurrentHashMap才稳定:
// 线程安全的表存储结构 ConcurrentHashMap<Integer,Table> tableIdMap; ConcurrentHashMap<String,Integer> tableNameMap;添加表时的关键逻辑:
- 文件ID作为主键(类似ISBN号)
- 表名允许重复,后添加的会覆盖前者
- 主键字段信息可选存储
3.2 实战中的坑
有次测试时发现表名查不到,调试才发现:
- 表名大小写敏感("Users" ≠ "users")
- 空字符串是合法表名
- 通过UUID生成的匿名表名需要特殊处理
4. BufferPool:数据库的内存缓存
4.1 缓存页管理
BufferPool就像CPU的L1缓存,我用固定大小的HashMap实现基础版:
private final Map<PageId, Page> bufferPool = new ConcurrentHashMap<>();几个关键参数:
- DEFAULT_PAGES = 50(默认页数)
- DEFAULT_PAGE_SIZE = 4096(4KB/页)
- 超过容量时简单抛异常(Lab1暂不实现淘汰算法)
4.2 取页流程
当查询需要某页数据时:
- 检查缓存是否存在
- 不存在则通过Catalog找到对应文件
- 调用文件类的readPage读取磁盘
- 放入缓存并返回
public Page getPage(TransactionId tid, PageId pid, Permissions perm) { if(!bufferPool.containsKey(pid)){ DbFile file = Database.getCatalog().getDatabaseFile(pid.getTableId()); Page page = file.readPage(pid); bufferPool.put(pid,page); } return bufferPool.get(pid); }5. HeapFile:堆文件存储实现
5.1 页式存储设计
HeapFile让我想起老式磁带——要找某首歌得快进到特定位置。计算页偏移量的公式是:
offset = pageNumber * BufferPool.PAGE_SIZE读取页数据的关键代码:
RandomAccessFile raf = new RandomAccessFile(f,"r"); byte[] bytes = new byte[BufferPool.getPageSize()]; raf.seek(offset); int read = raf.read(bytes,0,BufferPool.getPageSize());5.2 迭代器模式
实现DbFileIterator时,我最初犯了个错误——一次性加载所有页到内存。正确做法应该像流水线作业:
- 按需加载单页数据
- 当前页遍历完再加载下一页
- 维护当前页位置状态
public boolean hasNext() throws DbException { if(currentPageIterator == null) return false; if(currentPageIterator.hasNext()) return true; if(nextPage() != null) return true; return false; }6. 从理论到实践的跨越
完成HeapPage测试的那天,我对着控制台输出的十六进制数据发了半天呆。突然意识到这些密密麻麻的字节就是数据库最原始的样貌——没有花哨的界面,只有精准的位操作。比如计算槽位是否使用的代码:
public boolean isSlotUsed(int i) { int headerIndex = i / 8; int bitPosition = i % 8; return (header[headerIndex] & (1 << bitPosition)) != 0; }这种底层实现让我真正理解了《MySQL是怎样运行的》中说的"页是InnoDB管理存储空间的基本单位"。SimpleDB用4096字节的页大小,而InnoDB默认16KB,但设计思想异曲同工。
在实现SeqScan算子时,我特意加了个小功能——字段名前缀处理。这让我明白SQL查询中"table.column"的解析原理:
public TupleDesc getTupleDesc() { String[] newNames = new String[originalDesc.numFields()]; for(int i=0; i<originalDesc.numFields(); i++){ newNames[i] = tableAlias + "." + originalDesc.getFieldName(i); } return new TupleDesc(originalDesc.getFieldTypes(), newNames); }记得第一次成功运行完整查询流程时,控制台打印出的结果让我想起小时候第一次点亮电子管的喜悦。从Tuple到HeapFile,每个模块就像拼图碎片,最终组成完整的画面。这种亲手构建系统的体验,是任何理论讲解都无法替代的。