Java后端做前后端设计数据库表到底该拆多细一、我先拆成18张表然后被一个列表页教做人去年做公司内部的订单中心重构需求是从下单到发货到对账全链路。拿到需求文档那天我雄心勃勃觉得这次一定要设计得专业一点——商品、SKU、库存、订单、明细、支付、物流、地址、优惠券、发票……我一口气拆了18张表每张表都严格满足第三范式字段没有一处冗余。设计文档发到群里还有同事夸了一句这个ER图画得漂亮。两周后业务要一个我的订单列表页展示订单号、下单时间、商品名称、规格、单价、数量、金额小计、订单状态、收货人。我写出来的 SQL 长这样SELECTo.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_addressFROMt_order oJOINt_order_item iONi.order_ido.idJOINt_sku sONs.idi.sku_idJOINt_product pONp.ids.product_idLEFTJOINt_user_addr aONa.ido.addr_idWHEREo.user_id#{userId}ORDERBYo.create_timeDESCLIMIT20;五个 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 字段撑不住只能把数据抽到数仓里跑离线任务一个实时需求硬生生做成 T1。更难受的是字段语义的污染——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-- 订单主表只放订单自己的字段金额和收货信息全部存快照CREATETABLEt_order(idBIGINTNOTNULLAUTO_INCREMENTCOMMENT主键,order_noVARCHAR(32)NOTNULLCOMMENT订单号全局唯一,user_idBIGINTNOTNULLCOMMENT下单用户,statusVARCHAR(20)NOTNULLCOMMENT订单状态WAIT_PAY/PAID/WAIT_SHIP/SHIPPED/FINISHED/CLOSED,total_amountDECIMAL(12,2)NOTNULLCOMMENT商品总额,discount_amountDECIMAL(12,2)NOTNULLDEFAULT0COMMENT优惠金额,freight_amountDECIMAL(12,2)NOTNULLDEFAULT0COMMENT运费,pay_amountDECIMAL(12,2)NOTNULLCOMMENT实付金额 总额 - 优惠 运费,item_countINTNOTNULLDEFAULT0COMMENT商品件数冗余字段避免count查询,-- 收货信息快照用户改地址不影响历史订单receiver_nameVARCHAR(64)NOTNULLCOMMENT收货人下单时快照,receiver_phoneVARCHAR(32)NOTNULLCOMMENT收货电话下单时快照,receiver_addrVARCHAR(512)NOTNULLCOMMENT收货地址下单时快照,pay_timeDATETIMENULLCOMMENT支付时间,create_timeDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,update_timeDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,versionINTNOTNULLDEFAULT0COMMENT乐观锁版本号,PRIMARYKEY(id),UNIQUEKEYuk_order_no(order_no),-- 列表页的核心查询路径按用户 时间倒序KEYidx_user_time(user_id,create_timeDESC),KEYidx_status_time(status,create_time))ENGINEInnoDBDEFAULTCHARSETutf8mb4COMMENT订单主表;-- 订单明细一对多必须拆商品信息同样存快照CREATETABLEt_order_item(idBIGINTNOTNULLAUTO_INCREMENT,order_idBIGINTNOTNULLCOMMENT订单ID,order_noVARCHAR(32)NOTNULLCOMMENT订单号冗余便于按单号查明细,sku_idBIGINTNOTNULLCOMMENTSKU ID用于统计和搜索,-- 以下四个是快照字段商品改名/改价不能污染历史订单sku_nameVARCHAR(128)NOTNULLCOMMENT商品名称下单时快照,sku_specVARCHAR(128)NOTNULLCOMMENT规格描述下单时快照,sku_priceDECIMAL(12,2)NOTNULLCOMMENT下单时单价快照,sku_imageVARCHAR(255)NULLCOMMENT商品主图快照,quantityINTNOTNULLCOMMENT购买数量,subtotalDECIMAL(12,2)NOTNULLCOMMENT小计 单价 × 数量,create_timeDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,PRIMARYKEY(id),UNIQUEKEYuk_order_sku(order_id,sku_id),KEYidx_order_no(order_no),KEYidx_sku(sku_id))ENGINEInnoDBDEFAULTCHARSETutf8mb4COMMENT订单明细表;-- 库存表高频写必须独立于商品表CREATETABLEt_inventory(idBIGINTNOTNULLAUTO_INCREMENT,sku_idBIGINTNOTNULLCOMMENTSKU ID,total_stockINTNOTNULLDEFAULT0COMMENT总库存,locked_stockINTNOTNULLDEFAULT0COMMENT预占库存,available_stockINTNOTNULLDEFAULT0COMMENT可用库存 总库存 - 预占,versionINTNOTNULLDEFAULT0COMMENT乐观锁,update_timeDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,PRIMARYKEY(id),UNIQUEKEYuk_sku(sku_id))ENGINEInnoDBDEFAULTCHARSETutf8mb4COMMENT库存表高频写独立成表;-- 库存流水只增不改跟库存表的生命周期完全不同CREATETABLEt_inventory_flow(idBIGINTNOTNULLAUTO_INCREMENT,sku_idBIGINTNOTNULL,biz_noVARCHAR(64)NOTNULLCOMMENT业务单号幂等用,change_typeVARCHAR(20)NOTNULLCOMMENTLOCK/UNLOCK/DEDUCT/INBOUND/CHECK,quantityINTNOTNULLCOMMENT变动量正为入库负为出库,before_qtyINTNOTNULLCOMMENT变动前可用库存,after_qtyINTNOTNULLCOMMENT变动后可用库存,create_timeDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,PRIMARYKEY(id),UNIQUEKEYuk_biz_sku(biz_no,sku_id,change_type)COMMENT幂等同一业务单号同一类型只记一次,KEYidx_sku_time(sku_id,create_time))ENGINEInnoDBDEFAULTCHARSETutf8mb4COMMENT库存流水表;-- 订单状态流水售后扯皮时的唯一证据CREATETABLEt_order_status_flow(idBIGINTNOTNULLAUTO_INCREMENT,order_noVARCHAR(32)NOTNULL,from_statusVARCHAR(20)NOTNULL,to_statusVARCHAR(20)NOTNULL,operatorVARCHAR(64)NOTNULLCOMMENT操作人system/用户ID/客服工号,remarkVARCHAR(255)NULL,create_timeDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,PRIMARYKEY(id),KEYidx_order(order_no,create_time))ENGINEInnoDBDEFAULTCHARSETutf8mb4COMMENT订单状态流水表;几个设计点值得单独说金额一律 DECIMAL不用 DOUBLE。这个我见过太多次了0.1 0.2 ! 0.3在对账场景里就是真金白银的差错。item_count是个冗余字段。按第三条判断标准它不该出现在规范化设计里。但列表页要展示共3件商品没有它就得多一次 count 或者把明细全查出来。这是我在查询路径面前主动放弃范式的一次。快照字段是整套设计里最值钱的部分。sku_price、receiver_addr这些字段跟t_product里的数据会重复看起来很脏但它们保证了历史订单的不可变性——订单一旦生成它就是一份历史档案不应该被外部世界的任何改动影响。六、表拆开了事务边界怎么划这是拆表之后马上会撞上的第二个问题。表拆细了一次下单要写三张表主表、明细表、库存表。如果它们还跨服务那就要考虑分布式事务。我的做法是先别急着上分布式事务框架问自己一个问题这三张表能不能放在同一个数据库、同一个本地事务里大部分中小系统答案是能。订单、明细、库存本来就是同一个业务域强耦合是它们的天性硬拆成三个服务反而是给自己找麻烦。所以我的下单逻辑就是一个本地事务/** * 提交订单。三张表在同一个本地事务里不做分布式事务。 * 拆表是为了性能和扩展不是为了拆服务——这两个是两回事。 */Transactional(rollbackForException.class)publicStringsubmitOrder(SubmitOrderDTOdto){StringorderNoorderNoGen.next();// 1. 快照下单这一刻把商品名/规格/单价全部固化下来ListItemSnapshotsnapshotsproductService.snapshot(dto.getSkuIds());// 2. 写主表收货信息同样用快照不存 addrIdOrderorderbuildOrder(orderNo,dto,snapshots);orderMapper.insert(order);// 3. 写明细orderItemMapper.batchInsert(buildItems(order.getId(),orderNo,snapshots));// 4. 扣库存这一步必须在事务内且用条件更新防止超卖for(ItemSnapshots:snapshots){introwsinventoryMapper.lockStock(s.getSkuId(),s.getQuantity());if(rows0){// 抛异常触发回滚订单和明细一起撤掉不会出现单下了没库存thrownewBizException(库存不足, skuIds.getSkuId());}inventoryFlowMapper.insert(buildFlow(orderNo,s,StockChangeType.LOCK));}returnorderNo;}这里有个容易搞错的顺序问题先扣库存还是先建单我选择先建单再扣库存靠事务回滚兜底。理由是——如果先扣库存再建单建单失败时你得写一段反向释放库存的补偿逻辑而那段逻辑自己也可能失败。放在一个事务里数据库替你保证原子性比手写补偿可靠得多。只有当库存真的独立成服务、数据库都不在一起的时候才需要引入可靠消息或者 TCC。而在那之前为了架构好看提前上分布式事务是我见过最典型的过度设计。七、拆完之后列表页长什么样有了冗余和快照我的订单列表页退化成两张表的关联-- 列表页只查主表 明细不再 JOIN 商品/地址表SELECTo.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_imageFROMt_order oLEFTJOINt_order_item iONi.order_noo.order_noWHEREo.user_id#{userId}ORDERBYo.create_timeDESCLIMIT20;从五个 JOIN 降到两个P95 从 1.8 秒降到 120 毫秒。对账那边也直接用t_order_item.sku_price去核再也不用担心商品改价的历史问题。Java 侧我把订单的可变信息和快照信息分成两个对象避免后面有人误改/** * 订单详情VO。 * 分成两组字段不是为了好看是为了让快照不可改这件事在代码里可见。 */publicclassOrderDetailVO{/** 可变信息状态、金额、时间 */privateStringorderNo;privateStringstatus;privateBigDecimalpayAmount;privateLocalDateTimepayTime;/** 快照信息下单那一刻的现场任何情况下都不允许被更新接口覆盖 */privateStringreceiverName;privateStringreceiverPhone;privateStringreceiverAddr;privateListOrderItemSnapshotVOitems;}publicclassOrderItemSnapshotVO{privateLongskuId;privateStringskuName;// 快照privateStringskuSpec;// 快照privateBigDecimalskuPrice;// 快照不查 t_productprivateIntegerquantity;}对应的更新接口我干脆就不接收快照字段/** * 订单更新。DTO 里刻意不出现 receiverName / skuPrice 等快照字段 * 从入口上堵死顺手把快照改了的可能。 */publicvoidupdateOrder(OrderUpdateDTOdto){// dto 只有 orderNo / status / remark快照字段根本传不进来OrderorderorderMapper.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 建索引喜欢按单字段建或者给每个外键都建一个。真正有用的是复合索引而且顺序要跟WHEREORDER BY对齐。上面那个idx_user_time (user_id, create_time DESC)就是典型——单建user_id索引在分页深翻时依然会 filesort。补一句设计过程中/前后端设计遇到需要拍板的地方会让用户确认相关问题。这个环节别急着点默认那些问题往往正好是范式和性能冲突的点你答得越具体后面的接口设计和代码生成就越贴合。九、一句话总结表拆多细不看范式看变更频率、查询路径和事务边界这三件事。拆之前问自己四个问题比背三大范式有用字段写入频率一样吗这个实体会被单独统计吗生命周期一致吗这个值未来会不会变四个问题答完该拆的拆、该冗余的冗余、该存快照的存快照。最后给一份我自己贴在工位上的清单高频写字段库存、余额、计数→ 独立成表会被单独查询/统计的实体 → 独立成表不许塞 JSON只增不改的变动记录 → 独立成流水表历史单据里的外部数据价格、名称、地址→ 一律存快照为了列表页少一个 JOIN 而加的冗余字段 → 加但要在注释里写清楚冗余来源和同步方式。这套判断比表拆得越细越专业这种想法靠谱得多。我拆过18张表的漂亮ER图最后还是被一个列表页教做人了。