看完本文你将速通:窗口函数,处理 JSON 数据,CTE (通用表表达式),触发器,存储过程等等高级功能。看完你就是Mysql高手

还是用上一篇文章里的py生成的数据

窗口函数

窗口函数是Mysql8.0+的功能,它允许用户在不改变查询结果集行数的情况下,对每一行执行聚合计算或其他复杂的计算。

窗口函数不会改变查询结果集的行数,而是为每一行添加一个额外的列,这个列包含了窗口函数的计算结果。这使得窗口函数非常适合于需要在保持原始数据的同时进行聚合或其他复杂计算的场景。

使用窗口函数的SQL语法是

1
2
3
4
5
<窗口函数> OVER (
[PARTITION BY 字段1, 字段2...] -- 【分组】:按什么归类(比如按用户、按班级)
[ORDER BY 字段3 ASC/DESC] -- 【排序】:组内按什么顺序算(比如按金额、按成绩)
[ROWS/RANGE ...] -- 【范围】:(进阶) 滑动窗口范围,通常用默认值
)

<窗口函数>(<参数>):指定要使用的函数及其参数。函数可以是聚合函数,也可以是专门为窗口函数设计的函数。

PARTITION BY <分区表达式>(可选):将结果集分成多个分区,窗口函数会在每个分区内独立执行。分区表达式可以是一个或多个列名,用于确定如何将结果集分成不同的分区。

ORDER BY <排序表达式> ASC | DESC(可选):指定窗口内行的排序顺序。排序表达式可以是一个或多个列名,用于确定窗口内行的排序方式。

ROWS/Range <行范围>(可选):定义窗口的行范围。行范围可以是固定的行数(如ROWS BETWEEN 2 PRECEDING AND CURRENT ROW),也可以是相对于当前行的动态范围(如ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,表示从窗口开始到当前行的所有行)。

常用的窗口函数:

分类 函数名 作用 例子
序号/排名 ROW_NUMBER() 唯一序号 (1, 2, 3, 4) 给每个订单编个号,即使金额一样也不能重复
RANK() 跳跃排名 (1, 2, 2, 4) 并列第一,下一个就是第三名,相同的值会留下空位
DENSE_RANK() 紧凑排名 (1, 2, 2, 3) 并列第一,下一个是第二名。不会为相同的值留下空位 (适合发奖状)
错位/偏移 LAG(col, n) 往前看 n 行 这一单比上一单多花多少钱?
LEAD(col, n) 往后看 n 行 这一单比下一单少花多少钱?
分布/切片 NTILE(n) 切成 n 份 把用户分成“高/中/低”消费三档
聚合窗口 SUM(), AVG() 累计/组内统计 算出用户的累计消费流水

上手使用窗口函数

还记得那个富豪榜吗

1
2
3
4
5
6
7
SELECT 
u.username,
SUM(o.total_amount) as money
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
GROUP BY u.username
ORDER BY money DESC;

在这里只是使用了ORDER BY来让他使用购买额来排序,但是并没有一个单独的列告诉我这是排名第几?

1
2
3
4
5
6
7
8
9
10
+----------+----------+
| username | money |
+----------+----------+
| 刘林 | 69024.00 |
| 王萍 | 57733.00 |
| 余建华 | 57539.00 |
省略若干行..
| 齐琳 | 10975.00 |
+----------+----------+
19 rows in set (0.0014 sec)

如果想给他单独添加一行告诉我排名,就是用窗口函数的RANK

1
2
3
4
5
6
7
8
select 
u.username,
SUM(o.total_amount) as money,
RANK() OVER(ORDER BY SUM(o.total_amount) DESC) as ranklist
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
GROUP BY u.username
ORDER BY money DESC;

RANK()指我要对后面的东西来排名

OVER()表示指定的窗口->窗口是SUM(o.total_amout)的DESC排名

所以,连起来的意思就是,对SUM(o.total_amout)的DESC的这一列进行RANK()排名

然后将这一个排名列命名为ranklist。执行后的效果是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
+----------+----------+----------+
| username | money | ranklist |
+----------+----------+----------+
| 刘林 | 69024.00 | 1 |
| 王萍 | 57733.00 | 2 |
| 余建华 | 57539.00 | 3 |
.....省略若干行.....
| 潘岩 | 14220.00 | 17 |
| 李阳 | 11863.00 | 18 |
| 齐琳 | 10975.00 | 19 |
+----------+----------+----------+
19 rows in set (0.0021 sec)

如果我非要把这个列命名为rank

如果把这一列命名成rank的话就会报错

1
2
3
4
5
6
7
8
select 
u.username,
SUM(o.total_amount) as money,
RANK() OVER(ORDER BY SUM(o.total_amount) DESC) as rank
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
GROUP BY u.username
ORDER BY mone

报错内容为:

1
ERROR: 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'rank

这不是Mysql版本的问题,而是这个rank和窗口函数RANK()冲突了

这就像在 Python 里写 print = 1 或者在 Java 里写 int class = 5 一样,不能用系统自带的命令名来给变量起名。

如果不想换名字非要用rank?必须用 反引号把它包起来

1
2
3
4
5
6
7
8
9
SELECT 
u.username,
SUM(o.total_amount) as money,
-- 加了反引号 `rank`,告诉 MySQL 这是别名,不是关键字
RANK() OVER(ORDER BY SUM(o.total_amount) DESC) as `rank`
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
GROUP BY u.username
ORDER BY money DESC;

这样执行的效果就是

1
2
3
4
5
6
7
8
9
10
11
12
+----------+----------+----------+
| username | money | rank |
+----------+----------+----------+
| 刘林 | 69024.00 | 1 |
| 王萍 | 57733.00 | 2 |
| 余建华 | 57539.00 | 3 |
.....省略若干行.....
| 潘岩 | 14220.00 | 17 |
| 李阳 | 11863.00 | 18 |
| 齐琳 | 10975.00 | 19 |
+----------+----------+----------+
19 rows in set (0.0021 sec)

窗口函数和聚合函数的区别

窗口函数是单独新增一个列,并且给每一行放上窗口函数输出的值,在不压行的情况下算统计。

而聚合函数顾名思义是把多行压成一行

