游标是「先不拿完,一次只拿一行」的读法:
declare … cursor for 查询、open、fetch … into 变量、循环、close。
在 MySQL 里游标只能写在存储过程/函数/触发器里,
不能单独 open——所以第 13 章那套过程语言是它的前提。
游标几乎总能改写成一句 SQL,而集合操作快得多。
它是「实在没法用集合操作时的退路」,不是一种写法选择。
第 13 章讲「怎么把逻辑存进数据库」,这一篇讲那套逻辑里最特殊的一个工具:游标。 它只有一节,教材也给得很短——这个篇幅本身就说明了它的地位:能不用就不用。 但它必须懂:看不懂别人写的存储过程,就改不动它; 而线上那些「跑得特别慢的存储过程」,十有八九是在用游标逐行处理。
一、游标是什么:一个指向结果集的指针
一句 select 出来的是一整个结果集,
平时我们一次拿到全部。 游标是「先不拿完,只拿一行,处理完再拿下一行」。
像读书时用手指着一行一行往下移——
手指的位置就是游标。
open 是把手放到第一行前面,fetch 是往下移一行,close 是把手收回来。
为什么要有这么麻烦的东西? 因为有些事只有「逐行做」才做得出来:
每读一行都要调一次外部接口、每读一行都要跑一段 SQL 写不出来的判断。
这种需求用一条 SQL 表达不了,只能一行一行来。
二、MySQL 里的六步
第一步 declare 游标名 cursor for 查询——只定义,不执行,这时候 SQL 还没跑。
第二步 open 游标名——这时候才真正执行那句查询,
结果集在服务端准备好,游标停在第一行之前。
第三步 fetch 游标名 into 变量1, 变量2——取一行,塞进变量。
变量个数和查询的列数必须对上,类型也要能对上。
第四步是循环:loop … end loop 配合一个「取完了」的标志位。
第五步 close 游标名——释放服务端那个结果集。
顺序上有两条硬规定:
declare 必须写在 begin 之后、其他语句之前(和第 13.2 篇的变量声明一样);
declare 游标 必须写在 declare handler 之前。 写反了直接报错,而且报错信息不太友好。
三、handler for not found 是标配
取到末尾的时候,fetch 不是返回空,而是报一个 not found 条件(SQLSTATE 02000)。
不处理它,循环就结束不了——会直接抛异常。
所以每个游标都配一句:
declare continue handler for not found set done = 1;——
「遇到 not found 就把 done 置 1,然后接着往下走」。
循环里判断 if done then leave 循环; end if;。
注意 continue 这个词:它是「继续执行后面的语句」;
换成 exit 就是「跳出当前 begin … end 块」。
这里用 continue 才能让后面那句 set done = 1 被执行到——
写成 exit 会直接跳出,done 永远是 0,死循环。 这个坑很隐蔽,因为语法完全合法。
还有一个细节:fetch 在 done 之后还会再执行一次,把变量覆盖成 null。
所以循环体里必须先判断 done、再处理数据,顺序反了会把一行 null 当成真数据处理一遍。
四、本沙箱没有 SQL 游标,但 Python 的 cursor 就是游标
create procedure 在这里就报语法错,
游标更是无从谈起。 但「一次取一行」这件事,Python 的 cursor 对象本来就支持。
c.execute(...) 返回的就是一个游标:
fetchone() 取一行、fetchall() 取全部、fetchmany(n) 取一批。
概念上和 SQL 游标一一对应,只是它在客户端。
所以「游标」这个概念本身不难,难的是记住「它在哪一端」:
MySQL 的游标在服务端(结果集留在数据库进程里,一行一行往回送);
Python 的 cursor 在客户端(数据已经取回来了,只是你还没遍历完)。
这个区别决定了它们慢在哪。
五、三种取法:fetchone、fetchmany、fetchall
fetchone():
取一行,到底返回 None。 适合「取一行处理一行」的循环。 注意它不会自动回到开头——
到底之后再取还是 None,要重读得重新 execute。
fetchmany(n):取 n 行,返回列表。 这是「逐行太慢、全取太占内存」之间的折中,
批大小可以调,是很实用的一个中间档。
fetchall():全取,返回列表。 数据量不大时最省事,
数据量大时会把内存吃光——一百万行乘十列,光 Python 对象就上百 MB。
还有个更省事的写法:
for row in cur:——
游标本身就是可迭代的,for 循环等价于一直 fetchone 到 None。
这是 Python 里读结果集最常用的写法,也是本课 Demo 里用得最多的。
六、为什么游标慢:每行一次来回
Demo 在 5 万行上比了一次:
逐行 for 循环 68.3 毫秒,fetchall() 66.9 毫秒。 几乎一样。
这两个数不能当「游标慢多少倍」的结论。 两者都只读了一遍、都在同一个进程里、都没做任何处理
,差别主要在「建 5 万个元组」和「建一个列表装 5 万个元组」的开销上。
真正让游标慢的是「每行一次跨进程来回」:
在 MySQL 那种「客户端连服务端」的结构里,fetchone 一次就是一次网络往返,
5 万行就是 5 万次。 本沙箱是同一个进程里的库,这段代价被削掉了一大半。
所以「游标慢」这句话的准确说法是:
「逐行处理」慢,因为每一行都要走一遍完整的「取数据 → 处理 → 写回」流程,
而 SQL 一次能处理一整批。 这个结论跟具体哪个数据库、哪种语言关系都不大。
七、能用一条 SQL 做掉,就别用游标
Demo 里那个 MySQL 存储过程干的事是「把每行成绩加一」。
游标版要 5 万次 update;一句 SQL 就够:update big set score = score + 1。
一句 update 和「5 万次单行 update」是同一件事的两种写法,
但前者是集合操作、后者是逐行操作。 数据库擅长的是前者——
它一次读一批、一次写一批,中间不用来回。
所以看到「游标」,第一反应应该是「能不能改写成一句 SQL」:
累加用 update … set x = x + 1、补值用 update … set x = (select …)、
分组排名用窗口函数、找重复用 group by … having。
九成的游标都能这么改掉,而且快不止一个数量级。
这也是为什么「存储过程跑得慢」的故事里总有游标:
写的人是从「怎么一行一行处理」这个角度想的,而数据库的设计是围绕「一批一批处理」的。
两种思路对不上,慢就是必然的。
八、什么时候非用游标不可
一是一行要配一次外部动作。 比如「逐行读订单、每行调一次支付接口退款」。
接口调用 SQL 写不出来,只能一行一行来——
但更常见的做法是「SQL 查出该退的、应用里循环调接口」,
也就是把游标挪到应用层,别留在数据库里。
二是逐行逻辑复杂到 SQL 表达不了。 比如「按上一行的结果决定这一行怎么处理」这种有状态的逻辑
,集合操作天然做不了。 不过窗口函数出现之后,这类需求已经少了很多。
三是数据量大到装不下内存。 fetchall() 会把几百万行全塞进内存,
这时候用游标或者 fetchmany 分批才是对的。 注意这一条的理由是「内存」,不是「速度」——
论速度,分批一定比一次性取慢。
总结成一句:游标是退路,不是选项。 教材把它放在第 14 章、只给一节,也是这个意思。
九、动手 Demo:用 Python 的 cursor 走一遍游标的四种读法,再和一句 SQL 对照
Demo 五段:fetchone() 逐行取到 None、for 循环遍历、fetchmany() 分批、
5 万行上量一次逐行与整批、最后看一句 update 怎么替代游标。
第四段那两个数字(68.3 和 66.9)故意放在一起——
它们几乎相等,正好说明「同一个进程里的逐行并不慢」,慢的是跨进程那一趟。
import time
import _q46 as Q
c = Q.con()
# c.execute() 返回的就是游标
cur = c.execute("SELECT id, name, height FROM students ORDER BY id")
for i in range(4):
print("fetchone ->", cur.fetchone())
print("到底了 ->", cur.fetchone()) # None
# 更常用的写法:游标本身可迭代,等价于一直 fetchone
for row in c.execute("SELECT id, name FROM students ORDER BY id"):
print(row)
# 分批取:逐行太慢、全取太占内存时的折中
cur = c.execute("SELECT id, name FROM students ORDER BY id")
while True:
part = cur.fetchmany(2)
if not part:
break
print("一批", len(part), "行")
# 逐行与整批各量一次(5 万行)
c2 = Q.blank()
c2.execute("CREATE TABLE big (id INTEGER PRIMARY KEY, "
"name TEXT, score INTEGER)")
c2.executemany("INSERT INTO big VALUES (?,?,?)",
[(i, '学生%05d' % i, 60 + i % 40)
for i in range(1, 50001)])
c2.commit()
sql = "SELECT id, name, score FROM big"
t0 = time.perf_counter()
n = 0
for row in c2.execute(sql):
n += 1
t_row = (time.perf_counter() - t0) * 1000
t0 = time.perf_counter()
rows = c2.execute(sql).fetchall()
t_all = (time.perf_counter() - t0) * 1000
print("逐行 %.1f 毫秒,整批 %.1f 毫秒" % (t_row, t_all))
# 游标干的事,一句 SQL 就能做掉
Q.run(c2, "UPDATE big SET score = score + 1 WHERE id <= 3")
Q.show(c2, "SELECT id, name, score FROM big ORDER BY id LIMIT 3")
实跑输出:
--------------------------------------------------------------------
第七十二课:游标——一次只拿一行
--------------------------------------------------------------------
游标是什么:一个指向结果集的指针
**一句 `select` 出来的是一整个结果集**,
**平时我们一次拿到全部。** **游标是「先不拿完,只拿一行,处理完再拿下一行」。**
**像读书时用手指着一行一行往下移**——
**手指的位置就是游标。**
**`open` 是把手放到第一行前面,`fetch` 是往下移一行,`close` 是把手收回来。**
**为什么要这么麻烦?** **因为有些事「逐行做」才做得出来**:
**每读一行都要调一次外部接口、每读一行都要跑一段复杂判断。**
**这种需求用一句 SQL 写不出来,只能一行一行来。**
MySQL 里长什么样:必须写在存储过程里
MySQL 原码(本沙箱不执行):
| DELIMITER $$
| CREATE PROCEDURE fix_score()
| BEGIN
| DECLARE done INT DEFAULT 0;
| DECLARE s_id INT;
| DECLARE s_score INT;
| DECLARE cur CURSOR FOR SELECT id, score FROM big;
| DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
| OPEN cur;
| read_loop: LOOP
| FETCH cur INTO s_id, s_score;
| IF done THEN LEAVE read_loop; END IF;
| UPDATE big SET score = s_score + 1 WHERE id = s_id;
| END LOOP;
| CLOSE cur;
| END $$
| DELIMITER ;
**六步**:**`declare 游标名 cursor for 查询`** 定义,**`open`** 打开,
**`fetch … into 变量`** 取一行塞进变量,**循环**,**`close`** 关闭。
**那个 `handler for not found` 是游标的标配**:
**取不到行了会报 `not found`(SQLSTATE 02000)**,
**用 handler 把它变成一个标志位,循环才知道该退出。** **不写它,取到末尾就抛异常。**
**注意游标只能写在存储过程、存储函数、触发器里**——
**不能单独 `open` 一个游标。** **这是 MySQL 的硬规定**,
**所以第 13 章那套过程语言是游标的前提。**
**这也意味着:本沙箱连过程都没有,自然也谈不上 SQL 游标。**
本沙箱没有 SQL 游标,但 Python 的 cursor 就是游标
**`c.execute(...)` 返回的就是一个游标对象。**
**它有两个动作:`fetchone()` 取一行,`fetchall()` 取全部。**
逐行取:
fetchone -> (1, '张伟', 175.5)
fetchone -> (2, '李娜', 162.0)
fetchone -> (3, '王强', 181.0)
fetchone -> (4, '赵敏', 168.0)
取完了,再取一次:
fetchone -> None
**第五次 `fetchone()` 给的是 `None`**——
**因为游标已经到底了,不会自动回到开头。** **要重新读,得重新 `execute` 一次。**
游标的三种取法
**`fetchone()`**:取一行,到底返回 `None`。 **适合「取一行处理一行」的循环。**
**`fetchmany(n)`**:取 n 行,返回列表。
**适一次处理一批**,批大小可以调,**是「逐行太慢、全取太占内存」之间的折中。**
**`fetchall()`**:全取,返回列表。 **数据量不大时最省事,数据量大时会把内存吃光。**
**还有更省事的写法**:
**`for row in cur:`**——
**游标本身就是可迭代的,`for` 循环相当于一直 `fetchone` 到 `None`。**
**这是 Python 里读结果集最常用的写法。**
为什么游标慢:每行一次来回
**5 万行,两种读法各跑一次**:**逐行 `for` 循环 67.9 毫秒,`fetchall()` 65.4 毫秒。**
**但这里要说清楚:这两个数不能当「游标慢多少倍」的结论。**
**两者都只读了一遍、都在本地内存里、都没做任何处理**——
**差别主要在「建 5 万个元组」和「建一个列表装 5 万个元组」的开销上。**
**真正让游标慢的是「每行一次跨进程来回」**:
**在 MySQL 那种「客户端连服务端」的结构里,`fetchone` 一次就是一次网络往返**,
**5 万行就是 5 万次。** **本沙箱是同一个进程里的库,所以这个代价被削掉了一大半。**
**所以「游标慢」这句话的准确说法是**:
**「逐行处理」慢,因为每一行都要走一遍完整的「取数据 → 处理 → 写回」流程**,
**而 SQL 一次能处理一整批。** **这个结论跟具体哪个数据库关系不大。**
能用一条 SQL 做掉,就别用游标
**Demo 里那个 MySQL 存储过程干的事是「把每行成绩加一」。**
**游标版要 5 万次 `update`;一句 SQL 就够**:**`update big set score = score + 1`。**
SQL> UPDATE big SET score = score + 1 WHERE id <= 3
影响 3 行
SQL> SELECT id, name, score FROM big ORDER BY id LIMIT 3
id name score
---------------------------
1 学生00001 62
2 学生00002 63
3 学生00003 64
(3 行)
**一句 `update` 和「5 万次单行 `update`」是同一件事的两种写法**,
**但前者是「集合操作」,后者是「逐行操作」。** **数据库擅长的是前者**——
**它一次读一批、一次写一批,中间不用来回。**
**所以看到「游标」两个字,第一反应应该是「能不能改写成一句 SQL」**:
**累加用 `update … set x = x + 1`**、**补默认值用 `update … set x = (select …)`**、
**排名用窗口函数或者自连接。** **九成的游标都能这么改掉。**
什么时候非用游标不可
**一是一行要配一次外部动作。** **比如「逐行读订单,每行调一次支付接口退款」**——
**接口调用 SQL 写不出来,只能一行一行来。**
**但更常见的做法是「SQL 查出该退的、应用里循环调接口」**,**也就是把游标挪到应用层。**
**二是逐行逻辑复杂到 SQL 表达不了。**
**比如「按上一行的结果决定这一行怎么处理」这种有状态的逻辑**,
**集合操作天然做不了。** **不过窗口函数出现之后,这类需求也少了很多。**
**三是数据量大到装不下。** **`fetchall()` 会把几百万行全塞进内存**,
**这时候用游标(或者 `fetchmany` 分批)才是对的。** **注意这一条的理由是「内存」,不是「速度」**
——**论速度,分批一定比一次性取慢。**
**总结成一句**:
**游标是「实在没法用集合操作时的退路」,不是「一种写法选择」。**
**教材把它放在第 14 章、只给一节,也是这个意思。**
逐行取四行:张伟、李娜、王强、赵敏,第五次 fetchone() 给 None。
分批取:4 行按每批 2 行,分两批取完。
5 万行:逐行 68.3 毫秒,整批 66.9 毫秒,几乎一样。
最后一句 update:id <= 3 的三行成绩各加一,一行 SQL 干掉了游标 5 万次的活。
30 秒实验
- 把
fetchone()那四行取完之后再execute一次同样的查询,游标会回到开头吗? - 5 万行那次,如果把
fetchall()换成逐行但每行都做一次update,时间会差多少?先估再试。 - 为什么
handler里用exit而不是continue会死循环?
常见误区
- 用游标做「整表加减」这种集合操作。 一句
update就够,游标慢几个数量级。 handler写exit。 那句set done = 1执行不到,循环退不出来,而且语法完全合法。fetchall()读百万行的大表。 内存会吃光,要分批就用fetchmany。
自测三问(点开对答案)
1. 游标必须在什么里面才能用? —— 存储过程、存储函数、触发器等存储程序里。MySQL 不允许单独 `open` 一个游标。 2. 为什么每个游标都要配 `handler for not found`? —— 取到末尾时 `fetch` 报 `not found` 条件,不处理就抛异常、循环退不出来。 它把「到底了」变成一个标志位。 3. 本沙箱里逐行和整批几乎一样快,为什么 MySQL 上不是? —— 因为本沙箱的库和应用在同一个进程里,没有网络往返; MySQL 每次 `fetchone` 都是一趟网络,5 万行就是 5 万趟。第 14 章完了: 游标就这一篇,记住「能用 SQL 就别用它」就够了。 下一篇进第 15 章——事务,这一章比游标重要得多。
- ☐ (本篇完,下一篇 15.1 事务是什么)