ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

外卖订单数据库设计:订单主表、状态机与库存扣减实战

外卖订单数据库设计:订单主表、状态机与库存扣减实战

简介:一份基于C#与SQL Server 2019开发的餐饮外卖销售系统数据库设计资源包,面向高校数据库课程设计及桌面应用初学者,完整覆盖商家、客户、骑手三类角色的操作界面与注册登录流程,以扁平化视觉风格呈现,界面完成度接近商业级产品。资源共46个文件,压缩后约886KB,主体为C#源码,同时包含界面资源文件、图片素材、应用配置及可执行程序,项目结构规范,便于定位各模块代码。源码注释详细且逻辑通俗,配置好SQL Server环境后可直接使用Visual Studio打开运行,省去从零搭建的麻烦,曾获数据库课设满分评价。目前已有1592人学习,尤其适合需要快速完成课设、或想参考成熟商业风格UI的计算机专业学生。

1. 打开这份餐饮外卖数据库设计,你要先看清订单这条生命线

做餐饮外卖的人每天都在算三笔账:用户下了单能不能扣住库存,骑手接单后订单状态会不会错乱,月底对账时每一笔优惠分摊去哪了。这套数据库设计要解决的正是这三件事。它面向的不是「能存数据就行」的玩具库,而是一个从用户点餐、商家接单、骑手配送、支付回调、退款售后全链路走通的业务库。如果你正在搭外卖系统,或者手里拿到一份 .rar 里只有 ER 图和建表脚本的设计稿,这篇笔记帮你把订单主表、明细表、库存表、状态流转串成一条线,讲清楚每个字段为什么长这样,以及上线后最常见的翻车点在哪。

2. 从外卖业务场景反推数据模型:先定实体关系,再谈表结构

2.1 为什么外卖系统不能直接照搬超市收银的库表

外卖和传统零售在数据形态上有三个本质区别,决定了表结构不能照抄。

第一,外卖订单是「先付款后履约」的流程,订单状态不是一张订单表里的一个字段就能描述的。用户提交订单后要经过支付、商家接单、备餐、骑手取餐、送达、结算,中间还穿插用户取消、商家拒单、退款。每个状态变更都涉及不同的角色和时间点,如果不建状态流转记录,排查纠纷时只能干瞪眼。

第二,外卖是典型的「多商户 + 多门店 + 多 SKU」结构。同一个用户在同一个平台上可能同时从两个店铺下单,每单里又可能有加料、去冰、大份小份的组合规格。这意味着订单和商品之间绝不是「一单一行」的关系,而是必须拆成订单主表 + 订单明细表,把一次下单和商品快照分开存。

第三,库存是浅库存。餐饮备货是当天采购当天消耗,很多菜品卖完就下架。数据库层面必须支持「扣减即锁定」的原子操作,否则并发场景下很容易超卖。

基于这三点,建模的第一步是先画实体关系图,明确哪些实体是主数据、哪些是流程数据、哪些是流水数据。

2.2 实体梳理:用户、店铺、菜品、订单、配送各归各位

一份完整的外卖库设计,核心实体至少有这样几类:

  • 用户侧:user(用户账号)、user_address(收货地址)、cart_item(购物车)
  • 商家侧:shop(店铺)、product(菜品)、product_sku(规格)、category(分类)
  • 交易侧:order_info(订单主表)、order_detail(订单明细表)、payment(支付流水)、refund_info(退款单)
  • 履约侧:delivery(配送单)、status_log(订单状态日志)
  • 营销侧:coupon(优惠券)、user_coupon(用户券记录)

关系上注意几个容易画错的点:

user和shop都是独立的主数据,不要在设计时就把店铺挂在某一个用户下。product和product_sku是父子关系,同一个菜品可能有大份、小份、加辣不加辣多个 SKU,价格和库存挂在 SKU 上而不是菜品上。订单主表和明细表是标准的order_info一对多order_detail关系,明细表里要冗余一份商品名称、单价、规格快照——因为菜品价格随时会改,订单里存的必须是下单那一刻的价。

配送信息建议单独建表,不要硬塞进订单主表。原因是配送单有自己的生命周期:骑手接单、到店、取餐、送达,每个节点要写时间戳,如果都塞进订单主表,字段会越来越多,而且配送失败或被转单时,你只需要更新配送单,不影响订单本身。

