☰
图书销售系统数据库设计:从ER建模到事务防超卖的完整实践
2026/10/13 19:04:18 网站建设 项目流程

简介:这是一份面向数据库课程大作业的图书销售管理系统数据库设计文档,基于SQL Server实现,适合计算机专业学生完成课程设计、毕业设计或系统复习数据库建模流程时参考。文档内容完整涵盖七个环节:问题描述与背景、需求分析、概念模型设计、逻辑模型设计、建库录数据、数据库操作以及收获体会。核心部分包括全局与局部E-R图、Books、Inventory、SalesRecords、Customers、Purchases等数据表的字段与外键设计,并提供库存查询、销售统计、数据更新与删除等典型SQL操作实例,同时整理了数据一致性、性能优化等常见问题与对应解决方案,便于读者对照自查。资源共1个docx文档,压缩包约1.66MB,图表与文字配合清晰,可直接作为撰写课程设计报告的参考模板。该资源已有95人学习下载,对正在完成类似图书销售系统数据库作业的同学有较强实用价值。

1. 数据库大作业选图书销售管理系统:及格与优秀的差距在表设计和事务

数据库大作业选图书销售管理系统数据库,几乎是每年数据库课程里出现频率最高的题目之一。很多同学以为它简单,图书、用户、订单各建一张表,写几条增删改查就算完工,结果答辩时被老师追问一句“下单时库存和订单怎么保持一致”,当场卡住。这个题目的真正难点从来不在建表,而在三件事:表怎么切分才能支撑多商品订单、业务逻辑怎么写进数据库而不是堆在外部代码里、并发下单时怎么不把库存扣成负数。这篇笔记按照我实际带过的课程作业方案,把从 ER 设计到建表、统计、事务、以及最后交作业前必须查的坑完整过一遍,新手能照着建库,熟手能直接拿去当答辩前的自查清单。

2. 图书销售管理系统数据库的表结构设计:从需求拆解到可运行的建表SQL

2.1 用户、图书、订单、明细:最少要几张表才不被答辩问倒

图书销售管理系统数据库的核心业务链是“用户下单→生成订单→扣减库存→统计销售”。围绕这条链,最少需要四张表:用户表、图书表、订单表、订单明细表。四张表各自回答一个独立业务问题:用户表回答“谁在买”,图书表回答“卖什么、还有多少库存”,订单表回答“一笔订单整体是什么状态”,订单明细表回答“这笔订单里具体买了哪些书、各几本、成交价多少”。

有人会把库存单独拆一张库存表,也有人把图书分类、出版社单独建表,这都没问题,但要守住一个原则:字段只属于它描述的那个对象。图书定价、库存数量属于图书本身,放图书表;下单时间、订单状态属于订单,放订单表;购买数量、成交单价属于“某订单里的某本书”,放明细表。字段放错表,后面写统计SQL会非常别扭。最常见反例是有人把“购买数量”直接放进订单表,一个订单只买一本书时看不出问题,一次买三本就只能拆成三个订单,订单这个业务概念就被搞乱了。

边界情况也要在设计阶段定清楚。订单状态用 TINYINT 存数字还是用 VARCHAR 存文字?我一般用 TINYINT 加注释,0 待支付、1 已支付、2 已发货、3 已完成、4 已取消。理由是数字占用空间小、以后扩展状态方便,配合字段注释,别人看表结构也能读懂。同理用户角色、图书上下架状态都遵循“短枚举用数字、长描述用文字”的口诀。

2.2 建表语句落地:主键、外键、默认值与字符集

下面这套 SQL 是我做图书销售管理系统数据库时的标准起步模板,适用于 MySQL 5.7 以上,8.0 也可以直接跑。

