从查询封装到一致性边界:MySQL View、事务、存储过程与游标实战

SQL 学到 SELECT / JOIN / GROUP BY 之后,很容易产生一种错觉:数据库无非就是“把 SQL 写得更复杂”。但真正进入业务系统后,会不断遇到另外四类问题:复杂查询如何复用、多个写操作如何保证一致、数据库内部如何封装一段流程、必须逐行处理时该怎么办

这四类问题,分别对应 View(视图)Transaction(事务)Stored Procedure(存储过程)Cursor(游标)

本文以提供的《SQL 必知必会》相关资料为主线,并结合 MySQL 8.4 官方文档补充当前实现细节,同时修正几个老资料里容易误导的点。

1. 先建立整体认识:四个概念分别解决什么问题

可以先把它们放到一张图里理解:

能力 核心问题 思维方式 是否直接保存业务数据 典型用途
View 如何封装和复用查询 面向集合 报表、权限隔离、复杂 JOIN 封装
Transaction 多条操作如何成为一个一致性单元 一致性边界 不额外保存业务数据 转账、扣库存、结算、状态流转
Stored Procedure 如何把数据库内的一段流程封装起来 面向过程 + SQL 保存的是程序定义 批处理、数据库侧封装、遗留系统
Cursor 如何逐行处理结果集 面向过程 每行规则不同、无法自然集合化的处理

一句话记忆:

View 管“怎么看”,Transaction 管“一起成不成功”,Stored Procedure 管“数据库里怎么执行一段流程”,Cursor 管“结果集如何一行一行处理”。

工程上还有一个非常重要的优先级:

能用集合 SQL 解决,就优先使用集合 SQL;只有确实需要逐行状态和分支时,再考虑游标。


2. View:把查询封装成一个稳定的数据接口

2.1 View 到底是什么

视图可以理解为一个有名字的查询定义。查询视图时,数据库根据视图背后的 SELECT 生成结果集,因此它表现得像一张表,但普通视图本身并不是另一份独立业务数据。

这也是为什么资料里把 View 称为“虚拟表”:应用不需要知道底层到底连接了几张表,只需要面向一个稳定的查询接口。

例如订单系统中,页面经常需要展示订单、用户和已支付金额:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
CREATE OR REPLACE VIEW v_order_summary AS
SELECT
o.id AS order_id,
o.order_no,
o.user_id,
u.nickname,
o.order_amount,
COALESCE(SUM(p.pay_amount), 0) AS paid_amount,
o.status,
o.created_at
FROM orders o
JOIN users u ON u.id = o.user_id
LEFT JOIN payment p
ON p.order_id = o.id
AND p.status = 'SUCCESS'
GROUP BY
o.id,
o.order_no,
o.user_id,
u.nickname,
o.order_amount,
o.status,
o.created_at;

以后查询端只需要:

1
2
3
4
SELECT *
FROM v_order_summary
WHERE created_at >= '2026-08-01'
AND status = 'PAID';

复杂的 JOIN、聚合、字段别名都被收进了 View。

2.2 View 最有价值的几个场景

2.2.1 封装复杂 JOIN

当很多报表都重复使用同一段关联逻辑时,把公共查询封装为视图,可以减少 SQL 重复。

但“能复用”不代表要无限嵌套。view_a -> view_b -> view_c -> view_d 这种依赖链太长后,排查执行计划和字段来源会变得痛苦。视图是抽象,不是俄罗斯套娃大赛。

2.2.2 控制暴露字段

假设员工表中同时存在姓名、部门、手机号、身份证、薪资等字段,而普通业务只允许看到姓名和部门,可以建立一个只暴露必要字段的视图:

1
2
3
4
5
6
7
CREATE VIEW v_employee_public AS
SELECT
id,
employee_no,
employee_name,
department_id
FROM employee;

再通过数据库权限控制让某个账号只访问该视图,而不是直接访问基础表。

2.2.3 格式化和计算字段

视图不只用于“少看几列”,也可以把常用表达式封装起来:

1
2
3
4
5
6
7
8
CREATE VIEW v_product_price AS
SELECT
id,
product_name,
price,
discount_rate,
ROUND(price * discount_rate, 2) AS sale_price
FROM product;