2.3 订单状态机:从待支付到已完成,哪些跳转要禁止

外卖订单的状态不是「随便改」,它是一台状态机。常见做法是给状态字段定义一组枚举值,并在应用层或数据库层约束合法流转。

一个典型的正向链路是:

待支付 → 已支付 → 商家已接单 → 备餐中 → 骑手已取餐 → 已送达 → 已完成

分支链路包括:

待支付 → 已取消(用户主动取消或超时关单) 已支付 → 退款中 → 已退款(商家未接单前,用户可申请退款) 商家已接单 → 拒单退款(商家因缺货拒绝接单) 骑手已取餐 → 配送异常 → 退款处理中

建表时我建议至少留三个与状态相关的字段:status(当前状态)、prev_status(上一状态)、status_version(状态版本号)。prev_status用于审计和回溯,status_version用来做乐观锁——更新时带上WHERE status_version = 上一版本,防止两次并发操作把状态覆盖成脏数据。

状态机的约束写在数据库里更稳妥,用 CHECK 约束或触发器把「非法跳转」直接挡住。比如「待支付」不允许直接跳到「已完成」,这种防呆写在应用层也能做,但数据库加一层更安全,尤其是团队里新人多的时候。

3. 核心建表脚本:order 主表、明细表与库存表的落地写法

3.1 order_info 主表:为什么把收货地址直接冗余进订单

订单主表是一张订单的核心载体,它承载的是「这一单谁买的、从哪家店、送到哪、多少钱」。一个常见的设计原则叫「下单即快照」——用户地址、店铺名、商品名这些信息,在下单那一刻就固化进订单,而不是通过外键去实时关联。

原因很现实:用户改了默认地址,你不能让历史订单的收货地址跟着变;店铺改了个名,订单里还应该保留下单时的店名。这就是外卖订单表里到处都是冗余字段的原因。

订单主表的常见字段设计如下:

CREATE TABLE `order_info` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '物理主键', `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号,显示给用户和回调使用', `user_id` BIGINT UNSIGNED NOT NULL COMMENT '下单用户ID', `shop_id` BIGINT UNSIGNED NOT NULL COMMENT '店铺ID', `shop_name` VARCHAR(64) NOT NULL COMMENT '店铺名称快照', `receiver_name` VARCHAR(32) NOT NULL COMMENT '收货人姓名', `receiver_phone` VARCHAR(20) NOT NULL COMMENT '收货人手机号', `receiver_address` VARCHAR(255) NOT NULL COMMENT '完整收货地址快照', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单原价总额', `discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '优惠总额(优惠券+活动)', `pay_amount` DECIMAL(10,2) NOT NULL COMMENT '实付金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0待支付 1已支付 2已接单 3配送中 4已送达 5已完成 6已取消 7退款中 8已退款', `prev_status` TINYINT NOT NULL DEFAULT 0 COMMENT '上一状态,用于审计和回滚', `status_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态版本号,乐观锁用', `remark` VARCHAR(255) NULL COMMENT '用户备注', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0正常 1已删除', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外卖订单主表';

注意几个关键点:

order_no和id是两回事。id是自增物理主键,只在系统内部用;order_no是业务单号,要唯一,要生成规则可读(比如日期 + 随机串),用户和支付回调都认这个号。两张表关联用id,对外暴露用order_no,避免用户猜出你的单量。我给order_no建唯一索引,既保唯一,也给回调查询走索引。

金额字段全部用DECIMAL(10,2),绝对不用FLOAT或DOUBLE。二进制浮点计算会产生精度误差,对账时差一分钱都说不清。整数场景下你可以用「分」存储,但表设计里DECIMAL更直观,代码里再统一转分计算。

receiver_name、receiver_phone、receiver_address整段冗余到订单主表,包括shop_name。后面查订单、导出报表、打印小票时,不需要再去关联用户地址表和店铺表,联表少了,查询快了,数据还有历史保留价值。

3.2 order_detail 明细表:一次点餐可能拆成多行快照

明细表记录的是「这一单具体买了哪些菜」。为什么不能直接用一个字段把商品名拼成字符串塞进主表?因为你要算销量排行、要看某个菜品的复购率、要对账每个菜品实付金额——拆开存储才能统计。

CREATE TABLE `order_detail` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '物理主键', `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号,关联order_info', `order_id` BIGINT UNSIGNED NOT NULL COMMENT '订单主表ID', `product_id` BIGINT UNSIGNED NOT NULL COMMENT '菜品ID', `sku_id` BIGINT UNSIGNED NOT NULL COMMENT '规格ID', `product_name` VARCHAR(128) NOT NULL COMMENT '菜品名称快照', `sku_spec` VARCHAR(64) NOT NULL COMMENT '规格描述快照,如大份/加冰/去辣', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时单价', `quantity` INT NOT NULL DEFAULT 1 COMMENT '购买数量', `item_total_amount` DECIMAL(10,2) NOT NULL COMMENT '该明细原价小计', `item_discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '该明细分摊优惠', `item_pay_amount` DECIMAL(10,2) NOT NULL COMMENT '该明细实付小计', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外卖订单明细表';

明细表里有个实际场景必须处理:同一道菜加了两份,能不能两行合并成一行?我建议保留quantity字段,合并成一行,用数量表达。好处是统计销量时直接SUM(quantity),不会因为拆行导致同一商品多条记录。但如果是「加料」这种 SKU 级别的差异,即使菜名一样,只要规格不同就是不同 SKU,必须拆行。

还有一个设计细节:product_name和sku_spec一定要做快照。菜品可能改名字、SKU 可能下架,但历史订单里的记录不能跟着变。对账、开发票、售后处理时,拿到的都是下单那一刻的信息,这一点和主表冗余地址是同一个原则。

3.3 菜品 SKU 与库存扣减:乐观锁字段不能省

菜的规格挂在product_sku上,价格和库存都跟 SKU 走。菜品本身只存基础信息,比如名字、分类、图片,SKU 存具体售价和库存量。一个「鱼香肉丝」可以拆成「大份 28」「小份 18」两个 SKU。

CREATE TABLE `product_sku` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '规格ID', `product_id` BIGINT UNSIGNED NOT NULL COMMENT '菜品ID', `sku_name` VARCHAR(64) NOT NULL COMMENT '规格名称', `price` DECIMAL(10,2) NOT NULL COMMENT '当前售价', `stock` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '实时库存,0表示售罄', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1上架 0下架', `version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品规格表';

