简介:这份资源是面向高校数据库课程设计场景的餐饮外卖销售系统完整源码包,基于C#与SQL Server 2019开发,涵盖商家、客户、骑手三类用户界面及注册登录模块,采用扁平化设计,界面达到商业软件级别。资源包共46个文件,以cs源码、resx资源文件、config配置、jpg界面截图及exe可执行文件为主,压缩包约886KB,配置好环境后可直接用Visual Studio打开运行。代码结构清晰、注释详细,数据库课设满分作品,已有1593人学习下载。读者可从中获得完整的三端外卖业务建模方案、数据库表结构设计思路、登录注册与角色权限实现逻辑,以及可复用的WinForm界面布局与资源管理方式,适合作为课程设计参考、答辩演示或二次开发起点。
1. 餐饮外卖销售系统数据库设计:从订单洪峰到骑手轨迹的表结构推演
做过餐饮外卖系统的人都有一个共识:业务代码写得再漂亮,数据库设计一旦翻车,后面全是血泪史。一份「餐饮外卖销售系统数据库设计.rar」摆在面前,真正值钱的不是压缩包里那几个 .sql 文件,而是它背后对「用户下单 → 商家接单 → 骑手配送 → 完成结算」这条链路的建模思路。外卖场景和普通电商最大的区别在于:订单有强时效性、状态流转极快、配送轨迹需要持续写入、商家菜单频繁变更。这些特征直接决定了表结构不能照搬通用商城模板。这篇文章面向正在做外卖系统课程设计的学生、需要从零搭建外卖后端的中小团队,以及想重构旧表结构的一线开发者,把这份数据库设计拆成能直接复现的建表逻辑、索引策略和踩坑清单。
2. 外卖业务实体拆解:哪些表必须独立,哪些该合并
外卖系统的实体看起来很多,但真正需要独立建表的其实就那么几个核心域。很多初学者一上来就建二三十张表,结果查询时 JOIN 到怀疑人生。我一般会先画一遍业务流转图,把「谁产生数据、谁消费数据、数据生命周期多长」三个问题回答清楚,再决定表的粒度。
2.1 用户域、商家域、骑手域的边界划分
用户域的核心表是user(消费者)和user_address(收货地址)。注意外卖场景下地址不是简单的字符串,它需要经纬度坐标来支持「附近商家」和「配送范围计算」,所以user_address至少要包含longitude、latitude、address_detail、contact_name、contact_phone五个关键字段。地址表用软删除(is_deleted标记)而不是物理删除,因为历史订单需要回溯收货地址。
商家域拆成merchant(商家主体)、shop(门店)、category(菜品分类)、dish(菜品/SKU)四张表。这里有个容易搞混的点:一个商家可能有多个门店,一个门店有多个分类,一个分类下有多个菜品。如果你只做单店系统,merchant和shop可以合并,但一旦涉及连锁品牌,合并就是给自己挖坑。
骑手域相对简单,rider(骑手基本信息)和rider_location(骑手实时位置)分开建。位置表是高频写入表,必须和主表隔离,否则骑手信息查询会被位置写入拖垮。
-- 用户收货地址表:外卖场景必须带经纬度 CREATE TABLE `user_address` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` BIGINT UNSIGNED NOT NULL COMMENT '所属用户', `contact_name` VARCHAR(32) NOT NULL COMMENT '联系人姓名', `contact_phone` VARCHAR(20) NOT NULL COMMENT '联系电话', `address_detail` VARCHAR(255) NOT NULL COMMENT '详细地址', `longitude` DECIMAL(10,7) NOT NULL COMMENT '经度,用于配送范围计算', `latitude` DECIMAL(10,7) NOT NULL COMMENT '纬度', `tag` VARCHAR(16) DEFAULT NULL COMMENT '标签:家/公司/学校', `is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认地址', `is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除标记', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`, `is_deleted`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户收货地址';这段建表语句里,DECIMAL(10,7)是经纬度的常用精度,小数点后 7 位大约精确到 1 厘米级别,对配送场景完全够用。用DECIMAL而不是FLOAT是因为浮点数在范围比较时会出现精度漂移,导致「明明在配送范围内却判定为超出」的玄学问题。idx_user_id联合索引带上is_deleted,是因为查询用户地址列表时几乎总是带软删除过滤条件。
2.2 订单主表与订单明细表的字段取舍
订单是外卖系统的核心。order_main(订单主表)和order_item(订单明细)必须分开,这是一条铁律。主表存订单级别的信息:订单号、用户、商家、骑手、总金额、配送费、状态、下单时间、预计送达时间。明细表存每个菜品的快照:菜品名称、单价、数量、规格。
这里有一个新手最容易犯的错误:明细表里只存dish_id,不存菜品名称和价格快照。结果商家改了菜名或调了价格,历史订单显示的就全变了。正确做法是在order_item里冗余存储下单时刻的菜品名称、单价、规格描述,这叫「快照冗余」,是订单类系统的标准操作。
-- 订单主表:状态字段是灵魂 CREATE TABLE `order_main` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号,对外展示', `user_id` BIGINT UNSIGNED NOT NULL, `shop_id` BIGINT UNSIGNED NOT NULL, `rider_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '接单骑手,未分配时为空', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '商品总价', `delivery_fee` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '配送费', `pay_amount` DECIMAL(10,2) NOT NULL COMMENT '实付金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2商家接单 3骑手接单 4配送中 5已完成 6已取消', `expect_delivery_time` DATETIME DEFAULT NULL COMMENT '预计送达时间', `finished_at` DATETIME DEFAULT NULL COMMENT '实际完成时间', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_status` (`user_id`, `status`), KEY `idx_shop_status_created` (`shop_id`, `status`, `created_at`), KEY `idx_rider_status` (`rider_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';status用TINYINT而不是字符串枚举,是为了索引效率和存储空间。三个联合索引分别服务三类高频查询:用户查自己的订单、商家查待处理订单、骑手查配送中订单。注意idx_shop_status_created把created_at放在最后,因为商家后台通常按时间倒序拉取订单列表,这个顺序能让排序也走索引。
2.3 购物车、评价、优惠券的建表时机
购物车表cart的设计要区分登录用户和游客。登录用户存数据库,游客存本地或 Redis。如果非要在数据库里存游客购物车,用session_id关联,但一定要设置过期清理策略,否则表会膨胀得很快。
评价表order_review和订单是一对一关系,用order_id做唯一索引即可。优惠券拆成coupon(券模板)和user_coupon(用户领取记录)两张表,这是标准做法,不要合并。
这三类表有一个共同点:它们不是订单主链路的关键路径。建表时可以适当放宽范式要求,比如评价表里冗余存商家 ID 和用户 ID,避免查询时多一次 JOIN。
3. 高频写入场景下的索引与分表策略
外卖系统的数据库压力主要来自三个地方:订单创建、骑手位置上报、订单状态流转。这三类操作的写入频率远高于普通查询,索引设计稍有不慎就会导致写入性能断崖式下跌。
3.1 订单表按时间分区的落地步骤
当订单量达到千万级,单表查询会明显变慢。这时候需要考虑分区或分表。MySQL 原生支持 RANGE 分区,按created_at的月份分区是最直观的方案。
-- 对订单主表按创建时间做 RANGE 分区 ALTER TABLE `order_main` PARTITION BY RANGE (TO_DAYS(`created_at`)) ( PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')), PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')), PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01')), PARTITION p_max VALUES LESS THAN MAXVALUE );分区的核心价值在于:查询某个月订单时,数据库只需要扫描对应分区,而不是全表。但要注意,分区键必须包含在主键或唯一索引中,否则建分区会直接报错。上面order_main的主键是id,而分区键是created_at,这在 MySQL 里是不允许的——实际落地时要么把主键改成(id, created_at)联合主键,要么放弃分区改用应用层分表。这是一个非常典型的翻车点,很多人写到一半才发现。
我的建议是:日订单量在 50 万以下,先用单表加索引扛着;超过这个量级再考虑分表,而且优先选应用层分表(比如按shop_id取模),因为分区表的维护成本比想象中高。
3.2 骑手位置表的写入优化与冷热分离
骑手位置上报的频率通常是 3 到 5 秒一次,一个中等规模平台如果有 5000 名活跃骑手,每天产生的位置记录就是上亿条。这张表绝对不能和业务表放在同一个库实例里。
常见做法是:位置数据先写入 Redis 或时序数据库,只把最新一条位置同步到 MySQL 的rider_location表供业务查询。历史轨迹数据定期归档到冷存储。rider_location表只保留每个骑手的最新记录,用rider_id做唯一索引,写入时用INSERT ... ON DUPLICATE KEY UPDATE。
-- 骑手位置表:只存最新位置,用 upsert 写入 CREATE TABLE `rider_location` ( `rider_id` BIGINT UNSIGNED NOT NULL, `longitude` DECIMAL(10,7) NOT NULL, `latitude` DECIMAL(10,7) NOT NULL, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`rider_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='骑手最新位置'; -- 写入示例:存在则更新,不存在则插入 INSERT INTO `rider_location` (`rider_id`, `longitude`, `latitude`) VALUES (1001, 116.3974280, 39.9092300) ON DUPLICATE KEY UPDATE `longitude` = VALUES(`longitude`), `latitude` = VALUES(`latitude`);用rider_id直接做主键,省掉二级索引,写入性能最好。ON DUPLICATE KEY UPDATE在并发写入同一骑手位置时可能出现锁竞争,但外卖场景下同一骑手的位置上报是串行的,不会有大问题。
3.3 状态流转日志表该不该单独建
订单状态每次变更都值得记录一条日志,这张表叫order_status_log。它的价值在于:排查客诉时能还原完整时间线,做数据分析时能算出商家平均接单时长、骑手平均配送时长。
CREATE TABLE `order_status_log` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_id` BIGINT UNSIGNED NOT NULL, `from_status` TINYINT NOT NULL COMMENT '变更前状态', `to_status` TINYINT NOT NULL COMMENT '变更后状态', `operator_type` TINYINT NOT NULL COMMENT '1用户 2商家 3骑手 4系统', `operator_id` BIGINT UNSIGNED DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单状态流转日志';这张表是纯追加写入,没有更新操作,索引只需要一个order_id。数据量大了之后按created_at做归档即可,不需要分区。
4. 避坑与排查:外卖数据库设计里最容易翻车的五件事
4.1 金额字段用 FLOAT 导致对账差几分钱
现象:财务对账时发现订单总价和明细加总差 0.01 元,反复出现但无法稳定复现。
原因:FLOAT和DOUBLE是二进制浮点数,无法精确表示 0.1、0.2 这类十进制小数。多个浮点数累加时误差会累积。
解决:所有金额字段一律用DECIMAL(10,2),Java 侧对应BigDecimal,Python 侧用decimal.Decimal。不要用FLOAT,也不要用INT存「分」——虽然INT存分也能避免精度问题,但每次读写都要手动乘除 100,容易漏。
4.2 订单号用自增 ID 暴露业务量
现象:用户发现自己的订单号是 10001,下一单变成 10002,能推测出平台每天大概多少单。
原因:直接把自增主键当订单号返回给前端。
解决:订单号单独生成,常见方案是「日期 + 用户 ID 后四位 + 随机数」或雪花算法。数据库主键仍然用自增id,但对外展示的order_no是独立的业务编号,加唯一索引。
4.3 商家接单超时没有兜底导致订单卡死
现象:用户支付成功,但商家一直不接单,订单永远停在「已支付」状态。
原因:状态机设计时只考虑了正常流转,没有设计超时自动取消或自动转派逻辑。
解决:在order_main里加expect_accept_time字段,用一个定时任务扫描超时未接单的订单,自动取消并退款。状态流转日志里记录一条「系统自动取消」的记录,方便追溯。
4.4 骑手位置更新把主库 IO 打满
现象:高峰期数据库 CPU 飙升,订单查询变慢,排查发现是rider_location表的写入量太大。
原因:位置上报直接写 MySQL,且没有做批量合并,每条位置更新都触发一次磁盘 IO。
解决:位置数据先进 Redis,用 Redis 的过期时间做自动清理,只把最新位置异步刷到 MySQL。或者干脆把位置查询也走 Redis,MySQL 只做冷备。
4.5 菜品删除后历史订单查不到明细
现象:商家下架了某个菜品,用户查看历史订单时该菜品显示为空。
原因:order_item表只存了dish_id,没有存菜品名称快照,菜品被物理删除后 JOIN 不到数据。
解决:order_item必须冗余存储dish_name、dish_price、spec_desc三个快照字段。菜品表用软删除,不做物理删除。
5. 从建表到压测:一套可复现的验证流程
数据库设计做完不是终点,能不能扛住真实流量才是。我一般会走一遍「建表 → 造数 → 压测 → 看执行计划」的闭环,这里分享几个具体技巧。
5.1 用存储过程批量造测试数据
手工插几条数据看不出性能问题,至少造 10 万条订单、50 万条明细才有参考价值。用 MySQL 存储过程造数比写脚本快得多。
-- 批量插入 10 万条测试订单 DELIMITER $$ CREATE PROCEDURE `gen_test_orders`(IN num INT) BEGIN DECLARE i INT DEFAULT 0; WHILE i < num DO INSERT INTO `order_main` (`order_no`, `user_id`, `shop_id`, `total_amount`, `pay_amount`, `status`, `created_at`) VALUES ( CONCAT('TEST', LPAD(i, 10, '0')), FLOOR(1 + RAND() * 10000), FLOOR(1 + RAND() * 500), ROUND(10 + RAND() * 200, 2), ROUND(10 + RAND() * 200, 2), FLOOR(RAND() * 7), DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 90) DAY) ); SET i = i + 1; END WHILE; END$$ DELIMITER ; CALL `gen_test_orders`(100000);这段存储过程的关键点是created_at随机分布在过去 90 天内,这样能模拟真实的时间分布,测试按时间范围查询时才有意义。order_no用LPAD补齐 10 位,保证长度一致。
5.2 用 EXPLAIN 验证索引是否真的被命中
造完数据后,把最核心的几条查询拿出来跑EXPLAIN。比如「查询某用户最近 10 笔订单」:
EXPLAIN SELECT * FROM `order_main` WHERE `user_id` = 1234 AND `status` IN (1,2,3,4,5) ORDER BY `created_at` DESC LIMIT 10;重点看type列是不是ref或range,key列是不是命中了idx_user_status,rows列扫描行数是否合理。如果type是ALL,说明走了全表扫描,索引没生效。常见原因是查询条件里的字段类型和索引字段类型不一致,比如user_id是BIGINT但传了字符串。
5.3 慢查询日志的开启与关键参数
压测期间一定要开慢查询日志,否则你根本不知道哪条 SQL 在拖后腿。
-- 开启慢查询日志,阈值设为 1 秒 SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SET GLOBAL log_queries_not_using_indexes = ON;long_query_time设 1 秒是压测阶段的激进值,生产环境可以放宽到 2 到 3 秒。log_queries_not_using_indexes会把所有没走索引的查询都记下来,压测时非常有用,但生产环境慎开,日志量会很大。
5.4 一个容易被忽略的验证点:并发下单时的库存扣减
外卖菜品通常不像电商那样严格扣库存,但「限量特价菜」场景下仍然需要防超卖。如果数据库设计里没有库存字段,或者库存扣减用的是「先查再改」的两步操作,并发时必然超卖。
正确做法是在dish表加stock字段,扣减时用原子操作:
UPDATE `dish` SET `stock` = `stock` - 1 WHERE `id` = 1001 AND `stock` >= 1;然后检查affected_rows是否为 1,为 0 说明库存不足。这个操作不需要事务包裹单条语句,因为UPDATE本身是原子的。如果涉及多菜品扣减,再考虑用事务或分布式锁。
5.5 验证清单:上线前必须确认的六个点
| 检查项 | 验证方法 | 通过标准 |
|---|---|---|
| 金额字段类型 | 查information_schema.columns | 全部为decimal |
| 订单号唯一性 | 并发插入测试 | 无重复,无冲突报错 |
| 核心查询索引 | EXPLAIN逐条检查 | type不为ALL |
| 状态流转完整性 | 模拟全链路操作 | 日志表记录完整 |
| 位置表写入压力 | 模拟 5000 骑手并发上报 | 主库 CPU 低于 60% |
| 历史订单快照 | 删除菜品后查历史订单 | 明细仍可正常显示 |
这套流程走下来,基本能覆盖外卖数据库设计 90% 的落地问题。我自己踩过最深的坑是订单表分区键和主键冲突,当时已经写了几百行建表语句,最后不得不把主键改成联合主键才通过。所以现在我做任何分表分区之前,第一件事就是确认分区键是否在主键里。希望帮到你。
本文还有配套的精品资源,点击获取