做数据库开发这些年,最常被新手问到的SQL概念,“游标”绝对排得上号。不管是面试题里让人发懵的名词解释,还是实际业务中需要逐行处理数据、做复杂业务计算,游标都会突然跳出来刷一波存在感。我第一次真正理解它,不是靠文档,而是被一个报表需求虐了整整一下午:需要把订单表里符合条件的数据逐行处理,为每一行计算独立的分润比例,再更新到另一张表里。直接写UPDATE写不出来,到处查资料后才意识到,数据库里原来还有游标这种“指针/迭代器”式的角色,它就像C语言指针指向内存地址、Python生成器按需吐数据一样,帮我在SQL的集合世界里实现了逐行遍历。
这篇内容会围绕游标展开,讲清楚它是什么、内部怎么工作、主流数据库怎么用、什么时候该坚决避开它。适合正在做数据库课程设计的学生、刚接触存储过程开发的新手,以及想系统梳理游标知识的业务开发。老规矩,前面先讲透原理,后面直接给可抄的实战代码和踩坑记录。
1. 从一个真实查询场景切入:SQL是集合操作,可业务偏偏要逐行
1.1 集合操作与逐行处理的矛盾
SQL最擅长的事情,是对一批数据做整体计算。一条UPDATE语句可以一次更新上万行,一条SELECT可以返回整个结果集,这就是关系数据库的“集合思维”。数据库底层会帮我们优化执行计划,用索引、哈希连接、并行扫描等手段把集合操作跑得飞快。但现实业务里,有大量场景没法用一条集合语句完成。
举个我实际碰到的例子:资金调拨系统里,每天要对一批账户做利息计算,每笔金额不同、利率等级不同、计息天数也不同,而且计算完之后还要把结果累加到汇总表,并把每个账户的明细写入历史表。如果用单条UPDATE,根本没法在SQL层同时完成“计算-写入-统计”三件事。这时候传统的做法就是写一段存储过程,把查询结果集一条条拉出来,在过程中做判断和计算,再逐条更新。
这种“逐行处理”的需求,就是游标存在的理由。游标本质上是一个“结果集的遍历通道”,先用SQL把满足条件的行取出来,然后在程序或存储过程里一行一行地读取、处理、更新。它把数据库里的集合操作降维成了一串顺序访问的记录流,方便我们在每一行上执行复杂业务逻辑。
所以你在很多数据库教材和面试答案里会看到一句话:游标是数据库中的指针,它指向某条记录的位置。这个说法没错,但更准确一点,游标更像是“结果集的迭代器”。指针强调的是“地址”,游标强调的是“当前位置 + 遍历能力”。只要记住这个定位,后面学各种SQL方言都不会乱。
1.2 游标与指针、迭代器的十字关系
很多人一听到“游标(Cursor)”就条件反射地去联想C语言的指针(Pointer),又联想到C++/Java里的迭代器(Iterator),然后被绕晕。我用一张表直接讲清楚三者的关系:
| 维度 | C语言指针 | Python/Java迭代器 | 数据库游标 |
|---|---|---|---|
| 核心概念 | 保存内存地址,可算术运算 | 保存容器中的遍历位置,通过next()取下一个元素 | 保存结果集的当前行,通过FETCH移动 |
| 能做什么 | 解引用取值、偏移、指针运算 | 逐个取出元素,可以提前终止 | 逐个取出行,支持前进/后退、跳跃 |
| 生命周期 | 手动管理,可能悬空 | 随对象销毁自动失效 | 需要显式OPEN、CLOSE,忘了释放会泄漏 |
| 操作对象 | 内存 | 内存中的集合 | 数据库表/视图/查询结果 |
从这个对比可以看出,游标和迭代器的亲缘关系更近。因为迭代器封装了“遍历状态”,调用next()才产生下一个元素,这跟游标的FETCH从结果集取下一行几乎一模一样。而C语言指针最要命的“野指针”“内存越界”问题,在数据库游标里对应的是“游标未关闭”“FETCH方向超出结果集”这类报错。
理解这层关系之后,再去写游标代码,心态会稳很多。游标并不是某种高深魔法,它就是数据库版的“for line in file”,只不过文件换成了结果集,for循环换成了OPEN/FETCH/CLOSE这些SQL指令而已。
2. 游标的核心机制与生命周期:每一步都要明白在干什么
2.1 声明、打开、抓取、关闭、释放
无论哪个数据库,显式游标的操作步骤基本都围绕五个动作展开,我以通用流程来说明:
| 阶段 | 动作 | 说明 |
|---|---|---|
| 1 | DECLARE | 声明游标,并绑定一条SELECT语句,此时还没执行查询 |
| 2 | OPEN | 打开游标,真正执行SELECT,生成结果集或指向首行 |
| 3 | FETCH | 获取当前行,并把游标位置移到下一行 |
| 4 | CLOSE | 关闭游标,释放当前结果集占用的资源 |
| 5 | DEALLOCATE/释放 | 销毁游标对象(部分数据库需要) |
DECLARE阶段最容易被忽略。很多人以为DECLARE就是“创建结果集”,其实不是。它只是告诉数据库“我要定义一个游标,它跑的是这条SQL”。真正把SQL跑起来的是OPEN。类似写代码时的“声明变量”和“给变量赋值”是两件事,不是一回事。
FETCH是核心操作。每执行一次FETCH,游标指针就往前移动一行,并把当前行的列值赋给指定的变量。当你把人家的数据取完,继续FETCH就会触发“无数据”或“未找到记录”的条件,这个条件正是循环终止的关键。常见错误是忘了处理“取完最后一行的退出条件”,导致死循环或重复处理最后一行。
CLOSE之后,游标定义还在,还能再OPEN。但很多数据库CLOSE并不会彻底释放游标对象占用的内存,这时候需要DEALLOCATE或让变量离开作用域来彻底销毁。说句直白的经验:在Oracle里我见过最多的问题不是不会写游标,而是写完不CLOSE,或者PL/SQL里用隐式游标用惯了,忘了显式游标要收尾。
2.2 游标内部是怎么跑的:临时表、快照和锁定
很多资料会直接告诉你“游标慢,少用”,但说不清为什么慢。游标的内部机制大致分两类:一类是“物化结果集”,另一类是基于索引的“动态定位”。
物化结果集很容易理解:OPEN的时候,数据库把满足SELECT条件的行复制到一块临时存储(临时表或工作区)里,之后FETCH就从这块临时区取数。这样做的优点是游标打开后,即使原表数据发生变化,游标里的数据也不受影响,读起来稳定;缺点是产生额外的I/O和存储消耗,数据量大时格外明显。
另一类是直接在原表上通过索引定位当前行,每次FETCH都去表里读取数据。这样做省了临时表拷贝,但可能因为是否使用索引、事务隔离级别不同,出现不可重复读、幻读,甚至在更新数据时锁定行。MySQL的InnoDB里,如果用游标遍历数据的同时又去更新同一个表,很容易产生锁等待或死锁,原因就是游标持有的读锁和更新需要的写锁互相冲突。
所以游标不只是“慢”,它在没有索引的情况下会退化成逐条扫描,加上每次FETCH都是一次交互,累计开销非常可观。后面第四章我会专门讲替代方案,这里先记住一个结论:游标越是在大数据集上逐行操作,性能劣化越明显。
2.3 游标的类型与滚动方向:别只会FETCH NEXT
游标也有分类,掌握分类能帮你在不同场景快速选型。按“是否允许滚动”来看,可以分为:
- 非滚动游标(Forward-Only):FETCH只能向下取,不能回头,也不能跳行。这是最常用的,大多数业务场景用不到回退。
- 滚动游标(Scrollable):支持FETCH FIRST、NEXT、PRIOR、LAST、ABSOLUTE n、RELATIVE n等操作。适合需要在结果集里前后跳着查看的场景,但成本和复杂度都更高。
按“能否修改数据”来看,还分为只读游标和可更新游标。很多新手以为既然有游标,就可以一边遍历一边UPDATE,确实某些数据库支持,比如SQL Server的WHERE CURRENT OF可以更新游标当前行。但在实践里,我建议扪心自问一句:这个问题用集合UPDATE真的解决不了吗?如果真需要更新当前行,也建议先FETCH到主键,再用主键发UPDATE,尽量避免让游标兼任读写两种角色。
| 游标类型 | 支持方向 | 典型使用场景 | 注意事项 |
|---|---|---|---|
| Forward-Only | NEXT | 批量读、批量归档 | 无法回看之前的行 |
| Scrollable | NEXT、PRIOR、FIRST、LAST等 | 报表分页、前后翻页 | 内存占用可能更高 |
| Read-Only | 按声明决定 | 查询统计、只读业务 | 不能通过游标更新数据 |
| Updatable | 按声明决定 | 逐行修改 | 锁冲突和高风险,慎用 |
实际存储过程里,绝大多数情况用“只读 + 前向”游标就够了。把游标当成一个“流式读取器”,Business逻辑放在循环体内,别在游标上玩太多花活,这是最稳妥的姿态。
3. 主流数据库游标实操对照:一边看语法,一边学套路
3.1 MySQL:存储过程中的游标三步走
MySQL里游标只能在存储过程、函数或事件里使用,不能单独在控制台裸跑。典型场景是遍历某个结果集,然后调用其他逻辑。
先看一个完整示例:把user表中所有状态为0的用户,逐个取出后再更新他们的等级字段。
DELIMITER $$ CREATE PROCEDURE update_user_level() BEGIN DECLARE v_id INT; DECLARE v_status INT; DECLARE done INT DEFAULT FALSE; DECLARE cur CURSOR FOR SELECT id, status FROM user WHERE status = 0; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; OPEN cur; read_loop: LOOP FETCH cur INTO v_id, v_status; IF done THEN LEAVE read_loop; END IF; UPDATE user SET level = level + 1 WHERE id = v_id; END LOOP; CLOSE cur; END$$ DELIMITER ;关键点在DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE这一句。这句的作用是:当FETCH取不到更多行时,把done变量置为TRUE,然后循环里通过IF done检查退出。如果不写这个handler,FETCH取到结果集末尾会触发NOT FOUND条件,导致存储过程报错,而不是自然结束。
注意DECLARE有顺序要求:条件、游标声明必须先定义,handler定义应该在游标声明之后。MySQL对DECLARE的顺序比较严格,变量、条件、游标、handler要按规范来,写错就会报“DECLARE is not allowed here”之类的错误。
另外,循环里每取一行就UPDATE一次,会在结果集和表之间反复交互。如果数据量上千,性能还可以接受;一旦上万,就要考虑改用临时表+JOIN的方式批量更新。我建议MySQL游标仅用于数据量可控的批处理,比如“清理近一月的临时数据”这种级别。
3.2 SQL Server:MATCH和@@FETCH_STATUS的搭配
SQL Server游标语法相对工整,最经典的写法是WHILE循环 + FETCH NEXT,配合全局变量@@FETCH_STATUS判断状态。
直接看示例:把订单表中待处理的订单逐个发送提醒信息。
DECLARE @order_id INT; DECLARE @customer_id INT; DECLARE order_cursor CURSOR FOR SELECT order_id, customer_id FROM orders WHERE status = 'pending'; OPEN order_cursor; FETCH NEXT FROM order_cursor INTO @order_id, @customer_id; WHILE @@FETCH_STATUS = 0 BEGIN -- 这里假装发一条提醒 INSERT INTO order_notify(order_id, customer_id, notify_status) VALUES (@order_id, @customer_id, 'sent'); FETCH NEXT FROM order_cursor INTO @order_id, @customer_id; END; CLOSE order_cursor; DEALLOCATE order_cursor;这里有个细节:FETCH NEXT在执行完游标里所有行之后,@@FETCH_STATUS会变成-1,循环自然终止。但很多人写游标时只记得FETCH一次,忘了在循环体内再次FETCH,结果变成死循环无限处理同一行,这种bug查起来很费眼神。
SQL Server还支持在游标声明时使用参数,比如SCROLL、FAST_FORWARD、READ_ONLY等。如果不做任何修改,官方默认生成的游标可能包含额外的性能开销。我个人的习惯是,普通只读批处理直接用:
DECLARE order_cursor CURSOR FAST_FORWARD READ_ONLY FOR SELECT ...FAST_FORWARD指定了游标只能前向移动,且是只读的,这样SQL Server可以走更轻量的执行路径,性能会比脏默认值好不少。
3.3 Oracle:FOR循环隐式游标最省心
Oracle PL/SQL里有一种让开发者很舒服的写法:不用显式DECLARE游标变量,直接使用CURSOR FOR循环。
BEGIN FOR rec IN (SELECT id, status FROM user WHERE status = 0) LOOP DBMS_OUTPUT.PUT_LINE('处理用户: ' || rec.id || ', 状态: ' || rec.status); UPDATE user SET level = level + 1 WHERE id = rec.id; END LOOP; COMMIT; END; /这段代码里完全看不到OPEN、FETCH、CLOSE,Oracle自己管理了隐式游标的打开、抓取和关闭。rec是记录变量,自动匹配SELECT结果的字段。这个写法号称“上帝视角的游标”,因为最不容易写错,也是Oracle开发里最推荐优先使用的。
不过要注意,FOR循环隐式游标是只读的,你不能在循环里通过游标本身去修改当前行,但可以通过当前行的主键再发UPDATE,就像上面的示例。如果追求性能,还可以在SELECT里直接带上FOR UPDATE OF子句,配合CURRENT OF来更新游标指向的当前行,但锁会一直持有到事务结束,很容易造成并发阻塞。
DECLARE CURSOR cur IS SELECT id, status FROM user WHERE status = 0 FOR UPDATE; BEGIN FOR rec IN cur LOOP UPDATE user SET level = level + 1 WHERE CURRENT OF cur; END LOOP; COMMIT; END; /这块我个人的看法是:Oracle的隐式FOR游标好用,但能不用游标尽量不用。PL/SQL的BULK COLLECT一次批量获取1000行再处理,性能能差出几个数量级。
3.4 PostgreSQL:游标必须放在事务里
PostgreSQL的游标和Oracle略有不同,它必须在一个事务块内使用,因为游标依赖事务快照,事务结束游标就失效了。典型用法是在PL/pgSQL函数里,或者直接在psql中配合BEGIN/COMMIT。
BEGIN; DECLARE cur CURSOR FOR SELECT id, status FROM "user" WHERE status = 0; FETCH NEXT FROM cur; FETCH NEXT FROM cur; -- 取几行看看 CLOSE cur; COMMIT;如果在函数内部用,则通过FOR record IN cursor_name循环来处理:
DO $$ DECLARE cur CURSOR FOR SELECT id, status FROM "user" WHERE status = 0; v_id INT; v_status INT; BEGIN OPEN cur; LOOP FETCH cur INTO v_id, v_status; EXIT WHEN NOT FOUND; RAISE NOTICE 'id=%, status=%', v_id, v_status; END LOOP; CLOSE cur; END $$;PostgreSQL 9.3以上的PL/pgSQL还支持RETURN QUERY,配合RETURN NEXT实现生成器式的数据返回,这种思路更接近Python的生成器。如果在应用层和后端交互,用游标的另一个常见姿势是“命名游标 + FETCH分段取数”,适合大数据量分页,比如一次取1000行,处理完再取下一批,避免一次性加载太多数据到客户端内存。
4. 尽量别用游标:性能陷阱与更优雅的替代方案
4.1 为什么游标会拖垮系统性能
游标被诟病最多的是“行-by-行处理”。SQL的核心优势是集合操作,数据库从设计上就对“一次处理一批”做了大量优化。而游标把一批数据拆成无数个单次操作,每一次FETCH都可能触发一次上下文切换、一次网络往返、一次缓冲区读取。在大结果集上,这个开销会被无限放大。
举个例子,一张10万行的表,用一条UPDATE加条件能毫秒级完成,但如果用游标逐行UPDATE,即使每行更新只要0.1毫秒,10万行也要10秒,而且这还没算上日志、锁、上下文切换。若每行涉及多次查询、计算,时长直接变成分钟级。更麻烦的是,游标长时间持锁,很容易把别的并发事务堵住,甚至引发死锁。
从数据库优化器的角度看,游标也限制了执行计划的选择。原本可以用的hash join、merge join、并行扫描,可能因为游标只能逐行读取而无法发挥作用,又或者优化器为了满足游标语义不得不物化整个结果集到临时表,导致内存和临时空间暴涨。
4.2 替代方案:集合操作、窗口函数、递归CTE、临时表
关于“什么时候用游标”,我的建议就一句:先把集合方案想明白,再考虑游标。80%逐行处理的需求都能改成集合操作。
最典型的场景:根据明细表累计值更新主表汇总字段。如果用游标,就是逐行算再UPDATE;用集合SQL,直接一条UPDATE ... JOIN(或MERGE)搞定。
UPDATE summary s JOIN ( SELECT order_id, SUM(amount) AS total FROM order_detail GROUP BY order_id ) d ON s.order_id = d.order_id SET s.total_amount = d.total;窗口函数也很好用。比如想给每个分组里的记录按时间排序,并给行号、排名,传统方式可能要循环查分组,现在用ROW_NUMBER() OVER (PARTITION BY ...)就能实现。
SELECT user_id, amount, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM payment;递归CTE处理层级关系(树形结构、BOM展开)也会比游标高效得多,SQL Server、Oracle、PostgreSQL、MySQL 8.0+都支持WITH RECURSIVE。比如算部门下所有子部门,一条递归CTE就能完成,不需要自己用游标一层层找。
临时表也是个思路。先把要逐行处理的数据连同计算需要的中间结果一起JOIN好,落到临时表,再一次性更新目标表。很多情况下,把“游标循环体里的SELECT”提取出来,预先算成一个宽表,问题就解决了。
| 游标能做的事 | 更推荐的做法 | 说明 |
|---|---|---|
| 逐行求和更新汇总表 | UPDATE ... JOIN / MERGE | 一条语句完成聚合和更新 |
| 分组内取前N条 | ROW_NUMBER / RANK 窗口函数 | 更清晰易读 |
| 递归遍历树结构 | WITH RECURSIVE / CONNECT BY | 数据库原生支持递归,效率更高 |
| 大批量数据分批处理 | 分批UPDATE(按主键范围切段) | 每批500~1000条,兼顾锁粒度 |
| 需要逐行判断业务规则 | 尝试用CASE WHEN表达式提前映射 | 规则如果是纯字段计算,集合能搞定 |
4.3 确实需要逐行时的保命建议
如果试完了集合方案,发现某段逻辑确实绕不开逐行,比如要调用外部接口、发通知、解析不规则文本、做复杂状态机流转,那游标也只能上。此时尽量遵守几条保命规则:
第一,加只读和前向选项,减少数据库为了滚动和可更新做的额外工作,比如MySQL里FETCH NEXT只前进,SQL Server里的FAST_FORWARD。第二,控制结果集大小。游标本身就慢,别让它背负十万行数据。能加过滤条件就加,或者用分批读取、TOP/LIMIT切分。第三,循环体内避免慢操作。在游标循环里做二次查询是很常见的低级错误,等于嵌套循环,性能直接爆炸。应该尽量预先算好关联数据,哪怕一次读出来放临时变量里都比每次查库强。
第四,注意提交频率。批量处理场景,长时间事务会持有大量锁和undo,容易拖垮在线业务。可以在累计N行后COMMIT一次,但这样会中断一致性,如果中途失败,前面的数据无法回滚。所以在“性能”和“原子性”之间得做个权衡,我的习惯是事务小的做一次提交,事务大且不能中断时用分批写入+日志记录,尽量确保可重入。
第五,优先用数据库原生的“批处理”能力。Oracle有BULK COLLECT和FORALL,SQL Server有表值参数,PostgreSQL有unnest和批量INSERT。这些方式都能在“看起来还是逐行处理”的同时,把真正提交给数据库的语句合并成批量操作,效果比游标好很多。
5. 游标使用中的典型问题与排查实录
5.1 常见错误速查表
| 错误/现象 | 常见原因 | 解决思路 |
|---|---|---|
| 游标取不到数据或者重复取最后一条 | FETCH放在循环外,或未正确判断NOT FOUND | 确保循环内再次FETCH,正确利用done/WHILE条件 |
| 游标未关闭,内存持续增长 | OPEN后没有CLOSE,或异常时未进入CLOSE | 使用异常处理块,保证CLOSE执行;部分数据库用DEALLOCATE |
| MySQL报“Cursor is not open” | 游标还没OPEN就FETCH,或游标已关闭 | 调整执行顺序,先OPEN再FETCH |
| Oracle ORA-01002:fetch out of sequence | FETCH顺序错误,或游标已关闭后继续FETCH | 检查游标状态,确认没有提前CLOSE |
| 游标性能极差 | 缺少索引、循环体内有二次查询、结果集过大 | 增加索引、改为集合操作/批量获取 |
| 更新数据时行锁等待/死锁 | 游标遍历和更新同一张表,锁升级或交叉获取 | 先取主键集合,再统一更新,或缩短事务 |
| 结果集和预期不一致 | 使用了滚动游标,FETCH方向混乱 | 明确使用FETCH NEXT,前向游标 |
| PostgreSQL“cursor can only run in a transaction block” | 未在事务中声明游标 | 加上BEGIN/COMMIT,或移入PL/pgSQL函数 |
5.2 一个真实的排查案例:游标泄漏拖垮服务器
有一年我做数据迁移,写了个存储过程,循环处理一张大表并逐行更新另一张表。脚本跑了一天,突然数据库连接数暴涨,最后直接把业务库搞挂。查了很久才发现,存储过程里遇到特殊异常会跳到异常处理分支,但异常分支里没有CLOSE游标,游标没释放,连接也一直占着。每次异常重试一次就漏一个连接,最终连接耗尽。
这次事故之后,我给存储过程立了几条规矩:游标必须在异常块里关闭;循环里所有能抛异常的操作必须包一个独立的异常捕获;如果游标正开着又必须退出,用“写日志 + 保留现场”而不是直接ROLLBACK。具体到Oracle,可以在EXCEPTION块里调用CLOSE,或者用“游标变量 + 引用游标”交给外层调用方统一管理。SQL Server则务必在CATCH块里执行DEALLOCATE。
5.3 游标与死锁:一边遍历一边更新的坑
另一个高频问题是在游标循环里UPDATE当前行,导致死锁。原因很简单:别的会话可能在同时更新同一个表,游标FETCH持有的共享锁,和UPDATE需要的排他锁不是同一个资源,彼此等待,就形成了死锁。
我的解决经验是,把“读取游标结果”和“更新数据”彻底拆开。先通过游标把需要处理的主键收集到临时表或表变量,游标使命完成后立刻CLOSE释放锁,然后再对临时表里的主键执行UPDATE或循环更新。这样做游标持有锁的时间从“整个处理过程”缩短到“只读阶段”,并发冲突概率大大降低。
甚至很多场景下,连游标本身都能省掉。比如“把状态A的数据全部改成状态B”,直接用UPDATE带上WHERE status='A'不就完了?如果业务要求“逐条调外部接口再更新状态”,那也应该先查出一批主键,循环调接口,最后统一更新状态,而不是边调接口边拿着游标不放。
5.4 如何判断你的SQL是不是该用游标
最后分享一个我在代码评审时常用的判断清单:
- 需求能不能翻译成一条UPDATE/INSERT/MERGE语句?能,就别用游标。
- 是不是需要对每一行调用外部服务或打印输出?是,可以用游标,但要限制数据量。
- 是不是需要在多行之间保留状态做复杂计算?比如挨个累加余额、记录上一行值。这种先试试窗口函数SUM() OVER (ORDER BY ...),通常能解决。
- 是不是要处理树形结构?先试试递归CTE。
- 数据量有多大?超过一万行就要警惕,超过十万行基本不该用游标。
如果过完这个清单还是绕不开游标,那再写。至少能保证你不是“拿大炮打蚊子”,也不会一上线就被DBA找喝茶。
我个人在实际操作里的体会是,游标是一把双刃剑。拿它当帮手没问题,但别把它当默认武器。每次想用游标的时候,先深呼吸,把需求拆成集合操作再想一想;如果还是非用不可,就控制好数据量、锁粒度和资源释放。写清楚、测充分,游标在批量任务里也能稳如老狗。