库存扣减是关键操作,这句 SQL 的写法直接决定会不会超卖。我一般推荐三种方案,按场景选:

第一种是「乐观锁 + 条件更新」:

UPDATE product_sku SET stock = stock - 1, version = version + 1 WHERE id = #{skuId} AND stock > 0 AND version = #{expectedVersion};

stock > 0这个条件把扣减和校验合并到一条语句里,数据库行锁保证同一时刻只有一个事务能改到这一行。version = #{expectedVersion}是乐观锁的兜底,防止 ABA 问题。这条语句影响行数为 1 说明扣减成功,为 0 说明库存不足或者版本过期,需要重新查库存再提示用户「已售罄」。

第二种是「预占库存」模式:下单时先冻结库存,支付成功后正式扣减,超时未支付自动释放。这种适合高峰期秒杀或者营销活动,但实现复杂度高,订单表里要加一个lock_stock字段对应库存冻结状态。

第三种是直接依赖 Redis 扣库存再异步落库,库表只是最终一致性的落点。这个适合大并发场景,但对中小外卖系统来说引入额外组件,前期没必要。

外卖场景下我建议先做第一种,简单直接,等日订单量到了需要做活动秒杀再演进到第二种。表结构里提前把version字段留好,迁移不伤筋动骨。

4. 外卖库最容易翻车的 5 个细节:并发、幂等和金额精度

4.1 超卖:库存扣减只查再改,订单量一大就出事

现象:促销时段某个菜品卖出了 200 份,实际备货只有 100 份。

原因:库存扣减写成「先SELECT stock,然后在 Java 里判断stock > 0再UPDATE stock = stock - 1」。两个请求同时查到了stock = 1,都判断通过,各自执行 UPDATE,把库存扣成了负数。

解决:把判断和扣减合并成一条 SQL,用UPDATE ... WHERE stock > 0加乐观锁。SQL 影响行数为 0 就说明这次没抢到,直接返回售罄。数据库行锁保证同一行在同一时刻只能有一个事务在改。

4.2 支付回调被重试 n 次:幂等表没建就等着数据重复

