☰
送水系统数据库课设:从表结构设计到并发控制的完整实现
2026/9/26 14:25:41 网站建设 项目流程

简介:这份数据库课程设计资源围绕某送水公司的送水业务展开,面向高校数据库课程学习者与需要完成课设的学生,帮助解决从需求分析到数据库落地的完整设计问题。资源包共3个文件,包含1个doc设计报告、1个sql建库脚本和1个bak数据库备份,压缩包约388KB,报告内附清晰的设计思路、流程图与E-R图,建库代码也一并收录其中。内容覆盖工作人员与客户信息管理、矿泉水类别与供应商管理、入库出库管理,并实现触发器在出入库时自动增减对应类型矿泉水数量,通过存储过程统计每位送水员工指定月份的送水数量,以及查询指定月份用水量最大的前10名用户并按用水量递减排列,同时建立表间参照完整性约束。目前已有1543人学习下载,适合需要参考完整课设方案、理解触发器与存储过程写法的读者对照学习。

1. 送水系统课设到底在做什么:从一张订单到一次配送的数据库闭环

送水公司的业务听起来简单,无非是客户打电话订水、仓库安排人送过去。但真把它做成一个数据库课设,你会发现这里面的数据关系比想象中密得多:客户有地址和欠款状态,水站有库存和配送范围,送水工有排班和当前负载,订单有下单、派单、送达、结算四个状态,每一桶水从仓库出去还要对应到具体订单。数据库课程设计选这个题目,核心价值就在于它天然覆盖了增删改查、事务、并发锁、视图和存储过程这些知识点,而且业务逻辑不绕,容易讲清楚。

这篇笔记面向正在做数据库课设的本科生,也面向想拿一个完整小系统练手的初级开发者。我会按「先立数据模型、再落 SQL、最后调并发和性能」的顺序,把送水系统从建库到跑通的关键步骤拆开讲。你跟着走完,能拿到一个可演示、可答辩、能扛住老师追问的完整方案。中间会穿插我踩过的坑,比如订单状态更新和库存扣减的顺序问题,这个点当年让我在答辩现场被问住了十分钟。

2. 送水系统的表结构怎么设计:六张核心表与字段取舍

2.1 从业务动作反推实体,别一上来就画 ER 图

很多人做课设的习惯是先画 ER 图,结果画到一半发现实体关系对不上,又回头改。我的做法是先把业务动作列出来:客户注册、客户下单、系统派单、送水工接单、送达确认、财务结算、库存盘点。每个动作涉及哪些数据,自然就推出了实体。

客户下单这个动作,涉及客户信息、水品信息、订单主体、订单明细。系统派单涉及送水工和配送记录。库存盘点涉及仓库和水品库存。把这些合并去重,得到六张核心表:客户表 customer、水品表 product、订单表 orders、订单明细表 order_item、送水工表 delivery_worker、库存表 inventory。这里有个取舍:订单明细要不要单独一张表?如果一次只订一种水,可以合并进订单表;但实际业务里客户经常一次订好几桶不同规格的水,所以拆出来更合理,也方便后面做统计查询。

字段设计上,客户表除了基本的姓名、电话、地址,我建议加一个 account_balance 字段记录账户余额,因为送水公司常见月结模式,欠款状态直接影响能不能继续下单。订单表的状态字段用枚举值而不是布尔值,因为订单有「待派单、已派单、配送中、已送达、已取消」五种状态,用 status TINYINT 配合注释说明每个值含义,比用多个布尔字段清晰得多。

2.2 建表 SQL 与索引设计

下面是我实际用的建表脚本,以 MySQL 8.0 为例。注意字符集用 utf8mb4,因为客户地址里可能有生僻字。

