我手写了一个 Java 内存数据库(四):索引引擎、SQL 解析与总结

我手写了一个 Java 内存数据库(四):索引引擎、SQL 解析与总结

前三篇把 B+ 树写清楚了。这篇把 B+ 树组装进索引引擎,加上 SQL 解析和软删除,形成完整的数据流,最后聊聊做完了回头看的心得。

一、索引引擎:Table + B+ 树怎么配合

表结构

java 复制代码
public class Table {
    private rowSet data = new rowSet();                        // 数据存储
    private List<HashMap<String, Object>> mete;               // 字段元数据
    private String name;                                       // 表名
    private HashMap<String, BPTree> index = new HashMap<>();  // 字段 → B+ 树索引
    private String sql;                                        // 加载 SQL
}

一个设计决策:一张表可以建多个 B+ 树索引,每个索引对应一个字段。 比如有 10 万行人员数据,可以在"身份证号"字段建一个 B+ 树,在"年龄"字段再建一个,互不干扰。

从关系型数据库加载

Table.init() 的功能是从传统 RDBMS 把数据拉到内存,同时建好索引:

java 复制代码
Connection conn = session.getSession().getConnection();
ResultSet result = conn.createStatement().executeQuery(sql);
ResultSetMetaData rsmd = result.getMetaData();

// 先读元数据
for (int i = 0; i < rsmd.getColumnCount(); i++) {
    HashMap<String, Object> obj1 = new HashMap<>();
    mete.add(obj1);
    obj1.put("name", rsmd.getColumnName(i + 1));
    obj1.put("type", rsmd.getColumnTypeName(i + 1));
}

// 逐行加载
while (result.next()) {
    Row row1 = new Row();
    data.append(row1);
    int size = data.getList().size() - 1;
    for (int i = 0; i < rsmd.getColumnCount(); i++) {
        String key = rsmd.getColumnName(i + 1);
        Object value = String.valueOf(result.getObject(i + 1));
        row1.put(key, value);
        
        // 有索引的字段,同步插入 B+ 树
        if (index.get(key) != null) {
            long valuekey;
            if (value instanceof Integer || value instanceof Long)
                valuekey = Long.parseLong(String.valueOf(value));
            else
                valuekey = value.hashCode();
            index.get(key).insertOrUpdate(valuekey, size, false);
        }
    }
}

这里有个关键点:B+ 树索引存的不是数据本身,而是 size(数据在 rowSet 里的下标)。 查询时先通过 B+ 树找到下标,再用下标去 rowSet 取数据。这样多个索引可以指向同一行数据,不用存多份。

索引查询

等值查询:

java 复制代码
public Object get(Object name, Object val) {
    BPTree index1 = index.get(name);
    if (index1 != null) {
        long key = (val instanceof Integer || val instanceof Long) 
            ? Long.parseLong(String.valueOf(val)) 
            : val.hashCode();
        
        ArrayList<Object> addrs = (ArrayList<Object>) index1.get(key);
        List<Object> list = new ArrayList<>();
        for (int i = 0; i < addrs.size(); i++) {
            list.add(data.get(Integer.parseInt(String.valueOf(addrs.get(i)))));
        }
        return list;
    } else {
        // 无索引:全表扫描(这个我还没实现)
    }
    return null;
}

范围查询也类似,getLessThen / getMoreThen / getMoreAndLessThen 返回下标集合,再逐个取数据。

非数字类型的索引

java 复制代码
if (value instanceof Integer || value instanceof Long)
    valuekey = Long.parseLong(String.valueOf(value));
else
    valuekey = value.hashCode();

字符串等非数字类型用 hashCode() 做 B+ 树的 key。这在数据量不大时没问题,但 hashCode 存在碰撞的可能(两个不同字符串算出同一个值)。如果要做生产级,应该换成字符串本身的比较,或者用前缀索引。这个是我后来意识到的一个隐患。


二、SQL 解析器

为什么自己搞 SQL 解析