CREATE DATABASE IF NOT EXISTS book_shop DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE book_shop; -- 用户表:登录账号与角色 CREATE TABLE t_user ( user_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT '密码,演示用MD5摘要即可', role TINYINT NOT NULL DEFAULT 0 COMMENT '0普通用户 1管理员', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间' ) ENGINE=InnoDB COMMENT='用户表'; -- 图书分类表:和图书是一对多关系 CREATE TABLE t_category ( category_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '分类ID', category_name VARCHAR(50) NOT NULL UNIQUE COMMENT '分类名' ) ENGINE=InnoDB COMMENT='图书分类表'; -- 图书表:价格用DECIMAL,库存是当前可售数量 CREATE TABLE t_book ( book_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '图书ID', isbn VARCHAR(20) NOT NULL UNIQUE COMMENT 'ISBN号', title VARCHAR(100) NOT NULL COMMENT '书名', author VARCHAR(50) DEFAULT NULL COMMENT '作者', price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '定价,单位元', stock INT NOT NULL DEFAULT 0 COMMENT '当前库存', category_id INT UNSIGNED DEFAULT NULL COMMENT '分类ID', publish_date DATE DEFAULT NULL COMMENT '出版日期', KEY idx_category (category_id), CONSTRAINT fk_book_category FOREIGN KEY (category_id) REFERENCES t_category(category_id) ) ENGINE=InnoDB COMMENT='图书表'; -- 订单表:对外订单号独立于主键 CREATE TABLE t_order ( order_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '订单ID', order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '对外订单号', user_id INT UNSIGNED NOT NULL COMMENT '下单用户ID', total_amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '订单总金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', KEY idx_user_id (user_id), CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES t_user(user_id) ) ENGINE=InnoDB COMMENT='订单表'; -- 订单明细表:一笔订单对应多行图书 CREATE TABLE t_order_item ( item_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '明细ID', order_id INT UNSIGNED NOT NULL COMMENT '所属订单ID', book_id INT UNSIGNED NOT NULL COMMENT '图书ID', quantity INT NOT NULL DEFAULT 1 COMMENT '购买数量', price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '成交单价,下单时快照', KEY idx_order_id (order_id), KEY idx_book_id (book_id), CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES t_order(order_id), CONSTRAINT fk_item_book FOREIGN KEY (book_id) REFERENCES t_book(book_id) ) ENGINE=InnoDB COMMENT='订单明细表';

这套建表语句里有几个参数是刻意选的,说清楚之后答辩会加分。第一,所有主键用INT UNSIGNED,自增从 1 开始,实际项目数据量不大时完全够用,比 BIGINT 少占一半索引空间。第二,金额一律DECIMAL(10,2)或DECIMAL(12,2),绝对不能 FLOAT,浮点数的二进制近似会让总金额对不上,这点后面第 5 章专门讲。第三,库和表统一utf8mb4,它是 MySQL 里真正能存下中文和特殊字符的字符集,比老的utf8靠谱。第四,order_no建唯一索引,它是给用户看的业务订单号,而order_id只是内部主键,两者分开,避免把内部自增 ID 暴露到页面和日志里。

还有两个细节:图书表的category_id允许为空,因为有些旧书可以不分类;订单表和明细表的关联外键都建了普通索引KEY idx_order_id,这是为了让JOIN不走全表扫描。注意我全程用了ENGINE=InnoDB,图书销售系统的订单并发写和事务都依赖 InnoDB 的行锁与回滚,MyISAM 在这道题里属于错误答案。

2.3 订单明细单独成表的理由:快照价格与多商品订单

订单明细单独成表,不是书本上的教条,而是业务逼出来的。第一,一个订单天然可能包含多种图书,订单表一行存多个书名只能用逗号拼接,统计销量时还得拆字符串,属于自找麻烦;明细表一行一种书,GROUP BY就能算销量。第二,成交价必须是快照。图书价格今天 68 元,明天涨价到 75 元,昨天那笔订单的历史记录仍然应该显示 68 元,否则财务报表和用户订单页都会乱。所以t_order_item.price是下单那一刻从t_book.price复制过来的业务快照,它在表述上独立于当前定价,不算冗余。

3. 把销售业务写进SQL:排行统计、库存预警与下单事务

3.1 图书销售排行与库存预警:两条最常被问的统计SQL

表建好之后,数据库大作业里出现频率最高的两个查询就是“销量排行”和“库存预警”。排名题最容易翻车的地方是忘记过滤订单状态——把已取消的订单也算进销量,数据立刻失真。库存预警则要选定一个阈值,并且允许调用时修改。

-- 1)图书销售排行:按销量取Top10,附带销售金额 SELECT b.book_id, b.title, SUM(oi.quantity) AS sold_qty, SUM(oi.quantity * oi.price) AS sold_amount FROM t_order_item oi JOIN t_book b ON oi.book_id = b.book_id JOIN t_order o ON oi.order_id = o.order_id WHERE o.status IN (1, 2, 3) -- 只统计已支付、已发货、已完成 GROUP BY b.book_id, b.title ORDER BY sold_qty DESC, sold_amount DESC LIMIT 10; -- 2)库存预警:低于指定阈值的图书 SELECT book_id, title, stock FROM t_book WHERE stock <= 20 ORDER BY stock ASC;

