19. 数据库原理基础

深入数据库原理核心:关系模型与 SQL、存储与页、B+ 树与 LSM 索引、事务 ACID、隔离级别与锁、WAL 日志恢复、查询优化执行计划、范式与反范式,构建理解任何数据库系统的基础框架。

1. 关系模型与 SQL

1.1 关系模型核心概念

关系模型(Codd 1970)把数据组织为二维表:行(tuple)为记录,列(attribute)为字段。关系模型的三大要素:结构(relation)、完整性(约束)、操作(关系代数/SQL)。

概念含义例子
关系(Relation)一张二维表users 表
元组(Tuple)一行记录一个用户
属性(Attribute)一列字段name, age
候选键(Candidate Key)可唯一标识元组的属性集id, (name, dept)
主键(Primary Key)选定的候选键id
外键(Foreign Key)引用他表主键的约束user_id → users.id

1.2 SQL 分类与示例

-- DDL:数据定义
CREATE TABLE users (
    id       BIGINT PRIMARY KEY AUTO_INCREMENT,
    name     VARCHAR(64)  NOT NULL,
    email    VARCHAR(128) UNIQUE,
    age      INT          CHECK (age >= 0),
    dept_id  BIGINT,
    FOREIGN KEY (dept_id) REFERENCES dept(id)
);

-- DML:数据操作
INSERT INTO users (name, email, age) VALUES ('Alice', 'alice@x.com', 23);
UPDATE users SET age = 24 WHERE name = 'Alice';
DELETE FROM users WHERE id = 1;

-- DQL:查询
SELECT d.name, COUNT(u.id) AS cnt
FROM dept d
LEFT JOIN users u ON u.dept_id = d.id
WHERE u.age >= 20
GROUP BY d.id
HAVING COUNT(u.id) > 0
ORDER BY cnt DESC
LIMIT 10;
SQL 类别作用关键字示例
DDL定义结构CREATE / ALTER / DROP
DML操作数据INSERT / UPDATE / DELETE
DQL查询数据SELECT / FROM / WHERE
DCL权限控制GRANT / REVOKE
TCL事务控制BEGIN / COMMIT / ROLLBACK

SQL 是声明式语言:你声明"想要什么",优化器负责"怎么查"。理解执行计划(第 8 节)是写出高效 SQL 的前提。


2. 存储引擎与页

2.1 数据在磁盘上的组织

数据库以**页(Page)**为最小 IO 单位读写,页大小通常 4KB/8KB/16KB。表在磁盘上由一组页组成,页内是槽位化的记录数组。

Page Header (页元信息)
| PageLSN | 校验和 | 记录数 | 空闲指针 |
数据区
| slot0 | slot1 | slot2 | ... | 变长记录 |
页尾
| 空闲空间指针 | ... |

磁盘 IO 是数据库性能的最大瓶颈:一次随机读页约 10ms,而内存读纳秒级。所有数据库优化的本质都是减少随机 IO、提升顺序 IO、让热数据驻留内存。

存储参数典型值影响
页大小16KB(InnoDB)决定单行上限与索引扇出
预读(read ahead)顺序访问自动批量加载提升全表扫描吞吐
Buffer Pool内存中缓存页的池命中率决定读性能

2.2 行存储 vs 列存储

维度行存储(OLTP)列存储(OLAP)
组织方式一行连续存放一列连续存放
典型场景点查、高频更新全表聚合、扫描
压缩率低高(同类型数据聚集)
代表MySQL/PostgreSQLClickHouse/列存引擎

3. 索引结构:B+ 树

3.1 B+ 树结构

B+ 树是多叉平衡搜索树,所有数据都存放在叶子节点,叶子之间通过指针链表相连;内部节点只存键用于路由。

B+ 树相对 B 树/BST 的优势:

  1. 扇出大(一个节点几百个键),树高仅为 3-4 层——查询 10 亿条记录只需 3 次磁盘 IO;
  2. 叶子串成链表,范围查询(BETWEEN、ORDER BY)天然高效;
  3. 内部节点不存数据,能缓存更多路由键。
             [ 50 | 100 ]
           /     |       \
     [10|30|40]  [60|70]  [110|130|140]
      → (叶子) → (叶子) → (叶子)     ← 叶子链表