本来想直接让调用方传 Table 名和字段名,但想着既然做数据库,至少得支持 SQL 字符串输入吧。没自己写词法分析器,用了 JSQLParser 库,它能把 SQL 文本解析成抽象语法树(AST),我再从 AST 里提取各部分:

java 复制代码
public static List<String> select_items(String sql) throws JSQLParserException {
    CCJSqlParserManager parserManager = new CCJSqlParserManager();
    Select select = (Select) parserManager.parse(new StringReader(sql));
    PlainSelect plain = (PlainSelect) select.getSelectBody();
    List<SelectItem> selectitems = plain.getSelectItems();
    List<String> str_items = new ArrayList<>();
    for (int i = 0; i < selectitems.size(); i++) {
        str_items.add(selectitems.get(i).toString());
    }
    return str_items;
}

提取了什么

SELECT: 字段列表、表名、JOIN、WHERE、GROUP BY、ORDER BY

INSERT: 表名、列名、值

UPDATE: 表名、列名、值、WHERE

举个例子:

java 复制代码
String sql = "SELECT a.aab001, b.aab002 FROM ab01 a, ab02 b WHERE a.aab001 = b.aab001";

List<String> items  = praseSql.select_items(sql);    // ["a.aab001", "b.aab002"]
List<String> tables = praseSql.select_table(sql);    // ["ab01", "ab02"]
String where        = praseSql.select_where(sql);    // "a.aab001 = b.aab001"

有了这些信息,就能知道查哪张表、用什么字段过滤、要不要走索引。不过目前 WHERE 条件的执行还是比较简单,复杂的多表 JOIN 还没实现。


三、软删除

为什么不能真删

这个坑我是实打实踩过的。一开始 delete 直接从 ArrayList 里 remove,结果删了之后后面所有元素的下标全变了------B+ 树索引里存的地址全乱了。

改成了软删除:

java 复制代码
public class Row {
    private HashMap<String, Object> data = new HashMap<>();
    private boolean isDelete = false;
    
    public Object get(String key) {
        return isDelete ? null : data.get(key);  // 标记删除后返回 null
    }
}

rowSet 层也是标记,不真删:

java 复制代码
public synchronized void delete(int i) {
    list.get(i).setDelete(true);  // 只标记
    count--;
}

后来查资料发现 MySQL InnoDB 也是这么干的------DELETE MARK 标记删除,然后 purge 线程后台清理。算是思路不谋而合。


四、数据库入口

java 复制代码
public class database {
    private static HashMap<String, Table> tables = new HashMap<>();
    
    public static int load(String name) {
        Table obj = tables.get(name);
        if (obj == null) {
            obj = new Table();
            tables.put(name, obj);
        }
        obj.init(name);
        return 0;
    }
}

很简单的单例,按表名管理所有 Table。调用 database.load("ab01") 就能把 RDBMS 里的 ab01 表拉到内存并建好索引。


五、一个细节:自定义 long[] 动态数组

java 复制代码
public class myList {
    private long[] list = new long[2];
    private int count = 0;
    
    public void add(Long val) {
        if (count + 1 > list.length) {
            long[] dest = new long[list.length + 1];  // 每次扩容 +1
            System.arraycopy(list, 0, dest, 0, list.length);
            dest[list.length] = val;
            list = dest;
        } else {
            list[count] = val;
        }
        count++;
    }
}

这个是当时为了减少 Long 对象的内存开销写的(long[]ArrayList<Long> 省一半内存)。扩容策略是 +1,不是 ArrayList 的 1.5 倍。优点是内存精确,缺点是每次扩容都触发数组拷贝。数据量已知时好用,不确定数据量时不合适。


六、整体数据流