第一段查询的WHERE o.status IN (1,2,3)是整条 SQL 的灵魂,它把待支付和已取消的订单从统计口径里剔除。GROUP BY只写了book_id, title,因为这两个字段是确定性依赖,sold_qty和sold_amount是聚合结果,MySQL 的 ONLY_FULL_GROUP_BY 模式下这样的写法才是合规的。第二段查询可以用一个变量代替写死的 20:先把阈值装进@threshold或者在存储过程里当参数传,比每次改 SQL 更符合“系统”的形态。如果老师追问“Top10 里销量相同怎么处理”,在ORDER BY里补sold_amount DESC作为次级排序就是我的标准回答。

3.2 下单事务的写法:库存扣减与订单写入如何保持一致

单张表的增删改查不是数据库大作业的难点,难点在于“一次下单要同时写订单、写明细、扣库存,任何一个环节失败都不能留下半截数据”。这就是事务的用武之地。我推荐用一条存储过程把下单流程封起来,既能在数据库里保证原子性,又便于答辩时现场演示。

DELIMITER // CREATE PROCEDURE sp_create_order( IN p_user_id INT UNSIGNED, IN p_book_id INT UNSIGNED, IN p_quantity INT, IN p_price DECIMAL(10,2), OUT p_order_no VARCHAR(32) ) BEGIN DECLARE v_checked INT DEFAULT 0; -- 事务内任何SQL报错,自动回滚并重新抛出错误 DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 条件更新:库存足够才扣减,返回受影响行数 UPDATE t_book SET stock = stock - p_quantity WHERE book_id = p_book_id AND stock >= p_quantity; SELECT ROW_COUNT() INTO v_checked; -- 受影响行数为0,说明库存不足或图书不存在 IF v_checked = 0 THEN ROLLBACK; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足或图书不存在'; END IF; -- 生成可读订单号:时间戳加随机数 SET p_order_no = CONCAT(DATE_FORMAT(NOW(), '%Y%m%d%H%i%s'), '-', LPAD(FLOOR(RAND()*1000), 4, '0')); INSERT INTO t_order(order_no, user_id, total_amount, status) VALUES (p_order_no, p_user_id, p_quantity * p_price, 0); INSERT INTO t_order_item(order_id, book_id, quantity, price) VALUES (LAST_INSERT_ID(), p_book_id, p_quantity, p_price); COMMIT; END// DELIMITER ;

这段存储过程有四个关键点。EXIT HANDLER FOR SQLEXCEPTION是兜底机制,只要事务里任何语句报错,自动执行ROLLBACK并把错误重新抛出,避免应用层拿到半成品数据。库存扣减用UPDATE ... WHERE stock >= p_quantity的条件更新,这是防超卖的最轻量写法,后面第 4 章会展开对比。ROW_COUNT()拿到的是这次 UPDATE 影响的行数,库存不足时影响 0 行,直接回滚。LAST_INSERT_ID()拿到刚插入订单表的主键,作为明细表的外键,保证两个表的数据在事务内部完成关联。

调用方式很简单:CALL sp_create_order(1, 3, 2, 68.00, @order_no);然后查SELECT @order_no;。有一点必须提醒:这个存储过程只处理单本书下单,如果答辩演示“一个订单买三本不同的书”,需要把它扩展成循环处理明细列表,或者干脆在应用层组装好订单表和明细表数据后,用同一个事务提交——常见做法是 MyBatis 里@Transactional包住三个操作,效果相同。

3.3 视图与触发器:低成本给大作业加分的两个技巧

