适用环境:关系型数据库基础概念,SQL 示例使用 SQLite 3。示例应在新的空数据库或内存数据库中执行,不要直接粘贴到业务数据库。
学习目标:理解程序中的对象如何映射到数据库,区分 NULL 与零值,掌握数据关联、事务及二进制字段的用途。
示例验证:主示例及外键拒绝、事务回滚、BLOB 建表已在 SQLite 3.53.1 内存数据库中验证。
1. 数据库首先解决什么问题
一个程序可以把学生信息放在数组或对象中,但进程退出后,普通内存中的对象不会自动保留。需要下次继续使用,就必须持久化,也就是把数据保存到程序生命周期之外的存储介质。
普通文件可以完成保存。数据库进一步提供结构化查询、约束、事务等能力,使程序能够回答"谁有哪些记录""哪些数据有效""多个写入是否一起完成"等问题。
数据库不是 C++ 对象容器的直接磁盘复制。对象与数据库之间通常需要明确的转换代码。
cpp
程序中的对象 → 参数绑定和 SQL → 数据库记录
数据库记录 → 查询和字段转换 → 程序中的对象
数据库负责按照约定保存和查询字段,不会自动理解某个业务结构体新增加了什么成员。
2. 表、行、列与模式
可以把一张表理解为一种记录的集合:
- 列定义记录中的属性,例如学生姓名、分数。
- 行代表一条具体记录。
- 表结构及其约束共同构成数据库模式的一部分,通常称为 schema。
"看起来像 Excel 表格"只是入门类比。数据库还能够施加唯一性、关联关系和事务规则,而且查询结果的顺序不能依赖界面当前看起来的排列。
本文用两张表:students 保存学生,exam_results 保存每次考试结果。同一学生可以参加多次考试,所以两张表是一对多关系。
3. 完整的 SQLite 示例
SQLite 命令行可以用 sqlite3 :memory: 打开内存数据库。下方第一段代码包含建表、插入和查询,适合一次执行。
cpp
PRAGMA foreign_keys = ON;
CREATE TABLE students (
student_id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE exam_results (
result_id INTEGER PRIMARY KEY,
student_id INTEGER NOT NULL,
exam_name TEXT NOT NULL,
attempt INTEGER NOT NULL CHECK (attempt >= 1),
score REAL,
CHECK (score IS NULL OR (score >= 0 AND score <= 100)),
UNIQUE (student_id, exam_name, attempt),
FOREIGN KEY (student_id) REFERENCES students(student_id)
);
CREATE INDEX idx_results_student ON exam_results(student_id);
BEGIN TRANSACTION;
INSERT INTO students (student_id, name) VALUES
(1, 'Alice'),
(2, 'Bob');
INSERT INTO exam_results
(result_id, student_id, exam_name, attempt, score)
VALUES
(101, 1, 'Math', 1, 95.0),
(102, 1, 'Physics', 1, NULL),
(103, 2, 'Math', 1, 0.0);
COMMIT;
SELECT
r.result_id,
s.name,
r.exam_name,
r.score
FROM exam_results AS r
JOIN students AS s ON s.student_id = r.student_id
ORDER BY r.result_id;
SELECT
COUNT(*) AS record_count,
COUNT(score) AS scored_count,
AVG(score) AS average_score
FROM exam_results;
第一条查询得到以下记录;SQLite 工具可能把 NULL 显示为空白,但它并不是空字符串。
| result_id | name | exam_name | score |
|---|---|---|---|
| 101 | Alice | Math | 95.0 |
| 102 | Alice | Physics | NULL |
| 103 | Bob | Math | 0.0 |
第二条查询的结果是:记录数量 3,有分数的记录数量 2,平均分 47.5。
这不是把缺失分数当作 0 计算,而是 AVG(score) 忽略 NULL;实际存在的 0 分仍然参与平均值。
4. 主键和外键有什么区别
4.1 主键标识一条记录
student_id 用于稳定地识别某个学生。姓名可能重复或修改,不适合默认当作唯一身份。
主键不是显示列表中的"第几行"。查询排序改变后,记录在结果集中的位置可以变化,但它的 ID 不应因此变化。
本文使用 INTEGER PRIMARY KEY。在普通 SQLite rowid 表中,这种精确声明有特殊语义,与内部 rowid 关联。不要因此把所有数据库中的整数主键都理解成同一种实现。
4.2 外键表达关联并约束存在性
exam_results.student_id 引用 students.student_id,表示考试记录必须属于一个存在的学生。
SQLite 应在连接建立后、事务开始前显式启用并检查外键约束,不要仅凭建表语句里写了 FOREIGN KEY 就认为当前连接一定正在执行检查。SQLite 官方文档:外键
以下是预期失败的反例,应单独执行:
cpp
-- 失败示例:学生 999 不存在。
INSERT INTO exam_results
(result_id, student_id, exam_name, attempt, score)
VALUES
(104, 999, 'Math', 1, 60.0);
删除学生时如何处理关联成绩,也需要明确选择:禁止删除、级联删除,或采用业务层的归档方案。不能因为外键报错就简单关闭约束。
4.3 业务唯一性可能需要复合约束
成绩记录有自己的主键,但还需要防止"同一个学生、同一门考试、同一次考试序号"重复插入。因此示例另加:
cpp
UNIQUE (student_id, exam_name, attempt)
主键回答"这一条是哪条",业务唯一约束回答"哪种组合不允许重复",两者可以同时存在。
5. NULL、0 和空字符串为什么不能混用
| 值 | 本例含义 | 适合怎样理解 |
|---|---|---|
NULL |
尚未得到分数 | 缺失或未知,需要由业务说明原因 |
0.0 |
确实得到零分 | 一个有效数值 |
'' |
空文本 | 一个长度为零的字符串 |
查找缺失分数应使用:
cpp
SELECT result_id FROM exam_results WHERE score IS NULL;
不要写成 score = NULL。SQL 对 NULL 采用特殊的三值逻辑,普通相等比较不能像比较数字 0 那样使用。
如果"未参加""尚未批改""测量失败"需要不同处理,应增加状态字段,而不是把所有情况塞进同一个 NULL。存储层保留真实状态,界面和导出层再决定展示成空白、横线或说明文字。
不要把显示用的 --- 直接写进应当保存数值的列;这会使查询、聚合和跨数据库迁移更复杂。
6. SQLite 的类型为什么需要单独注意
SQLite 普通表使用类型亲和性,值具有存储类别,包括 NULL、INTEGER、REAL、TEXT 和 BLOB。声明列类型不等于获得所有静态类型数据库相同的强约束。需要更严格控制时,可结合输入验证、约束,以及支持版本中的 STRICT 表设计。SQLite 官方文档:数据类型
因此,"C++ 变量是 double"与"数据库中永远只有合法浮点数"不是一回事。转换与写入阶段需要检查,读取时也不能盲目信任历史数据。
金额等需要精确十进制语义的数据,也不应不加分析地使用二进制浮点数。按最小货币单位保存整数是一种常见选择,但仍要明确币种、单位和范围。
7. 为什么多条写入需要事务
假设一次操作需要建立订单并写入订单明细。如果订单成功,明细写入失败,最终就可能留下不完整业务状态。
事务用于把一组操作组织成整体:提交成功后保留,失败时回滚。需要注意,SQLite 中语句失败不一定自动撤销整个显式事务,应用应检查返回结果并决定回滚。SQLite 官方文档:事务
下面是独立的流程示例,结束后不保留这次修改:
cpp
BEGIN TRANSACTION;
UPDATE exam_results SET score = 80.0 WHERE result_id = 102;
ROLLBACK;
SELECT score FROM exam_results WHERE result_id = 102;
查询仍为 NULL。
实际程序逻辑通常是:开启事务,逐条执行并检查,全部成功后提交,否则回滚。COMMIT 自身也可能失败,不能忽略其结果。
事务的常见四个性质称为 ACID:原子性、一致性、隔离性和持久性。其中一致性依赖约束与正确的业务操作;数据库不会自动判断"发货数量是否符合你的业务规则"。持久性和隔离的具体保证也要结合数据库配置、存储环境与实现理解。
8. 索引帮助查找但不保证输出顺序
索引为某些查询提供更高效的查找路径,但会增加存储空间和写入维护成本。是否有效取决于数据规模和查询条件,不能认为"每列都建索引就最快"。
可以观察 SQLite 的查询计划:
cpp
EXPLAIN QUERY PLAN
SELECT * FROM exam_results WHERE student_id = 1;
示例已经有复合唯一约束,SQLite 创建的对应索引也可能支持其前缀列查询,因此单列索引是否值得保留应结合真实查询计划和负载决定。
即使查询用了索引,也应通过 ORDER BY 明确要求的顺序。没有 ORDER BY 的结果顺序未定义;排序键存在相同值时,应补充稳定的次级键。SQLite 官方文档:SELECT
cpp
SELECT * FROM exam_results
ORDER BY student_id, exam_name, attempt, result_id;
9. BLOB 与普通列怎么选择
BLOB 是二进制数据字段,可以保存图片、压缩内容或自定义序列化结果。它并不自动创建另一张表,也不自动解析其中的业务成员。
cpp
CREATE TABLE student_assets (
student_id INTEGER PRIMARY KEY REFERENCES students(student_id),
format_version INTEGER NOT NULL,
payload BLOB NOT NULL
);
这里的 format_version 和 payload 是两列。payload 内部究竟包含什么,需要应用自己的编解码规则。
| 方式 | 优点 | 代价 |
|---|---|---|
| 业务字段拆成列 | 易于 SQL 筛选、聚合和添加约束 | 结构变化可能需要数据库迁移 |
| 内容编码进 BLOB | 适合整体读取的复杂数据包 | 不透明,不便直接按内部字段查询 |
| 元信息用列,复杂内容用 BLOB | 能兼顾检索与完整内容保存 | 需要保持两者一致 |
如果经常需要查询"所有分数大于 90 的记录",分数通常适合单独成列。如果内容主要是整张图片,BLOB 或外部文件加元信息可能更合适。
BLOB 中的格式版本与数据库表结构版本是两个维度。更新了数据包,不一定要新建表;修改了表,也不意味着文件编码必须跟着改变。
10. C++ 程序怎样安全地写数据库
应使用参数绑定传递值,而不是把输入拼接进 SQL。
cpp
INSERT INTO students (student_id, name) VALUES (?, ?);
不同 C++ 数据库库提供不同绑定 API,但基本过程相同:准备语句、绑定参数、执行、检查结果、释放语句资源。
参数占位符用于值,通常不能直接替代任意表名、列名或 SQL 语法。动态选择列名时,应从受控白名单中选择。
读取时同样要先检查字段是否为 NULL,再把它转换为 C++ 数值。如果把 NULL 默认转换成 0,却丢掉是否为空的信息,业务层将无法区分"缺失"和"零值"。可用 std::optional<double> 表达这类可缺失数据。
连接和查询对象需要明确生命周期。使用 RAII 封装通常能够减少资源泄漏,但事务成功与失败仍需显式处理,不能把析构当成正常提交的替代品。

11. 自检题
- 为什么列表中的行号不适合作为记录的稳定身份?
- NULL 分数和 0 分对
AVG(score)有什么不同? - SQL 语句写了外键,为什么还要检查连接配置?
- 一个 BLOB 增加了内部字段,是否一定需要增加数据库列?
- 为什么插入顺序不能直接作为查询顺序?
参考答案
- 排序、过滤和分页都会改变显示位置,主键标识不应跟随这些变化。
- AVG 忽略 NULL,但会把有效的零分纳入计算。
- 特别在 SQLite 中,外键执行由连接设置控制;只看声明不足以确认约束生效。
- 不一定。先判断改变的是数据包格式还是表结构,两者分别管理。
- 没有 ORDER BY 时,查询结果顺序不作保证,执行计划变化也可能改变顺序。
建议练习:插入一名没有成绩的学生,分别使用 INNER JOIN 和 LEFT JOIN 查询,观察结果差异;再为该学生插入 NULL 分数与 0 分记录,比较 COUNT 与 AVG 的结果。