简介:校园外卖系统数据库设计.docx 是一份面向高校校园外卖场景的数据库设计完整文档,主要服务于数据库初学者、计算机相关专业学生以及需要完成课程设计的人员。文档围绕餐厅、菜品、顾客、订单四个核心实体展开,给出了RESTAURANT、FOOD、GUEST、RFG等表的结构设计,详细说明每个字段(如RNO、FNO、GNO、QTY)的含义,并辅以E-R图和流程图阐述实体间关联与订餐配送流程,能帮助读者快速理解关系型数据库从需求分析到逻辑设计的完整过程。压缩包内仅含1个docx文件,大小约1.92MB,内容包括SQL建表语句、数据插入操作、价格区间查询、顾客信息筛选、视图创建等典型示例,便于读者直接对照练习或迁移到其他管理系统设计中。已有3208人学习下载,适合正在构思外卖、订餐类数据库方案的学习者参考借鉴。
1. 为什么一个“校园外卖系统数据库设计”能决定项目的成败
很多同学把“校园外卖系统数据库设计”当成一门画图课,E-R 图画得漂亮,Word 文档排得整齐,结果一到运行就露馅:下了单库存没扣,饭点查询卡半分钟,骑手抢单只能靠手速。数据库设计不是画图大会,它是整个项目最早定型的骨架,表结构一旦定了,业务逻辑、接口、报表全都要围着它转。这篇文章按一份典型的校园外卖系统数据库设计来讲,从实体划分聊到建表 SQL,再落到订单状态机和并发控制,把设计文档里的每一张表都对应到能跑的 MySQL 结构。适合正在做课程设计、毕业设计,或者想在校园场景快速验证一个外卖 demo 的开发者。
2. 从业务规则到 E-R 模型:校园外卖的实体划分与关系约束
2.1 先盘业务:校园外卖和普通外卖到底差在哪
在动手画 E-R 图之前,我会先把业务规则列出来。校园外卖跟开放外卖平台比,有几个差异直接决定表怎么设计:第一,配送终端是宿舍楼栋和宿舍号,没有门牌号那套结构,所以配送地址通常要拆成校区、楼栋、宿舍号,有时还要记录“是否允许上楼”“放在楼下哪张桌子”。第二,订单时间高度集中在午餐和晚餐两个半小时里,峰值负载可能是其他时段的十倍以上,这不只是代码的问题,表结构和索引设计必须从一开始就为峰值查询着想。第三,骑手大量是校内兼职学生,存在排班和转单,一个订单可能先被 A 骑手接,再因为超时转给 B 骑手,所以“谁在送”不能简单做成订单表上的一个 courier_id 字段,最好留一张配送记录表来沉淀历史。
把业务规则写成硬约束,再转成实体,比上来就到画布上摆矩形要稳得多。我的习惯是先定四到五条不变的东西:一个用户可以有多个订单,一个订单只属于一个用户;一个商家可以上架多个菜品,一个菜品只属于一个商家;一个订单包含多个菜品明细,明细必须记录“下单那一刻的菜名和价格”;一个订单在同一时刻只能有一个骑手持有,但历史上可以有多个骑手接手。这几条规则摆清楚之后,实体个数和联系方向几乎是跟着规则走出来的,后面设计字段时也不会漏掉关键外键。
2.2 E-R 图的核心实体:哪些表必须存在,哪些可以后置
正常校园外卖系统的核心实体就那么几个:用户、商家、菜品、菜品分类、订单、订单明细、配送地址、配送记录、支付记录、优惠券、用户优惠券、评价。其中“用户”这一块我建议只建一张 users 表,通过 role 字段区分学生、商家、骑手和管理员,而不是把学生表、商家表、骑手表分开建。原因很简单,课程设计阶段分表会造成大量重复的手机号登录、实名认证逻辑,而且用户与订单、用户与优惠券的关系会被拆得很难查。分表方案不是不行,只是收益撑不起复杂度。
购物车也是一个需要决策的点。购物车本质上是一个临时容器,可以放在客户端内存里,也可以落一张 cart_items 表。如果文档想做到“可答辩、可上线”,建议单独建表,因为校园外卖需要支持跨设备查看购物车,而且购物车里存的 merchant_id 是后面下单时校验“购物车不能跨商家”的基础。评价表这类附属实体可以后置,但不能没有,因为答辩老师很爱问“用户下单之后,订单和评价之间的关系怎么保证”。
下表是这套 E-R 模型对应的关系清单,外键落在哪张表、是 1:N 还是 M:N,建表时可以直接照着用:
| 实体 A | 联系 | 实体 B | 基数 | 外键位置 |
|---|---|---|---|---|
| 用户 | 下单 | 订单 | 1:N | orders.user_id |
| 商家 | 上架 | 菜品 | 1:N | dishes.merchant_id |
| 分类 | 归属 | 菜品 | 1:N | dishes.category_id |
| 订单 | 包含 | 明细 | 1:N | order_items.order_id |
| 订单 | 支付 | 支付记录 | 1:1 | payments.order_id |
| 订单 | 配送 | 配送记录 | 1:N | delivery_records.order_id |
| 用户 | 领取 | 优惠券 | M:N | coupon_user 中间表 |
2.3 联系方式:E-R 转关系模型的规则与数据字典文档
E-R 图转关系模型就三条规则:1:N 联系把外键放在 N 端,例如用户和订单,订单表带 user_id;M:N 联系必须拆中间表,例如用户和优惠券,需要 coupon_user 中间表,不能把优惠券 id 直接塞进 users 表;1:1 联系把外键放在依赖一侧或查询多的那一侧,例如订单和支付记录,支付记录表带 order_id 并加唯一索引。
这里有一个高频易错点:订单和配送记录到底是 1:N 还是 1:1。如果一个订单只可能由一个骑手从头送到尾,在订单表放 courier_id 就够了。但只要存在转单,就必须有 delivery_records。校园场景里骑手请假、被调度是常态,我一般按 1:N 设计配送记录表,再用一个 status 字段标记当前生效的记录,这样既能查当前骑手,也能追溯历史。
在 Word 文档里画 E-R 图,常见做法是用 Visio 或 draw.io 画好再导出图片插入,实体用矩形、联系用菱形、属性用椭圆。图片导出时分辨率至少放大两倍,否则答辩投屏后连线看不清。数据字典部分我会单独做一张表格,字段名、类型、是否为空、默认值、说明各一列,这张表在动手建库之前就要维护好,它不只是给老师看的,也是后面写建表 DDL 时防止漏字段的 checklist。
3. 表结构设计与建表 SQL:把 E-R 模型落进 MySQL
3.1 建表前的三条约定:命名、主键、金额与时间类型
先立三条约定,省得后面到处返工。库名用 campus_delivery,表名和字段名全部 snake_case,例如 order_items、delivery_address;表名用单数,因为一行记录代表一个实体;主键统一叫 id,无特殊需求全用 bigint 自增,文档里注明生产环境可换雪花 ID,课程设计和校园 demo 用自增足够,但要知道自增主键在分库分表后一定会冲突,所以文档里留一句“生产环境建议分布式 ID”会显得完整。
第二条约定是金额统一 decimal(10,2),订单金额、配送费、优惠抵扣、退款金额全部用它,任何金额字段都不允许出现在 float 上。很多入门项目喜欢用 float 或 double,交付后统计报表时就会发现小数位飘掉几毛钱,一旦牵扯退款就是事故。第三条约定是时间字段统一 datetime,不用 timestamp,timestamp 的范围到 2038 年,而且隐式时区转换容易出 8 小时问题,datetime 配合连接串指定时区要干净得多。
密码字段再单独提醒一句:users.password_hash 一定要用 varchar(128) 存散列结果,不要设计成 password 明文。字段名本身也在传达语义,看到 password_hash,代码就不会往这个字段里塞明文。
3.2 基础表建表 SQL:用户、商家、菜品、购物车
下面这段建表 SQL 是这套数据库设计文档最核心的基础部分,每张表都带必要注释和索引:
-- 用户表:学生、商家、骑手、管理员共用,role 区分 CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL COMMENT '登录名', password_hash VARCHAR(128) NOT NULL COMMENT '密码散列值', phone VARCHAR(20) NOT NULL COMMENT '手机号', role VARCHAR(16) NOT NULL DEFAULT 'STUDENT' COMMENT 'STUDENT/MERCHANT/COURIER/ADMIN', student_no VARCHAR(32) NULL COMMENT '学号,学生角色填写', real_name VARCHAR(32) NULL COMMENT '真实姓名', status TINYINT NOT NULL DEFAULT 1 COMMENT '1可用 0禁用', delete_flag TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0有效 1已删', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username), UNIQUE KEY uk_phone (phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 商家表:基本资料和营业状态 CREATE TABLE merchants ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL COMMENT '关联 users.id', name VARCHAR(128) NOT NULL COMMENT '商家名称', campus_area VARCHAR(50) NOT NULL COMMENT '所属校区商圈', notice VARCHAR(255) NULL COMMENT '商家公告', open_time TIME NOT NULL DEFAULT '09:00:00', close_time TIME NOT NULL DEFAULT '21:30:00', status TINYINT NOT NULL DEFAULT 1 COMMENT '1营业 0休业', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商家表'; -- 菜品分类表:同商家下分类名唯一,防止重复插入 CREATE TABLE categories ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, merchant_id BIGINT UNSIGNED NOT NULL COMMENT '商家 id', name VARCHAR(64) NOT NULL COMMENT '分类名', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值', UNIQUE KEY uk_merchant_sort (merchant_id, name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品分类表'; -- 菜品表:库存 stock 用于下单扣减 CREATE TABLE dishes ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, merchant_id BIGINT UNSIGNED NOT NULL, category_id BIGINT UNSIGNED NULL, name VARCHAR(128) NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT '当前售价', stock INT NOT NULL DEFAULT 0 COMMENT '可售库存', sales INT NOT NULL DEFAULT 0 COMMENT '已售数量', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_merchant (merchant_id), INDEX idx_category (category_id), UNIQUE KEY uk_merchant_dish (merchant_id, name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品表'; -- 购物车表:唯一键保证同一道菜不重复加车 CREATE TABLE cart_items ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, merchant_id BIGINT UNSIGNED NOT NULL, dish_id BIGINT UNSIGNED NOT NULL, quantity INT NOT NULL DEFAULT 1 COMMENT '数量', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_dish (user_id, dish_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车明细表';这段代码要说明几个设计决策。users 表没有把角色拆成单独表,而是用 role 枚举,这是为了降低课程设计阶段权限体系的复杂度,文档里也要写明:如果后续要做精细化权限,再引出一张 role 权限表,不要一开始就上。dishes 表保留 stock 和 sales 两个字段,stock 是实时库存,sales 是销量统计;销量统计不要在下单时实时 count orders,否则订单量一大就会拖慢列表页,正确的做法是每次订单完结后买单行做一次计数累加。cart_items 的唯一索引 uk_user_dish 直接保证同一个用户不能重复添加同一道菜,这样“加入购物车”接口只需要执行 INSERT ... ON DUPLICATE KEY UPDATE quantity = quantity + 1,不需要先 SELECT 再 UPDATE,既省一次查询也少一个并发窗口。
3.3 订单主表与订单明细表:快照与冗余是核心
订单相关表是整个数据库设计的重心,先看建表 SQL:
-- 订单主表:一个订单归属于一个用户和一个商家 CREATE TABLE orders ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT '对外业务单号', user_id BIGINT UNSIGNED NOT NULL COMMENT '下单用户', merchant_id BIGINT UNSIGNED NOT NULL COMMENT '商家 id,冗余方便按商家统计', status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/PAID/PREPARING/DELIVERING/COMPLETED/CANCELED/REFUNDING/REFUNDED', 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 '实付金额', address_snapshot VARCHAR(255) NOT NULL COMMENT '下单时配送地址完整快照', remark VARCHAR(255) NULL COMMENT '用户备注', expect_time DATETIME NULL COMMENT '期望送达时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME NULL COMMENT '支付时间', finish_time DATETIME NULL COMMENT '完成或取消时间', delete_flag TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_order_no (order_no), INDEX idx_user_create (user_id, create_time), INDEX idx_merchant_status (merchant_id, status, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; -- 订单明细表:菜品信息做成快照,价格不受商家改价影响 CREATE TABLE order_items ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id BIGINT UNSIGNED NOT NULL, dish_id BIGINT UNSIGNED NOT NULL COMMENT '菜品 id,仅作追溯', dish_name VARCHAR(128) NOT NULL COMMENT '下单时菜名快照', dish_price DECIMAL(10,2) NOT NULL COMMENT '下单时单价快照', quantity INT NOT NULL DEFAULT 1, line_amount DECIMAL(10,2) NOT NULL COMMENT '等于 dish_price * quantity', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_order (order_id), UNIQUE KEY uk_order_dish (order_id, dish_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';订单主表里值得展开讲的是 status 字段和金额字段。status 用 varchar 存枚举英文而不是 int,是为了日志和排查时一眼能读懂;如果偏要用 int,数据字典里必须把每个数字的含义写全,否则后面维护状态机的人要满代码找枚举定义。金额字段拆成 total_amount、discount_amount、pay_amount 三个,pay_amount 必须等于前两个相减,这个校验可以写进下单事务,也可以在报表阶段对账。address_snapshot 字段则是经常被忽略的关键:用户把地址从 3 栋改成 5 栋,历史订单里的收货地址不应该跟着变,所以订单表必须保存下单那一刻的完整地址。
关于 line_amount,故意保留一个物理字段,而不是每次查询时用 dish_price * quantity 现算。明细行一旦上了万,报表里每一个 SUM 都要重算乘法,索引帮忙有限,保留冗余字段让统计直接读,属于文档里应该写明的“以空间换时间”决策。如果你要省这个字段,带来的不是存储节省,而是以后报表查询每一行都要多一步计算。
3.4 支付与配送记录表:让“钱”和“配送”都有据可查
支付记录表单独建,不要把钱的信息直接堆在 orders 里。一个订单可能有支付、重复支付回调、退款多次,orders 表只放聚合后的状态和金额,明细流水都放 payments 表。配送记录表的价值前面说过,主要在转单场景,每次骑手接单插入一条记录,status 字段标识当前生效记录;查询当前骑手时,在 delivery_records 上加一个 is_active 标记,同时保证同一 order_id 只有一条 ACTIVE 记录,这个约束业务代码控制即可。
-- 支付流水表:幂等键唯一,回调可重入 CREATE TABLE payments ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id BIGINT UNSIGNED NOT NULL, transaction_no VARCHAR(64) NOT NULL COMMENT '第三方支付流水号,幂等键', pay_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败 3已退款', pay_channel VARCHAR(20) NOT NULL COMMENT 'WECHAT/ALIPAY', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order (order_id), UNIQUE KEY uk_transaction (transaction_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付流水表'; -- 配送记录表:一个订单可能被多个骑手转单接力 CREATE TABLE delivery_records ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id BIGINT UNSIGNED NOT NULL, courier_id BIGINT UNSIGNED NOT NULL COMMENT '骑手 users.id', from_status VARCHAR(20) NOT NULL COMMENT '接手前订单状态', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE 当前生效/ENDED 已结束', accept_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME NULL COMMENT '签收或转出时间', finish_note VARCHAR(255) NULL, INDEX idx_order (order_id), INDEX idx_courier (courier_id, accept_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='配送记录表';payments 表的两个唯一索引是防重复支付的关键设计:uk_order 保证一个订单只有一条有效的支付流程,uk_transaction 保证同一个第三方流水号不会被第二次插入,回调接口碰到这两类冲突都会直接返回成功,而不是再扣一次钱。delivery_records 的 from_status 字段值得点一笔:转单时先把当前订单状态写进 from_status,再更新 orders 表,这样出问题时能通过记录还原上一个骑手是谁、状态是什么,排查链条不会断。
注意:课程设计里如果只追求功能跑通,支付和配送记录两张表可以先不实现,但文档里要保留设计,它们能把系统的完整度撑起来,也是答辩时“并发”和“对账”两个方向的主要素材。
4. 订单状态机与并发控制:让订单表在高峰期不翻车
4.1 订单状态机先定义清楚,再写业务代码
订单状态是校园外卖系统数据库设计里被问得最多的设计点之一。很多项目翻车不是表建得不对,而是状态流转在业务代码里写散了:同一个状态的处理逻辑散落在 Controller、Service、定时任务里,最后连“订单能不能从待支付直接变成已完成”都说不清楚。所以设计文档里一定要先画一张状态表,标准状态建议用下面这组:
| 状态值 | 含义 | 允许进入的动作 |
|---|---|---|
| PENDING | 待支付 | 下单成功;用户取消(转 CANCELED) |
| PAID | 已支付待接单 | 支付回调;商家接单(转 PREPARING);用户取消(转 CANCELED) |
| PREPARING | 商家备餐中 | 商家接单;骑手取餐(转 DELIVERING) |
| DELIVERING | 配送中 | 骑手取餐;用户确认或超时签收(转 COMPLETED) |
| COMPLETED | 已完成 | 签收确认 |
| CANCELED | 已取消 | 支付前或商家接单前取消 |
| REFUNDING | 退款中 | 取消或售后发起 |
| REFUNDED | 已退款 | 退款成功 |
有了这张表,代码里就不要再用零散的 if/else 到处判断。我一般会用一个状态机函数做统一校验,Java 项目里可以写成枚举的 canTransitionTo(OrderStatus target) 方法,Python 项目就维护一个字典。下面用 Python 演示这个思想:
ALLOW_TRANSITIONS = { "PENDING": {"PAID", "CANCELED"}, "PAID": {"PREPARING", "CANCELED", "REFUNDING"}, "PREPARING": {"DELIVERING", "CANCELED", "REFUNDING"}, "DELIVERING": {"COMPLETED", "REFUNDING"}, "COMPLETED": set(), "CANCELED": set(), "REFUNDING": {"REFUNDED"}, "REFUNDED": set(), } def can_transition(current: str, target: str) -> bool: return target in ALLOW_TRANSITIONS.get(current, set())这段代码不是数据库设计文档的主体,但它直接决定了订单表 status 字段在运行时会被写入哪些合法值。逻辑说明看几点:PENDING 只允许转向 PAID 或 CANCELED,所以“未支付订单直接变成已完成”这种脏数据在状态机层就被拦截;DELIVERING 只能转向 COMPLETED 或 REFUNDING,不能直接跳 CANCELED,因为配送中的订单取消必须走退款流程;COMPLETED 之后不允许任何状态变化,这是对账的基础。参数说明:这个状态集合要和数据库字段的数据字典严格一致,如果你在 DDL 里用了 int 枚举,这里也要改成对应的 int 常量,不要出现“代码里写 PAID、库里存 1”的两套命名。
4.2 并发控制:库存不能超卖,抢单不能重复
先看最容易出问题的库存。校园外卖的爆款单品在午高峰会被大量并发下单,如果代码是“先 SELECT stock,判断 stock > 0,再 UPDATE stock = stock - 1”,两个请求同时读到 stock = 1,就会产生超卖。避免这条路有两个常见做法:一条 UPDATE 语句直接带条件扣减,或者用 version 字段做乐观锁。我推荐直接把库存判断写进 UPDATE:
-- 安全扣库存:只更新“扣除后仍不小于 0”的行 UPDATE dishes SET stock = stock - 1 WHERE id = ? AND merchant_id = ? AND stock >= 1;在这个写法里,数据库会在满足 WHERE 条件的行上加锁,第二个并发请求执行时 stock 已经被扣到 0,匹配不到行,影响行数为 0,代码据此返回“库存不足”。参数含义拆开讲:stock >= 1 是扣减的硬条件,缺了它就会把库存扣成负数;merchant_id 条件不只是过滤,也是让 UPDATE 可以利用 (merchant_id, id) 定位到行,避免全表扫描。
骑手抢单的逻辑和扣库存本质相同,也是“比较并更新”:
UPDATE orders SET courier_id = ?, status = 'DELIVERING' WHERE id = ? AND status = 'PREPARING' AND courier_id IS NULL;执行这条 SQL 时,影响行数大于 0 说明当前骑手抢单成功,等于 0 说明订单已经被别人抢走或者不再处于 PREPARING。这个方案的先决条件是 orders 表的主键 id 有 InnoDB 行锁,两个同时到达的 UPDATE 会串行执行,第二个会被阻塞到第一个提交后才判断 WHERE,从而天然避免重复抢单。
4.3 事务边界、隔离级别与状态变更日志
下单是一个跨多张表的操作:写 orders、写 order_items、扣 dishes.stock、核销 coupon_user,这四步必须在一个事务里,缺一步就出脏数据。典型流程用 SQL 描述大致是这样:
START TRANSACTION; INSERT INTO orders (order_no, user_id, merchant_id, status, total_amount, discount_amount, pay_amount, address_snapshot) VALUES ('202606010001', 1001, 88, 'PENDING', 32.00, 3.00, 29.00, '3 栋 202'); INSERT INTO order_items (order_id, dish_id, dish_name, dish_price, quantity, line_amount) VALUES (LAST_INSERT_ID(), 501, '黄焖鸡米饭', 16.00, 2, 32.00); UPDATE dishes SET stock = stock - 2 WHERE id = 501 AND stock >= 2; UPDATE coupon_user SET status = 'USED', order_id = LAST_INSERT_ID() WHERE user_id = 1001 AND status = 'UNUSED' LIMIT 1; COMMIT;这里有三个细节。第一,LAST_INSERT_ID() 在同一个连接里取到的是刚插入的 orders.id,但业务代码里通常会用程序变量,把订单 id 显式传给后续 INSERT,避免插入明细和更新券时取错值。第二,扣减库存的 UPDATE 一定要放在插入明细之后、提交之前,如果库存不足回滚整个事务,订单和明细就都不会落库。第三,事务里不要夹带 HTTP 调用、短信发送这类远程操作,远程调用超时会一直占着连接,高峰时连接池被耗尽,和表结构设计没有直接关系,但数据库设计文档里写清楚“事务内只做本库写操作”能让后面写代码的人少走弯路。
隔离级别这边,MySQL 默认 REPEATABLE READ 在这个场景已经够用,不需要为了性能改成 READ COMMITTED。真正要注意的是“不要用 SELECT ... FOR UPDATE 去锁无关的行”,比如骑手抢单如果先 SELECT 订单再加锁,两个抢单请求会以“SELECT 发现状态还是 PREPARING,然后互相等待”的方式拖长事务。把状态判断写进 UPDATE 本身,既省了锁等待也少了死锁面。
最后提一个容易被漏掉但很有价值的表:order_status_log。它记录订单每一次状态变化的旧值、新值、操作人、操作时间。这张表不是必需品,但它能回答“订单什么时候被谁从 PREPARING 改成 REFUNDING”这类问题。数据库设计文档里预留这样一个追踪表,整个订单生命周期就有迹可循:
CREATE TABLE order_status_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id BIGINT UNSIGNED NOT NULL, old_status VARCHAR(20) NOT NULL, new_status VARCHAR(20) NOT NULL, operator_id BIGINT UNSIGNED NULL COMMENT '操作人,系统操作可为 NULL', operator_type VARCHAR(20) NOT NULL COMMENT 'USER/MERCHANT/COURIER/SYSTEM/TIMER', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_order_time (order_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单状态变更日志表';这张表虽然是日志,但不能做成由业务代码在每次状态更新后“顺手”异步 INSERT,而应该跟着状态更新放在同一个事务里。这样状态一旦落库,日志必然同时落库,排查时不会出现“库里状态已经变了,日志里却找不到记录”的黑匣子。索引只用 (order_id, create_time) 就够了,不需要在 old_status 或 new_status 上建索引,状态分布通常很集中,加了索引也帮不上忙。
5. 校园外卖数据库最容易翻车的 5 个排查现场:现象、原因与解决步骤
数据库出问题之后,第一反应别是改代码,先判断这是一张表的异常,还是多张表之间的数据不一致。下面这五个现场是我在按这类数据库设计做自测时最容易碰到的,按出现频率排序,基本覆盖了从“表结构没建好”到“索引没走对”的主要问题。
5.1 订单主表金额与明细表合计不一致
现象:统计 pay_amount 和 order_items 里 line_amount 求和,结果总有差。第一反应通常是“是不是有并发写了一半”,但对账日结时发现天天差,那就是结构问题。
原因:明细表里没有存菜品价格快照,下单后商家改了 price,历史订单的金额跟着变;另一种是金额字段用了 float,乘法后小数位飘了。两个原因都让报表对不平。
解决:按第 3.3 节的设计,明细表冗余 dish_price、dish_name,金额统一 decimal(10,2)。上线后写一个对账查询,按 order_id 分组求和 line_amount,再和 orders.pay_amount 做差比较。这个对账逻辑是判断数据库设计合不合理的第一道关卡,差一毛钱都要能查出来。
5.2 订单列表在午高峰变慢,数据库 CPU 被打满
现象:午高峰打开“商家待接单”列表要三到五秒,数据库 CPU 接近 100%。商家端刷新一次列表,接口返回几十条订单,却像是全表扫了一遍。
原因:orders 表只有主键,而列表页的过滤条件是 merchant_id、status、create_time 三个字段的组合;也可能是列表页为了显示菜品明细,把 order_items 也 join 进来,订单多的时候 JOIN 放大几百倍。
解决:按第 3.3 节的 idx_merchant_status 复合索引落地,过滤条件里字段顺序要跟索引保持一致;列表页只查订单主表,明细进详情页时再查。索引不是玄学,它就是数据库按要查询的顺序提前排好序,相当于把“按商家、再按状态、再按时间”这件事做成了索引树。
5.3 骑手重复抢到同一个订单
现象:两个骑手几乎同时点击抢单,后端都返回成功,订单页出现两个配送员。这种问题只在压测或高峰期出现,平时单量小测不出来。
原因:抢单代码写成“先 SELECT 看状态,再 UPDATE 改状态”,两个事务都读到 PREPARING,然后依次更新成功,后一个覆盖前一个。
解决:把判断和更新合并成第 4.2 节那条 UPDATE,WHERE 条件包含 status = 'PREPARING',影响行数为 0 的请求直接返回失败。这个坑属于经典并发丢失,也是数据库设计阶段就应预见到的:凡是“先查后改”的状态迁移,都要改成单条 CAS 语句。
5.4 数据库时间整体差 8 小时
现象:用户 12:00 下单,数据库里 create_time 却是 04:00,或者反过来接口查出来比库里晚 8 小时。前后端时间对不上,排错时首先怀疑代码,其实问题在连接层。
原因:MySQL 服务器时区、JDBC 连接串时区、应用服务器所在时区三者不一致,最常见的是连接串没带 serverTimezone,或者项目里用了 timestamp 类型被隐式转换。
解决:建表统一用 datetime;MySQL 连接串固定 serverTimezone=Asia/Shanghai;应用服务器与数据库服务器时区都配成同一时区。这个坑和表结构设计直接相关,因为数据类型从一开始就决定了时区敏感边界在哪儿。
5.5 逻辑删除记录与唯一索引相冲突
现象:用户删除一条配送地址后,再次添加完全相同的地址,接口报 duplicate 错误。用户端看到的提示是“地址重复”,但看起来明明已经删掉了。
原因:delivery_address 表上建了唯一索引 uk_user_phone_dormitory(user_id, phone, dormitory_id),逻辑删除只是把 delete_flag 置 1,唯一索引仍然把这条已删除记录算在内,插入新记录时撞上它。
解决:把 delete_flag 并入唯一索引,变成 uk(user_id, phone, dormitory_id, delete_flag),这样删除后的旧记录占用的唯一键是 (uid, phone, dorm, 1),新记录可以使用 (uid, phone, dorm, 0)。这只能覆盖删除一次的模型,删除两次再添加同样地址仍会冲突,更彻底的做法是加一个 delete_code 字段,删除时写入当前时间戳,唯一索引包含 delete_code。这个场景是逻辑删除与唯一约束互相打架的典型案例,设计阶段就要想清楚。
6. 往下怎么走:验证设计的三条查询,以及值得投入的分表与缓存方向
6.1 用三条查询验证表结构够不够用
数据库设计得行不行,别只看建表语句有多完整,我用三条业务 SQL 自测。第一条,午高峰营收报表,验证复合索引和聚合性能:
SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS order_date, HOUR(create_time) AS order_hour, COUNT(*) AS order_cnt, SUM(pay_amount) AS revenue FROM orders WHERE merchant_id = 88 AND create_time >= NOW() - INTERVAL 7 DAY GROUP BY order_date, order_hour ORDER BY order_date, order_hour;这条查询能当验证工具,是因为 WHERE 条件命中了 idx_merchant_status 的前两个字段。如果把 merchant_id 去掉只按时间统计,就走不了这个索引,速度会明显下降,这就是第 5.2 节“索引顺序要和查询条件一致”的最好验证。
第二条,热销菜品 Top N,验证明细表冗余字段的价值:
SELECT dish_name, SUM(quantity) AS sold_cnt, SUM(line_amount) AS revenue FROM order_items GROUP BY dish_name ORDER BY sold_cnt DESC LIMIT 10;这条查询完全不 join dishes 表,因为 dish_name 和 line_amount 已经冗余在明细表里。如果当初没有冗余,SQL 就会多一个 JOIN,GROUP BY 聚合的效率在数据量上来之后差距会非常大。
第三条,骑手工作量统计,验证配送记录表设计:
SELECT courier_id, COUNT(*) AS total_orders, SUM(TIMESTAMPDIFF(MINUTE, accept_time, finish_time)) AS total_minutes FROM delivery_records WHERE status = 'ENDED' AND accept_time >= NOW() - INTERVAL 7 DAY GROUP BY courier_id;这里用 status 直接过滤掉还在配送中的记录,再按时间窗口统计骑手单量和送餐时长。这张表只要索引和状态设计到位,三条统计维度都能在一个简单查询里完成。
6.2 值不值得往下投入:分表、读写分离与缓存
业务量如果真到了需要优化的阶段,有两个方向值得投入。第一是订单表按月分表,比如 orders_202605、orders_202606,查询时必须带上月份或加一个 order_date 字段做路由,否则所有分表都是废的。第二是读写分离,主库处理下单写,从库扛报表查询,但要注意从库延迟:下单成功后立刻查订单,很可能查到旧状态,页面体验上要容忍这个窗口,或者强制走主库。缓存方面,菜品列表和分类可以用 Redis 缓存,库存不建议直接放 Redis,除非你能把“缓存减库存、数据库最终扣减”的回滚窗口想清楚;校园外卖这个规模,用数据库行锁扣库存已经足够,不必为了炫技引入分布式锁。
6.3 从文档到上线,我养成的习惯
这套数据库设计,我通常会把执行顺序倒过来做:先写数据字典,把每个字段的含义和枚举值敲定;再写建表 DDL 落一个 MySQL 实例;接着用上面的三条查询做自测;然后模拟抢单和扣库存两个并发场景;最后画 E-R 图放进文档。前面的顺序,是让文档里每一条线和每一张表都是验证过的,不至于被老师问一句“这张表主要跑什么 SQL”就卡住。这个习惯帮我避掉了很多返工,希望帮到你。
本文还有配套的精品资源,点击获取