操作复杂度说明
等值查找O(log_m n)沿树高 m 叉二分
范围查询O(log_m n + k)叶子链表顺序扫描 k 个结果
插入/删除O(log_m n)节点分裂/合并,保持平衡
# B+ 树插入引发节点分裂的示意(m=3 阶)
def b_plus_insert(node, key):
    if node.is_leaf:
        node.keys.insert_sorted(key)
        if len(node.keys) > node.max_keys:      # 上溢出
            left, right = split_leaf(node)      # 从中间切分
            return left, right                  # 中间键上升为父路由键
    else:
        child = node.find_child(key)
        res = b_plus_insert(child, key)
        if res:                                 # 子节点分裂
            node.keys.insert(res.mid_key)
            if len(node.keys) > node.max_keys:  # 内部节点也分裂
                return split_internal(node)
    return None

4. 索引结构:LSM-Tree

4.1 写优化型索引

LSM-Tree(Log-Structured Merge Tree)面向写密集场景(日志、时序、消息队列)。写入只追加内存表(MemTable),达到阈值后落盘为不可变的 SSTable,后台按层级合并(Compaction)。

写入路径:  WAL(持久化) → MemTable(内存, 有序) → SSTable L0 → ... → Ln
读取路径:  先查 MemTable → 逐层查 SSTable(布隆过滤器快速排除)
# LSM 读路径:布隆过滤器 + 二分,逐层查找
def lsm_get(leveled_ssts, memtable, key, bloom):
    if key in memtable:
        return memtable[key]
    for sst in leveled_ssts:                 # 从 L0 到 Ln 逐层
        if key in bloom[sst]:                # 布隆过滤器说"可能存在"
            val = sst.binary_search(key)     # SSTable 内部有序,二分
            if val is not None:
                return val
    return None

4.2 B+ 树 vs LSM 对比

维度B+ 树LSM-Tree
写放大低(原地更新)高(多轮合并重写)
读放大低(1-3 次 IO)高(多层查找)
写性能随机写,受页 IO 限制顺序追加,写吞吐高
空间放大低中(未合并的冗余版本)
适用场景OLTP 点查/范围查询写密集、日志、时序
代表InnoDB, PostgreSQLRocksDB, HBase, Cassandra

选择判据:读多写少用 B+ 树,写多读少用 LSM。生产系统常混合使用——例如 MySQL 用 B+ 树做主存储,用内存/外置 LSM 引擎做写入缓冲。


5. 事务与 ACID

5.1 事务定义

事务是数据库执行的最小逻辑单元,要么全部成功要么全部回滚。ACID 四性:

特性含义实现机制
A 原子性全部执行或全部不执行Undo Log / 回滚段
C 一致性事务前后数据满足约束应用逻辑 + 数据库约束
I 隔离性并发事务互不干扰锁 / MVCC
D 持久性提交后数据不丢失Redo Log / WAL
-- 经典转账事务
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 若任一步失败则 ROLLBACK,保证余额守恒
COMMIT;

5.2 并发问题

并发异常描述被哪个隔离级别阻止
脏读读到未提交数据Read Committed
不可重复读同一查询两次结果不同Repeatable Read
幻读范围查询两次行数不同Serializable

6. 隔离级别与锁

6.1 四种隔离级别

隔离级别脏读不可重复读幻读实现方式
Read Uncommitted允许允许允许不加读锁
Read Committed阻止允许允许读后即释放快照(每语句新快照)
Repeatable Read阻止阻止允许*MVCC 事务级快照
Serializable阻止阻止阻止全表锁 / 区间锁 / 串行化调度

MySQL InnoDB 默认 Repeatable Read,且通过 Next-Key Lock(间隙锁) 同时阻止了幻读(表中 * 即此特殊点);PostgreSQL 默认 Read Committed,其 Repeatable Read 下无幻读。隔离级别越高,并发度越低。

6.2 锁的类型

锁兼容性说明
共享锁 SS 与 S 兼容读锁,可多事务同时持有
排他锁 X不兼容写锁,互斥
意向锁 IS/IX表级声明"即将在行上加 S/X",加速冲突检测
间隙锁 Gap-锁定区间,阻止幻读(InnoDB)
记录锁 Record-锁定单行索引记录
-- 加锁语句示例
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;          -- X 锁(写意图)
SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE;  -- S 锁