现象:用户支付成功,支付回调因为网络抖动重试了三次,订单状态被更新了三次,流水表里产生了三条重复的支付记录。

原因:支付渠道的回调天然会重试,而回调处理逻辑里没有做去重。

解决:建一张payment_notify流水表,给order_no加唯一索引。每次回调进来先INSERT IGNORE写一条记录,影响行数为 1 才继续处理状态更新,为 0 说明这条回调已经处理过,直接返回成功。状态更新本身也要带上条件WHERE status = 0,保证只有待支付状态的订单能被支付回调改成已支付。

4.3 优惠分摊去尾差:对不上账基本都是这里出的

现象:订单整单优惠了 5 元,明细里有三道菜,每道菜按比例分摊,三个分摊金额相加和 5 元差了 0.01 元。月底对账差一分钱,查半天查不出原因。

原因:金额分摊按比例计算时每一行都四舍五入,尾差累积起来就丢了。比如 10 元优惠按 7:3 分,一行 6.99,一行 2.99,加到一起是 9.98,少了 0.02。所有明细分摊金额必须严格等于整单优惠金额。

解决:分摊时用「逐行四舍五入,最后一行兜底」的算法——前面的行正常舍入计算,最后一行的分摊金额等于整单优惠金额减前面所有行分摊金额之和,把尾差放到最后一行。建表时明细表里的item_discount_amount和主表的discount_amount保持可校验关系,对账 SQL 直接查偏差:

SELECT order_no FROM order_info o WHERE o.discount_amount > 0 AND o.discount_amount <> ( SELECT IFNULL(SUM(item_discount_amount), 0) FROM order_detail d WHERE d.order_id = o.id );

查出来不为空,说明分摊逻辑有 bug,这条 SQL 放进每周巡检脚本里。

4.4 订单状态被直接 UPDATE:缺少流转约束的代价

现象:运营手动改数据库把一笔「已退款」的订单改回「已完成」,然后财务导出报表时发现这笔订单既算收入又算退款,两边账不平。

原因:状态字段没有流转约束,谁都能直接改,而且改的时候不写日志。

解决:建状态日志表order_status_log,每一次状态变更都插入一条记录:order_id、from_status、to_status、operator_type(user/system/shop/rider)、operator_id、create_time。数据库层可以加 CHECK 约束限制状态值范围,合法的状态跳转放应用层做校验,但日志表必须无条件写。谁在什么时间把订单从什么状态改到什么状态,每一步都有据可查,这是处理客诉和财务审计的救命稻草。

4.5 物理删除和逻辑删除混用:数据越查越少

现象:一个取消的订单被物理 DELETE 后,用户端订单列表历史突然少了一单,且运营想分析取消原因时数据已经不在了。

原因:有些开发图省事直接 DELETE,有些按逻辑删除标记走。两种策略混用导致同一条数据在不同接口里行为不一致。

解决:所有业务表统一使用deleted字段做逻辑删除,禁止物理 DELETE。查询时处处带deleted = 0条件,虽然写 SQL 时啰嗦一点,但历史数据永远留得住。真正要清理数据时走定时任务归档到历史库,而不是直接删线上表。外卖行业的售后纠纷最长可能追溯好几个月,一条订单记录删了就再也回不来了。

5. 查询慢了不要先怪服务器:外卖库索引与分页的调法

5.1 组合索引顺序:user_id 和 shop_id 谁放前面,差别很大

订单表的查询场景基本集中在三块:用户查自己的历史订单、商家查店铺的实时订单、运营查某个时间段的全平台订单。对应三个高频查询条件组合:

-- 场景一:用户端「我的订单」 WHERE user_id = ? AND status IN (0,1,2,3,4,5) ORDER BY create_time DESC -- 场景二:商家端「接单列表」 WHERE shop_id = ? AND status IN (1,2) ORDER BY create_time ASC -- 场景三:运营端「按时间范围拉订单」 WHERE create_time BETWEEN ? AND ? AND shop_id = ?

索引设计要按「等值条件放前面,范围条件放后面」的原则建。场景一是典型的等值 + 排序,建(user_id, create_time)组合索引,user_id精确过滤后,create_time直接走索引排序,不用额外 filesort。场景二建(shop_id, status, create_time),商家一般同时挂几十个活跃订单,status过滤到一两个值后数据量已经很小,排序成本可控。

