ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

电子报纸订购系统数据库设计:从ER模型到并发事务实战

电子报纸订购系统数据库设计:从ER模型到并发事务实战 简介一份面向数据库课程设计的Java实现电子报纸订购系统源码包适合高校学生完成类似选题或巩固Java与数据库开发能力。包内共24个文件包含19个Java源文件、4个SQL脚本和1份Markdown说明压缩包仅33KB。Java源文件覆盖登录、菜单、顾客信息、报纸信息及订购记录的增删改查等核心模块并涉及集合框架、异常处理、事件监听与MVC分层设计SQL脚本提供custom、paper、order等表结构及数据可导入MySQL/Oracle体现数据库范式设计与SELECT、INSERT、UPDATE、DELETE操作。README文档则辅助理解项目结构与启动流程。整体代码清晰、功能完整既可作为课程设计答辩方案也可用于自学数据库与Java整合开发的练手项目。目前已有180人学习下载适合正在完成数据库课程设计、需要完整可运行参考的读者。1. 电子报纸订购系统数据库课程设计里最“稳”但不简单的一道题“数据库课程设计电子报纸订购系统”是很多计算机专业学生的期末必修题。它表面上是给一份报纸做增删改查实际要处理的是用户、报纸、订阅、订单、投递之间的多对多关系和状态流转。正因为业务边界清晰它特别适合练数据库基本功从 ER 模型、三范式到事务、行锁、存储过程一条线走完答辩时有实物可讲。更关键的是这种题目最容易拉开差距——很多人做成了“登录 一张订单表 一个列表页”而真正拿高分的人会把订购过程中那些“并发下单、金额快照、软删除”的细节讲清楚。下面按实际做这类课设的顺序展开先讲表怎么设计再讲下单并发怎么处理最后给一份验收自测思路。新手照着重现能跑通熟手可以重点看参数、边界和踩坑点。2. 从需求到表结构电子报纸订购系统的实体关系与范式取舍2.1 需求拆解把“订购”拆成四个相互独立又要关联的业务对象拿到题目先别急着打开 IDE先把需求一条条写清楚。电子报纸订购系统至少包含四类核心对象用户、报纸、订阅、订单订单还带明细如果再往前走一步还有投递。用户负责账号和状态报纸负责目录、价格和库存订阅负责“在什么时间段内、以什么方式读哪份报纸”订单负责一次性购买行为可能是买某一期过刊也可能是买一个订阅周期。投递是订阅的执行流水电子报纸每天派发一条阅读链接纸质报纸则要记录投递日期和状态。如果一开始就把订阅和订单合并成一张表用“类型”字段区分是订阅还是单期购买后面写统计报表时会非常痛苦一会儿要按订单聚合一会儿要按订阅聚合两个业务混在一张表里字段越来越多SQL 越写越长连索引都不好设计。所以关系数据库设计的第一步不是写表而是划清实体边界。划完之后关系也就清楚了用户与报纸之间是典型的多对多关系一个用户可以订阅多份报纸一份报纸可以被多个用户订阅。这个多对多不能靠用户表里加一个 newspaper_id 或者报纸表里加一个 user_id 来表达必须拆成中间表也就是后面的订阅表。还有一个容易被忽略的点订单和订单明细是一对多。一个订单可以包含多份报纸如果设计成订单表里放一个 newspaper_id那订单就退化成了“单商品收银”系统扩展性为零。课设虽然演示时可能只一单一报但一份合格的订单表设计必须预留多明细。课程设计需求文档通常不会把这些约束写细需要自己补全答辩时主动讲出来老师会认为你确实想过边界。2.2 概念模型为什么“订阅”要单独做成实体而不是在用户或报纸表里加字段画 E-R 图时最经典的错误是把用户和报纸画成两个实体然后从用户表拉一条线到报纸表线上标一个 M:N就算完事。但“订阅”这个联系不是普通连线它有属性开始日期、结束日期、投递方式电子版/纸质版、是否自动续费、订阅状态。既然有属性就不能只用一条线表达应该把它提升为实体在 E-R 图中用“菱形属性”表示。为什么要强调这个因为“联系升实体”是数据库概念设计里最重要的分水岭。如果只在用户表里加 newspaper_id一个用户只能订一份报纸在报纸表里加 user_id一份报纸只能卖给一个用户。两种做法都违背了多对多关系的语义。把订阅单独做成一张表后它一方面引用 user_id另一方面引用 newspaper_id同时又携带自身的起止日期和投递方式。很多同学在这里画错答辩时被问一句“你的 E-R 图里用户和报纸是什么关系”就卡壳。订单模型同理。订单头orders只放公共信息订单号、用户 ID、总金额、状态、支付时间、创建时间。订单明细order_items放每份报纸的数量、成交单价、小计。为什么订单头不能直接放报纸字段因为一个订单可能包含多份报纸。有些同学为了省事把一个订单里的多种报纸拆成多行共用同一个订单号结果订单金额在统计时被重复计算最后对账怎么都对不上。这一层概念没理顺后面的建表语句全是隐患。概念模型完成后主键也基本定了用户、报纸、订阅、订单、投递各自用自增主键订单明细适合用联合主键order_id, newspaper_id既能保证同一订单里不会重复录入同一份报纸又能天然约束明细行的唯一性。这些主键选择会直接决定后面的外键关系和建表语句。2.3 逻辑设计六张核心表的字段选择、外键与三范式的实际取舍下面是六张核心表的建表 SQL注释尽量写全因为课程设计报告里的数据字典可以直接从这里提取。CREATE DATABASE IF NOT EXISTS newspaper_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE newspaper_db; CREATE TABLE users ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password_hash CHAR(64) NOT NULL, email VARCHAR(100) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-正常 0-停用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE newspapers ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL, category VARCHAR(50) DEFAULT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, periodicity ENUM(DAILY,WEEKLY,MONTHLY) NOT NULL DEFAULT DAILY, stock INT UNSIGNED NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-上架 0-下架, last_delivery_date DATE DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_newspapers_category (category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT报纸表; CREATE TABLE subscriptions ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, newspaper_id INT UNSIGNED NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, delivery_type ENUM(ELECTRONIC,PAPER) NOT NULL DEFAULT ELECTRONIC, auto_renew TINYINT(1) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-有效 0-已停, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_newspaper (user_id, newspaper_id), KEY idx_subscriptions_end_date (end_date), CONSTRAINT fk_sub_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT fk_sub_newspaper FOREIGN KEY (newspaper_id) REFERENCES newspapers(id) ON DELETE RESTRICT ON UPDATE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订阅表; CREATE TABLE orders ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT UNSIGNED NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, status ENUM(PENDING,PAID,CANCELLED,REFUNDED) NOT NULL DEFAULT PENDING, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, paid_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_orders_created_at (created_at), KEY idx_orders_user_id (user_id), CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表; CREATE TABLE order_items ( order_id INT UNSIGNED NOT NULL, newspaper_id INT UNSIGNED NOT NULL, quantity INT UNSIGNED NOT NULL DEFAULT 1, unit_price DECIMAL(10,2) NOT NULL, subtotal DECIMAL(10,2) NOT NULL, PRIMARY KEY (order_id, newspaper_id), KEY idx_oi_newspaper (newspaper_id), CONSTRAINT fk_oi_order FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, CONSTRAINT fk_oi_newspaper FOREIGN KEY (newspaper_id) REFERENCES newspapers(id) ON DELETE RESTRICT ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表; CREATE TABLE deliveries ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, subscription_id INT UNSIGNED NOT NULL, newspaper_id INT UNSIGNED NOT NULL, delivery_date DATE NOT NULL, is_delivered TINYINT(1) NOT NULL DEFAULT 0 COMMENT 0-未投递 1-已投递, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_sub_delivery (subscription_id, delivery_date), KEY idx_deliveries_date (delivery_date), CONSTRAINT fk_delivery_sub FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE, CONSTRAINT fk_delivery_newspaper FOREIGN KEY (newspaper_id) REFERENCES newspapers(id) ON DELETE RESTRICT ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投递表;先解释几个不容含糊的选型。金额字段用DECIMAL(10,2)而不是FLOAT或DOUBLE因为浮点数在二进制里存不精确0.1 加 0.2 会得到 0.30000000000000004。订单金额、订阅价格这种字段一旦产生一分钱误差对账阶段会让人查到怀疑人生这是血泪经验。时间字段用DATETIME而不是TIMESTAMP因为TIMESTAMP有 2038 年问题且时区处理在 JDBC 连接参数没配对时容易乱课程设计阶段没必要给自己埋这种雷。再看外键的 ON DELETE 策略用户和报纸是核心主数据订单和订阅都引用它们所以用RESTRICT防止误删后订单变成孤儿数据订单明细属于订单订单删除时明细应该跟着删所以用CASCADE投递记录依赖订阅订阅取消后投递计划自动清理也是CASCADE。这里有个设计细节subscriptions表加了UNIQUE KEY uk_user_newspaper (user_id, newspaper_id)表示同一个用户对同一份报纸只能有一条有效订阅。这是业务约束不是数据库强制的但加了这个唯一键后应用层即使写错也不会产生重复订阅。三范式在这个设计里不是死板的。严格按第三范式order_items.unit_price应该去newspapers表现查但报纸改价后历史订单金额就会被篡改所以我在这张表里冗余了一份成交单价把它当作“成交时点的快照”。这不是违反范式而是为了保住业务事实。同理投递表里有 newspaper_id看似可以由 subscription 反查但为了查询投递按报纸汇总时不带大 JOIN这个冗余是划算的。在报告里写清楚“范式是方法论不是法律”老师会觉得你已经理解了三范式的取舍。3. 用 MySQL 跑通核心业务增删改查、事务与并发锁3.1 最小初始化命令建库、建账号、字符集和 JDBC 参数MySQL 安装好后我习惯先建一个独立业务账号而不是全程用 root。这样既避免误删系统库答辩时也能说清楚“最小权限”的概念。mysql -u root -pCREATE DATABASE IF NOT EXISTS newspaper_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER newspaper_applocalhost IDENTIFIED BY Newspaper2025; GRANT ALL PRIVILEGES ON newspaper_db.* TO newspaper_applocalhost; FLUSH PRIVILEGES;字符集这里特别容易被坑。MySQL 里的utf8其实不是完整的 UTF-8它最多支持 3 字节遇到 emoji 或部分生僻字就直接报错或存成乱码。utf8mb4才是完整的 4 字节 UTF-8。排序规则utf8mb4_unicode_ci对中文和英文混合的场景比较稳定不要图方便写成utf8_general_ci。连接串也要配套。Java 端 JDBC 常见写法jdbc:mysql://localhost:3306/newspaper_db?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalse如果漏掉characterEncodingutf8mb4即使数据库是 utf8mb4客户端传进来的中文也有可能变成问号。Python 端用 PyMySQL 时对应charsetutf8mb4。这一步是很多“数据库中文乱码”问题的根源运行时查不到最后还是回来看连接参数。这些 mysql 数据库常用命令建议在报告附录里列一份老师看到会认为你具备基本运维能力。3.2 核心业务下单事务、行锁与“扣库存不能扣成负数”电子报纸订购里最核心的业务是“下单”。用户点了一次订购后端要同时完成四件事写订单头、写订单明细、扣报纸库存、生成订阅记录。这四件事必须同时成功或同时失败否则就会出现“订单生成了库存没扣”或“库存扣了订单还挂着”的脏数据。用事务包住是唯一正确做法。下面是一个最小可运行的下单事务START TRANSACTION; -- 这一步会锁住 newspapers 表 id1 这一行 SELECT stock FROM newspapers WHERE id 1 FOR UPDATE; -- 应用层或存储过程里在这里判断 stock 是否足够不够就 ROLLBACK UPDATE newspapers SET stock stock - 1 WHERE id 1; INSERT INTO orders (order_no, user_id, total_amount, status, paid_at) VALUES (202506010001, 1, 12.00, PAID, NOW()); SET new_order_id LAST_INSERT_ID(); INSERT INTO order_items (order_id, newspaper_id, quantity, unit_price, subtotal) VALUES (new_order_id, 1, 1, 12.00, 12.00); INSERT INTO subscriptions (user_id, newspaper_id, start_date, end_date, delivery_type, status) VALUES (1, 1, 2025-06-01, 2025-06-30, ELECTRONIC, 1); COMMIT;注意SELECT ... FOR UPDATE。它会对命中的报纸行加一个排他锁X 锁另一个事务再对同一行执行FOR UPDATE或UPDATE时会被阻塞直到前一个事务提交或回滚。这就是数据库并发锁的直观形态。如果不加这个锁两个客户端同时读到stock 1都判断“库存够了”然后都执行stock stock - 1最终库存变成 -1订单却成功了两笔。加了锁之后又会引出另一个问题数据库死锁。比如事务 A 先锁 newspapers 再锁 orders事务 B 先锁 orders 再锁 newspapers两个事务互相等对方释放锁InnoDB 检测到后会自动回滚其中一个并抛出Deadlock found异常。应用层对这个异常的处理就是重试设计层更优的做法是约定全系统按同一个顺序访问表先报纸、再订单、再订单明细、最后订阅。这样能大幅度减少死锁。还要强调FOR UPDATE只是锁行它不会帮你判断库存是否足够。业务逻辑里必须在SELECT stock之后显式判断如果不足要ROLLBACK并给用户提示“库存不足”。课程设计里很多人以为加锁就万事大吉这是常见误解。3.3 报表查询和索引EXPLAIN 怎么发现问题订单表数据少时查询怎么 JOIN 都很快一旦表里积累了几万条原来秒开的报表可能变成几秒甚至十几秒。原因多半不是 SQL 语法错了而是索引没建对。下面这条报表查询很典型EXPLAIN SELECT o.order_no, u.username, n.name, oi.quantity, oi.unit_price, o.created_at FROM orders o JOIN order_items oi ON o.id oi.order_id JOIN users u ON o.user_id u.id JOIN newspapers n ON oi.newspaper_id n.id WHERE o.created_at 2025-06-01 AND o.created_at 2025-07-01 ORDER BY o.created_at DESC;在orders表只有几百行时这个 EXPLAIN 的type列可能全是ALL也就是全表扫描但速度还能接受。数据量到几万后ALL就会被放大而且Extra列会出现Using filesort说明ORDER BY没有走索引需要临时排序。解决办法已经在建表语句里加了两个关键索引idx_orders_created_at和idx_orders_user_id。加完之后EXPLAIN 里关于 orders 的访问类型应该变成range后面的 join 依次变成refExtra里的Using filesort也会消失。索引不是越多越好。每次 INSERT、UPDATE 都要同步维护索引索引多了写入会变慢磁盘占用也会变大。课程设计阶段有一个简单的原则高频查询的 WHERE 条件和 JOIN 条件才建索引比如订单按时间范围和用户查低频的 LIKE 模糊查询不强求。对于order_items这张明细表联合主键已经覆盖了 “按订单查明细” 这个高频场景额外建idx_oi_newspaper是为了支持从报纸维度反查销量这是一个典型的“空间换时间”取舍。4. 订单取消、退款与订阅到期存储过程和触发器的适用边界4.1 用存储过程把“取消订单”做成原子操作校验状态、回补库存取消订单这个动作看起来简单但拆开有三步把订单状态改成已取消、把明细里涉及的报纸库存加回去、保证整个过程不被并发打断。如果这三步分散在应用代码里先 SELECT 再 UPDATE中间一旦被别人改掉状态就会出现“重复取消、库存翻倍回补”的脏逻辑。我一般会把这种业务放进存储过程让数据库层保证原子性。下面是一个可以跑通的 MySQL 存储过程DELIMITER // CREATE PROCEDURE sp_cancel_order(IN p_order_no VARCHAR(32)) BEGIN DECLARE v_order_id INT; DECLARE v_status VARCHAR(20); DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 锁住这条订单防止并发取消 SELECT id, status INTO v_order_id, v_status FROM orders WHERE order_no p_order_no FOR UPDATE; IF v_status NOT IN (PENDING, PAID) THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT current order cannot be cancelled; END IF; UPDATE orders SET status CANCELLED WHERE id v_order_id; -- 回补库存按订单明细实际数量加回 UPDATE newspapers n JOIN order_items oi ON n.id oi.newspaper_id SET n.stock n.stock oi.quantity WHERE oi.order_id v_order_id; COMMIT; END// DELIMITER ;调用方式CALL sp_cancel_order(202506010001);这里最重要的一段是FOR UPDATE锁订单行以及SIGNAL抛错。如果订单状态不是可取消的状态SIGNAL会让事务回滚并抛出错误应用层捕获到异常后就能提示用户。DECLARE EXIT HANDLER是兜底任何 SQL 异常发生时先回滚再重新抛错避免事务停在中间。存储过程的优点是把复杂流程收敛在数据库层减少应用和数据库之间的往返缺点也很明显修改要重新执行 DDL调试不如 Java 代码方便。课程设计里写一个存储过程是加分项但不要写一堆两三个足够展示能力。4.2 触发器什么时候用什么时候别用触发器是课程设计报告里常被提及的点也是实际项目里最容易被滥用的机制。常见需求是“投递记录生成后自动更新报纸的最后投递日期”。可以用一个 AFTER INSERT 触发器实现CREATE TRIGGER trg_delivery_after_insert AFTER INSERT ON deliveries FOR EACH ROW UPDATE newspapers SET last_delivery_date NEW.delivery_date WHERE id NEW.newspaper_id;逻辑确实干净每次插入投递记录报纸表自动记录最新投递日期不需要应用层额外更新。但它也有很深的隐患。第一触发器对应用代码是透明的相当于隐式操作排查问题时你根本想不到这个副作用藏在数据库里像个黑匣子。第二性能方面如果一次性批量插入一万条投递记录这个触发器会逐行触发一万次 UPDATE比显式一次性 UPDATE 慢很多。我的做法是课程设计里可以用一个触发器当亮点但要控制场景选这种“低频、小批量、状态简单”的场合不要在触发器里做复杂的统计或调用其他存储过程。至于“订阅到期自动把 status 改成停用”这种需求不要硬用触发器。更可靠的做法是每天定时任务去执行一条 UPDATEUPDATE subscriptions SET status 0 WHERE status 1 AND end_date CURDATE();这条 SQL 什么时候跑由应用层的定时任务或者操作系统的 cron 调存储过程而不是靠数据库内部的 EVENT Scheduler。理由很简单定时任务要能看见日志、能重试、能报警数据库事件调度器做不到这些。4.3 软删除为什么“删除用户”应该写成 UPDATE 而不是 DELETE课程设计做到后期很多同学会想做“删除用户”和“删除报纸”功能。如果一个用户已经下了订单、建立了订阅直接DELETE FROM users WHERE id 1会被外键拦住因为 orders 和 subscriptions 还在引用他。就算你强行关闭外键检查把关联数据也删了以后统计订阅率时就发现历史数据全没了。正确做法是软删除把状态字段从 1 改成 0UPDATE users SET status 0 WHERE id 1;对应地所有业务查询都要带着状态条件SELECT id, username, email FROM users WHERE status 1;报纸下架同理不要DELETE FROM newspapers而是UPDATE newspapers SET status 0 WHERE id 5。这样即使订单已经停止历史订单细节仍然能通过order_items关联到报纸名称不会变成空引用。软删除就是后悔药测试数据弄乱了可以随时把状态改回来继续用。如果老师问“为什么不用 DELETE”你就回答外键完整性约束和历史数据的可追溯性决定了不能用物理删除。这也是课程设计报告里值得写一小节的点。5. 数据库课程设计避坑指南5 个让你翻车的常见问题与排查思路5.1 建表 SQL 在同学电脑上执行失败字符集和存储引擎不一致现象自己电脑上 Navicat 里跑得好好的脚本换到另一台电脑或老师服务器上就报Unknown character set或者明明是建表语句却报ERROR 1005外键失败。原因多半是导出的 SQL 里混用了目标环境不支持的字符集或者表引擎不是 InnoDB。很多人用 Navicat 默认导出表引擎可能是 MyISAM而 MyISAM 根本不支持外键。外键建不出来时MySQL 不一定会直接告诉你“MyISAM 不支持外键”而是给出很迷惑的errno: 150。解决建表时统一写成ENGINEInnoDB DEFAULT CHARSETutf8mb4。部署前先查版本SELECT VERSION();确认是 MySQL 5.5.3 以上因为 utf8mb4 是从这个版本才开始支持的。再用SHOW TABLE STATUS LIKE orders\G;看Engine字段必须是InnoDB。另外Windows 记事本另存 SQL 文件时可能带 BOMMySQL 执行第一条语句会报错建议用 VS Code 或 Notepad 保存为 UTF-8 无 BOM。这些细节看起来玄学但最后往往就卡在这。5.2 并发下单库存变成负数锁没有生效现象用 JMeter 写一个数据库压测脚本模拟 50 个用户同时购买同一种报纸最终库存变成 -1订单却全部成功。原因业务代码是“先 SELECT 查库存再 UPDATE 扣减”。两个请求同时读到库存等于 1都认为有货于是都执行 UPDATE库存被扣成 -1。还有一种常见情况是虽然写了SELECT ... FOR UPDATE但整个流程没有真正放在一个事务里JDBC 默认autocommit1每个语句执行完就自动提交锁早就释放了。解决要么用事务包住并加行锁要么用一条带条件的 UPDATE 做原子扣减UPDATE newspapers SET stock stock - 1 WHERE id 1 AND stock 0;然后检查ROW_COUNT()如果等于 0说明库存不足回滚订单。这种方式不需要FOR UPDATE也不用担心锁等待超时是更推荐的乐观锁写法。如果老师追问并发锁再把FOR UPDATE方案拿出来对比讲解会显得理解更深。5.3 想删测试数据外键拦着不让删现象执行DELETE FROM newspapers WHERE id 5;报错Cannot delete or update a parent row: a foreign key constraint fails。图省事的同学直接SET FOREIGN_KEY_CHECKS0;删完再开结果后面 JOIN 报表时出现空关联。原因报纸被 subscriptions 或 order_items 引用。物理删除会破坏历史订单和订阅流水数据库外键约束在这种情况下是在保护数据安全不是作业系统跟你作对。解决对于报纸下架改状态就行见 4.3。如果确实要清空测试数据正确的顺序是从子表往主表删先删 order_items、deliveries再删 orders、subscriptions最后删 newspapers。或者干脆重来DROP DATABASE newspaper_db;然后重新执行建库脚本。千万不要靠关闭外键检查来绕过约束。5.4 报表里订单数翻倍多对多 JOIN 的统计陷阱现象想统计这个月有多少订单写了SELECT COUNT(*) FROM orders o JOIN order_items oi ON o.id oi.order_id WHERE o.created_at 2025-06-01 AND o.created_at 2025-07-01;结果返回的订单数比实际多了一倍甚至几倍。原因一个订单对应多条明细JOIN 之后订单行会被明细行复制展开COUNT(*)数的是明细行数不是订单数。这是多对多关系里最经典的统计陷阱。解决统计订单数量用COUNT(DISTINCT o.id)统计销售份数用SUM(oi.quantity)统计订阅了多少种报纸也要加 DISTINCT。下面这条才是正确的订单数统计SELECT COUNT(DISTINCT o.id) AS order_count, SUM(oi.quantity) AS total_copies FROM orders o JOIN order_items oi ON o.id oi.order_id WHERE o.created_at 2025-06-01 AND o.created_at 2025-07-01;如果报表更复杂应该先聚合明细再用临时表 JOIN避免中间结果膨胀。5.5 答辩时数据字典和实际表对不上从 information_schema 生成字典现象课程设计报告里写的表结构和数据库里实际不一样答辩老师随便打开一张表问一个字段你发现自己也说不清这个字段是后来哪次加的。原因项目开发后期改表太频繁而文档是手工维护的手工维护必然遗漏。这是几乎所有课设都会翻车的点。解决用一条 SQL 直接从数据库生成数据字典把结果导入 Excel 放到报告附录保证永远不会和实际表结构脱节SELECT TABLE_NAME AS 表名, COLUMN_NAME AS 字段名, COLUMN_TYPE AS 类型, IS_NULLABLE AS 是否为空, COLUMN_KEY AS 键, COLUMN_COMMENT AS 注释 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA newspaper_db ORDER BY TABLE_NAME, ORDINAL_POSITION;执行后在命令行工具里导出成 CSV或者直接在 Navicat 里复制到 Excel。报告里放这个表老师问起来你还有理有据数据结构以数据库实际为准。如果老师要看 ER 图也可以用 MySQL Workbench 的 Reverse Engineer 从数据库反向生成比手画准得多。6. 验收前最后一步用一份可重复执行的脚本把核心功能全部自测一遍课程设计交之前我习惯写一个smoke_test.sql在一个全新的测试库上反复执行验证“删库重建后系统还能跑”。这个习惯救过我很多次因为很多翻车不是功能没写完而是改表结构时把某个约束改没了UI 上点起来没感觉报表一跑就错。第一步是备份和恢复的闭环演练mysqldump -u root -p newspaper_db newspaper_backup.sql mysql -u root -p newspaper_db newspaper_backup.sql别看这两行简单它能验证脚本在不同机器之间可迁移。真正答辩前我会把数据库删掉用备份文件恢复一次确认不会出现“库没了不知道去哪找”的尴尬。第二步是在测试库跑一套自测 SQL按顺序覆盖主要业务路径-- 1. 清空业务表注意先子表后主表 SET FOREIGN_KEY_CHECKS 0; TRUNCATE TABLE order_items; TRUNCATE TABLE orders; TRUNCATE TABLE subscriptions; TRUNCATE TABLE deliveries; TRUNCATE TABLE newspapers; TRUNCATE TABLE users; SET FOREIGN_KEY_CHECKS 1; -- 2. 插入基础数据 INSERT INTO users (username, password_hash, email) VALUES (test_user, SHA2(123456, 256), testexample.com); INSERT INTO newspapers (name, category, price, stock) VALUES (测试日报, 综合, 12.00, 1); -- 3. 正常下单调用存储过程或直接跑事务 CALL sp_create_order(test_user, 测试日报, 1); -- 4. 取消订单验证库存回补 CALL sp_cancel_order(202506010001); -- 5. 验证失败场景把库存改成 0 后再下单应当抛出异常 UPDATE newspapers SET stock 0 WHERE id 1;第三步是执行计划检查确认关键报表查询没有全表扫描EXPLAIN SELECT o.order_no, u.username, n.name, oi.quantity, oi.unit_price FROM orders o JOIN order_items oi ON o.id oi.order_id JOIN users u ON o.user_id u.id JOIN newspapers n ON oi.newspaper_id n.id WHERE o.created_at 2025-06-01 AND o.created_at 2025-07-01;最后才是打开前端页面手动点一遍。手动点击只能证明“那条路能走通”自测脚本能证明“这条路每次都能走通且走不通时会告诉我为什么”。我一般把这份脚本放到项目根目录的sql/smoke_test.sql每次改完表结构先跑一遍再改 UI。数据库课程设计的核心从来不是页面多漂亮而是数据模型和业务逻辑的完整性。希望这套方法能帮你少踩几个课设里的坑。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进