
简介一款基于Java与HTML的简易数据库系统源码面向数据库初学者及轻量级应用需求实现了连接、查询、更新等基础数据库管理操作。压缩包共29个文件包含13个Java源文件、11个XML配置文件、1个HTML文件、1个SQL脚本及1个IDEA工程文件等整体大小仅181KB结构清晰。源码中Java部分涵盖缓冲管理、索引管理、目录管理、文件与页面处理等核心模块可借此了解数据库系统分层设计XML文件统一管理连接参数与配置HTML负责搭建简易Web操作界面并支持通过IntelliJ IDEA直接加载工程。从源码中还可以学习前后端交互方式以及各模块之间的调用逻辑帮助巩固数据库原理基础配套SQL脚本提供了初始表结构及测试数据readme与许可证文件辅助安装和合规使用。目前已有304人学习下载适合用于课程设计、毕业设计或作为小型应用的数据管理模块参考。1. 这个「简易数据库」不是玩具先判断它值不值得你动手如果你卡在「把 Java 和 HTML 写在一个项目里却不知道它们中间隔了什么」这一步那么「基于 Java 与 HTML 的简易数据库系统设计源码」这一标题实际上是在问一个更底层的问题离开 MySQL你自己能不能把一张表在磁盘上存下来再用浏览器把它查出来我第一次做这个方向时以为难点在前端表格渲染代码写了一半才发现真正卡住人的是文件格式、SQL 解析和并发写坏指针这三件事。它不是替代 MySQL 的玩具而是一套用来讲清楚「数据库系统概论」里关系、索引、事务落地的课堂源码适合课程设计、毕设选型和 Java 初中级开发者补底层认知。能收获的不是又会调一个 ORM而是把一个你以为只有大厂才能做的系统拆到千行以内就能跑起来。2. 从零搭工程骨架:Java 后端和 HTML 前端的最小分工2.1 为什么不建议一上来就 Spring Boot:黑匣子会吃掉源码的意义很多人在 VSCode 里新建一个 Spring Boot MyBatis 项目然后发现「简易数据库系统」变成了「简化版 CRUD」。每个请求经过 DispatcherServlet、MyBatis 映射、连接池最后才碰到数据库文件——这时候你手里根本没有「自己写的数据库」只有 MySQL 的一层皮。常见做课程设计与源码练习的更稳妥路径是:服务端用 JDK 自带的com.sun.net.httpserver.HttpServer起 HTTP 服务SQL 解析、存储、执行全部自己写在core包里前端用纯 HTML CSS JavaScript 做页面不引框架。这样划分后三个角色各司其职:Java 后端:只做一件事——接收 JSON 格式的 SQL解析、执行、写盘返回统一结果。HTML 前端:提供执行 SQL 的文本框、执行按钮、结果表格、表结构展示区全部由原生 JavaScript 渲染。HTTP 层:只把浏览器发来的 SQL 字符串和数据库返回的 JSON 报文做一次转发不参与业务判断。这套分工的好处是你可以用断点调试一路追进 SQL 解析器而不是在 Spring 的 Bean 生命周期里迷路。配合 VSCode 的话后端不用打成 jar直接javacjava两步跑起来前端页面按下 F12 看 Network 面板就能看到每一次 SQL 请求的报文结构排查问题直观得多。2.2 目录设计与能跑起来的入口类我一般会把工程拆成五个包另外在web/目录下放 HTML 静态页。包名可以直接用com.minidb。下面这套结构不是唯一答案但对 1000 行左右的源码来说它能让别人一眼看出「入口在哪、扩展往哪里加」。minidb/ ├── src/main/java/com/minidb/ │ ├── Server.java // HTTP 服务入口 │ ├── http/ │ │ ├── RequestHandler.java // 解析请求、路由、返回 JSON │ │ └── JsonUtil.java // 手写 JSON 序列化/反序列化 │ ├── storage/ │ │ ├── TableMeta.java // 字段名、类型、长度等元信息 │ │ ├── PageFile.java // 表数据文件的读写 │ │ └── Database.java // 库级管理创建表、列出表 │ ├── sql/ │ │ ├── Tokenizer.java // 词法拆解 │ │ ├── Parser.java // 语法校验与语义分发 │ │ └── Executor.java // 执行增删改查、返回结果集 │ └── type/ │ ├── MiniDBException.java // 统一业务异常 │ └── Row.java // 一行的存储载体 ├── web/ │ ├── index.html // 浏览器操作台 │ └── app.js // 发送 SQL、渲染表格 └── data/ // 数据目录运行时自动创建 ├── student.tbl // 表文件头区数据区 └── minidb.meta // 库的元信息入口Server.java只负责两件事:绑定端口、把请求交给RequestHandler。为了让新手能直接断点跟踪我不会引入线程池框架直接用HttpServer.create起单线程处理等真需要并发时再在Executor里加锁。package com.minidb; import com.minidb.http.RequestHandler; import com.sun.net.httpserver.HttpServer; import java.net.InetSocketAddress; public class Server { public static void main(String[] args) throws Exception { int port 8080; if (args.length 0) { port Integer.parseInt(args[0]); } HttpServer server HttpServer.create(new InetSocketAddress(port), 0); // 静态页面直接返回 web/ 下的 HTML server.createContext(/, new RequestHandler.StaticHandler()); // SQL 执行前端把 SQL 放到请求体里发过来 server.createContext(/sql, new RequestHandler.SqlHandler()); server.setExecutor(null); // 不设线程池方便断点观察 server.start(); System.out.println(MiniDB started at http://localhost: port); } }setExecutor(null)在 JDK 的HttpServer里表示使用默认的同步执行方式每个请求处理完才轮到下一个。这样写不是为了性能而是为了让课程设计的人在断点调试时不会跳进线程池的堆栈里去。等需要压测时再换成一个固定线程池并且把写操作的锁放到Executor层。2.3 前端和后端只认一种报文浏览器不能直接执行 SQLJava 进程也不能直接操作 DOM所以两者之间必须约定一个中间语言。我这里选的是 JSON字段名固定为四个。前端和后端都只认这一种结构排查问题时只需要在浏览器开发者工具里看 Network 面板的请求和响应。// 请求体前端发送给 /sql 接口 { sql: select * from student where age 18 } // 响应体后端返回给前端 { code: 0, message: ok, columns: [id, name, age], types: [INT, VARCHAR, INT], rows: [ [1, 张三, 19], [2, 李四, 20] ], rowsAffected: 2 }设计这套报文时有三个细节容易翻车。第一columns和types必须按顺序对应否则前端把age渲染成字符串后排序会出错第二rows里的每一行没有字段名只有值数组前端必须按columns的序号去渲染不能在 Java 端把行数据变成MapString, Object因为这样做不仅慢而且键顺序会被HashMap打乱第三code非 0 时必须把服务端异常信息塞进message千万不要让前端拿到一个 500 状态码和空响应体否则用户只能靠猜来排错。后端的SqlHandler收到请求后只做一层薄处理把 SQL 字符串交给 Parser再把结果对象序列化返回。如果有人问「为什么不直接支持 GET 请求里的 SQL」答案是 GET 的 URL 长度有限制中文参数会被浏览器转码搞乱而且 SQL 会泄露在访问日志里。统一走 POST 加 JSON body是成本最低、最不容易出边界问题的方式。3. 数据落盘:选定长记录格式之前先想清这三个问题3.1 表、行、类型:数据库系统概论第一课的直接落地《数据库系统概论》课程里讲的「关系」在这套简易系统里落地就对应三层结构:一个数据库目录下有多张表每张表有固定列集合每行是列值的线性组合。听起来简单但代码实现时必须回答三个问题:字段能不能增删、行长度是否固定、删除后空间要不要立即回收。这三个问题的不同答案直接决定文件格式的编写难度。常见做法是选择「固定列集合 支持追加字段」的方案。也就是创建表时定义好列后期通过alter table可以新增列但很少删除列所有行统一按「元信息里最大列数」格式化。这样文件里的每条记录长度都可以预先算出来就可以通过「文件偏移量 表头大小 行号 × 行长度」实现随机访问不需要在每行前面存指针。这个决定的直接收益是查询第 N 行的时间复杂度是 O(1)代价是删除列时字段数据还留在文件里只能通过元信息标记隐藏。字段类型上不要贪多能覆盖课程设计和面试题的场景就够了。我常用的一套是:类型名存储长度说明INT4 字节有符号整数兼容 Java intBIGINT8 字节长整数负责 id 主键VARCHAR(n)2 n 字节变长前 2 字节记录实际长度n 为最大长度DOUBLE8 字节双精度浮点BOOL1 字节0 或 1类型表一旦确定TableMeta的职责就清晰了:保存列名数组、类型数组、每列的最大长度并提供一个getRowSize()方法让数据文件写入时知道自己要预留多少空间。别小看这个方法它是后面随机读写的基石。3.2 定长记录加删除标记:用最少的代码换最高的稳定性数据文件的布局我建议做成三块:文件头、元信息块、数据区。文件头固定 32 字节存版本号、表头偏移量、行数、删除标记数。元信息块存字段列表数据区每条记录固定长度。二进制格式写出来大约是这样的:public class PageFile { // 每条记录的结构状态标记(1) 各字段值 // 状态标记0活跃1已删除2已覆写待回收 public void writeRow(Row row) { ByteBuffer buf ByteBuffer.allocate(rowSize); buf.put((byte) 0); // 活跃标记 for (int i 0; i columns.length; i) { byte[] bytes encodeColumn(columns[i], row.get(i)); buf.put(bytes); // 定长写入 } writeAt(headerOffset rowCount * rowSize, buf.array()); } }定长记录有两个直接后果。第一读第 N 行不需要扫全表直接seek(headerOffset N * rowSize)就能读到第二删除操作的成本极低找到目标行后把状态标记改成 1再更新文件头的「行数」字段即可。很多初学源码的人容易在这步踩坑一删除就调用ByteArrayOutputStream重写整个文件数据量一大就卡顿还给游客留下「简易数据库很弱」的印象。实际上把状态位标记为删除后文件会产生「空洞」。这些空洞会拖慢全表扫描所以系统需要一个后台线程在删除占比超过 30% 时做一次压缩重写。这一步放到Database.compact()方法里做遍历旧文件把所有活跃记录搬到新文件然后原子替换文件名。public void compact() { File old tableFile; File tmp new File(tableFile.getPath() .tmp); int activeCount 0; // 读旧文件跳过删除标记为 1 的记录写入 tmp // 最后把 tmp 改名为旧文件并更新 TableMeta 的行数 if (tmp.renameTo(old)) { System.out.println(compact done, active activeCount); } }writeAt和readAt必须走 RandomAccessFile而不是 FileOutputStream因为这些方法要求定位写入。Java 新程序员经常用 FileInputStream 的read(byte[])把整个文件读进内存再操作这在 1000 行源码的阶段能跑但一旦数据文件超过几百 MB内存就崩了。定长记录配合 RandomAccessFile是公认的最稳写法。3.3 持久化与重启恢复:先写字节、再刷文件的顺序不能反数据文件写入后不是立刻落盘的字节数据会留在操作系统的页缓存里机器一断电就可能丢。简易数据库可以不实现完整 WAL(预写日志)但至少要保证两步顺序:先写数据文件再写元信息文件。如果先更新元信息文件里的行数再写行数据中途崩溃就会出现「元信息说我有 100 行但文件第 100 行还没写完」的不一致状态。public void flush() { // 真正含义把内存中未落盘的数据写回文件并调用 fsync try (FileChannel channel FileChannel.open(tableFile.toPath(), StandardOpenOption.WRITE)) { channel.force(true); } catch (IOException e) { throw new MiniDBException(数据落盘失败, e); } }FileChannel.force(true)对应 Linux 上的 fsync它会强制要求内核把修改写入磁盘。这一步不能省否则你在 Windows 上跑得好好的部署到云主机上一断电文件头明明写着行数内容却是残缺的重启后要花一个晚上找哪里对不上。我在实现里把flush的调用策略做成两种:每次 DDL(建表、加列)后立即刷盘DML(insert/update/delete)则先攒在一个后台队列里每 200 毫秒批量刷一次。前者保证结构不丢后者保证高频写入不至于慢到不可用。4. 把 SQL 从字符串变成操作:词法拆解和 where 解析的取舍4.1 关键词白名单与整句校验:SOL注入在入门项目里比想象中近SQL 解析第一步不是写一个 500 行的语法分析器而是做「整句合法校验」。常见做法是先把开头第一个单词拆出来放进一个SetString里匹配匹配不到就直接报「不支持的 SQL 类型」。这看起来像是偷懒实际是在控制解析边界——简易数据库只实现四条 DML 和几条 DDL你给它一条WITH ... SELECT它处理不了直接拒绝比报错半个钟头再抛异常友好得多。public class Tokenizer { private static final SetString DML_KEYWORDS Set.of(select, insert, update, delete); private static final SetString DDL_KEYWORDS Set.of(create, drop, alter); public static String extractFirstKeyword(String sql) { String trimmed sql.trim().toLowerCase(); int spaceIdx trimmed.indexOf( ); if (spaceIdx 0) { throw new MiniDBException(无法识别的 SQL); } String keyword trimmed.substring(0, spaceIdx); if (!DML_KEYWORDS.contains(keyword) !DDL_KEYWORDS.contains(keyword)) { throw new MiniDBException(不支持的 SQL 类型: keyword); } return keyword; } }SQL 注入在简易系统里比在 MySQL 里更容易发生因为 MySQL 有 PreparedStatement 兜底你这里所有语句都是拼好字符串后执行的。省掉 MyBatis 的利润是每一条 SQL 都能被断点盯住代价是你必须写一个「值白名单」:表名必须在Database的表名集合里列名必须在TableMeta的列名集合里如果两者对不上直接抛异常。像student; drop table teacher这种多语句注入在词法拆解时就应该发现空格后面跟的不是合法值而是又出现了一遍关键词直接报错。4.2 把 SELECT/INSERT/UPDATE/DELETE 拆成四路分支关键字提取完成后下一步是按 SQL 类型分发到四个解析方法。这一阶段容易写崩的点是:你试图先写一个通用的whereParser然后在四个方法里复用。这个想法的方向对但实施难度远超预期因为 insert 没有 where、update 和 delete 的区别只在 SET 子句、select 要处理*和列名列表。我建议先把四个分支的骨架写全再把 where 解析器放到最后写这样调试时每一步的输入输出都很直观。public QueryResult execute(String sql) { Tokenizer tokenizer new Tokenizer(sql); String keyword tokenizer.nextKeyword(); switch (keyword) { case select: return executeSelect(tokenizer); case insert: return executeInsert(tokenizer); case update: return executeUpdate(tokenizer); case delete: return executeDelete(tokenizer); default: throw new MiniDBException(未实现的分支: keyword); } } private QueryResult executeSelect(Tokenizer tk) { ListString columns new ArrayList(); String token; // 解析列名列表遇到 from 则结束 while (tk.hasNext()) { token tk.next(); if (token.equalsIgnoreCase(from)) break; columns.add(token.replace(,, )); } String tableName tk.next(); WhereCondition where new WhereParser(tk).parse(); return storageManager.select(tableName, columns, where); }这里的关键约定是 Tokenizer 不能把逗号和括号拆成独立的 token而是原样保留在列名后面用replace去掉。这样做的原因很实际:select id, name from student拆出来是id,和name,如果分词器把逗号当作分隔符拆掉id和name之间的映射关系容易错位而且一旦 where 里出现函数括号会让状态机瞬间复杂一倍。对简易系统来说保留分隔符在 token 里再二次去重反而比先拆开再判断上下文更可控。4.3 where 的等值、比较和 IN 子句:三套条件与一张评价表where 是这套源码里技术含量最高的一处。等值条件age 18拆成三元组(列名、操作符、值)容易但一旦出现age 18 AND score 60就要解决「优先级」问题。实现选择是:第一版只支持一个条件第一版跑通后第二版再在循环里把它扩展成多个条件并用AND连接。对于比较规则做一个枚举带matches方法比到处写if-else干净得多。public enum CompareOp { EQ() { public boolean matches(Object colVal, Object target) { return Objects.equals(colVal, target); } }, GT() { public boolean matches(Object colVal, Object target) { return ((Comparable) colVal).compareTo(target) 0; } }, IN(in) { public boolean matches(Object colVal, Object target) { return ((List?) target).contains(colVal); } }; private final String symbol; CompareOp(String symbol) { this.symbol symbol; } public abstract boolean matches(Object colVal, Object target); }GT和LT要求列类型必须实现Comparable所以存储层在写数据时推荐用对应 Java 类型(Integer、Double、String)而不要全存成 String否则age 18会把字符串9排在18前面查出来的结果让你怀疑人生。IN子句在解析时要读取括号里的多个值当成List传入。注意这里IN列表的每个值也要走类型转换前端传来的 JSON 数字到 Java 里是Integer如果直接与字符串比较会全部不相等。4.4 参数化限制与注入防线:把能想到的攻击路径堵死简易数据库没有预编译接口所以唯一靠谱的防线是输入校验 类型转换而不是「过滤特殊字符」。过滤名单永远能不完整比如你滤掉了单引号忘了反斜杠\就能绕过去。更稳的做法是:在解析阶段就把每个字面量通过类型转换函数parseValue(columnType, literal)变成具体 Java 对象任何转换失败都直接抛异常。public Object parseLiteral(String valueStr, String typeName) { switch (typeName) { case INT: return Integer.parseInt(valueStr.trim()); case DOUBLE: return Double.parseDouble(valueStr.trim()); case VARCHAR: // 只去掉首尾成对的单引号保留内部内容 if (valueStr.startsWith() valueStr.endsWith()) { return valueStr.substring(1, valueStr.length() - 1); } throw new MiniDBException(字符串缺少引号); default: throw new MiniDBException(未知类型: typeName); } }这样age 18会报类型错误而不是自动转换。VARCHAR字段里的值在 SQL 里必须加单引号没有引号视为未闭合字符串直接拒绝。这套规则比 MySQL 严格但对你自己的源码来说越严格意味着越少奇葩边界情况。另一个防线是禁止 SQL 中出现分号看到;就抛「多语句不允许」异常。这样delete from student; drop table teacher在词法阶段就被拦住。5. 避坑与排查:五个跑崩再回头看代码的典型坑5.1 重启后表结构消失:元信息没落盘或读错位置现象:插入了几百条数据重启进程后show tables能看到表名但select *报「列数不匹配」或直接读到乱码。原因:表文件里的数据和元信息各写各的但文件头里的「表头偏移量」在第一次建表后没有更新或者TableMeta是每次启动时临时构建的没有从文件头读取。最隐蔽的情况是:你在内存里给表加了列落盘时只写了数据区没把偏移量同步到文件头。解决:建表时先格式化一份完整的「元信息块」里面有魔数(MAGIC)、版本号、列数、每列类型和长度再把它的偏移量写入文件头。每次启动必须按「文件头→元信息块→数据区」的顺序解析。单元测试直接写一个用例:建表 → 插数据 → 重启 JVM → 查数据跑通这一步后面的大多数问题都会消失。5.2 中文全部变成问号:Java 内存里的字符串和文件字节不一致现象:insert into student(name) values(张三)执行成功但用select查出来全是??。原因:写入时用了FileWriter或getBytes()不带字符集操作系统默认编码可能是 GBK;读取时又用readLine()按平台默认编码解两边一个 GBK 一个 UTF-8中文就无法对齐。解决:全项目统一定义一个常量StandardCharsets.UTF_8作为唯一编码写入字节和解析字节都显式传这个参数。前端页面要在 HTML 的head里加meta charsetUTF-8HTTP 响应头要设置Content-Type: application/json;charsetUTF-8。排查时先看响应头里的 charset 是什么再看表文件里的二进制中文 UTF-8 一个字符通常占 3 字节如果是 2 字节说明写成了 GBK。5.3 删除大量数据后文件不瘦身:删除标记和压缩条件的平衡现象:delete from student where age 18后查行数确实变少了但磁盘上的文件大小一点没降。原因:删除标记只改了一个字节后面数据还在。这是「定长记录 删除标记」方案的必然结果不是 bug。但如果你一直不压缩删除占比高了以后全表扫描要把大量已删除记录读进来做判断性能劣化成 O(废行数)。解决:设置一个阈值当「已删除行数 / 总行数 30%」时触发compact()。压缩时把活跃行复制到新文件替换旧文件。要注意压缩期间不能有写入请求所以要在Executor里加一个ReentrantReadWriteLock,压缩时取写锁普通查询取读锁。压测时如果发现频繁压缩拖慢性能就把阈值调到 50%代价是删除后的文件瘦身变得迟钝取舍随你。5.4 前端表格显示数字精度丢失:JSON 序列化环节的锅现象:select * from student中某列 DOUBLE 值显示为19.999999999或者输入 9007199254740993 后显示 9007199254740992。原因:JavaScript 的 Number 类型是 IEEE 754 双精度超过 2^53 的整数无法精确表示。但你的 Java 后端可能在序列化时就把Double直接交给了toString浏览器端 JSON.parse 又做了一次转换精度就丢了两次。解决:序列化时对 BIGINT 和 DOUBLE 类型的值统一转成字符串并加引号。前端拿到后需要精确计算的列用字符串展示不参与前端运算;如果一定要在前端做加减再转成BigDecimal或拆成整数部分和小数部分。校验方法:插入一个9007199254740993刷新页面看返回值是不是原样字符串。5.5 where 条件里带单引号的字符串被拆断:分词器的引号状态没维护现象:select * from student where name obrien报错或者select * from student where remark its解析出来的值变成it加一堆残留。原因:SQL 字符串常量里的引号转义规则是连续两个单引号表示一个单引号。分词器如果只是split( ),遇到带空格的字符串就会断成多个 token,引号闭合判断也跟着失效。解决:分词器必须维护一个「是否在引号内」的状态。拆 token 时遇到单引号就进入字符串模式直到遇到下一个配对单引号才退出;如果连续两个单引号则输出一个转义的单引号并留在字符串内。这一步实现约二十行但它是 where 解析能正确工作的前提。失败时看 Tokenizer 的输出:打印每个 token 的起始下标和内容一眼就能看出引号状态在哪一步翻转错了。6. 验证这套系统是否真的可以投入:三把尺子量一遍源码写完不能只是「编译能过」要把「能跑」和「能扛」分清楚。我判断这个方向值不值得继续投入会拿三把尺子量它。第一把尺子是重启恢复。写一个 bash 脚本循环插入 200 条带中文和浮点数的数据然后强制 kill 进程再重启查询总数。如果行数不变、中文正常说明元信息与数据区的写入顺序是对的。for i in $(seq 1 200); do curl -s -X POST http://localhost:8080/sql \ -H Content-Type: application/json \ -d {\sql\: \insert into student(name,age,score) values(测试$i,$i,${i}.5)\} /dev/null done kill -9 $(pgrep -f com.minidb.Server) sleep 1 curl -s -X POST http://localhost:8080/sql \ -H Content-Type: application/json \ -d {sql: select count(*) from student}第二把尺子是并发写入的稳定性。用十个并发线程各插入 100 条数据全部完成后查询行数是否为 1000。如果少于这个数说明多个请求同时写文件时发生了数据互踩此时去检查 Executor 的写锁是否覆盖了「新增行 更新文件头行数」两个动作。很多简易系统的行数丢失就发生在写到数据区和更新行数之间没有原子保护。第三把尺子是功能闭环。我会拿《数据库系统概论》里最基础的几个例子去测:等值查询、范围查询、IN 子句、字符串模糊匹配(LIKE)。前三个用比较运算符和IN就能实现LIKE 需要额外实现一个%通配匹配。一个方向如果连这些基础 SQL 都跑不齐后续的索引和事务就没有讨论的底座。我习惯在完成这三把尺子后把这个简易数据库和 SQLite 做一次行为对照:相同的建表语句、相同的数据量观察自己的系统在哪些场景下结果不一致。大部分翻车都发生在类型转换规则和 where 的字符串比较规则上。对照不是要让它达到 SQLite 的能力而是让项目的边界变得清晰——哪些问题是我该去解决的哪些问题是一开始就没有必要实现的。这个取舍想清楚源码投入的方向才算真正立住。希望帮到你。本文还有配套的精品资源点击获取