简介:这份资源是面向高校数据库课程大作业场景的图书销售管理系统数据库设计文档,适合正在完成数据库课程设计、需要参考完整设计流程的学生,以及希望巩固SQL Server建模与查询实践的初学者。压缩包内仅含1个docx文件,整体约1.66MB,以图文混排方式呈现项目全过程。文档围绕图书信息管理、库存管理、销售管理与客户关系管理四大模块展开,依次覆盖问题描述、需求分析、概念模型设计、逻辑模型设计、建库与数据录入、数据库操作以及收获体会,并配有全局与局部E-R图、用例图和数据流图,给出Books、Inventory、SalesRecords、Customers、Purchases等表结构及外键约束说明。读者可据此获得一套可直接借鉴的数据库大作业方案,理解从需求到建表、从查询更新到数据一致性处理的完整思路,同时参考其中遇到的问题与解决方案,减少自行摸索的成本。目前已有95人学习。
1. 图书销售管理系统数据库:从需求到建表的完整落地路径
很多同学做数据库大作业时,第一反应是打开客户端直接建表,结果写到一半发现订单表和图书表对不上,退款逻辑没地方放,库存扣减和销售统计互相打架。图书销售管理系统数据库这个题目看着简单,实际上是一个典型的「麻雀虽小五脏俱全」的 OLTP 场景:它同时涉及多对多关系、状态机、事务边界和聚合查询,几乎把关系型数据库的核心知识点都串了一遍。我带过几届课程设计,翻车最多的不是 SQL 写不出来,而是一开始就没想清楚实体之间的关系和字段的约束。这篇笔记按「需求拆解 → 表结构设计 → 约束与索引 → 核心查询 → 事务与并发 → 避坑」的顺序走一遍,目标是让你照着能建出一套能跑、能查、能扛住基本并发的小型销售库。适合正在做课程设计、想拿一个完整案例练手,或者想补一补数据库落地经验的同学。
2. 需求拆解与实体关系:先画清楚再动手建表
2.1 从业务动作反推实体,而不是从表名开始
做图书销售管理系统数据库,最容易犯的错是先想「我要建哪些表」,然后凭感觉列一堆:图书表、用户表、订单表、分类表……列完发现订单里到底存不存图书价格、库存放在哪张表、一个订单能不能买多本书,全都没定。正确的顺序是从业务动作反推实体。
把系统里会发生的事情列出来:用户注册、浏览图书、加入购物车、下单、支付、发货、收货、评价、退货。每一个动作背后都对应一个实体或一组关系。比如「下单」这个动作,它产生一条订单记录,同时订单里包含多本书,每本书有数量和小计,这就自然推导出订单主表和订单明细表两张表。再比如「浏览图书」需要图书有分类,一个分类下有多本书,一本书也可能属于多个分类(比如「计算机」和「教材」),这就是典型的多对多,需要一张中间表。
我一般会先在纸上画三样东西:实体清单、实体之间的关系、每个关系的基数。实体清单大概长这样:用户、图书、分类、订单、订单明细、购物车、评价、库存变更记录。关系上,用户和订单是一对多,订单和订单明细是一对多,图书和订单明细是一对多,图书和分类是多对多,用户和评价是一对多,图书和评价是一对多。把这些关系画成 ER 图之后,表结构基本就浮出来了。
这里有个经验:不要急着把「购物车」和「订单」合并。购物车是临时状态,订单是持久状态,两者的生命周期和约束完全不同。购物车可以随便改数量、删条目,订单一旦生成就不能随便动,否则对账会出问题。分开建表,后面做状态流转会清爽很多。
2.2 用一张表把实体和关系落成字段
下面这张表是我常用的实体字段梳理模板,把每个实体的关键属性、类型、约束和说明列清楚,建表时直接照着写 DDL 就行。
| 实体 | 关键字段 | 类型建议 | 约束 | 说明 |
|---|---|---|---|---|
| 用户 | user_id | BIGINT | PK, AUTO_INCREMENT | 主键 |
| 用户 | username | VARCHAR(50) | UNIQUE, NOT NULL | 登录名 |
| 用户 | password_hash | CHAR(60) | NOT NULL | 存哈希不存明文 |
| 用户 | phone | VARCHAR(20) | UNIQUE | 联系方式 |
| 用户 | created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | 注册时间 |
| 图书 | book_id | BIGINT | PK, AUTO_INCREMENT | 主键 |
| 图书 | isbn | CHAR(13) | UNIQUE, NOT NULL | 国际标准书号 |
| 图书 | title | VARCHAR(200) | NOT NULL | 书名 |
| 图书 | author | VARCHAR(100) | NOT NULL | 作者 |
| 图书 | price | DECIMAL(10,2) | NOT NULL, CHECK(price>=0) | 定价 |
| 图书 | stock | INT | NOT NULL, DEFAULT 0 | 库存 |
| 图书 | status | TINYINT | DEFAULT 1 | 1 上架 0 下架 |
| 分类 | category_id | INT | PK, AUTO_INCREMENT | 主键 |
| 分类 | name | VARCHAR(50) | UNIQUE, NOT NULL | 分类名 |
| 图书分类 | book_id | BIGINT | FK | 联合主键 |
| 图书分类 | category_id | INT | FK | 联合主键 |
| 订单 | order_id | BIGINT | PK, AUTO_INCREMENT | 主键 |
| 订单 | user_id | BIGINT | FK, NOT NULL | 下单用户 |
| 订单 | total_amount | DECIMAL(12,2) | NOT NULL | 订单总额 |
| 订单 | status | TINYINT | NOT NULL | 0 待支付 1 已支付 2 已发货 3 已完成 4 已取消 |
| 订单 | created_at | DATETIME | NOT NULL | 下单时间 |
| 订单明细 | detail_id | BIGINT | PK, AUTO_INCREMENT | 主键 |
| 订单明细 | order_id | BIGINT | FK, NOT NULL | 所属订单 |
| 订单明细 | book_id | BIGINT | FK, NOT NULL | 所购图书 |
| 订单明细 | quantity | INT | NOT NULL, CHECK(quantity>0) | 数量 |
| 订单明细 | unit_price | DECIMAL(10,2) | NOT NULL | 下单时单价 |
| 评价 | review_id | BIGINT | PK, AUTO_INCREMENT | 主键 |
| 评价 | user_id | BIGINT | FK | 评价人 |
| 评价 | book_id | BIGINT | FK | 被评图书 |
| 评价 | rating | TINYINT | CHECK(rating BETWEEN 1 AND 5) | 评分 |
| 评价 | content | TEXT | 评价内容 |
这张表里有两个字段值得单独说。一个是订单明细里的unit_price,它和图书表的price是两回事。图书的price会变,但订单一旦生成,里面的价格必须冻结,否则用户三个月后翻订单会发现金额对不上。另一个是订单的status,用 TINYINT 存状态码比用字符串省空间,也方便加索引,但一定要在文档里写清楚每个数字代表什么,不然过两周自己都忘了。
2.3 建表 DDL 与字符集、存储引擎的选择
字段定完就可以写 DDL 了。下面这段是核心几张表的建表语句,用 MySQL 8.0 的语法,其他数据库稍微改改也能用。
-- 用户表:登录名和手机号唯一,密码只存哈希 CREATE TABLE `user` ( `user_id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password_hash` CHAR(60) NOT NULL, `phone` VARCHAR(20) DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`user_id`), UNIQUE KEY `uk_username` (`username`), UNIQUE KEY `uk_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- 图书表:价格用 DECIMAL,库存加非负约束 CREATE TABLE `book` ( `book_id` BIGINT NOT NULL AUTO_INCREMENT, `isbn` CHAR(13) NOT NULL, `title` VARCHAR(200) NOT NULL, `author` VARCHAR(100) NOT NULL, `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 1, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`book_id`), UNIQUE KEY `uk_isbn` (`isbn`), KEY `idx_title` (`title`), CONSTRAINT `chk_price` CHECK (`price` >= 0), CONSTRAINT `chk_stock` CHECK (`stock` >= 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- 订单表:状态字段加索引,方便按状态筛选 CREATE TABLE `orders` ( `order_id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `total_amount` DECIMAL(12,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`order_id`), KEY `idx_user_status` (`user_id`, `status`), KEY `idx_created` (`created_at`), CONSTRAINT `fk_order_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- 订单明细:联合索引覆盖按订单查明细的场景 CREATE TABLE `order_detail` ( `detail_id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `book_id` BIGINT NOT NULL, `quantity` INT NOT NULL, `unit_price` DECIMAL(10,2) NOT NULL, PRIMARY KEY (`detail_id`), KEY `idx_order` (`order_id`), KEY `idx_book` (`book_id`), CONSTRAINT `fk_detail_order` FOREIGN KEY (`order_id`) REFERENCES `orders` (`order_id`), CONSTRAINT `fk_detail_book` FOREIGN KEY (`book_id`) REFERENCES `book` (`book_id`), CONSTRAINT `chk_qty` CHECK (`quantity` > 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;这段 DDL 里有几个参数需要解释。存储引擎选 InnoDB,因为只有它支持事务和行级锁,图书销售系统里扣库存、生成订单必须在一个事务里完成,MyISAM 做不到。字符集用 utf8mb4,因为书名和评价里可能出现生僻字甚至 emoji,utf8 三字节存不下。排序规则用utf8mb4_0900_ai_ci,这是 MySQL 8.0 默认的,大小写不敏感,适合书名和用户名的比较。外键约束建议加上,虽然有些互联网公司为了性能会去掉外键在应用层保证,但课程设计阶段加上外键能帮你提前发现数据不一致的问题,而且 InnoDB 的外键开销在数据量不大时完全可以接受。
订单表的名字用了orders而不是order,因为order是 SQL 关键字,虽然加反引号能绕过去,但后面写查询时容易忘,不如直接换个名字省心。这个坑我踩过,当时写了十几条查询才发现有一半没加反引号,报错报得怀疑人生。
3. 约束、索引与事务:让数据自己守住底线
3.1 用约束把脏数据挡在门外
表建好只是第一步,真正决定这套库能不能用的,是约束有没有设到位。约束分四层:主键、外键、唯一键、CHECK。主键保证每行可定位,外键保证引用完整性,唯一键防止重复,CHECK 拦住明显不合法的值。
图书表的price加了CHECK (price >= 0),库存加了CHECK (stock >= 0)。这两个约束看起来简单,但能挡住很多手滑操作。比如批量导入图书数据时,如果 CSV 里有一行价格是负数,没有 CHECK 的话它会直接进库,后面做销售统计时总额莫名其妙变少,排查半天才发现是脏数据。有了 CHECK,导入直接报错,定位到那一行改掉就行。
订单明细的quantity加了CHECK (quantity > 0),因为数量为 0 或负数的明细没有意义。评价表的rating加了CHECK (rating BETWEEN 1 AND 5),防止有人传 0 分或 6 分进来。
注意:MySQL 8.0.16 之前的版本会忽略 CHECK 约束,只做语法解析不实际执行。如果你的环境是 5.7 或更早,CHECK 写了等于没写,需要在应用层或者用触发器来兜底。建库前先
SELECT VERSION();确认一下版本。
外键约束在课程设计里建议保留,但要知道它的行为。默认情况下,父表删除一条被引用的记录会报错,这能防止误删。如果确实需要级联删除,比如删订单时自动删明细,可以在外键上加ON DELETE CASCADE。但级联删除要慎用,删用户时如果级联删订单,再级联删明细,一条DELETE可能悄无声息地清掉几百行数据,没有后悔药。我一般只在订单和明细这种强从属关系上用级联,用户和订单之间不加,删用户前先检查有没有未完成订单。
3.2 索引不是越多越好,要按查询模式来
索引这块,新手容易走两个极端:要么一个索引不加,查询慢得像蜗牛;要么每个字段都加索引,写入慢得像蜗牛。正确的做法是先想清楚系统里最频繁的查询是什么,再针对性地加。
图书销售系统里最常见的查询有这么几类:按书名模糊搜书、按分类列书、按用户查订单、按订单查明细、按状态筛订单、按时间统计销量。对应的索引策略是:
- 书名搜索用
idx_title,但要注意LIKE '%关键词%'这种前置通配符用不上索引,只有LIKE '关键词%'才能走。如果确实需要全文搜索,MySQL 可以用 FULLTEXT 索引,或者上专门的搜索引擎,课程设计阶段用LIKE '关键词%'就够了。 - 分类列书通过中间表
book_category关联,中间表上(category_id, book_id)建联合主键,book_id上再单独建一个索引,这样两个方向的查询都能走索引。 - 按用户查订单用
idx_user_status (user_id, status),这是一个联合索引,最左前缀是user_id,所以WHERE user_id = ?和WHERE user_id = ? AND status = ?都能用上。如果只查status不带user_id,这个索引就用不上,需要单独在status上建索引,但状态值只有几个,区分度太低,单独建索引意义不大,不如不建。 - 按时间统计用
idx_created,做日报、月报时按created_at范围扫描。
索引的代价是写入变慢和占空间。每加一个索引,插入和更新时都要多维护一棵 B+ 树。课程设计的数据量不大,多几个索引感知不明显,但养成「按查询建索引」的习惯,以后做真实项目会少踩很多坑。
3.3 事务边界:下单和扣库存必须绑在一起
图书销售系统里最核心的事务是下单。一个下单动作包含三步:插入订单主表、插入订单明细、扣减图书库存。这三步必须在一个事务里完成,要么全成功,要么全回滚。如果插入订单成功但扣库存失败,就会出现「有订单没扣库存」的超卖;如果扣库存成功但插入订单失败,就会出现「扣了库存没订单」的少卖。两种都是事故。
下面是一个典型的下单事务,用 Python 的 pymysql 演示:
import pymysql def create_order(conn, user_id, items): """ items: [{'book_id': 1, 'quantity': 2}, ...] 返回订单 ID,失败抛异常 """ cursor = conn.cursor() try: # 开启事务,关闭自动提交 conn.begin() total = 0 # 先锁定涉及的图书行,防止并发扣减 book_ids = [it['book_id'] for it in items] placeholders = ','.join(['%s'] * len(book_ids)) cursor.execute( f"SELECT book_id, price, stock FROM book WHERE book_id IN ({placeholders}) FOR UPDATE", book_ids ) books = {row[0]: {'price': row[1], 'stock': row[2]} for row in cursor.fetchall()} # 校验库存并计算总额 for it in items: b = books.get(it['book_id']) if b is None: raise ValueError(f"图书 {it['book_id']} 不存在") if b['stock'] < it['quantity']: raise ValueError(f"图书 {it['book_id']} 库存不足") total += b['price'] * it['quantity'] # 插入订单主表 cursor.execute( "INSERT INTO orders (user_id, total_amount, status) VALUES (%s, %s, 0)", (user_id, total) ) order_id = cursor.lastrowid # 插入明细并扣库存 for it in items: cursor.execute( "INSERT INTO order_detail (order_id, book_id, quantity, unit_price) VALUES (%s, %s, %s, %s)", (order_id, it['book_id'], it['quantity'], books[it['book_id']]['price']) ) cursor.execute( "UPDATE book SET stock = stock - %s WHERE book_id = %s", (it['quantity'], it['book_id']) ) conn.commit() return order_id except Exception as e: conn.rollback() raise e finally: cursor.close()这段代码有几个关键点。第一,SELECT ... FOR UPDATE在事务里锁住了涉及的图书行,其他并发下单请求会在这里排队,避免两个事务同时读到相同的库存然后都扣成功。第二,conn.begin()显式开启事务,配合commit和rollback保证原子性。第三,库存扣减用stock = stock - %s而不是先查再算再写,这样即使没有行锁,单条 UPDATE 也是原子的,但为了校验库存是否足够,还是需要先锁行读一次。
参数上,FOR UPDATE的锁在事务提交或回滚后释放,所以事务里的操作要尽量快,不要在锁住行之后去做网络请求或者等用户输入,否则会拖垮并发。如果业务允许,可以把库存校验和扣减合并成一条UPDATE book SET stock = stock - %s WHERE book_id = %s AND stock >= %s,根据影响行数判断是否成功,这样能少一次查询,但拿不到单价,需要另外查一次价格。
4. 核心查询与统计:把业务问题翻译成 SQL
4.1 多表关联查询的写法与执行顺序
图书销售系统里绕不开多表关联。比如「查某个用户的所有订单及每笔订单的图书明细」,需要 orders、order_detail、book 三张表关联。再比如「查某个分类下销量前十的书」,需要 book、book_category、order_detail 三张表关联再加聚合。
先看一个基础的多表查询:查用户 1001 的订单列表,每笔订单显示订单号、总额、状态和包含的书名。
SELECT o.order_id, o.total_amount, o.status, o.created_at, GROUP_CONCAT(b.title SEPARATOR ' / ') AS books FROM orders o JOIN order_detail od ON od.order_id = o.order_id JOIN book b ON b.book_id = od.book_id WHERE o.user_id = 1001 GROUP BY o.order_id, o.total_amount, o.status, o.created_at ORDER BY o.created_at DESC;这条 SQL 的逻辑是:先从 orders 里筛出用户 1001 的订单,然后通过 order_id 关联到明细,再通过 book_id 关联到图书,最后按订单分组,用GROUP_CONCAT把同一订单里的书名拼成一列。GROUP BY后面要把 SELECT 里所有非聚合列都列上,这是 MySQL 的ONLY_FULL_GROUP_BY模式的要求,也是 SQL 标准的要求。如果只写GROUP BY o.order_id,在严格模式下会报错。
执行顺序上,数据库一般先做 JOIN 再做 WHERE 再做 GROUP BY,但优化器可能会调整。看执行计划用EXPLAIN,重点看type列是不是ref或range,key列有没有用上索引,rows列扫描行数大不大。如果type是ALL,说明全表扫描了,数据量一大就慢。
4.2 聚合统计:销量、销售额、复购率
统计类查询是图书销售系统的重头戏。常见的指标有:每本书的销量和销售额、每个分类的销售占比、每个月的销售趋势、用户的复购率。
先看每本书的销量和销售额:
SELECT b.book_id, b.title, SUM(od.quantity) AS total_qty, SUM(od.quantity * od.unit_price) AS total_sales FROM order_detail od JOIN orders o ON o.order_id = od.order_id JOIN book b ON b.book_id = od.book_id WHERE o.status IN (1, 2, 3) -- 只统计已支付及之后的订单 GROUP BY b.book_id, b.title ORDER BY total_sales DESC LIMIT 20;这里WHERE o.status IN (1, 2, 3)很关键。待支付(0)和已取消(4)的订单不能算进销量,否则统计出来的销售额会虚高。这个条件放在 JOIN 之后、GROUP BY 之前,先过滤再聚合,比先聚合再 HAVING 过滤效率高。
再看复购率,定义是「下过两单及以上的用户数 / 总下单用户数」:
SELECT COUNT(*) AS total_buyers, SUM(CASE WHEN order_cnt >= 2 THEN 1 ELSE 0 END) AS repeat_buyers, ROUND(SUM(CASE WHEN order_cnt >= 2 THEN 1 ELSE 0 END) / COUNT(*), 4) AS repeat_rate FROM ( SELECT user_id, COUNT(*) AS order_cnt FROM orders WHERE status IN (1, 2, 3) GROUP BY user_id ) t;子查询先按用户分组算出每人有效订单数,外层再用CASE WHEN统计复购人数。这种「先聚合再统计」的嵌套写法在报表里很常见,注意子查询要起别名,MySQL 要求派生表必须有别名。
4.3 分页、排序与慢查询的初步定位
列表页离不开分页。MySQL 用LIMIT offset, size,但 offset 很大时性能会急剧下降,因为数据库要扫描并丢弃前 offset 行。比如LIMIT 100000, 20会先扫 100020 行再扔掉前 100000 行。优化方式是用游标分页,记住上一页最后一条的 ID,下一页用WHERE book_id > last_id ORDER BY book_id LIMIT 20。这种方式要求排序字段唯一且有序,适合按主键或时间戳翻页。
排序字段一定要有索引,否则每次排序都要建临时表。ORDER BY created_at DESC如果 created_at 上有索引,可以直接走索引顺序读,不用额外排序。如果排序字段和过滤字段能组成联合索引,效果更好,比如idx_user_status (user_id, status)配合WHERE user_id = ? ORDER BY status就能避免排序。
慢查询定位先用EXPLAIN看执行计划,再看SHOW PROFILE或者开启慢查询日志。课程设计的数据量小,一般不会遇到真正的慢查询,但养成看执行计划的习惯很重要。我一般会重点看三个地方:type是不是ALL,rows是不是接近全表行数,Extra里有没有Using filesort或Using temporary。有这两个说明排序或分组没走索引,数据量上来就会拖慢。
5. 避坑与排查:那些建表时没人告诉你的事
5.1 坑一:用浮点数存金额,统计时对不上账
现象:订单总额和明细加总差几分钱,或者销售额统计出现0.30000000000000004这种诡异数字。
原因:金额字段用了 FLOAT 或 DOUBLE,浮点数在二进制里无法精确表示十进制小数,累加时误差会放大。
解决:金额一律用 DECIMAL。DECIMAL(10,2) 表示总共 10 位,其中 2 位小数,能精确存储到分。如果涉及更细的精度,比如某些场景要算到厘,用 DECIMAL(12,4)。已经建了 FLOAT 字段的,用ALTER TABLE ... MODIFY COLUMN改成 DECIMAL,改之前先备份数据,因为转换过程中可能有舍入。
5.2 坑二:库存扣成负数,超卖了几十本
现象:某个热门书做活动,库存显示 -15,订单却都成功了。
原因:扣库存时没有校验stock >= quantity,或者校验和扣减不在同一个事务里,两个并发请求都读到库存 1,都判断够,然后都扣,库存变成 -1。
解决:把校验和扣减合并到一条 UPDATE 里,用影响行数判断是否成功:
UPDATE book SET stock = stock - 2 WHERE book_id = 100 AND stock >= 2;如果返回的影响行数是 0,说明库存不足,回滚事务。这条语句在 InnoDB 里会对匹配的行加锁,并发请求会排队,不会出现两个事务同时扣同一行的情况。如果业务需要先读价格再扣库存,那就用SELECT ... FOR UPDATE锁行,但锁的范围要尽量小,只锁涉及的图书。
5.3 坑三:外键导致删数据删不掉,报 1451 错误
现象:想删一本已经下架的书,报Cannot delete or update a parent row: a foreign key constraint fails。
原因:这本书在 order_detail 里有引用,外键约束阻止删除。
解决:图书销售系统里,已经产生过订单的图书不应该物理删除,而是把status置为 0(下架),保留历史数据。如果确实要删,先确认没有未完成的订单引用它,然后把外键改成ON DELETE SET NULL或者先删明细再删图书。但更推荐的做法是逻辑删除,加一个is_deleted字段,查询时过滤掉。物理删除会破坏历史订单的完整性,用户翻旧订单发现书没了,体验很差。
5.4 坑四:字符集不统一,中文书名变成问号
现象:插入中文书名后,查出来是???或者乱码。
原因:建库、建表、连接三处的字符集不一致。比如库是 utf8mb4,连接是 latin1,写入时就会丢字符。
解决:统一用 utf8mb4。建库时CREATE DATABASE db_name DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;,建表时也指定,连接串里加charset='utf8mb4'。已经乱码的数据很难恢复,因为原始字节已经丢了,只能重新导入。所以建库第一步就把字符集定死,别等出问题再改。
5.5 坑五:事务没提交,数据「消失」了
现象:代码里插入了数据,程序里查得到,换个客户端查不到,重启程序后数据没了。
原因:连接开启了事务但忘了commit,数据只在当前事务里可见,事务回滚或连接断开后就丢了。
解决:检查连接的autocommit设置。pymysql 默认autocommit=False,需要显式commit。如果用了连接池,要确保每次操作后提交或回滚,否则连接归还池里时事务还挂着,下一个请求可能读到脏数据。我一般会在封装的执行函数里统一处理:正常结束commit,异常rollback,finally里关闭游标。
6. 进阶技巧:用视图和存储过程把复杂逻辑收口
6.1 用视图封装高频关联查询
前面写的多表关联查询,如果每个页面都手写一遍,容易写错也难维护。可以把常用的关联逻辑做成视图。比如「订单详情视图」把订单、明细、图书、用户的信息拼在一起:
CREATE VIEW v_order_detail AS SELECT o.order_id, o.user_id, u.username, o.status, o.total_amount, o.created_at, b.book_id, b.title, od.quantity, od.unit_price, od.quantity * od.unit_price AS subtotal FROM orders o JOIN user u ON u.user_id = o.user_id JOIN order_detail od ON od.order_id = o.order_id JOIN book b ON b.book_id = od.book_id;视图的好处是查询简单,SELECT * FROM v_order_detail WHERE order_id = 1001就能拿到所有信息。但视图不存数据,每次查都执行底层 SQL,性能没有提升,只是封装了复杂度。另外视图在 MySQL 里不能加索引,如果底层查询慢,视图也慢。适合读多写少、逻辑固定的场景。
6.2 用存储过程处理月度统计
月度销售报表需要按月份分组统计,用存储过程可以把参数和逻辑封装起来,调用时只传月份:
DELIMITER // CREATE PROCEDURE sp_monthly_sales(IN p_year INT, IN p_month INT) BEGIN SELECT b.book_id, b.title, SUM(od.quantity) AS qty, SUM(od.quantity * od.unit_price) AS sales FROM order_detail od JOIN orders o ON o.order_id = od.order_id JOIN book b ON b.book_id = od.book_id WHERE o.status IN (1, 2, 3) AND YEAR(o.created_at) = p_year AND MONTH(o.created_at) = p_month GROUP BY b.book_id, b.title ORDER BY sales DESC; END // DELIMITER ;调用时CALL sp_monthly_sales(2025, 3);就能拿到三月的销售排行。存储过程适合逻辑固定、调用频繁的统计场景,但调试麻烦,改逻辑要重新部署。课程设计里用不用都行,知道有这么个东西,面试被问到能说清楚就行。
6.3 验证数据一致性的几个自查 SQL
系统跑起来之后,怎么知道数据有没有问题?我一般会跑几条自查 SQL。第一条查订单总额和明细加总是否一致:
SELECT o.order_id, o.total_amount, SUM(od.quantity * od.unit_price) AS detail_sum FROM orders o JOIN order_detail od ON od.order_id = o.order_id GROUP BY o.order_id, o.total_amount HAVING o.total_amount <> SUM(od.quantity * od.unit_price);第二条查库存有没有负数:
SELECT book_id, title, stock FROM book WHERE stock < 0;第三条查有没有孤儿明细(明细的订单不存在):
SELECT od.detail_id FROM order_detail od LEFT JOIN orders o ON o.order_id = od.order_id WHERE o.order_id IS NULL;这三条查出来都是空,说明基本的数据完整性没问题。建议在每次批量操作后跑一遍,比事后对账省事得多。
做这套库的过程中,我最大的教训是:别急着写代码,先把实体关系和状态流转在纸上画清楚。我见过太多同学表建到一半发现少了个字段,回头改 DDL,结果外键、索引、已有数据全要跟着动,改到最后自己都乱了。另一个习惯是,任何涉及金额和库存的操作,先问自己「如果这一步失败了,数据会怎样」,想清楚回滚路径再动手。希望帮到你。
本文还有配套的精品资源,点击获取