🦏 第14章 游标
📄 一句话概括

游标是「先不拿完,一次只拿一行」的读法: 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 秒实验

  1. 把 fetchone() 那四行取完之后再 execute 一次同样的查询,游标会回到开头吗?
  2. 5 万行那次,如果把 fetchall() 换成逐行但每行都做一次 update,时间会差多少?先估再试。
  3. 为什么 handler 里用 exit 而不是 continue 会死循环?

常见误区

  1. 用游标做「整表加减」这种集合操作。 一句 update 就够,游标慢几个数量级。
  2. handler 写 exit。 那句 set done = 1 执行不到,循环退不出来,而且语法完全合法。
  3. fetchall() 读百万行的大表。 内存会吃光,要分批就用 fetchmany。
自测三问(点开对答案) 1. 游标必须在什么里面才能用? —— 存储过程、存储函数、触发器等存储程序里。MySQL 不允许单独 `open` 一个游标。 2. 为什么每个游标都要配 `handler for not found`? —— 取到末尾时 `fetch` 报 `not found` 条件,不处理就抛异常、循环退不出来。 它把「到底了」变成一个标志位。 3. 本沙箱里逐行和整批几乎一样快,为什么 MySQL 上不是? —— 因为本沙箱的库和应用在同一个进程里,没有网络往返; MySQL 每次 `fetchone` 都是一趟网络,5 万行就是 5 万趟。

第 14 章完了: 游标就这一篇,记住「能用 SQL 就别用它」就够了。 下一篇进第 15 章——事务,这一章比游标重要得多。

相关笔记