码上问学问代码之理,学编程之道

第 8 章 · 视图与存储过程

作品列表页要「作品 + 分类名」,就得写一句二表 LEFT JOIN——可这句 SQL 你在首页推荐位、列表页、详情页、后台列表里要抄四遍,改一次得改四处。本章教你用视图把这段 SQL 打包成一个名字,以后一句 SELECT * FROM v_article_list 就够;顺带入门存储过程,认识另一种「把逻辑存进数据库」的工具。

为什么需要视图

给一段常用 SQL 起个名字
视图 = 给一段常用 SQL 起名字:基表是实景,视图是固定的「观景窗」基表(真实数据)articlecategoryCREATE VIEW视图 v_article_list(虚拟表)titlecategoryauthor不存数据,只存「这条 SELECT」好处:口径统一(列表页、首页、后台都查同一个视图)· 简化 SQL · 权限隔离基表数据变了,视图「看到」的也随之变——窗户不变,风景在动
先讲为什么你每天做早饭,不会每次都从和面开始——你会买好现成的包子,蒸一下就行。视图就是数据库里的「半成品包子」:把一段复杂、反复要用的 SELECT 先存起来、取个名字,之后谁要用,直接查这个名字即可。
「古典文学网」的作品列表就是典型:一次要拿 article 的 10 个字段,再加上 category 里的分类名。这句 JOIN 属于「固定口径」,正适合做成视图。

CREATE VIEW:建一张虚拟表

语法只有一句
先讲为什么先别急着建「作品列表」这么大的视图,用一个最小的例子把语法摸熟:凡是「我老是这么查」的东西,都值得包成视图。比如「唐朝的作品有哪些」——后台、运营、导出都得用。
动手做把「唐朝作品」的查询存成一个叫 v_tang_article 的视图。语法结构就是 CREATE VIEW 名字 AS 一条 SELECT。
建视图.sqlsql可运行
CREATE VIEW v_tang_article AS
SELECT id, title, author, views
FROM article
WHERE dynasty = '唐';
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 视图 v_tang_article 已创建

建好之后,它就真的像一张表。直接 SELECT 它:

查视图.sqlsql可运行
SELECT * FROM v_tang_article;
查询结果SQL 执行结果
运行结果: id | title | author | views 1 | 静夜思 | 李白 | 328 2 | 春望 | 杜甫 | 256 6 | 陋室铭 | 刘禹锡 | 203 -- 共 3 行
讲透
  • 视图的列名默认取 SELECT 的列名或别名。想改名就在 SELECT 里用 AS;也可以在创建时显式列出:CREATE VIEW v_tang (编号, 标题) AS …。
  • 结果里「唐」的作品是 3 篇:静夜思、春望、陋室铭。注意 陋室铭 虽然分类属于「散文」,但它的 dynasty 确实是「唐」,所以被筛进来——视图筛的是朝代,不是分类。
  • 视图只存定义不存数据:如果你现在 UPDATE article SET views = 500 WHERE id = 1,再查这个视图,静夜思的 views 立刻变成 500。因为每次查视图,数据库都是把那条 SELECT 重跑一遍。
  • 用 CREATE OR REPLACE VIEW 更省事:视图已存在时直接覆盖定义,不用先 DROP。hiwen.sql 里建 v_article_list 用的就是它——脚本才能重复执行。
  • 想看某个视图的完整定义,用 SHOW CREATE VIEW v_tang_article;。
避坑视图的列在创建那一刻就固定下来了。用 SELECT * 建视图是给自己埋雷:以后给 article 加了新字段,视图不会自动带上它;反过来,如果你把视图里引用到的某列删了,这个视图会直接失效,一查就报 Unknown column。所以建视图时请把列名一个个写清楚。

用、改、删视图

把它当成只读的表
动手做视图可以出现在 FROM 的位置,所以 WHERE、ORDER BY、LIMIT、甚至和别的表 JOIN 都照用。先在视图上做一次筛选排序。
查视图.sqlsql可运行
SELECT title, views
FROM v_tang_article
WHERE views > 250
ORDER BY views DESC;
查询结果SQL 执行结果
运行结果: title | views 静夜思 | 328 春望 | 256 -- 共 2 行
动手做需求变了——想多看两列、还要排掉草稿。用 ALTER VIEW 换掉定义,用完再用 DROP VIEW 删掉。
改删视图.sqlsql可运行
-- 改定义:整体替换,不是「加一列」
ALTER VIEW v_tang_article AS
SELECT id, title, author, dynasty, views, likes
FROM article
WHERE dynasty = '唐' AND status = 1;

