Java后端做前后端设计,数据库表到底该拆多细?
一、我先拆成18张表,然后被一个列表页教做人
去年做公司内部的订单中心重构,需求是从下单到发货到对账全链路。拿到需求文档那天我雄心勃勃,觉得这次一定要设计得"专业"一点——商品、SKU、库存、订单、明细、支付、物流、地址、优惠券、发票……我一口气拆了18张表,每张表都严格满足第三范式,字段没有一处冗余。设计文档发到群里,还有同事夸了一句"这个ER图画得漂亮"。
两周后,业务要一个"我的订单"列表页:展示订单号、下单时间、商品名称、规格、单价、数量、金额小计、订单状态、收货人。
我写出来的 SQL 长这样:
SELECT o.order_no, o.create_time, o.status, o.pay_amount,
p.product_name, s.spec_desc, i.sku_price, i.quantity,
a.receiver_name, a.receiver_phone, a.detail_address
FROM t_order o
JOIN t_order_item i ON i.order_id = o.id
JOIN t_sku s ON s.id = i.sku_id
JOIN t_product p ON p.id = s.product_id
LEFT JOIN t_user_addr a ON a.id = o.addr_id
WHERE o.user_id = #{userId}
ORDER BY o.create_time DESC
LIMIT 20;
五个 JOIN。上线后 P95 响应时间 1.8 秒,慢查询日志天天报警。更要命的是后来发现一个隐蔽的BUG:商品改价之后,历史订单里显示的单价跟着变了,财务对账对不上,被追着问了三天。
那次之后我重新想了这个问题。表拆多细,从来不是范式问题,是三个更现实的问题:这张表的字段变更频率一样吗?它会不会被单独查询统计?它跟别人的事务边界在哪?
二、现在我用飞算JavaAI /前后端设计 做数据库设计
先把这个能力说清楚。/前后端设计 的入口在飞算JavaAI的智能会话里——添加指令 → 选 /前后端设计。它有一个硬依赖:必须先有需求分析产出的需求文档和业务设计文档,否则它拿不到业务上下文。跑起来之后它会输出一整套文档:工作区上下文、技术栈决策、数据库设计、接口设计、技术需求覆盖、后端设计规范基线等等。设计过程中遇到需要拍板的地方,它会停下来让你确认。
我现在的用法是:拿 /需求分析 出来的需求文档和业务设计文档喂给 /前后端设计,让它先出第一版数据库设计,然后我按自己的判断改。这个顺序比我自己从零画ER图快得多,但它给的第一版有个很明显的倾向——偏规范化。它倾向于把每个实体都拆成独立的表,外键关系拉得清清楚楚。
这不算错,范式化是安全的默认值。但它不会替你考虑"这个列表页一天要查三百万次",也不会替你考虑"商品改价不能影响历史订单"。这两件事,得你自己来。
三、同一个需求,两种拆法
场景就用那个订单中心。核心诉求:用户选多个商品下单,扣库存,支付,发货,售后可查历史。
方案A:大宽表,能一张表绝不两张
t_order: id, order_no, user_id, status, pay_amount, ...
product_info (JSON), receiver_info (JSON)
商品明细和收货信息塞 JSON 字段。好处是下单写入只要一次 INSERT,天然单表事务,列表页零 JOIN,快得飞起。
坏处呢,我踩过:
- 想统计"这个月卖得最好的SKU",得把 product_info 全表扫出来在应用层解析;
- 想按商品名模糊搜索订单,
LIKE '%手机%'打在 JSON 上,索引完全用不上; - 售后要改单个商品的数量,得读出整个 JSON、改完再整体写回,并发下互相覆盖;
- 收货信息存的是地址ID还是快照?存ID的话用户改了地址,历史订单跟着变。
大宽表的真正问题不是"字段多",是它把生命周期完全不同的数据强行绑在了一行上。订单状态一天变五次,商品名称一年变一次,它们凭什么挤在一张表里。
方案B:完全规范化,每个实体一张表
就是我最初那18张表。理论上挑不出毛病,但在真实的列表页和真实的对账场景里,代价是:
- 列表页 5~8 个 JOIN,数据量上来就是慢查询制造机;
- 下单要写订单、明细、库存三张表,分布式事务或者本地事务边界都得精心设计;
- 商品改名/改价会"穿透"到历史订单,历史数据不再可信。
两种方案的返工成本,完全不对等
我后来复盘过这两种极端做法,发现它们的失败方式不一样,代价也完全不对等。
拆太粗的代价是"改不动"。 一开始省下来的 JOIN,最后会以"这个需求做不了"的形式还回来。运营想看SKU维度的销量排行,JSON 字段撑不住,只能把数据抽到数仓里跑离线任务;一个实时需求硬生生做成 T+1。更难受的是字段语义的污染——JSON 里今天塞商品信息,明天有人塞优惠券信息,半年后没人敢动这张表,因为不知道里面到底有什么。
拆太细的代价是"查不动、写不动"。 查询要靠 JOIN 堆,数据量一上来就得加冗余、加缓存、加宽表去补救。写入则要把一个业务动作拆成多次数据库操作,事务边界、幂等、补偿全都冒出来了。我那次重构,最后有三分之一的代码是在处理"多表写入失败怎么办",而不是业务逻辑本身。
但这两者有个关键区别:拆太细是可以事后补救的——加冗余字段、加快照、建宽表,都属于增量改动,风险可控。而拆太粗基本要重来——把 JSON 字段拆成独立的表,意味着全量数据迁移、所有读写逻辑重写、上下游接口全部调整。
所以我的结论很明确:拿不准的时候,宁可拆细一点。 细了能补,粗了只能推倒。这一点上我和AI的默认倾向是一致的——/前后端设计 给出的第一版偏规范化,方向没错,我要做的只是按查询路径和变更频率,在它基础上做减法式的冗余。
两种方案的正面PK
| 维度 | 大宽表(JSON塞明细) | 完全规范化(18张表) |
|---|---|---|
| 下单写入 | 1次INSERT,事务简单 | 3~5张表,需事务编排 |
| 列表页查询 | 0 JOIN,毫秒级 | 5+ JOIN,数据量大就慢 |
| 按商品统计/搜索 | 基本做不了 | 直接SQL就能做 |
| 单个商品售后改单 | 整体覆盖写,有并发风险 | 精确更新单行 |
| 商品改价对历史订单 | 影响大(除非存快照) | 同样有影响(除非存快照) |
| 字段语义 | 混乱,JSON里什么都能塞 | 清晰 |
| 扩展到SKU维度运营 | 几乎不可能 | 天然支持 |
表里最后两行是我真正想说的:大宽表输在"语义"和"扩展",规范化输在"查询"和"事务"。所以正确答案不是二选一,是第三条路。
四、我现在的判断标准:四个问题决定一张表该不该拆
不再问"这符合第几范式",改问这四个问题:
1. 这些字段的写入频率一样吗?不一样就拆。
库存数量每秒被扣几十次,商品标题一年改两次。放一张表,库存的行锁会拖慢商品的所有读操作,缓存也跟着失效。
2. 这个实体会不会被单独查询或统计?会就必须独立成表。
商品要被搜索、要被统计销量、要被运营单独维护——那它必须是独立的表,不能是别人的JSON字段。反过来,"订单的优惠券使用记录"如果永远只在订单详情里顺带展示,做成冗余字段或者明细表都行。
3. 字段的生命周期跟主表一致吗?不一致就拆。
订单状态流转和订单主信息生命周期一致,放一起没问题。支付流水是只增不改、永远保留的,它跟订单的"更新"语义完全不同,必须独立。
4. 这个值未来会不会变?会变就必须存快照。
这是最容易被AI、也最容易被新手忽略的一条。商品单价、商品名称、收货地址、优惠规则——全都可能在下单之后变。历史订单必须记录"下单那一刻的值",而不是"现在的值"。
按这四条,最终的表结构落在中间:主表 + 明细表 + 独立的库存表 + 流水表 + 关键字段快照。
五、最终方案:可执行的建表SQL
-- 订单主表:只放"订单自己"的字段,金额和收货信息全部存快照
CREATE TABLE t_order (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号,全局唯一',
user_id BIGINT NOT NULL COMMENT '下单用户',
status VARCHAR(20) NOT NULL COMMENT '订单状态:WAIT_PAY/PAID/WAIT_SHIP/SHIPPED/FINISHED/CLOSED',
total_amount DECIMAL(12,2) NOT NULL COMMENT '商品总额',
discount_amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '优惠金额',
freight_amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '运费',
pay_amount DECIMAL(12,2) NOT NULL COMMENT '实付金额 = 总额 - 优惠 + 运费',
item_count INT NOT NULL DEFAULT 0 COMMENT '商品件数,冗余字段避免count查询',
-- 收货信息快照:用户改地址不影响历史订单
receiver_name VARCHAR(64) NOT NULL COMMENT '收货人(下单时快照)',
receiver_phone VARCHAR(32) NOT NULL COMMENT '收货电话(下单时快照)',
receiver_addr VARCHAR(512) NOT NULL COMMENT '收货地址(下单时快照)',
pay_time DATETIME NULL COMMENT '支付时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
-- 列表页的核心查询路径:按用户 + 时间倒序
KEY idx_user_time (user_id, create_time DESC),
KEY idx_status_time (status, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';
-- 订单明细:一对多,必须拆;商品信息同样存快照
CREATE TABLE t_order_item (
id BIGINT NOT NULL AUTO_INCREMENT,
order_id BIGINT NOT NULL COMMENT '订单ID',
order_no VARCHAR(32) NOT NULL COMMENT '订单号,冗余便于按单号查明细',
sku_id BIGINT NOT NULL COMMENT 'SKU ID,用于统计和搜索',
-- 以下四个是快照字段:商品改名/改价不能污染历史订单
sku_name VARCHAR(128) NOT NULL COMMENT '商品名称(下单时快照)',
sku_spec VARCHAR(128) NOT NULL COMMENT '规格描述(下单时快照)',
sku_price DECIMAL(12,2) NOT NULL COMMENT '下单时单价(快照)',
sku_image VARCHAR(255) NULL COMMENT '商品主图(快照)',
quantity INT NOT NULL COMMENT '购买数量',
subtotal DECIMAL(12,2) NOT NULL COMMENT '小计 = 单价 × 数量',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_order_sku (order_id, sku_id),
KEY idx_order_no (order_no),
KEY idx_sku (sku_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';
-- 库存表:高频写,必须独立于商品表
CREATE TABLE t_inventory (
id BIGINT NOT NULL AUTO_INCREMENT,
sku_id BIGINT NOT NULL COMMENT 'SKU ID',
total_stock INT NOT NULL DEFAULT 0 COMMENT '总库存',
locked_stock INT NOT NULL DEFAULT 0 COMMENT '预占库存',
available_stock INT NOT NULL DEFAULT 0 COMMENT '可用库存 = 总库存 - 预占',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_sku (sku_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存表(高频写,独立成表)';
-- 库存流水:只增不改,跟库存表的生命周期完全不同
CREATE TABLE t_inventory_flow (
id BIGINT NOT NULL AUTO_INCREMENT,
sku_id BIGINT NOT NULL,
biz_no VARCHAR(64) NOT NULL COMMENT '业务单号,幂等用',
change_type VARCHAR(20) NOT NULL COMMENT 'LOCK/UNLOCK/DEDUCT/INBOUND/CHECK',
quantity INT NOT NULL COMMENT '变动量,正为入库负为出库',
before_qty INT NOT NULL COMMENT '变动前可用库存',
after_qty INT NOT NULL COMMENT '变动后可用库存',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_biz_sku (biz_no, sku_id, change_type) COMMENT '幂等:同一业务单号同一类型只记一次',
KEY idx_sku_time (sku_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水表';
-- 订单状态流水:售后扯皮时的唯一证据
CREATE TABLE t_order_status_flow (
id BIGINT NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
from_status VARCHAR(20) NOT NULL,
to_status VARCHAR(20) NOT NULL,
operator VARCHAR(64) NOT NULL COMMENT '操作人:system/用户ID/客服工号',
remark VARCHAR(255) NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_order (order_no, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单状态流水表';
几个设计点值得单独说:
金额一律 DECIMAL,不用 DOUBLE。 这个我见过太多次了,0.1 + 0.2 != 0.3 在对账场景里就是真金白银的差错。
item_count 是个冗余字段。 按第三条判断标准,它不该出现在规范化设计里。但列表页要展示"共3件商品",没有它就得多一次 count 或者把明细全查出来。这是我在"查询路径"面前主动放弃范式的一次。
快照字段是整套设计里最值钱的部分。 sku_price、receiver_addr 这些字段跟 t_product 里的数据会重复,看起来很"脏",但它们保证了历史订单的不可变性——订单一旦生成,它就是一份历史档案,不应该被外部世界的任何改动影响。
六、表拆开了,事务边界怎么划
这是拆表之后马上会撞上的第二个问题。表拆细了,一次下单要写三张表:主表、明细表、库存表。如果它们还跨服务,那就要考虑分布式事务。我的做法是先别急着上分布式事务框架,问自己一个问题:这三张表能不能放在同一个数据库、同一个本地事务里?
大部分中小系统,答案是能。订单、明细、库存本来就是同一个业务域,强耦合是它们的天性,硬拆成三个服务反而是给自己找麻烦。所以我的下单逻辑就是一个本地事务:
/**
* 提交订单。三张表在同一个本地事务里,不做分布式事务。
* 拆表是为了性能和扩展,不是为了拆服务——这两个是两回事。
*/
@Transactional(rollbackFor = Exception.class)
public String submitOrder(SubmitOrderDTO dto) {
String orderNo = orderNoGen.next();
// 1. 快照:下单这一刻把商品名/规格/单价全部固化下来
List<ItemSnapshot> snapshots = productService.snapshot(dto.getSkuIds());
// 2. 写主表(收货信息同样用快照,不存 addrId)
Order order = buildOrder(orderNo, dto, snapshots);
orderMapper.insert(order);
// 3. 写明细
orderItemMapper.batchInsert(buildItems(order.getId(), orderNo, snapshots));
// 4. 扣库存:这一步必须在事务内,且用条件更新防止超卖
for (ItemSnapshot s : snapshots) {
int rows = inventoryMapper.lockStock(s.getSkuId(), s.getQuantity());
if (rows == 0) {
// 抛异常触发回滚,订单和明细一起撤掉,不会出现"单下了没库存"
throw new BizException("库存不足, skuId=" + s.getSkuId());
}
inventoryFlowMapper.insert(buildFlow(orderNo, s, StockChangeType.LOCK));
}
return orderNo;
}
这里有个容易搞错的顺序问题:先扣库存还是先建单? 我选择先建单再扣库存,靠事务回滚兜底。理由是——如果先扣库存再建单,建单失败时你得写一段"反向释放库存"的补偿逻辑,而那段逻辑自己也可能失败。放在一个事务里,数据库替你保证原子性,比手写补偿可靠得多。
只有当库存真的独立成服务、数据库都不在一起的时候,才需要引入可靠消息或者 TCC。而在那之前,为了"架构好看"提前上分布式事务,是我见过最典型的过度设计。
七、拆完之后,列表页长什么样
有了冗余和快照,我的订单 列表页退化成两张表的关联:
-- 列表页:只查主表 + 明细,不再 JOIN 商品/地址表
SELECT o.order_no, o.status, o.pay_amount, o.item_count,
o.receiver_name, o.create_time,
i.sku_name, i.sku_spec, i.sku_price, i.quantity, i.sku_image
FROM t_order o
LEFT JOIN t_order_item i ON i.order_no = o.order_no
WHERE o.user_id = #{userId}
ORDER BY o.create_time DESC
LIMIT 20;
从五个 JOIN 降到两个,P95 从 1.8 秒降到 120 毫秒。对账那边也直接用 t_order_item.sku_price 去核,再也不用担心商品改价的历史问题。
Java 侧我把订单的"可变信息"和"快照信息"分成两个对象,避免后面有人误改:
/**
* 订单详情VO。
* 分成两组字段不是为了好看,是为了让"快照不可改"这件事在代码里可见。
*/
public class OrderDetailVO {
/** 可变信息:状态、金额、时间 */
private String orderNo;
private String status;
private BigDecimal payAmount;
private LocalDateTime payTime;
/** 快照信息:下单那一刻的现场,任何情况下都不允许被更新接口覆盖 */
private String receiverName;
private String receiverPhone;
private String receiverAddr;
private List<OrderItemSnapshotVO> items;
}
public class OrderItemSnapshotVO {
private Long skuId;
private String skuName; // 快照
private String skuSpec; // 快照
private BigDecimal skuPrice; // 快照,不查 t_product
private Integer quantity;
}
对应的更新接口,我干脆就不接收快照字段:
/**
* 订单更新。DTO 里刻意不出现 receiverName / skuPrice 等快照字段,
* 从入口上堵死"顺手把快照改了"的可能。
*/
public void updateOrder(OrderUpdateDTO dto) {
// dto 只有 orderNo / status / remark,快照字段根本传不进来
Order order = orderMapper.selectByOrderNo(dto.getOrderNo());
OrderStatus.valueOf(order.getStatus()).checkTransferTo(dto.getStatus());
orderMapper.updateStatus(order.getId(), dto.getStatus(), order.getVersion());
statusFlowMapper.insert(buildFlow(order, dto));
}
八、AI给的数据库设计,我复核这四个地方
/前后端设计 输出的数据库设计文档是完整的,字段、类型、索引、外键关系都有。但拿到手我一定做这四项复核:
1. 有没有快照字段。 AI 生成的订单明细通常只存 sku_id,然后去关联商品表取名称和价格。这在演示环境没问题,上线一年必出对账事故。凡是"历史单据",一律补快照。
2. 高频写的字段有没有独立成表。 库存、余额、计数这类字段,AI 常常直接挂在商品表/用户表上。我要把它们拎出来单独建表,让行锁的影响范围可控。
3. 有没有流水表。 AI 默认不建流水表,因为它不在"实体关系"里。但状态流转和库存变动没有流水,线上出问题你只能靠猜。我的原则:只要一个字段会被多方修改,就必须配一张流水表。
4. 索引是不是跟着查询路径走的。 AI 建索引喜欢按单字段建,或者给每个外键都建一个。真正有用的是复合索引,而且顺序要跟 WHERE + ORDER BY 对齐。上面那个 idx_user_time (user_id, create_time DESC) 就是典型——单建 user_id 索引在分页深翻时依然会 filesort。
补一句,设计过程中 /前后端设计 遇到需要拍板的地方会让用户确认相关问题。这个环节别急着点"默认",那些问题往往正好是范式和性能冲突的点,你答得越具体,后面的接口设计和代码生成就越贴合。
九、一句话总结
表拆多细,不看范式,看变更频率、查询路径和事务边界这三件事。
拆之前问自己四个问题,比背三大范式有用:字段写入频率一样吗?这个实体会被单独统计吗?生命周期一致吗?这个值未来会不会变?四个问题答完,该拆的拆、该冗余的冗余、该存快照的存快照。
最后给一份我自己贴在工位上的清单:
- 高频写字段(库存、余额、计数)→ 独立成表;
- 会被单独查询/统计的实体 → 独立成表,不许塞 JSON;
- 只增不改的变动记录 → 独立成流水表;
- 历史单据里的外部数据(价格、名称、地址)→ 一律存快照;
- 为了列表页少一个 JOIN 而加的冗余字段 → 加,但要在注释里写清楚冗余来源和同步方式。
这套判断,比"表拆得越细越专业"这种想法靠谱得多。我拆过18张表的漂亮ER图,最后还是被一个列表页教做人了。
本文探讨Java后端在前后端协同设计中数据库表拆分的实践原则,指出不应盲目追求范式,而应依据字段写入频率、独立查询需求、生命周期一致性及值是否可变这四个核心问题决策。重点分析大宽表与完全规范化方案的代价差异,强调快照字段、冗余设计、事务边界划分及索引优化等关键技术点,并结合飞算JavaAI辅助设计场景,提出可落地的复核清单。
643

被折叠的 条评论
为什么被折叠?