对比点 聚合函数 窗口函数
行数 会变少 不变
是否保留明细 不保留 保留
是否用 GROUP BY 必须/常用 不需要
适合干嘛 汇总报表 排名、占比、累计
心智模型 压成一坨 每行旁边算一遍

PARTITION BY(分区)的使用

假如我要获取到东莞市的前三排名用户消费金额

1
2
3
4
5
6
7
8
9
10
SELECT 
u.city,
u.username,
IFNULL(SUM(o.total_amount),0) as money,
RANK() OVER(ORDER BY SUM(o.total_amount) DESC) as ranklist
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
WHERE u.city = "东莞市"
GROUP BY u.city, u.username
LIMIT 3;

OK成功获取到结果

1
2
3
4
5
6
7
8
+--------+----------+----------+----------+
| city | username | money | ranklist |
+--------+----------+----------+----------+
| 东莞市 | 王淑华 | 15365.00 | 1 |
| 东莞市 | 戴秀云 | 9909.00 | 2 |
| 东莞市 | 刘鹏 | 9589.00 | 3 |
+--------+----------+----------+----------+
3 rows in set (0.0192 sec)

假如我要获取全国前三消费城市

1
2
3
4
5
6
7
8
SELECT 
u.city,
IFNULL(SUM(o.total_amount),0) as money,
RANK() OVER(ORDER BY SUM(o.total_amount) DESC) as ranklist
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
GROUP BY u.city, u.username
LIMIT 3;

这样也能获取到富豪城市前三

1
2
3
4
5
6
7
8
+--------+----------+----------+
| city | money | ranklist |
+--------+----------+----------+
| 淮安市 | 33505.00 | 1 |
| 丽华市 | 31875.00 | 2 |
| 秀芳县 | 30721.00 | 3 |
+--------+----------+----------+
3 rows in set (0.1092 sec)

那假如我要获取到每个城市的消费前三呢,这时候可以使用PARTITION BY

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SELECT * FROM(
SELECT
u.city,
u.username,
IFNULL(SUM(o.total_amount),0) as money,
RANK() OVER(
PARTITION BY u.city
ORDER BY SUM(o.total_amount) DESC
) as ranklist
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
GROUP BY u.city, u.username
)AS t
WHERE t.ranklist <= 3 AND t.money > 0;

这样执行结果就是想要的了

1
2
3
4
5
6
7
8
9
10
11
12
+------------+----------+----------+----------+
| city | username | money | ranklist |
+------------+----------+----------+----------+
| 上海县 | 张斌 | 19107.00 | 1 |
| 上海县 | 林帆 | 14341.00 | 2 |
| 上海县 | 张雪 | 6129.00 | 3 |
| 东莞市 | 王淑华 | 15365.00 | 1 |
| 东莞市 | 戴秀云 | 9909.00 | 2 |
| 东莞市 | 刘鹏 | 9589.00 | 3 |
...省略若干行...
+------------+----------+----------+----------+
690 rows in set (0.1214 sec)

这个SQL有点复杂先看里面那个

首先去了用户表的名字,和城市,并且是使用左连接把这两列和订单表里的金额列通过userid连了起来,并且使用姓名和城市进行分组。

这时候在内存是这样的:

姓名 城市 购买金额
布莱恩 圆蛤镇 5000
布莱恩 圆蛤镇 1000
斯图威 圆蛤镇 500
梅格 圆蛤镇 NULL(梅格只注册没有下过单)
耄耋 白手套 120
耄爸 白手套 10
耄妈 白手套 10000

然后再通过SUM聚合函数把购买金额给聚合了起来

姓名 城市 购买金额
布莱恩 圆蛤镇 6000
斯图威 圆蛤镇 500
梅格 圆蛤镇 NULL
耄耋 白手套 120
耄爸 白手套 10
耄妈 白手套 10000

然后进入窗口函数,首先对城市进行分区。圆蛤镇和白手套单独使用SUM(o.total_amount)内部进行RANK。

如果不加分区,就会让耄妈排名第一,耄爸排名第六。而设计的目标是要取每个城市的前三。
所以这样,通过按照城市分区后,再分区内部单独RANK就获取到了每个城市的排名。
但是显然不可能每个城市只有3个人。所以把里面这个SQL输出作为表再次运行一次子查询,要求t.ranklist <= 3 AND t.money > 0这样就成功取到所有城市的消费前三了。

ROWS/Range

如果说 PARTITION BY 是把蛋糕切成大块,ORDER BY 是给切好的蛋糕排队,那么 ROWS / RANGE 就是“定义手里的放大镜到底要看几行”(Window Frame)。

它们定义了:在当前这一行进行计算时,要回头看几行?往后看几行?

1
2
3
4
5
<窗口函数> OVER (
PARTITION BY ...
ORDER BY ...
[ROWS|RANGE] BETWEEN <开始位置> AND <结束位置>
)

常用的位置关键字:

  • CURRENT ROW:当前这一行。
  • n PRECEDING:往前 n 行。
  • n FOLLOWING:往后 n 行。
  • UNBOUNDED PRECEDING:最前面(起点)。
  • UNBOUNDED FOLLOWING:最后面(终点)。

rows的使用

rows使用行,往前一行或者往后一行

比如要计算值为3的移动平均线,就是获取前天,昨天,今天的数值加起来除以3

1
2
3
4
5
6
SELECT 
total_amount,
AVG(total_amount) OVER (
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) as moving_avg
FROM orders;

或者要查看当前订单和它的上一单下一单的金额总和。

1
2
3
4
5
6
7
8
SELECT 
order_no,
total_amount,
SUM(total_amount) OVER (
ORDER BY created_at
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
) as context_sum
FROM orders;

range的使用

range按照按值的逻辑范围,一般用于时间范围,比如这样

1
2
3
4
5
SUM(amount) OVER (
PARTITION BY user_id
ORDER BY create_time
RANGE BETWEEN INTERVAL 7 DAY PRECEDING AND CURRENT ROW
)

来统计最近 7 天订单总额

range和rows的区别

假如有这样一个表

id score
1 10
2 10
3 20
4 30
5 30

用rows来新增一个求和列的话

1
2
3
4
5
6
7
8
SELECT
id,
score,
SUM(score) OVER (
ORDER BY score
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
) AS sum_rows
FROM score;

就会是这样的