注意status IN (...)会让组合索引里status后面的字段失效。所以设计索引顺序时,把status放中间而不是最后。MySQL 8 支持索引跳跃扫描,但依赖它不如把索引建对来得稳妥。

索引不是越多越好,订单表高频写。每多一个索引,INSERT 和 UPDATE 都要同步维护。外卖订单表最多建 4 ~ 5 个索引,切不可为了一个低频报表查询单独建一个全表人物的组合索引。低频查询宁可走定时任务汇总,也不要在线上库压写性能。

5.2 深分页扫库拖垮库:用时间戳游标代替 offset

订单列表的经典分页 SQL 是:

SELECT id, order_no, pay_amount, status FROM order_info WHERE user_id = ? ORDER BY create_time DESC LIMIT 10 OFFSET 20000;

数据量到了一定规模后,OFFSET会越来越大,MySQL 要扫过前面 20000 行再丢掉才能返回 10 行,查询越来越慢。

替代方案是游标分页:利用上次查询结果里最后一条记录的create_time作为下一次查询的起点。

SELECT id, order_no, pay_amount, status FROM order_info WHERE user_id = ? AND create_time < #{lastCreateTime} ORDER BY create_time DESC LIMIT 10;

create_time上的(user_id, create_time)索引会直接命中,避免了大偏移量的扫描。实现时注意边界问题:如果同一秒内有多条订单,create_time < #{lastCreateTime}可能会漏数据。解决办法是在查询时把主键也带进游标条件:create_time < #{lastCreateTime} OR (create_time = #{lastCreateTime} AND id < #{lastId})。第一次查询不用带id条件,翻页后把上一条记录的create_time和id都带上。

前端展示上有个好处:游标分页天然适合「加载更多」模式,用户往下拉就带上一次查询的最后一条订单时间戳继续加载,不再需要页码跳转,体验也更顺滑。

5.3 历史订单归档与分表策略

外卖订单有明显的冷热分层。用户大部分时间只看最近三个月的订单,运营也只分析最近的数据。半年以前的订单基本只有司法和财务场景才用得上。

常见的做法是「按时间归档 + 按用户分表」。订单主表和明细表以月度为单位归档到历史库,线上库只保留最近 3 ~ 6 个月的数据,历史数据放归档表或者冷存储。查询端做路由,最近 3 个月走在线库,更早的走归档查询服务,或者干脆提示用户去 App 下载历史账单。

订单表量大的时候可以按user_id哈希分表。分表后所有按用户维度进行的查询都路由到同一张物理表,商家端查店铺订单时用shop_id冗余一份索引表,或者送到搜索组件。分表方案在日订单量达到几十万级再上,前期用单库加索引完全够用。分表的代价是跨表查询和统计报表复杂度上升,所以这个决策要压在业务量后面,不要提前优化。

6. 用一份验收清单把设计从「能跑」调到「能交付」

拿到这套数据库设计,动手写代码之前先花一天做验收,能省后面三周的返工。

第一,校验金额关系。跑一遍那条「整单优惠金额等于明细分摊之和」的 SQL,然后把total_amount >= discount_amount + pay_amount也加进去检查。金额关系是订单库的生命线,错一处,所有财务统计全部失信。

第二,验证状态机跳转。写一个小的 Python 脚本模拟所有状态跳转路径,把合法跳转和不合法跳转各跑一遍,确认数据库层和应用层都能挡住非法更新。重点测「待支付直接到已完成」「退款完成后还能改回已支付」这两条最容易被绕过路径。

第三,压一次并发扣库存。用 50 个并发线程同时对同一个 SKU 扣减库存,验证最终扣减量不超过库存总数。不要用生产环境,拿测试库跑,观察是否有超卖记录产生。

第四,做一次备份恢复演练。导出建表脚本和执行计划,确认所有表都用 InnoDB,都有主键,都带deleted和create_time、update_time字段——这三个是所有业务表的底座。

这套设计里我把用户地址、店铺名称、商品规格全部做快照冗余,上线后你自然会尝到甜头——打印小票、对接发票、客服查单都不用到处联表。归档脚本和巡检 SQL 也要在第一个版本就建好,不要等数据量大了再补,那时候补起来全是眼泪。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进