从 0 到 1:手写一个社群团购系统——数据库设计(上):团购、商品、订单三张主表的演进过程

先给结论

数据建模最容易犯的错,是打开数据库工具直接开始画 ER 图。正确的顺序是反过来的:先把业务规则一条条列出来,再问"这条规则需要什么数据才能落地",表结构是答案的汇总,不是起点。

本篇先给结论:

  1. 团购、商品、订单三张主表,每一列都能追溯到某条业务规则——追溯不出来的字段,一律不加;
  2. 三级价格体系(供货价/帮卖价/零售价)落在 SKU 表,不放商品表;
  3. 订单表必须冗余价格快照字段——主数据只代表"现在",交易事实以"当时"为准;
  4. 金额从第一行 DDL 起就以「分」为单位存 BIGINT,这条规矩后期改造的成本极高,第一天就要立;
  5. 状态机字段用 10 间隔编码,给必然增加的中间态留位置。

下一篇讲资金相关的佣金表和结算表,本篇先解决"货"的三张主表。全程用同一套方法演示:业务规则 → 数据需求 → 表字段,你会看到表结构是怎么"推"出来的。


1. 从业务规则到表:一次完整的推导

第 1 篇拆过这套系统的 8 条业务规则,挑出和三张主表相关的 6 条,做一次"规则 → 数据需求 → 落点"的推导:

业务规则 需要什么数据 落点
到截单时间后不可再下单 团购的截单时间、生命周期状态 t_group_buy.end_time + status
帮卖团长在供货价基础上自主加价 每个商品的供货价、建议帮卖价、加价上限 t_goods_sku 三个价格字段
不同规格价格不同(3 斤装/5 斤装) SKU 级别的价格与库存 t_goods_sku
订单归属帮卖团长,佣金按下单时规则算 下单时的帮卖关系与价格快照 t_order.help_sell_id + t_order_item 快照字段
消费者可查"我的订单",帮卖可查"我带来的订单" 订单同时按买家、按帮卖两个维度查询 t_order 两个查询索引
未支付订单 30 分钟自动关闭 支付状态、下单时间 t_order.pay_status + create_time

把落点串起来,三张主表加一张关系表的关系长这样:

t_group_buy(团购)──1:N──> t_goods(商品)──1:N──> t_goods_sku(SKU)
      │                                                  │
      1:N                                                1:N
      │                                                  │
   t_order(订单)──1:N──> t_order_item(订单明细)────────┘
      │
      0..1(自购为空)
      │
   t_help_sell_record(帮卖绑定记录,第 14 篇展开)

注意订单和帮卖团长之间是"关系"不是"字段"——不是在订单表上简单放一个团长 ID 就完事,而是指向一条独立的帮卖绑定记录。这个建模在佣金场景会反复兑现(第 6、14 篇),这里先按下不表。下面逐表展开。


2. 团购表:状态机字段 + 截单时间的双重约束

一场团购的生命周期:创建(草稿)→ 上架进行中 → 到点截单 → 履约完成(或中途取消)。团购表的核心不是"团购有哪些信息"(标题、封面、说明这些谁都会加),而是生命周期怎么用字段表达:

CREATE TABLE `t_group_buy` (
  `id`             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `title`          VARCHAR(64)  NOT NULL COMMENT '团购标题',
  `leader_id`      BIGINT       NOT NULL COMMENT '供货团长ID(未来分片键,前置埋点)',
  `status`         TINYINT      NOT NULL DEFAULT 10
                   COMMENT '10草稿 20进行中 30已截单 40已完成 50已取消',
  `start_time`     DATETIME(3)  NOT NULL COMMENT '开售时间',
  `end_time`       DATETIME(3)  NOT NULL COMMENT '截单时间',
  `min_order_num`  INT          NOT NULL DEFAULT 0 COMMENT '最低成团单数,0=不设门槛',
  `delivery_type`  TINYINT      NOT NULL DEFAULT 10 COMMENT '10快递 20到店自提',
  `auto_close`     TINYINT      NOT NULL DEFAULT 1 COMMENT '是否自动截单',
  `create_time`    DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `update_time`    DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
                   ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  KEY `idx_gb_status_endtime` (`status`, `end_time`)
) ENGINE=InnoDB COMMENT='团购活动表';

三个设计理由值得展开:

第一,截单靠 end_time + status 双重约束,不靠单点。 定时任务到点把状态从 20 改成 30,但任务可能延迟(第 4 篇说过任务失败不能是静默事件,但延迟总有可能)。所以下单接口必须做实时校验:status = 20 且 now() < end_time,两个条件缺一不可。只查状态,任务延迟一分钟就多放进来一批本不该有的订单;只比时间,手动提前截单的团购就失效了。定时任务负责"批量推进状态",接口校验负责"绝对兜底",两层各管各的。