1
2
3
4
5
6
7
8
9
10
+----+-------+----------+
| id | score | sum_rows |
+----+-------+----------+
| 1 | 10 | 10 |
| 2 | 10 | 20 |
| 3 | 20 | 30 |
| 4 | 30 | 50 |
| 5 | 30 | 60 |
+----+-------+----------+
5 rows in set (0.0005 sec)

即使分数一样,也是一行一行数。符合直觉吧

而如果换成range

1
2
3
4
5
6
7
8
SELECT
id,
score,
SUM(score) OVER (
ORDER BY score
RANGE BETWEEN 1 PRECEDING AND CURRENT ROW
) AS sum_rows
FROM score;

就会是这样的

1
2
3
4
5
6
7
8
9
10
+----+-------+----------+
| id | score | sum_rows |
+----+-------+----------+
| 1 | 10 | 20 |
| 2 | 10 | 20 |
| 3 | 20 | 20 |
| 4 | 30 | 60 |
| 5 | 30 | 60 |
+----+-------+----------+
5 rows in set (0.0004 sec)

因为上面那个RANGE,说人话的意思就是,把 score 在 [当前值 - 1, 当前值] 这个区间里的所有行加起来

所以

score RANGE 区间 被算的 score 结果
10 [9,10] 10,10 20
10 [9,10] 10,10 20
20 [19,20] 20 20
30 [29,30] 30,30 60
30 [29,30] 30,30 60

关于窗口函数更多内容,可以前往深入MySQL窗口函数:原理和应用-腾讯云开发者社区-腾讯云

处理json数据

MySQL 从 5.7 版本开始原生支持 JSON 数据类型

什么时候用json

  1. 不确定的属性(动态 Schema):
    • 电商的商品表。衣服有“颜色、尺码”,手机有“内存、屏幕分辨率”,书有“作者、ISBN”。如果为每个属性都建一列,表会变得极其宽且难以维护。用 JSON 字段存 attributes 是完美方案。
  2. 用户个性化配置:
    • 用户的偏好设置、UI 布局配置、通知开关等,这些字段经常变动,没必要每次加配置都去改表结构。
  3. 接口数据存储/日志:
    • 直接存储调用第三方 API 返回的 JSON 结果,或者存储前端提交的复杂表单数据,用于留存或后续审计。
  4. 稀疏数据:
    • 一个表有 100 个列,但大部分行只有其中 5 个列有值,其他都是 NULL。用 JSON 可以节省空间并简化结构。

上手使用json

在创建表的时候直接将列的数据类型指定为json

1
2
3
4
5
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255),
details JSON -- 定义 JSON 类型
);

插入数据

直接插入json字符串即可

1
2
3
INSERT INTO products (name, details) VALUES 
('T-Shirt', '{"color": "white", "size": "L", "tags": ["summer", "casual"]}'),
('iPhone 15', '{"color": "black", "storage": "256GB", "screen": "6.1"}');

执行后表的内容就是这样的

1
2
3
4
5
6
7
8
MySQL  localhost:3306 ssl  mall_db_simple  SQL > select * from products;
+----+-----------+---------------------------------------------------------------+
| id | name | details |
+----+-----------+---------------------------------------------------------------+
| 1 | T-Shirt | {"size": "L", "tags": ["summer", "casual"], "color": "white"} |
| 2 | iPhone 15 | {"color": "black", "screen": "6.1", "storage": "256GB"} |
+----+-----------+---------------------------------------------------------------+
2 rows in set (0.0004 sec)

json相关函数

读取类

函数 / 操作符 作用 语法示例 结果 & 说明
-> 提取值 (保留引号) 提取指定路径的节点,返回的是 JSON 类型。 SELECT @j->'$.name'; "Mario"
注意带有双引号,通常用于进一步处理 JSON。
->> 提取值 (去引号) 提取并转为字符串/数字,最推荐用于显示或比较。 SELECT @j->>'$.name'; Mario
双引号没了,直接变成了字符串。
JSON_KEYS 获取所有 Key 返回对象顶层所有的键名数组。 SELECT JSON_KEYS(@j); ["name", "score", "tags"]
常用于查看 JSON 结构。
JSON_LENGTH 获取长度 对象的键数量或数组的元素个数。 SELECT JSON_LENGTH(@j, '$.tags'); 2
查看 tags 数组里有几个元素。

修改类

函数 作用 语法示例 结果 & 说明
JSON_SET (万能) 插入或更新
Key 存在则更新,不存在则插入。
SELECT JSON_SET(@j, '$.score', 200, '$.age', 18); {"name":..., "score": 200, "age": 18}
最推荐使用,类似 Upsert。
JSON_INSERT 只插入
Key 不存在才插入,存在则忽略。
SELECT JSON_INSERT(@j, '$.score', 200); {"score": 100...}
因为 score 已存在,所以不改。
JSON_REPLACE 只更新
Key 存在才更新,不存在则忽略。
SELECT JSON_REPLACE(@j, '$.age', 18); {"name":...}
因为 age 不存在,所以不加。
JSON_REMOVE 删除
删除指定路径的节点。
SELECT JSON_REMOVE(@j, '$.tags'); {"name": "Mario", "score": 100}
tags 字段被删掉了。
JSON_ARRAY_APPEND 数组追加
向数组类型的字段追加元素。
SELECT JSON_ARRAY_APPEND(@j, '$.tags', 'new'); ..."tags": ["red", "game", "new"]

查询与搜索类

函数 作用 语法示例 结果 & 说明
JSON_CONTAINS 包含检查
检查 JSON 文档是否包含指定的值(精确匹配)。
SELECT JSON_CONTAINS(@j, '"red"', '$.tags'); 1 (True)
判断 tags 数组里有没有 “red”。注意字符串要多包一层引号。
JSON_SEARCH 搜索路径
查找某个值在 JSON 中的路径。
SELECT JSON_SEARCH(@j, 'one', 'game'); "$.tags[1]"
告诉你 “game” 在哪里。
JSON_VALID 格式校验
判断字符串是否为合法的 JSON。
SELECT JSON_VALID('{"a":1}'); 1 (True)
常用于清洗脏数据。

构造类

