☰
Java+HTML手搓简易数据库系统:从SQL解析到存储引擎的完整实现
2026/10/10 12:22:14 网站建设 项目流程

简介:这是一份面向Java初学者与小型应用开发者的简易数据库系统设计源码,基于Java与HTML技术实现,适合需要基础数据库管理功能、又不想引入复杂框架的用户,也可作为课程设计或自学练手的参考项目。压缩包共29个文件,约181KB,其中13个Java源文件承担数据库连接、查询、更新等后端逻辑,11个XML配置文件负责连接参数与工程配置,另含1个HTML前端界面、1个SQL初始化脚本、1个说明文档及许可文件,结构紧凑、层次清晰。项目围绕缓冲管理、记录管理、索引管理与SQL解释等模块展开,配合IntelliJ IDEA工程文件,便于直接导入阅读与调试。目前已有304人学习下载,适合想理解数据库底层实现、练习Java与Web整合的读者参考借鉴。

1. 从零手搓一个简易数据库系统:Java 后端加 HTML 前端到底能跑多远

很多人第一次听到“基于 Java 与 HTML 的简易数据库系统设计源码”,脑子里冒出来的画面是拿 Spring Boot 加 MyBatis 连一个现成的 MySQL,然后写几个增删改查页面。但真正做过这类课程设计或练手项目的人会告诉你,标题里的“简易数据库系统”往往不是指“用数据库的系统”,而是指“自己实现一个数据库”。这两者差别巨大:前者你是在调 API,后者你要处理 SQL 解析、存储引擎、索引结构、事务边界,甚至要考虑 B+ 树节点怎么落盘。我见过不少同学在这个标题下翻车,原因就是把“数据库系统”理解成了“数据库应用”。这篇笔记就按我实际带人做过的路径,把 Java 做后端计算与存储、HTML 做前端交互的这套方案拆开讲清楚。适合有 Java 基础、想通过一个项目把数据结构、IO、网络编程串起来的人,也适合需要交一个能演示、能讲清楚原理的课程设计的人。读完你能判断这个方向值不值得投入,以及怎么在两周内做出一个能跑通增删改查和简单索引的版本。

2. 先定边界:简易数据库系统到底要支持哪些 SQL 与存储能力

2.1 功能边界决定代码量,别一上来就对标完整数据库

我一般会先把功能砍到最小可用集。一个能演示的简易数据库系统,至少要有四块:SQL 解析器、执行引擎、存储管理器、网络服务层。SQL 解析器负责把CREATE TABLE、INSERT、SELECT、DELETE这类语句转成内部抽象语法树;执行引擎根据语法树去调用存储层;存储管理器管数据文件、页缓存和索引;网络服务层用 Java 的 Socket 或内置 HTTP Server 把结果吐给 HTML 页面。如果你一上来就想支持 JOIN、子查询、事务隔离级别,代码量会从两千行直接飙到两万行,而且调试成本极高。常见做法是只支持单表操作,条件过滤只做等值和范围比较,排序只做单列。这样解析器规则少,执行路径短,出问题也容易定位。

2.2 存储格式选文本还是二进制,直接决定你后面调不调得动

存储层我强烈建议第一版用定长二进制记录,不要用 CSV 或 JSON。原因很简单:文本格式每次读写都要解析和序列化,字段类型一多,代码里全是split和parseInt,性能差不说,还容易在字符串转义上踩坑。定长记录的意思是,建表时确定每行占多少字节,比如id用 4 字节 int,name用 32 字节定长字符串,age用 4 字节 int,那么一行就是 40 字节。读取第 N 行时直接seek(N * 40),写入也是同理。这样实现简单,随机访问快,而且天然支持后面加页式管理。代价是字符串不能超长,但练手项目完全够用。下面是一个记录序列化的核心代码片段,我把它放在存储管理器里:

// 定长记录序列化:将一行数据按固定偏移写入 ByteBuffer public byte[] serialize(Row row) { ByteBuffer buf = ByteBuffer.allocate(RECORD_SIZE); // RECORD_SIZE 建表时算好 buf.putInt(row.getId()); // 偏移 0,4 字节 byte[] nameBytes = row.getName().getBytes(StandardCharsets.UTF_8); byte[] nameField = new byte[NAME_LEN]; // NAME_LEN 固定 32 System.arraycopy(nameBytes, 0, nameField, 0, Math.min(nameBytes.length, NAME_LEN)); buf.put(nameField); // 偏移 4,32 字节 buf.putInt(row.getAge()); // 偏移 36,4 字节 return buf.array(); }

这段代码的关键参数是RECORD_SIZE和NAME_LEN,它们在建表时根据列定义计算出来,之后所有读写都依赖这两个常量。如果你中途改了表结构,旧数据文件就废了,所以第一版不要支持ALTER TABLE。逻辑说明:ByteBuffer保证了大端序写入,跨平台一致;System.arraycopy处理短字符串补零,避免残留脏数据。失败时先看RECORD_SIZE是否和文件实际行距一致,不一致就会读出乱码。

2.3 索引先做内存哈希,别急着上 B+ 树

很多教程一上来就让你实现 B+ 树,结果光插入删除的节点分裂和合并就写了两天,最后还没调通。我的血泪经验是:第一版索引用HashMap做内存索引,键是主键值,值是记录在文件中的偏移量。启动时全表扫描一遍建索引,之后所有按主键的查询直接走哈希。这样代码不到五十行,而且能立刻看到加速效果。等主键查询跑通了,再考虑把哈希索引持久化到磁盘,或者换成 B+ 树支持范围查询。下面这段是内存索引的构建逻辑:

// 启动时构建主键内存索引:key -> 文件偏移量 public Map<Integer, Long> buildPrimaryIndex(File dataFile) throws IOException { Map<Integer, Long> index = new HashMap<>(); try (RandomAccessFile raf = new RandomAccessFile(dataFile, "r")) { long offset = 0; while (offset + RECORD_SIZE <= raf.length()) { raf.seek(offset); int id = raf.readInt(); // 读取主键 index.put(id, offset); // 记录偏移 offset += RECORD_SIZE; } } return index; }

参数说明:RECORD_SIZE必须和序列化时一致,否则偏移错位。raf.length()判断文件末尾,避免读越界。逻辑上这个索引只加速主键等值查询,范围查询仍然要全表扫描。如果你后面要支持WHERE age > 20,要么加辅助索引,要么老老实实扫全表。常见误用是把索引建在非唯一列上,导致HashMap覆盖旧值,查询结果变少。

3. 用 Java 实现 SQL 解析与执行引擎的最小闭环

3.1 词法分析只认四类 token,手写比引入 ANTLR 更快

SQL 解析听起来吓人,但如果你只支持CREATE TABLE、INSERT、SELECT、DELETE这四条语句,手写词法分析器半天就能搞定。我一般把 token 分成四类:关键字(CREATE、TABLE、INSERT、INTO、VALUES、SELECT、FROM、WHERE、DELETE)、标识符(表名、列名)、字面量(数字、字符串)、符号(逗号、括号、星号、等号)。词法分析就是从左到右扫字符串,遇到空格跳过,遇到字母就往后读直到非字母数字下划线,然后查关键字表决定是关键字还是标识符。下面是一个简化版的 tokenizer:

// 简易 SQL 词法分析:把输入串切成 token 列表 public List<Token> tokenize(String sql) { List<Token> tokens = new ArrayList<>(); int i = 0; while (i < sql.length()) { char c = sql.charAt(i); if (Character.isWhitespace(c)) { i++; continue; } if (Character.isLetter(c)) { StringBuilder sb = new StringBuilder(); while (i < sql.length() && Character.isLetterOrDigit(sql.charAt(i))) { sb.append(sql.charAt(i++)); } String word = sb.toString().toUpperCase(); if (KEYWORDS.contains(word)) { tokens.add(new Token(TokenType.KEYWORD, word)); } else { tokens.add(new Token(TokenType.IDENTIFIER, sb.toString())); } } else if (Character.isDigit(c)) { StringBuilder sb = new StringBuilder(); while (i < sql.length() && Character.isDigit(sql.charAt(i))) { sb.append(sql.charAt(i++)); } tokens.add(new Token(TokenType.NUMBER, sb.toString())); } else if (c == '\'') { // 字符串字面量,读到下一个单引号 StringBuilder sb = new StringBuilder(); i++; while (i < sql.length() && sql.charAt(i) != '\'') { sb.append(sql.charAt(i++)); } i++; // 跳过结尾单引号 tokens.add(new Token(TokenType.STRING, sb.toString())); } else { tokens.add(new Token(TokenType.SYMBOL, String.valueOf(c))); i++; } } return tokens; }

参数说明:KEYWORDS是一个Set<String>,包含所有支持的关键字。逻辑上,标识符保留原始大小写,因为表名和列名可能区分大小写;关键字统一转大写方便比较。失败时先打印 token 列表,看是不是字符串没闭合或者关键字拼错。这个 tokenizer 不支持注释和转义引号,但练手足够。

3.2 递归下降解析器:四条语句的语法规则用 switch 分发

拿到 token 列表后,解析器按顺序读 token,根据第一个关键字决定走哪条解析分支。CREATE TABLE的语法是CREATE TABLE 表名 (列名 类型, ...),INSERT是INSERT INTO 表名 VALUES (值, ...),SELECT是SELECT 列名 FROM 表名 WHERE 列名 = 值,DELETE是DELETE FROM 表名 WHERE 列名 = 值。每条分支解析完返回一个Statement对象,执行引擎根据对象类型调用对应方法。下面以INSERT为例:

// 解析 INSERT INTO table VALUES (v1, v2, ...) private InsertStatement parseInsert() { expect(TokenType.KEYWORD, "INSERT"); expect(TokenType.KEYWORD, "INTO"); String tableName = expect(TokenType.IDENTIFIER).getValue(); expect(TokenType.KEYWORD, "VALUES"); expect(TokenType.SYMBOL, "("); List<Object> values = new ArrayList<>(); while (true) { Token t = next(); if (t.getType() == TokenType.NUMBER) { values.add(Integer.parseInt(t.getValue())); } else if (t.getType() == TokenType.STRING) { values.add(t.getValue()); } else { throw new ParseException("不支持的值类型: " + t.getValue()); } Token sep = next(); if (sep.getValue().equals(")")) break; if (!sep.getValue().equals(",")) throw new ParseException("缺少逗号"); } return new InsertStatement(tableName, values); }

参数说明:expect方法在类型或值不匹配时抛异常,异常信息里带上当前 token 位置,方便定位。逻辑上,值只支持数字和字符串,和存储层的定长记录对应。如果你后面要支持NULL,需要在这里加分支,并在序列化时用特殊值标记。常见翻车点是字符串里带逗号,比如'a,b',上面的 tokenizer 会把整个a,b当一个字符串 token,所以解析器不会误切,但如果你在 tokenizer 里按逗号切就会出错。

3.3 执行引擎:把 Statement 翻译成对存储管理器的调用

执行引擎拿到InsertStatement后,先查表元数据拿到RECORD_SIZE和列偏移,然后把值序列化成字节数组,追加到数据文件末尾,同时更新内存索引。SelectStatement则先看WHERE条件是否命中主键,命中就走索引直接读偏移,否则全表扫描逐行反序列化再过滤。下面是一个全表扫描的查询实现:

// 全表扫描:逐行读取并过滤,返回匹配的行 public List<Row> scan(String tableName, Predicate<Row> filter) throws IOException { TableMeta meta = catalog.getTable(tableName); List<Row> result = new ArrayList<>(); try (RandomAccessFile raf = new RandomAccessFile(meta.getDataFile(), "r")) { long offset = 0; while (offset + meta.getRecordSize() <= raf.length()) { raf.seek(offset); byte[] buf = new byte[meta.getRecordSize()]; raf.readFully(buf); Row row = deserialize(buf, meta); // 反序列化 if (filter.test(row)) { result.add(row); } offset += meta.getRecordSize(); } } return result; }