这样调用方不用每次重复写计算公式。

2.3 创建、修改和删除视图

创建

1
2
CREATE VIEW view_name AS
SELECT ...;

MySQL 还支持:

1
2
CREATE OR REPLACE VIEW view_name AS
SELECT ...;

修改

1
2
ALTER VIEW view_name AS
SELECT ...;

删除

1
DROP VIEW IF EXISTS view_name;

2.4 MySQL 的 View Algorithm:MERGE、TEMPTABLE、UNDEFINED

MySQL 的 CREATE VIEW 支持一个非标准扩展:

1
2
CREATE ALGORITHM = MERGE VIEW v_xxx AS
SELECT ...;

三个值可以先这样理解:

  • MERGE:尽量把外层查询和视图定义合并后优化;
  • TEMPTABLE:把视图结果先物化为临时结果再供外层使用;
  • UNDEFINED:不强制指定,由 MySQL 决定。

多数业务代码不需要刻意指定它,但看到历史 SQL 中的 ALGORITHM 时要知道它控制的是视图的处理方式,不是“给视图建索引”。MySQL 普通视图本身没有索引,真正能利用的是基础表上的索引。

2.5 View 能不能 UPDATE

答案不是简单的“能”或“不能”。

MySQL 支持可更新视图,前提是视图中的一行能够明确映射到基础表中的一行。包含复杂聚合、GROUP BY、某些连接或其他破坏一一映射关系的结构时,通常就不能直接更新。

例如:

1
2
3
4
5
CREATE VIEW v_active_user AS
SELECT id, nickname, status
FROM users
WHERE status = 'ACTIVE'
WITH CHECK OPTION;

WITH CHECK OPTION 的意义是:通过这个视图进行 INSERT / UPDATE 后,行仍然必须满足视图的 WHERE status = 'ACTIVE' 条件。

换句话说,视图既可以作为“读接口”,在合适条件下也能成为受约束的写入口;不过工程中一般仍建议把复杂写逻辑放在明确的业务层或事务中,而不是依赖视图的隐式更新能力。

2.6 View、临时表和 CTE 怎么选

对比项 View Temporary Table CTE (WITH)
定义生命周期 持久保存在数据库元数据中 一般随会话/连接 单条 SQL
是否存放中间数据 普通视图不单独存 通常是逻辑查询表达式
复用范围 跨 SQL、跨会话 当前会话 当前 SQL
适合场景 稳定查询接口 多步骤中间计算 提升单条复杂 SQL 可读性

一个简单判断:

  • 这是一个长期稳定的查询接口:View
  • 这是一次流程中的中间结果:临时表
  • 只是想把一条 SQL 拆清楚:优先考虑 CTE

3. Transaction:把多条 SQL 变成一个一致性单元

3.1 事务的本质:不是“几条 SQL 放在一起”,而是一个一致性边界

最经典的例子是转账:

  1. A 账户减 100;
  2. B 账户加 100。

如果第一步成功、第二步失败,数据就坏了。事务要求这两个动作被当成同一个逻辑单元:全部成功,或者全部撤销

1
2
3
4
5
6
7
8
9
10
11
START TRANSACTION;

UPDATE account
SET balance = balance - 100
WHERE id = 1;

UPDATE account
SET balance = balance + 100
WHERE id = 2;

COMMIT;

发生异常时:

1
ROLLBACK;

3.2 ACID 四个特性

Atomicity - 原子性

事务中的操作是一个不可分割的整体。出现失败时,要把该事务产生的修改撤销到事务开始前的状态。

Consistency - 一致性

事务前后都必须满足业务和数据库约束。

比如“账户余额不能无缘无故消失”“订单实付不能为负”“唯一键不能出现重复”,这些才是一致性的具体内容。

Isolation - 隔离性

多个事务并发执行时,数据库需要控制彼此能看到什么、会互相等待什么,从而避免并发异常。

Durability - 持久性

事务一旦成功提交,结果应当具有持久性。InnoDB 会结合日志机制、缓冲等保证崩溃恢复能力。

3.3 MySQL 默认 autocommit 到底意味着什么