函数 作用 语法示例 结果 & 说明
JSON_OBJECT 创建对象
将键值对列表转为 JSON 对象。
SELECT JSON_OBJECT('id', 1, 'name', 'A'); {"id": 1, "name": "A"}
JSON_ARRAY 创建数组
将值列表转为 JSON 数组。
SELECT JSON_ARRAY('a', 1, NOW()); ["a", 1, "2023-10-01..."]
JSON_ARRAYAGG 聚合为数组
配合 GROUP BY,将多行数据聚合为一个 JSON 数组。
SELECT JSON_ARRAYAGG(name) FROM users; ["Alice", "Bob", "Charlie"]
非常有用的聚合函数。

查询json列中的数据

使用 ->->> 操作符:

  • ->:返回带引号的 JSON 结果(例如 "white")。
  • ->>:返回去引号的字符串结果(例如 white)。

比如我要查询每个产品的名称和颜色

1
select name,details->>'$.color' as color from products;

执行后的输出是这样的

1
2
3
4
5
6
7
+-----------+-------+
| name | color |
+-----------+-------+
| T-Shirt | white |
| iPhone 15 | black |
+-----------+-------+
2 rows in set (0.0020 sec)

或者我要查询详情中包含 “storage” 属性的商品

1
select * from products where details->>'$.storage' is not null;

执行后的输出是这样的

1
2
3
4
5
6
+----+-----------+---------------------------------------------------------+
| id | name | details |
+----+-----------+---------------------------------------------------------+
| 2 | iPhone 15 | {"color": "black", "screen": "6.1", "storage": "256GB"} |
+----+-----------+---------------------------------------------------------+
1 row in set (0.0005 sec)

或者如果要查找json的某个数组中包含某个值的行,使用JSON_CONTAINS()

1
select * from products where json_contains(details,'"summer"',"$.tags");

执行后的输出结果是这样的

1
2
3
4
5
6
+----+---------+---------------------------------------------------------------+
| id | name | details |
+----+---------+---------------------------------------------------------------+
| 1 | T-Shirt | {"size": "L", "tags": ["summer", "casual"], "color": "white"} |
+----+---------+---------------------------------------------------------------+
1 row in set (0.0010 sec)

修改json数据

不要把整个 JSON 拿出来改完再覆盖回去,使用原生函数效率更高。

比如我要给所有商品增加一个NB属性,并把color设置成blue

1
update products set details = json_set(details,"$.color","blue","$.NB","true");

执行的效果是

1
2
3
4
5
6
7
+----+-----------+----------------------------------------------------------------------------+
| id | name | details |
+----+-----------+----------------------------------------------------------------------------+
| 1 | T-Shirt | {"NB": "true", "size": "L", "tags": ["summer", "casual"], "color": "blue"} |
| 2 | iPhone 15 | {"NB": "true", "color": "blue", "screen": "6.1", "storage": "256GB"} |
+----+-----------+----------------------------------------------------------------------------+
2 rows in set (0.0004 sec)

如果要删除NB属性

1
update products set details = json_remove(details,"$.NB");

执行的效果是

1
2
3
4
5
6
7
+----+-----------+--------------------------------------------------------------+
| id | name | details |
+----+-----------+--------------------------------------------------------------+
| 1 | T-Shirt | {"size": "L", "tags": ["summer", "casual"], "color": "blue"} |
| 2 | iPhone 15 | {"color": "blue", "screen": "6.1", "storage": "256GB"} |
+----+-----------+--------------------------------------------------------------+
2 rows in set (0.0004 sec)

使用json需要注意的

json列本身不能直接建立索引。如果需要经常需要在 WHERE 子句中根据 json里的某个字段,需要使用虚拟列。并给虚拟列添加索引age’ > 20;

1
2
3
4
5
6
7
8
ALTER TABLE users ADD COLUMN v_age INT 
GENERATED ALWAYS AS (profile->>'$.age') VIRTUAL;

CREATE INDEX idx_age ON users(v_age);

SELECT * FROM users WHERE v_age > 20;
-- 或者
SELECT * FROM users WHERE profile->>'$.age' > 20;

json存储会比普通字段占用更多空间。不要把 json当作大文本存,尽量保持 json结构精简。

CTE (通用表表达式)

可以把 CTE 理解为 “临时命名的结果集”。它就像是一个在当前 SQL 语句中临时定义的“虚拟表”或“变量”,可以像查询普通表一样查询它。

CTE 主要用于解决以下三个痛点:

  1. 提高代码可读性(最常用)
    • 以前写复杂的 SQL,会使用大量的嵌套子查询(Subquery),一层套一层,像“洋葱”一样,非常难以阅读和维护。
    • CTE 可以把逻辑扁平化,先定义数据 A,再定义数据 B,最后查询 A 和 B,逻辑像写代码一样自上而下。
  2. 逻辑复用
    • 如果同一个子查询结果在主查询中需要被用到多次(例如 join 两次),用 CTE 只需要定义一次,后面引用即可,不用重复写子查询代码。
  3. 递归查询(杀手级功能)
    • 处理树形结构(如:公司组织架构、商品多级分类、评论回复楼层)时,必须使用递归 CTE (WITH RECURSIVE)。

CTE的语法

1
2
3
4
5
6
WITH cte_name (列名1, 列名2, ...) AS (
-- 这里写你的子查询
SELECT ...
)
-- 紧接着写主查询,可以使用上面的 cte_name
SELECT * FROM cte_name;

使用CTE

还记得上面那个每个城市消费前三的例子吗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SELECT * FROM(
SELECT
u.city,
u.username,
IFNULL(SUM(o.total_amount),0) as money,
RANK() OVER(
PARTITION BY u.city
ORDER BY SUM(o.total_amount) DESC
) as ranklist
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
GROUP BY u.city, u.username
)AS t
WHERE t.ranklist <= 3 AND t.money > 0;

如果把它换成CTE写法就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 1. 先定义“带排名的用户列表”
WITH UserRanks AS (
SELECT
u.city,
u.username,
IFNULL(SUM(o.total_amount),0) as money,
RANK() OVER(PARTITION BY u.city ORDER BY SUM(o.total_amount) DESC) as ranklist
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
GROUP BY u.city, u.username
)
-- 2. 再对这个列表进行筛选
SELECT *
FROM UserRanks
WHERE ranklist <= 3 AND money > 0;

