个人主页: > for_ever_love__ <(欢迎各位大佬莅临😊)
其他栏目: > 大模型开发从0到1 <
其他栏目: > iOS项目总结大全 <
其他栏目: > 我想学python了 <
其他栏目: > iOS UI <
文章目录
- [MySQL 8.0 表设计规范:从业务建模、约束到索引评审的完整方法](#MySQL 8.0 表设计规范:从业务建模、约束到索引评审的完整方法)
-
- 一、表设计的目标不是通过代码评审
- 二、从业务事实开始,而不是从字段列表开始
-
- [2.1 先明确数据粒度](#2.1 先明确数据粒度)
- 三、命名规范要解决跨平台和沟通问题
- 四、主键只负责行身份,业务唯一性另建约束
- 五、NULL、默认值与"未知"要区分
-
- [5.1 默认值不能掩盖缺失参数](#5.1 默认值不能掩盖缺失参数)
- 六、状态字段要有字典,也要有约束
- 七、金额、时间与字符集必须在建表时定下来
-
- [7.1 金额不要用 FLOAT/DOUBLE](#7.1 金额不要用 FLOAT/DOUBLE)
- [7.2 创建时间与业务时间分开](#7.2 创建时间与业务时间分开)
- [7.3 默认使用 utf8mb4](#7.3 默认使用 utf8mb4)
- 八、范式化:先消除更新异常,再有证据地冗余
- 九、外键不是绝对禁止,也不是必须使用
- 十、索引设计从查询形状反推
-
- [10.1 唯一索引是业务约束,不只是性能工具](#10.1 唯一索引是业务约束,不只是性能工具)
- [10.2 删除冗余索引](#10.2 删除冗余索引)
- 十一、软删除与唯一键是高频冲突点
- 十二、JSON、预留字段和大字段的边界
- [十三、一份完整的订单表 DDL](#十三、一份完整的订单表 DDL)
- 十四、用系统表做自动化体检
- 十五、表结构变更要按生产操作设计
- 十六、常见但不可靠的"规范"
- 十七、建表评审清单
- 十八、小结
MySQL 8.0 表设计规范:从业务建模、约束到索引评审的完整方法
好的表结构不是字段命名整齐,而是把业务不变量固化下来,
让错误数据难以写入,让核心查询有稳定的访问路径。
本文以订单域为例,给出一套可落地的 MySQL 8.0 建表流程,
同时解释哪些"行业规范"只是经验值,不能机械执行。
一、表设计的目标不是通过代码评审
数据库表需要长期承受四类变化:
- 数据量从几万增长到几千万。
- 查询从单一详情发展为列表、统计和归档。
- 业务规则增加,状态和唯一性不断演进。
- 应用版本并存,历史程序仍可能写入旧格式。
因此设计质量至少包含五个维度:
| 维度 | 核心问题 |
|---|---|
| 正确性 | 非法状态和重复数据能否被拒绝 |
| 可查询性 | 高频访问是否有清晰索引路径 |
| 可演进性 | 加字段、改规则是否可控 |
| 可运维性 | 能否定位、归档、备份和恢复 |
| 可理解性 | 表和字段是否表达真实业务 |
"每张表必须五个审计字段"或"索引不能超过五个"都不是数据库定律。真正的规范应能解释为什么,并允许基于业务证据调整。
二、从业务事实开始,而不是从字段列表开始
设计订单表前,先写出事实和不变量:
- 一个订单只属于一个租户和一个客户。
- 订单号在租户内唯一。
- 一张订单包含一个或多个明细。
- 金额必须非负,币种不能缺失。
- 状态只能沿允许的路径变化。
- 创建时间不可被后续更新覆盖。
这些句子会映射成主键、唯一键、非空约束、检查约束和关联关系。如果连事实都没说清,直接画表往往只是在固化需求误解。
2.1 先明确数据粒度
每张表的一行必须能用一句话描述:
| 表 | 一行代表什么 |
|---|---|
customer_order |
一张订单 |
customer_order_item |
一张订单中的一个商品项 |
order_payment |
一次支付尝试 |
order_status_log |
一次状态迁移事件 |
| "订单支付信息表"如果同时保存订单汇总和多次支付记录,粒度混乱后,重复行和更新覆盖几乎不可避免。 |
三、命名规范要解决跨平台和沟通问题
推荐使用小写 snake_case:
text
库名:trade_center
表名:customer_order
字段:customer_id、created_at
索引:uk_order_no、idx_customer_created
| 规则 | 原因 |
|---|---|
| 小写字母与下划线 | 避免不同操作系统大小写行为差异 |
| 使用完整业务名词 | 降低缩写歧义 |
| 不用保留字 | 避免处处反引号 |
| 同一概念同一名称 | JOIN 和数据血缘更清晰 |
| 索引名表达列与用途 | 故障排查更快 |
是否使用单数表名并无性能差异,团队保持一致即可。不要为了"规范"把 order 当表名,它是关键字;customer_order 比 order 更稳妥。 |
四、主键只负责行身份,业务唯一性另建约束
InnoDB 表应显式定义主键。没有主键时,InnoDB 会寻找合适的非空唯一索引,再不满足才生成内部行标识,这会削弱可维护性和复制定位能力。
推荐把技术主键与业务键分开:
sql
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_order_no (tenant_id, order_no)
这样做的原因是:
- 技术主键短、稳定,不随业务规则修改。
- 业务键由唯一索引保证,不依赖"先查再插"。
- 外部系统使用订单号,内部关联使用主键。
主键究竟用自增、UUID 还是雪花 ID,要结合部署拓扑和写入局部性选择;不要在每张表里混用不同策略。
五、NULL、默认值与"未知"要区分
NULL 应表示"未知、缺失或尚未发生",不应仅仅因为开发者不想传值就允许为空。
以支付时间为例:
sql
paid_at DATETIME(3) NULL
订单未支付时确实没有支付时间,NULL 合理。但订单金额、币种和创建时间一旦缺失就失去业务意义,应设为 NOT NULL。
| 数据状态 | 推荐表达 |
|---|---|
| 尚未支付 | paid_at IS NULL |
| 金额为零 | amount = 0.00 |
| 空备注 | 按业务统一 NULL 或空串 |
| 未知地址 | NULL,不要填"暂无" |
| 无效状态 | 不应写入,由约束拒绝 |
NULL 和空字符串不是同一个值。混用会让统计、唯一索引和接口映射产生额外分支。 |
5.1 默认值不能掩盖缺失参数
下面的定义看似方便:
sql
status TINYINT NOT NULL DEFAULT 0
只有当 0 确实表示"新建"且所有插入都允许从新建开始时才合理。如果某条导入记录必须带真实状态,默认 0 反而会把调用错误变成脏数据。
六、状态字段要有字典,也要有约束
建议使用小整数保存稳定的状态代码:
| 值 | 状态 | 是否终态 |
|---|---|---|
| 10 | 待支付 | 否 |
| 20 | 已支付 | 否 |
| 30 | 已发货 | 否 |
| 40 | 已完成 | 是 |
| 90 | 已取消 | 是 |
sql
status TINYINT UNSIGNED NOT NULL,
CONSTRAINT chk_order_status CHECK (status IN (10, 20, 30, 40, 90))
MySQL 8.0.16 起执行 CHECK;MySQL 5.7 会忽略检查约束。如果要兼容 5.7,应用层必须执行同样校验,也可以通过受控写入接口和定期数据巡检补强。
CHECK 只能约束单行状态合法,不能保证状态迁移合法。"已取消不能再变成已发货"仍需事务逻辑或状态机更新条件:
sql
UPDATE customer_order
SET status = 30,
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = ?
AND status = 20;
检查受影响行数,才能识别并发下状态已被别人改变。
七、金额、时间与字符集必须在建表时定下来
7.1 金额不要用 FLOAT/DOUBLE
sql
amount DECIMAL(14, 2) NOT NULL,
discount_amount DECIMAL(14, 2) NOT NULL DEFAULT 0.00
精确十进制避免二进制浮点舍入误差。同时保存 currency CHAR(3),否则数值 100 本身无法说明币种。
7.2 创建时间与业务时间分开
| 字段 | 含义 |
|---|---|
created_at |
记录写入时间 |
updated_at |
最近一次修改时间 |
paid_at |
业务支付发生时间 |
completed_at |
业务完成时间 |
不能用 updated_at 代替所有业务事件时间,因为一次备注修改就会覆盖原来的完成时刻。 |
7.3 默认使用 utf8mb4
MySQL 的历史 utf8 实际最多三字节,不能覆盖所有 Unicode 字符。新系统应明确使用 utf8mb4,并选择一致的排序规则。
sql
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_0900_ai_ci
utf8mb4_0900_ai_ci 是 MySQL 8.0 常用默认规则,MySQL 5.7 不支持 0900 系列,跨版本复制或迁移时可选择双方都支持的规则。
八、范式化:先消除更新异常,再有证据地冗余
订单头和订单项应拆表,因为它们是一对多:
sql
CREATE TABLE customer_order_item (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
product_name VARCHAR(120) NOT NULL,
unit_price DECIMAL(14, 2) NOT NULL,
quantity INT UNSIGNED NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order_product (order_id, product_id),
KEY idx_product_id (product_id),
CONSTRAINT chk_item_quantity CHECK (quantity > 0)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_0900_ai_ci;
这里冗余 product_name 和 unit_price 反而合理,因为订单需要保存成交时快照,商品后续改名或调价不应改变历史订单。
合理冗余需要同时满足:
- 有明确查询或历史快照价值。
- 指定唯一权威来源。
- 明确何时写入、是否允许更新。
- 有对账或修复机制。
"为了少 JOIN"不是完整理由。
九、外键不是绝对禁止,也不是必须使用
外键的核心价值是让数据库保证引用完整性:
sql
CONSTRAINT fk_item_order
FOREIGN KEY (order_id) REFERENCES customer_order(id)
| 使用外键的收益 | 使用外键的成本 |
|---|---|
| 阻止孤儿记录 | 写入顺序受约束 |
| 规则集中、可审计 | 批量导入和迁移更复杂 |
| 删除更新行为明确 | 跨库分片无法直接使用 |
| 降低应用遗漏风险 | 级联操作可能放大锁影响 |
| 单库核心业务通常值得使用外键;分库分表、跨服务边界或超高吞吐链路可以在应用层维护,但必须有补偿、巡检和孤儿数据治理方案。 | |
不要使用大范围 ON DELETE CASCADE 代替业务删除流程。一次父记录删除可能锁住并删除大量子记录,故障影响难以控制。 |
十、索引设计从查询形状反推
先列出核心查询,再建索引:
sql
-- 查询某客户最近订单
SELECT id, order_no, status, amount, created_at
FROM customer_order
WHERE tenant_id = ?
AND customer_id = ?
AND created_at < ?
ORDER BY created_at DESC, id DESC
LIMIT 20;
对应索引可设计为:
sql
KEY idx_tenant_customer_created (
tenant_id,
customer_id,
created_at,
id
)
联合索引的顺序应考虑:
- 等值过滤列;
- 范围过滤列;
- 排序方向与稳定兜底列;
- 是否需要覆盖高频返回列;
- 写入成本和索引重复度。
"区分度最高的列永远放最左"并不准确。如果查询总是先按租户隔离,tenant_id即使区分度不高,也常应位于前部。
10.1 唯一索引是业务约束,不只是性能工具
下面的应用逻辑存在竞态:
text
先 SELECT 判断订单号不存在
再 INSERT 新订单
两个事务可能同时通过检查并写入重复数据。正确做法是建立唯一索引,插入时处理唯一键冲突。
sql
UNIQUE KEY uk_tenant_order_no (tenant_id, order_no)
数据库是所有写入路径最后共同经过的地方,应由它守住真正的唯一性。
10.2 删除冗余索引
如果已有 (tenant_id, customer_id, created_at),单独的 (tenant_id) 往往是前缀冗余,但不能只凭列前缀就立即删除。
需要检查:
- 是否存在不同覆盖需求;
- 索引可见性和使用统计;
- 排序方向与查询条件;
- 外键是否依赖索引;
- 删除后的执行计划变化。
MySQL 8.0 可先把索引设为不可见做验证:
sql
ALTER TABLE customer_order
ALTER INDEX idx_tenant_id INVISIBLE;
确认没有回退后再删除,比直接删索引更安全。
十一、软删除与唯一键是高频冲突点
常见设计:
sql
is_deleted TINYINT UNSIGNED NOT NULL DEFAULT 0
如果用户名有唯一索引,软删除后重新注册同名用户会冲突。简单加入 is_deleted 也有问题:
sql
UNIQUE KEY uk_name_deleted (tenant_id, user_name, is_deleted)
它只允许一条已删除同名记录,因为所有已删除行的值都是 1。
一种做法是保存唯一删除标记:
sql
deleted_at DATETIME(6) NULL
但 MySQL 唯一索引允许多个 NULL,可以利用生成列只约束有效数据:
sql
active_user_name VARCHAR(64)
GENERATED ALWAYS AS (
CASE WHEN deleted_at IS NULL THEN user_name ELSE NULL END
) STORED,
UNIQUE KEY uk_tenant_active_name (tenant_id, active_user_name)
这个方案必须结合实际排序规则验证大小写和重音语义。
十二、JSON、预留字段和大字段的边界
不要创建 reserved1、reserved2、ext1 这类无语义字段。它们无法被约束,使用一段时间后没人知道里面装了什么。
JSON 可以承载变化快、非核心、低频查询的扩展属性,但以下字段不应藏进去:
- 关联主键;
- 订单状态;
- 金额和币种;
- 高频过滤条件;
- 必须保证唯一的业务字段。
大文本若很少与主记录一起读取,可以拆成一对一扩展表:
sql
CREATE TABLE customer_order_content (
order_id BIGINT UNSIGNED NOT NULL,
content MEDIUMTEXT NOT NULL,
PRIMARY KEY (order_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
这能让核心列表查询的聚簇索引行更窄,但是否拆分仍应由访问模式和真实测量决定。
十三、一份完整的订单表 DDL
sql
CREATE TABLE customer_order (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '技术主键',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
order_no VARCHAR(32) NOT NULL COMMENT '租户内订单号',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID',
status TINYINT UNSIGNED NOT NULL COMMENT '订单状态',
currency CHAR(3) NOT NULL COMMENT 'ISO 4217币种',
goods_amount DECIMAL(14, 2) NOT NULL COMMENT '商品金额',
discount_amount DECIMAL(14, 2) NOT NULL DEFAULT 0.00 COMMENT '优惠金额',
payable_amount DECIMAL(14, 2) NOT NULL COMMENT '应付金额',
paid_at DATETIME(3) NULL COMMENT '支付时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
remark VARCHAR(500) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_order_no (tenant_id, order_no),
KEY idx_tenant_customer_created (tenant_id, customer_id, created_at, id),
KEY idx_tenant_status_created (tenant_id, status, created_at, id),
CONSTRAINT chk_status CHECK (status IN (10, 20, 30, 40, 90)),
CONSTRAINT chk_amount CHECK (
goods_amount >= 0
AND discount_amount >= 0
AND payable_amount >= 0
AND payable_amount = goods_amount - discount_amount
)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_0900_ai_ci
COMMENT = '客户订单';
真实业务可能有运费、税费和多种优惠,届时金额等式要按完整公式调整,而不是照抄示例。约束应表达真实且稳定的不变量。
十四、用系统表做自动化体检
检查没有主键的业务表:
sql
SELECT t.table_schema, t.table_name
FROM information_schema.tables AS t
LEFT JOIN information_schema.table_constraints AS tc
ON tc.table_schema = t.table_schema
AND tc.table_name = t.table_name
AND tc.constraint_type = 'PRIMARY KEY'
WHERE t.table_type = 'BASE TABLE'
AND t.table_schema = 'trade_center'
AND tc.constraint_name IS NULL;
检查表的字符集和排序规则:
sql
SELECT
table_name,
table_collation
FROM information_schema.tables
WHERE table_schema = 'trade_center';
检查列定义:
sql
SELECT
table_name,
column_name,
column_type,
is_nullable,
column_default,
collation_name
FROM information_schema.columns
WHERE table_schema = 'trade_center'
ORDER BY table_name, ordinal_position;
将这些查询接入迁移流水线,比靠人工记忆几十条规范更可靠。
十五、表结构变更要按生产操作设计
MySQL 8.0 支持多种在线 DDL 能力,部分加列可使用 INSTANT:
sql
ALTER TABLE customer_order
ADD COLUMN source TINYINT UNSIGNED NOT NULL DEFAULT 0,
ALGORITHM = INSTANT;
但是否支持取决于具体版本、操作类型和表结构。即使是在线操作,也可能等待元数据锁,长事务会让一个看似瞬间完成的 DDL 长时间阻塞。
上线前要确认:
- 当前版本实际支持的 DDL 算法。
- 是否会重建表和产生大量临时空间。
- 元数据锁等待是否可控。
- 主从复制或集群延迟是否可接受。
- 失败后如何中止与回滚。
MySQL 5.7 没有 8.0 完整的INSTANT能力,大表变更更需要在线变更工具或分阶段迁移。
十六、常见但不可靠的"规范"
误区 1:任何表都必须有完全相同的审计字段。
事件表、关系表、临时表的生命周期不同,应按审计需求选择。
误区 2:一律禁止外键。
外键是完整性工具。是否使用应基于部署边界和运维能力,而不是口号。
误区 3:单表超过 500 万行就必须分库分表。
行宽、查询、硬件、冷热分布差异巨大,没有通用魔法阈值。
误区 4:索引最多只能五个。
索引数量没有统一答案,但每个索引都必须有已知收益和写入成本。
误区 5:所有字段都 NOT NULL DEFAULT ''。
空串不能表达未知时间、未知关联或尚未发生的事件。
误区 6:预留几个字段方便以后扩展。
无语义字段会让数据治理失控,正确做法是按需演进结构。
十七、建表评审清单
- 是否能用一句话说明每张表一行的粒度?
- 主键是否显式、短小、稳定且不可变?
- 业务唯一性是否由唯一索引兜底?
- 每个
NULL是否都有明确业务含义? - 默认值是否可能掩盖调用方漏传参数?
- 金额、时间、状态、字符集是否语义准确?
- 关联列的类型和符号属性是否完全一致?
- 高频查询能否映射到明确的联合索引?
- 冗余字段是否有权威来源和一致性方案?
- JSON 是否只存非核心扩展属性?
- 软删除是否与唯一约束发生冲突?
- 大字段是否影响核心读取路径?
- DDL 在目标 MySQL 小版本上是否验证过算法与锁?
- 是否准备了容量增长、归档和恢复方案?
十八、小结
- 表设计从业务事实和数据粒度开始,不从字段模板开始。
- 技术主键负责行身份,业务唯一性由独立唯一索引保证。
NULL、零值和空字符串语义不同,必须统一约定。- 约束是最后一道数据防线,但 MySQL 5.7 不执行
CHECK。 - 索引应由查询形状反推,不应机械套用数量和区分度规则。
- 范式化先解决更新异常,冗余则必须带一致性方案。
- 外键是否使用取决于系统边界;不使用也必须治理孤儿数据。
- 软删除、大小写不敏感排序和唯一索引组合时尤其容易出错。
- MySQL 8.0 的在线 DDL 能力更强,但元数据锁和资源消耗仍需评估。
- 真正规范应能自动检查、持续演进,并且每条规则都说得清原因。