
简介这份资源是一套基于Java与HTML实现的简易数据库系统设计源码面向具备一定Java基础、希望理解数据库底层原理或完成课程设计的开发者与在校学生。项目围绕数据库管理的基础功能展开涵盖连接、查询、更新等后端逻辑并通过HTML构建前端交互界面适合作为学习数据库系统实现的入门参考。压缩包共29个文件约181KB其中13个Java源文件承担核心逻辑11个XML配置文件负责数据库连接与项目参数设置另含1个SQL脚本用于初始化数据结构、1个HTML页面、1个说明文档及许可文件整体结构清晰、便于按模块阅读。目前已有304人学习下载。读者可从中获取一套可运行的数据库系统骨架理解Java与HTML协作方式并借助SQL脚本快速搭建测试环境适合作为课程设计或小型应用开发的参考模板。1. 从零手写一个简易数据库系统为什么 Java HTML 是教学与原型的最佳组合很多人第一次听到「基于 Java 与 HTML 的简易数据库系统设计源码」脑子里冒出来的第一个疑问是现在 MySQL、PostgreSQL 都这么成熟了为什么还要自己写一个答案其实很直接——你不是为了替代它们而是为了搞懂它们。当你亲手实现过一个能建表、能插入、能查询、能通过浏览器操作的小型数据库系统之后再回头看那些生产级数据库的文档很多设计决策会突然变得「理所当然」。这个方向特别适合三类人一是计算机专业的学生需要一份能讲清楚原理又跑得起来的课程设计二是想从前端或业务开发转向基础架构的工程师需要一个可控的练手项目三是需要在内网或嵌入式场景里塞一个极轻量数据管理能力的开发者不想引入完整数据库依赖。Java 负责存储引擎、SQL 解析和网络服务HTML 负责把操作界面搬到浏览器上两者结合刚好覆盖「后端逻辑 前端交互」的完整链路而且技术栈足够通用不挑环境。2. 存储引擎怎么选从内存表到文件持久化的三条路线2.1 三种存储方案的取舍逻辑在动手写代码之前必须先决定数据放在哪里。常见做法有三种纯内存、纯文件、内存索引加文件持久化。纯内存方案最简单用一个HashMap就能搞定但进程一退出数据就没了只适合做演示。纯文件方案每次读写都直接操作磁盘实现直观但性能差并发一上来就崩。我一般会推荐第三条路内存里维护一份索引和热数据磁盘上用追加写的方式记录变更启动时回放日志重建内存状态。这样做的好处是查询快、持久化可靠而且追加写天然比随机写更友好。下面是一个最简化的存储引擎骨架用 Java 实现核心思路是「内存表 追加日志」import java.io.*; import java.util.*; import java.util.concurrent.ConcurrentHashMap; public class SimpleStorageEngine { // 内存中的表数据表名 - (主键 - 行数据) private final MapString, MapString, MapString, String tables new ConcurrentHashMap(); // 追加日志文件 private final File logFile; private BufferedWriter logWriter; public SimpleStorageEngine(String dataDir) throws IOException { this.logFile new File(dataDir, wal.log); if (!logFile.exists()) { logFile.getParentFile().mkdirs(); logFile.createNewFile(); } this.logWriter new BufferedWriter(new FileWriter(logFile, true)); replayLog(); // 启动时回放日志 } // 写入一行数据同时写内存和日志 public synchronized void put(String table, String pk, MapString, String row) throws IOException { tables.computeIfAbsent(table, k - new ConcurrentHashMap()).put(pk, row); // 日志格式PUT|表名|主键|k1v1,k2v2 StringBuilder sb new StringBuilder(PUT|).append(table).append(|).append(pk).append(|); row.forEach((k, v) - sb.append(k).append().append(v).append(,)); logWriter.write(sb.toString()); logWriter.newLine(); logWriter.flush(); } public MapString, String get(String table, String pk) { MapString, String t tables.get(table); return t null ? null : t.get(pk); } public CollectionMapString, String scan(String table) { MapString, MapString, String t tables.get(table); return t null ? Collections.emptyList() : t.values(); } // 启动时回放日志重建内存状态 private void replayLog() throws IOException { if (logFile.length() 0) return; try (BufferedReader reader new BufferedReader(new FileReader(logFile))) { String line; while ((line reader.readLine()) ! null) { String[] parts line.split(\\|, 4); if (parts.length 4 || !PUT.equals(parts[0])) continue; String table parts[1]; String pk parts[2]; MapString, String row new HashMap(); for (String kv : parts[3].split(,)) { String[] pair kv.split(, 2); if (pair.length 2) row.put(pair[0], pair[1]); } tables.computeIfAbsent(table, k - new ConcurrentHashMap()).put(pk, row); } } } public void close() throws IOException { logWriter.close(); } }这段代码里几个关键点值得展开说。ConcurrentHashMap保证多线程读写的安全性虽然写操作加了synchronized但读操作完全不受影响。日志格式用|和做分隔符简单但够用解析时注意split的 limit 参数要设对否则字段里的分隔符会把内容切碎。replayLog在构造时调用意味着每次重启都会把历史操作重新执行一遍数据量大了会慢生产级做法是定期做快照加增量日志但教学项目里全量回放完全够用。2.2 表结构定义与元数据管理存储引擎只管「存」但数据库还需要知道「表长什么样」。元数据管理就是记录每张表有哪些列、每列什么类型、主键是谁。最省事的做法是用一个特殊的表来存元数据比如叫__meta__里面每行描述一张用户表。这样元数据本身也走同一套存储逻辑不需要额外维护文件格式。public class MetaManager { private final SimpleStorageEngine engine; private static final String META_TABLE __meta__; public MetaManager(SimpleStorageEngine engine) { this.engine engine; } // 建表记录表名和列定义 public void createTable(String tableName, ListString columns, String primaryKey) throws IOException { MapString, String meta new HashMap(); meta.put(columns, String.join(,, columns)); meta.put(pk, primaryKey); engine.put(META_TABLE, tableName, meta); } public ListString getColumns(String tableName) { MapString, String meta engine.get(META_TABLE, tableName); if (meta null) return Collections.emptyList(); return Arrays.asList(meta.get(columns).split(,)); } public String getPrimaryKey(String tableName) { MapString, String meta engine.get(META_TABLE, tableName); return meta null ? null : meta.get(pk); } }这里有个容易翻车的点元数据表的表名如果和用户表重名就乱了。我一般会加一个前缀或者用双下划线开头并且在建表时校验表名不能以这个前缀开头。另外列定义目前只存了列名没有存类型因为简易系统里所有值都当字符串处理类型校验放在 SQL 解析层做存储层不关心。2.3 并发写入时的锁粒度控制多个浏览器同时提交插入请求时如果整个引擎一把大锁吞吐量会很难看。上面的put方法用了synchronized锁的是整个引擎实例。更细的做法是按表加锁不同表的写入互不阻塞。实现上可以用ConcurrentHashMapString, Object存每张表的锁对象写之前先拿到对应表的锁。private final MapString, Object tableLocks new ConcurrentHashMap(); public void putWithTableLock(String table, String pk, MapString, String row) throws IOException { Object lock tableLocks.computeIfAbsent(table, k - new Object()); synchronized (lock) { tables.computeIfAbsent(table, k - new ConcurrentHashMap()).put(pk, row); // 写日志... } }锁粒度细了但日志写入仍然是全局串行的因为多个线程同时写同一个文件会交错。解决办法是每个表一个日志文件或者用带缓冲的队列把日志写入串行化。教学项目里按表分文件最直观排查问题时也方便。3. SQL 解析与执行把一句 SELECT 拆成可执行步骤3.1 词法分析与语法树构建用户在前端输入SELECT name, age FROM users WHERE age 18后端不能直接拿字符串去匹配得先拆成 token再按语法规则组装成树。简易系统不需要支持完整 SQL 标准覆盖CREATE TABLE、INSERT、SELECT、DELETE四种语句就能撑起一个可用的演示。public class SimpleSqlParser { // 把 SQL 拆成 token 列表 public ListString tokenize(String sql) { ListString tokens new ArrayList(); StringBuilder current new StringBuilder(); for (char c : sql.toCharArray()) { if (Character.isWhitespace(c) || c , || c ( || c ) || c || c || c ) { if (current.length() 0) { tokens.add(current.toString()); current.setLength(0); } if (!Character.isWhitespace(c)) tokens.add(String.valueOf(c)); } else { current.append(c); } } if (current.length() 0) tokens.add(current.toString()); return tokens; } // 解析 SELECT 语句 public SelectStatement parseSelect(ListString tokens) { // 期望格式SELECT col1,col2 FROM table WHERE col op value SelectStatement stmt new SelectStatement(); int i 1; // 跳过 SELECT ListString cols new ArrayList(); while (!FROM.equalsIgnoreCase(tokens.get(i))) { if (!,.equals(tokens.get(i))) cols.add(tokens.get(i)); i; } stmt.columns cols; i; // 跳过 FROM stmt.table tokens.get(i); if (i tokens.size() WHERE.equalsIgnoreCase(tokens.get(i))) { i; stmt.whereColumn tokens.get(i); stmt.whereOp tokens.get(i); stmt.whereValue tokens.get(i); } return stmt; } }tokenize里把逗号和括号也当成独立 token这样解析时不用再处理粘连的情况。parseSelect假设了固定的词序没有做错误恢复遇到不合法的 SQL 会直接抛异常。教学项目里这样写没问题但至少要在最外层捕获异常并返回有意义的错误信息否则前端只能看到 500。3.2 条件过滤与结果集组装拿到语法树之后执行阶段就是遍历内存表、按条件过滤、按列投影。注意WHERE条件的值目前都是字符串比较如果列是数字类型得先转换再比否则9 18会返回 true这是经典翻车点。public ListMapString, String executeSelect(SelectStatement stmt, SimpleStorageEngine engine) { ListMapString, String results new ArrayList(); for (MapString, String row : engine.scan(stmt.table)) { if (stmt.whereColumn ! null) { String actual row.get(stmt.whereColumn); if (!matchCondition(actual, stmt.whereOp, stmt.whereValue)) continue; } MapString, String projected new LinkedHashMap(); for (String col : stmt.columns) { if (*.equals(col)) { projected.putAll(row); break; } projected.put(col, row.getOrDefault(col, )); } results.add(projected); } return results; } private boolean matchCondition(String actual, String op, String expected) { if (actual null) return false; // 尝试按数字比较失败则按字符串比较 try { double a Double.parseDouble(actual); double e Double.parseDouble(expected); switch (op) { case : return a e; case : return a e; case : return a e; default: return false; } } catch (NumberFormatException ex) { switch (op) { case : return actual.equals(expected); case : return actual.compareTo(expected) 0; case : return actual.compareTo(expected) 0; default: return false; } } }matchCondition先尝试数字比较失败再退回字符串比较这样age 18和name A都能正确处理。但要注意如果一列里混了数字和字符串比较行为会不一致所以建表时最好约定列的类型或者在元数据里记录类型并在插入时校验。3.3 把执行结果转成 JSON 返回给前端后端处理完查询后需要把结果序列化成 JSON通过 HTTP 响应发给浏览器。不引入第三方库的话手写一个简单的 JSON 序列化器就够用。public class JsonUtil { public static String toJson(ListMapString, String rows) { StringBuilder sb new StringBuilder([); for (int i 0; i rows.size(); i) { if (i 0) sb.append(,); sb.append({); int j 0; for (Map.EntryString, String e : rows.get(i).entrySet()) { if (j 0) sb.append(,); sb.append(\).append(escape(e.getKey())).append(\:\) .append(escape(e.getValue())).append(\); } sb.append(}); } return sb.append(]).toString(); } private static String escape(String s) { return s.replace(\\, \\\\).replace(\, \\\); } }转义只处理了反斜杠和双引号换行和制表符没处理如果数据里包含这些字符前端解析会出错。更稳妥的做法是把所有控制字符都转义或者直接用现成的 JSON 库。但教学项目里手写一遍能让你真正理解序列化在做什么。4. 用 HTML Fetch 搭一个能用的操作界面4.1 页面结构与交互流程前端不需要框架一个 HTML 文件加一段原生 JavaScript 就能跑。页面分三个区域SQL 输入框、执行按钮、结果表格。用户输入 SQL点击执行前端把语句 POST 给后端拿到 JSON 后渲染成表格。!DOCTYPE html html head meta charsetUTF-8 title简易数据库控制台/title style body { font-family: monospace; margin: 20px; } #sqlInput { width: 100%; height: 80px; font-family: monospace; } #resultTable { border-collapse: collapse; margin-top: 16px; } #resultTable td, #resultTable th { border: 1px solid #ccc; padding: 4px 8px; } #error { color: red; margin-top: 8px; } /style /head body h2简易数据库控制台/h2 textarea idsqlInput placeholder输入 SQL例如SELECT * FROM users/textarea br button onclickexecuteSql()执行/button div iderror/div div idresult/div script async function executeSql() { const sql document.getElementById(sqlInput).value.trim(); if (!sql) return; document.getElementById(error).textContent ; try { const resp await fetch(/api/query, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ sql: sql }) }); const data await resp.json(); if (data.error) { document.getElementById(error).textContent data.error; document.getElementById(result).innerHTML ; return; } renderTable(data.rows); } catch (e) { document.getElementById(error).textContent 请求失败 e.message; } } function renderTable(rows) { if (!rows || rows.length 0) { document.getElementById(result).innerHTML p无结果/p; return; } const cols Object.keys(rows[0]); let html table idresultTabletr; cols.forEach(c html th c /th); html /tr; rows.forEach(row { html tr; cols.forEach(c html td (row[c] || ) /td); html /tr; }); html /table; document.getElementById(result).innerHTML html; } /script /body /htmlfetch发的是 JSON 格式的 POST 请求后端需要对应解析。renderTable从第一行数据里取列名如果结果为空就显示「无结果」。注意row[c] || 会把0和false也变成空字符串如果数据里有这些值得改成row[c] ! null ? row[c] : 。4.2 后端 HTTP 服务的极简实现Java 端可以用com.sun.net.httpserver.HttpServerJDK 自带不需要引入 Tomcat 或 Jetty。启动一个监听 8080 端口的服务处理/api/query的 POST 请求。import com.sun.net.httpserver.*; import java.io.*; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; public class DbHttpServer { public static void main(String[] args) throws IOException { SimpleStorageEngine engine new SimpleStorageEngine(./data); MetaManager meta new MetaManager(engine); SqlExecutor executor new SqlExecutor(engine, meta); HttpServer server HttpServer.create(new InetSocketAddress(8080), 0); server.createContext(/api/query, exchange - { if (!POST.equals(exchange.getRequestMethod())) { exchange.sendResponseHeaders(405, -1); return; } String body new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); String sql extractSql(body); // 从 JSON 里取 sql 字段 String response; try { response executor.execute(sql); } catch (Exception e) { response {\error\:\ e.getMessage().replace(\, ) \}; } byte[] bytes response.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set(Content-Type, application/json; charsetutf-8); exchange.sendResponseHeaders(200, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); }); server.setExecutor(java.util.concurrent.Executors.newFixedThreadPool(4)); server.start(); System.out.println(服务已启动http://localhost:8080); } private static String extractSql(String json) { // 极简提取实际项目建议用 JSON 库 int start json.indexOf(\sql\); if (start 0) return ; start json.indexOf(:, start) 1; int q1 json.indexOf(\, start); int q2 json.indexOf(\, q1 1); return json.substring(q1 1, q2); } }HttpServer默认用单线程处理请求setExecutor换成固定大小线程池后能并发处理。extractSql是手写的 JSON 字段提取遇到转义引号会出错生产环境必须用正经的 JSON 解析库。响应头里指定charsetutf-8很重要否则中文数据到前端会乱码。4.3 前端与后端的联调检查清单联调时最容易出问题的地方就那么几个请求方法不对、Content-Type 没设、响应不是合法 JSON、跨域被拦。按这个顺序排查基本能覆盖九成情况。先看浏览器开发者工具的 Network 面板确认请求发出去了、状态码是 200、响应体是预期格式。如果状态码是 405检查后端有没有判断请求方法。如果响应体是 HTML 而不是 JSON说明请求打到了错误的路径。跨域问题在本地开发时可以用同源部署规避把 HTML 文件也放到 Java 服务的静态资源目录里。5. 避坑与排查那些让简易数据库跑不起来的原因5.1 中文数据写入后变成问号现象插入包含中文的记录查询出来显示为???。原因文件写入时没有指定字符集FileWriter默认用平台编码在某些环境下不是 UTF-8。解决把FileWriter换成OutputStreamWriter并显式指定StandardCharsets.UTF_8读取时同理。5.2 并发插入导致日志文件内容交错现象两个请求同时插入日志里出现半行拼接半行的情况重启后回放报错。原因多个线程共用一个BufferedWriter没有做写入同步。解决给日志写入加锁或者每个表独立日志文件再或者用java.util.concurrent.BlockingQueue把日志事件排队单线程消费写入。5.3 SELECT 的 WHERE 条件对数字比较失效现象age 18把age9的记录也查出来了。原因字符串比较9 18返回 true。解决在matchCondition里先尝试Double.parseDouble成功则按数值比较失败再退回字符串比较。同时建议在元数据里记录列类型插入时做校验。5.4 服务重启后数据丢失现象明明插入成功了重启服务后数据没了。原因日志写入用了缓冲但没有flush进程退出时缓冲区没落盘。解决每次写入后调用flush()或者在close()里确保 flush。更稳妥的做法是加一个定时刷盘线程兼顾性能和可靠性。5.5 前端表格渲染时列顺序错乱现象查询结果里列的顺序和 SQL 里写的不一致。原因后端用HashMap存行数据遍历顺序不确定。解决把行数据改成LinkedHashMap保持插入顺序这样SELECT name, age返回的 JSON 里字段顺序就是 name 在前。6. 从能跑到好用三个让简易数据库更接近实用的改造技巧第一个技巧是给查询加一个简单的结果集上限。现在SELECT * FROM users会把整张表拉出来数据量大了浏览器直接卡死。在executeSelect里加一个limit参数默认 1000超出就截断并在响应里带一个truncated: true标记。前端看到这个标记就提示用户「结果已截断请加 WHERE 条件缩小范围」。这个改动很小但体验提升很明显。第二个技巧是把日志文件按大小滚动。单个wal.log无限增长回放会越来越慢。做法是当日志超过比如 10MB 就新建一个文件启动时按文件名顺序回放所有日志。更进一步的优化是定期把内存状态全量 dump 成快照文件回放时先加载快照再回放快照之后的增量日志这样启动时间能从分钟级降到秒级。第三个技巧是给 SQL 执行加超时。用户输入一个没有 WHERE 的 DELETE整个表瞬间清空这种操作在教学项目里是灾难。可以在执行前做一次预检如果语句是 DELETE 或 UPDATE 且没有 WHERE 子句直接拒绝并返回错误。另外给每个请求设一个执行时间上限超过就中断并返回超时提示。// 预检拒绝无 WHERE 的 DELETE public void precheck(String sql) { String upper sql.trim().toUpperCase(); if (upper.startsWith(DELETE) !upper.contains(WHERE)) { throw new IllegalArgumentException(拒绝执行DELETE 语句必须带 WHERE 条件); } }这个预检放在SqlExecutor.execute的最前面任何语句进来先过一遍。规则可以按需扩展比如禁止DROP、限制单次插入行数等。别小看这几行代码它能挡住大部分误操作。我自己在这个方向上踩过最深的坑是早期版本没有做日志回放的幂等性校验结果一次异常退出导致日志里出现了重复记录重启后内存里同一主键出现了两条数据查询结果翻倍。后来在回放时加了主键去重并且给每条日志加了自增序号回放时按序号排序并跳过重复序号才算彻底解决。从那以后我养成了一个习惯任何涉及持久化的操作先问自己「如果这步执行到一半崩了重启后会怎样」。这个习惯比任何框架都管用。希望帮到你。本文还有配套的精品资源点击获取