看着好像没什么差别?确实,在这个简单例子里,确实只是“排版”的区别。

分步聚合

我要找出总消费金额 > 5000的用户,并列出他们的姓名、电话、城市以及具体的消费总额。

先写一个查询列出所有用户消费金额

1
2
3
4
select 
user_id,
SUM(total_amount) AS total_spent
FROM orders GROUP BY user_id;

然后把这表作为一个UserSpent临时表,和用户表内连接起来就是这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
WITH UserSpent AS (
select
user_id,
SUM(total_amount) AS total_spent
FROM orders GROUP BY user_id
)

SELECT
u.username,
u.city,
u.phone,
us.total_spent
FROM users AS u
INNER JOIN UserSpent AS us ON u.id = us.user_id
WHERE us.total_spent > 5000
ORDER BY us.total_spent DESC;

再用到窗口函数,给他加个排名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
WITH UserSpent AS (
select
user_id,
SUM(total_amount) AS total_spent
FROM orders GROUP BY user_id
)

SELECT
u.username,
u.city,
u.phone,
us.total_spent,
RANK() OVER(ORDER BY us.total_spent DESC) as `rank`
FROM users AS u
INNER JOIN UserSpent AS us ON u.id = us.user_id
WHERE us.total_spent > 5000
ORDER BY us.total_spent DESC;

执行出来的结果就是这样的

1
2
3
4
5
6
7
8
9
10
+----------+------------+-------------+-------------+------+
| username | city | phone | total_spent | rank |
+----------+------------+-------------+-------------+------+
| 车燕 | 淮安市 | 15138603669 | 33505.00 | 1 |
| 谢梅 | 丽华市 | 15198232698 | 31875.00 | 2 |
....省略若干行...
| 梁秀华 | 慧市 | 13235808817 | 5049.00 | 502 |
| 陈莉 | 重庆县 | 18953949093 | 5022.00 | 503 |
+----------+------------+-------------+-------------+------+
503 rows in set (0.0126 sec)

复用逻辑/链式CTE

查看 “数码” 类目下,各个商品的销售额,以及该商品占整个“数码”类目总销售额的百分比

需要下面两个数据

  1. 每个数码商品的销售额(Group By product_id)。
  2. 数码类的总销售额(计算要用到它来做分母)。
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
WITH DigitalProductSales AS (
-- 1. 先把数码类的所有销售明细算出来
SELECT
p.name AS product_name,
SUM(oi.quantity * oi.price) AS prod_total
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE p.category = '数码'
GROUP BY p.name
),
TotalDigitalSales AS (
-- 2. 复用上面的 CTE!计算数码类的总销售额
-- 这里直接查询了定义好的 DigitalProductSales,不用再去 join 原始表了
SELECT SUM(prod_total) AS cat_total
FROM DigitalProductSales
)
-- 3. 主查询:计算占比
SELECT
dps.product_name,
dps.prod_total,
tds.cat_total,
CONCAT(ROUND((dps.prod_total / tds.cat_total) * 100, 2), '%') AS percent
FROM DigitalProductSales dps
CROSS JOIN TotalDigitalSales tds
ORDER BY dps.prod_total DESC;

可以看到,下面的TotalDigitalSales是可以直接使用DigitalProductSales的数据的。定义多个CTE使用逗号隔开

但是这么写其实不太好,我也只是为了演示CTE之间可以调用数据。

这种需求应该使用窗口函数

1
2
3
4
5
6
7
8
9
10
SELECT 
p.name AS product_name,
SUM(oi.quantity * oi.price) AS prod_total,
SUM(SUM(oi.quantity * oi.price)) OVER() AS cat_total,
CONCAT(ROUND(SUM(oi.quantity * oi.price) / SUM(SUM(oi.quantity * oi.price)) OVER() * 100, 2), '%') AS percent
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE p.category = '数码'
GROUP BY p.name
ORDER BY prod_total DESC;

执行效果是一样的。窗口函数更简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+--------------+------------+------------+---------+
| product_name | prod_total | cat_total | percent |
+--------------+------------+------------+---------+
| 数码-我的 | 299052.00 | 1448330.00 | 20.65% |
| 数码-项目 | 238022.00 | 1448330.00 | 16.43% |
| 数码-喜欢 | 231068.00 | 1448330.00 | 15.95% |
| 数码-不能 | 123622.00 | 1448330.00 | 8.54% |
| 数码-比较 | 119634.00 | 1448330.00 | 8.26% |
| 数码-起来 | 107300.00 | 1448330.00 | 7.41% |
| 数码-以上 | 101840.00 | 1448330.00 | 7.03% |
| 数码-行业 | 67035.00 | 1448330.00 | 4.63% |
| 数码-开发 | 57277.00 | 1448330.00 | 3.95% |
| 数码-全国 | 54400.00 | 1448330.00 | 3.76% |
| 数码-一般 | 49080.00 | 1448330.00 | 3.39% |
+--------------+------------+------------+---------+
11 rows in set (0.0343 sec)

再来个例子,生成一个“月度销售报表”,包含:月份、当月订单数、当月总金额、以及环比上个月的增长率。

同样,先把数据按照月份分组统计

1
2
3
4
5
SELECT 
DATE_FORMAT(created_at, '%Y-%m') AS sale_month,
SUM(total_amount) AS total_money
FROM orders
GROUP BY DATE_FORMAT(created_at, '%Y-%m');

然后把这个查询作为一个CTE,在另一个CTE里使用窗口函数创建一个上一个月的列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
WITH MonthlyStats AS (
SELECT
DATE_FORMAT(created_at, '%Y-%m') AS sale_month,
SUM(total_amount) AS total_money
FROM orders
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
),
GrowthStats AS (
SELECT
sale_month,
total_money,
LAG(total_money, 1) OVER (ORDER BY sale_month) AS last_month_money
FROM MonthlyStats
)

最后再把数据显示出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
WITH MonthlyStats AS (
SELECT
DATE_FORMAT(created_at, '%Y-%m') AS sale_month,
SUM(total_amount) AS total_money
FROM orders
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
),
GrowthStats AS (
SELECT
sale_month,
total_money,
LAG(total_money, 1) OVER (ORDER BY sale_month) AS last_month_money
FROM MonthlyStats
)
SELECT
sale_month,
total_money,
last_month_money,
CONCAT(ROUND((total_money - last_month_money) / last_month_money * 100, 2), '%') AS growth_rate
FROM GrowthStats
ORDER BY sale_month;