MySQL 新连接默认开启 autocommit。在 InnoDB 中,这意味着没有显式事务时,通常每条 SQL 都独立形成一个事务。

1
SELECT @@autocommit;

显式事务是更推荐的多语句写法:

1
2
3
4
5
6
7
8
9
10
11
START TRANSACTION;

UPDATE inventory
SET available_qty = available_qty - 1
WHERE sku_id = 10001
AND available_qty > 0;

INSERT INTO order_item(order_id, sku_id, qty)
VALUES (90001, 10001, 1);

COMMIT;

即使 autocommit = 1,只要显式执行 START TRANSACTION,后面的多条语句仍然会等到 COMMIT / ROLLBACK 才结束事务。

3.4 SAVEPOINT:不是所有失败都必须从头回滚

复杂事务中可以设置保存点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
START TRANSACTION;

UPDATE orders
SET status = 'PROCESSING'
WHERE id = 90001;

SAVEPOINT after_order_status;

INSERT INTO order_operation_log(order_id, operation)
VALUES (90001, 'CREATE_SETTLEMENT_LOG');

-- 如果后续某个非核心步骤失败
ROLLBACK TO SAVEPOINT after_order_status;

COMMIT;

相关命令:

1
2
3
SAVEPOINT sp_name;
ROLLBACK TO SAVEPOINT sp_name;
RELEASE SAVEPOINT sp_name;

需要注意,SAVEPOINT 不是“子事务”,它只是当前事务内部的一个回滚标记。

3.5 最容易踩的坑:DDL 可能让事务提前结束

很多人会写出类似代码:

1
2
3
4
5
6
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;

ALTER TABLE account ADD COLUMN remark VARCHAR(255);

ROLLBACK;

然后期待 UPDATE 也一起撤销。

不要这样想。 MySQL 8.4 虽然支持 atomic DDL,但 atomic DDL 不等于 transactional DDL。很多 DDL 会隐式结束当前事务,相当于先发生一次提交。

所以业务事务中不要混入 CREATE / ALTER / DROP 之类 DDL。

3.6 并发事务的三类经典异常

脏读 Dirty Read

事务 A 读到了事务 B 尚未提交的数据。B 随后回滚,于是 A 读到的内容从未真正生效。

不可重复读 Non-repeatable Read

同一个事务里,两次读取同一行,结果发生变化。典型原因是另一个事务已经提交了对该行的 UPDATE / DELETE

幻读 Phantom Read

同一个事务按一个范围条件重复查询,结果集的行集合发生变化,典型场景是其他事务插入了满足条件的新行。

一个实用区分:

不可重复读更关注“这条记录的内容变了”;幻读更关注“这个范围里的记录集合变了”。

3.7 四种隔离级别

SQL 标准定义了四种常见隔离级别:

隔离级别 脏读 不可重复读 幻读 并发能力
READ UNCOMMITTED 可能 可能 可能
READ COMMITTED 避免 可能 可能 较高
REPEATABLE READ 避免 避免 标准层面不要求完全避免 中等
SERIALIZABLE 避免 避免 避免

但这里必须加一个 MySQL/InnoDB 特别说明

MySQL 8.4 的 InnoDB 默认隔离级别是 REPEATABLE READ。普通一致性读会使用事务快照;对于锁定读、UPDATEDELETE 等范围操作,InnoDB 还会使用 gap lock / next-key lock 等机制阻止特定范围内的并发插入。因此,不能只拿一张 SQL 标准表,就机械地推断每一种 InnoDB 查询一定会出现什么现象。

查看当前隔离级别:

1
SELECT @@transaction_isolation;

修改当前会话:

1
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

3.8 普通 SELECT 与锁定读

如果只是读一个一致性快照,普通 SELECT 通常不需要对读到的行加排他锁。

但如果你的业务逻辑是:

  1. 先查余额;
  2. 判断余额足够;
  3. 再扣余额;

那么普通 SELECT 可能不够,因为另一个事务可能在你查询后改变这行数据。可以使用锁定读:

1
2
3
4
5
6
7
8
9
10
11
12
START TRANSACTION;

SELECT balance
FROM account
WHERE id = 1
FOR UPDATE;

UPDATE account
SET balance = balance - 100
WHERE id = 1;

COMMIT;

