简介:面向北邮研一数据库课程的一份学生成绩管理系统大作业详解,完整覆盖从需求分析、数据字典、局部与全局ER图、逻辑结构设计到SQL建表语句的核心环节,可直接用于理解数据库课程设计的完整流程。系统基于Course、Student、Sc、Teacher四张核心表展开,围绕成绩信息维护、教师信息管理、不及格学生名单统计、无教学任务教师查询等功能进行建模,并对学号、课程号等主键约束,以及学生与课程之间的一对多选课关系做了清晰的说明。资源包含1个docx文档,压缩包大小503KB,以表格、关系说明和建表语句为主,适合需要提交文档或对照学习关系数据库设计的同学参考。已有606人学习下载,既可充当北邮研一数据库大作业的设计蓝本,也可用于理解第三范式、外键关联和主键组合在实际教务系统中的应用。
1. 研一数据库大作业:从“能用”到“能答辩”的差距在哪
很多人把“北邮研一数据库大作业”默认理解成“用 MySQL 搭一个管理系统,写点增删改查”,结果到答辩现场被问两句“事务怎么回滚的”“索引为什么选 B+ 树”就接不上话。真正拉开差距的,是把大作业做成一个带存储引擎、SQL 解析、事务回滚的迷你数据库,哪怕功能少一点,也比套壳管理系统有说服力。这篇文章以一条可复现的落地路径为主线,从需求拆解讲到并发控制、答辩验证,适合正在选型、怕踩坑、想在数据库方向上拿出实锤作品的研一学生。
2. 先定边界再写代码:选题范围、技术栈与三层架构的落地拆解
2.1 把大作业需求拆成四个可验收交付物
先想清楚老师到底收什么。以我接触过的同类课程为例,数据库大作业通常有四样交付物:可运行的程序、源码、实验报告、答辩演示。程序里至少要覆盖:
- 存储引擎:数据落盘,重启后不丢;
- SQL 层:支持 create / insert / select / update / delete 这几类数据库增删改查;
- 事务:begin / commit / rollback,能回滚未提交修改;
- 基本索引:至少一个 B+ 树或哈希索引,能解释清楚查询计划。
不要贪多。很多同学一上来就想做分布式、向量数据库或者时序数据库(TDengine 这类),结果三个月过去连单机事务都没跑通。研一数据库课程设计的时间通常只有一个学期,把单机关系模型做好,已经是中等偏上水平。如果老师布置的是“在开源内核上做增强”,那就先跑通 PostgreSQL 或人大金仓的源码编译,再谈加功能;如果是“自研迷你数据库”,就按下面的路线走。
评分角度也要提前问清楚。有的课侧重性能测试,有的侧重正确性和代码结构,有的会要求跑 TPC-C 的某张表。最好在第一周找助教确认评分权重,避免把时间花在老师不看的地方。我一般会先列一张“必要性”表格:功能项、工作量、答辩追问点、是否必需。比如“支持 group by”看着简单,但要处理聚合函数与 NULL 语义,工作量不算小,属于“可以最后做”的功能。
2.2 技术栈选型:Java / Python / C++ 的选择标准
常见做法是选 Java,理由很现实:JVM 自带的内存管理能让你把精力放在算法上,而不是花两周排查指针越界;Maven 管理依赖方便,写 JUnit 测试也顺手。C++ 适合对性能较真的人,但调试成本高;Python 开发最快,适合快速验证,但真要做到“数据落盘 + WAL 恢复”,对象序列化开销会让你在性能报告里不好看。
我的建议是:如果导师没有硬性规定,用 Java 17 + ANTLR 写解析器 + 自研 B+ 树存储引擎;如果只是想先把算法跑通、后面再换语言,用 Python 3 做原型最合适。下面是两套常见目录结构,按项目大小自取:
# Java 方案(推荐) src/main/java/edu/db/minidb/ parser/ # ANTLR 生成的 AST 与语法树 executor/ # select/insert/update/delete 算子 storage/ # 页、缓冲池、B+树、日志 transaction/ # 锁表、日志序列号、回滚段 src/test/java/edu/db/minidb/ # 每个模块配一个单元测试 # Python 原型方案 minidb/ parser.py executor.py storage.py transaction.py tests/选 Java 时要注意:不要引入 Spring Boot。大作业目标是数据库内核,不是 Web 服务。数据库连接池(比如 HikariCP)是应用层的东西,放到迷你数据库里只会增加负担;如果你额外做一个 JDBC 驱动,再做连接池才有意义。另外,不要为了蹭热度硬加向量数据库、时序数据库等模块,这类需求会把你拖进“特征提取”“时序压缩”的深水区。
2.3 三层架构:Parser、Executor、Storage 的分工与接口
不管选什么语言,我一般都会把代码切成三层,避免把所有逻辑堆在一个类里。
- Parser 层:输入 SQL 字符串,输出抽象语法树(AST)。它不碰数据。
- Executor 层:遍历 AST,调用 Storage 的接口,返回结果集。它不关心数据怎么落盘。
- Storage 层:管理页、缓冲池、B+树、日志文件。它对上层暴露 get / put / delete / scan 四个接口。
三层接口这样定义比较稳:
# storage.py 的对外接口 class StorageEngine: def get(self, table, key) -> Row | None: """按主键取一行,返回 None 表示不存在""" ... def put(self, table, key, row) -> None: """写入或更新一行""" ... def delete(self, table, key) -> bool: """删除一行,返回是否真的删掉了""" ... def scan(self, table, low=None, high=None) -> Iterator[Row]: """按主键范围扫描;low/high 为 None 表示不设边界""" ...这个接口最大的好处是:你可以先拿dict做一版能跑的假存储,把解析器和执行器调通,再替换成 B+ 树。做过数据库课程设计的人都知道,最怕的是几个模块同时出问题,没法定位。先做假存储,再逐层替换,是效率最高的路径。
Executor 层要维护一个“当前事务”状态:事务开始时间、已修改页、锁列表。它调用 Storage 的接口时,Storage 不感知事务,事务逻辑由 Executor 和 TransactionManager 协同完成。新手常见翻车点是让 Storage 层自己加锁,结果事务嵌套后锁越加越多,最后只能重启。
接口设计还有两个容易忽略的参数:表名和条件表达式。很多人的get(table, key)只有主键查询,但select ... where age > 20需要全表扫描。所以 Storage 层要提供scan(table, condition)。注意condition不要用条件表达式,而是传一个可调用的 predicate,这样执行器可以把 WHERE 下推,存储层只负责遍历。实际上,为了减少返工,我建议 Executor 层把 AST 转成一个“表达式树”,再传给 storage 做过滤。这样后续要支持索引,只需要让 storage 根据 column 和 range 选择走 B+ 树还是全表扫描。
再有一个边界:事务回滚时,put和delete的旧值必须能找回来。所以 Storage 层除了 get/put/delete/scan,还要提供“记录变更日志”的能力。更简单的做法是先写 WAL,再写数据页。到第 5 章我们再细说。总之,接口里留一个table_meta的表结构字典,别把表和列信息散落在各种全局变量里,否则后面加 ALTER TABLE 时你会想重写。
3. 存储引擎:B+树索引与缓冲池的最小可运行实现
3.1 为什么 B+树是必选项,而不是哈希索引
哈希索引的查找是 O(1),非常快,但它解决不了范围查询。select * from t where id between 100 and 200这种语句在哈希索引下只能全表扫描;而 B+ 树把所有叶子节点用链表串起来,天然支持范围查询,这是 InnoDB 默认索引选 B+ 树的核心原因。
另一个原因是磁盘 IO 模型。B+ 树是矮胖树,一棵 100 万行的表,4KB 页大小、每页能存 100 个 key 的话,树高只有 3 层左右,读任意一行最多 3 次磁盘 IO。哈希索引虽然单点快,但没有局部性;LSM-Tree 写入友好但实现复杂,恢复逻辑不适合课程设计展示。所以无论从答辩角度还是实现角度,B+ 树都是最优解。
还有一个容易被问到的点:为什么不用二叉搜索树?因为二叉搜索树在磁盘上是链式存储,每次比较都可能跳页,缓存命中率差;更关键的是没有按页聚合,无法做顺序预读。B+ 树把相邻 key 放在同一个页里,天然适合页式存储。这些理由写到报告里,比一句“大家都用”有说服力得多。
3.2 页、缓冲池与 LRU 淘汰
存储引擎的第一个组件是页。页是磁盘和内存交换的最小单位,固定大小通常为 4KB 或 8KB。所有数据、索引、日志最终都落在页上。没有页概念的话,你只能把整棵树序列化成一个文件,修改时全量重写,这在大作业里属于“能跑但答不了题”的方案。
缓冲池的作用是减少磁盘 IO:读页时先看缓存,写页时先改缓存,等淘汰时再写回磁盘。给一个最小的 Python 实现骨架:
class BufferPool: def __init__(self, capacity: int = 64): self.capacity = capacity # 最多缓存的页数 self.page_map = {} # page_id -> bytearray self.pin_count = {} # 页被引用次数,>0 时不能被淘汰 self.dirty = set() # 脏页集合,淘汰时要写回磁盘 def read_page(self, page_id: int) -> bytearray: if page_id not in self.page_map: self._load_from_disk(page_id) self._evict_if_needed() self.pin_count[page_id] += 1 return self.page_map[page_id] def unpin_page(self, page_id: int): self.pin_count[page_id] -= 1 def mark_dirty(self, page_id: int): self.dirty.add(page_id) def _evict_if_needed(self): if len(self.page_map) < self.capacity: return for pid in list(self.page_map): if self.pin_count[pid] == 0: if pid in self.dirty: self._write_to_disk(pid) self.dirty.remove(pid) del self.page_map[pid] return逻辑说明:read_page先看缓存,没有则读磁盘;返回前 pin 计数加一,保证调用方正在使用时不会被淘汰。unpin_page必须成对调用,否则缓存永远满。淘汰策略这里用了最简单的“从前往后找第一个 pin_count 为 0 的页”,没有做真正的 LRU。课程设计可以直接用collections.OrderedDict实现 LRU,或者用 CLOCK 算法替代。CLOCK 的思路是每个页一个“引用位”,访问时置 1,淘汰扫描时遇到引用位为 0 的页才淘汰,遇到 1 的改为 0 并跳过。这是数据库教材里最常见的近似 LRU 实现。
参数说明:capacity一般设为 64~256 页。页大小固定为 4KB 或 8KB,文件系统里一个页就是一个 block。页大小决定了 B+ 树节点大小,也决定单次 IO 的吞吐。注意页大小不能太大,否则内存里同时能缓存的页数变少;也不能太小,否则索引扇出低、树变高。
这里有一个血泪经验:一定要在初始化时把页大小定义为常量,而不是散落在各处。后面做 B+ 树节点序列化时,你需要在固定位置写入“页类型”“key 数量”“左右指针”,任何一处偏移算错,重启后读出来的就是乱码。建议先写一个page.py,只负责把字节数组读写为整型、字符串,不要让业务代码直接操作bytearray。
3.3 B+树插入分裂:代码骨架与三个参数
B+ 树的核心操作是插入和分裂。很多人以为先写删除,其实插入才是主线。删除可以先做标记删除,避免一开始就处理复杂的节点合并。下面是插入的关键代码:
ORDER = 4 # 每个节点最多 4 个 key,5 个指针 class BPlusNode: def __init__(self, is_leaf=False): self.keys = [] self.children = [] # 内部节点:指向子节点;叶子节点:指向记录 self.is_leaf = is_leaf self.next = None # 叶子节点的兄弟指针,用于范围扫描 def insert(root, key, value): leaf = find_leaf(root, key) leaf.keys.append(key) leaf.children.append(value) leaf.keys.sort() if len(leaf.keys) > ORDER: new_leaf, mid_key = split_leaf(leaf) return insert_in_parent(root, new_leaf, mid_key) return root逻辑说明:先找到应该插入的叶子节点,把 key 和 value 追加进去并排序;如果 key 数量超过阶数 ORDER,就把叶子拆成两个,并把中间 key 上升到父节点。注意find_leaf要处理相等 key 的情况,通常允许重复 key 时往右走。分裂时,原叶子保留前一半 key,新叶子存后一半,并把两个叶子的next指针连起来。这样范围查询只需要从最小 key 出发,沿next扫。
参数说明:ORDER越大,单节点能容纳的 key 越多,树越矮,但节点分裂时复制成本也越高。做课程设计选 4~8 就行,不要拧到 100。真正影响性能的是磁盘页大小和缓冲池命中率,而不是内存里的 ORDER。
还有三个容易踩的细节:
- 根节点分裂:根节点满时,要新建一个空根节点,树的高度加一。很多实现漏掉这个,结果根节点 keys 超过 ORDER 却不处理,查询直接乱掉。
- 删除后合并:B+ 树不要求删除后立刻合并,除了根节点。课程设计可以先支持“删除标记”,即给 key 加一个 tombstone,避免处理复杂的再平衡。答辩时可以坦诚说明这是简化点。
- 叶子节点 value 存什么:可以直接存 Row,也可以存“页内偏移”。如果存 Row,页大小和节点大小不好控制;建议叶子节点存
record_id(页号+槽位号),真正数据行放在 data page 里。这是 InnoDB 的做法,也更贴近真实数据库。
验证 B+ 树是否正确的最快方法:随机插入 1000 个 key,然后按叶子链表中序遍历,断言整个序列递增。再随机删除一批,继续校验。这一步能省下后面调试执行器的无数时间。
4. SQL 通路:从解析器到增删改查执行器的完整骨架
4.1 词法/语法分析:手写还是用第三方生成器
解析器是很多人最早放弃的地方。遇到 SQL 字符串就用正则硬拆,拆到where a=1 and b=2开始崩。正确做法是:Java 用 ANTLR,Python 用lark或ply。不要自己把状态机写得像天书。
我建议用lark的原因之一是它支持 EBNF 语法定义,直观且可迭代。下面是一个 SQL 子集语法片段:
select: "SELECT" column_list "FROM" table ["WHERE" expr] ; expr: term ("OR" term)* ; term: factor ("AND" factor)* ; factor: column ("=" | ">" | "<") literal ;逻辑说明:这个语法只覆盖了单表查询的 WHERE 条件,没有 join、子查询、group by。先跑通这个最小集,再逐步扩展。每次扩展语法后都要重新生成 parser,别手改 AST。
对应的 Python 解析器骨架:
class SimpleParser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 def parse_select(self): assert self.match("SELECT") cols = self.parse_columns() self.match("FROM") table = self.parse_identifier() where = None if self.match("WHERE"): where = self.parse_expr() return SelectStmt(table=table, columns=cols, where=where)参数说明:parse_columns要分清楚*和列名列表;parse_expr返回的是一棵表达式树,而不是字符串。表达式树里每个节点可以是ColumnRef、Literal、CompareOp。这样执行器求值时,不需要再对字符串做二次解析。
4.2 执行器:select/insert/update/delete 的算子最小集
执行器的输入是 AST,输出是结果集。最核心的 select 实现如下:
def execute_select(stmt, storage): rows = [] for row in storage.scan(stmt.table): if stmt.where is None or eval_expr(stmt.where, row): rows.append(project(row, stmt.columns)) return rows说明:这里先全表扫描,逐行过滤,最后投影。没有做谓词下推和索引选择,但对于少量数据足够了。如果stmt.where里出现了主键列且是等值条件,可以调storage.get(stmt.table, key)直接拿一行;如果是age > 20且有 age 索引,则用索引范围扫描。这是执行器里最重要的优化,也是最容易在答辩中展示的亮点。
插入、更新、删除相对简单,但都绑定事务:
def execute_insert(stmt, storage, tx): row_id = storage.put(stmt.table, stmt.row) tx.record_change(stmt.table, row_id, old=None, new=stmt.row) def execute_update(stmt, storage, tx): for row_id in matched_ids(stmt, storage): old = storage.get(stmt.table, row_id) new = apply_updates(old, stmt.assignments) storage.put(stmt.table, row_id, new) tx.record_change(stmt.table, row_id, old=old, new=new) def execute_delete(stmt, storage, tx): for row_id in matched_ids(stmt, storage): old = storage.get(stmt.table, row_id) storage.delete(stmt.table, row_id) tx.record_change(stmt.table, row_id, old=old, new=None)逻辑说明:所有写操作都要通过tx.record_change记录变更,这样 rollback 时知道怎么恢复旧值。执行器本身不直接加锁,锁由事务管理器在record_change前统一申请,避免一个操作里混入锁逻辑。matched_ids来自 WHERE 的筛选结果,走索引还是全表扫描,由执行器自己决定。
这里有个性能大坑:很多人写 update 时,先scan所有行,然后对每一行再get一次,导致同一个表被扫两遍。正确做法是scan时就把匹配的行缓存到列表,然后遍历这个列表去 update。数据量大时,这个差别非常明显。
4.3 类型系统、NULL 与长度校验:最常见的隐性扣分点
表结构设计比想象中复杂。先定义一个最小元数据:
class Column: def __init__(self, name, typ, length=None, nullable=True, default=None): self.name = name self.typ = typ # "int" / "float" / "varchar" self.length = length # varchar 长度 self.nullable = nullable self.default = default这里有几个必须定下来的行为:
- 类型校验:插入数据时检查类型,int 列传入字符串要报错,不要静默转换。报错信息里最好带上表名、列名和值,方便调试。
- 长度校验:varchar 超过 length,到底是报错还是截断?两种都可以,但必须写进报告并保持一致。MySQL 默认严格模式报错;很多学生用 Python 的 sqlite 自动截断,导致行为不一致。
- NULL 语义:NULL 参与算术运算结果还是 NULL;WHERE 判断要用
IS NULL而不是= NULL。如果where age = NULL会匹配不到任何行。 - 主键唯一性:插入重复主键要返回错误,同时这个错误要能被 rollback 捕获。不要把唯一性检查放在执行器里,应该放在 Storage 层,否则并发事务会绕过检查。
这里可以用一张测试用例表来校准自己:
| 用例 | 输入 | 预期行为 |
|---|---|---|
| 类型错误 | INSERT INTO t VALUES ('abc', 1)到 int 列 | 报错,事务回滚 |
| 长度超限 | varchar(3) 插入 'hello' | 报错或截断,策略要写明 |
| NULL 过滤 | SELECT * FROM t WHERE name = NULL | 返回 0 行 |
| 主键重复 | 插入相同主键 | 报错并回滚 |
很多同学把int和varchar都当成字符串存,看起来能跑,但一执行where age > '10'就会按字典序比较,出现9 > 10为 true 的诡异结果。所以类型信息必须在 insert 时就转成对应 Python/Java 类型,而不是留到查询时再猜。
5. 并发控制与常见问题:死锁、锁表与 WAL 恢复的踩坑清单
5.1 锁管理:2PL 与表锁/行锁的选择
事务要满足隔离性,最朴素的实现是两阶段锁(2PL):事务可以在任何时候获取锁,但一旦开始释放锁,就不能再获取新锁。课程设计不必严格区分“严格两阶段锁”和“普通两阶段锁”,统一在 commit/rollback 时释放所有锁即可。
对于课程设计,建议先做表锁,再做行锁。表锁实现简单:一个字典,key 是表名,value 是持有者集合。行锁需要把 record_id 作为 key。如果做了 B+ 树,行锁很容易加在 index entry 上。给一个最小锁管理器:
class LockManager: def __init__(self): self.locks = {} # resource -> set[tx_id] def acquire(self, tx_id, resource, mode="X"): while not self._can_grant(tx_id, resource, mode): wait_on(resource) self.locks.setdefault(resource, set()).add(tx_id) def release_all(self, tx_id): for res, owners in list(self.locks.items()): owners.discard(tx_id) if not owners: del self.locks[res]逻辑说明:_can_grant判断当前锁是否兼容。表锁只有 S/X 两种模式,S 和 S 兼容,其他冲突;行锁类似。wait_on可以用threading.Condition,不要用time.sleep死等。这里要注意mode升级:事务已经持有 S 锁,再请求 X 锁时不能自己等自己,否则必死锁。_can_grant里要允许同一个 tx_id 覆盖。
5.2 数据库死锁:超时重试与等待图检测
锁等待场景下,死锁必然会发生。两个事务互相持有对方想要的锁,就卡死了。常见做法有两种:
- 超时:每个锁等待设 500ms 或 1s,超时就回滚当前事务。简单,但可能误杀长时间事务。
- 等待图:每次
acquire把等待关系记入有向图,检测到环就挑一个 victim 回滚。课程设计做等待图会更出彩,代码量也不大。
等待图检测的简化代码:
def detect_deadlock(waits_for): # waits_for: {tx_id: set[tx_id]} 表示 tx_id 在等待哪些事务释放锁 for start in waits_for: stack = [start] visited = set(stack) while stack: t = stack.pop() for nxt in waits_for.get(t, []): if nxt == start: return start # 发现环 if nxt not in visited: visited.add(nxt) stack.append(nxt) return None逻辑说明:这是一个 DFS 找环的朴素实现。每次锁等待发生时都调用一次,发现环就返回一个事务回滚。注意要判断nxt == start而不是只记录 visited,否则环不闭合。实际里还会挑一个 undo 成本较小的事务做 victim,比如写日志少、开启时间短的那个。超时和等待图可以同时启用:等待图检测做主,超时做兜底。
5.3 WAL 与恢复:redo/undo 的最小做法
先说结论:不要只把数据写到内存,等 commit 才写磁盘。这样宕机会丢数据,答辩就是送分题。正确的顺序是:
- 写 WAL 日志:
<LSN, tx_id, page_id, old_value, new_value> - 刷 WAL 到磁盘(fsync)
- 修改缓冲池里的页
- 提交事务,返回成功
恢复时,从头扫描 WAL,所有已提交事务的 redo 重放;未提交事务的 undo 用 old_value 回滚。为了课程设计简化,可以在每一条日志里同时存 old 和 new,这样 redo/undo 都能做。给一个日志写入的示例:
def write_wal(lsn, tx_id, page_id, old, new): record = (lsn, tx_id, page_id, old, new) with open("wal.log", "a") as f: f.write(json.dumps(record) + "\n") f.flush() os.fsync(f.fileno())参数说明:lsn是日志序列号,单调递增。事务提交前,必须保证这个事务产生的所有 WAL 都 fsync 到磁盘,否则 commit 返回后宕机,数据会丢。page_id用于定位数据页,恢复时按 LSN 顺序应用。这里用 JSON 序列化只适合课程设计,真实数据库会用二进制日志,但原理一致。
还有一个容易忽略的问题:WAL 文件无限膨胀。课程设计可以在测试结束后直接删掉 WAL 重新开始,但报告里要写明“正式环境需要 checkpoint 定期截断日志”。能答出 checkpoint 的时机,就已经超过大部分同组同学。
5.4 三个高频问题:死锁、锁不释放、回滚失败
现象 1:事务 A 更新一行后,事务 B 更新同一行,B 一直卡住,A 回滚后 B 才继续。
原因:B 在等 A 的 X 锁,A 没有及时提交或回滚;或者锁管理器的release_all没有被事务管理器调用。
解决:检查 commit/rollback 路径,确保在finally块里调用release_all(tx_id);给acquire加超时,超时后主动回滚当前事务,避免永久挂起。
现象 2:两个并发事务各自更新不同行,然后互相更新对方行,程序卡死。
原因:经典的数据库死锁,没有检测机制。
解决:在acquire时调用等待图检测,发现环就选择一个事务回滚。不要把死锁问题丢给操作系统,课程设计里必须自己处理。
现象 3:事务回滚后,之前删掉的行又回来了,但插入的行还在。
原因:执行器只记录了新值,没有记录旧值;rollback 逻辑对 delete 操作没有恢复 old 值。
解决:统一在record_change中保存 old/new 快照,rollback 时按操作类型反向执行。delete 的 old 要保存完整行数据,不能只保存主键。
5.5 隔离级别怎么交代:做到 READ COMMITTED 就够
很多同学的代码跑着跑着出现“你更新我没看见”的怪状,多半是隔离级别没有定义清楚。课程设计不需要实现完整的 MVCC,做到 READ COMMITTED 即可:事务只能读到已提交事务写入的数据,未提交的修改对别的不可见。最简单实现是写操作加 X 锁、读操作加 S 锁,锁在 commit 前不释放。这样虽然牺牲了一点并发度,但正确性容易解释。
如果你做完这些还有余力,再考虑可重复读。可重复读需要快照读,通常是 MVCC,工作量直接翻倍。报告里明确写“本实现采用严格两阶段锁,提供 READ COMMITTED 隔离级别”,比含糊地说“支持事务”要专业得多。
6. 答辩前要做的三个验证:正确性、性能与演示脚本
6.1 正确性自测:用事务回滚证明你真的写对了
写一个回归脚本:启动数据库,插入 1000 条随机数据,开启事务,更新 500 条,回滚,再查询;断言数据量和更新前的快照完全一致。再用 1000 次随机并发事务跑一遍,看有没有死锁、丢更新。这个脚本能暴露 90% 的存储和事务问题。我当年第一版没做 WAL,恢复实验当着老师面丢数据,那个场景我现在都记得,所以后来我把恢复实验放在答辩清单第一位。
6.2 性能验证:别拿 MySQL 比,要和自己的基线比
不要做那种“我的数据库比 MySQL 慢 20 倍”的自杀式对比。正确做法是:对比自己的关闭索引 vs 开启索引、落盘 WAL vs 不落盘 WAL 的差距。把结果画成折线图,展示优化有效。比如 B+ 树索引查询 vs 全表扫描在 10 万行数据下的耗时,能直观说明索引价值。如果页面上的数字不理想,先排查是不是缓冲池容量设得太小,其次排查是不是每次查询都 fsync。
6.3 答辩演示脚本:把数据库死锁当成现场节目
准备一个演示场景:开两个终端,事务 A 和 B 互相锁对方资源,然后展示检测到死锁后自动回滚另一个事务。这比讲 10 页 PPT 更能让老师信服。演示脚本里要包含重启恢复的步骤:插入数据、崩溃、重启、查询,数据还在。最后留一句诚实的话:哪些功能是简化过的,为什么简化。不懂装懂被追问时反而扣分,说“这里我为了保证正确性牺牲了一点性能”通常能保住基本盘。希望帮到你。
本文还有配套的精品资源,点击获取