-- 删视图
DROP VIEW IF EXISTS v_tang_article;
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected Query OK, 0 rows affected -- ALTER 后定义已整体替换;DROP 后 v_tang_article 不再存在
讲透
  • ALTER VIEW 是「重新定义」,不是增量修改:你写的这条 SELECT 会把旧定义整段盖掉。CREATE OR REPLACE VIEW 效果一样,接受度更高。
  • DROP VIEW 只删视图本身,底层表的数据一根毛都不动——这是它和 DROP TABLE 的本质区别。DROP TABLE article 会把 6 篇作品全删掉;DROP VIEW v_tang_article 删掉的只是一段定义文字。
  • 加 IF EXISTS 更安全:视图不存在时 DROP VIEW 会报错打断脚本,加上它就不报错。hiwen.sql 里对表也是这么做的。
  • 一句话记牢:视图是「读的门」,不是「写的门」——这一点下一节详细说。

看真家伙:v_article_list

本项目里最忙的一个视图
先讲为什么翻开 pyweb/sql/hiwen.sql,里面已经建好了一个视图 v_article_list。它是整个网站被查得最多的对象:作品列表页、首页推荐位、我的收藏、搜索结果,背后都是它在干活。看懂它,你就看懂了视图在真实项目里的价值。
动手做这就是它的定义原文——把 article 和 category 一次连起来,还顺手把「分类 id」翻译成了「分类名」。
v_article_list 定义.sqlsql可运行
CREATE OR REPLACE VIEW `v_article_list` AS
SELECT a.`id`, a.`title`, a.`author`, a.`dynasty`,
       c.`name` AS `category_name`,
       a.`cover`, a.`summary`, a.`views`, a.`likes`, a.`is_recommend`, a.`created_at`
FROM `article` a
LEFT JOIN `category` c ON a.`category_id` = c.`id`
WHERE a.`status` = 1;
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 视图 v_article_list 已创建(覆盖旧定义)

于是「作品列表页」要的那 11 个字段,一句查询就全出来了——再也不用在 Python 里拼 JOIN:

列表页.sqlsql可运行
SELECT id, title, author, dynasty, category_name, views, likes
FROM v_article_list
ORDER BY views DESC;
查询结果SQL 执行结果
运行结果: id | title | author | dynasty | category_name | views | likes 3 | 念奴娇·赤壁怀古 | 苏轼 | 宋 | 宋词 | 412 | 58 1 | 静夜思 | 李白 | 唐 | 唐诗 | 328 | 42 4 | 声声慢 | 李清照 | 宋 | 宋词 | 298 | 47 2 | 春望 | 杜甫 | 唐 | 唐诗 | 256 | 31 6 | 陋室铭 | 刘禹锡 | 唐 | 散文 | 203 | 26 5 | 天净沙·秋思 | 马致远 | 元 | 元曲 | 187 | 22 -- 共 6 行

首页推荐位只要在视图上加一个条件:

推荐位.sqlsql可运行
SELECT id, title, category_name, views
FROM v_article_list
WHERE is_recommend = 1
ORDER BY views DESC
LIMIT 6;
查询结果SQL 执行结果
运行结果: id | title | category_name | views 3 | 念奴娇·赤壁怀古 | 宋词 | 412 1 | 静夜思 | 唐诗 | 328 2 | 春望 | 唐诗 | 256 -- 共 3 行
讲透
  • 为什么用 LEFT JOIN 而不是 JOIN:article.category_id 允许为 NULL(外键定义是 ON DELETE SET NULL,分类被删时作品仍在)。用内连接的话,这些作品会凭空消失;用左连接它们还在,只是 category_name 为 NULL。
  • 为什么必须 WHERE a.status = 1:草稿不该出现在任何列表页。把这个口径写进视图,就不怕某个接口忘了加过滤。
  • 它和后端接口是一一对应的:视图输出的 id / title / author / dynasty / category_name / cover / summary / views / likes,正好就是接口文档里 GET /api/article/list 返回的字段。(看接口文档)
  • 「视图 + 一个 WHERE」就是筛选功能:按分类筛加 WHERE category_id = 1,按朝代筛加 WHERE dynasty = '唐'。骨架不变,只换条件——这就是视图带来的统一。

视图能不能改数据