复制代码
┌──────────────────────────────────────────────┐
│                  database                     │
│  HashMap<String, Table>                       │
│  ┌──────────────┐  ┌──────────────┐          │
│  │  Table "ab01" │  │  Table "ab02" │          │
│  │              │  │              │          │
│  │  rowSet      │  │              │          │
│  │  [Row, Row]  │  │              │          │
│  │              │  │              │          │
│  │  index:      │  │              │          │
│  │  "aab001" →  │  │              │          │
│  │   BPTree     │  │              │          │
│  │   ┌────────┐│  │              │          │
│  │   │ Node   ││  │              │          │
│  │   │ (root) ││  │              │          │
│  │   └────────┘│  │              │          │
│  └──────────────┘  └──────────────┘          │
└──────────────────────────────────────────────┘

查询:
  SQL → praseSql 解析 → Table.get(field, value)
                       → BPTree.get(key) 返回下标
                       → rowSet.get(下标) 返回 Row

七、做完了回头看

做对了的

设计 当时怎么想的
B+ 树完整实现 既然要写就写全套,插入分裂、删除合并、范围查询都做了
索引存下标 避免数据冗余,多个索引共享同一份数据
软删除 踩了真删的坑之后改的,下标不能变
预留空间 批量插入时分裂太频繁,加了 10% 预留
二分查找变体 searchless / searchmore 让范围查询在节点内也高效

做得不够的

问题 如果重做会怎么改
没有持久化 加 WAL 或定期序列化到磁盘
没有事务 引入 MVCC + undo log
非叶子节点路由线性扫描 应该也用二分
没有查询优化器 至少做个索引选择性判断
myList 扩容 +1 改成 1.5 倍几何增长
hashCode 做索引 key 有哈希冲突风险,应该用字符串比较
SQL 函数都是空实现 to_char、to_date 没来得及写

最大收获

这个项目让我真正理解了:

  • 为什么 MySQL 用 B+ 树而不是 B 树(B 树非叶子节点也存数据,一个页能放的 key 更少,树更高)
  • 为什么"最左前缀原则"对联合索引有效(B+ 树按 key 排列,跳过第一列就没法二分了)
  • 为什么索引建多了会影响写入性能(每次插入要更新所有索引的 B+ 树)
  • EXPLAIN 里的 range 类型扫描是怎么工作的

这些知识看书和博客也能知道,但手写一遍之后,感觉完全不一样。


系列回顾:

  • ([一)起因与架构\] --- 为什么做、选 B+ 树的原因、节点设计、等值查询](https://blog.csdn.net/yuhou25/article/details/160579862?spm=1011.2124.3001.6209) -[\[(二)B+ 树的插入与分裂\] --- 分裂机制、级联传播、validate 校准](https://blog.csdn.net/yuhou25/article/details/160580320?spm=1011.2124.3001.6209)


系列:我手写了一个 Java 内存数据库(共 4 篇)

相关推荐
czlczl200209256 小时前
MySQL 中为什么我们要避免“多个范围查询”
数据库·mysql
若兰幽竹6 小时前
【HCIE-openGauss数据库认证】01 准备阶段:实验环境深度剖析与搭建指南
数据库·hcie-opengauss·华为专家级认证
杨云龙UP6 小时前
Oracle 19c多租户架构下设置用户密码永不过期及登录锁定策略说明_20260430
linux·运维·服务器·数据库·oracle
Irene19916 小时前
SQL 有效性/作用域说明:会话级别、事务级别,语句级别
sql·级别
qiuyunoqy6 小时前
MySQL - 4 - mysqldump/mysqladmin/mysqlshow讲解
数据库·mysql
TO_ZRG6 小时前
Android Broadcast Receiver完全入门指南
java·后端·spring
PaperData6 小时前
2014-2026.3应届生网络招聘大数据
大数据·数据库·人工智能·数据分析·经管
Knight_AL6 小时前
使用 CyclicBarrier + 自定义线程池实现 SpringBoot 并行报表(完整性能对比)
java·spring boot·后端
数据库小学妹6 小时前
锁机制(Locking):解决数据库“死锁”与“阻塞”的终极指南
数据库·sql·mysql·性能优化·学习方法