执行后得到的结果就是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+------------+-------------+------------------+-------------+
| sale_month | total_money | last_month_money | growth_rate |
+------------+-------------+------------------+-------------+
| 2025-01 | 540403.00 | NULL | NULL |
| 2025-02 | 489717.00 | 540403.00 | -9.38% |
| 2025-03 | 538732.00 | 489717.00 | 10.01% |
| 2025-04 | 509465.00 | 538732.00 | -5.43% |
| 2025-05 | 573503.00 | 509465.00 | 12.57% |
| 2025-06 | 391503.00 | 573503.00 | -31.73% |
| 2025-07 | 429322.00 | 391503.00 | 9.66% |
| 2025-08 | 505246.00 | 429322.00 | 17.68% |
| 2025-09 | 501646.00 | 505246.00 | -0.71% |
| 2025-10 | 536669.00 | 501646.00 | 6.98% |
| 2025-11 | 632946.00 | 536669.00 | 17.94% |
| 2025-12 | 456490.00 | 632946.00 | -27.88% |
| 2026-01 | 98514.00 | 456490.00 | -78.42% |
+------------+-------------+------------------+-------------+
13 rows in set (0.0085 sec)

其实这里就能看到CTE的作用了,因为这种情况下如果不用CTE,整个SQL会非常难读难改像一坨大的,就像这样:

1
2
3
4
5
6
7
SELECT
DATE_FORMAT(created_at, '%Y-%m') AS sale_month,
SUM(total_amount) AS total_money,
LAG(SUM(total_amount), 1) OVER (ORDER BY DATE_FORMAT(created_at, '%Y-%m')) AS last_month_money,
CONCAT(ROUND((SUM(total_amount) - LAG(SUM(total_amount), 1) OVER (ORDER BY DATE_FORMAT(created_at, '%Y-%m')) ) / LAG(SUM(total_amount), 1) OVER (ORDER BY DATE_FORMAT(created_at, '%Y-%m')) * 100, 2), '%') AS growth_rate
FROM orders
GROUP BY DATE_FORMAT(created_at, '%Y-%m');

两者执行效果是一样的,但是后者会让人一眼就放弃

主要还是因为SQL 的一个核心规则:在同一个 SELECT 层级中,定义的“别名”(Alias)不能在同一层级的其他计算中直接使用。

递归(WITH RECURSIVE)

在CTE中是可以调用自己的

1
2
3
4
5
6
7
8
WITH RECURSIVE NumSeries AS (
SELECT 1 AS n

UNION ALL

SELECT n + 1 FROM NumSeries WHERE n < 5
)
SELECT * FROM NumSeries;

注意,SQL 的递归不是“函数调用”,而是结果集自我扩张。UNION ALL 上面的语句只执行1次,下面的语句会执行多次,且每次引用的 NumSeries 指的不是整个表,而是上一轮刚刚产生的那一行数据。

递归 CTE 根本不是函数递归,它是:先算出一行,再用这行算出下一行,再用“新算出来的那行”继续算,直到算不出新行

1
SELECT n + 1 FROM NumSeries WHERE n < 5

这里面的NumSeries,把它想象成执行NumSeries最新一行数据的指针

执行逻辑就类似于下面的Python程序

1
2
3
4
5
6
7
8
9
10
11
12
nums = [1]

while True:
new_nums = []
for n in nums:
if n < 5:
new_nums.append(n + 1)
if not new_nums:
break
nums.extend(new_nums)

print(nums)

递归 CTE 每一轮只用“上一轮新产生的行”去算下一轮,不会反复拿旧行再算一遍,否则它会 无限重复、无限膨胀。

递归使用这个模板

1
2
3
4
5
6
7
8
9
10
11
12
WITH RECURSIVE cte_name AS (
-- 1. 初始查询(种子):你想从谁开始找?
SELECT ...

UNION ALL

-- 2. 递归查询(套娃):逻辑通常是 JOIN cte_name
SELECT ...
FROM table
JOIN cte_name ON table.parent_id = cte_name.id -- 或者是 cte.id = table.parent_id
)
SELECT * FROM cte_name;

来个例子:

假设表 employees 结构如下:

id name manager_id (上司ID)
1 大老板 NULL
2 技术总监 1
3 产品总监 1
4 程序员A 2
5 程序员B 2
6 实习生 4

要查出技术总监 (id=2) 手下的所有人(包括下属的下属,一直到底)。

sql语句是这么写的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
WITH RECURSIVE Subordinates AS (
-- 启动:先找到那个“根”节点(技术总监)
SELECT id, name, manager_id, 1 AS depth
FROM employees
WHERE id = 2 -- 从 ID=2 开始往下找

UNION ALL

-- 循环:找上一轮找到的人的下属,s 是上一轮找到的结果集
SELECT e.id, e.name, e.manager_id, s.depth + 1
FROM employees e
JOIN Subordinates s ON e.manager_id = s.id
-- 员工的 manager_id 等于 上一轮找到的人的 id
)
SELECT * FROM Subordinates;

先通过WHERE id = 2获取到了技术总监那行的数据,然后再把那行再和原employees表通过技术总监的manager_idid内连接。显然内连接的结果就是程序员A和程序员B两行。

同理,再用程序员A的id和原表的manager_id内连接,得到实习生那行。

用程序员B的的id和原表的manager_id内连接,得到的是空

用实习生的的id和原表的manager_id内连接,得到的是空

自此,循环结束

性能上的微小区别

在 MySQL 8.0 中,CTE 还有一个特性叫 Materialization(物化/临时表缓存)

普通子查询:有时候优化器会把子查询“打散”,合并到主查询里去执行(Derived Merge)。

CTE:如果你的 CTE 逻辑非常复杂(比如计算量巨大),且在主查询里被引用了多次,MySQL 可能会选择把这个 CTE 的结果存成一个内部临时表(只算一次),然后多次读取。这在某些特定场景下比多次执行子查询要快。