多数时候:不能
先讲为什么视图看起来就是一张表,很多人自然以为「往视图里 INSERT 一条,就等于往真表插一条」。对简单视图能,对复杂视图不行。这背后的原因很朴素:数据库得知道「你这一行,到底该写进哪张表的哪一行」。
动手做拿刚建好的 v_article_list 试一下写入,看看数据库怎么回答你。
试着写视图.sqlsql可运行
INSERT INTO v_article_list (title, author) VALUES ('新诗', '无名氏');
查询结果SQL 执行结果
运行结果: ERROR 1471 (HY000): The target table v_article_list of the INSERT is not insertable-into -- 拒绝写入:这是个多表连接的视图
讲透:什么算「可更新视图」
  • 可更新的(简单视图):只查一张表,且不含聚合函数、GROUP BY、DISTINCT、UNION、子查询、计算列。这种视图和底层表是「一行对一行」的,INSERT / UPDATE / DELETE 都能透传到底表。
  • 只读的(复杂视图):涉及 JOIN、聚合、分组去重的,一律不可写。v_article_list 就是这一类。
  • 为什么不能:它的一行是 article 和 category 两张表拼出来的。假设你 UPDATE v_article_list SET category_name = '诗歌',数据库该改 category.name?还是给 article 塞个新分类?没法判断,所以干脆禁止。
  • 不可写不代表没用:本项目里视图只承担「读」的职责,所有增删改都直接落到具体的表上——这正是好的分工。
避坑别拿视图当写入口。就算是「可更新视图」,UPDATE 也是直接作用到底层表上的——你以为在改视图,实际改的是真数据,而且没有回头路。要改数据,请明确地对着表写 SQL(UPDATE article SET …),别绕道视图。

存储过程入门

把一段 SQL 脚本存进数据库
先讲为什么视图只能装「一条 SELECT」。但有些活儿是一串步骤:先查一下、判断一下、再更新、再记条日志。这时视图就不够用了。存储过程(Stored Procedure)是存在数据库里的一段可执行脚本,能带参数、能写多个语句、能有 if 和循环,调用时一句 CALL 名字(参数) 就行。
动手做做一个「按朝代统计作品数」的过程:传入朝代名,它返回篇数。注意,过程体里句句带分号,所以要先 临时把语句结束符改成 $$,写完再改回来。
建存储过程.sqlsql可运行
DELIMITER $$

CREATE PROCEDURE p_count_by_dynasty(IN p_dynasty VARCHAR(20))
BEGIN
  SELECT COUNT(*) AS 篇数
  FROM article
  WHERE dynasty = p_dynasty;
END $$

DELIMITER ;
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 存储过程 p_count_by_dynasty 已创建

调用它,参数写在小括号里:

调用.sqlsql可运行
CALL p_count_by_dynasty('唐');
查询结果SQL 执行结果
运行结果: 篇数 3 -- 共 1 行

用完删掉它(存储过程也是要清理的):

删除.sqlsql可运行
DROP PROCEDURE IF EXISTS p_count_by_dynasty;
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 存储过程已删除
讲透
  • DELIMITER $$ 是给客户端看的,不是 SQL 语法:mysql 命令行客户端默认用分号判断「一句话说完了」,可过程体里全是分号,客户端会误判提前截断。所以临时把结束符换成 $$,过程写完了再换回 ;。
  • IN 是入参:调用时传进去的值。除此之外还有 OUT(把结果带回给调用方)和 INOUT(既进又出)。
  • 过程存在数据库里,能被任何应用复用。查看它们:SHOW PROCEDURE STATUS WHERE db = 'hiwen'; 或 SELECT routine_name FROM information_schema.routines WHERE routine_schema = 'hiwen';
  • 顺手对比一下:如果要一次性看所有朝代的统计,其实第 4 章的 GROUP BY 一条就够了——SELECT dynasty, COUNT(*) AS 篇数 FROM article GROUP BY dynasty; 会得到 唐 3、宋 2、元 1。存储过程的优势在于带参数、可复用,不在「能统计」。
避坑别把业务逻辑大搬家到存储过程里。业务一旦分散在「Python 代码 + 存储过程」两处,排查问题时你会来回翻;存储过程也进不了 Git 的 diff、写不了单元测试、换数据库几乎要重写。所以在 Web 项目里,只把极少数「纯数据统计、被多处复用」的逻辑放进存储过程,其余留在后端代码里。另外,DELIMITER 只在 mysql 命令行客户端有效,图形工具(Navicat 等)有各自的处理方式,别把带 DELIMITER 的脚本直接丢进去执行。

视图 / 存储过程 / 普通查询怎么选