FOR UPDATE 不是“让查询更安全”的万能按钮,它意味着更强的锁竞争。应该只在业务确实需要“读后写并保持这段期间的排他控制”时使用。

3.9 工程里写事务的几个原则

  1. 事务尽量短。 不要在事务里做 HTTP 调用、发消息、等用户输入。
  2. 访问顺序尽量一致。 多个业务都按相同顺序锁资源,可以降低死锁概率。
  3. WHERE 条件要有合适索引。 扫描范围越大,可能锁住的范围也越大。
  4. 死锁要设计重试。 死锁不是简单等价于数据库坏了,它是并发系统中的一种可预期冲突。
  5. 不要把长批处理塞进一个巨型事务。 巨型事务的 undo、锁持有时间和回滚成本都很高。

4. Stored Procedure:把数据库侧的一段流程封装起来

4.1 存储过程是什么

存储过程是保存在数据库服务端的一段程序化 SQL。它不仅可以执行查询,还可以包含变量、条件判断、循环、异常处理等流程控制。

基本形式:

1
2
3
4
5
6
7
8
9
DELIMITER //

CREATE PROCEDURE proc_name(IN p_id BIGINT, OUT p_result VARCHAR(32))
BEGIN
-- SQL / IF / LOOP / HANDLER ...
SET p_result = 'OK';
END //

DELIMITER ;

调用:

1
2
CALL proc_name(10001, @result);
SELECT @result;

4.2 IN、OUT、INOUT

1
2
3
4
5
6
7
8
CREATE PROCEDURE demo(
IN p_user_id BIGINT,
OUT p_total DECIMAL(18, 2),
INOUT p_counter INT
)
BEGIN
...
END;
  • IN:调用者传入;
  • OUT:过程内部赋值,再返回给调用者;
  • INOUT:既传入初始值,也把修改后的值带回。

4.3 DELIMITER 到底是什么

DELIMITER 不是业务 SQL 的一部分,它主要是 MySQL 客户端用于改变“这一整条语句在哪里结束”的客户端命令。

存储过程内部本身有大量 ;

1
2
3
4
BEGIN
SET a = 1;
SET b = 2;
END

如果客户端仍把第一个 ; 当成 CREATE PROCEDURE 的结尾,就无法把整个过程体一次发送给服务器。因此命令行常这样写:

1
2
3
4
5
6
7
8
9
DELIMITER $$

CREATE PROCEDURE p()
BEGIN
SELECT 1;
SELECT 2;
END $$

DELIMITER ;

不同 IDE/数据库工具可能替你处理这个细节,所以有时在 Navicat、DataGrip 等工具里看不到手写 DELIMITER

4.4 流程控制

存储程序可以包含:

  • IF / ELSEIF / ELSE
  • CASE
  • LOOP / LEAVE / ITERATE
  • WHILE
  • REPEAT ... UNTIL
  • DECLARE 局部变量;
  • SELECT ... INTO
  • HANDLER 异常/条件处理。

例如:

1
2
3
4
IF p_amount <= 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'amount must be positive';
END IF;

4.5 一个带事务与异常处理的存储过程

下面用积分转移演示“存储过程 + 事务”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
DELIMITER $$

CREATE PROCEDURE transfer_points(
IN p_from BIGINT,
IN p_to BIGINT,
IN p_amount INT
)
BEGIN
DECLARE v_balance INT;

DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;

IF p_amount <= 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'p_amount must be positive';
END IF;

START TRANSACTION;

SELECT points
INTO v_balance
FROM user_points
WHERE user_id = p_from
FOR UPDATE;

IF v_balance < p_amount THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'insufficient points';
END IF;

UPDATE user_points
SET points = points - p_amount
WHERE user_id = p_from;

UPDATE user_points
SET points = points + p_amount
WHERE user_id = p_to;

COMMIT;
END $$

DELIMITER ;

这里有一个特别容易混淆的点:

存储过程里的 BEGIN ... END 是复合语句块,不代表开启事务。

在 stored program 中如果需要显式开始事务,应使用 START TRANSACTION

4.6 老资料里一个需要修正的点:ALTER PROCEDURE 不能改过程体

