8.0 新特性列了一长串,真正改变写法的是三样:窗口函数、CTE、降序索引。 其余那些是「内部变了」——不改代码也能跑,但升级时得留神。 窗口函数解决「保留每行再加一列排名」,CTE 解决「嵌套太深读不懂」。
上一篇看清了版本号,这一篇看 8.0 到底带来了什么。 窗口函数和 CTE 在第 10 章讲查询时会反复出现,这里先认识它们的样子。 降序索引是第 20 章优化的事,这一篇只讲它解决了什么。 和 2.4 节的关系:8.0 改了默认字符集和默认认证插件, 这两处是升级时最容易踩的坑,那一篇会具体讲怎么改。
一、为什么说 8.0 是「写法变了」
教材列了十几条:窗口函数、公共表表达式、降序索引、JSON 增强、角色管理、原子 DDL、 默认 utf8mb4、新认证插件…… 这一串得分两类看,混在一起记只会记成一张清单。
| 分类 | 包括哪些 | 你要做什么 |
|---|---|---|
| 写法变了 | 窗口函数、CTE、降序索引 | 重学这三样,老代码可以不动 |
| 默认值变了 | utf8mb4、caching_sha2_password | 升级时查字符集和驱动版本 |
| 能力增强 | JSON、角色、原子 DDL、直方图 | 按需用,不影响已有代码 |
判断标准很简单:不改代码也能跑,说明只是「内部变了」。 原来写不出来的查询现在一句能写,才叫「写法变了」。 三样里最值钱的是窗口函数。 它把一整类「排名、累计、比上一行」的问题,从自连接里解放了出来。
二、窗口函数:保留每一行,再加一列
需求:每个部门内部按金额排名,取前三。
5.7 没有窗口函数,只能自连接:同一张表跟自己按部门连起来,再数「不比它小的有几条」。
8.0 一句 ROW_NUMBER() OVER (PARTITION BY dept ORDER BY amt DESC) 就完了。
| 函数 | 遇到并列给什么 | 典型用途 |
|---|---|---|
| ROW_NUMBER() | 照排 1 2 3 4,并列也分先后 | 取前 N 名、分页 |
| RANK() | 并列同名次,后面跳号(1 1 3) | 排行榜 |
| DENSE_RANK() | 并列同名次,后面不跳号(1 1 2) | 分数段统计 |
三个函数的差别只在「遇到相同的值」那一刻,其余完全一样。
记住一句话就够:要「取三条」用 ROW_NUMBER,要「第几名」用 RANK。
用错了不会报错,只会悄悄少给或多给几行。
OVER 括号里那两段也得记住:PARTITION BY 是分组,ORDER BY 是组内排序。
它和 GROUP BY 完全不同:GROUP BY 把多行压成一行,窗口函数保留所有行,只是多挂一列。
三、CTE:把嵌套摊平,还能递归
CTE 就是 WITH 名字 AS (子查询),给子查询起个名,后面当表用。
它的价值不在快,在于把「从里往外读」变成「从上往下读」。
更关键的是【递归 CTE】:只有它能表达「一直往下找」。
组织树、菜单树、评论盖楼、物料清单展开,全靠它。
import sqlite3
c = sqlite3.connect(":memory:")
c.execute("CREATE TABLE dept (id INTEGER PRIMARY KEY, name TEXT,"
" parent INTEGER REFERENCES dept(id))")
c.executemany("INSERT INTO dept VALUES (?,?,?)", [
(1, "总公司", None), (2, "研发中心", 1), (3, "市场中心", 1),
(4, "后端组", 2), (5, "前端组", 2), (6, "华东区", 3), (7, "华南区", 3)])
SQL = """
WITH RECURSIVE sub(id, name, lv, path) AS (
SELECT id, name, 0, CAST(id AS TEXT) FROM dept WHERE name = ?
UNION ALL
SELECT d.id, d.name, s.lv + 1, s.path || '/' || d.id
FROM dept d JOIN sub s ON d.parent = s.id
)
SELECT lv, name, path FROM sub
"""
for root in ("研发中心", "总公司"):
rows = c.execute(SQL, (root,)).fetchall()
rows.sort(key=lambda r: [int(x) for x in r[2].split("/")])
print("[%s] 一共 %d 个节点(含自己)" % (root, len(rows)))
for lv, name, path in rows:
print(" " + " " * lv + name)
[研发中心] 一共 3 个节点(含自己)
研发中心
后端组
前端组
[总公司] 一共 7 个节点(含自己)
总公司
研发中心
后端组
前端组
市场中心
华东区
华南区
输出的两层缩进就是树的形状:以「研发中心」为根只有 3 个节点,以「总公司」为根有 7 个。 递归 CTE 的结构固定是三块,换任何场景都是这三块。
| 块 | 写什么 | 在上面这段里 |
|---|---|---|
| WITH RECURSIVE 名字 | 声明这是递归 CTE | sub |
| 第一段(锚点) | 起点是谁 | name 等于研发中心那一行 |
| UNION ALL 第二段 | 怎么从上一层长出下一层 | d.parent = s.id |
最容易踩的坑是忘了终止条件:只要数据成环,它就会一直长下去。
MySQL 默认最多递归 1000 层,超了报 Recursive query aborted。
这个默认值由 cte_max_recursion_depth 控制,别随手调到很大。
四、降序索引和其余几样
8.0 之前,CREATE INDEX ... (col DESC) 里的 DESC 是被悄悄忽略的,索引一律升序建。
8.0 真的按降序存,所以 ORDER BY ... DESC 可以顺着索引读,不用再排一遍。
其余几样,一句话一条:
| 特性 | 一句话 |
|---|---|
| 默认字符集 utf8mb4 | emoji 和生僻字终于能存了 |
| JSON 增强 | 可以按路径查、按路径建索引 |
| 角色(ROLE) | 权限可以打包发,不用一个个授 |
| 原子 DDL | 建表失败不会留下半张表 |
| 直方图 | 让优化器知道数据分布,选对执行计划 |
| caching_sha2_password | 默认认证插件换了,老客户端连不上就是它 |
最后一行是升级时最常踩的坑。
5.7 时代的老驱动连 8.0 会报认证失败,改法有两个:升级驱动,
或者把账号改回 mysql_native_password。
前者是正路,后者是缓兵之计。
五、动手 Demo:8.0 那三样:窗口函数、CTE、降序索引,各跑一遍
同一份数据、同一台沙箱,把「5.7 怎么写」和「8.0 怎么写」各跑一遍。 本沙箱的 SQLite 支持窗口函数和 CTE,所以这两样能真跑。 降序索引只能演效果,语法照 MySQL 原样贴出来。
import sqlite3
c = sqlite3.connect(":memory:")
c.execute("CREATE TABLE sale (id INTEGER PRIMARY KEY,"
" dept TEXT, name TEXT, amt REAL)")
rows = [(i, "D%d" % (i % 3 + 1), "人%03d" % i,
1000.0 + (i * 37) % 9000) for i in range(1, 301)]
c.executemany("INSERT INTO sale VALUES (?,?,?,?)", rows)
sql57 = ("SELECT a.dept, a.name, a.amt, COUNT(*) AS rk FROM sale a "
"JOIN sale b ON a.dept = b.dept AND a.amt <= b.amt "
"GROUP BY a.dept, a.name, a.amt HAVING COUNT(*) <= 3 "
"ORDER BY a.dept, rk")
sql80 = ("SELECT dept, name, amt, ROW_NUMBER() OVER "
"(PARTITION BY dept ORDER BY amt DESC) AS rk FROM sale")
print("5.7 自连接 %2d 行" % len(c.execute(sql57).fetchall()))
mid = c.execute("SELECT COUNT(*) FROM sale a JOIN sale b "
"ON a.dept = b.dept AND a.amt <= b.amt").fetchone()[0]
print("中间结果 %2d 行" % mid)
c.execute("CREATE VIEW v_rank AS " + sql80)
q80 = ("SELECT dept, name, amt, rk FROM v_rank WHERE rk <= 3 "
"ORDER BY dept, rk")
print("8.0 窗口函数 %2d 行" % len(c.execute(q80).fetchall()))
实跑输出:
--------------------------------------------------------------------
第五课:8.0 那几样新东西,到底解决了什么
--------------------------------------------------------------------
**教材列了 8.0 的新特性一长串,真正改变写法的只有三样:窗口函数、CTE、降序索引。**
**其余那些(默认 utf8mb4、JSON 增强、角色管理)是「更好用」,这三样是「写法变了」。**
**这一课用同一份数据,把「5.7 怎么写」和「8.0 怎么写」各跑一遍。**
**注意:本沙箱的 SQLite 3.53 支持窗口函数和 CTE,所以这两样能真跑。**
**降序索引只能演效果,语法照 MySQL 贴。**
一、窗口函数:排名不用自连接了
--------------------------------------------------------------------
**需求:每个部门内部按金额排名,取前三。5.7 时代只能这么写——**
[教材原码 · 本沙箱不执行]
| SELECT a.dept, a.name, a.amt, COUNT(*) AS rk
| FROM sale a
| JOIN sale b ON a.dept = b.dept AND a.amt <= b.amt
| GROUP BY a.dept, a.name, a.amt
| HAVING COUNT(*) <= 3;
(自连接 + 分组计数,中间结果要把部门内两两配对全摆出来)
**这一条在沙箱里跑通 9 行,耗时 0.0153 秒。**
**8.0 用窗口函数,一句就够:**
SELECT dept, name, amt, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY amt DESC) AS rk
FROM sale
**把它包成视图,外面再筛「rk 小于等于 3」——这样两边取的都是前三名。**
mysql> SELECT dept, name, amt, rk FROM v_rank WHERE rk <= 3 ORDER BY dept, rk
dept name amt rk
------------------
D1 人243 9991 1
D1 人240 9880 2
D1 人237 9769 3
D2 人241 9917 1
D2 人238 9806 2
D2 人235 9695 3
D3 人242 9954 1
D3 人239 9843 2
D3 人236 9732 3
9 rows in set
写法 耗时 结果行数 中间结果
-------------------------------------------------------
5.7 自连接 0.0153 秒 9 15150 行
8.0 窗口函数 0.0006 秒 9 不落中间表,一遍扫完
**两条 SQL 的差距不只是字数。**
**自连接那条的中间结果是「部门内的两两配对」,300 行输入吹成了 15150 行。**
**窗口函数只扫一遍,排名是边扫边算出来的,不需要把两两配对先摆出来。**
**这台机器上 5.7 那条明显更慢,慢就慢在这坨中间结果上。**
**但这个差距不能外推——换机器、换缓存状态、换数据分布,倍数就变。**
**真正稳的是中间结果的行数:它只跟数据量有关,跟机器无关。**
二、CTE:把嵌套子查询摊平
--------------------------------------------------------------------
**需求:找出「销售额高于本部门平均」的人,再按超额比例排序。**
**5.7 得套两层子查询:**
[教材原码 · 本沙箱不执行]
| SELECT name, amt FROM sale
| WHERE amt > (SELECT AVG(amt) FROM sale s2 WHERE s2.dept = sale.dept)
| ORDER BY amt DESC LIMIT 5;
**8.0 可以先把部门均值算成一个「临时视图」,再拿它去 join:**
WITH avg_dept AS (SELECT dept, AVG(amt) AS a FROM sale GROUP BY dept)
SELECT s.name, s.amt, avg_dept.a AS dept_avg
FROM sale s JOIN avg_dept ON avg_dept.dept = s.dept
WHERE s.amt > avg_dept.a
ORDER BY s.amt DESC
LIMIT 3
name amt dept_avg
-------------------
人243 9991 4895.5
人242 9954 4858.5
人241 9917 4821.5
3 rows in set
**CTE 的好处不是快,是好读:语句从内往外读变成了从上往下读。**
**递归 CTE 更是只有它做得到——组织树、菜单树全靠它。**
三、降序索引:省掉一次倒序
--------------------------------------------------------------------
[教材原码 · 本沙箱不执行]
| CREATE INDEX idx_amt_desc ON sale (amt DESC);
| -- 8.0 之前 DESC 是被忽略的,索引一律升序建;
| -- 8.0 真的按降序存,ORDER BY amt DESC 可以直接顺着索引读。
**SQLite 也支持 DESC 索引,这里真建一个,看计划有没有变:**
索引 ORDER BY amt DESC 的计划
-----------------------------------------------------
amt 升序 SCAN sale USING COVERING INDEX i_amt
amt DESC 降序 SCAN sale USING COVERING INDEX i_amt_d
**两行都用了索引,但只有第二行是「顺着读」,第一行要「倒着读」。**
**小数据看不出差别,大表上倒序读会多一次临时排序。**
四、其余那些,一句话说完
--------------------------------------------------------------------
特性 解决了什么
-------------------------------------------------------------
默认字符集 utf8mb4 emoji 和生僻字终于能存了
JSON 增强 可以按路径查、按路径建索引
角色(ROLE) 权限可以打包发,不用一个个授
原子 DDL 建表失败不会留下半张表
caching_sha2_password 默认认证插件换了,老客户端连不上就是它
**最后一行是升级时最常踩的坑:5.7 的老驱动连 8.0 会报认证失败。**
**不是密码错了,是插件不认。**
第一段最值得看的不是耗时,是那个 15150。
300 行输入,自连接那条要先摆出 15150 行的中间结果,才数得出名次。
窗口函数只扫一遍,边扫边算,中间结果一行都不落。
耗时那一列两个数差了二十来倍,但这个倍数不能外推。
换机器、换缓存状态、换数据分布,数就变。
真正跟机器无关的是中间结果的行数。
第二段的 CTE 只用了六个字的好处:从上往下读。
注意它和子查询的结果完全一样,换来的只有可读性——可读性在三个月后改自己的 SQL 时才知道值钱。
第三段是真的建了两个索引对比:升序索引和 DESC 索引。
两行计划都显示用了索引,但只有降序那个是「顺着读」。
小数据看不出差别,大表上倒序读会多一次排序。
最后那张表里,只有 caching_sha2_password 会当场咬人。
它是 8.0 的默认认证插件,5.7 的老驱动不认,报的却是「认证失败」。
这种「报错说得不明不白」的坑,知道一次就够。
30 秒实验
- 把 Demo 里的
ROW_NUMBER换成RANK,再跑一遍。这份数据没有并列金额,所以结果一模一样。 想看到差别,得把某两行的 amt 改成相同的值。 - 把 CODE221 里递归 CTE 的
WHERE name = ?删掉,看返回几行。 锚点一下子变成 7 行,每行都往下长,同一节点会被算好几次。 这就是「锚点必须只圈一个起点」的原因。 - 把 Demo 的数据从 3 个部门改成 1 个部门,窗口函数会不会更快?
会,因为不需要分组了——
PARTITION BY全落在同一组,等于只排一次序。
常见误区
- 「窗口函数比 GROUP BY 快。」
不是一类东西,没法比。
GROUP BY把多行压成一行,窗口函数保留每一行再加一列。 快慢取决于具体查询,不取决于用哪个语法。 - 「CTE 是临时表,会落盘。」 CTE 是语法结构,不是建了张表。 优化器可能把它合并进主查询,也可能物化成临时结果,要看执行计划才知道。
- 「升到 8.0 就不用管字符集了。」
新库默认 utf8mb4 是对的,但 5.7 升上来的老库还是 utf8。
要改得一张张表
CONVERT TO CHARACTER SET,2.4 节会算这笔账。
自测三问(点开对答案)
1. ROW_NUMBER、RANK、DENSE_RANK 遇到并列时分别给什么? —— **ROW_NUMBER:1 2 3 4,并列也分先后。** **RANK:1 1 3,并列同号、后面跳。** **DENSE_RANK:1 1 2,并列同号、后面不跳。** 2. 递归 CTE 必须写哪三段? —— **`WITH RECURSIVE 名字 AS (` 加锚点、`UNION ALL`、再加重放段 `)`。** **锚点只圈起点,重放段写「怎么从上一层长出下一层」。** 3. 为什么 5.7 的 Java 老驱动连不上 8.0? —— **8.0 默认认证插件换成了 `caching_sha2_password`,老驱动不认这个握手。** **报出来的是「认证失败」,看起来像密码错,其实跟密码无关。**下一篇讲机器怎么摆:单机、主从、集群,三种方案各要几台机器, 以及为什么「写」才是那道绕不过去的坎。
- ☐ (本篇完,下一篇 2.3 MySQL 的应用环境)