数据库大作业想要从“能跑”升到“有设计”,视图和触发器是性价比最高的两个工具。视图可以把复杂的多表查询封装成“虚拟表”,触发器则能把库存扣减这样的配套动作自动执行。但两者都要控制使用场合。

-- 视图:每个用户的订单总览,应用层直接SELECT就行 CREATE VIEW v_user_order_summary AS SELECT u.username, COUNT(o.order_id) AS order_count, IFNULL(SUM(o.total_amount), 0) AS total_spent FROM t_user u LEFT JOIN t_order o ON u.user_id = o.user_id GROUP BY u.user_id, u.username; -- 触发器:明细表插入后自动扣库存 DELIMITER // CREATE TRIGGER trg_item_after_insert AFTER INSERT ON t_order_item FOR EACH ROW BEGIN UPDATE t_book SET stock = stock - NEW.quantity WHERE book_id = NEW.book_id; END// DELIMITER ;

视图的这段LEFT JOIN特意保留了没下过单的用户,IFNULL把空值补成 0,这样应用层展示报表时不会出现 NULL。触发器这段AFTER INSERT ON t_order_item的问题是:如果在第 3.2 节存储过程已经手动扣过库存的前提下再加触发器,会造成双重扣减。所以我的建议是二选一——要么所有下单都走存储过程统一扣减,要么依赖触发器兜底、存储过程里只写订单和明细。选择后者的好处是即使将来有人绕过存储过程直接 INSERT 明细,库存也自动扣了;坏处是触发器报错时排查链路更长、不透明,很多生产团队因此禁用触发器。数据库大作业里,明确告诉老师“我选了触发器方案,并且测试过重复插入不会扣两次”,本身就是加分项。

4. 范式、索引与并发:图书销售数据库的取舍课

4.1 该不该冗余:成交价快照与范式之间的平衡

数据库设计课讲第三范式时,老师喜欢拿图书销售举例子:成交价能不能由图书表的价格推导出来?如果能,为什么还要在订单明细表里存一份?这个问题的标准答案是:能推导,但那是在“图书价格永不变化”的假设下。真实业务里价格会调整、会促销、会打折,订单一旦成交,它的价格事实就固定了,不再跟随图书表变化。所以t_order_item.price不是冗余,它是订单明细这个实体自己的属性,叫“业务快照”。

更值得讨论的是t_order.total_amount。它理论上可以由明细表SUM(quantity * price)算出来,存一份属于冗余。我保留它的理由是:查询性能。每次打开订单列表都要聚合明细才能知道总额,数据量上去后会很慢;而且总额在订单生命周期里是一个被频繁查询和展示的字段。代价是必须保证它和明细一致,这个保证就放在第 3.2 节的同一个事务里完成。答辩时可以这样表达:这个冗余是用空间换查询性能,属于刻意设计,一致性由事务保证,而不是无意中造成的重复存储。

4.2 索引加在哪:三个必调的索引参数与EXPLAIN判断

图书销售系统数据库的索引不需要多,需要准。我遵循三条经验:第一,JOIN 的关联列必须有索引,t_order_item.order_id、t_order_item.book_id就是典型;第二,WHERE 过滤频繁的列建索引,t_order.user_id、t_book.category_id属于这类;第三,低基数列别建,比如t_order.status只有 0 到 4 几个值,索引区分度太低,MySQL 优化器大概率放弃使用。

-- 用EXPLAIN看一条关联查询的执行计划 EXPLAIN SELECT o.order_no, oi.book_id, oi.quantity FROM t_order_item oi JOIN t_order o ON oi.order_id = o.order_id WHERE o.user_id = 1;

看 EXPLAIN 结果时,我只看五个关键位置。type是访问类型,出现ALL代表全表扫描,要警惕;ALL出现在驱动表上且表很大时,基本就是慢查询根源。key是实际用到的索引,为 NULL 说明索引没生效。rows是估算扫描行数,数字越小越好。Extra里出现Using filesort时要注意排序字段是否缺索引。最后是key_len,它反映索引用了多少字节,能帮你判断联合索引到底用到了第几个字段。这些参数不要求背下来,答辩时能指着一行说“这里 type 是 ref,说明走了非唯一索引,扫描行数可控”,已经超过多数同学。