7. 日志与恢复:WAL

7.1 WAL 原理

Write-Ahead Logging(预写日志):数据页的修改必须先写 Redo Log 并落盘,之后才允许写数据页。崩溃恢复时重放 Redo Log,即可保证已提交事务的修改不丢失。

事务 T1:  [BEGIN] → 写 Redo 记录 → [COMMIT, LSN=120]  (先日志后数据)
崩溃恢复: 从最后检查点向后,重放所有 Redo 记录 → 数据页重建
日志类型作用恢复方向
Redo Log重做已提交事务的修改(持久性)前滚(Redo)
Undo Log回滚未提交事务的修改(原子性)后滚(Undo)
# WAL 恢复示意
def crash_recovery(redo_log, checkpoint_lsn):
    for record in redo_log.after(checkpoint_lsn):   # 从检查点后重放
        if record.txn_committed:
            apply_redo(record)                      # 把修改重新应用到数据页
    return 0  # 恢复完成,已提交数据不丢失

**检查点(Checkpoint)**定期把内存脏页刷盘并记录 LSN,缩短崩溃恢复时间;缓冲池淘汰脏页前必须确保其 Redo 已落盘。

7.2 刷盘策略

策略提交时行为性能/安全权衡
每次都 fsync每组提交刷盘最安全,性能最差
组提交 Group Commit合并多次提交一次 fsync兼顾性能与安全
延迟刷盘定时批量刷盘高吞吐,断电可能丢已提交事务

8. 查询优化与执行计划

8.1 查询执行流程

SQL → 词法/语法分析 → 逻辑优化(谓词下推、等价改写)→ 物理优化(选择执行计划)→ 执行算子(扫描/连接/聚合/排序)。

连接算法复杂度适用场景
Nested Loop JoinO(n·m)小表驱动大表,带索引更好
Hash JoinO(n+m)无索引、等值连接(哈希)
Merge JoinO(n+m)两侧已排序(按连接键)

8.2 读懂 EXPLAIN

EXPLAIN SELECT d.name, COUNT(u.id)
FROM dept d LEFT JOIN users u ON u.dept_id = d.id
WHERE d.location = 'Beijing'
GROUP BY d.id;

-- 输出关键列
-- type: ALL | index | range | ref | eq_ref | const   (访问方式,从上到下性能递增)
-- key:  实际使用的索引
-- rows: 预估扫描行数
-- Extra: Using index(覆盖索引)/ Using filesort / Using temporary

优化铁律:

  1. type 尽量到 ref/eq_ref,避免 ALL(全表扫描);
  2. rows 与真实行数偏差大时,检查统计信息是否过期(ANALYZE TABLE);
  3. Using filesort/Using temporary 出现时,考虑为排序列建索引;
  4. 覆盖索引(Extra: Using index)可让查询只读索引页,避免回表,是最高效的加速手段。

9. 范式与反范式

9.1 规范化(Normalization)

范式核心要求解决的问题
1NF属性不可再分(原子性)表结构混乱
2NF非主属性完全依赖候选键部分依赖(冗余)
3NF非主属性不传递依赖主键传递依赖(更新异常)
BCNF每个决定因素都是候选键3NF 的残余异常
-- 违反 2NF:订单明细表存在部分依赖
CREATE TABLE order_items (
    order_id  INT,
    product_id INT,
    product_name VARCHAR(100),   -- 只依赖 product_id,与 order_id 无关
    qty       INT,
    PRIMARY KEY (order_id, product_id)
);
-- 修正:拆出 product 表,订单明细只存 product_id

9.2 反范式(Denormalization)

范式消除冗余,但带来更多 JOIN。读多写少的场景常用反范式换性能:冗余热点字段、预先聚合、引入快照列。

手段收益代价适用场景
冗余字段免 JOIN更新一致性成本点赞数、粉丝数
汇总表免实时聚合定时任务延迟报表、排行榜
缓存列免子查询需双写商品详情页
宽表免多表关联表结构臃肿数仓、OLAP

设计决策本质是权衡:读性能 ↔ 写一致性与存储。OLTP 坚持 3NF,OLAP/读场景按需反范式,是业界共识。


参考文章

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机基础」更多文章

  1. 22. CPU 缓存与一致性
  2. 21. 传输层与 TCP 深入
  3. 20. 编译原理基础