一句话决策
怎么选:普通查询 / 视图 / 存储过程——两道选择题一段 SQL只在这里用一两次?多处复用?直接写在代码里不必包装,越直白越好继续问:有多步逻辑/参数吗?还是一条 SELECT 能表达?一条 SELECT多步 + 入参建视图只读口径,人人可查写存储过程带参数的一段脚本一句话决策:查询复用选视图,流程复用选存储过程;一次性需求谁也别包装
动手做把三者摆在一起对比,遇到需求时按这张表对号入座。
方式本质适合不适合
普通查询现场写的 SELECT一次性、临时的取数同一段长 SQL 到处复制
视图存起来的「一条 SELECT」口径固定的复合查询(作品列表)需要传参数的场景
存储过程存起来的「一段可执行脚本」多步逻辑、要参数、要分支循环简单查询;需要跨数据库移植
讲透:最关键的差别是「能不能带参数」
  • 视图不能带参数。所以「按朝代查作品列表」不能做成 v_article_by_dynasty('唐')——只能建视图 v_article_list,然后在外面补 WHERE dynasty = '唐'。这是视图最大的限制。
  • 正因如此,本项目只建了一个视图:把「最稳定、最通用」的那部分(作品 + 分类名 + 只取已发布)固定下来,其余筛选条件都交给调用方拼。
  • 存储过程的参数是它的看家本领,但代价是逻辑离开代码库、进数据库。团队协作里,代码审查、版本管理都更难,所以项目二(Flask)里的业务逻辑一律写在 Python 侧。
  • 本项目的选择是:视图用,存储过程基本不用。你要能说清「为什么不用」,比背下语法更重要。
动手练习
1建一个视图 v_article_comment_stat,显示每篇作品的 id、title 和它的书评条数(别名 comment_count)。注意:没有书评的作品也要出现,条数显示 0。
2写一个存储过程 p_count_by_category(IN p_cat VARCHAR(30)),传入分类名(如「唐诗」),返回该分类下的作品数;调用一次并删掉它。
3迁移练习:「红影赏析」有 movie 表(id、title、genre、rating、director_id)和 director 表(id、name)。请建一个视图把「电影 + 导演名」固化下来,然后解释:为什么这个视图不能用来 INSERT?
参考答案
练习答案.sqlsql可运行
-- 第 1 题:作品 + 书评条数
-- 用 LEFT JOIN 保证没书评的作品也在;条数用 COUNT(c.id)(COUNT(*) 会把 NULL 那行也数成 1)
CREATE OR REPLACE VIEW v_article_comment_stat AS
SELECT a.id, a.title, COUNT(c.id) AS comment_count
FROM article a
LEFT JOIN comment c ON c.article_id = a.id AND c.status = 1
GROUP BY a.id, a.title;

SELECT * FROM v_article_comment_stat ORDER BY id;

-- 第 2 题:按分类名统计作品数
DELIMITER $$
CREATE PROCEDURE p_count_by_category(IN p_cat VARCHAR(30))
BEGIN
  SELECT COUNT(*) AS 篇数
  FROM article a
  JOIN category c ON a.category_id = c.id
  WHERE c.name = p_cat;
END $$
DELIMITER ;

CALL p_count_by_category('唐诗');

DROP PROCEDURE IF EXISTS p_count_by_category;

-- 第 3 题(红影赏析,表结构不同,思路一致)
CREATE OR REPLACE VIEW v_movie_list AS
SELECT m.id, m.title, m.genre, m.rating, d.name AS director_name
FROM movie m
LEFT JOIN director d ON m.director_id = d.id;
查询结果SQL 执行结果
运行结果: -- 第 1 题 Query OK, 0 rows affected id | title | comment_count 1 | 静夜思 | 2 2 | 春望 | 0 3 | 念奴娇·赤壁怀古 | 1 4 | 声声慢 | 0 5 | 天净沙·秋思 | 0 6 | 陋室铭 | 0 -- 共 6 行 -- 第 2 题 Query OK, 0 rows affected 篇数 2 -- 共 1 行 Query OK, 0 rows affected -- 第 3 题(在红影的库里执行) Query OK, 0 rows affected -- INSERT INTO v_movie_list ... 会报 ERROR 1471, -- 因为它是 movie 与 director 两张表连出来的视图,数据库无法确定新行该落到哪张表

第 1 题最容易错的地方是写成 COUNT(*):左连接后,没书评的作品会留一行 c.* 全为 NULL 的记录,COUNT(*) 会把它算成 1。写 COUNT(c.id) 才会得到 0。这就是第 4 章讲过的那条规则——COUNT(列) 忽略 NULL。

本章小结