第二,min_order_num 成团条件保留但默认 0。 社群团购多数不设成团人数——团长在群里发起了基本就能成。但"不满 N 单自动取消并退款"是预售制供应链的常见玩法(供货方按成团量向产地下单),字段留着默认关闭,成本低,反过来补字段的成本高。

第三,时间一律 DATETIME(3) 不用 TIMESTAMP。 TIMESTAMP 有 2038 上限和时区转换行为,订单、团购这类业务时间不需要它带来的任何特性,全项目统一 DATETIME 消除心智负担。

状态机编码 10/20/30/40/50 为什么留 10 的间隔,第 5 节专门讲。


3. 商品表:三级价格体系落在 SKU

这套系统最核心的商品规则是三级价格:供货团长填报供货价(他的结算依据)、平台给出帮卖价(帮卖团长加价的基准)、页面展示零售价(消费者的到手价,帮卖价 + 帮卖加价)。三级价格的"消费方"各不相同:

价格 谁设置 谁使用 什么时候用到
供货价 price_supply 供货团长 平台结算、审核 供货团长结算、佣金基数
帮卖价 price_helpsell 平台/供货团长 帮卖团长 一键帮卖时的加价起点
零售价 price_retail 帮卖团长(帮卖价+加价) 消费者 C 端展示与下单

价格必须放在 SKU 表而不是商品表——同一个团购里的商品有规格(5 斤装/10 斤装),规格不同价格不同,这在生鲜品类是常态而非例外:

CREATE TABLE `t_goods` (
  `id`           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `group_buy_id` BIGINT      NOT NULL COMMENT '所属团购',
  `title`        VARCHAR(128) NOT NULL COMMENT '商品标题',
  `main_image`   VARCHAR(256) NOT NULL COMMENT '主图',
  `status`       TINYINT     NOT NULL DEFAULT 10 COMMENT '10上架 20下架',
  `sort`         INT         NOT NULL DEFAULT 0 COMMENT '排序权重',
  PRIMARY KEY (`id`),
  KEY `idx_goods_gb_status` (`group_buy_id`, `status`)
) ENGINE=InnoDB COMMENT='商品表';

CREATE TABLE `t_goods_sku` (
  `id`              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `goods_id`        BIGINT   NOT NULL COMMENT '所属商品',
  `spec`            VARCHAR(64) NOT NULL COMMENT '规格描述,如 5斤装',
  `price_supply`    BIGINT   NOT NULL COMMENT '供货价(分)',
  `price_helpsell`  BIGINT   NOT NULL COMMENT '建议帮卖价(分)',
  `price_retail`    BIGINT   NOT NULL COMMENT '建议零售价(分)',
  `markup_max`      INT      NOT NULL DEFAULT 0 COMMENT '帮卖加价上限(分),0=不允许加价',
  `stock_total`     INT      NOT NULL DEFAULT 0 COMMENT '总库存',
  `stock_lock`      INT      NOT NULL DEFAULT 0 COMMENT '已预扣库存',
  `limit_per_user`  INT      NOT NULL DEFAULT 0 COMMENT '每人限购,0=不限',
  PRIMARY KEY (`id`),
  KEY `idx_sku_goods` (`goods_id`)
) ENGINE=InnoDB COMMENT='商品SKU表';

三个字段细节:markup_max 加价上限——帮卖自主加价不能无限加,否则同一个货源在群里卖出十种价格,供货团长的价格体系就乱了;这个上限就是"加价校验"这条业务规则的数据落点。库存用 stock_total - stock_lock 计算剩余而不存"剩余库存"字段——两个字段一个值,并发下必然算错,剩余库存永远现算(配合 Redis 预扣,第 12 篇展开)。limit_per_user 限购是防羊毛党的第一道闸,羊毛党最爱囤低价生鲜团购。


4. 订单表:为什么冗余快照字段

这是三张主表里最重要的设计决策,值得用整个小节讲透。

先看反例:某天供货团长把 5 斤装草莓的供货价从 39.9 调到 49.9,用户来投诉"我下单时明明是 39.9"——如果订单明细里没存价格,你只能去翻日志、翻改价记录,甚至无从举证。更隐蔽的是佣金:帮卖团长的佣金按下单时的加价算,如果订单表只存 SKU ID、佣金结算时才去 JOIN SKU 表取价,任何一次改价都会让历史订单的佣金跟着漂移。资金纠纷九成源于"按下单时的规则还是现在的规则算",快照字段把这个问题从根上消灭。

