简介:一款基于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,直接javac+java两步跑起来;前端页面按下 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 前端和后端只认一种报文
浏览器不能直接执行 SQL,Java 进程也不能直接操作 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 端把行数据变成Map<String, 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),代价是删除列时字段数据还留在文件里,只能通过元信息标记隐藏。
字段类型上不要贪多,能覆盖课程设计和面试题的场景就够了。我常用的一套是:
| 类型名 | 存储长度 | 说明 |
|---|---|---|
| INT | 4 字节 | 有符号整数,兼容 Java int |
| BIGINT | 8 字节 | 长整数,负责 id 主键 |
| VARCHAR(n) | 2 + n 字节 | 变长,前 2 字节记录实际长度,n 为最大长度 |
| DOUBLE | 8 字节 | 双精度浮点 |
| BOOL | 1 字节 | 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 行的语法分析器,而是做「整句合法校验」。常见做法是先把开头第一个单词拆出来,放进一个Set<String>里匹配,匹配不到就直接报「不支持的 SQL 类型」。这看起来像是偷懒,实际是在控制解析边界——简易数据库只实现四条 DML 和几条 DDL,你给它一条WITH ... SELECT它处理不了,直接拒绝比报错半个钟头再抛异常友好得多。
public class Tokenizer { private static final Set<String> DML_KEYWORDS = Set.of("select", "insert", "update", "delete"); private static final Set<String> 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) { List<String> 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 charset="UTF-8">,HTTP 响应头要设置Content-Type: application/json;charset=UTF-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 = 'o''brien'报错,或者select * from student where remark = 'it''s'解析出来的值变成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 的能力,而是让项目的边界变得清晰——哪些问题是我该去解决的,哪些问题是一开始就没有必要实现的。这个取舍想清楚,源码投入的方向才算真正立住。希望帮到你。
本文还有配套的精品资源,点击获取