校园外卖系统数据库设计:从表结构到并发控制实战
简介一份校园外卖系统数据库设计文档面向高校学生、数据库课程设计者及SQL初学者系统展示从需求分析、流程图到E-R图与物理建表的完整过程。文档以餐厅、菜品、顾客、订单四个核心实体为主线明确了各表字段含义如餐厅编号、菜品价格、订餐人宿舍与电话等并给出了创建餐厅信息表RESTAURANT、菜品表FOOD、顾客表GUEST及订单关联表RFG的具体SQL语句同时涵盖数据插入、条件查询、嵌套查询、集合查询和视图创建等常见操作方便读者对照练习或直接改造复用。资源封装为单个docx文件压缩包大小1.92MB内容包含需求分析、各表格作用说明、数据定义、E-R图及查询示例结构清晰完整适合作为数据库原理、SQL程序设计课程的作业参考或期末复习资料。这份资源已有3208人学习对于正在完成外卖类课程设计或希望掌握关系数据库建模与查询语法的读者具有明确的实际参考价值。1. 校园外卖系统数据库设计为什么这张表结构能决定项目生死校园外卖系统看起来是「用户下单、骑手配送、商家出餐」三条业务线但真正动手写代码之前数据库设计才是那个决定项目能不能撑过第一轮迭代的隐形关卡。我见过太多团队把精力花在 App 界面上结果数据库表字段命名混乱、订单状态没有统一约束、结算对账全靠人工跑 SQL最后产品上线两周就被数据问题拖垮。这篇文章要解决的就是从一份 docx 格式的数据库设计文档出发把校园外卖系统的核心表结构、订单状态机、并发扣库存方案、骑手调度数据模型这些问题一次性讲透让你照着这份思路就能设计出一套经得起并发和业务扩展的库表。适合正在做课程设计、毕业设计或者真要在学校周边跑一个外卖小项目的开发者。2. 从需求到表结构先把校园外卖的业务边界画清楚2.1 校园外卖和普通外卖的本质差异在哪校园外卖系统的数据库设计和美团、饿了么这类大平台最大的区别在于业务边界特别清晰。用户群体是学生和教职工配送范围基本锁死在校园围墙内商户要么是食堂窗口、要么是学校周边的夫妻店。这意味着订单的配送距离、预计送达时间、骑手调度范围都非常可控数据库设计可以做得更精简但也更容易踩「过度设计」和「设计不足」两个极端。我一般会先用一张表格把核心实体列出来确认哪些是独立主表、哪些是关联表、哪些是状态枚举表。校园外卖系统至少需要用户表、商家表、菜品表、订单表、订单明细表、配送表、骑手表、评价表、优惠券表这几张基础表再加上购物车表、地址簿表这类辅助表。先别急着建字段把实体关系和业务规则写清楚比直接开建表重要得多。实体关系上用户和订单是 1 对 N商家的菜品是 1 对 N订单和订单明细是 1 对 N订单和配送单是 1 对 1 或 1 对 N因为存在拆单的情况。核心业务规则有几条必须写进数据库层面的约束订单金额必须等于明细金额汇总、优惠券抵扣金额不能超过订单金额、配送状态必须在订单支付后才能创建。这些规则能通过应用层判断但最好在建表时就考虑用字段约束和触发器做一道防线。2.2 字段设计的通用原则命名、类型、索引一起定我在设计校园外卖系统的库表时会先定一套字段规范避免后期维护时出现「同一个含义的字段在不同表里叫法不一样」的尴尬。主键统一用id并设置成BIGINT UNSIGNED AUTO_INCREMENT创建时间用create_time、更新时间用update_time且更新时自动刷新删除标记用deleted的TINYINT字段做逻辑删除而不是物理删除。状态字段全部用TINYINT存储枚举值再用注释标明每个数字代表什么意思绝对不在数据库里存中文状态字符串——那不仅浪费存储索引效率也差。类型选择上金额一律用DECIMAL(10, 2)绝不用FLOAT或DOUBLE这是做交易系统的基本常识。手机号用CHAR(11)而不是VARCHAR因为定长字段检索更快。订单号这种需要唯一索引的字段用VARCHAR(32)存储由应用层生成带时间戳和随机数的字符串避免数据库自增主键直接暴露订单量。索引方面每个表必须有主键索引业务高频查询字段要建二级索引。用户表要建(school_id, status)的联合索引有按学号搜索的场景。订单表是重点user_id、merchant_id、status、create_time这四个字段需要建组合索引具体怎么组合要在第 3 章讲订单查询优化的时候再展开。这里先记住一个原则索引不是越多越好每个索引都会拖慢写入速度只给真正高频的查询建索引。-- 用户表核心字段示例 CREATE TABLE user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键ID, school_id VARCHAR(20) NOT NULL COMMENT 学号/工号, user_name VARCHAR(50) NOT NULL COMMENT 用户昵称, phone CHAR(11) DEFAULT NULL COMMENT 手机号, user_type TINYINT NOT NULL DEFAULT 1 COMMENT 用户类型1学生 2教职工 3管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1正常 0禁用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除0未删 1已删, PRIMARY KEY (id), UNIQUE KEY uk_school_id (school_id), KEY idx_phone_status (phone, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;上面这段建表语句里有几个值得注意的细节。school_id加了唯一索引因为校园系统里学号天然唯一这比用自增主键去重更符合业务语义。phone和status的联合索引用来支撑后台管理端按手机号加状态过滤用户的场景。deleted字段做逻辑删除后所有查询语句都要记得加AND deleted 0条件这个习惯要在一开始就养成不然后期数据会越查越乱。2.3 商家与菜品表分类、规格、库存三者怎么关联商家表和普通外卖平台的设计思路差不多但校园场景多一个维度商家要有配送范围属性。我一般会加delivery_radius字段存储配送半径单位千米并且管理后台要根据学校宿舍楼位置维护一张可配送区域表。菜品表则要处理好分类、规格、库存这三个维度的关系。菜品分类建议单独建一张category表用parent_id支持两级分类一级是商家自己的分类热销、主食、饮品二级是平台级分类川菜、鲁菜、快餐不要把分类信息直接塞在菜品表里。菜品规格用sku表管理普通做法是一个菜品有多个 SKU比如「大份」「小份」「加辣」「不加辣」每个 SKU 有自己的价格和库存。-- 菜品SKU表设计 CREATE TABLE dish_sku ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键ID, dish_id BIGINT UNSIGNED NOT NULL COMMENT 菜品ID, sku_name VARCHAR(50) NOT NULL COMMENT 规格名称如大份/小份, price DECIMAL(10, 2) NOT NULL COMMENT 售价, original_price DECIMAL(10, 2) DEFAULT NULL COMMENT 原价用于展示折扣, stock INT NOT NULL DEFAULT 0 COMMENT 库存数量-1表示无限, 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, PRIMARY KEY (id), KEY idx_dish_id_status (dish_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品规格表;这里要提醒一个常见翻车点库存字段千万不要只做「减库存」操作库存变化要留审计记录。校园外卖高峰期用户反复加购又取消库存数据很容易被改乱。我会在项目一开始就给库存变更建一张流水表记录每次变更的订单号、操作类型、变更前后数值这样即使数据错了也能追溯而不是两眼一抹黑。3. 订单核心表状态机设计是数据库的灵魂3.1 订单主表与订单明细表为什么必须拆分订单相关表是整个校园外卖数据库的心脏。主表和明细表必须拆分这是交易系统的铁律。主表存储订单维度的信息订单号、用户 ID、商家 ID、订单总金额、优惠券抵扣金额、实付金额、订单状态、支付时间、送达时间。明细表存储商品维度的信息每个 SKU 的购买数量、单价、小计金额。拆分的意义在于商家端查看订单时既要显示订单整体状态又要知道用户点了哪些菜品用户端取消订单时系统要能准确判断哪些菜品能退、哪些已经出餐不能退。如果把这些信息全部塞在一张表里JSON 字段会越来越长查询性能越来越差而且无法用 SQL 直接做聚合统计——比如老板要查「哪个菜卖得最好」明细表单独存在才能跑出准确的销量数据。-- 订单主表 CREATE TABLE orders ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号业务唯一, user_id BIGINT UNSIGNED NOT NULL COMMENT 用户ID, merchant_id BIGINT UNSIGNED NOT NULL COMMENT 商家ID, total_amount DECIMAL(10, 2) NOT NULL COMMENT 商品总额, discount_amount DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT 优惠金额, pay_amount DECIMAL(10, 2) NOT NULL COMMENT 实付金额, order_status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0待支付 1已支付 2已接单 3配送中 4已完成 5已取消 6退款中 7已退款, pay_time DATETIME DEFAULT NULL COMMENT 支付时间, delivery_type TINYINT NOT NULL DEFAULT 1 COMMENT 配送方式1商家自送 2平台配送 3到店自取, remark VARCHAR(255) DEFAULT NULL COMMENT 用户备注, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_create_time (user_id, create_time), KEY idx_merchant_status (merchant_id, order_status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表; -- 订单明细表 CREATE TABLE order_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_id BIGINT UNSIGNED NOT NULL COMMENT 订单主表ID, dish_id BIGINT UNSIGNED NOT NULL COMMENT 菜品ID, sku_id BIGINT UNSIGNED NOT NULL COMMENT SKU ID, dish_name VARCHAR(100) NOT NULL COMMENT 菜品名称下单时冗余快照, sku_name VARCHAR(50) NOT NULL COMMENT 规格名称, price DECIMAL(10, 2) NOT NULL COMMENT 下单时单价, quantity INT NOT NULL COMMENT 购买数量, total_price DECIMAL(10, 2) NOT NULL COMMENT 小计金额, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;订单明细表里冗余了dish_name和sku_name字段这是有意为之。因为商家可能改名、下架菜品如果订单明细只存外键 ID后续查询历史订单时菜品名已经变了对不上的数据会带来一堆客诉。做冗余快照是为了让历史订单永远保持下单那一刻的样子。3.2 订单状态机一张表看懂合法流转路径订单状态是整个系统最容易出 bug 的地方。很多同学设计订单表时只加一个状态字段但是完全没有约束状态怎么流转结果就是「已退款」的订单还能被标记成「配送中」这种脏数据几乎无法修复。正确的做法是要用状态机去约束每一步操作。校园外卖的订单状态流转核心路径是待支付 → 已支付 → 已接单 → 配送中 → 已完成。异常路径包括待支付时用户主动取消到已取消已支付后用户申请退款走到退款中退款中再到已退款。商家接单前用户取消订单不扣任何费用商家接单后用户取消可能需要支付一定比例的违约金——这块业务规则决定了状态机的边界数据库设计阶段就要想清楚不能等代码写完了再来补。当前状态允许的下一状态触发动作备注待支付已支付 / 已取消用户支付 / 用户取消超时未支付也走取消已支付已接单 / 退款中 / 已取消商家接单 / 用户申请退款 / 系统超时取消商家拒单时自动退款已接单配送中 / 退款中骑手取餐 / 用户申请退款已接单后退款需商家同意配送中已完成 / 退款中用户确认收货 / 异常订单退款配送中超时未达可申请售后已完成退款中用户发起售后售后窗口期通常 24 小时退款中已退款 / 已接单商家同意退款 / 商家拒绝退款拒绝后退回原状态状态变更记录的落库方式有讲究。我强烈建议单独建一张order_status_log表每变更一次状态就插入一条记录字段包括订单 ID、旧状态、新状态、操作人、操作时间、备注信息。这样不仅能在出问题时复盘链路还能统计每个环节的平均耗时——比如从支付到接单平均多少秒、从接单到出餐平均多少分钟这些数据对运营调度非常有用。3.3 订单查询慢的根源组合索引到底怎么建订单表建好了查询慢的坑也来了。校园外卖系统一个典型的查询场景是「用户查看自己的历史订单」另一个是「商家查看今天的待处理订单」。这两个场景的查询条件完全不同索引设计也要分开考虑。用户查历史订单查询条件是WHERE user_id ? AND order_status ? ORDER BY create_time DESC所以我建议建(user_id, order_status, create_time)组合索引。这里有个细节create_time要放在最后因为它是范围排序字段放在前面会导致后面的order_status索引失效。商家查待处理订单查询条件是WHERE merchant_id ? AND order_status IN (1, 2) ORDER BY create_time ASC建(merchant_id, order_status, create_time)组合索引。还有一类高频查询不能忽略骑手端查配送列表。骑手要查的是「距离当前位置 1 公里内、状态为配送中或待接单的订单」这种查询条件里latitude和longitude的经纬度范围查询没法用普通 B 树索引优化需要引入空间索引或者用 GeoHash 方案。我一般建议订单表加geo_hash字段用精度 6 位的 GeoHash 编码定位然后建普通索引查询前缀匹配实战效果比直接算距离好得多。4. 配送与骑手模块时间预估和调度依赖的数据结构4.1 配送单表订单与骑手之间的关联枢纽配送数据模型是校园外卖区别于普通电商系统的关键部分。外卖的配送是强时效业务用户下单后必须在几十分钟内送达这条链路在数据库设计上要靠配送单表来支撑。订单表只存「订单状态是配送中」但具体哪个骑手在送、预计几点到、送到哪个宿舍楼这些信息要放在配送单表里。配送单表的核心字段包括配送单号、订单 ID、骑手 ID、配送状态、取餐时间、送达时间、预计送达时间、配送距离、配送费用、用户地址快照、配送备注。其中「用户地址快照」非常重要因为用户可能在下单后修改地址簿里的默认地址如果配送单只存地址 ID实际配送时可能取到修改后的地址导致送错。所以配送单里要冗余存储用户下单那一刻的楼栋、宿舍号、联系方式。CREATE TABLE delivery_order ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, delivery_no VARCHAR(32) NOT NULL COMMENT 配送单号, order_id BIGINT UNSIGNED NOT NULL COMMENT 订单ID, rider_id BIGINT UNSIGNED DEFAULT NULL COMMENT 骑手ID未接单时为NULL, delivery_status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0待分配 1待取餐 2配送中 3已送达 4异常, pickup_time DATETIME DEFAULT NULL COMMENT 取餐时间, delivered_time DATETIME DEFAULT NULL COMMENT 送达时间, estimated_arrival DATETIME NOT NULL COMMENT 预计送达时间, distance_meter INT NOT NULL DEFAULT 0 COMMENT 配送距离米, delivery_fee DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT 配送费, receiver_building VARCHAR(50) NOT NULL COMMENT 收货楼栋, receiver_room VARCHAR(20) NOT NULL COMMENT 宿舍/房间号, receiver_phone CHAR(11) NOT NULL COMMENT 收货电话, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_id (order_id), KEY idx_rider_status (rider_id, delivery_status), KEY idx_estimated_arrival (estimated_arrival) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT配送单表;配送单和订单是 1 对 1 关系订单表里不需要额外加配送状态字段查询时把订单表和配送单表做关联就行避免字段冗余导致状态不一致。骑手端最常执行的 SQL 是「查询分配给当前骑手且状态为待取餐的配送单」(rider_id, delivery_status)联合索引就是给这个查询用的。4.2 骑手位置与在线状态实时数据怎么落库骑手模块除了基础的身份信息表之外还得考虑位置数据的存储方案。骑手的实时经纬度是高频写入数据如果每几秒就更新一次数据库的rider_location表压力会非常大。我见过不少学生项目把骑手位置存在 MySQL 里到了中午高峰期直接卡死。常见做法是把实时位置存到 Redis 这类内存数据库用GEO类型存储只在骑手接单和送达时回写 MySQL。MySQL 里的骑手表存储的是偏静态的数据骑手姓名、手机号、接单状态、评分、当日接单数、累计配送里程。接单状态是一个可枚举字段0 离线 / 1 空闲 / 2 忙碌 / 3 休息。骑手的接单状态和订单表的关联关系是当一个配送单分配给骑手后骑手状态切换为忙碌配送完成后再恢复为空闲。这些状态切换能由 Redis 的分布式锁保证并发安全但不能在 MySQL 层面靠简单字段约束完成。骑手的评分数据单独建表统计记录每笔订单完成后用户给骑手的评分然后定时汇总到骑手表的rider_score字段。前端展示骑手评分时直接读汇总字段比每次临时计算AVG聚合要快得多这是订单完成量上来之后必须做的优化。4.3 预计送达时间怎么算数据模型要支持动态规则预计送达时间的计算是配送模块中最有「玄学」色彩的部分没有哪套固定公式能适应所有场景。常见做法是把它拆成三个可调整的组成部分商家出餐时间、骑手取餐与骑行时间、上楼配送时间。数据库设计要做的就是把这些时间数据存储下来支持后面调整算法。商家出餐时间可以建一张merchant_prepare_time配置表按商家维度记录平均出餐时长这个值可以每天早上由系统根据历史订单数据自动更新。骑行时间根据距离和骑手历史速度估算骑手表里加一个avg_speed字段存储最近 50 单的平均速度配送距离除以平均速度就是骑行时间。上楼配送时间根据不同楼栋配置有的宿舍楼有电梯、有的没有每栋楼的加时不一样楼栋信息表里要有这个字段。预计送达时间的算法逻辑可以不写在数据库里但数据表必须支撑随时调整参数。如果一开始就把estimated_arrival写成写死的字段后面想按天气、楼层、高峰时段动态调整时就得改表结构那才是真正的血泪教训。数据库设计要为不确定性留好扩展位这是做业务系统最核心的意识。5. 并发与事务控制减库存和防超卖的落地姿势5.1 库存扣减方案乐观锁和悲观锁怎么选校园外卖系统在午餐高峰期会迎来瞬间涌入的大量订单热点商家的一款爆品 SKU 可能同时被几百人下单。库存扣减必须保证不超卖这是数据库事务设计里最难也最容易翻车的一环。乐观锁和悲观锁是两种主流方案各有适用场景。悲观锁的做法是查库存时直接SELECT ... FOR UPDATE锁住这一行事务提交前其他事务都不能操作这一行库存。优点是简单粗暴一定不会超卖缺点是并发性能差高峰期容易造成行锁等待数据库连接池被打满。乐观锁的思路是更新时带上版本号或库存条件判断用UPDATE dish_sku SET stock stock - 1, version version 1 WHERE id ? AND stock 1如果影响行数为 0 说明库存不足或者版本冲突应用层重新处理。校园外卖这个场景我一般推荐乐观锁。因为 SKU 库存的扣减操作本身极快一次 UPDATE 就能完成乐观锁冲突后让用户重新下单或者提示库存不足就行不需要复杂的重试机制。少量热点商品的乐观锁冲突概率虽然高但配合 Redis 预扣库存的方案MySQL 的压力能分摊掉很大一部分。-- 乐观锁扣减库存Java/MyBatis 示例 UPDATE dish_sku SET stock stock - #{quantity}, version version 1, update_time NOW() WHERE id #{skuId} AND stock #{quantity}这段 SQL 的逻辑核心在于stock #{quantity}这个条件既是库存校验又是并发控制。两个事务同时执行这条语句时数据库的行级锁保证它们串行执行第二个执行的事务会因为条件不满足而得到 0 条更新记录。应用层根据更新结果判断是否扣减成功成功才继续创建订单失败则回滚整个下单流程。5.2 下单事务的隔离级别与回滚边界下单这个动作涉及多张表的写入插入订单主表、插入订单明细、扣减 SKU 库存、删除购物车记录、可能还要生成优惠券使用记录。这些操作必须在一个数据库事务里执行要么全部成功要么全部失败。MySQL InnoDB 默认的REPEATABLE_READ隔离级别能保证普通场景下的一致性但在高并发场景下还不够要配合正确的锁机制。我在设计下单接口时喜欢把事务边界控制得很小先扣库存再插入订单然后生成配送单。扣库存用的是上面说的乐观锁 UPDATE插入订单和明细是插入操作本身不会产生锁竞争生成配送单是一次条件插入。整个事务的耗时控制在几十毫秒内能显著降低死锁概率。事务回滚边界要特别注意「补偿」问题。比如用户下单后余额支付成功但插订单明细时失败了事务回滚会撤销订单记录可是支付平台的扣款可能已经发生了——这属于分布式事务问题不能只用本地事务解决。常见做法是在订单创建阶段先标记为待支付状态支付回调之后再更新为已支付把支付动作和订单创建动作彻底解耦。5.3 数据一致性检查对账脚本必须早期规划数据一致性是外卖系统最容易出问题的地方用户支付了但订单状态没更新、库存扣了但订单没生成、骑手送达了但订单还挂着配送中。这些问题有些是并发竞争导致更多是接口异常后没有补偿机制。所以数据库设计阶段就要考虑对账需求关键业务表要留好状态变更日志。订单表和支付记录表之间要能对得上账。我建议建一张payment_record表记录每笔支付的流水号、支付平台订单号、订单号、支付金额、支付状态、回调时间字段。定时任务每天凌晨跑一次对账找出「已支付但订单未变更」和「订单已取消但支付未退款」的异常数据推送给运营人工介入。这张表在建订单表的时候同步建好不要等出了事故再来补。骑手和订单的对账相对简单配送单表的状态和订单表的order_status要保证一致性视图。如果配送单已送达但订单状态还是配送中说明状态回调漏了或者异步任务失败定时任务要通过补单机制把两个状态重新对齐。这些补丁代码虽然不优雅但不能没有——生产环境的校园外卖系统不可能永远是理想状态。6. 数据库设计避坑指南5 个最容易翻车的细节6.1 订单号不要用自增主键暴露给用户现象订单号直接用数据库自增 ID 拼接生成比如 10001、10002用户和运营都能从订单号看出平台的日订单量竞对也能通过下单推算单量。原因自增主键是物理存储层面的连续序列本身有业务可推测性而且订单表一旦做分库分表全局自增主键会冲突到时候迁移数据只能改表。解决订单号单独用order_no业务字段由应用层生成规则可以是「时间戳 随机数 用户 ID 尾号」保证全局唯一且不可推测。数据库层面给order_no加唯一索引自增id字段继续留着做物理主键但永远不出现在接口返回里。6.2 金额字段千万别用 FLOAT现象数据库里订单金额存 FLOAT跑了一段时间后对账发现金额差了 0.01 元、0.02 元对账怎么都对不上。原因FLOAT 和 DOUBLE 是浮点数存储十进制小数时存在二进制精度误差1.1 2.2 在浮点数体系下结果是 3.3000000000000003。解决所有涉及金额、费用的字段一律用DECIMAL(10, 2)Java 端用BigDecimal类型对应应用层计算时不要直接相加双精度浮点数。这是交易系统的基本底线数据库设计的文档里必须明确标注。6.3 逻辑删除字段导致索引失效现象订单表查询特别慢明明建了(user_id, create_time)联合索引EXPLAIN查看却发现走了全表扫描。原因所有查询条件都加了AND deleted 0但联合索引里并没有包含deleted字段MySQL 的优化器评估后发现过滤性不够选择了全表扫描。解决逻辑删除字段要么放进联合索引里变成(user_id, deleted, create_time)要么查询量大的核心表直接用「删除状态 时间分区」的方式管理。或者更干脆一点订单表用物理删除把做数据分析需要的数据同步到数仓表业务库里不留废数据。6.4 状态字段不断加枚举值改表结构现象订单状态最开始只有 5 个枚举值后来加了「待接单」「待取餐」「异常完成」每次加状态都要改表字段注释历史数据的状态代码对应的含义变了统计报表全乱了。原因状态字段的语义在设计阶段没有留扩展位枚举值不够用之后只能硬塞数字和含义的映射关系变得不可控。解决状态字段的类型和含义在文档里用独立状态枚举表描述核心业务代码禁止散落着一堆魔法数字。每次新增状态要回归检查状态机全流程确保历史逻辑不受影响。6.5 优惠券表设计时没考虑一人多券现象用户领了一张满 20 减 5 的优惠券下单时系统只校验优惠券 ID结果一个配置的优惠券被所有用户重复使用活动结束一算账亏了一大笔。原因优惠券表设计成了「一张券对应一个配置」「领券关系」没有单独建表导致用户和券之间的关联关系没有数据模型约束。解决优惠券核心表拆成coupon_template券模板和user_coupon用户领券明细两张表模板保存面额、使用门槛、有效期明细表记录哪个用户领取了哪张券、状态是未使用/已使用/已过期。每张用户券有独立 ID下单时锁定用户券 ID 而不是模板 ID.7. 进阶实践从单表设计走向分表分库时的数据迁移思路数据库设计文档写完之后项目可能要经历从小流量到高并发的增长过程。校园外卖的订单表、日志表增长速度快单表行数超过千万后MySQL 的 B 树索引深度增加写入性能明显下降这时候就得考虑分表分库。我不建议课程设计阶段就做分表但数据模型设计时要提前预留分表的可能性。订单表分表最常用的方案是「按月分表」或者「按订单号哈希取模分表」。按时间分表适合历史订单查询占比高的场景比如查询上个月订单就走orders_202510表查询这个月订单就走orders_202511表按哈希分表适合写入并发高的场景订单号取模 16数据均匀分布在 16 张表里任何单表压力都降下来了。分表之后最难受的是联表查询和唯一索引。订单明细表还按订单 ID 关联如果订单和明细分表策略不一致跨表 JOIN 就没法做了所以分表必须做到父子表同分片键——订单表用order_no分片订单明细表也用order_no分片这样同一个订单的数据永远落在同一张物理表里查询不用跨表汇总。还有一类容易被忽略的表是日志流水表包括订单状态日志、库存流水、骑手位置历史。这些表只做写入和按时间查询不做更新非常适合直接用时间分区表存储。MySQL 的PARTITION BY RANGE (create_time)加上定时任务自动创建下月分区数据容量和查询性能都能得到保证而且不用改业务代码。我在给校园外卖项目做技术方案时总爱在文档最后加一句数据库设计文档不是交完作业就丢的它要伴随项目的完整生命周期。表结构、索引、状态机、事务边界、分表策略这些决策后期改动的成本远远大于前期思考的成本。希望这篇笔记能帮你把校园外卖系统的数据库一次设计到位少走我当年走过的弯路。本文还有配套的精品资源点击获取