所以订单表的设计原则是:交易发生那一刻的业务事实,全部以快照形式固化进订单,之后主数据怎么改都与历史订单无关。

CREATE TABLE `t_order` (
  `id`             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `order_no`       CHAR(32)     NOT NULL COMMENT '业务订单号',
  `buyer_user_id`  BIGINT       NOT NULL COMMENT '买家',
  `help_sell_id`   BIGINT       NULL COMMENT '帮卖绑定记录ID,自购为NULL',
  `leader_id`      BIGINT       NOT NULL COMMENT '供货团长ID(未来分片键,前置埋点)',
  `group_buy_id`   BIGINT       NOT NULL COMMENT '团购ID',
  `pay_amount`     BIGINT       NOT NULL COMMENT '实付金额(分)',
  `pay_status`     TINYINT      NOT NULL DEFAULT 10 COMMENT '10待支付 20已支付 30已退款',
  `fulfil_status`  TINYINT      NOT NULL DEFAULT 10 COMMENT '10待履约 20已发货 30已核销 40已完成',
  `create_time`    DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`),
  KEY `idx_order_buyer_ctime` (`buyer_user_id`, `create_time`),
  KEY `idx_order_helpsell_ctime` (`help_sell_id`, `create_time`),
  KEY `idx_order_gb_status` (`group_buy_id`, `pay_status`)
) ENGINE=InnoDB COMMENT='订单主表';

CREATE TABLE `t_order_item` (
  `id`                       BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `order_id`                 BIGINT      NOT NULL,
  `sku_id`                   BIGINT      NOT NULL COMMENT 'SKU,仅用于关联,不用于取价',
  `goods_title_snapshot`     VARCHAR(128) NOT NULL COMMENT '商品标题快照',
  `spec_snapshot`            VARCHAR(64)  NOT NULL COMMENT '规格快照',
  `quantity`                 INT         NOT NULL,
  `price_deal`               BIGINT      NOT NULL COMMENT '成交单价快照(分)',
  `price_supply_snapshot`    BIGINT      NOT NULL COMMENT '供货价快照(分),佣金基数',
  `commission_rate_snapshot` DECIMAL(5,4) NOT NULL COMMENT '帮卖佣金比例快照',
  PRIMARY KEY (`id`),
  KEY `idx_item_order` (`order_id`)
) ENGINE=InnoDB COMMENT='订单明细表';

注意 t_order_item 里 sku_id 的注释——它只用于关联,不用于取价。评审时看到有人写 JOIN t_goods_sku ON ... 取订单价格,直接打回。两案对比:

维度 不冗余快照(JOIN SKU 取价) 冗余快照进订单明细
历史订单正确性 改价后历史订单价格错乱 永远是下单时的真实价格
佣金可举证性 依赖改价记录,扯皮无解 订单行内自证,铁证
查询性能 列表页多一次 JOIN 单表查询,列表页零 JOIN
存储成本 省 每单多约 100 字节,千万级订单约 1GB

最后一行是多数人反对冗余的理由——每单多 100 字节。千万级订单换 1GB 存储,换"每一笔交易的事实不可抵赖",这笔账在任何交易系统里都该毫不犹豫地选右边。存储是最便宜的,争议和资损是最贵的。

还有一个容易被忽略的细节:leader_id 同时冗余进了订单表和团购表。单表时代它"看起来多余"(订单可以顺链路查到团购再查到团长),但它是未来的分片键——按团长分片后,"导出某供货团长的全部订单"必须落在单片内。分片键必须设计期埋点,不能等拆表时回填千万行数据。


5. 状态机编码:为什么留 10 的间隔

三张主表的状态字段全部采用"10 起步、10 间隔"的整型编码。理由只有一个,但足够硬:状态机的中间态几乎一定会增加。

真实案例:订单状态初期是 0待支付 1已支付 2已完成,上线三个月后供应链提需求——批量发货后要有"部分发货"状态。0/1/2 的连续编码里没有位置,你只能用 3,然后前端的状态字典、后台的筛选下拉、BI 的报表口径全要同步改,历史数据里如果有按"状态 < 2"写的判断逻辑,全部要排查。而 10 间隔编码下,"部分发货"插成 25(20 已支付和 30 已完成之间),排序不变、区间判断不破、报表区间含义不变,改动收敛在一个枚举文件里。

public enum OrderStatus {
    WAIT_PAY(10), PAID(20), PART_SHIPPED(25), ALL_SHIPPED(30), DONE(40), CLOSED(50);
}

同理,支付状态和履约状态分成两个字段而不是一个——支付流转和履约流转是两条独立的状态机(先支付后履约,退款时支付回退而履约可能已发生),合成一个字段必然出现"已支付未发货""已发货已退款"这类组合无法表达的时刻。


6. 索引跟着查询走:六类核心查询一一对应

索引不是"给字段加索引",是"给查询加索引"。先列这个系统的六类核心查询,再回头对照上面 DDL 里的索引:

查询场景 触发频率 对应索引
消费者看进行中的团购列表 极高 idx_gb_status_endtime(status, end_time)
消费者查"我的订单" 极高 idx_order_buyer_ctime(buyer_user_id, create_time)
帮卖查"我带来的订单" 极高 idx_order_helpsell_ctime(help_sell_id, create_time)
供货方截单后批量导出订单 中(脉冲) idx_order_gb_status(group_buy_id, pay_status)
佣金页打开即查佣金明细 高 第 6 篇 idx_comm_leader_status
幂等防重 每次下单 uk_order_no 唯一索引

这张表想强调的不是某个索引,而是一个高频踩坑:前两类查询是同一个"订单列表"接口服务的,但买家视角走 idx_order_buyer,帮卖视角走 idx_order_helpsell。 很多团队图省事写成一条 SQL:WHERE buyer_user_id = ? OR help_sell_id = ?——OR 条件让两个索引都用不上,MySQL 只能全表扫描,订单表到百万行后这个接口就是定时炸弹。正确做法是按"谁在看"拆成两条 SQL 分流,代码多五行的代价换来索引永远命中。

索引设计的检验方法也一并给出:把六类查询的 EXPLAIN 结果贴进 Code Review,type 必须是 ref 或 range,出现 ALL(全表扫描)当场改。索引跟着查询走、查询跟着业务链路走,第 2 篇推的五条业务链路,就是索引清单的来源。


7. 金额字段:从第一行 DDL 就用「分」

本篇反复出现的 BIGINT ... COMMENT 'xxx(分)' 不是风格偏好,是资金安全的第一道闸。为什么不用 DECIMAL 存元?对比一下:

维度 BIGINT(分) DECIMAL(12,2)(元)
数据库精度 整数,绝对精确 精确,但依赖定义
Java 侧 long,天然精确 BigDecimal,用错 scale 即出错
前端 JS 计算 传分,展示时格式化 parseFloat 有浮点陷阱(0.1+0.2 !== 0.3)
乘除运算(佣金) 整数运算无舍入歧义 每步运算要指定 RoundingMode
团队心智 一条规则:都是分 一套规则:哪里要转换、哪里会丢精度

浮点陷阱不是理论问题:0.1 + 0.2 = 0.30000000000000004,一笔订单看不出问题,一万笔佣金结算累计出的分差足以让对账永远对不平。JS 侧还有第二个坑:超过 2^53 - 1(约 9007 万亿)的整数会丢精度,虽然"分"为单位下很难触达,但反过来说明金额计算永远不该发生在前端——前端只做展示格式化,一切计算在服务端用 long 完成。

团队规范写两条:数据库和 Java 全链路金额一律 long 分;对外展示的格式化只发生在 DTO 的最后一步。 违反者 Code Review 一票否决。


8. 本篇小结

  1. 表结构是从业务规则推出来的——每张表每个字段都能追溯到第 1 篇的业务规则,追溯不出的字段不加;
  2. 团购表用 end_time + status 双重约束截单:定时任务推进状态,接口实时校验兜底;时间统一 DATETIME(3);
  3. 三级价格(供货价/帮卖价/零售价)落在 SKU 表,各有人设置、有人使用;加价上限 markup_max 是帮卖规则的数据落点;
  4. 订单明细冗余全套快照字段(成交价/供货价/佣金比例),sku_id 只关联不取价——交易事实以快照为准,存储换不可抵赖,永远划算;
  5. 状态机 10 间隔编码,给必然新增的中间态留位置;支付与履约是两条独立状态机,分两个字段;
  6. 索引跟着查询走:六类核心查询一一对应,同接口按"谁在看"拆 SQL 分流索引,OR 条件是索引杀手;
  7. 金额一律 long 分存储,从第一行 DDL 立规矩,浮点陷阱和精度丢失在资金场景零容忍。

三张主表解决的是"货",但这条业务线里真正容易出事的是"钱":佣金怎么在表结构上保证不出账?下一篇《数据库设计(下):佣金、结算表的资金安全设计》——资金表设计的 6 条军规,从分片键前置埋点讲到下单事务内锁佣金快照。


posted @ 2026-09-07 14:43  15889726201  阅读(9)  评论(0)    收藏  举报