数据库存储基础:理解表结构、主键、事务与 BLOB

适用环境:关系型数据库基础概念,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. 自检题

  1. 为什么列表中的行号不适合作为记录的稳定身份?
  2. NULL 分数和 0 分对 AVG(score) 有什么不同?
  3. SQL 语句写了外键,为什么还要检查连接配置?
  4. 一个 BLOB 增加了内部字段,是否一定需要增加数据库列?
  5. 为什么插入顺序不能直接作为查询顺序?

参考答案

  1. 排序、过滤和分页都会改变显示位置,主键标识不应跟随这些变化。
  2. AVG 忽略 NULL,但会把有效的零分纳入计算。
  3. 特别在 SQLite 中,外键执行由连接设置控制;只看声明不足以确认约束生效。
  4. 不一定。先判断改变的是数据包格式还是表结构,两者分别管理。
  5. 没有 ORDER BY 时,查询结果顺序不作保证,执行计划变化也可能改变顺序。

建议练习:插入一名没有成绩的学生,分别使用 INNER JOIN 和 LEFT JOIN 查询,观察结果差异;再为该学生插入 NULL 分数与 0 分记录,比较 COUNT 与 AVG 的结果。

相关推荐
JackLam1 小时前
Gitea HTTPS 反向代理配置
数据库
量子物理学1 小时前
oracle plsql instantclient 安装
数据库·oracle
草莓熊Lotso1 小时前
【C++ 基于微服务的即时通讯系统】网关基建:cpp-httplib 与 WebSocketpp 从入门到实战
linux·开发语言·数据库·c++·redis·微服务·云原生
李纲明1 小时前
WordPress 媒体库上传失败或一直转圈:先核 PHP 上传限制与目录权限,再查安全插件与对象存储
运维·开发语言·安全·php·wordpress·媒体库·devdiy
ClickHouseDB9 小时前
ClickHouse Cloud 与 Snowflake 实时性价比对比:CostBench 测试中的 412 倍差距来
运维·数据库·系统架构
第九序言T10 小时前
Python 数据处理进阶(十八)批量处理同结构文件:一个文件夹一次跑完
开发语言·python·自动化·pandas·数据处理
m0_6399069410 小时前
Redis入门|五种数据结构、使用场景、持久化RDB&AOF
数据结构·数据库·redis
Sakura_j_h_10 小时前
EasyEDA PCB 快速设计Skills
c++·python·github
时间售卖铺11 小时前
触发器与函数实战:MySQL高效编程秘诀
运维·数据库·mysql·云原生