总之只要满足以下任意一点,就建议用 CTE:

  1. 同一个子查询结果,要在后面用 2 次以上(复用)。
  2. 需要递归查询(树形结构)。
  3. 逻辑太长太深,需要把代码拆分成“第一步算这个、第二步算那个”来提高可读性。

触发器

Mysql的触发器可以在数据插入、更新或删除时自动执行相应的逻辑

主要作用包括:

  1. 自动执行:触发器可以在特定事件发生时自动执行预定义的操作,无需手动调用。
  2. 数据完整性:通过在数据库操作前或后执行验证和修改操作,触发器能够维护数据的完整性和一致性。
  3. 审计和日志记录:触发器可用于记录对数据库进行的操作,便于审计和追踪数据变更。
  4. 复杂业务逻辑:触发器允许在数据库级别实现复杂的业务逻辑,从而确保数据操作的一致性和正确性。

触发器的SQL语句是这样:

1
2
3
4
5
6
7
CREATE TRIGGER 触发器名
{BEFORE | AFTER} {INSERT | UPDATE | DELETE}
ON 表名
FOR EACH ROW
BEGIN
-- 触发器逻辑
END;

在触发器逻辑中可以使用OLD和NEW关键字指向原有行或者新的行。但是要看触发事件

触发事件 关键字 含义 能用 NEW 吗? 能用 OLD 吗? 典型用途
INSERT NEW 即将插入的新数据 ✅ 可用 ❌ 没有旧数据 校验新数据格式、自动填充字段
UPDATE NEW 改成什么样了 ✅ 可用 ✅ 可用 记录”从xx改为xx”的日志
DELETE OLD 即将消失的数据 ❌ 没有新数据 ✅ 可用 做备份、归档

创建触发器

比如我要给订单表加一个触发器,新增一个订单就扣除一个库存

那就是在order_items加一个INSERT监视器,获取order_items表中NEW这一行的product_idquantity,即购买商品的ID和购买数量

然后再到products表里,根据购买商品的ID,扣除相应的库存

1
2
3
4
5
6
7
8
9
10
11
12
DELIMITER $$

CREATE TRIGGER koukucun
BEFORE INSERT
ON order_items
FOR EACH ROW
BEGIN
UPDATE products SET products.stock = products.stock - NEW.quantity
WHERE NEW.product_id = products.id;
END $$

DELIMITER ;

DELIMITER是用来更改SQL语句的结尾的。因为我们要在触发器逻辑里写结束符号。所以在外部使用DELIMITER暂时修改一下结束符号

但是这么写又有问题来了,还记得上一篇文章说的超卖问题吗。那个解决方法在这里不适用了。如果加上AND product.stock > NEW.quantity的话,这么执行不会报错,但是也不会修改库存数据。

正确的做法是在触发器里就检测库存

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
DELIMITER $$

CREATE TRIGGER koukucun
BEFORE INSERT
ON order_items
FOR EACH ROW
BEGIN
-- 1. 定义一个变量存当前库存
DECLARE current_stock INT;

-- 2. 查出当前商品的库存
SELECT stock INTO current_stock
FROM products
WHERE id = NEW.product_id; -- 明确指定 id 是产品的 id

-- 3. 判断库存够不够
IF current_stock < NEW.quantity THEN
-- 4. 如果不够,抛出异常!这会中断 INSERT 操作,订单创建失败
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '库存不足,无法下单';
ELSE
-- 5. 如果够,执行扣减
UPDATE products
SET stock = stock - NEW.quantity
WHERE id = NEW.product_id;
END IF;

END $$

DELIMITER ;

这样如果库存不足的话,INSERT语句就会直接创建失败了

使用触发器

现在products表里随便找个商品看看库存有多少个?

1
2
3
4
5
6
7
MySQL  localhost:3306 ssl  mall_db_simple  SQL > select * from products limit 1;
+----+-----------+----------+--------+-------+--------+
| id | name | category | price | stock | status |
+----+-----------+----------+--------+-------+--------+
| 1 | 零食-世界 | 零食 | 535.00 | 999 | 1 |
+----+-----------+----------+--------+-------+--------+
1 row in set (0.0043 sec)

可以看到id为1的商品还剩999个。现在向order_items插入一条订单数据看看,这里只为了演示触发器的使用我就不加order_idprice了。

1
insert into order_items (product_id,quantity) values (1,500);

执行后会弹出警告

1
Warning (code 1364): Field 'order_id' doesn't have a default value

这个正常我们来看看库存现在咋样了

1
2
3
4
5
6
7
MySQL  localhost:3306 ssl  mall_db_simple  SQL > select * from products limit 1;
+----+-----------+----------+--------+-------+--------+
| id | name | category | price | stock | status |
+----+-----------+----------+--------+-------+--------+
| 1 | 零食-世界 | 零食 | 535.00 | 499 | 1 |
+----+-----------+----------+--------+-------+--------+
1 row in set (0.0012 sec)

库存减掉500个了,现在来试试超卖的情况

1
2
3
4
5
6
7
8
9
MySQL  localhost:3306 ssl  mall_db_simple  SQL > insert into order_items (product_id,quantity) values (1,900);
ERROR: 1644 (45000): 库存不足,无法下单
MySQL localhost:3306 ssl mall_db_simple SQL > select * from products limit 1;
+----+-----------+----------+--------+-------+--------+
| id | name | category | price | stock | status |
+----+-----------+----------+--------+-------+--------+
| 1 | 零食-世界 | 零食 | 535.00 | 499 | 1 |
+----+-----------+----------+--------+-------+--------+
1 row in set (0.0004 sec)

直接报错了,库存也没减少

列出/删除触发器

要列出触发器直接使用SHOW关键字

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
MySQL  localhost:3306 ssl  mall_db_simple  SQL > show TRIGGERS;
+----------+--------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------+------------------------+-----------------------------------------------------------------------------------------------------------------------+----------------+----------------------+----------------------+--------------------+
| Trigger | Event | Table | Statement | Timing | Created | sql_mode | Definer | character_set_client | collation_connection | Database Collation |
+----------+--------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------+------------------------+-----------------------------------------------------------------------------------------------------------------------+----------------+----------------------+----------------------+--------------------+
| koukucun | INSERT | order_items | BEGIN
-- 1. 定义一个变量存当前库存
DECLARE current_stock INT;