有些教程会写“更新存储过程使用 ALTER PROCEDURE”,这句话不够严谨。

在 MySQL 8.4 中,ALTER PROCEDURE 可以修改 procedure 的特征,例如 COMMENTSQL SECURITY 等,但不能修改参数列表,也不能修改过程体

如果要修改参数或 SQL 逻辑,通常需要:

1
DROP PROCEDURE IF EXISTS transfer_points;

然后重新:

1
2
3
4
CREATE PROCEDURE transfer_points(...)
BEGIN
...
END;

所以生产环境里更应该把 procedure 的 DDL 脚本纳入 Git 和数据库迁移体系,而不是只在数据库 GUI 里手改。

4.7 存储过程的优点

封装

把一段复杂数据库操作集中起来,调用方只需要 CALL

权限边界

可以通过 routine 的执行权限与 SQL SECURITY 设计数据库侧的受控入口。

减少多次往返

如果一个流程原本需要应用与数据库多次交互,封装在服务器端有时能减少网络往返。

适合数据库中心型系统

一些遗留系统、ETL、定时批处理、强数据库逻辑系统仍然大量使用存储过程。

4.8 存储过程的代价

数据库绑定更重

MySQL、Oracle、SQL Server 的存储程序语法和能力差异很大,迁移成本高。

测试与调试体验通常弱于应用代码

Java 中可以用 IDE、单测、mock、覆盖率、静态分析;复杂 stored procedure 的工程生态通常没这么顺手。

业务逻辑容易分裂

一半规则在 Java,一半规则在 Procedure,时间久了很容易出现“这个状态到底是谁改的”的考古现场。

扩容维度不同

应用层通常更容易横向扩容;把大量 CPU 密集或流程性逻辑压到数据库上,会让数据库成为更重的计算节点。

因此现代互联网应用里更常见的策略是:

核心业务流程放应用层;数据库负责数据一致性、约束和高效集合运算。只有确实适合数据库侧执行的流程,才使用存储过程。


5. Cursor:当集合 SQL 不够自然时,再逐行处理

5.1 从“集合思维”到“逐行思维”

SQL 天生擅长表达:

1
我要哪些数据?

而不是:

1
先处理第 1 行,再处理第 2 行,然后根据当前状态决定下一步。

例如统计总生命值:

1
2
SELECT SUM(hp_max)
FROM hero;

这一行 SQL 已经完成任务,完全没必要拿游标循环 100 万行去自己累加。

但如果每一行都需要非常不同的分支,且规则又依赖逐行过程状态,游标就有价值。

5.2 MySQL Cursor 的特性

MySQL 的 server-side cursor 主要用在 stored program 中,并具有几个重要限制:

  • 只读:不能通过 cursor 本身直接更新它所指向的行;
  • 不可滚动:只能向前 FETCH,不能随意跳到上一行;
  • asensitive:服务器可能直接使用底层数据,也可能生成内部临时结果,应用不应依赖具体实现。

5.3 正确的声明顺序

MySQL 对 stored program 中的 DECLARE 顺序有要求:

  1. 局部变量 / condition;
  2. cursor;
  3. handler。

例如:

1
2
3
4
5
6
7
8
9
10
DECLARE done BOOLEAN DEFAULT FALSE;
DECLARE v_id BIGINT;
DECLARE v_amount DECIMAL(18, 2);

DECLARE cur CURSOR FOR
SELECT id, amount
FROM settlement_detail;

DECLARE CONTINUE HANDLER FOR NOT FOUND
SET done = TRUE;

5.4 游标的标准使用流程

在 MySQL stored program 中,可以记成:

1
DECLARE -> OPEN -> FETCH -> LOOP -> CLOSE

1. 声明

1
2
3
4
DECLARE cur_order CURSOR FOR
SELECT id, order_amount
FROM orders
WHERE status = 'WAIT_PROCESS';

2. 打开

1
OPEN cur_order;

3. 获取当前行

1
FETCH cur_order INTO v_id, v_amount;

4. 循环处理

配合 NOT FOUND handler 判断结果集结束。

5. 关闭

1
CLOSE cur_order;

5.5 老资料里另一个要修正的点:MySQL Cursor 不需要 DEALLOCATE

