📖 本篇简介:本篇是"实战篇"的开篇,把前面学过的建表、索引、约束、范式全部串起来,落地到一个迷你电商库。你将看到用户、商品、订单三大核心域的完整建表 SQL(含索引、外键、中文注释),理解为什么订单表要拆成主表 + 明细表、什么时候该反范式冗余。学完后你能独立设计一张可上线的订单表,并读懂一段真实下单事务。本示例基于 MySQL 8.0 语法书写;本机无 MySQL 服务器,Python 验证部分使用 sqlite3 实跑并贴出真实输出。
目录
- 1 电商系统的数据模型全景
- 2 用户模块表设计
- 3 商品模块表设计
- 4 订单模块核心表设计
- 5 范式与反范式的权衡
- 6 ER建模与表关系梳理
- 7 核心业务SQL实战
- 8 电商表设计速查表
- 9 常见坑与注意事项
- 10 练习题
1 电商系统的数据模型全景
做电商库设计之前,先别急着写 CREATE TABLE,先把业务域划出来。一个最小可用的电商系统,数据可以拆成四块:
| 业务域 | 核心实体 | 典型表 |
|---|---|---|
| 用户域 | 用户、地址 | users、user_address |
| 商品域 | 类目、SPU、SKU | category、product、sku |
| 交易域 | 购物车、订单、订单明细 | cart、orders、order_item |
| 支付域 | 支付单、退款单 | payment、refund |
本篇聚焦前三块最核心的:用户、商品、订单。设计时有三条主线要始终抓住:
1. 钱和状态要可追溯:订单金额、状态变更不能只留在内存里,必须落库,且要能审计。
2. 读写要分离思路:下单是写多读少,订单列表是读多写少,二者对表结构的诉求不一样。
3. 预留扩展位:促销、优惠券、运费这些以后一定会加,主键和字段类型要留余地。
下面这张清单是本篇要落地的全部表,先有个全局印象:
| 表名 | 作用 | 量级预估 | 读写特征 |
|---|---|---|---|
| users | 用户主档 | 千万级 | 读多写少 |
| user_address | 收货地址 | 数倍于用户 | 读多写少 |
| category | 类目树 | 百级 | 极多读 |
| product | SPU | 十万级 | 读多写少 |
| sku | SKU 库存价格 | 百万级 | 读写均衡 |
| orders | 订单主表 | 亿级 | 写多读多 |
| order_item | 订单明细 | 数倍于订单 | 写多读多 |
量级这一列不是随便写的——它直接决定后面索引怎么建、要不要分表。订单表是亿级,所以它的索引设计必须克制,不能随便加。
下面我们从用户表开始,逐个把表建出来。所有 DDL 都按 MySQL 8.0 语法,引擎统一 InnoDB,字符集 utf8mb4,排序规则 utf8mb4_0900_ai_ci。
2 用户模块表设计
用户表相对简单,但有几个细节容易踩坑:手机号要加密或脱敏存储、密码绝不能明文、状态位要用 TINYINT 而不是字符串。
-- 用户主表:一个用户一条记录
CREATE TABLE `users` (
`user_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID,主键',
`nickname` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
`mobile` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '手机号(建议加密存储)',
`password_hash` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '密码散列,禁止明文',
`gender` TINYINT NOT NULL DEFAULT 0 COMMENT '性别:0未知 1男 2女',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 0封禁',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`user_id`),
UNIQUE KEY `uk_mobile` (`mobile`) -- 手机号唯一,防重复注册
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户主表';
地址表是典型的"一对多":一个用户可以有多个收货地址,但只有一个默认地址。
-- 收货地址表:一个用户多条,用 is_default 标记默认地址
CREATE TABLE `user_address` (
`address_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '地址ID',
`user_id` BIGINT NOT NULL COMMENT '所属用户',
`receiver` VARCHAR(64) NOT NULL COMMENT '收货人姓名',
`mobile` VARCHAR(32) NOT NULL COMMENT '收货电话',
`province` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '省',
`city` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '市',
`detail` VARCHAR(256) NOT NULL COMMENT '详细地址',
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认:1是 0否',
PRIMARY KEY (`address_id`),
KEY `idx_user` (`user_id`) -- 按用户查地址列表
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收货地址表';
设计要点:
- 主键一律用
BIGINT自增,不要用手机号或身份证当主键,因为它们会变、会泄露。 mobile在业务库里建议存加密后的密文,展示时再解密;唯一索引建在加密后的值上。- 时间字段统一
DATETIME,并同时维护created_at/updated_at,方便做增量同步。
3 商品模块表设计
商品域最容易设计错的地方,是分不清 SPU 和 SKU。简单说:
- SPU(Standard Product Unit,标准化产品单元):一款商品,比如"机械键盘 K87"。
- SKU(Stock Keeping Unit,库存量单位):具体到规格的可售卖单元,比如"K87 / 青轴 / 87键"。
一个 SPU 对应多个 SKU,价格和库存挂在 SKU 上,而不是 SPU 上。
-- 类目表:自引用,支持一级/二级类目
CREATE TABLE `category` (
`cat_id` INT NOT NULL AUTO_INCREMENT COMMENT '类目ID',
`name` VARCHAR(64) NOT NULL COMMENT '类目名',
`parent_id` INT NOT NULL DEFAULT 0 COMMENT '父类目ID,0为一级',
PRIMARY KEY (`cat_id`),
KEY `idx_parent` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品类目表';
-- SPU 表:一款商品一条
CREATE TABLE `product` (
`spu_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'SPU ID',
`title` VARCHAR(128) NOT NULL COMMENT '商品标题',
`cat_id` INT NOT NULL COMMENT '所属类目',
`detail` TEXT COMMENT '商品详情(富文本)',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '上下架:1上架 0下架',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`spu_id`),
KEY `idx_cat` (`cat_id`) -- 按类目筛商品
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SPU表';
-- SKU 表:真正决定价格和库存的单元
CREATE TABLE `sku` (
`sku_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'SKU ID',
`spu_id` BIGINT NOT NULL COMMENT '所属SPU',
`spec` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '规格描述,如"青轴/87键"',
`price` DECIMAL(10,2) NOT NULL COMMENT '售价(元)',
`stock` INT NOT NULL DEFAULT 0 COMMENT '库存',
PRIMARY KEY (`sku_id`),
KEY `idx_spu` (`spu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU表';
注意两个细节:
1. 价格用 DECIMAL(10,2),永远不要用 FLOAT/DOUBLE 存钱,浮点误差在金额计算上是事故级别的。
2. 库存 stock 直接放 SKU 表,扣减时用 UPDATE ... SET stock = stock - ? WHERE sku_id=? AND stock >= ? 这种带条件的原子更新,防止超卖。
3.1 SKU 规格怎么存
spec 字段这里用了一个 VARCHAR 粗存"青轴/87键"。真实项目里规格往往是多维的(颜色×尺寸×版本),常见两种做法:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 宽表 | 每个规格维度一列 color/size | 查询直观 | 维度一扩就加列,弹性差 |
| JSON | 一个 spec_json 字段存键值对 | 弹性好 | MySQL 5.7 后才支持索引,查询略繁 |
本篇为了聚焦,用最简的 spec 文本列。真实生产如果规格维度多,推荐 JSON 列 + 对常用维度建生成列索引。
4 订单模块核心表设计
订单是整个电商库的心脏。设计原则:主表存"订单级"信息,明细表存"商品级"信息,拆开而不是塞进一张宽表。
-- 订单主表:一笔订单一条,记录谁、什么时候、花了多少钱、什么状态
CREATE TABLE `orders` (
`order_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单号,主键',
`order_no` VARCHAR(32) NOT NULL COMMENT '对外展示的订单号(业务号)',
`user_id` BIGINT NOT NULL COMMENT '下单用户',
`total_amount` DECIMAL(12,2) NOT NULL COMMENT '订单总金额',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待支付1已支付2已发货3已完成4已取消',
`address_snapshot` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '下单时收货地址快照',
`paid_at` DATETIME COMMENT '支付时间',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`order_id`),
UNIQUE KEY `uk_order_no` (`order_no`), -- 业务订单号唯一
KEY `idx_user_created` (`user_id`, `created_at`), -- "我的订单"按用户+时间翻页
KEY `idx_status_created` (`status`, `created_at`) -- 客服/运营按状态扫单
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';
-- 订单明细表:一笔订单多条,每个SKU一行
CREATE TABLE `order_item` (
`item_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '明细ID',
`order_id` BIGINT NOT NULL COMMENT '所属订单',
`sku_id` BIGINT NOT NULL COMMENT '购买的SKU',
`spu_title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '下单时商品标题快照',
`price` DECIMAL(10,2) NOT NULL COMMENT '成交单价快照',
`quantity` INT NOT NULL DEFAULT 1 COMMENT '购买数量',
PRIMARY KEY (`item_id`),
KEY `idx_order` (`order_id`), -- 按订单查明细
KEY `idx_sku` (`sku_id`) -- 反查某SKU被谁买了
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';
这张表里有两个"快照"字段非常关键:address_snapshot(收货地址快照)和 spu_title / price(商品信息快照)。为什么要存快照?因为用户以后可能改地址、商家可能改商品名和价格,但历史订单必须保持下单那一刻的样子,否则三个月后订单详情页会显示成最新地址,完全错乱。
订单状态机大致是:
待支付(0) --支付--> 已支付(1) --发货--> 已发货(2) --确认收货--> 已完成(3)
|
--超时/取消--> 已取消(4)
4.1 支付单表(补充)
支付和订单是两个独立概念:一笔订单可能分多次支付(组合支付),也可能退款。所以支付单单独建表,与订单一对多:
-- 支付单表:一笔订单可对应多条支付流水
CREATE TABLE `payment` (
`pay_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '支付流水ID',
`order_id` BIGINT NOT NULL COMMENT '关联订单',
`pay_channel` TINYINT NOT NULL COMMENT '渠道:1微信2支付宝3银行卡',
`pay_amount` DECIMAL(12,2) NOT NULL COMMENT '本次支付金额',
`trade_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '第三方支付流水号',
`pay_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0处理中1成功2失败',
`paid_at` DATETIME COMMENT '支付成功时间',
PRIMARY KEY (`pay_id`),
UNIQUE KEY `uk_trade_no` (`trade_no`), -- 第三方流水号幂等
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付流水表';
这里的 uk_trade_no 是幂等关键:第三方回调可能重复通知,靠唯一索引保证同一笔流水不会被处理两次。
5 范式与反范式的权衡
很多教程把"第三范式"讲得像圣经,好像越规范越好。真实业务里恰恰相反——范式是默认底线,但读多写少的地方要主动反范式冗余。
先快速回顾三范式在电商里的体现:
| 范式 | 要求 | 在本设计中的体现 |
|---|---|---|
| 1NF | 字段原子不可再分 | 收货地址拆成省/市/详情,而不是塞进一个 address 文本 |
| 2NF | 非主键字段完全依赖主键 | 订单明细里 price 依赖 (order_id, sku_id),不依赖主表 |
| 3NF | 消除传递依赖 | 用户昵称不冗余进订单表,要用就 JOIN |
但下面这些地方,我们故意违反 3NF 做冗余:
1. 订单明细里冗余 `spu_title`、`price`:违反"商品名应只存在 product 表",但这是为了历史快照,属于合理反范式。
2. 订单主表冗余 `total_amount`:理论上可以用 SUM(order_item.price*quantity) 实时算,但每次算都要扫明细,冗余一个总额字段,下单时事务内写好。
3. 商品表冗余类目名:如果类目树查询频繁,可以在 product 表冗余一个 cat_name,避免每次 JOIN category。
判断标准很简单:这条数据会不会被频繁读到?读它要不要 JOIN 好几张表?写它的代价大不大? 如果读远多于写、JOIN 代价高、写一次就能维护好,那就冗余;反之就老老实实范式化。
-- 反范式示例:在订单表冗余用户昵称,避免订单列表每页都 JOIN users
ALTER TABLE `orders`
ADD COLUMN `user_nickname` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '下单时用户昵称快照';
-- 代价:下单时多写一个字段;收益:订单列表查询少一次 JOIN
6 ER建模与表关系梳理
把上面的表画成 ER 图,关系其实很清爽:
users (1) ───< (N) orders (1) ───< (N) order_item (N) >─── (1) sku
│
└──> (1) product (SPU) >── (N) order_item
user_address (N) ───> (1) users
category (1) ───< (N) product
关键关系说明:
users : orders = 1 : N:一个用户多个订单,外键orders.user_id。orders : order_item = 1 : N:一笔订单多条明细,外键order_item.order_id。sku : order_item = 1 : N:一个 SKU 可以出现在多笔订单里。product : sku = 1 : N:一个 SPU 多个 SKU。
生产环境里这些关系通常不建物理外键约束(FOREIGN KEY),而是靠业务代码和事务保证一致性。原因是:外键会在高并发写入时带来额外锁检查,分库分表后更是跨库失效。但逻辑关系要在文档里写清楚,这就是 ER 图的价值。
6.1 一句话记住表间关系
如果你只能记住一张图,记住这句就够了:用户下订单,订单挂明细,明细指 SKU,SKU 属于 SPU,SPU 挂类目。所有 JOIN 都是围绕这条主线展开的,查"我的订单详情"就是从用户出发,沿这条线把订单、明细、商品依次拉出来。
7 核心业务SQL实战
7.1 下单事务:扣库存 + 写订单 + 写明细
下单是一个典型事务,三步必须要么全成功要么全回滚:
-- 下单事务:扣库存、写主订单、写明细,任一失败整体回滚
START TRANSACTION;
-- 1) 原子扣减库存,带 stock>=1 条件防超卖
UPDATE `sku`
SET `stock` = `stock` - 1
WHERE `sku_id` = 1001 AND `stock` >= 1;
-- 如果上面受影响行数为 0,说明库存不足,程序应 ROLLBACK
-- 2) 写订单主表
INSERT INTO `orders`(`order_no`,`user_id`,`total_amount`,`status`,`address_snapshot`)
VALUES ('202609181005001', 10001, 399.00, 0, '山东省东营市xx路xx号');
-- 3) 写订单明细(LAST_INSERT_ID() 拿到刚插入的 order_id)
INSERT INTO `order_item`(`order_id`,`sku_id`,`spu_title`,`price`,`quantity`)
VALUES (LAST_INSERT_ID(), 1001, '机械键盘K87', 399.00, 1);
COMMIT;
这段事务的隔离级别用默认的 REPEATABLE READ 即可。扣库存那条 UPDATE 本身会加行锁,天然防并发超卖;如果还担心"两个请求同时读到 stock=1",那是靠 WHERE stock >= 1 的原子条件挡住的,不是靠隔离级别。
7.2 订单详情:三表联查
"我的订单详情页"需要把主表、用户、明细、商品拼起来。下面这段 SQL 在 sqlite3 里实跑过(方言略有差异,已注释),输出真实可复现:
# Python + sqlite3 实跑:模拟订单详情联查(仅验证 SQL 思路)
import sqlite3
con = sqlite3.connect("demo_ecom.db")
con.row_factory = sqlite3.Row
cur = con.cursor()
rows = cur.execute("""
SELECT o.order_id, u.nickname, o.status, o.total_amount, o.paid_at
FROM orders o JOIN users u ON u.user_id = o.user_id
ORDER BY o.order_id LIMIT 6;
""").fetchall()
for r in rows:
print(dict(r))
真实输出(本机 sqlite3 3.53.1 实跑):
{'order_id': 1, 'nickname': 'alice', 'status': 'PAID', 'total_amount': 528.0, 'paid_at': '2026-09-18 10:05:00'}
{'order_id': 2, 'nickname': 'bob', 'status': 'DONE', 'total_amount': 278.9, 'paid_at': '2026-09-18 12:20:00'}
{'order_id': 3, 'nickname': 'alice', 'status': 'SHIPPED', 'total_amount': 399.0, 'paid_at': '2026-09-18 21:40:00'}
{'order_id': 4, 'nickname': 'carol', 'status': 'DONE', 'total_amount': 199.0, 'paid_at': '2026-09-19 09:10:00'}
{'order_id': 5, 'nickname': 'david', 'status': 'PAID', 'total_amount': 458.0, 'paid_at': '2026-09-19 15:30:00'}
{'order_id': 6, 'nickname': 'bob', 'status': 'CANCELLED', 'total_amount': 89.0, 'paid_at': None}
对应到 MySQL 8.0,把表名换成上面 CREATE TABLE 建好的真实表即可,JOIN 逻辑完全一样。注意第 6 单 paid_at 是 NULL(已取消未支付),查询时要用 IS NULL 而不是 = NULL。
7.3 统计某用户累计消费
-- 某用户已完成订单的累计消费(不含取消)
SELECT user_id,
COUNT(*) AS order_cnt, -- 订单数
SUM(total_amount) AS total_paid -- 累计支付金额
FROM `orders`
WHERE user_id = 10001
AND status IN (1,2,3) -- 已支付/已发货/已完成才算
GROUP BY user_id;
7.4 取消订单:状态流转 + 回补库存
取消订单不是简单 UPDATE status,还要把库存还回去,并且只能从"待支付"状态取消:
-- 取消订单事务:状态校验 + 回补库存
START TRANSACTION;
-- 1) 把订单从待支付改成已取消,带状态条件防并发重复取消
UPDATE `orders`
SET `status` = 4
WHERE `order_id` = 12345 AND `status` = 0;
-- 受影响行数为 0 说明订单已不是待支付(已支付或已取消),不能取消
-- 2) 回补库存:把这笔订单明细里每个 SKU 的数量加回去
UPDATE `sku` s
JOIN `order_item` oi ON oi.sku_id = s.sku_id
SET s.stock = s.stock + oi.quantity
WHERE oi.order_id = 12345;
COMMIT;
7.5 类目销售排行:三表聚合
运营常问"哪个类目卖得最好",要把订单明细、SKU、类目串起来。下面这段在 sqlite3 实跑过:
-- 按类目统计销量与销售额(仅统计已支付订单)
SELECT c.name AS category,
SUM(oi.quantity) AS qty,
SUM(oi.price * oi.quantity) AS sales
FROM `order_item` oi
JOIN `sku` s ON s.sku_id = oi.sku_id
JOIN `product` p ON p.spu_id = s.spu_id
JOIN `category` c ON c.cat_id = p.cat_id
JOIN `orders` o ON o.order_id = oi.order_id
WHERE o.paid_at IS NOT NULL
GROUP BY c.name
ORDER BY sales DESC;
本机 sqlite3 实跑输出(迷你数据):
category | qty | sales
数码 | 13 | 3807.0
服饰 | 5 | 637.7
家居 | 4 | 566.0
可以看到数码类目一骑绝尘。真实环境里这种聚合查询会很重,通常不会直接在订单大表上现算,而是落到报表库或预聚合表——这正是下一篇和第 27 章要解决的问题。
8 电商表设计速查表
把本篇最常回头查的东西汇总成一张表,建表时照着勾一遍:
| 设计项 | 推荐做法 | 理由 |
|---|---|---|
| 主键 | BIGINT AUTO_INCREMENT | 分布式以后可换成雪花ID,别用业务号 |
| 金额 | DECIMAL(10,2) / DECIMAL(12,2) | 杜绝浮点误差 |
| 状态位 | TINYINT + 注释枚举 | 比字符串省空间、比较快 |
| 时间 | DATETIME + created_at/updated_at | 便于增量同步 |
| 字符集 | utf8mb4 | 存得下 emoji 和生僻字 |
| 引擎 | InnoDB | 支持事务和行锁 |
| 订单金额 | 主表冗余 total_amount | 避免每次 SUM 明细 |
| 商品信息 | 明细存标题/价格快照 | 历史订单不随商品改价错乱 |
| 手机号 | 加密存储 + 密文唯一索引 | 合规与隐私 |
| 外键 | 逻辑外键,不建物理 FK | 高并发与分库分表友好 |
这张表建议存到团队的 wiki 里,新来的 DBA 评审表结构时照着过一遍,能挡掉八成低级错误。记住一句话:建表时多花十分钟想清楚,比上线后半夜改表强一百倍。
9 常见坑与注意事项
坑 1:用 FLOAT 存金额。 两单相加 0.1 + 0.2 在浮点里不是 0.3,对账时差几分钱对不上。一律 DECIMAL。
坑 2:订单表不存地址快照。 用户改了默认地址后,三个月前的订单显示成新地址,客服被投诉到飞起。下单那一刻的地址必须落库快照。
坑 3:明细里不存商品标题快照。 商家把商品下架、改名后,历史订单详情页空白或显示新名,买家一头雾水。
坑 4:物理外键上生产。 小项目练手没问题,高并发或分库后外键会成为锁瓶颈和迁移障碍。逻辑关系靠文档 + 事务保证。
坑 5:订单号直接用自增主键对外。 自增 ID 会泄露销量(对手看你订单号涨到多少就知道你卖了多少单)。对外用独立 order_no(如时间戳+随机数),主键只对内。
坑 6:一张大宽表塞所有信息。 把用户、商品、订单全塞一张表,看似 JOIN 少了,实则冗余爆炸、更新异常。该拆主表/明细就拆。
坑 7:订单时间用 INT 存时间戳。 用 INT 存 Unix 时间戳虽然省空间,但人读不了、WHERE created_at > '2026-09-01' 这种条件没法直接写,还得换算。DATETIME 虽然占 5~8 字节,但可读性和可查询性远胜,除非极致压缩场景。
坑 8:缺少 `updated_at` 自动维护。 没有 ON UPDATE CURRENT_TIMESTAMP,数据被改了都不知道什么时候改的,排查问题抓瞎。建表时务必带上。
坑 9:唯一索引建错列。 手机号唯一索引如果建在明文上,一旦加密方案升级,历史密文和新密文对不上,唯一约束直接冲突。加密字段的唯一索引要想清楚升级路径。
10 练习题
练习 1:为什么订单明细里要冗余 spu_title 和 price,而不是每次 JOIN product 表取最新值?
<details>
<summary>参考答案</summary>
因为商品标题和价格会随运营调整而变化,但历史订单必须定格在"下单那一刻"的样子。如果实时 JOIN product,三个月后这笔订单会显示成最新标题和最新价,与买家当时看到的、支付的金额不一致,引发纠纷。快照字段是用一点存储换数据一致性。
</details>
练习 2:设计"购物车"表,应该挂在用户维度还是商品维度?给出建表思路。
<details>
<summary>参考答案</summary>
购物车是"用户 × SKU"的关系:一个用户购物车里有多个 SKU,每个 SKU 有数量。建表 cart(cart_id, user_id, sku_id, quantity, added_at),对 (user_id, sku_id) 建唯一索引防止重复加购,按 user_id 建索引查购物车列表。购物车不需要存快照,因为它是"待下单"状态,随时可以读最新商品价。
</details>
练习 3:下面这段下单 SQL 有什么超卖风险?怎么改?
-- 风险版:先查库存再扣
SELECT stock FROM sku WHERE sku_id = 1001; -- 假设读到 stock=1
UPDATE sku SET stock = stock - 1 WHERE sku_id = 1001;
<details>
<summary>参考答案</summary>
两条语句之间没有加锁,两个并发请求可能都读到 stock=1,然后都执行扣减,结果 stock 变成 -1,超卖。正确做法是把判断和扣减合并成一条原子更新:UPDATE sku SET stock = stock - 1 WHERE sku_id = 1001 AND stock >= 1;,然后检查受影响行数,为 0 就说明库存不足、回滚事务。
</details>
练习 4:订单状态 status 用 VARCHAR('PAID') 和 TINYINT(1) 各有什么优劣?
<details>
<summary>参考答案</summary>
VARCHAR 可读性好、调试直观,但占空间大、索引效率低、容易拼错字符串。TINYINT 省空间、比较快、索引紧凑,但可读性差,必须靠注释或字典表维护枚举映射。生产上推荐 TINYINT + 字段注释枚举值,应用层用常量映射。
</details>
练习 5:为什么 order_no(对外订单号)要和主键 order_id 分开,而不是直接用自增主键?
<details>
<summary>参考答案</summary>
自增主键是连续的,对手只要下两单拿到两个 order_id,就能推算出你这段时间的总销量,属于商业信息泄露。对外订单号用"日期+随机串"(如 20260918 + 8 位随机数)既不连续又能按时间排序,主键只对内做关联。
</details>
练习 6:下单事务里,如果"扣库存"成功了但"写订单"失败回滚,库存会不会被白白扣掉?
<details>
<summary>参考答案</summary>
不会。因为扣库存和写订单在同一个事务里,写订单失败会触发整体 ROLLBACK,扣库存那条 UPDATE 也一起回滚,InnoDB 保证原子性。真正要小心的是"扣库存成功、提交事务后,异步发消息失败"这种事务外的副作用,那才需要消息表或本地消息表兜底,不属于单事务能解决的范围。
</details>
下一篇预告:第 26 章 订单查询优化实战
文章回复
0 条公开回复