简介:面向软件工程与数据库课程学习者,内容对应西北工业大学软件学院数据库实验五,要求依据电子商务项目描述完成完整ER模式设计,适合正在做概念建模、实体联系分析与实验报告整理的学生对照参考。压缩包共20个文件,约282KB,以14个gif业务流程图和2个txt、2个doc说明文档为主,另含cdm概念数据模型与htm实验页面,可辅助理解ER图绘制及结果呈现。已有911人学习。读者可获取实验五的ER图、文字说明以及注册、购物车、结算、订单等环节的图示材料,用于核对实体、联系、属性设置与基数约束,梳理电子商务场景的建模思路,也可作为数据库概念设计实验的对照样例,提升实验完成与检查效率。
1. 数据库实验走到第五关:存储过程、触发器、事务与索引的复习地图
数据库实验前四关基本是「把数据放进去、再取出来」:建库建表、单表查询、多表连接、视图与索引。到第五关风格突变,题目开始要求用存储过程封装业务、用触发器守住完整性、用事务保证一组操作要么全成要么全败,最后还得拿执行计划证明索引没白建。
这几块的共同点是:跑通不等于写对。存储过程能返回结果,参数模式却可能写反;触发器能触发,批量插入时只照顾到第一行;事务能提交,隔离级别选错照样读出脏数据。
所以下面按写法、语义、验证三层铺开:先拆存储过程与触发器的骨架,再讲事务隔离与锁的取舍,最后把索引和执行计划放进易错清单里对照,落到一份能逐条打勾的复习表。
2. 存储过程与函数:把业务逻辑交给数据库的写法要点
2.1 骨架与参数模式:DELIMITER 到底在解决什么问题
初学存储过程最常见的困惑不是语法,而是「为什么我照着书写,客户端一直报语法错误」。原因在分号:客户端默认把分号当作一条语句的结束符,而存储过程体内部本身就有好几处分号,于是客户端会在第一个分号处把语句截断。DELIMITER $$的作用就是把结束符临时换成$$,让整段CREATE PROCEDURE被当成一条语句整体发送给服务器,写完再换回分号。
DELIMITER $$ CREATE PROCEDURE sp_monthly_stat( IN p_month CHAR(7), -- 输入:形如 '2025-04' OUT p_total DECIMAL(12,2) -- 输出:回传当月订单总额 ) BEGIN -- IFNULL 兜底,避免无数据时 OUT 参数为 NULL SELECT IFNULL(SUM(amount), 0) INTO p_total FROM orders WHERE created_at >= CONCAT(p_month, '-01') AND created_at < DATE_ADD(CONCAT(p_month, '-01'), INTERVAL 1 MONTH); END$$ DELIMITER ; -- 调用:OUT 参数必须用用户变量接住,不能直接传常量 CALL sp_monthly_stat('2025-04', @total); SELECT @total;这段代码做了三件事:声明过程名与参数、在体内做一次聚合查询并把结果写进 OUT 参数、最后用用户变量接收。参数部分要分清三种模式:IN是默认值,调用时传值进去,过程里改它不影响外部;OUT只负责往外带结果,进过程时是 NULL;INOUT既读又写。实操里最容易翻车的是把OUT参数直接写成常量调用,例如CALL sp_x('2025-04', 0),语法上直接不通过。
| 参数模式 | 调用时传什么 | 过程内初值 | 是否回传 | 典型用途 |
|---|---|---|---|---|
| IN | 常量、变量、表达式 | 实参值 | 否 | 查询条件、金额 |
| OUT | 只能是变量 | NULL | 是 | 返回码、统计结果 |
| INOUT | 只能是变量 | 实参值 | 是 | 累加器、状态码 |
SQL Server 那边没有DELIMITER这回事,它的BEGIN...END本身就是语句块边界,输出参数用OUTPUT关键字标记。做对比记忆时抓住一点:换的是「结束符的声明方式」,不换的是「输入、输出、双向三种语义」。
2.2 流程控制、游标与异常处理的三件套
存储过程之所以值得单独练,是因为它把「判断、循环、出错兜底」塞进了一个可以被一次调用的单元。分支用IF...ELSEIF...ELSE或CASE,循环有WHILE、REPEAT、LOOP三种,跳出用LEAVE 标签,跳过一次用ITERATE 标签。注意LEAVE后面跟的是你自己定义的循环标签名,不是关键字LOOP,这一点写错就会得到一个很难懂的报错。
游标是一套固定动作:DECLARE声明、OPEN打开、FETCH逐行取、CLOSE关闭,再配一个NOT FOUND处理器在取空时把循环停掉。
DELIMITER $$ CREATE PROCEDURE sp_recalc_level() BEGIN DECLARE v_done INT DEFAULT 0; -- 循环结束标志 DECLARE v_id INT; DECLARE v_amt DECIMAL(12,2); -- 游标必须在变量之后、处理器之前声明 DECLARE cur CURSOR FOR SELECT id, total_amount FROM customer; DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1; OPEN cur; read_loop: LOOP FETCH cur INTO v_id, v_amt; IF v_done = 1 THEN LEAVE read_loop; -- 取空了就退出 END IF; UPDATE customer SET level = CASE WHEN v_amt >= 10000 THEN 'A' WHEN v_amt >= 3000 THEN 'B' ELSE 'C' END WHERE id = v_id; END LOOP; CLOSE cur; END$$ DELIMITER ;声明顺序是硬性规定:变量 → 游标 → 处理器,顺序颠倒直接报错。另一个隐蔽点是NOT FOUND不只被FETCH触发,SELECT ... INTO查不到行时同样会触发它,所以一个过程里如果既有游标又有多次单行查询,共用一个NOT FOUND处理器很容易把两种「没数据」混为一谈,血泪经验是:要么分成两个过程,要么用SQLSTATE精确捕获。
异常处理器的三种行为值得背下来:CONTINUE是处理完继续往下走、EXIT是直接跳出当前BEGIN...END块、UNDO是回滚已执行的语句(实际支持面很窄)。条件可以是SQLEXCEPTION(所有错误)、SQLWARNING、NOT FOUND,也可以精确到某个SQLSTATE。
2.3 函数、过程与视图:边界划在哪
存储过程 vs 函数,考试和答辩都爱问。函数必须RETURN一个值且只能带IN参数,体内不允许出现事务控制语句;过程可以没有返回值、可以带OUT、可以显式开事务。函数能嵌在SELECT里当列用,过程只能CALL,这是最直观的区分方式。
| 维度 | 存储过程 | 函数 | 视图 |
|---|---|---|---|
| 返回 | 可 0 个或多个 OUT | 必须有且仅有一个 RETURN | 结果集 |
| 参数 | IN、OUT、INOUT | 只有 IN | 无 |
| 事务语句 | 允许 | 不允许 | 不适用 |
| 调用方式 | CALL p(...) | SELECT f(...) | SELECT * FROM v |
| 常见误用 | 把纯计算写成过程 | 在函数里写 COMMIT | 把视图当缓存表用 |
视图只是给查询起个名字,本身不含逻辑,理解成「保存下来的 SELECT」即可;它能简化多表连接,但在视图上再叠加复杂过滤往往会拖慢执行,这点在第 5 章读执行计划时会再碰面。
至于「该不该把业务放进数据库」,实验场景基本没得选,题目要求写就得写;但心里要有数:存储过程的版本管理、跨数据库迁移、调试能力都弱于应用层代码,线上项目通常只把强一致性要求的短逻辑放进来。把这条边界想清楚,答辩时能多答一个层次。
3. 触发器与完整性约束:被动响应的边界卡在哪
3.1 触发时机与前缀:BEFORE/AFTER 乘以 INSERT/UPDATE/DELETE
触发器的完整名称由三部分组成:时机(BEFORE或AFTER)、事件(INSERT、UPDATE、DELETE)、粒度(行级FOR EACH ROW或语句级)。主流开源数据库只支持行级触发器,也就是说一条 UPDATE 影响 500 行,触发器体就被执行 500 次;商业数据库则同时支持语句级,一条语句只触发一次。
时机选择的判断标准很朴素:需要在数据落盘之前改写或校验,用BEFORE;需要在数据落盘之后做记录、级联、通知,用AFTER。典型的BEFORE INSERT用来补默认值、规范格式、拦截非法数据;典型的AFTER UPDATE用来写审计日志、同步汇总表。
DELIMITER $$ CREATE TRIGGER trg_stock_audit AFTER UPDATE ON product FOR EACH ROW BEGIN -- 只有库存真正发生变化才记一笔,避免无意义的日志膨胀 IF OLD.stock <> NEW.stock THEN INSERT INTO stock_log(product_id, delta, changed_at, operator) VALUES (NEW.id, NEW.stock - OLD.stock, NOW(), CURRENT_USER()); END IF; END$$ DELIMITER ;OLD和NEW是触发器里唯一的上下文入口:INSERT只有NEW,DELETE只有OLD,UPDATE两者都有。NEW.stock - OLD.stock得到的是本次变化的增量,正数代表增加、负数代表扣减,这个差值写进日志表比记录绝对值更好查问题。参数上要注意OLD在INSERT触发器里访问会直接报错,不是返回 NULL,别抱着「试一下」的心态。
3.2 多行触发、递归与级联:三个容易被忽略的连锁反应
行级触发器看起来天然处理了多行,但陷阱恰恰藏在这里。如果过程里想跨行累计一个状态(比如用会话变量@counter在触发器里做全局计数),每行触发时变量的生命周期和作用域就会让结果不可预期;正确做法是让每一行都独立完成自己的写入,不去依赖跨行的中间状态。
递归触发是第二个坑。触发器里更新自己所在的那张表,可能再次触发同一个触发器,有的数据库直接报错禁止,有的按递归深度限制截断。实验里如果出现「更新一行却报了一堆错」或「日志表突然多了几倍记录」,先怀疑自引用。
级联是第三个。外键的ON DELETE CASCADE和AFTER DELETE触发器都在删父表数据时干活,两者的执行先后与锁的范围纠缠在一起,出问题时很难判断是谁删的。经验做法是:外键级联负责保证引用完整性,触发器只负责记录和派生数据,别让两者做同一件事。
-- 反例:在 product 的 AFTER UPDATE 触发器里再 UPDATE product -- 会形成递归;改成更新另一张汇总表 UPDATE stock_summary SET total = total + (NEW.stock - OLD.stock) WHERE product_id = NEW.id;把「写日志表」「更新汇总表」这类操作放在触发器里,前提是这张表与触发器所在表不是同一张,触发链才不会绕回来。
3.3 约束、触发器、应用层校验的三角选择
同一份完整性规则,三种地方都能写,差别在可靠性和可维护性。
| 手段 | 生效位置 | 优点 | 代价 |
|---|---|---|---|
| 声明式约束 | 数据库内核 | 无法绕过、性能最好 | 表达力有限,报错信息生硬 |
| 触发器 | 数据库内核 | 可写复杂逻辑、可记日志 | 隐式执行、调试困难、批量时性能差 |
| 应用层校验 | 业务代码 | 易测试、易改 | 绕过接口直接改库就失效 |
排序原则是:能用NOT NULL、UNIQUE、CHECK、FOREIGN KEY表达的,一律用约束;约束表达不了、又必须保证「任何入口都拦得住」的,才上触发器;纯粹为了给用户友好提示的校验,放应用层。实验报告里如果写了触发器却能用 CHECK 等价实现,答辩时会被追问一句「为什么不直接用约束」,提前想好答案。
另外,触发器是隐式执行的,调用方看不到它的存在,一旦它内部报错,整个 DML 语句随之失败,报错信息往往指向触发器而不是原始语句。排查时先禁用触发器跑一遍,能定位问题就说明逻辑在触发器里。
4. 事务与并发控制:隔离级别、锁与死锁的复习重点
4.1 ACID 与四个隔离级别对应哪三个异常
原子性、一致性、隔离性、持久性这四个词背起来不难,难的是把隔离性落到具体现象上。并发事务会引发三类读异常:脏读是读到了别人未提交的数据;不可重复读是同一事务内两次读同一行结果不同;幻读是同一事务内两次按同一条件查询,第二次多出了行。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现思路(简述) |
|---|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 | 基本不加读锁 |
| 读已提交 | 不可能 | 可能 | 可能 | 语句级快照 |
| 可重复读 | 不可能 | 不可能 | 可能(部分实现可避免) | 事务级快照 + 间隙锁 |
| 串行化 | 不可能 | 不可能 | 不可能 | 读写都加范围锁 |
记忆方式是「级别越高,锁越多、并发越差」。实验里常被问的一句是:为什么默认级别不是串行化?答案是吞吐量,串行化把并发几乎压成了排队。另一个常被忽略的点是「读已提交」和「可重复读」的差别来自快照的生成时机——前者每条语句生成一次,后者整个事务共用一次。
4.2 锁的类型、粒度与意向锁存在的理由
按强度分,共享锁(S 锁)允许别人也读、排他锁(X 锁)独占;按粒度分行锁、页锁、表锁;按用途还有意向锁。意向锁的作用经常被误解,它不是用来锁数据的,而是用来「快速判断表里有没有行被锁住」——没有它,一个事务想给整张表加锁时,得逐行扫描确认有没有行锁,代价太高。
还有一个必须分清的概念是快照读与当前读。普通SELECT走快照读,读的是事务开始时(或语句开始时)的一致性视图,不加锁;SELECT ... FOR UPDATE、UPDATE、DELETE走当前读,读最新已提交版本并加锁。事务里想做「先查余额再扣减」,查询那一步必须用FOR UPDATE,否则两个事务同时读到旧余额,扣减就串了。
-- 会话 A BEGIN; SELECT balance FROM account WHERE id = 1 FOR UPDATE; -- 当前读,加排他锁 UPDATE account SET balance = balance - 100 WHERE id = 1; COMMIT;这段的关键在FOR UPDATE:它把快照读变成了当前读,后续同一行的并发修改会被阻塞到本事务提交。参数上要注意,FOR UPDATE只在事务块内有意义,自动提交模式下语句一结束锁就释放,起不到保护作用。
4.3 死锁怎么产生、怎么定位
死锁的教科书场景是两个事务以相反顺序拿两把锁。
-- 会话 A -- 会话 B BEGIN; BEGIN; UPDATE t SET v = 1 WHERE id = 1; UPDATE t SET v = 2 WHERE id = 2; UPDATE t SET v = 1 WHERE id = 2; -- 等待 A 释放 id=2 -- 会话 A 执行下一句: UPDATE t SET v = 2 WHERE id = 1; -- UPDATE t SET v = 1 WHERE id = 2; -- 等待 B 释放 id=1两边互等,形成环路,数据库会自动检测并牺牲其中一个事务回滚,报出死锁错误。定位手段是先看错误信息里给出的语句和被回滚的事务,再看死锁日志,日志里会明确写出两个事务各持有什么锁、在等什么锁。
规避死锁的三条实践:固定加锁顺序(所有事务都按主键升序更新)、缩短事务(别在事务里做网络请求或等用户输入)、让更新语句走索引(走全表扫描会锁住大量行,把不相干的事务也卷进来)。第三条与下一章的索引直接呼应,很多「莫名其妙死锁」的根因是没有索引导致的锁范围扩大。
4.4 事务边界画在代码的哪一行
事务边界指的是BEGIN和COMMIT之间的范围。它应该只包住「必须一起成功或一起失败」的数据库操作,越长越容易出问题:锁持有时间变长、死锁概率上升、连接池被占满。
实验里被扣分最多的写法是把业务查询、日志打印、文件写入全部塞进事务。文件写进去之后事务回滚,磁盘上的文件不会跟着回滚,于是数据与文件不一致。这类操作应当放到COMMIT之后,用「先提交、再通知」的顺序,接受最终一致。
还有一个隐蔽点:DDL 语句在很多数据库里会隐式提交当前事务。也就是说事务里如果混进了建表、改表、清空表,ROLLBACK只能回滚 DDL 之前的部分,看着像回滚失败。稳妥做法是把建表脚本和业务脚本分开执行,别在同一个事务里混着来。
5. 易错与易漏:索引失效、执行计划误读与验收环节的五处易错点
5.1 函数写在 WHERE 左边:结果对,索引废
易漏点:WHERE DATE_FORMAT(created_at, '%Y-%m') = '2025-04'这类写法业务上完全正确,慢查询也只在数据量大时才显形,实验数据几十行根本感觉不到。
为什么漏:索引是对列值排好序的结构,列一旦被函数包住,数据库只能对每一行先算出函数值再比较,等于是全表扫描。结果是正确的,代价是隐形的。
怎么补:把函数从列上挪到常量侧,改成范围查询created_at >= '2025-04-01' AND created_at < '2025-05-01';类型上也要留意,字符串列拿数字去比、字符集不一致的列做连接,都会触发隐式转换而放弃索引。写完一条查询,习惯性在EXPLAIN里看一眼type是不是从ALL变成了range或ref。
5.2 执行计划只看 type,不看 rows 和 Extra
易漏点:type显示ref就以为索引用上了,忽略rows仍然是几十万、Extra里还挂着Using filesort和Using temporary。
为什么漏:type只说明「访问方式」,不说「扫了多少」。一个走索引但扫描行数巨大的查询,实际耗时可能不如全表扫描。
怎么补:读计划的固定顺序是先看key(用了哪个索引)、再看rows(预估扫描行数)、最后看Extra。Using index是好事,说明覆盖索引命中、不用回表;Using filesort说明排序没走上索引;Using temporary多半出在GROUP BY或DISTINCT上。优化方向也随之明确:给排序字段或分组字段补上合适的联合索引。
5.3 触发器在批量语句下只照顾到一行
易漏点:单行插入测通了,批量插入时日志表少记了记录,或者汇总数只加了一次。
为什么漏:不同数据库的触发器模型不一样。行级触发器逐行执行,逻辑天然对;语句级触发器一条语句只执行一次,此时inserted、deleted这类伪表里是多行数据,如果按单行方式取值,只能拿到其中一行。
怎么补:写触发器之前先确认当前数据库是行级还是语句级。行级就把逻辑写成「只处理 NEW 和 OLD 这一行」;语句级必须用集合操作,把伪表整体参与连接或聚合,例如INSERT INTO log SELECT id, ... FROM inserted,绝对不要用变量去接伪表里的单个值。写完之后用一条影响多行的 UPDATE 测一次,比写十条单行测试都有效。
5.4 事务里混进 DDL,回滚回不干净
易漏点:脚本里先CREATE TABLE再INSERT,最后ROLLBACK,以为临时表会消失、数据会撤销,实际结构和部分数据都还在。
为什么漏:建表、改表、清空表这类语句会隐式提交当前事务,前面挂起的事务被悄悄结束,ROLLBACK只能作用于它之后的部分。控制台上没有任何提示,顺序执行看不出异常。
怎么补:把结构变更脚本与数据操作脚本物理分开成两个文件;事务块里只允许出现INSERT、UPDATE、DELETE、SELECT;做回滚测试时用一个单独的小事务,别拿整套初始化脚本当试验田。
5.5 存储过程和视图的所有者一换就报权限错
易漏点:本机跑得好好的过程,换台机器或者换账号导入后调用报「拒绝访问」,代码一个字没改。
为什么漏:过程和视图默认按定义者权限执行,创建者的账号信息会一并写进定义里。换环境后那个账号不存在,或者没有对应表的权限,调用就失败了。另一个同源问题是改了过程体却忘了重新执行CREATE,调用时还在跑旧版本,行为对不上代码。
怎么补:导出脚本时检查并清理定义者信息,或者显式声明按调用者权限执行(SQL SECURITY INVOKER);每次修改过程体都要重新执行一遍创建语句,别指望它自动生效;交付前换一个普通账号完整调用一次,这个动作能提前暴露九成权限问题。
6. 复习清单与查漏补缺:按题号过一遍而不是按章节过一遍
复习到这一步,最容易犯的错是按教材目录从头翻,翻完发现「都看过」但「都写不出」。更有效的方式是按实验题目逐条自查,看每个模块能不能当场演示,而不是能不能复述概念。
把下面这张表当验收单用,右边一列只要有一项答不上来,就回到对应章节补。
| 模块 | 必须能当场做出的动作 | 常见失分 |
|---|---|---|
| 存储过程 | 写出带 IN/OUT 参数的过程并正确调用 | OUT 参数传常量、忘记 DELIMITER |
| 游标 | 声明、打开、取数、关闭四步齐全 | 声明顺序错、NOT FOUND 复用 |
| 触发器 | 说明时机与粒度,写出带 OLD/NEW 的逻辑 | 批量语句下只处理一行 |
| 事务 | 演示一次回滚、说明隔离级别差异 | 事务里混 DDL、忘记 FOR UPDATE |
| 索引 | 用 EXPLAIN 对比建索引前后 | 只看 type、WHERE 左边写函数 |
| 完整性 | 说清约束与触发器的分工 | 能用 CHECK 却硬写触发器 |
再给一个「从零重跑」的顺序,考试或答辩前照着走一遍:先执行建表脚本并检查外键与约束是否生效;再单独执行存储过程创建脚本,用两组参数各调用一次,包括一次失败路径;然后用一条影响多行的语句验证触发器;接着开两个会话手工制造一次锁等待,观察阻塞与释放;最后对三条核心查询各跑一次EXPLAIN,把建索引前后的type和rows拍照留档。这套流程走完,报告里的每一段结论都有对应的现场证据。
我自己的教训是第一次做这类综合实验时,所有脚本写在一个文件里一口气跑完,报错就从头再跑一遍,结果每次错的地方都不一样,排查全靠猜。后来改成按模块拆文件、每段跑完先验证再往下走,翻车次数直接掉下来。从那以后我每次交实验前都强制走一遍上面那张自查表,哪怕时间只剩二十分钟,也要把存储过程和触发器各调用一次——能跑通和被问不倒,是两回事。希望帮到你。
本文还有配套的精品资源,点击获取