联合索引的典型场景是订单明细表经常按“订单+图书”查。如果只建单个idx_order_id,WHERE order_id = ? AND book_id = ?也能用,但只能发挥第一个字段;建(order_id, book_id)联合索引能把两个条件都约束住。需要注意联合索引的字段顺序是决定性的,查询里等值条件放前面、范围条件放后面,是排字段序的核心口诀。

4.3 并发下单会超卖:悲观锁与条件更新的两种解法

“两个用户同时抢最后一本书”是这道大作业里最容易被追问的场景。库存是 1,两个请求同时读到库存 1,同时做 UPDATE 减 1,库存变成 -1,这就是超卖。解法有两类,第一类是悲观锁,第二类是第 3.2 节已经用过的条件更新。

-- 方案A:悲观锁,读的时候就锁住这一行 START TRANSACTION; SELECT stock FROM t_book WHERE book_id = 1 FOR UPDATE; -- 应用层检查stock,若>=购买数量则继续 UPDATE t_book SET stock = stock - 1 WHERE book_id = 1; COMMIT;
-- 方案B:乐观条件更新,一次性完成检查和扣减 UPDATE t_book SET stock = stock - 1 WHERE book_id = 1 AND stock >= 1; -- 若ROW_COUNT()为0,说明库存不足,需要回滚整个下单事务

方案 A 的FOR UPDATE让第二个事务必须等待第一个事务提交后才能读这一行,逻辑直白,死锁风险在于:一个订单要锁多本书时,如果两个事务锁书的顺序不一致,就会互相等待。方案 B 不加锁,把“库存是否充足”的判断压进 UPDATE 的 WHERE 条件里,受影响行数为 0 就说明抢购失败,并发能力比悲观锁强很多。我的建议是数据库大作业用方案 B,因为它代码更短、效果更清晰,答辩解释“利用数据库行锁原子性保证扣减不超卖”也更有说服力。如果你选悲观锁,务必记住“多本图书按 book_id 升序加锁”,这是避免死锁的常见做法。

5. 图书销售系统数据库的常见问题与排查清单

这一章是交作业前必过的自查清单,每一条都是我见过真实翻车的记录,按“现象→原因→解决”的格式整理。

5.1 中文乱码:字符集不一致导致数据错乱

现象:往表里插入“数据库大作业”,查询出来变成“???”,或者 Navicat、命令行和网页显示的三套结果互不相同。

原因:建库、建表、客户端连接三处字符集不一致。常见于库是 utf8mb4,但客户端连接用的 character_set_client 还是 latin1;或者建表时没指定字符集,继承了服务器默认的旧字符集。

解决:先执行SHOW VARIABLES LIKE 'character_set%';看三个变量,再统一。建库时就用第 2 节的DEFAULT CHARACTER SET utf8mb4;命令行连接后执行一次SET NAMES utf8mb4;;如果是 JDBC 连接,URL 里加characterEncoding=utf8,注意 MySQL 驱动里 utf8 实际指的就是 utf8mb4。已经建错的表可以用ALTER TABLE t_book CONVERT TO CHARACTER SET utf8mb4;补救,但要把表里现有数据备份出来再转换,防止数据被截断。

5.2 外键约束导致插入失败:插入顺序与级联规则的坑

现象:向t_order_item插入明细时,报错Cannot add or update a child row: a foreign key constraint fails;删除一本被订单引用过的图书时,报错Cannot delete or update a parent row。

原因:外键要求子表的关联值必须存在于父表主键中。插入订单明细时,所引用的order_id或book_id在父表里不存在;删除图书时,它已经被订单明细引用,默认的RESTRICT规则拒绝删除。

解决:第一,牢记插入顺序是“先父后子”——先插用户再插订单、先插订单再插明细,删除顺序反过来。第二,用SHOW CREATE TABLE t_order_item;确认外键确实存在,很多同学发现外键根本没建成功,就是因为建表时子表先于父表创建。第三,明确删除策略:图书被删除时,历史订单里的明细同样需要保留,所以fk_item_book不要用ON DELETE CASCADE,否则删一本下架书会把整段销售历史一起删掉;正确做法是给图书加is_deleted状态字段做逻辑删除。

