数据库复习不能只停在“SQL 可以增删改查,NoSQL 比较灵活”。真实系统还要回答:一次 JOIN 为什么把金额算多了?商品改价后旧订单怎么办?两个人同时买最后一件商品会不会超卖?有主从副本为什么还需要备份?
本文用一个小型订单系统贯穿建模、查询、性能和并发。SQL 以 PostgreSQL 为主,使用 PostgreSQL 14 及以上共有的基础能力;引用的版本化文档主要为 PostgreSQL 17。MySQL 差异会单独说明,MongoDB、Redis 和图查询片段则属于各自的数据模型示例。
建表和样例数据应在独立练习库中运行。后面的写入演示使用回滚保持样例不变;库存下单部分是需要应用检查结果并控制事务的步骤,不应忽略注释后直接当作完整业务程序执行。
1. 从业务操作推导数据模型
1.1 先明确关系与必须成立的规则
我们需要支持用户查询自己的订单、一个订单购买多个商品、下单扣减库存,以及统计已支付订单的销售额。这里约定所有金额均为 AUD,暂不包含税费、折扣和退款。
users 1 ───── N orders
orders 1 ───── N order_items
products 1 ───── N order_itemsorder_items 连接订单和商品,并记录购买数量与成交单价。本文约定同一个商品在同一订单中合并为一行,因此 (order_id, product_id) 可以唯一标识一条明细。如果业务允许同商品不同批次、不同优惠分行,就应另设明细 ID,而不是强行沿用这个键。
先写清约束:每个订单属于一个用户;每条明细属于一个存在的订单和商品;数量必须为正;价格和库存不能为负。数据库约束保护所有写入入口,应用校验则负责更友好的反馈,两者可以同时存在。
1.2 建表:字段类型与约束表达不同职责
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE products (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(12, 2) NOT NULL CHECK (price >= 0),
stock INTEGER NOT NULL CHECK (stock >= 0),
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0)
);
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')),
created_at TIMESTAMPTZ NOT NULL,
paid_at TIMESTAMPTZ,
CHECK (
(status = 'paid' AND paid_at IS NOT NULL)
OR (status <> 'paid' AND paid_at IS NULL)
)
);
CREATE TABLE order_items (
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(12, 2) NOT NULL CHECK (unit_price >= 0),
PRIMARY KEY (order_id, product_id)
);这里使用显式 ID 方便核对结果;生产系统可以采用 identity、序列或其他 ID 方案。NUMERIC 用于精确十进制金额,不使用近似浮点数;也可以约定用最小货币单位的整数表示。多币种系统还需要币种与舍入规则,不能把不同币种直接相加。
| 约束 | 本例保护的事实 | 不会自动保证的事情 |
|---|---|---|
| PRIMARY KEY | 每行有唯一且非空的标识 | 标识是否难以猜测、调用者是否有权访问 |
| FOREIGN KEY | 明细不会引用不存在的商品 | 商品是否在售、是否允许当前用户购买 |
| UNIQUE | 邮箱值不重复 | 邮箱大小写归一化等业务规则 |
| NOT NULL | 字段不能缺失 | 字符串不为空白 |
| CHECK | 数量、金额和状态组合满足条件 | 复杂跨表规则与合法状态转换 |
在 PostgreSQL 中,CHECK 的结果为未知值时也可能通过,所以 CHECK (stock >= 0) 不能替代 NOT NULL。主键和唯一约束会建立对应的唯一索引,外键的引用方不一定自动获得所需查询索引。PostgreSQL 约束文档
本例不级联删除历史订单;默认外键行为会阻止删除仍被引用的商品。商品下架可以作为状态变化处理。这里的订单状态检查也只是教学约定,真实支付和退款状态需要单独建模。
1.3 一组贯穿全文的数据
INSERT INTO users (id, name, email) VALUES
(1, 'Alice', 'alice@example.com'),
(2, 'Bob', 'bob@example.com'),
(3, 'Chen', 'chen@example.com');
INSERT INTO products (id, name, price, stock) VALUES
(10, 'Keyboard', 120.00, 5),
(11, 'Mouse', 50.00, 8),
(12, 'Stand', 40.00, 1);
INSERT INTO orders (id, user_id, status, created_at, paid_at) VALUES
(101, 1, 'paid', '2026-05-20 10:00:00+00', '2026-05-20 10:05:00+00'),
(102, 1, 'pending', '2026-05-21 09:00:00+00', NULL),
(103, 2, 'paid', '2026-05-21 11:00:00+00', '2026-05-21 11:05:00+00'),
(104, 2, 'paid', '2026-05-21 12:00:00+00', '2026-05-21 12:05:00+00');
INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES
(101, 10, 1, 100.00),
(101, 11, 2, 50.00),
(102, 12, 1, 40.00),
(103, 11, 1, 50.00),
(104, 11, 1, 50.00);订单 101 的金额为 1 × 100 + 2 × 50 = 200;102 为 40,尚未支付;103 和 104 各为 50。因此已支付订单总额为 300。
注意键盘当前价格为 120,历史成交价格为 100。products.price 描述现在的报价,order_items.unit_price 描述那次交易的事实,两者不是必须保持同步的重复字段。样例库存是当前库存快照,不是根据这几条历史订单重新计算的初始库存。
2. SQL:先保证结果正确,再讨论快不快
2.1 查询、排序与 NULL
SELECT id, status, created_at
FROM orders
WHERE user_id = 1 AND status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20;
SELECT id
FROM orders
WHERE paid_at IS NULL
ORDER BY id;第一条只返回 101;第二条返回 102。没有 ORDER BY 就不能依赖结果顺序,时间相同时再用 ID 排序可以避免顺序含糊。
NULL 表示缺失或未知,不能使用 paid_at = NULL 来寻找它。COUNT(*) 统计行数,COUNT(paid_at) 只统计该列非空的行,因此本例分别得到 4 和 3。NOT IN 的子查询若包含 NULL,也可能产生意料之外的未知结果;表达“不存在匹配项”时可以考虑 NOT EXISTS。
2.2 JOIN:连接改变了结果的粒度
SELECT o.id AS order_id, p.name, oi.quantity, oi.unit_price,
oi.quantity * oi.unit_price AS line_amount
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.id
JOIN products AS p ON p.id = oi.product_id
WHERE o.id = 101
ORDER BY oi.product_id;| order_id | name | quantity | unit_price | line_amount |
|---|---|---|---|---|
| 101 | Keyboard | 1 | 100.00 | 100.00 |
| 101 | Mouse | 2 | 50.00 | 100.00 |
查询返回的是“订单明细行”,不是“一行一个订单”。理解这个粒度变化,才能避免后续统计错误。
INNER JOIN 只保留有匹配的行。若要显示从未下单的用户,可以用 LEFT JOIN:
SELECT u.id, u.name, COUNT(o.id) AS paid_order_count
FROM users AS u
LEFT JOIN orders AS o
ON o.user_id = u.id AND o.status = 'paid'
GROUP BY u.id, u.name
ORDER BY u.id;结果为 Alice 1、Bob 2、Chen 0。这里将订单状态条件放在 ON 中:如果放到 WHERE,未匹配用户的订单字段为 NULL,会被筛掉。也不能用 COUNT(*),因为 Chen 仍有一行左连接产生的结果。PostgreSQL 表表达式与连接
2.3 GROUP BY、WHERE 与 HAVING
SELECT o.id, SUM(oi.quantity * oi.unit_price) AS total_amount
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.id
WHERE o.status = 'paid'
GROUP BY o.id
HAVING SUM(oi.quantity * oi.unit_price) >= 100
ORDER BY o.id;结果只有订单 101,金额 200。WHERE 先排除未支付订单,GROUP BY 把明细分成订单组,HAVING 再筛选聚合后的金额。
若统计“每天销售额”,还需要定义业务口径:按创建日还是支付日,使用哪个时区,是否扣除退款。下面按 UTC 支付日计算已支付订单的明细金额:
SELECT (o.paid_at AT TIME ZONE 'UTC')::date AS paid_day,
SUM(oi.quantity * oi.unit_price) AS sales_amount
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.id
WHERE o.status = 'paid'
GROUP BY (o.paid_at AT TIME ZONE 'UTC')::date
ORDER BY paid_day;结果为 2026-05-20:200,2026-05-21:100。时区转换和 ::date 使用 PostgreSQL 语法。真实财务报表还需要处理退款和其他金额项目,不能把这个简化统计直接叫作净收入。
2.4 JOIN 后重复统计:DISTINCT 不是万能修复
下面故意展示一个错误查询。先算好订单总额,再连接明细,会让订单 101 的总额出现两次:
WITH order_totals AS (
SELECT order_id, SUM(quantity * unit_price) AS amount
FROM order_items
GROUP BY order_id
)
SELECT SUM(t.amount) AS wrong_total,
SUM(DISTINCT t.amount) AS also_wrong_total
FROM orders AS o
JOIN order_totals AS t ON t.order_id = o.id
JOIN order_items AS oi ON oi.order_id = o.id
WHERE o.status = 'paid';结果分别是 500 和 250,正确值应为 300。SUM(DISTINCT amount) 把两笔不同订单恰好相同的 50 也合并了。DISTINCT 去重的是值,不是你脑中想象的业务实体。
正确做法是让参与总计的数据保持“一行一个订单”,去掉不需要的明细连接:
WITH order_totals AS (
SELECT order_id, SUM(quantity * unit_price) AS amount
FROM order_items
GROUP BY order_id
)
SELECT SUM(t.amount) AS paid_total
FROM orders AS o
JOIN order_totals AS t ON t.order_id = o.id
WHERE o.status = 'paid';先检查每个中间结果的粒度,再考虑 DISTINCT、子查询或预聚合,通常比反复修改聚合函数更有效。
2.5 EXISTS:表达“至少有一次”
要找买过键盘的用户,不需要返回每条订单明细:
SELECT u.id, u.name
FROM users AS u
WHERE EXISTS (
SELECT 1
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.id
WHERE o.user_id = u.id
AND o.status = 'paid'
AND oi.product_id = 10
)
ORDER BY u.id;结果只有 Alice。EXISTS 表达存在性,所以同一个人买过十次也不会在外层产生十行。是否比另一种等价写法更快,仍需要执行计划与实际数据证明。
2.6 CTE 与窗口函数:每个用户最近一笔订单
WITH ranked_orders AS (
SELECT id, user_id, status, created_at,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY created_at DESC, id DESC
) AS rn
FROM orders
)
SELECT user_id, id, status
FROM ranked_orders
WHERE rn = 1
ORDER BY user_id;结果为 Alice 的 102 和 Bob 的 104;Chen 没有订单,不在结果中。窗口函数保留原有行,并为每个用户组内的记录编号,外层再取第一行;这里不是把全部订单聚合成一行。PostgreSQL 窗口函数教程
如果需求变为“最近一笔已支付订单”,应在编号前过滤 status = 'paid'。在外层过滤会变成“最近一笔订单恰好已支付”,Alice 就被遗漏了。CTE 能改善分步表达,但不意味着一定物化或一定更快。
2.7 写入、影响行数与参数化
以下演示在事务末尾回滚,不改变后续练习数据:
BEGIN;
INSERT INTO users (id, name, email)
VALUES (4, 'Diana', 'diana@example.com');
UPDATE users
SET name = 'Alice Chen'
WHERE id = 1
RETURNING id, name;
DELETE FROM users
WHERE id = 4
RETURNING id;
ROLLBACK;使用稳定的 ID 限定修改对象,而不是假定名字唯一。检查影响行数:预期修改一行却修改零行,可能是对象已删除或并发条件不匹配;修改多行则可能是 WHERE 条件有误。
应用里使用驱动绑定参数。例如 PostgreSQL 驱动通常把 $1 与参数数组一起传给数据库。不要拼接用户输入,也不要认为 ORM 自动阻止了无条件批量更新。RETURNING 是这里使用的 PostgreSQL 能力,移植到 MySQL 时要按驱动与语法调整。
3. 规范化:从具体异常理解范式
3.1 先定义键和函数依赖
假设把订单、用户和商品混成一张表:
order_id, product_id, user_id, user_name, email,
product_name, quantity, unit_price在前述“一订单一商品一行”的假设下,候选键是 (order_id, product_id)。候选键是能够唯一确定记录的最小属性集合;主键是从候选键中选定的一个,不能只检查选中的主键而忽略其他候选键。
我们假设以下业务依赖成立:
order_id → user_id
user_id → user_name, email
product_id → product_name
(order_id, product_id) → quantity, unit_price箭头表示左侧值确定右侧值,而不是当前样例碰巧没有重复。把这些依赖放在一张表里,会产生更新异常(邮箱需要改多行)、插入异常(无订单时难以记录商品)和删除异常(删除最后一条明细可能连商品资料也丢掉)。
3.2 第一范式:不要使用重复列组承载关系
product1、product2、product3 会让第四个商品变成改表问题;把商品 ID 存为逗号分隔字符串,又会让逐个商品的约束、数量和查询难以表达。这个业务中,把每条明细放成独立行更合适。
“原子值”不是把所有字符串拆到单个字符。拆分应基于选定的数据域与操作需求;姓名是否拆分与订单是否有多条明细是不同问题。数据库支持数组和 JSON,也不意味着它们可以直接替代需要外键和逐项约束的关系。
3.3 第二范式:消除对候选键一部分的依赖
2NF 要求在 1NF 基础上,非主属性不能只依赖某个候选键的真子集;主属性是出现在任一候选键中的属性。
在大表里,product_name 只依赖 product_id,user_id 只依赖 order_id,都没有依赖完整复合键。把商品信息放进 products,把订单层信息放进 orders,明细保留数量和成交价,就消除了这里的部分依赖。
给大表加一个自增 ID,不会让这些业务依赖自动消失。规范化仍需检查真实候选键和决定关系,不能用一个代理键掩盖重复事实。
3.4 第三范式:把不同实体的事实分开
拆出 orders 后,如果仍存储 user_id, user_name, email,就有 order_id → user_id → email 的传递依赖。用户的当前资料应该放进 users,订单通过外键引用它。
更准确地说,3NF 对每个非平凡函数依赖 X → A,要求 X 是超键或 A 是主属性。对本文这种简单模型,可以先用“非键事实应属于该实体,避免通过另一个非键属性间接确定”来理解,但不要把简化口诀当作所有模型的严格定义。
分解还应能正确连接回原关系,并尽量保留可以直接检查的依赖。不是表越多就越规范,也不是拆到第三范式就自动保证查询快。
3.5 历史快照与性能冗余要分开讨论
成交单价、收货地址快照可以表达下单时的事实。它们不应随着当前商品价格或用户地址变化而更新,这并不是“为了性能偷偷保留重复字段”。
另一种情况是为了列表展示,把订单总金额或评论数量预先存起来。这才需要明确谁更新派生值、是否与原始数据同事务、允许延迟多久,以及如何重算和对账。
可以先保持清晰的事实模型,再根据慢查询证据引入汇总表或物化视图。只有“读取会快”而没有维护策略的反规范化,往往把复杂度转移到了错误恢复上。
4. 索引与执行计划:优化具体访问路径
4.1 索引有读收益,也有维护成本
可以把 B-tree 索引理解为维护有序键到记录位置的查找结构,它常用于等值、范围和排序访问。它不等于把整张表复制成一个永远更快的版本。
查询若只需要很小一部分记录,索引通常更有机会降低访问量;若要读取表的大部分内容,顺序扫描可能更便宜。写入、删除或修改索引列时,需要维护相应结构,还会消耗空间、缓存和写入带宽。
唯一索引还能支撑唯一性约束,而普通查询索引主要提供访问路径。不要把“已经有主键索引”理解为其他查询都自动得到优化。
4.2 用过滤和排序共同决定复合索引
CREATE INDEX orders_user_status_created_idx
ON orders (user_id, status, created_at DESC, id DESC);
SELECT id, created_at
FROM orders
WHERE user_id = 1 AND status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20;这个查询先固定用户和状态,再按时间及 ID 取最近记录,因此该索引值得评估。但如果另一条查询不限制 status,却要求跨状态按时间排序,status 位于中间可能让访问路径不同,不能直接断言同一索引同样合适。
复合 B-tree 索引的前导列通常影响扫描范围,但“没有最左列就绝对不能用索引”过于绝对。优化器能力、数据库版本、索引类型和数据分布都会影响选择;本文以 PostgreSQL 17 的规则为参考,最终看执行计划。PostgreSQL 多列索引
针对少量特定状态的数据,PostgreSQL 还支持带谓词的部分索引。它要求查询条件能够匹配索引条件,并不是所有数据库都支持相同语法。PostgreSQL 部分索引
4.3 EXPLAIN 看计划,ANALYZE 看实际执行
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at
FROM orders
WHERE user_id = 1 AND status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20;重点观察扫描方式、估算行数与实际行数、循环次数、排序,以及缓冲区访问。cost 是规划器的成本单位,不是毫秒;实际时间才是执行测量,且受缓存与测量开销影响。PostgreSQL EXPLAIN 指南
本文只有四个订单,优化器选择顺序扫描完全可能合理。不要为了得到“使用索引”的截图强制关闭顺序扫描,然后宣称性能改善。真正评估需要接近业务规模的数据、典型参数和新鲜统计信息。
EXPLAIN ANALYZE 会执行语句。对写操作分析时,要在隔离环境和明确的事务策略下进行;普通回滚也不代表序列变化或外部副作用都可以撤销。
4.4 深分页与稳定游标
OFFSET 100000 LIMIT 20 即使走索引,也可能先处理大量要跳过的记录。对于连续浏览,可让下一页从上一页最后一个排序键继续:
SELECT id, created_at
FROM orders
WHERE user_id = 2
AND status = 'paid'
AND (created_at, id) < (TIMESTAMPTZ '2026-05-21 12:00:00+00', 104)
ORDER BY created_at DESC, id DESC
LIMIT 20;结果为 103。时间与 ID 一起构成确定的顺序,游标还应绑定筛选条件。游标分页不便随机跳到第几页,也不自动提供跨请求快照:数据删除、权限变化或排序字段改变仍需要定义行为。
5. 事务与并发:库存正确不靠运气
5.1 ACID 对应哪些失败
| 属性 | 订单系统中的问题 |
|---|---|
| Atomicity,原子性 | 库存扣了但明细写入失败,是否一起回滚? |
| Consistency,一致性 | 数量、外键等约束是否持续成立,业务代码是否维护规则? |
| Isolation,隔离性 | 两个并发请求互相看见什么,会不会基于过期状态决定? |
| Durability,持久性 | 已确认提交的数据在故障后能否按配置恢复? |
数据库只能执行已声明的约束和应用写出的操作,不会自动知道“一个已支付订单至少一条明细”这种未实现规则。ACID 的一致性也不等于分布式系统里谈到的所有一致性模型。
5.2 最后一件库存:先查再写的竞争
A: SELECT stock → 1
B: SELECT stock → 1
A: 认为库存足够,准备创建订单
B: 也认为库存足够,准备创建订单如果两个请求都根据旧值把库存设为 0,最终库存看起来不负,却可能产生两笔订单。CHECK (stock >= 0) 也无法识别这种少扣一次的错误。
对于单商品扣减,可以把条件与减法放进同一条语句:
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 12 AND stock >= 1
RETURNING id, stock, price;应用必须检查是否返回一行。返回零行表示没有满足条件的商品,不能继续创建订单。在 PostgreSQL Read Committed 下,竞争更新会等待并针对更新后的行重新检查相关条件;更高隔离级别可能要求处理序列化失败,而不是期待相同返回路径。
5.3 把扣库存与订单写入放在同一事务
下面展示一次购买支架的步骤,末尾回滚是为了保持练习数据。应用必须根据第一条 UPDATE 的结果分支;示例成交价 40 来自当前样例,真实代码应使用服务端确定的报价。
BEGIN;
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 12 AND stock >= 1
RETURNING id, stock, price;
-- Continue only if one row was returned; otherwise ROLLBACK and stop.
INSERT INTO orders (id, user_id, status, created_at)
VALUES (105, 1, 'pending', '2026-05-22 09:00:00+00');
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (105, 12, 1, 40.00);
-- In the application: COMMIT only after all required writes succeed.
ROLLBACK;库存预留成功后,可以建立 pending 订单,再通过独立支付流程确认。不要持有数据库行锁去等待用户付款或长时间调用支付平台。未支付超时释放库存、重复回调和重复释放也都需要状态条件与幂等处理。
数据库事务不能把远程支付自动纳入同一个回滚。可以在本地事务中记录待处理事件,之后可靠投递,再通过幂等与对账处理跨系统失败。
5.4 悲观锁、乐观版本与死锁
如果决策必须读取商品的多个字段,可以在事务里先锁住该行:
BEGIN;
SELECT id, stock, price
FROM products
WHERE id = 12
FOR UPDATE;
-- Validate and perform the related writes while holding the row lock.
ROLLBACK;行锁限制竞争写入,不表示所有普通读取都被阻塞。多个商品需要统一的加锁顺序,并缩短事务时间;即使这样,也应处理死锁后重试整个事务。PostgreSQL 显式锁
乐观方案则要求旧版本仍匹配:
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 12 AND version = 1 AND stock >= 1
RETURNING id, stock, version;零行更新表示条件不再成立,需要重新读取并决定是否重试,不能丢掉版本条件直接覆盖。该机制要求所有相关写入路径维护 version。乐观控制减少显式预先锁定的需要,但实际写入仍会使用数据库锁。
5.5 隔离级别要结合数据库实现
| 级别 | 复习重点 |
|---|---|
| Read Uncommitted | 理论上允许脏读;PostgreSQL 实际按 Read Committed 处理 |
| Read Committed | PostgreSQL 普通查询每条语句建立快照,同一事务两次读取可能不同 |
| Repeatable Read | PostgreSQL 使用稳定事务快照,但仍可能发生序列化异常,如写偏差 |
| Serializable | 成功提交的结果需符合某种串行执行,应用可能需要重试失败事务 |
例如 A 两次读取库存,中间 B 提交扣减:在 Read Committed 下 A 可能先看到 1,再看到 0。Repeatable Read 的普通快照查询可以仍看到旧值,但这不代表随后的写操作必定成功。具体规则需要结合操作类型理解。PostgreSQL 事务隔离
写偏差可以用两名审核员举例:规则要求至少一人值班,两笔事务各自看到另一人还在,于是分别把自己设为离线。它们改的是不同行,仅有稳定快照仍可能违反整体规则,需要更合适的锁、约束设计或串行化隔离。
MySQL InnoDB 默认隔离级别通常为 Repeatable Read,PostgreSQL 默认为 Read Committed;同名隔离级别在快照和锁行为上也不能直接照搬结论。MySQL InnoDB 隔离级别
5.6 事务重试与请求幂等不是同一件事
死锁或序列化失败通常要求回滚并重新执行完整事务逻辑,并设置次数上限与退避。如果客户端没收到提交响应,则连“事务是否已提交”也可能不确定,不能靠直接重试创建解决。
业务请求 ID 加唯一约束可以帮助识别同一用户的重复下单,但还需要绑定请求内容、保存结果,并与订单创建协调。事务提供一组本地写入的边界;幂等机制识别跨请求重复,两者解决不同问题。
6. NoSQL:根据数据模型与访问模式理解
6.1 文档数据库:嵌入还是引用
订单经常整体读取,可以考虑把有界的明细嵌入一个文档。下面用最小货币单位整数表示金额,避免把 JSON 普通小数误当成数据库精确金额类型:
{
"_id": "order_101",
"userId": "user_1",
"currency": "AUD",
"status": "paid",
"items": [
{ "productId": "product_10", "quantity": 1, "unitPriceCents": 10000 },
{ "productId": "product_11", "quantity": 2, "unitPriceCents": 5000 }
]
}如果子数据随父对象一起访问、数量有界、生命周期相近,嵌入值得考虑;若独立更新频繁、共享程度高或数组可能无限增长,则更适合评估引用。MongoDB 数据建模指南
不能把用户全部历史订单塞进一个不断增长的用户文档。商品当前资料也不应随意复制进所有订单并期待同步无成本;成交价快照则是不同语义。
MongoDB 支持单文档原子操作,也支持多文档事务。讨论应关注部署条件、读写保障和事务成本,而不是“文档数据库没有事务”。MongoDB 事务说明
6.2 Redis:缓存只是其中一种用途
Redis 支持字符串、Hash、集合、有序集合和 Stream 等结构,不只是一个字符串字典。Redis 数据类型
商品展示缓存可以使用明确的键与过期时间:
SET product:12:summary '{"id":12,"name":"Stand","priceCents":4000}' EX 60
GET product:12:summary
DEL product:12:summaryEX 60 为这个键设置秒级有效期。Redis SET 命令
Cache-aside 的读取流程是先查缓存,未命中再查数据库并回填。更新商品时通常先提交数据库,再使缓存失效,但并发旧读取仍可能把旧值重新填回;TTL 只限制部分陈旧窗口,不自动提供强一致性。
库存决策不能直接依赖展示缓存。还需要考虑热点键失效后大量回源、无效 ID 持续查询,以及缓存故障时数据库是否扛得住。Redis 的持久化与淘汰策略取决于配置,不能把缓存默认当作订单唯一事实来源。
6.3 宽列/列族数据库:从分区内查询设计键
以 Cassandra 为例,数据模型常围绕查询建立。若需求是“某用户某月的订单”,可以使用 (user_id, month) 作为分区键,在分区内按时间和订单 ID 组织记录:
partition key: (user_id, month)
clustering key: (created_at DESC, order_id DESC)
query: one user's orders within one month这不是所有数据库通用的建表语法,而是访问模型示意。按用户查询和按商品查询可能需要不同的表;冗余是为了已知访问路径,随之而来的是多份数据的维护工作。Cassandra 数据建模说明
分区键决定数据分布,热门用户或无限增长分区会造成问题。加月份能限制部分增长,却使跨月查询需要访问多个分区;不能只说“分布式,所以写入一定快”。
6.4 图数据库:把关系作为查询对象
图模型可以表示用户、商品以及关注或购买关系。下面的 Cypher 示例表达“找我关注的人买过的商品”,它不是前面的 SQL 数据库查询:
MATCH (me:User {id: 'user_1'})-[:FOLLOWS]->(friend:User)
-[:BOUGHT]->(product:Product)
RETURN DISTINCT product.id, product.name;节点、关系和属性让路径查询更直接。Neo4j 图概念
但有用户关系就不一定需要图数据库。要评估遍历深度、关系规模和延迟要求;简单关联可能用关系库已足够。推荐系统也不只是图遍历,还可能涉及排序模型、内容特征和其他算法。
6.5 宽列与分析型列式存储不是一个概念
宽列数据库的讨论重点是分区、键和访问模式;分析型列式存储则强调按列组织数据,方便压缩以及扫描大量记录中的少数字段。
订单创建属于频繁的小范围事务操作,销售分析可能需要扫描数月数据进行聚合。ClickHouse 这类分析系统面向后者,不能因为同样带有“列”字就和 Cassandra 当作同一类。ClickHouse 产品说明
时序场景还常需要按时间写入、保留期限、降采样和时间窗口聚合。它描述的是一种工作负载与模型需求,相关系统也可能支持 SQL,不应把所有非传统数据库都简单等同于“不用 SQL”。
6.6 一致性与扩展能力不能只看 SQL 标签
关系数据库可以复制、分区,部分产品支持分布式事务;NoSQL 产品也可能提供事务和不同的一致性选项。具体保障由产品、拓扑、配置和操作共同决定。
网络分区下,需要明确系统是拒绝某些请求,还是接受某种陈旧或冲突状态;不能把 CAP 当成随时自由选择的三个功能开关。选型时更有用的问题是:订单确认允许读旧数据吗?失败时用户会看到什么?恢复后如何对账?
7. MySQL、PostgreSQL 与系统选型
7.1 不用“简单”和“高级”给数据库排名
MySQL 的 InnoDB 提供事务与 ACID 相关能力,不能把 MySQL 归类为“只适合简单读写”,再把一致性当作 PostgreSQL 独有优势。MySQL InnoDB 与 ACID
| 比较维度 | 应该实际确认的问题 |
|---|---|
| SQL 与查询 | 所需连接、窗口函数和执行计划在目标版本上表现如何? |
| 类型与索引 | 需要哪些 JSON、全文、空间或特殊索引能力? |
| 事务行为 | 默认隔离、锁等待、死锁恢复如何影响业务? |
| 运维环境 | 团队熟悉什么,托管服务支持什么,恢复流程是否演练过? |
| 迁移成本 | 驱动、ORM、SQL 方言和数据迁移要改多少? |
PostgreSQL 的 JSONB 与索引、扩展生态值得针对需求评估,但 JSON 字段不该替代全部关系设计。PostgreSQL JSON 类型
两者都可以承担本例的订单业务。选择应由所需能力、实测性能和运维成本支撑,不应由“MySQL 轻量、PostgreSQL 严谨”这样的标签决定。
7.2 一种数据库先满足主要需求,再增加其他存储
对这个小系统,可以先由关系数据库保存用户、订单、明细和库存。读压力或其他需求得到证据支持之后,再评估缓存、搜索或分析存储。
| 数据用途 | 可评估的存储角色 | 需要承担的额外责任 |
|---|---|---|
| 订单与库存事实 | 关系数据库 | 约束、事务、备份与迁移 |
| 热门商品展示 | Redis 缓存 | 失效、回源与故障降级 |
| 商品全文检索 | 搜索索引 | 数据同步、重建与结果新鲜度 |
| 历史销售分析 | 分析数据库或汇总表 | 抽取、去重、迟到数据与对账 |
主库写入成功、搜索索引更新失败时,不应让系统悄悄丢失更新。需要可重试的投递、版本或事件 ID 去重,以及重建能力。多一种数据库就多一种故障边界,不能只计算查询收益。
7.3 读副本与分片需要回答业务问题
读副本可以分担部分读取,但复制延迟可能让用户刚下的订单暂时查不到;需要明确关键读取走主库、会话粘连或其他读后写一致性策略。
分片可以拆分容量与流量,也会使跨分片 JOIN、唯一约束、事务和重新分布更复杂。先确认瓶颈来自查询、连接数、存储还是单机容量,再判断是否需要分片,而不是因为预计“以后用户会多”就提前拆库。
8. 运行维护也是数据库设计的一部分
8.1 慢查询与连接池
慢请求可能在等待连接、等待锁、执行 SQL,或把太多结果传回应用。观察高分位延迟、连接池等待、慢查询、锁等待和磁盘压力,再决定优化哪个环节。
连接池不是越大越好:每个应用副本开大量连接,可能让数据库花更多资源争抢执行。短事务、受限批量任务和合理超时,往往比单纯增加连接数有效。
8.2 迁移采用可共存的步骤
增加必填字段时,可以先增加可空字段,让新旧应用共同运行,再分批回填、验证数据,最后收紧约束并移除旧逻辑。不要让旧实例在新 Schema 下立即无法写入。
大表建索引、改类型或增加约束可能带来锁和额外负载;在线选项也有版本限制与失败恢复要求。迁移应在接近生产规模的环境评估,不能仅以空表执行成功为依据。
8.3 有副本不等于有备份
副本可能很快复制误删除,因此它不能独立提供历史恢复点。备份计划需要定义可容忍的数据丢失时间 RPO 和恢复时间 RTO,并包含真正的恢复演练。PostgreSQL 备份与恢复
例如 PostgreSQL 的时间点恢复需要匹配的基础备份与所需 WAL,而不只是保留几份孤立日志。恢复验证应检查数据、约束、权限、应用连接及恢复耗时,不能只检查“备份文件存在”。
8.4 保留期限与归档
订单历史、审计日志和缓存有不同生命周期。提前定义保留期限、归档查询和删除流程,可以减少后期大批量删除对索引、锁和存储的压力。
分区可以帮助某些时间范围管理与清理,但不自动提升所有查询。归档之后仍要考虑历史订单与商品、用户记录之间的关联及隐私处理,不能让归档成为数据完整性的盲区。
9. 实践检查与自测
可以直接用本文数据验证以下结果,并把并发实验放在独立数据库中:
- 订单 101 的历史金额为 200,修改商品当前价格后仍应保持 200。
- 三个用户的已支付订单数分别为 1、2、0。
- 错误聚合得到 500 和 250,正确已支付总额为 300。
- UTC 每日已支付销售额为 200 和 100。
- 每个用户最新订单是 102、104,不等于最新已支付订单。
- 非法外键、重复订单明细、负库存和缺失必要支付时间被约束拒绝。
- 事务回滚后,新订单不存在,库存恢复。
- 两个连接竞争最后一件库存时,不能都成功创建订单。
- 版本条件失败后重新判断业务,而不是无条件覆盖。
- 重复请求、缓存失效失败和恢复备份都有明确处理方式。
自测时不要只背术语,可以尝试回答:
- 为什么不能用商品当前价格计算历史订单? 当前报价与成交快照描述不同时间的事实。
- 为什么
SUM(DISTINCT amount)不能修复所有 JOIN 重复? 不同订单可能具有相同金额,值去重会丢掉真实交易。 - 给大表增加自增主键,就满足规范化了吗? 没有,真实函数依赖和其他候选键仍需分析。
- 建了索引为何仍出现 Seq Scan? 小表或大比例读取时可能更便宜,也可能是条件不适合、估算偏差,需要看计划。
- 库存有非负 CHECK,为什么仍可能超卖? 两个请求都把旧值 1 改成 0,库存不负但少扣了一次。
- 事务和幂等键各解决什么问题? 前者界定本地写入的提交与回滚,后者识别跨请求重复。
- MongoDB 能用事务,为什么还要认真建模? 事务能力不能消除跨文档协调、文档增长和访问路径的成本。
- 恢复成功如何证明? 在目标时间范围恢复到隔离环境,验证数据与应用,并测量实际恢复耗时。
掌握这些知识后,应该能从一条业务操作推导表结构、查询、约束和并发策略,再用结果、执行计划和故障实验检验设计。