-- 2. 查出当前商品的库存
SELECT stock INTO current_stock
FROM products
WHERE products.id = NEW.product_id;

-- 3. 判断库存够不够
IF current_stock < NEW.quantity THEN
-- 4. 如果不够,抛出异常!这会中断 INSERT 操作,订单创建失败
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '库存不足,无法下单';
ELSE
-- 5. 如果够,执行扣减
UPDATE products
SET stock = stock - NEW.quantity
WHERE id = NEW.product_id;
END IF;

END | BEFORE | 2026-01-06 18:07:03.53 | ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION | root@localhost | utf8mb4 | utf8mb4_0900_ai_ci | utf8mb4_0900_ai_ci |
+----------+--------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------+------------------------+-----------------------------------------------------------------------------------------------------------------------+----------------+----------------------+----------------------+--------------------+
1 row in set (0.0015 sec)

可以看到触发器内会保存注释,毕竟SQL不用编译成二进制执行

删除触发器使用DROP关键字即可。DROP TRIGGER 触发器名不演示了

一个不要使用触发器的例子

比如一个用户注销后,在用户表挂一个DELETE触发器自动去删除其他表比如评论表,订单表中这个用户的数据,这样后端写用户注销的接口的接口只用传一个delete from users where xxx;

如果事务隔离级别使用的是RR,将要面对间隙锁。最坏情况下,如果订单表第一条是这个用户的数据,最后一条也是这个用户的数据,间隙锁会将整张表锁住!此时其他操作要等到触发器删除完订单表的内容才会把整张订单表从锁里放出来。

那如果把隔离等级换成RC?如果在没有建立索引的情况下,就有可能遇到死锁了:

假如 orders 表里的 user_id 没有索引。

事务 A 执行 DELETE FROM orders WHERE user_id = 123:因为没有索引,MySQL 必须进行全表扫描(或者主键扫描)来找 123。在 RC 级别下,虽然它会边扫描边释放不匹配的锁,但在扫描过程中,它实际上扫过了所有的行。

事务 B 执行 DELETE FROM orders WHERE user_id = 456它也要全表扫描。

这时候,如果扫描顺序稍微撞一下,或者在扫描过程中互相等待锁(比如 A 扫到了 456 的行准备判断,B 扫到了 123 的行准备判断),就可能死锁,或者退化成严重的锁等待。

既然如此,那如果把隔离等级换成RC,并给相应的列加上索引呢?技术上是可行的。不同的注销事务只会锁目标行,事务 A 删用户 123,事务 B 删用户 456,互不干扰。

但是这样又会遇到下面的问题

隐式逻辑难以维护

触发器有个特性就是要去show TRIGGERS;才能看到这有个触发器

假如我写了触发器在数据库里,后端注销接口我只传了个delete from users where xxx一年后我提桶跑路了,新来的一看就懵了,为什么每次一删用户表里的数据别的表里的数据也跟着没了?翻遍整个后端代码都没看见这个逻辑。直到突然想起连上Mysql发现有个触发器

这是“业务逻辑泄露”。代码是显性的,触发器是隐性的。在大型系统中,逻辑应该尽可能集中在代码层,做到“所见即所得”。

大量数据阻塞性能

假如注销的是个顶级老资历,订单表里有大量他的订单,那就可能阻塞性能。但是这不是因为触发器导致的。不过可以单独在后端写,分批删除这位老资历的数据。防止数据库在这个大事务中卡死,甚至把数据库的 Undo Log 撑爆。

并不是说把触发器的逻辑放在后端就不卡了,而是放在后端让我们拥有了把大任务拆解成小任务的能力。

推荐使用触发器的场景

审计与数据留痕

说人话就是日志。如果需要记录某张核心表(比如 balance 余额表)的所有变动历史。哪怕是 DBA 半夜偷偷连上数据库用命令行改了一个数字,或者有一个陈旧的 PHP 后台改了数据,这条修改记录也要被记下来。

这个后端做不了,后端代码只能监控通过后端 API 发起的请求。它管不了直接操作数据库的行为,也管不了别的微服务或者遗留系统对数据库的修改。

复杂的强数据约束

说人话就是防止超买超卖,就比如上面给的那个例子。金融、库存等对数据准确性要求高的。不过这是兜底的,后端该检验要是要检验的

自动更新统计数据

论坛里有一个 boards(板块)表,里面有个字段 post_count(帖子总数)。不想每次展示板块都去 postsSELECT COUNT(*),因为太慢了。

有人发帖(INSERT posts):UPDATE boards SET post_count = post_count + 1 WHERE id = NEW.board_id

有人删帖(DELETE posts):UPDATE boards SET post_count = post_count - 1 WHERE id = OLD.board_id

如果不写在触发器里,就需要在后端代码的每一个“发帖、删帖、审核通过、隐藏帖子”的地方都记得去更新这个计数,漏一个地方数据就不准了。

但是并发不能高,并发高boards那行会被锁死

存储过程

好比在后端写的函数,只不过这个函数是用 SQL 写的,而且保存在数据库里。客户端不用发一堆 SQL,只发一个命令:“执行函数 A,参数是 X”。数据库在内部自己算完,把结果吐出来。

比如我要写一个充值功能,如果充值金额大于100再多送5元

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
DELIMITER $$

CREATE PROCEDURE recharge_and_bonus(IN p_user_id INT, IN p_amount DECIMAL(10,2))
BEGIN
-- 1. 充钱
UPDATE users SET balance = balance + p_amount WHERE id = p_user_id;

-- 2. 送钱
IF p_amount >= 100 THEN
UPDATE users SET balance = balance + 5 WHERE id = p_user_id; -- 再送5元!
END IF;

-- 3. 返回最新余额
SELECT balance, points FROM users WHERE id = p_user_id;
END $$

DELIMITER ;

后端代码只需要发一句 SQL就能完成充值操作:

1
CALL recharge_and_bonus(1, 200);

但是现在没啥人用了

  • 难以调试:没法打断点,报错信息模糊。
  • 难以版本控制:代码都在数据库里,Git 很难管理它。
  • 计算压力转移:把计算逻辑压在数据库头上。数据库是很难扩容的,应用服务器却很容易扩容。

所以现在的原则是:“计算下移(去后端),存储上移(回数据库)”

所以这里就不深入了,就当是填个坑