柳嘉希

UNSW 信息技术硕士 · 寻找软件开发机会

全栈与后端开发 · API 集成 · 自动化 · AI Agent 学习与实践

数据库复习:SQL、规范化、NoSQL 与常见数据库类型

数据库复习不能只停在“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_items

order_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:summary

EX 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,不等于最新已支付订单。
  • 非法外键、重复订单明细、负库存和缺失必要支付时间被约束拒绝。
  • 事务回滚后,新订单不存在,库存恢复。
  • 两个连接竞争最后一件库存时,不能都成功创建订单。
  • 版本条件失败后重新判断业务,而不是无条件覆盖。
  • 重复请求、缓存失效失败和恢复备份都有明确处理方式。

自测时不要只背术语,可以尝试回答:

  1. 为什么不能用商品当前价格计算历史订单? 当前报价与成交快照描述不同时间的事实。
  2. 为什么 SUM(DISTINCT amount) 不能修复所有 JOIN 重复? 不同订单可能具有相同金额,值去重会丢掉真实交易。
  3. 给大表增加自增主键,就满足规范化了吗? 没有,真实函数依赖和其他候选键仍需分析。
  4. 建了索引为何仍出现 Seq Scan? 小表或大比例读取时可能更便宜,也可能是条件不适合、估算偏差,需要看计划。
  5. 库存有非负 CHECK,为什么仍可能超卖? 两个请求都把旧值 1 改成 0,库存不负但少扣了一次。
  6. 事务和幂等键各解决什么问题? 前者界定本地写入的提交与回滚,后者识别跨请求重复。
  7. MongoDB 能用事务,为什么还要认真建模? 事务能力不能消除跨文档协调、文档增长和访问路径的成本。
  8. 恢复成功如何证明? 在目标时间范围恢复到隔离环境,验证数据与应用,并测量实际恢复耗时。

掌握这些知识后,应该能从一条业务操作推导表结构、查询、约束和并发策略,再用结果、执行计划和故障实验检验设计。