数据库设计是最不值得返工、也最容易返工的环节。机器人租赁系统开发里的核心表——设备、订单、租期、押金——表面看就是几张 CRUD 表,实际上藏着一个隐蔽的建模陷阱:"租赁"这个词混用了太多含义。这篇把我们踩过之后重构成型的核心 ER 模型、建表 SQL 和索引设计分享出来,帮你少走一次弯路。
一、先拆概念:四个词四张表,别混着用
第一次建模时我们犯过的错,是把"订单"当成万能容器:订单表里塞了租期起止、押金金额、设备状态。直到出现"租了三个月的设备中途换了两次机""押金部分扣款分三期"这类需求,才发现这个模型根本画不动。
重构后的核心认知:订单、租期、设备档期、押金是四个独立的业务概念,各自有独立生命周期:
- 设备(t_device):资产实体,寿命最长,状态在线/离线/维修/报废间流转;
- 订单(t_order):一次交易契约,生命周期从创建到完结/取消;
- 租期记录(t_rental_period):订单在时间维度上的展开,一笔订单可以对应多段租期(续租、换机都是新租期段);
- 押金流水(t_deposit_flow):资金维度,冻结、续冻、部分扣款、分批退回,每一笔都是独立流水。
核心 ER 关系一句话说清:
机型(t_model) 1 ─── N 设备(t_device) 1 ─── N 档期记录(t_schedule) │ 订单(t_order) N ─── 1 设备(主设备,编队场景另有关系表) │ ├── 1 ─── N 租期记录(t_rental_period) └── 1 ─── N 押金流水(t_deposit_flow)注意订单对设备是"下单时确定的主设备",多设备编队场景通过 t_order_device 关联表解决,不要在订单表里塞逗号分隔的 device_id 字符串——见过有人这么干,后来统计设备利用率时哭都来不及。
二、两个最容易返工的建模决策
2.1 易错点一:租期和档期必须分成两张表
很多第一版设计会把"客户租了哪段时间"直接写在订单上。问题在于:订单记录的是契约(客户视角),档期记录的是资源占用(设备视角),两者一致性需要维护,但绝不能合并。
反例场景:客户下单租 5.1-5.7,后来协商换机到另一台同型号设备,租期不变。如果租期写在订单上,你就要在订单、设备、档期三处同步改;分开建模后,换机只是"旧档期释放 + 新档期预占"的档期域操作,订单和租期记录一个字段都不用动。
另一个理由是并发控制:档期表是全平台写竞争最激烈的表,它必须保持最瘦的结构(只有设备、区间、状态、订单引用),任何塞进来的宽字段都会放大热点行的锁开销。
CREATETABLEt_rental_period(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_idBIGINTNOTNULL,device_idBIGINTNOTNULL,start_dateDATENOTNULL,end_dateDATENOTNULL,period_typeVARCHAR(20)NOTNULLCOMMENT'RENT/RENEW/REPLACE',statusVARCHAR(20)NOTNULLCOMMENT'ACTIVE/FINISHED/TERMINATED',versionINTNOTNULLDEFAULT0,KEYidx_order(order_id),KEYidx_device_range(device_id,start_date,end_date))COMMENT'租期记录:契约的时间展开,一笔订单多段租期';2.2 易错点二:押金必须独立流水表,不要在订单上记余额
第一版我们把押金金额、冻结状态写在订单表上,直到遇到真实需求:“押金 20000 元,设备归还后先退 60%,维修费用确认后再扣 15%,剩余退回。”——订单字段根本表达不了多笔、多次、方向不同的资金操作。
正确做法是把押金建成事件流水表,余额是流水推算出来的投影:
CREATETABLEt_deposit_flow(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_idBIGINTNOTNULL,flow_noVARCHAR(64)NOTNULLCOMMENT'业务流水号,幂等键',flow_typeVARCHAR(20)NOTNULLCOMMENT'FREEZE/DEDUCT/RELEASE/ADJUST',amountDECIMAL(12,2)NOTNULLCOMMENT'正数,方向由 flow_type 表达',balance_afterDECIMAL(12,2)NOTNULLCOMMENT'操作后余额快照,便于对账',channel_noVARCHAR(64)COMMENT'支付渠道单号',statusVARCHAR(20)NOTNULLCOMMENT'INIT/SUCCESS/FAILED',created_atDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,UNIQUEKEYuk_flow_no(flow_no),KEYidx_order(order_id,created_at))COMMENT'押金流水:每次资金动作一条记录';三个设计要点:flow_no唯一键天然支持支付回调幂等;balance_after冗余快照让对账从"重算全部流水"变成"比对相邻流水";amount 一律存正数、方向由类型表达,避免了"退款存负数还是扣款存负数"这种团队分裂式争论。客户查"我的押金"时,按 order_id 汇总流水即可,退还进度一目了然。
三、关键建表 SQL 摘录
设备表,重点是状态机和归属关系:
CREATETABLEt_device(idBIGINTPRIMARYKEYAUTO_INCREMENT,model_idBIGINTNOTNULLCOMMENT'机型ID',owner_tenantBIGINTNOTNULLCOMMENT'归属租赁商',sn_codeVARCHAR(64)NOTNULLCOMMENT'出厂序列号',biz_statusVARCHAR(20)NOTNULLCOMMENT'IDLE/LEASED/REPAIR/SCRAP',online_statusVARCHAR(10)COMMENT'ONLINE/OFFLINE,由IoT服务维护',current_orderBIGINTCOMMENT'当前生效订单,冗余字段见下文',versionINTNOTNULLDEFAULT0,UNIQUEKEYuk_sn(sn_code),KEYidx_tenant_status(owner_tenant,biz_status))COMMENT'设备档案';订单表摘要(简化):
CREATETABLEt_order(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_noVARCHAR(32)NOTNULL,customer_idBIGINTNOTNULL,device_idBIGINTNOTNULLCOMMENT'主设备',statusVARCHAR(20)NOTNULLCOMMENT'状态机:CREATED/SIGNED/PAID/DELIVERED/RETURNING/FINISHED/CANCELED',rent_amountDECIMAL(12,2)NOTNULL,deposit_amountDECIMAL(12,2)COMMENT'仅作下单时快照,实际以流水为准',versionINTNOTNULLDEFAULT0,UNIQUEKEYuk_order_no(order_no),KEYidx_customer(customer_id,status))COMMENT'订单';四、索引设计与反范式权衡
4.1 设备 + 日期区间的复合索引
档期冲突检测是这个系统跑得最频繁的查询,索引设计直接决定 P99:
-- 高频查询:某设备与给定区间是否重叠SELECT1FROMt_scheduleWHEREdevice_id=?ANDstatusIN('PRE_RESERVED','BOOKED','OCCUPIED')ANDstart_date<?ANDend_date>?;索引是KEY idx_device_range (device_id, start_date, end_date)。注意两点:等值列 device_id 放最前,两个范围列放后面——MySQL 中一个索引只能有效利用一个范围条件,end_date 列主要靠回表后过滤,但 device_id 等值过滤已把扫描范围缩小到单设备的少量行,实测单次查询 2-5ms;status 不放进索引前缀,因为可选值少且区分度低,放进去反而让索引维护变贵。
4.2 状态字段的字典设计与查询习惯
状态列统一用 VARCHAR 存字典码而不是数字枚举,看似浪费空间,实际收益巨大:排查问题时 DBA 直接SELECT status, COUNT(*) ... GROUP BY status就能看懂分布,不用对着字典表翻译;状态新增值时也不存在历史数据语义漂移的问题。配套纪律是字典码集中在一个枚举类里维护,代码里禁止裸写字符串。另外所有表都保留 created_at/updated_at,updated_at 用数据库自动维护——听起来是常识,但我们接手过的外部系统里,缺了它导致数据修复时无从下手的情况不止一次。
4.3 历史数据归档的提前设计
订单、押金流水、档期记录都是只增不删的数据,两年后量级很容易上千万。建表时就定好归档策略:档期表按年份归档到历史表,押金流水因为对账周期(财务通常要求保留三年)单独延迟归档,设备遥测归时序库天然不占业务库。归档任务用低峰期批量小事务搬运,每批 5000 条,避免大事务锁表。归档键在建表时就定为 order 的完结时间而非创建时间——这个决定如果留到要做归档那天再改,历史数据的回填会非常痛苦。
4.4 反范式:冗余 current_order 的得与失
设备表冗余了 current_order 字段,这是刻意为之:设备详情页、IoT 控制台、工单系统都要频繁回答"这台设备现在租给谁",如果每次都跨表查档期或订单,热点接口就要多一次 JOIN。代价是订单状态流转时要多维护这个字段(同事务内更新 + 对账任务兜底校验)。
我们的反范式纪律只有一条:冗余字段必须能被对账任务自动校验。校验不了的冗余宁可不做,否则数据一脏就是事故。
五、总结:建模不返工的五条清单
- 订单/租期/档期/押金是四个概念四张表,"换机不换租期、退押分多笔"这类需求是试金石;
- 档期表保持最瘦,它是写热点,宽字段是性能毒药;
- 押金用流水表 + 幂等键 + 余额快照,余额永远是推算的投影而非存储的事实;
- 复合索引等值在前范围在后,别指望一个索引吃掉两个范围条件;
- 反范式可以有,但每个冗余字段都必须有对账校验兜底。