数据库原理
SQL、ACID、CAP、索引与事务。
数据库原理
这一课你将能做到
- 用关系模型语言描述表、主键、外键、视图,并说出范式化与反范式化的取舍
- 解释 ACID 四种特性与四个隔离级别,区分脏读、不可重复读、幻读
- 说清 B+ 树索引的结构、聚簇与二级索引的区别,以及最左前缀原则
为什么要学
数据库是后端持久化的核心。判题平台的用户、题目、提交记录、判题结果全部存放在 MySQL 这类关系数据库里。一个后端工程师每天最多的性能问题往往不在 Java 代码,而在 SQL:
- 列表接口慢:没走索引还是索引失效?
- 两个用户同时报名/扣减名额:数据对不上怎么办?
- 表字段越加越多,JOIN 越写越乱:数据模型当初该怎么设计?
本课不背 SQL 语法,而是建立四组底层认知:关系模型、事务、索引、架构直觉。理解它们之后,MySQL 的大部分行为都能推导,而不是靠死记规则。
先建立图像
关系模型:一切皆表(关系)
┌──────────────────── 表 problems 题目表 ───────────────────┐
│ id(PK) │ title │ difficulty │ created_at │
│ 1 │ 两数之和 │ easy │ 2026-01-01 │
│ 2 │ 反转链表 │ easy │ 2026-01-02 │
└─────────────────────────────────────────────────────────┘
id 作为主键被 submissions 表的外键引用(一对多)
事务并发的三种「读异常」
脏读: 读到别人还没提交、随时可能回滚的数据
不可重复读:同一事务内两次读同一行,结果不同(别人 UPDATE 了)
幻读: 同一事务内两次范围查询,行数不同(别人 INSERT/DELETE 了)
索引结构(B+ 树,矮胖的多叉平衡树)
[ 30 | 60 ]
/ | \
[10|20] [30|40|50] [60|70|80] ← 真实数据都在叶子节点
叶子之间首尾相连 ──▶ 支持范围扫描
跟着做
步骤 1:设计判题平台的核心表并体会范式
-- 第一范式:字段原子;主键唯一
CREATE TABLE problems (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
difficulty VARCHAR(16) NOT NULL,
time_limit INT NOT NULL DEFAULT 1000,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 提交记录单独成表,通过外键(或逻辑外键)引用题目 —— 第二/三范式的直觉:
-- 不把题目标题冗余进提交表,避免题目改名后成千上万行数据不一致
CREATE TABLE submissions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
problem_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
language VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL, -- AC / WA / TLE / RE
code MEDIUMTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_time (user_id, created_at),
INDEX idx_problem_status (problem_id, status)
);
范式直觉:1NF 原子、2NF 完全依赖主键、3NF 非主属性不传递依赖。范式化减少冗余和更新异常;反范式化(冗余字段)用空间换查询性能、减少 JOIN。工程现实:OLTP 业务表保持 3NF 左右,列表页可适当冗余(如 submissions 里冗余 title 快照,因为提交时刻的题目具有「存档」语义);报表/数仓则大量反范式。
步骤 2:在 Java 中正确使用事务
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SubmissionService {
private final SubmissionRepository submissionRepository;
private final ProblemStatRepository statRepository;
public SubmissionService(SubmissionRepository submissionRepository,
ProblemStatRepository statRepository) {
this.submissionRepository = submissionRepository;
this.statRepository = statRepository;
}
// ACID 场景:写提交记录 + 更新题目统计,要么都成功要么都回滚
@Transactional
public void submitAndCount(Submission submission) {
submissionRepository.save(submission);
if ("AC".equals(submission.status())) {
statRepository.incrementAc(submission.problemId());
}
// 抛出运行时异常时整个事务回滚,不会出现「记录写了但统计没加」
}
}
record Submission(Long problemId, Long userId, String language, String status, String code) {}