参数说明:filter是一个Predicate<Row>,由WHERE条件生成,比如row -> row.getAge() > 20。逻辑上,readFully保证读满一条记录,避免半条记录导致反序列化错位。失败时先检查meta.getRecordSize()和文件实际行距是否一致,不一致通常是建表后改了列定义但没重建文件。这个实现每次查询都打开关闭文件,练手够用,但如果你要压测,应该把RandomAccessFile缓存起来。

4. HTML 前端与 Java 后端怎么对接:别用 WebSocket,用 HTTP 短连接

4.1 后端用内置 HttpServer 暴露 REST 接口,省掉 Tomcat

Java 自带的com.sun.net.httpserver.HttpServer足够撑起这个项目,不需要引入 Spring 或 Tomcat。你只需要注册一个/query的 handler,接收 POST 请求,body 里放 SQL 字符串,执行后把结果集转成 JSON 返回。下面是一个最小实现:

// 启动 HTTP 服务,接收 SQL 并返回 JSON 结果 public void startServer() throws IOException { HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0); server.createContext("/query", exchange -> { if (!"POST".equals(exchange.getRequestMethod())) { exchange.sendResponseHeaders(405, -1); return; } String sql = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); String json; try { Statement stmt = parser.parse(sql); List<Row> rows = engine.execute(stmt); json = toJson(rows); // 把结果集转成 JSON 数组 } catch (Exception e) { json = "{\"error\":\"" + e.getMessage() + "\"}"; } byte[] resp = json.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); exchange.sendResponseHeaders(200, resp.length); exchange.getResponseBody().write(resp); exchange.close(); }); server.start(); }

参数说明:端口 8080 可改,但注意不要和本机其他服务冲突。toJson方法需要自己实现,简单做法是遍历Row对象拼字符串,注意对字符串值做转义。逻辑上,每个请求独立执行,没有连接状态,所以不需要考虑并发写冲突。但如果你后面要支持多线程写入,需要在存储层加文件锁。常见坑是exchange.getRequestBody()只能读一次,读完后要关闭。

4.2 前端页面用原生 fetch 发 SQL,表格动态渲染结果

HTML 页面不需要框架,一个<textarea>输入 SQL,一个<button>触发请求,一个<table>展示结果。下面是一个可复制的页面片段:

<!-- 简易 SQL 控制台:输入 SQL,点击执行,下方表格展示结果 --> <textarea id="sqlInput" rows="4" cols="60">SELECT * FROM student</textarea> <button onclick="runSql()">执行</button> <table id="resultTable" border="1"></table> <script> async function runSql() { const sql = document.getElementById('sqlInput').value; const resp = await fetch('/query', { method: 'POST', body: sql }); const data = await resp.json(); const table = document.getElementById('resultTable'); table.innerHTML = ''; if (data.error) { table.innerHTML = '<tr><td>' + data.error + '</td></tr>'; return; } if (data.length === 0) { table.innerHTML = '<tr><td>无结果</td></tr>'; return; } // 表头 const header = Object.keys(data[0]); let html = '<tr>' + header.map(h => '<th>' + h + '</th>').join('') + '</tr>'; // 数据行 for (const row of data) { html += '<tr>' + header.map(h => '<td>' + row[h] + '</td>').join('') + '</tr>'; } table.innerHTML = html; } </script>

参数说明:fetch的body直接传字符串,后端按 UTF-8 解析。逻辑上,结果集是 JSON 数组,每个对象对应一行,键是列名。失败时先看浏览器控制台有没有跨域报错,因为前后端如果不同端口,需要后端加Access-Control-Allow-Origin头。这个页面没有做 SQL 注入防护,因为本来就是给用户直接执行 SQL 的,但如果你要部署到公网,必须加白名单或只读账号。

4.3 前后端联调时先跑通一条 INSERT 再跑 SELECT

我一般会按这个顺序验证:先在后端用curl发一条INSERT,看数据文件有没有变长;再发SELECT,看返回的 JSON 对不对;最后打开 HTML 页面,在 textarea 里输入同样的 SQL,看表格有没有渲染。如果INSERT成功但SELECT查不到,先检查内存索引是不是没更新,或者文件偏移量算错了。如果 HTML 页面报Failed to fetch,先看后端服务有没有启动,端口是不是 8080。联调阶段不要同时改前后端,一次只动一边,否则出问题不知道是谁的锅。

5. 避坑与排查:简易数据库系统最容易翻车的五个地方

5.1 现象:插入中文后查询显示乱码。原因:序列化时用了平台默认字符集。解决:所有getBytes和new String都显式指定StandardCharsets.UTF_8,包括 HTTP 响应头和文件读写。

5.2 现象:查询结果比预期少几行。原因:内存索引在删除后没有移除对应键,或者HashMap被非唯一列覆盖。解决:删除时同步index.remove(id),并且只对主键建索引。如果主键不唯一,建表时就要加唯一约束。

5.3 现象:程序跑一段时间后报OutOfMemoryError。原因:每次查询都new RandomAccessFile且没有关闭,文件句柄泄漏。解决:用 try-with-resources 包住,或者把RandomAccessFile缓存在TableMeta里复用。但复用要注意多线程同步。

5.4 现象:SELECT * FROM table WHERE id = 1返回空,但数据明明存在。原因:WHERE条件解析时把数字当成了字符串,或者索引查到的偏移量指向了已删除的记录。解决:在解析器里打印条件表达式的类型,确认数字被正确解析为Integer;删除记录时不要物理删除,而是标记删除位,查询时跳过。

5.5 现象:前端表格只显示第一行。原因:toJson方法在拼接字符串时漏了逗号,或者 JSON 数组没有闭合。解决:用StringBuilder逐行拼接,每行后面加逗号,最后去掉末尾逗号再补]。更稳妥的做法是引入一个轻量 JSON 库,但练手项目手写也能过。

6. 进阶技巧:把内存索引换成磁盘 B+ 树之前先做这三件事

如果你已经跑通了上面的版本,想继续深入,我建议不要直接上手写 B+ 树。先做三件事:第一,把数据文件按页管理,每页固定 4KB,记录不再跨页,这样后面加页缓存和淘汰策略才有意义。第二,给SELECT加上EXPLAIN命令,输出它走了索引还是全表扫描,扫描了多少行,这样你能量化索引的收益。第三,写一个简单的压测脚本,用ExecutorService起 10 个线程并发插入一万条记录,观察文件锁竞争和索引一致性。这三件事做完,你对存储引擎的理解会比直接抄 B+ 树代码深得多。下面是一个页式存储的元数据定义,你可以先从这个结构开始改:

// 页式存储元数据:每页 4096 字节,记录不跨页 public class PageMeta { public static final int PAGE_SIZE = 4096; private int pageId; private int freeSpaceOffset; // 页内空闲区域起始偏移 private int recordCount; // 本页记录数 // 页头占 12 字节,剩余空间存记录 }

参数说明:PAGE_SIZE一旦确定就不要改,因为所有页偏移都基于它计算。freeSpaceOffset初始为页头大小,每插入一条记录就增加RECORD_SIZE。逻辑上,当freeSpaceOffset + RECORD_SIZE > PAGE_SIZE时,当前页写满,需要分配新页。失败时先检查页头有没有被覆盖,常见原因是记录序列化时越界写入了页头区域。

我自己的习惯是,每加一个功能之前先写一个能复现问题的最小测试,比如“插入两条记录后删除第一条,再查询第二条”,跑通了再合并到主分支。这个项目最大的价值不是做出一个能用的数据库,而是让你亲手摸到 SQL 从字符串变成磁盘字节的完整链路。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询