简介:《超市管理系统》课程设计报告是一份完整的数据库大作业,面向计算机相关专业学生,用于解决小型超市销售、库存、人员管理的信息化问题,可作期末项目参考。系统基于C语言、MySQL与Visual Studio 2013开发,借助Navicat管理数据库,按顾客、员工、管理员三类角色设计:顾客可搜索商品信息,员工管理库存与个人信息,管理员登录后维护员工信息并查看销售情况;内容覆盖需求分析、E-R图、数据库表结构及三类模块实现流程。整套资源打包为1个PDF文件,大小约555KB,阅读和打印方便;已有3641人浏览学习。文档中给出了员工、商品、货架、进货、日销售量等基本表设计,结合MFC说明了界面优化思路,并剖析了mysql_real_connect、mysql_query、mysql_store_result等关键API的使用,同时总结了主外键选择、CString类型、并发访问未实现等实践问题,便于读者快速梳理开发思路、避开常见坑点。
1. 数据库大作业里的超市管理系统:及格线不在页面,在数据设计
数据库课程设计里,“超市管理系统”是出现频率最高的题目之一。大多数小组把精力花在页面和报表样式上,数据库却只是几张能插数据的表:价格可以随便改、库存负了照样开单、退货的商品在库存里没有任何痕迹。这套大作业的及格线从来不是“跑得起来”,而是设计文档和实现自洽——数据库部分通常占一半以上分值。下面的内容按从需求梳理、ER 图、建表到存储过程与文档撰写的顺序,把一份能交差的超市管理系统数据库设计拆开讲。适合正在做课程设计、并且想在设计环节少返工的人。
2. 把超市业务拆成实体:三员四流与 ER 图实操
一个老练的数据库设计者接手这个题目,不会先打开软件建表,而是先回答一个问题:这个超市系统“管”什么。“管”字决定了边界。业务上至少涉及进货、上架、销售、退货、库存盘点和供应商对账,参与的人有采购员、收银员、店长,外加顾客背后的会员体系。把这几个角色和流程理清楚,实体基本就自己浮出来了,比凭空编表可靠得多。
2.1 用“三员四流”圈定系统边界,别一上来就建表
“三员”指的是采购员、收银员、店长(或者系统管理员)。“四流”是我做这类项目时习惯拆的线索:商品流、资金流、票据流、库存流。商品流回答“货从哪来、到哪去”——供应商供货、入库、上架、顾客买走、退货回流;资金流回答“钱怎么动”——收银台收款、采购付款、会员充值;票据流对应每一笔业务留下的单——采购单、销售单、退货单;库存流则记录每一次数量变化。
这四条流是画 ER 图的原料,也是最容易被忽略的部分。很多课程设计只做了一张商品表和一张订单表,供应商直接做成商品的一个字段,结果要查“这个月从某供应商进了多少货”时写不出 SQL,只能在程序里循环凑数。正确的顺序是:先把流程走一遍,再给流程上的每个环节命名,最后才落成表。
| 角色 | 典型动作 | 系统需要留下的数据 |
|---|---|---|
| 采购员 | 下单、收货、退回供应商 | 进货单、进货明细、供货商档案 |
| 收银员 | 开单、收款、退货 | 销售单、销售明细、退货单 |
| 店长/管理员 | 商品管理、会员管理、查报表 | 商品档案、会员档案、日报/月报 |
| 会员 | 充值与消费 | 余额、积分、消费历史 |
从这个表往外扩,实体基本固定:商品、供应商、员工、会员,以及依附于它们的进货单、销售单、退货单和各自的明细。明细属于典型的“跟着主单走”的弱实体,后面建表时会用外键把它挂在主单上。
四流拆开后,会发现一些人把进货和销售揉在一张出入库表里:字段既有入库数量又有出库数量,还塞一个“类型”字段区分方向。这种设计的直接后果是,任何一笔业务的字段有一半是空的,数据字典写得别扭,统计时还要频繁做条件聚合。我的建议是进货、销售、退货各走各的表,方向由表本身表达,不要靠一个 type 字段硬掰。
2.2 画 ER 图的次序:实体、属性、关系、约束
ER 图是这份 PDF 里最直观的加分项,但画错的人最多。常见的错误是:一上来直奔画布,边画边想,最后图和表对不上。给一个能用的次序。
第一步列候选实体:商品、供应商、员工、会员、进货单、进货明细、销售单、销售明细、退货单、库存流水。第二步区分核心实体和弱实体:商品、供应商、员工、会员是能独立存在的核心实体;明细表离开主单没有意义,是弱实体。第三步给每个实体补属性,把主键标出来;第四步连关系,标清 1:1、1:N、M:N;第五步回到约束,把非空、唯一、默认值标在属性旁。
| 实体 | 关键属性 | 说明 |
|---|---|---|
| 商品 | id、编码、名称、分类、售价、成本、库存、安全库存 | 售价与成本必须分开 |
| 供应商 | id、名称、联系人、电话 | 一个供应商可以供多种商品 |
| 员工 | id、工号、姓名、角色、状态 | 角色用于区分采购员/收银员 |
| 会员 | id、手机号、余额、积分、状态 | 可选,但加上能拉开与别人的差距 |
| 销售单 | id、单号、员工、会员、总额、状态 | 状态标记“已支付/已退货” |
| 销售明细 | id、销售单、商品、数量、单价、小计 | 快照单价,不能去查商品表当前价 |
| 进货单/明细 | 同销售单结构 | 入库联动库存加 |
| 库存流水 | id、商品、变动数量、类型、关联单号 | 给“库存为什么变”留证据 |
实体属性补全这一步,新人最常漏的是“业务编号”和“状态”。除了数据库主键 id,用户和文档都需要看得懂的单号,比如销售单号、供应商编码、会员卡号,它们要 UNIQUE。“状态”几乎每个档案表都要有,商品在售/下架、员工在职/停用、会员正常/注销,没有状态就没法实现逻辑删除。这两类属性在 ER 图阶段不画出来,建表时一定会返工。
表格下方的易错点:销售明细里的“单价”是快照。商品的价格会调,订单是历史事实,所以明细必须存当时的单价。这个点写进 PDF 设计说明,老师会认为你理解业务而不是只会抄表结构。
2.3 三处容易画错的关系:退货、会员与商品-供应商
关系这里有三道高频送分题,也是答辩时容易被问的地方。
第一道是退货。很多小组直接把“退货”做成销售单的一个状态位,删掉明细了事。这样退货金额、退货原因、退款给谁全部丢失,报表里“退货金额”只能靠状态位凑。正确做法是单独建退货单和退货明细,退货单通过 order_id 指向原销售单。一次全额退就一条记录,部分退就多条。退货明细同样要记录当时的单价,因为它影响退款金额。
第二道是会员与销售单。会员是 1:N 销售单,这没什么争议,争议在于会员充值与消费要不要分开记录。建议独立一张“会员账户流水”表,记录充值、消费、返利、退款。原因很实际:如果会员余额只是一个字段,一旦有退款业务,你很难说清余额是怎么变的。加一张流水表,库存流水的设计经验直接复用。
第三道是商品与供应商。商品和供应商是 M:N——一个供应商供多种商品,一种商品也可能有多个备选供应商。在大作业里不用建模得那么复杂,常见的稳妥做法是:商品表保留默认供应商 supplier_id,再单独建 product_supplier 关系表作为选修项。ER 图里画成 M:N 并拆出中间表,会比把供应商做成商品字段专业不少。
库存流水是否作为实体画进 ER 图,是另一个容易犹豫的地方。我的建议是画进去,因为它是超市系统里解释“库存为什么变”的唯一依据。进货、销售、退货、盘点都会触发库存变动,流水表记录商品、变动数量、类型和关联单号,相当于给库存字段做了日志。没有它,库存对不上时只能人工翻单据,课程设计答辩时讲不清。
3. 从 ER 图到 MySQL 建表:数据类型与约束一次到位
模型画完,下一步是把模型变成能跑的库。下面以 MySQL 为例,给出这套超市管理系统的核心建表语句。表名用单数还是复数、字段用驼峰还是下划线,不是大问题,但前后要统一,文档里的数据字典必须和库一致。
3.1 建库建表:把商品、供应商、员工、会员落到 SQL
先建库和四张基础表。注意字符集直接指定 utf8mb4,避免后面出现中文内容乱码的麻烦。
CREATE DATABASE IF NOT EXISTS supermarket_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE supermarket_db; CREATE TABLE employee ( id INT PRIMARY KEY AUTO_INCREMENT, emp_no VARCHAR(20) NOT NULL UNIQUE COMMENT '工号', emp_name VARCHAR(50) NOT NULL COMMENT '姓名', role TINYINT NOT NULL DEFAULT 2 COMMENT '1-店长 2-收银员 3-采购员', password_hash VARCHAR(64) NOT NULL COMMENT '登录密码哈希', status TINYINT NOT NULL DEFAULT 1 COMMENT '1-在职 0-停用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='员工表'; CREATE TABLE supplier ( id INT PRIMARY KEY AUTO_INCREMENT, sup_no VARCHAR(20) NOT NULL UNIQUE COMMENT '供应商编码', sup_name VARCHAR(100) NOT NULL COMMENT '供应商名称', contact VARCHAR(50) COMMENT '联系人', phone VARCHAR(20) COMMENT '联系电话', status TINYINT NOT NULL DEFAULT 1 COMMENT '1-合作中 0-停用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='供应商表'; CREATE TABLE member ( id INT PRIMARY KEY AUTO_INCREMENT, mem_no VARCHAR(20) NOT NULL UNIQUE COMMENT '会员卡号', mem_name VARCHAR(50) NOT NULL COMMENT '会员姓名', phone VARCHAR(20) NOT NULL UNIQUE COMMENT '手机号', balance DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '账户余额', points INT NOT NULL DEFAULT 0 COMMENT '积分', status TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常 0-注销', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='会员表'; CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, prod_no VARCHAR(20) NOT NULL UNIQUE COMMENT '商品编码', prod_name VARCHAR(100) NOT NULL COMMENT '商品名称', category_id INT NOT NULL COMMENT '分类,可用字典表,也可直接存分类名', unit VARCHAR(10) NOT NULL DEFAULT '件' COMMENT '计量单位', price DECIMAL(10,2) NOT NULL COMMENT '售价', cost DECIMAL(10,2) NOT NULL COMMENT '进货成本', stock INT NOT NULL DEFAULT 0 COMMENT '当前库存', safety_stock INT NOT NULL DEFAULT 0 COMMENT '安全库存,低于它提示补货', supplier_id INT NOT NULL COMMENT '默认供应商', status TINYINT NOT NULL DEFAULT 1 COMMENT '1-在售 0-下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_product_supplier FOREIGN KEY (supplier_id) REFERENCES supplier(id) ) ENGINE=InnoDB COMMENT='商品表';这段代码里需要注意几个选型。emp_no、sup_no、mem_no、prod_no 这些业务编号是给人看的,必须唯一,所以加了 UNIQUE;id 是给数据库用的,只负责定位。password_hash 用 VARCHAR(64) 而不是直接存明文,对应常见摘要算法的输出长度。status 全部用 TINYINT + COMMENT,是为了避免文档里出现一堆魔法值说不清含义。product 表直接建了指向 supplier 的外键,保证“每个商品有一个默认供应商”这条业务规则在数据库层就成立。
3.2 订单与明细:外键怎么挂,金额字段为什么必须够宽
基础表建好后,接着建订单族。订单族的核心是“主单 + 明细 + 外键”。销售单和销售明细分开,进货单和进货明细分开,退货单再挂回原销售单。
CREATE TABLE sale_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '销售单号', member_id INT DEFAULT NULL COMMENT '会员,散客为NULL', staff_id INT NOT NULL COMMENT '收银员', total_amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '订单总额', status TINYINT NOT NULL DEFAULT 1 COMMENT '1-已支付 2-已退货 0-作废', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_order_member FOREIGN KEY (member_id) REFERENCES member(id), CONSTRAINT fk_order_staff FOREIGN KEY (staff_id) REFERENCES employee(id) ) ENGINE=InnoDB COMMENT='销售单'; CREATE TABLE sale_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, qty INT NOT NULL COMMENT '购买数量', price DECIMAL(10,2) NOT NULL COMMENT '成交单价快照', subtotal DECIMAL(10,2) NOT NULL COMMENT '小计', CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES sale_order(id), CONSTRAINT fk_item_product FOREIGN KEY (product_id) REFERENCES product(id) ) ENGINE=InnoDB COMMENT='销售明细'; CREATE TABLE stock_in ( id INT PRIMARY KEY AUTO_INCREMENT, in_no VARCHAR(32) NOT NULL UNIQUE COMMENT '进货单号', supplier_id INT NOT NULL, staff_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_in_supplier FOREIGN KEY (supplier_id) REFERENCES supplier(id), CONSTRAINT fk_in_staff FOREIGN KEY (staff_id) REFERENCES employee(id) ) ENGINE=InnoDB COMMENT='进货单'; CREATE TABLE stock_in_item ( id INT PRIMARY KEY AUTO_INCREMENT, stock_in_id INT NOT NULL, product_id INT NOT NULL, qty INT NOT NULL, unit_cost DECIMAL(10,2) NOT NULL COMMENT '进货单价', subtotal DECIMAL(10,2) NOT NULL, CONSTRAINT fk_in_item_head FOREIGN KEY (stock_in_id) REFERENCES stock_in(id), CONSTRAINT fk_in_item_product FOREIGN KEY (product_id) REFERENCES product(id) ) ENGINE=InnoDB COMMENT='进货明细';这里最关键的三个决定是:会员允许为空,用 DEFAULT NULL 表示“散客”,不要为了省事强制填一个“0号会员”;明细里的 price 和 subtotal 是快照,绝不能通过商品表现价临时算;外键一律建上,课程设计阶段“用外键保护数据”比“用程序保证数据”更容易让评分人认可。总金额用 DECIMAL(10,2) 够覆盖常规超市量级,如果后续想支持批发订单,可以放到 DECIMAL(12,2)。
3.3 数据类型与约束:金额用 DECIMAL、状态用 TINYINT 的理由
这一节专门讲选型,因为数据字典是 PDF 里被仔细看的部分。金额和数量相关字段统一 DECIMAL,数量用 INT 或 DECIMAL 视商品而定,但金额必须 DECIMAL(10,2) 起步。FLOAT/DOUBLE 是二进制浮点,0.1 在二进制里是无限循环小数,累加几万笔就会出现 0.30000000000000004 这种值,报表里没法解释。这是数据库设计里少有的“绝对要避开”的坑。
状态字段统一 TINYINT,配合 COMMENT。字符串状态看着直观,但改状态名要改表结构,而且容易写错大小写。日期时间用 DATETIME,不要用 VARCHAR 存时间,否则范围查询和排序会变成字符串比较。库存字段在超市系统里用 INT 即可,不够再改,但别用无符号 UNSIGNED——扣减库存时如果临时算成负数,无符号列会直接溢出报错,普通 INT 加 CHECK 约束反而更容易排查。
MySQL 8.0.16 起 CHECK 约束真正生效,可以在建表时把“库存不为负”“售价大于等于成本”这类规则写进表结构。如果是 5.7,CHECK 会被解析但基本不校验,规则只能靠存储过程和程序层守。下面一段是给商品表补约束的写法,8.0 及以上可以直接执行:
ALTER TABLE product ADD CONSTRAINT chk_product_price_positive CHECK (price > 0), ADD CONSTRAINT chk_product_stock_not_negative CHECK (stock >= 0), ADD CONSTRAINT chk_product_cost_not_negative CHECK (cost >= 0);条件允许时,这些约束应该画在 ER 图边上、写进 PDF 的约束设计小节。答辩时被问“库存能不能为负”时,能直接指出 CHECK 约束,和只会说“程序里判断了”是完全不同的印象。
3.4 索引建在查询路径上,别把每列都加一遍
索引是大作业里容易被忽略也能快速加分的地方。原则只有一条:索引建在查询条件和连接条件上,不在每个字段上平铺。这套系统的高频查询是“按单号查订单”“按商品编码查商品”“按时间范围查销售报表”,所以对应字段建唯一索引或普通索引足够。主键自带聚簇索引,不需要额外建。
给两段可以直接用在文档里的索引语句:
CREATE INDEX idx_order_create_time ON sale_order(create_time); CREATE INDEX idx_item_product ON sale_order_item(product_id);不要做的是:给 status、role 这类低基数列盲目加索引。整表只有 1、0 两个值,索引不但不能加速,还会拖慢写入。如果一定要在文档里体现思考过程,可以写“status 低基数,暂不建索引,业务量上来后按分区表处理”,这比无脑索引专业得多。
4. 让作业拿到加分项:存储过程、视图与事务的写法
基础表建好后,很多小组就去做页面了,这是最可惜的部分。超市系统最值钱的两段业务是“进货入库”和“前台结账”,它们天然要跨多张表写数据。用存储过程把这两段逻辑收敛在数据库层,既能在 PDF 里写“业务逻辑下沉”,又能在答辩时演示“一次调用完成多表联动”,是投入产出比很高的加分项。
4.1 进货入库:为什么不直接写两条 INSERT
进货入库最少要干三件事:写进货单、写进货明细、把数量累加到商品库存。如果这三步由前端分三次调用,任何一次失败都会造成“单据有了库存没变”或者反过来。常见做法是写一个存储过程把三步包进一个事务。
DELIMITER $$ CREATE PROCEDURE sp_stock_in( IN p_in_no VARCHAR(32), IN p_supplier_id INT, IN p_staff_id INT, IN p_product_id INT, IN p_qty INT, IN p_unit_cost DECIMAL(10,2) ) BEGIN DECLARE v_stock_in_id INT; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; INSERT INTO stock_in(in_no, supplier_id, staff_id, total_amount) VALUES(p_in_no, p_supplier_id, p_staff_id, p_qty * p_unit_cost); SET v_stock_in_id = LAST_INSERT_ID(); INSERT INTO stock_in_item(stock_in_id, product_id, qty, unit_cost, subtotal) VALUES(v_stock_in_id, p_product_id, p_qty, p_unit_cost, p_qty * p_unit_cost); UPDATE product SET stock = stock + p_qty WHERE id = p_product_id; COMMIT; END$$ DELIMITER ;调用时只要一句CALL sp_stock_in('IN20250601001', 1, 2, 101, 50, 12.50);,参数依次是进货单号、供应商、操作员、商品、数量、进价。EXIT HANDLER 的作用是:任何一步报错立刻回滚并向上抛错,避免出现“订单写了库存没加”的脏数据。LAST_INSERT_ID() 用来拿刚插入的进货单主键,把明细挂到它下面。这里一次调用只处理一行明细,适合课程设计演示;要支持一单多商品,复刻 4.2 节的游标思路即可。顺序上要注意:先写主单和明细,再更新库存,回滚时机才对。
4.2 前台结账:一个事务里完成校验、扣库存、写订单
前台结账比进货入库更敏感,因为它涉及两个收银台同时操作同一个商品的并发场景。这里给出一个能讲清楚思路的版本:结账前先把购物车明细写进一张临时表,再调用存储过程完成“校验库存、写销售单、写明细、扣库存、算总额”。
CREATE TABLE temp_cart ( product_id INT PRIMARY KEY, qty INT NOT NULL, staff_id INT NOT NULL, member_id INT DEFAULT NULL ) ENGINE=InnoDB COMMENT='结账购物车,程序写入,存过程读取'; DELIMITER $$ CREATE PROCEDURE sp_checkout(IN p_staff_id INT, IN p_member_id INT) BEGIN DECLARE v_order_id INT; DECLARE v_total DECIMAL(10,2) DEFAULT 0; DECLARE v_pid INT; DECLARE v_qty INT; DECLARE v_price DECIMAL(10,2); DECLARE v_stock INT; DECLARE v_done INT DEFAULT 0; DECLARE cur CURSOR FOR SELECT product_id, qty FROM temp_cart WHERE staff_id = p_staff_id; DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; INSERT INTO sale_order(order_no, member_id, staff_id, total_amount) VALUES(CONCAT('SO', DATE_FORMAT(NOW(), '%Y%m%d%H%i%s'), FLOOR(RAND()*1000)), p_member_id, p_staff_id, 0); SET v_order_id = LAST_INSERT_ID(); OPEN cur; read_loop: LOOP FETCH cur INTO v_pid, v_qty; IF v_done = 1 THEN LEAVE read_loop; END IF; SELECT price, stock INTO v_price, v_stock FROM product WHERE id = v_pid FOR UPDATE; IF v_stock < v_qty THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '商品库存不足,已回滚整笔订单'; END IF; UPDATE product SET stock = stock - v_qty WHERE id = v_pid; INSERT INTO sale_order_item(order_id, product_id, qty, price, subtotal) VALUES(v_order_id, v_pid, v_qty, v_price, v_price * v_qty); SET v_total = v_total + v_price * v_qty; END LOOP; CLOSE cur; UPDATE sale_order SET total_amount = v_total WHERE id = v_order_id; DELETE FROM temp_cart WHERE staff_id = p_staff_id; COMMIT; END$$ DELIMITER ;这段逻辑里最值得在答辩时讲的是两处。一处是SELECT ... FOR UPDATE:它在事务内把商品行锁住,另一个收银台同时结账同一商品时会等这把锁释放,从机制上避免超卖。另一处是SIGNAL:一旦库存不足,直接抛错并触发外面的 EXIT HANDLER 回滚,前面已经扣掉的库存和写好的明细全部撤销,不会留下半截订单。订单号用时间戳加随机数拼接,在课程设计量级下够用,文档里可以补一句“生产环境应使用发号器”,显得你清楚边界。关于性能不用纠结,课程设计的数据量下这种方式完全够用。
4.3 视图:把日报表从十几行 SQL 收成一条查询
报表查询是另一个加分点。销售日报在传统写法里要 JOIN 三张表再 GROUP BY,前端调一次写一次。建一个视图,把“某天卖了多少、按分类拆开”固定下来,文档里写报表设计时也整洁。
CREATE VIEW v_sales_daily AS SELECT DATE(so.create_time) AS sale_date, p.category_id, COUNT(DISTINCT so.id) AS order_count, SUM(soi.qty) AS qty, SUM(soi.subtotal) AS amount FROM sale_order so JOIN sale_order_item soi ON soi.order_id = so.id JOIN product p ON p.id = soi.product_id WHERE so.status = 1 GROUP BY DATE(so.create_time), p.category_id;查询时SELECT * FROM v_sales_daily WHERE sale_date = '2025-06-01';就能拿到当天的分类销售汇总。视图对调用方隐藏了表关系,PDF 里数据流图可以直接画成“报表层从视图取数”,比画一堆 JOIN 线清爽。注意 MySQL 的视图不直接支持物化,数据量大后还是要落到定时汇总表,这点在文档里补一句说明,显得你清楚视图的边界。报表要保留“退货”维度时,把退货单也 JOIN 进来,按净销售额展示。
5. 验收前最常见的 5 个问题与排查:现象、原因、后悔药
这部分是这类课程设计里血泪经验最集中的一段。我见过的小组反复踩的坑,每一类都按“现象 → 原因 → 解决”来写,可以对着自己的项目一条条排查。大多数问题不是业务复杂,而是环境、精度和约束之间的组合摩擦。
5.1 连不上数据库:时区报错和驱动类名对不上
现象:程序一启动就抛Communications link failure、时区相关报错或者ClassNotFoundException。很多人以为是数据库没启动,最后发现是驱动和连接串的问题。
原因:数据库用的是 MySQL 8.0 以上,但项目里引的是旧版驱动包,类名还是com.mysql.jdbc.Driver;连接串没带serverTimezone,而服务端时区参数没设置。两个问题叠加,报错信息又不像缺依赖,极具迷惑性。
解决:换新驱动后,驱动类改成com.mysql.cj.jdbc.Driver,连接串显式加serverTimezone=Asia/Shanghai和useSSL=false。比如jdbc:mysql://localhost:3306/supermarket_db?serverTimezone=Asia/Shanghai&useSSL=false&characterEncoding=utf8。这里要提醒:数据库和驱动的大版本尽量对齐,别混着用。
5.2 外键约束当场翻车:商品删不掉、供应商改不了
现象:演示时想删一个测试商品或改一个供应商编码,MySQL 直接报Cannot delete or update a parent row: a foreign key constraint fails,当场卡住。
原因:商品被销售明细、进货明细引用,默认外键策略是 RESTRICT,父行被引用时不允许删除;供应商被商品表引用,同理。这是外键应有的保护行为,不是数据库出错。
解决:按业务语义分两类处理。商品和供应商这种档案类数据,不要在演示时物理删除,表里已经有 status 字段,把状态置 0 就是“下架/停用”,查询时过滤 status=1。订单明细是历史证据,它的外键一定要保留 RESTRICT。如果确实需要重来一遍,可以用SET FOREIGN_KEY_CHECKS=0临时关闭,但演示完要恢复,文档里不要这么写。
5.3 金额对不上账:FLOAT 与 DECIMAL 的精度黑匣子
现象:某小组结账 999 笔后,报表总额比 Excel 逐笔累加多了 0.03 元;更诡异的是有时多有时少,看不出规律。查程序没逻辑错,最后定位到字段类型。
原因:价格和数量用了 FLOAT 或 DOUBLE。二进制浮点无法精确表示 0.1、0.2 这类小数,单笔差异在浮点尾数里,累加几百上千笔后就显性了。这不是逻辑 Bug,是数据类型选型错误。
解决:金额、单价、小计全部改成 DECIMAL(10,2)。改表用下面的语句,应用层取数后不要再用 double 中转,Java 侧用 BigDecimal 或字符串接收。报表查询里顺手用 ROUND 兜底,是双保险。
ALTER TABLE sale_order_item MODIFY price DECIMAL(10,2) NOT NULL, MODIFY subtotal DECIMAL(10,2) NOT NULL;5.4 库存出现负数,CHECK 却没拦截
现象:商品表明明加了CHECK (stock >= 0),结账时库存还是能变成 -2,而且是在 8.0 的库里。
原因:CHECK 约束是数据规则,不是并发控制。最典型的绕过方式是“先查库存、程序判断、再 UPDATE”三段式:程序里判断时库存是 1,两个收银台同时并发,判断都通过,两次 UPDATE 后库存变 -1。
解决:把“判断 + 扣减”放进同一个事务并用SELECT ... FOR UPDATE锁行,像 4.2 节那样,并发下第二个事务会等锁释放,再读到新库存继续判断。还有一个隐藏点:如果表是 5.7 建的,CHECK 根本不生效,需要先确认所在版本。
5.5 触发器递归把自己锁死:库存流水表的经典教训
现象:给商品表建了一个“库存变动自动写库存流水”的触发器,第一次手工 UPDATE 库存正常,第二次触发库存流水表上的另一个触发器,直接报recursive limit;或者批量导入一万条商品时卡了十几秒。
原因:两个触发器互相 UPDATE 导致递归;批量导入时逐行触发,单条开销小但总数大,整体看不出瓶颈。这些都是触发器的固有特性,不算玄学,但容易忽略。
解决:同一类业务只保留一个方向的触发器,比如“库存变动 → 写流水”只写在 product 表上,stock_log 表不建写回商品的触发器。批量导入数据时,可以先 DROP TRIGGER 导入完再建回来,或者用会话变量做开关,触发器中判断IF @DISABLE_TRIGGER = 1 THEN直接跳过。文档里写明触发器使用范围和边界,比堆一堆触发器显得克制、专业。
6. 用 information_schema 自检一遍:让 PDF 和数据库逐行对上
文档和代码对不上,是课程设计答辩里最冤的翻车方式。我见过有人 ER 图画了三张纸,库里的表却少了两张;也见过数据字典写 price DECIMAL(10,2),实际表里是 DOUBLE。这类问题不查永远不知道,一查十分钟就能发现。下面这条自检路径我每次做项目都会走一遍。
先把 PDF 里的表清单列出来,再跑这段查询,把结果导出成 CSV,和 PDF 数据字典逐张核对:
SELECT table_name, column_name, column_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'supermarket_db' ORDER BY table_name, ordinal_position;行数对得上,看字段类型;类型对得上,看默认值和是否为空;最后检查外键,information_schema.KEY_COLUMN_USAGE能列出所有外键,PDF 约束设计里写了几条,库里就得有几条。
| 检查项 | 对照方法 | 常见不合格表现 |
|---|---|---|
| 表数量一致 | 查询结果和 PDF 目录比对 | 文档画了退货单,库里没有 |
| 字段类型一致 | column_type 与数据字典比对 | 金额写成 DOUBLE |
| 外键数量一致 | KEY_COLUMN_USAGE 与约束设计比对 | 文档画了关系,库没建外键 |
| 存储过程/视图齐全 | 查 routines、views | 文档写了 sp_checkout,库里没有 |
| 演示数据能走通流程 | 进货→销售→退货→查报表 | 退货后库存不回补 |
最后一步,用演示数据把整条业务链走一遍:建一个测试商品,进货 100 件,结账 3 件,退货 1 件,查日报表看净销量是不是 2 件,查库存是不是 99 件。能走通,这十分钟才算结束。我自己的习惯是,把这四步的 SQL 连同结果截图存进 PDF 附录,作为“系统验证”小节——这比任何文字说明都更有说服力。截图前记得把演示数据初始化整齐,别让一张测试垃圾数据毁了整个附录。希望帮到你。
本文还有配套的精品资源,点击获取