数据库与数据存储
数据库是系统对“什么事实必须被长期保存”的正式回答。代码可以重启,缓存可以丢失,日志可以轮转;数据库中的订单、用户和状态通常必须可恢复、可查询并保持一致。
选择存储之前,先写清访问模式:谁写入、写多频繁、按什么条件读、允许多旧、数据量和保留期多大、失败时能丢多少。不同存储只是在读写成本、一致性、查询能力和运维复杂度之间做不同取舍。
1. 数据模型先于数据库
跳转到“1. 数据模型先于数据库”设计表之前,应先识别实体、属性、关系和约束。
User 1 ─── N Order 1 ─── N OrderItem │ └── N Product- 实体:用户、订单、商品。
- 属性:订单状态、金额、创建时间。
- 关系:一个用户有多个订单,一个订单包含多个条目。
- 约束:订单必须有用户,数量必须大于零,支付后不能回到未创建状态。
如果实体边界和业务约束没想清楚,换数据库只会换一种方式混乱。
2. 关系型数据库的基本结构
跳转到“2. 关系型数据库的基本结构”关系型数据库用表、行和列表示数据,用主键、外键、唯一约束和检查约束维护结构。
CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id), status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'cancelled')), total_cents BIGINT NOT NULL CHECK (total_cents >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now());2.1 主键与外键
跳转到“2.1 主键与外键”- 主键唯一标识一行,必须稳定且不可复用。
- 外键保证引用对象存在,但会带来锁、级联删除和跨表检查成本。
- 唯一约束防止重复,例如同一用户只能有一条默认地址。
- 检查约束把简单不变量放到数据库,但复杂业务规则通常仍在应用层。
用邮箱、手机号等可变字段作主键会让关联和迁移复杂。代理键更稳定,业务唯一性用单独的唯一索引保证。
2.2 NULL 不是空字符串
跳转到“2.2 NULL 不是空字符串”NULL 表示未知或不存在,参与比较时通常不等于任何值,包括另一个 NULL。NULL = NULL 的结果不是真。查询时要使用 IS NULL 或 IS NOT NULL。
设计字段时明确:未知、空、默认值是不是同一件事。0、'' 和 NULL 的业务含义不同,不能混用。
3. SQL 与查询
跳转到“3. SQL 与查询”SQL 是声明式语言:描述想得到什么,由数据库决定怎样执行。
SELECT o.id, o.status, o.total_centsFROM orders oWHERE o.user_id = $1 AND o.status = 'paid'ORDER BY o.created_at DESCLIMIT 20;3.1 连接
跳转到“3.1 连接”连接把多张表按关系组合:
INNER JOIN只保留双方都匹配的行。LEFT JOIN保留左表全部行,右表无匹配时为NULL。RIGHT JOIN是相反方向。FULL OUTER JOIN保留双方所有行,很多数据库或业务并不需要。
连接条件漏写会形成笛卡尔积,结果行数暴涨。找“没有关联记录”的行时要用 LEFT JOIN ... WHERE right.id IS NULL,不要误把过滤条件放在 WHERE 里清掉外连接语义。
3.2 聚合与分组
跳转到“3.2 聚合与分组”SELECT status, COUNT(*) AS order_count, SUM(total_cents) AS amountFROM ordersWHERE created_at >= now() - interval '7 days'GROUP BY status;WHERE 在分组前过滤行,HAVING 在分组后过滤组。聚合查询要明确时间范围、时区和重复计算规则,否则报表数字会难以解释。
3.3 分页与稳定排序
跳转到“3.3 分页与稳定排序”OFFSET 分页在数据变化时可能重复或跳过,并且偏移很大时数据库仍需查找并丢弃前面行。游标分页使用稳定排序:
SELECT id, created_atFROM ordersWHERE (created_at, id) < ($1, $2)ORDER BY created_at DESC, id DESCLIMIT 20;排序列最好有索引,并加入唯一列打破并列。否则相同时间戳的先后顺序不稳定。
4. 索引:用写入成本换查询速度
跳转到“4. 索引:用写入成本换查询速度”没有索引时,数据库可能扫描整张表。索引维护一份额外结构,让数据库快速定位目标行。代价是占用空间,并在插入、更新、删除时同步维护。
4.1 B+ 树
跳转到“4.1 B+ 树”关系数据库常见索引使用 B+ 树。它是有序的多层树,非叶子节点存键与子节点指针,叶子节点存键和行位置,叶子之间通常相连,适合:
- 等值查询。
- 范围查询。
- 排序。
- 前缀匹配。
哈希索引只适合等值查找,不擅长范围和排序。全文、向量和空间索引又各有自己的数据结构。
4.2 联合索引与最左前缀
跳转到“4.2 联合索引与最左前缀”联合索引 (user_id, status, created_at) 通常可以支持:
user_iduser_id + statususer_id + status + created_at
但通常不能直接从 status 或 created_at 高效利用整棵索引。字段顺序取决于查询模式与选择性,不是把最常用的字段随便放第一个。
索引列上做函数计算、隐式类型转换或前后通配符匹配,可能让数据库无法使用索引:
-- 可能无法使用 created_at 索引WHERE DATE(created_at) = '2026-10-31'
-- 改成范围条件WHERE created_at >= '2026-10-31' AND created_at < '2026-11-01'4.3 覆盖索引与回表
跳转到“4.3 覆盖索引与回表”如果索引已经包含查询需要的全部列,数据库可能无需回到主表读取,这叫覆盖索引。它减少随机 I/O,但索引过宽会增加写入和存储成本。
4.4 执行计划
跳转到“4.4 执行计划”不要凭感觉判断索引有没有生效。使用 EXPLAIN 或数据库的执行计划工具观察:
- 是否全表扫描。
- 使用哪个索引。
- 预计扫描行数与实际行数。
- 是否发生排序、临时表或嵌套循环过多。
- 每一步的估算成本。
统计信息过期、数据分布倾斜和参数值都会影响计划。慢查询要结合真实参数和数据库版本分析。
5. 事务
跳转到“5. 事务”事务把一组操作视为一个逻辑单元:要么全部提交,要么全部回滚。
BEGIN;UPDATE accounts SET balance = balance - 100 WHERE id = 1;UPDATE accounts SET balance = balance + 100 WHERE id = 2;COMMIT;如果第二条失败,第一条也应回滚,否则钱消失。事务边界应围绕业务不变量,而不是围绕单个 SQL 语句。
5.1 ACID
跳转到“5.1 ACID”| 特性 | 含义 |
|---|---|
| Atomicity 原子性 | 一组操作全部成功或全部失败 |
| Consistency 一致性 | 事务前后满足数据库约束与业务不变量 |
| Isolation 隔离性 | 并发事务之间按一定规则互相隔离 |
| Durability 持久性 | 提交后即使故障也能恢复 |
不同数据库默认隔离级别不同,应用必须知道自己依赖哪一种。
5.2 并发异常
跳转到“5.2 并发异常”- 脏读:读到了另一个事务尚未提交的数据。
- 不可重复读:同一事务内两次读取同一行,结果不同。
- 幻读:同一条件第二次查询出现新行。
- 丢失更新:两个事务基于旧值修改,后写覆盖先写。
- 写偏斜:两个事务分别满足自身约束,但组合后破坏全局不变量。
5.3 隔离级别
跳转到“5.3 隔离级别”| 级别 | 大致效果 | 代价 |
|---|---|---|
| Read Uncommitted | 隔离最弱,可能脏读 | 并发高,但结果不可靠 |
| Read Committed | 通常避免脏读 | 同一事务内可能看到不同版本 |
| Repeatable Read | 同一事务重复读通常稳定 | 实现依赖数据库,仍可能有写偏斜 |
| Serializable | 最强隔离 | 冲突、重试和吞吐成本最高 |
不要假设级别名称在所有数据库行为完全一致。PostgreSQL、MySQL、SQL Server 对快照和锁的实现有差异,要阅读具体数据库文档。
5.4 锁与 MVCC
跳转到“5.4 锁与 MVCC”锁防止并发修改冲突,但会带来等待和死锁。MVCC(多版本并发控制)通过保存多个版本,让读操作不必等待写操作,但会增加存储和清理成本。
处理死锁的常见方式是捕获数据库错误、自动重试整个事务,并确保重试逻辑幂等。重试事务时不能只重跑中间一条 SQL。
5.5 乐观锁与悲观锁
跳转到“5.5 乐观锁与悲观锁”- 悲观锁先加锁再修改,适合冲突频繁、必须立即阻止并发。
- 乐观锁读取版本号,提交时检查版本是否变化;冲突时重试。适合冲突较少的长流程。
UPDATE documentsSET content = $1, version = version + 1WHERE id = $2 AND version = $3;受影响行数为 0 通常表示版本冲突,应告知用户或重新加载,而不是覆盖。
6. Schema 设计与迁移
跳转到“6. Schema 设计与迁移”6.1 规范化与反规范化
跳转到“6.1 规范化与反规范化”规范化减少重复数据,让一个事实只存一处。反规范化把常用字段复制到多个地方,减少连接或查询成本,但增加了同步责任。
合理的反规范化通常有明确触发条件,例如读多写少、报表性能或历史快照。复制字段前要问:谁是事实来源,更新失败时怎样修复。
6.2 字段类型
跳转到“6.2 字段类型”- 金额使用整数最小单位或精确十进制,不用二进制浮点。
- 时间明确时区;跨地区系统通常保存 UTC,并在展示时转换。
- 枚举可以用约束或字典表,避免拼写和非法值。
- 大文本与二进制大对象通常不适合放进高频主表。
- 大整数 ID 对外传输时注意客户端精度。
6.3 迁移
跳转到“6.3 迁移”迁移不只是 ALTER TABLE。需要考虑:
- 大表加列、改类型和建索引的锁与耗时。
- 新旧代码同时运行时的兼容。
- 数据回填的批次、断点与限速。
- 回滚是否可行,是否必须先备份。
- 迁移脚本是否幂等,是否记录执行版本。
一种安全模式是“扩展—迁移—收缩”:先添加兼容结构,双写或回填,切换读取,确认稳定后再删除旧结构。
7. 不只关系型数据库
跳转到“7. 不只关系型数据库”7.1 文档数据库
跳转到“7.1 文档数据库”以文档为单元,适合结构灵活、经常整体读取的对象。缺点是跨文档关系、事务和一致性通常更复杂。文档不是“随便塞 JSON”的理由,仍然需要 schema 演进和索引。
7.2 键值存储
跳转到“7.2 键值存储”按 key 读写,简单、水平扩展方便,适合缓存、会话和简单映射。复杂查询通常要由应用自己维护索引。
7.3 宽列数据库
跳转到“7.3 宽列数据库”按分区键和列族组织,适合大规模写入和按已知键查询。数据建模围绕查询路径,跨分区查询代价高。
7.4 图数据库
跳转到“7.4 图数据库”用节点和边表达关系,适合多跳关系、路径和推荐。常见普通关系表也能表达图,只有在查询模式确实以多跳遍历为主时才值得引入。
7.5 时序数据库
跳转到“7.5 时序数据库”为时间戳、标签和聚合查询优化,适合指标、监控和事件数据。保留期、降采样和查询窗口需要提前设计。
7.6 搜索引擎
跳转到“7.6 搜索引擎”倒排索引支持全文检索、分词、相关性和聚合。它通常不是事实来源,而是由主数据库异步同步出的查询索引。要处理索引延迟、重建、权限同步和删除。
7.7 对象存储
跳转到“7.7 对象存储”适合图片、视频、备份和大文件。数据库保存对象键、元数据、所有者、访问策略和生命周期。不要把大文件内容塞进数据库行,也不要假设对象存储 URL 天然公开安全。
8. 存储选择矩阵
跳转到“8. 存储选择矩阵”| 需求 | 优先考虑 | 关键问题 |
|---|---|---|
| 事务与复杂查询 | 关系型数据库 | 约束、索引、隔离与迁移 |
| 简单键值访问 | KV / 缓存 | 过期、容量、一致性 |
| 灵活文档 | 文档数据库 | schema 演进、跨文档一致性 |
| 全文搜索 | 搜索引擎 | 同步、分词、相关性与权限 |
| 大文件 | 对象存储 + 元数据表 | 访问控制、生命周期、直传 |
| 多跳关系 | 图数据库或关系模型 | 查询模式与规模 |
| 指标时间序列 | 时序数据库 | 标签基数、保留期、降采样 |
9. 备份、恢复与一致性
跳转到“9. 备份、恢复与一致性”备份只有成功恢复才算有效。需要定期演练:
- 备份频率与保留期。
- 全量、增量和日志备份的组合。
- 恢复需要多久,数据能丢多少。
- 备份是否加密,谁能访问。
- 恢复后应用是否连接到正确环境。
- 误删和勒索场景是否有隔离副本。
主从复制可以提高可用性与读能力,但存在复制延迟。刚写入就从副本读取可能看不到数据。涉及“读到自己刚刚写的内容”时,要路由到主库或使用一致性标记。
检查清单
跳转到“检查清单”- 是否先明确访问模式,再选存储类型。
- 主键、外键、唯一约束和 NULL 语义是否清楚。
- 查询是否有稳定排序和分页边界。
- 慢查询是否查看执行计划,而不是凭感觉加索引。
- 索引是否匹配查询顺序,是否评估写入成本。
- 事务是否围绕业务不变量,隔离级别是否明确。
- 是否处理丢失更新、冲突重试和死锁。
- 字段类型是否适合金额、时间、ID 和二进制数据。
- 迁移是否支持新旧版本并行和回填。
- 备份是否演练过恢复,是否知道 RPO 与 RTO。