RSS
菜单
全部文章快讯开发科技深度热点

第 72 章 业务建模踩坑

内容摘要

业务建模是把现实世界映射成表结构的过程,模型一旦建错,后面所有 SQL、索引、分库分表都是在错误的地基上盖楼。本章汇总业务建模阶段最容易踩的 11 个坑,覆盖一对一/多对多关系、状态机、软删除、冗余字段、流水表、币种、审计字段等典型问题。所有 SQL 示例基于 MySQL 8.0 语法。读完你能在需求评审阶段就发现那些"现在省时间、未来还技术债"的建模方案。

📖 本篇简介:业务建模是把现实世界映射成表结构的过程,模型一旦建错,后面所有 SQL、索引、分库分表都是在错误的地基上盖楼。本章汇总业务建模阶段最容易踩的 11 个坑,覆盖一对一/多对多关系、状态机、软删除、冗余字段、流水表、币种、审计字段等典型问题。所有 SQL 示例基于 MySQL 8.0 语法。读完你能在需求评审阶段就发现那些"现在省时间、未来还技术债"的建模方案。

本章适合刚接手中型业务系统、需要参与表结构评审的工程师。建议配合第 42 章(建表与数据类型)、第 62 章(分库分表)一起阅读。

目录

  • 坑 1:一对一关系硬塞进一张大宽表
  • 坑 2:多对多关系不用中间表,硬塞逗号分隔字符串
  • 坑 3:状态字段硬编码魔法数字,没有状态机
  • 坑 4:软删除字段滥用导致唯一索引失效
  • 坑 5:时间字段只存一个 datetime,不区分创建/更新/业务时间
  • 坑 6:冗余字段不同步,主表与统计表数据打架
  • 坑 7:账户余额直接 UPDATE 覆盖,不留流水
  • 坑 8:金额字段不存币种,跨境业务翻车
  • 坑 9:TEXT/JSON 大字段塞进主表拖慢查询
  • 坑 10:用业务单号做主键与外键关联
  • 坑 11:缺审计字段 created_atupdated_atcreated_by
  • 速查表:业务建模红线一览
  • 自测:这个模型合理吗

坑 1:一对一关系硬塞进一张大宽表

场景 / 错误示范:

-- 用户主表 + 用户详情表被合并成一张超宽表
CREATE TABLE user (
  id BIGINT PRIMARY KEY,
  login_name VARCHAR(64),
  password_hash VARCHAR(128),
  -- 下面这些是"详情"字段,90% 的查询根本不需要
  real_name VARCHAR(64),
  id_card VARCHAR(32),
  birthday DATE,
  gender TINYINT,
  address VARCHAR(256),
  avatar_url VARCHAR(512),
  bio TEXT,                 -- 个人简介,可能几 KB
  id_card_front MEDIUMTEXT, -- 身份证正面图 base64,几十 KB
  id_card_back MEDIUMTEXT
);

为什么是坑:InnoDB 按主键聚簇存储,一行的所有列(含溢出页)物理上挨在一起。把不常用的详情、大字段塞进主表,会让每次 SELECT * FROM user WHERE id=1 都要读多个页;二级索引叶子节点也会因为行变大而装更少的记录,缓冲池命中率下降。

正确做法:

-- 正确:主表只放高频字段,详情拆到扩展表,按主键 1:1 拆分
CREATE TABLE user (
  id BIGINT PRIMARY KEY,
  login_name VARCHAR(64) NOT NULL,
  password_hash VARCHAR(128) NOT NULL,
  status TINYINT NOT NULL DEFAULT 0,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE user_profile (
  user_id BIGINT PRIMARY KEY,           -- 与 user.id 1:1,同时是外键
  real_name VARCHAR(64),
  id_card VARCHAR(32),
  birthday DATE,
  gender TINYINT,
  address VARCHAR(256),
  avatar_url VARCHAR(512),
  bio TEXT
);

加分点:垂直拆分的判断标准是"访问频率"。一个字段如果 80% 的查询都不读它,就该拆出去;详情页才 JOIN 一次,列表页完全不碰。

坑 2:多对多关系不用中间表,硬塞逗号分隔字符串

场景 / 错误示范:

-- 一个商品属于多个标签,开发图省事把标签 ID 拼成字符串
CREATE TABLE product (
  id BIGINT PRIMARY KEY,
  name VARCHAR(128),
  tag_ids VARCHAR(256)    -- "1,5,8,12",用逗号分隔
);

-- 想查"打了标签 5 的所有商品"
SELECT * FROM product WHERE tag_ids LIKE '%5%';
-- 错误:会把 tag_id=15、25 也查出来;而且完全用不上索引

为什么是坑:逗号分隔字符串违反第一范式,无法用外键约束、无法走索引、无法 JOIN,查"包含某标签"必须全表扫描 + 字符串拆分。数据量一大,这种查询就是慢查询;想删一个标签还得解析字符串再拼回去,极易出 bug。

正确做法:

-- 正确:标准多对多中间表
CREATE TABLE tag (
  id BIGINT PRIMARY KEY,
  name VARCHAR(64) NOT NULL UNIQUE
);

CREATE TABLE product_tag (
  product_id BIGINT NOT NULL,
  tag_id     BIGINT NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (product_id, tag_id),
  KEY idx_tag (tag_id)          -- 反向查询"某标签下所有商品"走这个索引
);

加分点:中间表还可以挂额外属性,比如"打标签的时间、打标签的人",这些在逗号字符串里根本存不下。

坑 3:状态字段硬编码魔法数字,没有状态机

场景 / 错误示范:

-- 订单表 status 字段,代码里到处是这种判断
-- if (order.status == 1) { ... } else if (order.status == 2) { ... }
-- 没人记得 1 是待支付还是待发货,2 是已支付还是已取消

CREATE TABLE trade_order (
  id BIGINT PRIMARY KEY,
  status INT NOT NULL    -- 注释只写了个"状态"
);

为什么是坑:魔法数字散落在几百个 Java/JS 文件里,改一个状态要全局搜索;新同事接手要靠口口相传。更糟的是状态流转没有约束,代码里可能出现"已取消 → 已支付"这种业务上不允许的跳转,直接造成资金事故。

正确做法:

-- 正确 1:用 TINYINT + 详尽 COMMENT,状态集合放应用层枚举
CREATE TABLE trade_order (
  id BIGINT PRIMARY KEY,
  status TINYINT NOT NULL DEFAULT 0
    COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消 5退款中 6已退款'
);

-- 正确 2:再建一张状态流转表,把合法跳转显式化
CREATE TABLE order_status_transition (
  from_status TINYINT,
  to_status   TINYINT,
  PRIMARY KEY (from_status, to_status)
);
INSERT INTO order_status_transition VALUES
  (0,1),(0,4),(1,2),(1,5),(2,3),(5,6),(5,1);

加分点:状态机应该在应用层用枚举 + switch 实现,所有跳转必须经过统一入口,禁止在业务代码里直接 order.status = 3。

坑 4:软删除字段滥用导致唯一索引失效

场景 / 错误示范:

-- 用户表要求 login_name 唯一,但又要"软删除"保留数据
CREATE TABLE user (
  id BIGINT PRIMARY KEY,
  login_name VARCHAR(64) NOT NULL,
  is_deleted TINYINT NOT NULL DEFAULT 0,
  UNIQUE KEY uk_login (login_name)
);

-- 用户 A 被软删:is_deleted=1
-- 新用户注册同名 login_name='alice' -> 唯一索引冲突,注册不了

为什么是坑:唯一索引不知道"软删除"这种业务概念,它眼里只要 login_name 相同就冲突。结果是被删过的名字再也注册不了,或者开发被迫放弃唯一索引,改成应用层校验,并发下又出重复数据。

正确做法:

-- 正确 1:把删除时间戳拼进唯一索引,软删后原记录 login_name 被"让出"
CREATE TABLE user (
  id BIGINT PRIMARY KEY,
  login_name VARCHAR(64) NOT NULL,
  deleted_at DATETIME NULL DEFAULT NULL,   -- NULL 表示未删除
  UNIQUE KEY uk_login (login_name, deleted_at)
);
-- 未删除:deleted_at 全是 NULL,MySQL 唯一索引里 NULL 不参与冲突判断,多个 NULL 共存
-- 删除时:UPDATE user SET deleted_at=NOW() WHERE id=1
-- 这样 login_name='alice' 可以存在多条 deleted_at 不同的记录

加分点:MySQL 唯一索引对 NULL 的处理是"多个 NULL 不算冲突",这正好被软删除场景利用;不要用 is_deleted TINYINT 拼唯一索引,因为 0 和 0 仍然冲突。

坑 5:时间字段只存一个 datetime,不区分创建/更新/业务时间

场景 / 错误示范:

-- 订单表只建了一个 create_time
CREATE TABLE trade_order (
  id BIGINT PRIMARY KEY,
  order_no VARCHAR(32),
  create_time DATETIME    -- 这是下单时间?支付时间?发货时间?
);
-- 运营问:"昨天有多少订单支付成功?"开发只能翻日志猜

为什么是坑:业务系统里"时间"至少有三类:记录创建时间、记录更新时间、业务发生时间(支付/发货/完成)。混为一谈后,做报表、做审计、做客服查询都要靠猜;等业务量上来,补字段要 ALTER 大表,成本极高。

正确做法:

-- 正确:每张表固定带 created_at / updated_at,业务时间单独建字段
CREATE TABLE trade_order (
  id BIGINT PRIMARY KEY,
  order_no VARCHAR(32) NOT NULL,
  -- 系统时间
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  -- 业务时间,每个关键动作一个字段,默认 NULL
  paid_at     DATETIME NULL,
  shipped_at  DATETIME NULL,
  finished_at DATETIME NULL,
  cancelled_at DATETIME NULL
);

加分点:报表统计一律基于业务时间(paid_at 算支付订单),不要用 created_at 代替,否则"刚创建未支付"的订单会被算进支付额。

坑 6:冗余字段不同步,主表与统计表数据打架

场景 / 错误示范:

-- 订单表冗余了用户昵称,免得每次 JOIN user
CREATE TABLE trade_order (
  id BIGINT PRIMARY KEY,
  user_id BIGINT,
  user_nick VARCHAR(64)   -- 冗余字段,下单时把昵称拷过来
);

-- 用户后来改了昵称,user 表已经更新,但历史订单里的 user_nick 还是老的
-- 运营导出订单列表,看到的是老昵称;客服查用户,看到的是新昵称,两边对不上

为什么是坑:冗余字段是为了性能,但必须明确"以谁为准"。如果是"快照型冗余"(下单时的地址、昵称,永远不改),要在字段名上标 _snapshot;如果是"镜像型冗余"(要跟随主表更新),必须有可靠的同步机制,否则就是数据不一致。

正确做法:

-- 正确 1:快照型冗余,命名上明确"历史快照,永不更新"
CREATE TABLE trade_order (
  id BIGINT PRIMARY KEY,
  user_id BIGINT,
  user_nick_snapshot VARCHAR(64),   -- 下单时的昵称,历史订单就该长这样
  address_snapshot VARCHAR(256)
);

-- 正确 2:镜像型冗余,通过 binlog 订阅(Canal/Debezium)异步同步
-- user 表昵称变更 -> Canal 监听 binlog -> 批量回写 trade_order.user_nick
-- 并在业务上接受"秒级延迟",不要强一致

加分点:能不冗余就不冗余;必须冗余时,先用一句话写清楚"这个字段谁来更新、什么时候更新、不一致怎么办",写不清楚就别冗余。

坑 7:账户余额直接 UPDATE 覆盖,不留流水

场景 / 错误示范:

-- 账户表只有 balance 一个字段,所有变动都直接覆盖
CREATE TABLE account (
  user_id BIGINT PRIMARY KEY,
  balance DECIMAL(12,2)
);

UPDATE account SET balance = balance - 100 WHERE user_id = 1;
-- 月底对账:运营问"这 100 块扣到哪去了?"——查不到,历史被覆盖了

为什么是坑:资金类业务必须"余额可追溯"。直接 UPDATE 覆盖后,出了客诉、出了对账差异,根本无法复盘是哪笔订单扣的钱。监管要求支付类企业必须保留完整流水,不留流水等于合规风险。

正确做法:

-- 正确:余额表 + 流水表双写
CREATE TABLE account (
  user_id BIGINT PRIMARY KEY,
  balance DECIMAL(12,2) NOT NULL DEFAULT 0   -- 当前余额,由流水汇总而来
);

CREATE TABLE account_flow (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  change_amount DECIMAL(12,2) NOT NULL,      -- 正=入账,负=出账
  balance_after DECIMAL(12,2) NOT NULL,      -- 变动后余额,用于对账
  biz_type VARCHAR(32) NOT NULL,             -- ORDER_REFUND / CONSUME / RECHARGE
  biz_no VARCHAR(64) NOT NULL,               -- 关联业务单号
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  KEY idx_user_time (user_id, created_at)
);
-- 所有变动必须先写流水,再更新余额;余额 = SUM(流水)

加分点:流水表只 INSERT 不 UPDATE/DELETE,余额表是流水的"物化视图";任何对账差异都可以通过 SUM(flow) 与 balance 对比定位。

坑 8:金额字段不存币种,跨境业务翻车

场景 / 错误示范:

-- 订单表只有一个 amount 字段
CREATE TABLE trade_order (
  id BIGINT PRIMARY KEY,
  amount DECIMAL(12,2)    -- 这是人民币?美元?欧元?
);
-- 国内订单 amount=100,美国订单 amount=100,财务报表一加:200,
-- 实际 100 人民币 + 14 美元 = 约 200 人民币,对不上

为什么是坑:只要业务可能出海、接海外支付、做跨境结算,不存币种就是定时炸弹。更隐蔽的是汇率:即使存了币种,不存下单时的汇率快照,事后也无法复算当时的人民币金额。

正确做法:

-- 正确:金额 + 币种 + 汇率快照三件套
CREATE TABLE trade_order (
  id BIGINT PRIMARY KEY,
  currency_code CHAR(3) NOT NULL DEFAULT 'CNY',   -- ISO 4217:CNY/USD/EUR
  original_amount DECIMAL(12,2) NOT NULL,         -- 原币金额
  exchange_rate DECIMAL(12,6) NOT NULL DEFAULT 1, -- 下单时对 CNY 的汇率
  cny_amount DECIMAL(12,2) GENERATED ALWAYS AS
    (original_amount * exchange_rate) STORED       -- 生成列,便于报表
);

加分点:币种字段用 CHAR(3) 存 ISO 4217 代码,不要用中文"人民币";汇率快照必须随订单落库,不能事后查汇率表重算。

坑 9:TEXT/JSON 大字段塞进主表拖慢查询

场景 / 错误示范:

-- 商品表把商品详情 HTML、图片 JSON、规格 JSON 全塞进来
CREATE TABLE product (
  id BIGINT PRIMARY KEY,
  name VARCHAR(128),
  price DECIMAL(10,2),
  detail_html MEDIUMTEXT,     -- 商品详情页 HTML,几十 KB
  images_json JSON,           -- 图片列表
  spec_json JSON              -- 规格参数
);
-- 列表页 SELECT id,name,price FROM product 时,
-- InnoDB 仍然可能因为行过大而读多个页

为什么是坑:InnoDB 行格式为 DYNAMIC 时,TEXT/BLOB 的前 768 字节或 20 字节指针会留在聚簇索引页,其余存到溢出页。但只要行里有大字段,缓冲池里一页能装的行数就变少,列表查询的缓存命中率下降。

正确做法:

-- 正确:主表只放列表查询字段,详情拆到扩展表
CREATE TABLE product (
  id BIGINT PRIMARY KEY,
  name VARCHAR(128) NOT NULL,
  price DECIMAL(10,2) NOT NULL,
  status TINYINT NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE product_detail (
  product_id BIGINT PRIMARY KEY,
  detail_html MEDIUMTEXT,
  images_json JSON,
  spec_json JSON
);
-- 列表页只查 product;详情页才 JOIN product_detail

加分点:JSON 字段虽然灵活,但不要把它当"万能列",能拆成关系表就拆;JSON 上的函数索引(MySQL 8.0)可以解决局部查询需求,但写入成本高。

坑 10:用业务单号做主键与外键关联

场景 / 错误示范:

-- 用订单号当主键
CREATE TABLE trade_order (
  order_no VARCHAR(32) PRIMARY KEY    -- '2026100700012345'
);

CREATE TABLE order_item (
  id BIGINT PRIMARY KEY,
  order_no VARCHAR(32),               -- 外键,每行带 32 字节字符串
  sku_id BIGINT,
  qty INT
);
-- 所有二级索引叶子节点都带着 32 字节的 order_no,空间放大严重

为什么是坑:InnoDB 聚簇索引按主键物理排序,业务单号通常是"时间 + 序列",虽然趋势递增,但字符串本身比 BIGINT 占空间;更糟的是每个二级索引叶子节点都要带一份主键值,二级索引空间被白白放大。业务单号一旦要改(合规要求、系统迁移),主键一改全库联动。

正确做法:

-- 正确:代理键 BIGINT 自增或雪花 ID 做主键,业务单号加唯一索引
CREATE TABLE trade_order (
  id BIGINT PRIMARY KEY,                  -- 代理键,聚簇索引
  order_no VARCHAR(32) NOT NULL UNIQUE,   -- 业务单号,走唯一二级索引
  ...
);

CREATE TABLE order_item (
  id BIGINT PRIMARY KEY,
  order_id BIGINT NOT NULL,               -- 外键用代理键,8 字节
  sku_id BIGINT,
  qty INT,
  KEY idx_order (order_id)
);

加分点:关联表之间一律用代理键关联,业务单号只在对外展示和对账时使用;这是为了让"业务规则变化"不触动"存储结构"。

坑 11:缺审计字段 created_at/updated_at/created_by

场景 / 错误示范:

-- 一张业务表,没有任何审计字段
CREATE TABLE coupon (
  id BIGINT PRIMARY KEY,
  name VARCHAR(64),
  discount DECIMAL(10,2)
);
-- 出问题时:这张券谁建的?什么时候建的?谁改的?——全不知道

为什么是坑:审计字段不是"锦上添花",是出事时唯一的追溯线索。客服要查"这张券什么时候发的"、合规要查"这条数据谁改的"、运维要按时间做增量归档,没有审计字段全部抓瞎。

正确做法:

-- 正确:每张业务表固定带三件套 + 操作人
CREATE TABLE coupon (
  id BIGINT PRIMARY KEY,
  name VARCHAR(64) NOT NULL,
  discount DECIMAL(10,2) NOT NULL,
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  created_by  BIGINT NOT NULL,                 -- 创建人用户 ID
  updated_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  updated_by  BIGINT NOT NULL,                 -- 最后修改人
  is_deleted  TINYINT NOT NULL DEFAULT 0
);
-- 这五个字段建议在公司建表模板里默认带上,不用业务方每次想

加分点:核心业务表(订单、账户、权限)还应加一张操作流水表,记录每次 UPDATE 前后的值,而不仅是 updated_by。

速查表:业务建模红线一览

| 建模决策 | 错误做法 | 正确做法 |

|---|---|---|

| 1:1 关系 | 全塞一张宽表 | 主表高频 + 扩展表低频大字段 |

| 多对多 | 逗号分隔字符串 | 独立中间表 + 双向索引 |

| 状态机 | 魔法数字散落在代码 | TINYINT + 枚举 + 状态流转表 |

| 软删除 | is_deleted 拼唯一索引 | deleted_at 时间戳拼唯一索引 |

| 时间字段 | 一个 create_time 走天下 | created_at/updated_at + 业务时间分列 |

| 冗余字段 | 拷过来就不管 | 明确快照型 vs 镜像型,写清同步机制 |

| 资金余额 | UPDATE 覆盖 | 余额表 + 只追加流水表 |

| 跨境金额 | 只存 amount | amount + currency + exchange_rate 快照 |

| 大字段 | TEXT/JSON 塞主表 | 拆到详情扩展表 |

| 主键 | 业务单号当主键 | BIGINT 代理键 + 业务号唯一索引 |

| 审计 | 啥都没有 | created_at/by + updated_at/by + is_deleted |

自测:这个模型合理吗

题 1:为什么多对多关系不能用逗号分隔字符串?

<details>

<summary>参考答案</summary>

违反第一范式,列里存多个值无法用外键约束、无法走索引、无法 JOIN;查"包含某标签"必须全表扫描 + 字符串解析。正确做法是建独立中间表 product_tag(product_id, tag_id),并在两列上各建索引支持双向查询。

</details>

题 2:软删除与唯一索引冲突怎么解?

<details>

<summary>参考答案</summary>

用 deleted_at DATETIME NULL 代替 is_deleted TINYINT,唯一索引建在 (login_name, deleted_at) 上。未删除记录 deleted_at 为 NULL,MySQL 唯一索引不认为多个 NULL 冲突;删除时把 deleted_at 设为当前时间戳,原 login_name 被"让出",新用户可以注册同名。

</details>

题 3:为什么账户余额不能直接 UPDATE 覆盖?

<details>

<summary>参考答案</summary>

资金类业务必须可追溯,直接覆盖后历史变动丢失,客诉与对账无法复盘。正确做法是"余额表 + 流水表"双写:所有变动先 INSERT 一条流水(含变动后余额),再更新余额表;余额本质上是流水的物化汇总,任何差异都可以通过 SUM(flow) 重算定位。

</details>

题 4:代理键与业务主键怎么选?

<details>

<summary>参考答案</summary>

关联表之间一律用 BIGINT 代理键(自增或雪花 ID)做主键,因为 8 字节定长、趋势递增、二级索引叶子节点携带成本低;业务单号(订单号、手机号)加唯一二级索引用于对外展示与对账。这样业务规则变化(订单号改版)不会触动存储结构。

</details>

题 5:冗余字段有哪两种,分别怎么维护?

<details>

<summary>参考答案</summary>

快照型冗余(下单时的昵称、地址):拷过来就永不更新,字段名加 _snapshot,反映"历史那一刻"。镜像型冗余(要跟随主表变化):通过 binlog 订阅(Canal/Debezium)异步同步,业务上接受秒级延迟,不要强求强一致。拿不准是哪种时,宁可不冗余。

</details>

下一篇预告:第 73 章 大数据量场景踩坑

— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 72 章 业务建模踩坑》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。