校园外卖数据库设计:从E-R模型到并发控制实战
简介校园外卖系统数据库设计.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:Norders.user_id商家上架菜品1:Ndishes.merchant_id分类归属菜品1:Ndishes.category_id订单包含明细1:Norder_items.order_id订单支付支付记录1:1payments.order_id订单配送配送记录1:Ndelivery_records.order_id用户领取优惠券M:Ncoupon_user 中间表2.3 联系方式E-R 转关系模型的规则与数据字典文档E-R 图转关系模型就三条规则1:N 联系把外键放在 N 端例如用户和订单订单表带 user_idM: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 模型落进 MySQL3.1 建表前的三条约定命名、主键、金额与时间类型先立三条约定省得后面到处返工。库名用 campus_delivery表名和字段名全部 snake_case例如 order_items、delivery_address表名用单数因为一行记录代表一个实体主键统一叫 id无特殊需求全用 bigint 自增文档里注明生产环境可换雪花 ID课程设计和校园 demo 用自增足够但要知道自增主键在分库分表后一定会冲突所以文档里留一句“生产环境建议分布式 ID”会显得完整。第二条约定是金额统一 decimal(10,2)订单金额、配送费、优惠抵扣、退款金额全部用它任何金额字段都不允许出现在 float 上。很多入门项目喜欢用 float 或 double交付后统计报表时就会发现小数位飘掉几毛钱一旦牵扯退款就是事故。第三条约定是时间字段统一 datetime不用 timestamptimestamp 的范围到 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT配送记录表;payments 表的两个唯一索引是防重复支付的关键设计uk_order 保证一个订单只有一条有效的支付流程uk_transaction 保证同一个第三方流水号不会被第二次插入回调接口碰到这两类冲突都会直接返回成功而不是再扣一次钱。delivery_records 的 from_status 字段值得点一笔转单时先把当前订单状态写进 from_status再更新 orders 表这样出问题时能通过记录还原上一个骑手是谁、状态是什么排查链条不会断。注意课程设计里如果只追求功能跑通支付和配送记录两张表可以先不实现但文档里要保留设计它们能把系统的完整度撑起来也是答辩时“并发”和“对账”两个方向的主要素材。4. 订单状态机与并发控制让订单表在高峰期不翻车4.1 订单状态机先定义清楚再写业务代码订单状态是校园外卖系统数据库设计里被问得最多的设计点之一。很多项目翻车不是表建得不对而是状态流转在业务代码里写散了同一个状态的处理逻辑散落在 Controller、Service、定时任务里最后连“订单能不能从待支付直接变成已完成”都说不清楚。所以设计文档里一定要先画一张状态表标准状态建议用下面这组状态值含义允许进入的动作PENDING待支付下单成功用户取消转 CANCELEDPAID已支付待接单支付回调商家接单转 PREPARING用户取消转 CANCELEDPREPARING商家备餐中商家接单骑手取餐转 DELIVERINGDELIVERING配送中骑手取餐用户确认或超时签收转 COMPLETEDCOMPLETED已完成签收确认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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 节那条 UPDATEWHERE 条件包含 status PREPARING影响行数为 0 的请求直接返回失败。这个坑属于经典并发丢失也是数据库设计阶段就应预见到的凡是“先查后改”的状态迁移都要改成单条 CAS 语句。5.4 数据库时间整体差 8 小时现象用户 12:00 下单数据库里 create_time 却是 04:00或者反过来接口查出来比库里晚 8 小时。前后端时间对不上排错时首先怀疑代码其实问题在连接层。原因MySQL 服务器时区、JDBC 连接串时区、应用服务器所在时区三者不一致最常见的是连接串没带 serverTimezone或者项目里用了 timestamp 类型被隐式转换。解决建表统一用 datetimeMySQL 连接串固定 serverTimezoneAsia/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 就会多一个 JOINGROUP 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”就卡住。这个习惯帮我避掉了很多返工希望帮到你。本文还有配套的精品资源点击获取