-- 客户表 CREATE TABLE customer ( customer_id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL UNIQUE, address VARCHAR(200) NOT NULL, account_balance DECIMAL(10,2) DEFAULT 0.00 COMMENT '正数为欠款,负数为预存', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_phone (phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 水品表 CREATE TABLE product ( product_id INT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(50) NOT NULL, unit_price DECIMAL(8,2) NOT NULL, spec VARCHAR(20) COMMENT '规格,如18.9L', is_active TINYINT DEFAULT 1 COMMENT '1上架 0下架' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 送水工表 CREATE TABLE delivery_worker ( worker_id INT PRIMARY KEY AUTO_INCREMENT, worker_name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, status TINYINT DEFAULT 1 COMMENT '1空闲 2配送中 3休息', current_load INT DEFAULT 0 COMMENT '当前待送订单数' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 CREATE TABLE orders ( order_id INT PRIMARY KEY AUTO_INCREMENT, customer_id INT NOT NULL, worker_id INT DEFAULT NULL, status TINYINT DEFAULT 1 COMMENT '1待派单 2已派单 3配送中 4已送达 5已取消', total_amount DECIMAL(10,2) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, delivered_at DATETIME DEFAULT NULL, FOREIGN KEY (customer_id) REFERENCES customer(customer_id), FOREIGN KEY (worker_id) REFERENCES delivery_worker(worker_id), INDEX idx_status (status), INDEX idx_customer (customer_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细表 CREATE TABLE order_item ( item_id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL, FOREIGN KEY (order_id) REFERENCES orders(order_id), FOREIGN KEY (product_id) REFERENCES product(product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存表 CREATE TABLE inventory ( inventory_id INT PRIMARY KEY AUTO_INCREMENT, product_id INT NOT NULL, stock_quantity INT NOT NULL DEFAULT 0, warehouse_name VARCHAR(50) DEFAULT '主仓', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (product_id) REFERENCES product(product_id), UNIQUE KEY uk_product_warehouse (product_id, warehouse_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段脚本里几个关键点值得说明。orders 表的 status 字段建了索引,因为后台最频繁的查询就是「查所有待派单的订单」,没有索引的话数据量一上来就全表扫描。customer 表的 phone 字段加了唯一约束,防止同一个号码重复注册。inventory 表用 product_id 和 warehouse_name 做联合唯一键,保证同一个仓库里同一种水只有一条库存记录,避免出现两条记录导致扣减时不知道扣哪条。

外键约束在课设里建议保留,虽然生产环境有时会为了性能去掉,但课设答辩时老师通常会问「你怎么保证数据一致性」,外键就是最直接的答案。如果用的是达梦或人大金仓这类国产数据库,语法基本兼容,把 AUTO_INCREMENT 换成对应的自增语法即可。

2.3 订单状态流转的字段设计陷阱

订单状态字段最容易踩的坑是用字符串存状态。我见过有同学用 VARCHAR 存「待派单」「已派单」这种中文,查询时写 WHERE status = '待派单',一旦有人录入时多打一个空格就查不出来。用 TINYINT 配合注释是更稳的做法,查询快,也不容易出错。

另一个坑是 delivered_at 字段。有人把它设计成 NOT NULL,结果订单还没送达时不知道该填什么。正确做法是允许 NULL,送达时才写入时间。这个字段后面做配送时效统计时很有用,比如算平均送达时长。

3. 增删改查怎么落到送水业务:从下单到结算的完整 SQL

3.1 下单操作:一个事务里完成三件事

客户下单时,系统要做三件事:插入订单主记录、插入订单明细、扣减库存。这三步必须在一个事务里完成,否则可能出现订单建了但库存没扣,或者库存扣了订单没建的情况。

START TRANSACTION; -- 1. 插入订单主记录 INSERT INTO orders (customer_id, status, total_amount) VALUES (1001, 1, 36.00); -- 获取刚插入的订单ID SET @new_order_id = LAST_INSERT_ID(); -- 2. 插入订单明细(假设订了2桶18.9L的水,单价18元) INSERT INTO order_item (order_id, product_id, quantity, subtotal) VALUES (@new_order_id, 1, 2, 36.00); -- 3. 扣减库存,同时检查库存是否充足 UPDATE inventory SET stock_quantity = stock_quantity - 2 WHERE product_id = 1 AND stock_quantity >= 2; -- 检查上一步是否真的扣成功了 -- 如果受影响行数为0,说明库存不足,需要回滚 -- 在应用层判断 ROW_COUNT(),这里用存储过程演示 COMMIT;

这段逻辑的关键在于第三步的 WHERE 条件里带了 stock_quantity >= 2。这样写的好处是把「检查库存」和「扣减库存」合并成一条原子操作,避免了先查再扣之间的并发窗口。如果库存不足,UPDATE 影响行数为 0,应用层捕获到这个信号后执行 ROLLBACK。

参数说明:customer_id 来自当前登录客户,product_id 和 quantity 来自购物车,total_amount 由应用层计算后传入。实际项目中金额计算建议放在应用层,数据库只负责存储,因为不同水品可能有不同的折扣策略。

3.2 派单查询:找出负载最低的可用送水工

派单是送水系统里最有业务味道的一步。简单做法是随机分配,但更合理的是按当前负载分配,让活少的送水工多接单。

-- 查询当前空闲且负载最低的送水工 SELECT worker_id, worker_name, current_load FROM delivery_worker WHERE status = 1 ORDER BY current_load ASC, worker_id ASC LIMIT 1; -- 派单:更新订单的worker_id和状态 UPDATE orders SET worker_id = 5, status = 2 WHERE order_id = @new_order_id AND status = 1; -- 同时增加送水工负载 UPDATE delivery_worker SET current_load = current_load + 1, status = CASE WHEN current_load + 1 >= 5 THEN 2 ELSE 1 END WHERE worker_id = 5;

这里有个细节:更新订单时 WHERE 条件带了 status = 1,这是乐观锁的思路。如果两个管理员同时派单,只有一个能成功,另一个影响行数为 0,应用层提示「该订单已被派单」。送水工状态在负载达到 5 单时自动切换为「配送中」,不再接新单,这个阈值可以根据实际运力调整。

3.3 结算与欠款更新:用存储过程封装复杂逻辑

客户签收后,系统要更新订单状态、记录送达时间、累加客户欠款、减少送水工负载。这四步也建议放在一个事务里,用存储过程封装起来,调用方只需要传订单号。

DELIMITER // CREATE PROCEDURE settle_order(IN p_order_id INT) BEGIN DECLARE v_customer_id INT; DECLARE v_amount DECIMAL(10,2); DECLARE v_worker_id INT; -- 获取订单信息 SELECT customer_id, total_amount, worker_id INTO v_customer_id, v_amount, v_worker_id FROM orders WHERE order_id = p_order_id AND status = 3; -- 如果订单不存在或状态不对,直接退出 IF v_customer_id IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '订单状态异常,无法结算'; END IF; START TRANSACTION; -- 更新订单状态为已送达 UPDATE orders SET status = 4, delivered_at = NOW() WHERE order_id = p_order_id; -- 累加客户欠款 UPDATE customer SET account_balance = account_balance + v_amount WHERE customer_id = v_customer_id; -- 减少送水工负载 UPDATE delivery_worker SET current_load = current_load - 1, status = CASE WHEN current_load - 1 <= 0 THEN 1 ELSE status END WHERE worker_id = v_worker_id; COMMIT; END // DELIMITER ;

调用时执行 CALL settle_order(1001) 即可。存储过程的好处是把多步操作封装成一个原子单元,应用层不用关心内部细节。注意 SIGNAL SQLSTATE 那段是异常处理,如果订单状态不是「配送中」,直接抛错而不是静默失败,这样应用层能拿到明确的错误信息。

参数说明:p_order_id 是唯一入参。存储过程内部先查后改,查的时候带了 status = 3 条件,保证只有配送中的订单才能结算。如果老师问「为什么不用触发器」,你可以回答:触发器适合简单的联动更新,但结算涉及业务判断和异常处理,存储过程更合适。

4. 并发场景下送水系统会出什么问题:锁、死锁与隔离级别

4.1 两个客户同时下单同一款水,库存扣成负数

这是课设答辩最常被问的场景。假设库存只剩 1 桶,两个客户同时下单,如果没有并发控制,可能出现两个事务都读到库存为 1,都执行扣减,最后库存变成 -1。

InnoDB 默认的 REPEATABLE READ 隔离级别下,UPDATE 语句会对匹配的行加排他锁。所以前面 3.1 节里那条带 stock_quantity >= 2 条件的 UPDATE,第二个事务会等第一个事务提交后才能执行,此时库存已经变成 0,第二个事务的 WHERE 条件不满足,影响行数为 0,扣减失败。这就是为什么把检查和扣减合并成一条语句能解决问题。

但如果你写成先 SELECT 查库存,再在应用层判断,最后 UPDATE 扣减,那在 SELECT 和 UPDATE 之间就有并发窗口,两个事务可能都查到库存为 1。这种写法必须配合 SELECT ... FOR UPDATE 手动加锁:

START TRANSACTION; SELECT stock_quantity FROM inventory WHERE product_id = 1 FOR UPDATE; -- 应用层判断 stock_quantity >= 2 UPDATE inventory SET stock_quantity = stock_quantity - 2 WHERE product_id = 1; COMMIT;

FOR UPDATE 会对查询行加排他锁,第二个事务的 SELECT 会阻塞,直到第一个事务提交。这样能保证安全,但锁持有时间更长,并发性能下降。我的建议是优先用合并写法,实在需要先查再改时才用 FOR UPDATE。

4.2 派单和结算同时操作同一个送水工,死锁怎么排查

死锁在送水系统里出现的典型场景是:派单事务先更新 orders 表再更新 delivery_worker 表,结算事务先更新 orders 表再更新 delivery_worker 表,但两个事务更新的订单和送水工交叉了,就可能形成循环等待。

排查死锁的第一步是看 InnoDB 的状态输出:

SHOW ENGINE INNODB STATUS;

在输出里找 LATEST DETECTED DEADLOCK 这一段,它会显示两个事务分别持有什么锁、等待什么锁。常见解法是统一更新顺序,比如规定所有事务都先更新 orders 再更新 delivery_worker,这样就不会出现交叉等待。另一个解法是缩短事务,把不必要的查询放到事务外面。

如果用的是达梦数据库,可以查 V$DEADLOCK_HISTORY 视图;人大金仓则看 pg_stat_activity 和 pg_locks。不同数据库的排查手段不同,但思路一致:找到互相等待的两个会话,看它们各自持有什么锁。

4.3 隔离级别选哪个:课设里用默认的就够

MySQL 默认 REPEATABLE READ,能避免脏读和不可重复读,幻读在 InnoDB 的间隙锁机制下也基本能防住。课设场景下用默认级别就行,不需要改成 SERIALIZABLE,因为那会大幅降低并发性能,而且送水系统的业务对幻读不敏感。

如果老师问「什么时候需要调整隔离级别」,你可以举例:如果要做实时库存报表,希望读到最新数据,可以把那个查询会话设成 READ COMMITTED。但全局改隔离级别要慎重,因为不同业务对一致性的要求不一样。

5. 课设答辩前必查的五个坑:从字段类型到演示流程

5.1 金额字段用了 FLOAT,结算时出现 0.01 误差

现象:订单金额 36.00 元,结算后客户欠款变成 36.00000001 或 35.99999999。

原因:FLOAT 和 DOUBLE 是浮点数,二进制无法精确表示某些十进制小数,累加多次后误差放大。

解决:金额字段一律用 DECIMAL(10,2),它按十进制存储,精确到分。如果已经建了表,用 ALTER TABLE 改字段类型,但要注意数据迁移时可能丢失精度,最好先备份。

5.2 订单状态更新了但库存没扣,事务没生效

现象:演示时下单成功,订单表里有记录,但库存表数量没变。

原因:可能是 autocommit 被打开了,每条 SQL 自动提交,START TRANSACTION 没起作用;也可能是代码里 COMMIT 写在了异常处理分支外面,出错时没回滚。

解决:先执行 SELECT @@autocommit 确认是 0;检查代码里 START TRANSACTION 和 COMMIT 是否配对,异常分支里要有 ROLLBACK。用存储过程封装的话,把事务控制放在存储过程内部,应用层只负责调用。

5.3 外键约束导致删数据失败,演示时卡住

现象:想删除一个测试客户,报错「Cannot delete or update a parent row」。

原因:该客户在 orders 表里有订单记录,外键约束阻止删除。

解决:课设演示时不要直接删有订单的客户,可以先把订单状态改成「已取消」再删,或者用 ON DELETE CASCADE 让订单跟着删。但 CASCADE 要慎用,生产环境可能误删数据。更稳妥的做法是给客户表加一个 is_deleted 标记字段,做逻辑删除。

5.4 中文乱码,客户姓名显示成问号

现象:插入中文客户名后,查询出来是 ???。

原因:数据库、表、连接三处的字符集不一致。常见的是数据库建的时候用了 latin1,或者 JDBC 连接串没指定 characterEncoding。

解决:建库时指定 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;建表时也指定;JDBC 连接串加 ?useUnicode=true&characterEncoding=utf8。三处都对齐后就不会乱码。如果用的是 Navicat 或 dbx 这类工具,连接属性里也要设成 utf8。

5.5 演示时数据太少,查询看不出效果

现象:答辩时老师让查「本月销量最高的水品」,结果表里只有三条订单,查出来都一样。

原因:测试数据准备不足。

解决:提前用脚本批量插入模拟数据,至少 50 个客户、200 条订单、覆盖各种状态。可以用 Python 脚本生成随机数据,也可以用 SQL 的 INSERT ... SELECT 自我复制。数据量上来了,索引的效果、分页查询、聚合统计才能演示出差异。

6. 让送水系统课设多拿十分:视图、统计查询与演示脚本

课设拿高分的关键不在于功能多,而在于你能展示出对数据库特性的理解。我当年答辩时,老师看到我用了视图做报表统计,直接多给了五分。下面说几个投入产出比高的进阶点。

第一个是建一个订单汇总视图,把客户名、水品名、送水工名、订单状态这些分散在多张表里的信息拼在一起,查询时不用写复杂的 JOIN。

CREATE VIEW v_order_detail AS SELECT o.order_id, c.name AS customer_name, c.phone AS customer_phone, p.product_name, oi.quantity, oi.subtotal, w.worker_name, CASE o.status WHEN 1 THEN '待派单' WHEN 2 THEN '已派单' WHEN 3 THEN '配送中' WHEN 4 THEN '已送达' WHEN 5 THEN '已取消' END AS status_text, o.created_at FROM orders o JOIN customer c ON o.customer_id = c.customer_id JOIN order_item oi ON o.order_id = oi.order_id JOIN product p ON oi.product_id = p.product_id LEFT JOIN delivery_worker w ON o.worker_id = w.worker_id;

有了这个视图,查「某个客户的所有订单」就变成 SELECT * FROM v_order_detail WHERE customer_name = '张三',演示时非常直观。注意 LEFT JOIN 送水工表,因为待派单的订单还没有 worker_id,用 INNER JOIN 会漏掉这些记录。

第二个是写一个配送时效统计查询,展示送水工的平均送达时长,这个能体现你对时间函数的掌握。

SELECT w.worker_name, COUNT(*) AS total_orders, ROUND(AVG(TIMESTAMPDIFF(MINUTE, o.created_at, o.delivered_at)), 1) AS avg_minutes FROM orders o JOIN delivery_worker w ON o.worker_id = w.worker_id WHERE o.status = 4 GROUP BY w.worker_id, w.worker_name ORDER BY avg_minutes ASC;

TIMESTAMPDIFF 算两个时间差,单位是分钟。这个查询能回答「哪个送水工效率最高」,答辩时老师通常会追问「如果订单量差异很大,平均时长还有意义吗」,你可以补充说可以加一个 HAVING COUNT(*) >= 10 过滤掉样本太少的送水工。

第三个是准备一个演示脚本,按固定顺序执行,避免现场手忙脚乱。我的习惯是写一个 demo.sql 文件,里面按顺序放:清空测试数据、插入基础数据、模拟下单、模拟派单、模拟结算、查询报表。每一步前面加注释说明预期结果。这样即使紧张也不会漏步骤。

最后说一个我踩过的坑:演示前一定要在答辩用的电脑上完整跑一遍,不要假设环境和你开发机一样。我见过同学在自己电脑上跑得好好的,到答辩教室发现 MySQL 版本不同,存储过程语法报错。提前跑一遍,把数据库导出成 SQL 文件带着,万一环境有问题可以快速重建。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询