跳转到内容

数据库与数据存储

数据库是系统对“什么事实必须被长期保存”的正式回答。代码可以重启,缓存可以丢失,日志可以轮转;数据库中的订单、用户和状态通常必须可恢复、可查询并保持一致。

选择存储之前,先写清访问模式:谁写入、写多频繁、按什么条件读、允许多旧、数据量和保留期多大、失败时能丢多少。不同存储只是在读写成本、一致性、查询能力和运维复杂度之间做不同取舍。

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()
);
  • 主键唯一标识一行,必须稳定且不可复用。
  • 外键保证引用对象存在,但会带来锁、级联删除和跨表检查成本。
  • 唯一约束防止重复,例如同一用户只能有一条默认地址。
  • 检查约束把简单不变量放到数据库,但复杂业务规则通常仍在应用层。

用邮箱、手机号等可变字段作主键会让关联和迁移复杂。代理键更稳定,业务唯一性用单独的唯一索引保证。

NULL 表示未知或不存在,参与比较时通常不等于任何值,包括另一个 NULL。NULL = NULL 的结果不是真。查询时要使用 IS NULL 或 IS NOT NULL。

设计字段时明确:未知、空、默认值是不是同一件事。0、'' 和 NULL 的业务含义不同,不能混用。

SQL 是声明式语言:描述想得到什么,由数据库决定怎样执行。

SELECT o.id, o.status, o.total_cents
FROM orders o
WHERE o.user_id = $1
AND o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 20;

连接把多张表按关系组合:

  • INNER JOIN 只保留双方都匹配的行。
  • LEFT JOIN 保留左表全部行,右表无匹配时为 NULL。
  • RIGHT JOIN 是相反方向。
  • FULL OUTER JOIN 保留双方所有行,很多数据库或业务并不需要。

连接条件漏写会形成笛卡尔积,结果行数暴涨。找“没有关联记录”的行时要用 LEFT JOIN ... WHERE right.id IS NULL,不要误把过滤条件放在 WHERE 里清掉外连接语义。

SELECT status, COUNT(*) AS order_count, SUM(total_cents) AS amount
FROM orders
WHERE created_at >= now() - interval '7 days'
GROUP BY status;

WHERE 在分组前过滤行,HAVING 在分组后过滤组。聚合查询要明确时间范围、时区和重复计算规则,否则报表数字会难以解释。

OFFSET 分页在数据变化时可能重复或跳过,并且偏移很大时数据库仍需查找并丢弃前面行。游标分页使用稳定排序:

SELECT id, created_at
FROM orders
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;

排序列最好有索引,并加入唯一列打破并列。否则相同时间戳的先后顺序不稳定。

4. 索引:用写入成本换查询速度

跳转到“4. 索引:用写入成本换查询速度”

没有索引时,数据库可能扫描整张表。索引维护一份额外结构,让数据库快速定位目标行。代价是占用空间,并在插入、更新、删除时同步维护。

关系数据库常见索引使用 B+ 树。它是有序的多层树,非叶子节点存键与子节点指针,叶子节点存键和行位置,叶子之间通常相连,适合:

  • 等值查询。
  • 范围查询。
  • 排序。
  • 前缀匹配。

哈希索引只适合等值查找,不擅长范围和排序。全文、向量和空间索引又各有自己的数据结构。

4.2 联合索引与最左前缀

跳转到“4.2 联合索引与最左前缀”

联合索引 (user_id, status, created_at) 通常可以支持:

  • user_id
  • user_id + status
  • user_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'

如果索引已经包含查询需要的全部列,数据库可能无需回到主表读取,这叫覆盖索引。它减少随机 I/O,但索引过宽会增加写入和存储成本。

不要凭感觉判断索引有没有生效。使用 EXPLAIN 或数据库的执行计划工具观察:

  • 是否全表扫描。
  • 使用哪个索引。
  • 预计扫描行数与实际行数。
  • 是否发生排序、临时表或嵌套循环过多。
  • 每一步的估算成本。

统计信息过期、数据分布倾斜和参数值都会影响计划。慢查询要结合真实参数和数据库版本分析。

事务把一组操作视为一个逻辑单元:要么全部提交,要么全部回滚。

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

如果第二条失败,第一条也应回滚,否则钱消失。事务边界应围绕业务不变量,而不是围绕单个 SQL 语句。

特性 含义
Atomicity 原子性 一组操作全部成功或全部失败
Consistency 一致性 事务前后满足数据库约束与业务不变量
Isolation 隔离性 并发事务之间按一定规则互相隔离
Durability 持久性 提交后即使故障也能恢复

不同数据库默认隔离级别不同,应用必须知道自己依赖哪一种。

  • 脏读:读到了另一个事务尚未提交的数据。
  • 不可重复读:同一事务内两次读取同一行,结果不同。
  • 幻读:同一条件第二次查询出现新行。
  • 丢失更新:两个事务基于旧值修改,后写覆盖先写。
  • 写偏斜:两个事务分别满足自身约束,但组合后破坏全局不变量。