5.3 金额算不对:FLOAT精度丢失要用DECIMAL

现象:下单总金额显示成270.22999999999996,或者用 Java 的 double 计算后再写入数据库,多出一分钱;月度销售汇总和明细逐笔相加结果不一致。

原因:FLOAT 和 DOUBLE 是二进制浮点近似存储,0.1 在二进制里是无限循环小数,任何涉及浮点的乘法累加都会累积误差。图书定价和订单金额是货币数据,不能用近似类型。

解决:建表全部用DECIMAL(10,2),应用层计算也要用 BigDecimal 而不是 double;SQL 里的累加直接用SUM(quantity * price),让数据库全程保持十进制运算。如果线上已经用了 FLOAT,ALTER TABLE t_book MODIFY price DECIMAL(10,2) NOT NULL DEFAULT 0;可以改,但改之前必须核对历史数据,曾有项目改完发现历史订单金额多出 0.01 元而无法向财务交代。

5.4 关联查询越来越慢:索引失效与N+1查询

现象:测试时几千条数据查询很快,导入几万条订单后,排行榜 SQL 从十几毫秒涨到几秒;或者应用层查订单列表时,每行订单都要额外查一次明细库。

原因:两类典型。一是 JOIN 的关联列没有索引,t_order_item.order_id缺索引就会全表扫描;二是应用层循环里逐条查数据库,也就是 N+1 查询,订单 100 笔、明细每笔 1 次,就是 100 次额外查询。

解决:先给所有外键列补索引,再执行第 4 节的 EXPLAIN 确认type不再是ALL。N+1 问题在 SQL 侧解决,比如显示订单列表时,先一次查出订单,再SELECT * FROM t_order_item WHERE order_id IN (1,2,3,...)批量查出全部明细,到应用层做一次内存组装。数据库大作业的答辩现场遇到“查询慢”问题,能先喊出“看 EXPLAIN 的 type 和 rows”,比盲目优化高到不知道哪里去了。

6. 把大作业做厚:造测试数据、验一致性、备答辩

表设计再合理,没有数据和校验脚本,答辩时老师一句“你怎么证明它是对的”就能让你露怯。我的习惯是交作业前补三件事:批量造数据、一致性校验、口径说明。

批量造数据用一条递归 CTE 在 MySQL 8.0 里搞定,5.7 环境就用存储过程循环,效果一样:

INSERT INTO t_book(isbn, title, author, price, stock) WITH RECURSIVE seq AS ( SELECT 1 AS n UNION ALL SELECT n + 1 FROM seq WHERE n < 50 ) SELECT CONCAT('978-', LPAD(n, 12, '0')), CONCAT('测试图书', n), '测试作者', ROUND(RAND() * 80 + 10, 2), FLOOR(RAND() * 100 + 1) FROM seq;
-- 一致性校验1:订单总额必须等于明细行金额合计 SELECT o.order_id, o.total_amount, SUM(oi.quantity * oi.price) AS calc_amount FROM t_order o JOIN t_order_item oi ON o.order_id = oi.order_id GROUP BY o.order_id, o.total_amount HAVING o.total_amount <> calc_amount; -- 一致性校验2:任何一本书的库存都不能为负数 SELECT book_id, title, stock FROM t_book WHERE stock < 0;

这两条校验 SQL 是你答辩时的护身符:第一条证明事务写入正确,第二条证明并发防超卖生效。两条查询都返回空结果,就是“系统正确”的最有力证据。备答辩时再准备一段 30 秒的陈述:说清楚四张表各管什么、成交价为什么用快照、下单事务怎么保证原子性、并发库存怎么防超卖,这四句话对应第 2 到第 4 章的内容,足够应对 80% 的追问。

最后说一个我的教训:当年交类似作业前只测了单用户流程,没测并发,现场演示时模拟两个用户同时下单同一本书,库存直接变成 -1,老师当时就点了数据不一致的问题。后来我把所有下单流程都改成条件更新加影响行数校验,再没出现过负库存。数据库大作业的价值不在于 SQL 写得花哨,而在于你能用数据库自己的机制解决一个真实的业务问题,然后把解决过程讲清楚。希望帮到你。

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

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

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

立即咨询