从 0 到 1:手写一个社群团购系统——数据库设计(上):团购、商品、订单三张主表的演进过程
先给结论
数据建模最容易犯的错,是打开数据库工具直接开始画 ER 图。正确的顺序是反过来的:先把业务规则一条条列出来,再问"这条规则需要什么数据才能落地",表结构是答案的汇总,不是起点。
本篇先给结论:
- 团购、商品、订单三张主表,每一列都能追溯到某条业务规则——追溯不出来的字段,一律不加;
- 三级价格体系(供货价/帮卖价/零售价)落在 SKU 表,不放商品表;
- 订单表必须冗余价格快照字段——主数据只代表"现在",交易事实以"当时"为准;
- 金额从第一行 DDL 起就以「分」为单位存 BIGINT,这条规矩后期改造的成本极高,第一天就要立;
- 状态机字段用 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 篇的业务规则,追溯不出的字段不加;
- 团购表用 end_time + status 双重约束截单:定时任务推进状态,接口实时校验兜底;时间统一 DATETIME(3);
- 三级价格(供货价/帮卖价/零售价)落在 SKU 表,各有人设置、有人使用;加价上限
markup_max是帮卖规则的数据落点; - 订单明细冗余全套快照字段(成交价/供货价/佣金比例),
sku_id只关联不取价——交易事实以快照为准,存储换不可抵赖,永远划算; - 状态机 10 间隔编码,给必然新增的中间态留位置;支付与履约是两条独立状态机,分两个字段;
- 索引跟着查询走:六类核心查询一一对应,同接口按"谁在看"拆 SQL 分流索引,OR 条件是索引杀手;
- 金额一律 long 分存储,从第一行 DDL 立规矩,浮点陷阱和精度丢失在资金场景零容忍。
三张主表解决的是"货",但这条业务线里真正容易出事的是"钱":佣金怎么在表结构上保证不出账?下一篇《数据库设计(下):佣金、结算表的资金安全设计》——资金表设计的 6 条军规,从分片键前置埋点讲到下单事务内锁佣金快照。

浙公网安备 33010602011771号