部分旧教程会把 Cursor 生命周期写成:

1
DECLARE -> OPEN -> FETCH -> CLOSE -> DEALLOCATE

甚至写出:

1
DEALLOCATE PREPARE cur_xxx;

这是把预处理语句游标混在了一起。

DEALLOCATE PREPARE 是释放 prepared statement 的语法,不是释放 MySQL stored-program cursor 的语法。MySQL 游标使用 CLOSE cursor_name;如果没有显式关闭,在所属 BEGIN ... END 块结束时也会自动关闭。

所以不要给 Cursor 强行多安排一个不存在的“葬礼仪式”。

5.6 一个完整 Cursor 例子

假设我们确实有一类“逐订单调整费率”的离线任务:不同金额区间采取不同规则,并且为了演示需要逐行更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
DELIMITER $$

CREATE PROCEDURE adjust_order_fee()
BEGIN
DECLARE done BOOLEAN DEFAULT FALSE;
DECLARE v_id BIGINT;
DECLARE v_amount DECIMAL(18, 2);
DECLARE v_rate DECIMAL(8, 4);

DECLARE cur_order CURSOR FOR
SELECT id, order_amount
FROM orders
WHERE status = 'WAIT_PROCESS'
ORDER BY id;

DECLARE CONTINUE HANDLER FOR NOT FOUND
SET done = TRUE;

OPEN cur_order;

read_loop: LOOP
FETCH cur_order INTO v_id, v_amount;

IF done THEN
LEAVE read_loop;
END IF;

IF v_amount >= 10000 THEN
SET v_rate = 0.0050;
ELSEIF v_amount >= 1000 THEN
SET v_rate = 0.0080;
ELSE
SET v_rate = 0.0100;
END IF;

UPDATE orders
SET service_fee = ROUND(v_amount * v_rate, 2)
WHERE id = v_id;
END LOOP;

CLOSE cur_order;
END $$

DELIMITER ;

不过先别急着为 Cursor 鼓掌。这个例子其实仍然可以集合化:

1
2
3
4
5
6
7
8
9
10
11
UPDATE orders
SET service_fee = ROUND(
order_amount *
CASE
WHEN order_amount >= 10000 THEN 0.0050
WHEN order_amount >= 1000 THEN 0.0080
ELSE 0.0100
END,
2
)
WHERE status = 'WAIT_PROCESS';

第二种往往更简单,也更容易让优化器整体优化。

这就是游标真正应该遵守的原则:

不是“能不能用 Cursor”,而是“这个问题有没有更好的集合表达”。

5.7 Cursor 适合什么场景

比较合理的场景包括:

  • 每一行都需要复杂分支,集合 SQL 难以清晰表达;
  • 后一行处理依赖前一行产生的过程状态;
  • 需要调用 stored program 内特定的逐行流程;
  • 数据量可控的后台管理/迁移任务。

不适合的典型场景:

  • SUM / COUNT / AVG 等聚合;
  • 可以用 CASE WHEN 完成的批量更新;
  • 可以用 JOIN / EXISTS / CTE / Window Function 完成的集合运算;
  • 高并发 OLTP 主链路中的大结果集逐行处理。

6. 把四个概念放回一个真实业务流程

假设有一套订单结算系统,可以这样分层:

6.1 View:给报表一个稳定查询模型

1
2
3
4
5
6
7
8
9
10
CREATE VIEW v_settlement_report AS
SELECT
s.id,
s.settlement_no,
s.merchant_id,
s.total_amount,
s.status,
s.settlement_date
FROM settlement s
WHERE s.deleted = 0;

报表只依赖 View,不关心底层表后续如何拆分字段。

6.2 Transaction:保证一次结算状态变更的原子性

1
2
3
4
5
6
7
8
9
10
11
START TRANSACTION;

UPDATE settlement
SET status = 'SETTLED'
WHERE id = 10001
AND status = 'WAIT_SETTLE';

INSERT INTO settlement_log(settlement_id, action)
VALUES (10001, 'SETTLED');

COMMIT;

状态修改和日志写入必须同生共死。

6.3 Stored Procedure:数据库侧批处理入口

如果是一个数据库中心型的离线批处理,可以封装:

1
CALL settle_by_date('2026-08-10');

过程内部统一处理当日满足条件的数据。

6.4 Cursor:只处理无法自然集合化的特殊行

假设 99% 的结算规则都能一条 SQL 处理,只有 1% 的特殊商户规则必须逐行判断,那就只让 Cursor 处理那 1%,而不是把所有记录都拖进逐行循环。

这套分工的核心不是“把数据库功能都用一遍”,而是每种能力只承担它最擅长的职责


7. 高频误区与面试易错点

7.1 View 会不会保存数据

普通 MySQL View 保存的是查询定义,不是另一份独立结果数据。查询时它表现为虚拟表。

不要把普通 View 和“物化视图”混为一谈;MySQL 8.4 本身没有提供通用的原生 materialized view 功能。

7.2 View 一定比直接写 SQL 快吗

不一定。

View 首先是抽象和复用机制,不是性能加速器。优化器最终仍要处理它背后的查询及基础表。是否快取决于查询结构、谓词下推、基础表索引、统计信息等。

7.3 BEGIN…END 就是事务吗

不是。

存储程序中的:

1
2
3
BEGIN
...
END

是代码块。

事务应该显式使用:

1
2
3
START TRANSACTION;
...
COMMIT;

7.4 一条 SQL 报错,MySQL 会自动把整个事务都回滚吗

不能简单这样理解。

不同错误的行为可能不同。应用程序应明确处理异常,并在需要时显式 ROLLBACK。不要把“语句失败”想当然地等价成“整个事务已经替你回滚完成”。

7.5 隔离级别越高越好吗

不是。

更高隔离通常意味着更强的并发控制和更高的等待/冲突成本。隔离级别实际上是在一致性、可重复性、吞吐量之间做选择。

7.6 Stored Procedure 一定比应用层 SQL 快吗

也不是。

它可能减少客户端与数据库的往返,但流程逻辑本身仍然消耗数据库资源。是否应该使用,更多是架构边界、可维护性、扩展方式和运维体系的问题,而不是一句“存储过程预编译所以一定快”。

7.7 Cursor 会自动更省内存吗

不能把它当成通用的“百万行省内存神器”。Cursor 有自己的服务器端实现和资源成本,而且逐行处理常常会牺牲集合优化能力。真正的大数据量任务要综合考虑批量处理、分页、流式读取、集合 SQL、临时表以及应用侧处理。


8. 一张速查表

问题 优先考虑
多个地方反复写同一段复杂查询 View
想限制调用方只能看部分字段 View + 权限
两个 UPDATE 必须一起成功 Transaction
需要失败后撤销一组写操作 Transaction
同一事务中先读后写关键资源 Transaction + 合适的锁定读
想把数据库侧的一段流程封成 CALL Stored Procedure
需要 IF / LOOP / HANDLER 等数据库内流程控制 Stored Procedure
结果集必须逐行处理 Cursor
SUM / CASE / JOIN / Window Function 能解决 不要急着用 Cursor

9. 总结

把这四个概念串起来之后,SQL 会从“查询语言”变成一套更完整的数据处理体系:

  • View 提供查询抽象,让复杂 SQL 可以成为稳定的数据接口;
  • Transaction 定义一致性边界,用 ACID、隔离和锁保证并发写操作可控;
  • Stored Procedure 让数据库具备更强的过程化封装能力,但需要权衡可移植性和维护成本;
  • Cursor 为 SQL 增加逐行处理能力,但应该是集合方案无法自然解决时的后手。

真正值得形成肌肉记忆的是下面四句话:

1
2
3
4
查询需要复用        -> View
多条写操作要同生共死 -> Transaction
数据库侧要封装流程 -> Stored Procedure
必须一行一行处理 -> Cursor

以及最后一条工程原则:

数据库最擅长集合运算。不要为了“会用高级语法”而把一个能用一条 SQL 解决的问题改造成循环。


10. 资料来源与延伸阅读


从查询封装到一致性边界:MySQL View、事务、存储过程与游标实战
https://allendericdalexander.github.io/2026/08/10/db/53sql/03mysql-view-transaction-stored-procedure-cursor/
作者
AtLuoFu
发布于
2026年8月10日
许可协议