级别 大致效果 代价
Read Uncommitted 隔离最弱,可能脏读 并发高,但结果不可靠
Read Committed 通常避免脏读 同一事务内可能看到不同版本
Repeatable Read 同一事务重复读通常稳定 实现依赖数据库,仍可能有写偏斜
Serializable 最强隔离 冲突、重试和吞吐成本最高

不要假设级别名称在所有数据库行为完全一致。PostgreSQL、MySQL、SQL Server 对快照和锁的实现有差异,要阅读具体数据库文档。

锁防止并发修改冲突,但会带来等待和死锁。MVCC(多版本并发控制)通过保存多个版本,让读操作不必等待写操作,但会增加存储和清理成本。

处理死锁的常见方式是捕获数据库错误、自动重试整个事务,并确保重试逻辑幂等。重试事务时不能只重跑中间一条 SQL。

  • 悲观锁先加锁再修改,适合冲突频繁、必须立即阻止并发。
  • 乐观锁读取版本号,提交时检查版本是否变化;冲突时重试。适合冲突较少的长流程。
UPDATE documents
SET content = $1, version = version + 1
WHERE id = $2 AND version = $3;

受影响行数为 0 通常表示版本冲突,应告知用户或重新加载,而不是覆盖。

规范化减少重复数据,让一个事实只存一处。反规范化把常用字段复制到多个地方,减少连接或查询成本,但增加了同步责任。

合理的反规范化通常有明确触发条件,例如读多写少、报表性能或历史快照。复制字段前要问:谁是事实来源,更新失败时怎样修复。

  • 金额使用整数最小单位或精确十进制,不用二进制浮点。
  • 时间明确时区;跨地区系统通常保存 UTC,并在展示时转换。
  • 枚举可以用约束或字典表,避免拼写和非法值。
  • 大文本与二进制大对象通常不适合放进高频主表。
  • 大整数 ID 对外传输时注意客户端精度。

迁移不只是 ALTER TABLE。需要考虑:

  • 大表加列、改类型和建索引的锁与耗时。
  • 新旧代码同时运行时的兼容。
  • 数据回填的批次、断点与限速。
  • 回滚是否可行,是否必须先备份。
  • 迁移脚本是否幂等,是否记录执行版本。

一种安全模式是“扩展—迁移—收缩”:先添加兼容结构,双写或回填,切换读取,确认稳定后再删除旧结构。

以文档为单元,适合结构灵活、经常整体读取的对象。缺点是跨文档关系、事务和一致性通常更复杂。文档不是“随便塞 JSON”的理由,仍然需要 schema 演进和索引。

按 key 读写,简单、水平扩展方便,适合缓存、会话和简单映射。复杂查询通常要由应用自己维护索引。

按分区键和列族组织,适合大规模写入和按已知键查询。数据建模围绕查询路径,跨分区查询代价高。

用节点和边表达关系,适合多跳关系、路径和推荐。常见普通关系表也能表达图,只有在查询模式确实以多跳遍历为主时才值得引入。

为时间戳、标签和聚合查询优化,适合指标、监控和事件数据。保留期、降采样和查询窗口需要提前设计。

倒排索引支持全文检索、分词、相关性和聚合。它通常不是事实来源,而是由主数据库异步同步出的查询索引。要处理索引延迟、重建、权限同步和删除。

适合图片、视频、备份和大文件。数据库保存对象键、元数据、所有者、访问策略和生命周期。不要把大文件内容塞进数据库行,也不要假设对象存储 URL 天然公开安全。

需求 优先考虑 关键问题
事务与复杂查询 关系型数据库 约束、索引、隔离与迁移
简单键值访问 KV / 缓存 过期、容量、一致性
灵活文档 文档数据库 schema 演进、跨文档一致性
全文搜索 搜索引擎 同步、分词、相关性与权限
大文件 对象存储 + 元数据表 访问控制、生命周期、直传
多跳关系 图数据库或关系模型 查询模式与规模
指标时间序列 时序数据库 标签基数、保留期、降采样

9. 备份、恢复与一致性

跳转到“9. 备份、恢复与一致性”

备份只有成功恢复才算有效。需要定期演练:

  • 备份频率与保留期。
  • 全量、增量和日志备份的组合。
  • 恢复需要多久,数据能丢多少。
  • 备份是否加密,谁能访问。
  • 恢复后应用是否连接到正确环境。
  • 误删和勒索场景是否有隔离副本。

主从复制可以提高可用性与读能力,但存在复制延迟。刚写入就从副本读取可能看不到数据。涉及“读到自己刚刚写的内容”时,要路由到主库或使用一致性标记。

  • 是否先明确访问模式,再选存储类型。
  • 主键、外键、唯一约束和 NULL 语义是否清楚。
  • 查询是否有稳定排序和分页边界。
  • 慢查询是否查看执行计划,而不是凭感觉加索引。
  • 索引是否匹配查询顺序,是否评估写入成本。
  • 事务是否围绕业务不变量,隔离级别是否明确。
  • 是否处理丢失更新、冲突重试和死锁。
  • 字段类型是否适合金额、时间、ID 和二进制数据。
  • 迁移是否支持新旧版本并行和回填。
  • 备份是否演练过恢复,是否知道 RPO 与 RTO。