只要是写过几年SQL的人,多少都听过这种话:“别用游标,游标性能差”、“遇到游标就重写”。游标在内行人眼里风评一直不怎么样,但说这话的人未必真把游标这头“牛”完整切开看过。我入行那会儿带我的老DBA说过一句话到现在都记得:你可以不用游标,但不能不懂游标——不懂它你连“该不该换掉它”都判断不了。
这篇博文就拿“庖丁解牛”的方式,把游标(Cursor)整个拆一遍:它到底是个什么东西、内部怎么跑、四种游标类型各自什么性格、真正耗在哪里、什么场景下它反而是对的、以及万一必须用游标时怎么写出不那么丢人的版本。适用对象是刚接触游标的开发同学,也适合天天写CRUD但对性能细节一知半解的SQL老兵。我尽量把每一步都讲透,讲到你能跟别人聊游标时不心虚,讲到你能对着一条慢SQL判断“这锅到底是不是游标的”。
1. 先把游标具象化:它不是光标,是结果集上的那根手指
1.1 游标的“光标”误区
很多人一听Cursor这个词就联想到文本编辑器里的闪烁光标,于是以为游标是屏幕上的一条线,这是头号误区。数据库里的游标和你眼睛看到的界面没有任何关系,它存在于数据库会话(Session)内部,本质是一套“结果集遍历机制”的状态对象。
我给你一个很土但特别准的类比:你把一张查询结果想象成一张超长的流水账单,游标就是你的手指头,顺着账单一行一行往下划。手指头本身不存账单内容,它只记录“我目前指到第几行了”,以及“下一步该往下还是往上”。所有你通过游标取到的数据,都是数据库按你的指向临时搬过来的。
所以游标最核心的组成是三样东西:位置指针(当前行的位置)、结果集的元数据(每一列的名称、类型、长度)、以及可选的数据缓冲(取决于服务器具体实现和游标类型)。知道这个构成,后面很多怪异行为就都能解释了——比如为什么有些游标“看不到别人刚提交的数据”,因为它可能已经把数据快照到临时库了;为什么有些游标不能倒着取,因为它压根没保存整份结果。
1.2 庖丁眼里有牛,你眼里要有“结果集、游标、当前行”三层
《庄子·养生主》里庖丁解牛之所以游刃有余,是因为他眼里不再是整头牛,而是骨骼、筋脉、关节的间隙。游标这头“牛”其实也可以拆成三层抽象,我的经验是每遇到一次游标问题,就按这三层去排查,基本不跑偏。
- 结果集(Result Set):你的 SELECT 语句执行后产生的完整数据集合。集合视角,所有行同时存在。
- 游标(Cursor):附着在结果集上的状态化指针,它持有了位置、方向、以及服务器为支持遍历分配的资源。
- 当前行(Current Row):游标指针所在的那一行,也是唯一一行你“通过游标”能直接访问和操作的行。
这个三层抽象非常重要。你写DECLARE CURSOR时只是定义了一把刀的轮廓;等你OPEN时数据库才真正把刀开刃——执行 SELECT、确定数据来源、分配缓冲,这也是游标最常见的性能分叉点;FETCH是把刀刃切到牛骨缝隙上,一次只取一行;CLOSE和DEALLOCATE则是擦刀和收鞘。
1.3 为什么值得花时间把游标彻底“拆开”
游标这个工具的特殊之处在于:它既是新手最爱用的“锤子”,也是老手最爱骂的“反模式”。如果只停留在“游标慢,别用”这个层面,你会失去很多解决问题的角度;反过来,如果只知道无脑用游标,你又会踩进性能泥潭。
把游标彻底拆开,你得到的不只是一堆语法命令,而是一把尺子:知道什么场景下游标是合理的、什么场景下它只是偷懒的替代品、什么场景下你该用窗口函数或临时表去替代它。这也是这篇博文想给你的核心价值——不是帮你消灭游标,是帮你把游标放在正确的位置上,让工具归工具,让思路归思路。
2. 解剖游标这头牛:内部机制与生命周期
2.1 游标的内部三件套:指针、元数据、数据缓冲
刚才提到游标由三样东西组成,但它们各自的角色和开销差别非常大,我展开说。
**指针(Current Position)**是最廉价的,它就一个数字或者内部偏移量,记录当前指向第几行。**元数据(Metadata)**包括结果集里每一列的名称、数据类型、长度、是否可空,这个几乎不占什么空间,但在动态游标里每次FETCH都需要用它去解释数据。数据缓冲才是游标内存和性能开销的大头,它在不同游标类型下差别巨大。
以SQL Server为例,如果你声明一个 STATIC 游标,OPEN 的执行计划里会多出一个“Spool(表假脱机)”操作符,意思就是服务器把结果集完整复制到 tempdb 里生成一个隐式快照,后续FETCH全走快照。动态游标则直接引用源表的实际数据页,每一次FETCH都可能发起一次新的表/索引查找,它不存快照,但每次读都“现做现卖”。这两者就是“照相”和“直播”的区别,静态游标拍下瞬间,动态游标实时捕捉。
2.2 四种游标类型:静态、动态、键集、只进
几乎所有主流数据库(SQL Server、Oracle、PostgreSQL的某些驱动)对游标类型的划分都可以归并成四类,我整理了一张对比表,这是整个游标知识体系里最重要的表格。
| 游标类型 | 数据来源 | 能否看到他人提交的新行 | 能否看到数据值更新 | 更新能否回传源表 | 内存/IO开销 |
|---|---|---|---|---|---|
| STATIC | 临时快照 | 否 | 否 | 不支持更新 | 高 |
| DYNAMIC | 源表实时读取 | 是 | 是 | 支持 | 高 |
| KEYSET | 键集快照+源表取值 | 否(新行不可见) | 是(值更新可见) | 支持,但有条件 | 中 |
| FAST_FORWARD | 单向顺读,通常不缓存整集 | 取决于服务器,通常可见 | 取决于服务器 | 若声明READ_ONLY则不能更新 | 低 |
举个例子帮助记忆。STATIC 就是拍了一张照片,照片里不会有后来新出现的人,也不会变老;DYNAMIC 是实时监控摄像头,谁进了仓库你马上看得到,数据改动也实时反映;KEYSET 是一张人员名单册,新来的人不会写进册子,但册子上每个人的状态你打开系统时实时查;FAST_FORWARD 是只进不回的自动扶梯,你只能往一个方向走,但胜在轻快。
选型时我的习惯是:能FAST_FORWARD就不KEYSET,能KEYSET就不STATIC,能STATIC就不DYNAMIC。DYNAMIC听着很美,但它在极端并发下会让数据读到“一会儿一个样”,而且每次FETCH都需要新的逻辑读,稍不注意就把数据库IO打爆。
2.3 五步刀法:声明、打开、获取、关闭、释放
游标的生命周期是五个动作,我用SQL Server语法完整演示一遍,顺便把每一步的坑标出来。
-- 第1步:声明(DECLARE) DECLARE cur_orders CURSOR LOCAL SCROLL READ_ONLY FOR SELECT OrderID, CustomerID, OrderDate FROM Sales.Orders WHERE OrderDate >= '2024-01-01'; -- 第2步:打开(OPEN) OPEN cur_orders; -- 第3步:获取(FETCH) FETCH FIRST FROM cur_orders; -- 取第一行 FETCH NEXT FROM cur_orders; -- 取下一行 FETCH PRIOR FROM cur_orders; -- 取上一行(要求SCROLL) FETCH RELATIVE -2 FROM cur_orders; -- 相对当前位置回退2行(要求SCROLL) -- 第4步:关闭(CLOSE) CLOSE cur_orders; -- 第5步:释放(DEALLOCATE) DEALLOCATE cur_orders;第一步的坑:很多新手以为DECLARE只是在“定义一个变量”,大错特错。LOCAL表示游标作用域限于当前批处理或存储过程,不写则默认为GLOBAL,全局游标会在连接上一直挂着,连接池复用时就可能串数据。SCROLL表示可任意方向滚动,如果只取NEXT方向,务必改成FAST_FORWARD,能省掉很多回滚支持的开销。
第二步的坑:OPEN才真正执行SELECT,这一步决定游标的资源占用量。如果底表有几千万行,你OPEN一个SCROLL游标,服务器可能立刻就要去建临时结构。我见过有人在存储过程里OPEN了游标却忘记CLOSE,结果事务一直不提交,tempdb疯狂增长。
第三步的重点:FETCH NEXT是最常见的操作,但你要知道,如果用FETCH PRIOR或FETCH RELATIVE,必须要在声明时就写了SCROLL,否则运行期直接报错。另外FETCH本身不会判断结果集是否已经到末尾,判断是否到末尾是靠@@FETCH_STATUS这个全局变量,0表示成功,-1表示超出结果集,-2表示行已不存在(比如动态游标中该行被并发删除)。
第四步和第五步的区别:这两步是最容易被混淆的。CLOSE是关闭游标,它释放的是结果集锁和大部分数据缓冲,但游标本身这个“声明过的对象”还在,你可以重新OPEN同一游标再遍历一遍。DEALLOCATE是彻底释放游标对象,之后这个游标名就不能再用了。一句话记忆:CLOSE是合上菜单但位置还留着,DEALLOCATE是把菜单从餐厅彻底撤掉。
我见过最经典的生产事故:一个报表存储过程每次执行都DECLARE、OPEN、FETCH、CLOSE都做了,但就没写DEALLOCATE,导致游标对象一直残留在会话上。因为用了连接池,会话不真正断开,游标对象越堆越多,最后连接池里的每个会话都挂了几十个幽灵游标,内存和tempdb压力直线上升。后来加了一行DEALLOCATE,内存直接降了30%。
3. 游标到底慢在哪:性能剖析与集合思维
3.1 慢的根源:行级操作天然要付出行级代价
为什么游标会被全行业DBA嫌弃?核心原因不是某个数据库实现得差,而是它把SQL从“面向集合”拉低成了“面向行”。
一次普通的UPDATE语句,数据库可以走索引扫描、批量锁定、一次性记日志;但同一件事你用游标做,比如循环10万行、每行执行一次UPDATE,那么数据库会经历10万次单独的语句编译/复用、10万次独立的锁定请求、10万次日志刷写调度。这里的开销是乘数级的,不是加法级的。
我拿实际跑过的数据举例子:一张200万行的订单表,需要给其中10万行更新一个状态位。用一条集合UPDATE跑,锁粒度合理的状态下耗时大概1~3秒,事务日志增长约120MB;换游标逐行更新,跑完差不多要25分钟,事务日志增长接近900MB,这还没算它对其他并发会话的阻塞影响。25分钟和3秒,差了约500倍,这就是“用牛刀杀鸡”的真实代价。
3.2 集合思维:一次性处理整头牛,而不是切一万根肉丝
很多人说游标难用,本质上是“没把问题转化为集合问题”。SQL最擅长的是一句话处理一堆行,你要训练自己遇到“逐行处理”需求时,先问三个问题:
- 能不能用一条
UPDATE ... JOIN或者CASE WHEN搞定? - 能不能用窗口函数(
ROW_NUMBER()、LAG()、LEAD()、SUM() OVER())搞定跨行计算? - 能不能先把目标行缩成一个临时表,再一次性
UPDATE关联回去?
这三个问题解决了80%的游标滥用场景。比如“按订单号加序号”,很多人第一反应写游标,其实ROW_NUMBER() OVER(PARTITION BY CustomerID ORDER BY OrderDate)一行就出来;“取每个客户最近一单”,也是ROW_NUMBER()配合外层WHERE rn = 1。这些活儿本来就是为窗口函数准备的,拿游标去干纯粹是给数据库上刑。
3.3 什么场景下,游标反而是正确答案
当然,前面说了那么多游标的“坏话”,也该说说它的正面战场。我从实际项目里总结了几类游标完全合理的场景:
- 必须逐行调用外部服务:比如给每个用户发短信、调支付接口核对状态、同步数据到第三方ERP。这种场景你不可能用一条SQL让数据库去调外部HTTP,逻辑天然就是“逐行取出,逐行调用”。
- 断点续跑与进度反馈:给一个超大批处理任务做进度记录,每一万行回写一次日志,游标天然适合,因为它可以配合变量记录进度、失败时从某一位置继续。
- 跨行状态依赖且窗口函数表达不了:比如根据上一行的计算结果决定下一行的计算基数,这类“串行依赖”虽然也能用递归CTE,但复杂的业务规则下递归CTE写出来难读到爆炸,游标反而可读、可控、可调试。
- 管理类任务:遍历数据库里所有表、对每张表动态拼接SQL执行,这种元数据遍历场景,游标几乎是唯一选择。
你看,游标不是一无是处。懂这些边界,你才算真正“解过牛”——知道哪里是骨头,哪里是下刀缝。
4. 实操实录:一次游标优化,我把耗时从23分钟压到4秒
4.1 原始场景:一个让我印象深刻的“回填需求”
去年帮一个电商项目的团队优化存储过程,需求是这样的:订单表Sales.Orders有100多万行,客户表Sales.Customers里有个字段RegionCode,要根据每个客户最近3笔订单所使用的配送方式数量(去重计数)去回填。当初写这需求的同学思路很朴素——触发的笔数多、规则复杂,于是他写了一个游标,在订单表上循环,每拿到一个客户ID就子查询算一次。
初始逻辑(已经过脱敏和简化)大致如下:
DECLARE @cust_id INT, @ship_count INT, @continue BIT; DECLARE cur_customers CURSOR LOCAL FAST_FORWARD FOR SELECT CustomerID FROM Sales.Customers WHERE RegionCode IS NULL; OPEN cur_customers; FETCH NEXT FROM cur_customers INTO @cust_id; WHILE @@FETCH_STATUS = 0 BEGIN SELECT @ship_count = COUNT(DISTINCT ShipMethodID) FROM ( SELECT TOP 3 o.ShipMethodID FROM Sales.Orders o WHERE o.CustomerID = @cust_id ORDER BY o.OrderDate DESC, o.OrderID DESC ) t; UPDATE Sales.Customers SET RegionCode = CAST(@ship_count AS VARCHAR(10)) WHERE CustomerID = @cust_id; FETCH NEXT FROM cur_customers INTO @cust_id; END; CLOSE cur_customers; DEALLOCATE cur_customers;这个存储过程在测试环境跑都要23分钟,上了生产那就是灾难。我接手后第一反应不是直接删游标,而是先看它到底干了什么。实际上这里的问题有三层:第一层,TOP 3子查询没法走高效的索引合并,每个客户都要临时排序,成本极高;第二层,逐行UPDATE导致日志狂增;第三层,游标遍历了所有客户,但绝大多数客户的配送方式都是重复的,根本没必要全量重算。
4.2 集合化重写:把“逐行”变成“按组”
我把逻辑拆成三步:第一步用ROW_NUMBER()给每个客户的订单按时间倒序编号,只留前3行;第二步按客户分组,统计配送方式去重数;第三步用UPDATE ... JOIN回填客户表。
WITH RankedOrders AS ( SELECT o.CustomerID, o.ShipMethodID, ROW_NUMBER() OVER(PARTITION BY o.CustomerID ORDER BY o.OrderDate DESC, o.OrderID DESC) AS rn FROM Sales.Orders o ), RecentShipMethods AS ( SELECT CustomerID, COUNT(DISTINCT ShipMethodID) AS ship_count FROM RankedOrders WHERE rn <= 3 GROUP BY CustomerID ) UPDATE c SET RegionCode = CAST(r.ship_count AS VARCHAR(10)) FROM Sales.Customers c INNER JOIN RecentShipMethods r ON c.CustomerID = r.CustomerID WHERE c.RegionCode IS NULL;就这么三段CTE,逻辑和原来一模一样,但数据库可以并行地把排序、分组、更新一次跑完。我给Orders表加了(CustomerID, OrderDate DESC, OrderID DESC) INCLUDE (ShipMethodID)的索引以后,整个存储过程从23分钟降到了4秒。
4.3 性能对比与取舍:不是所有游标都该死,但能集合化就该集合化
| 指标 | 游标版本 | 集合版本 |
|---|---|---|
| 耗时 | 23分18秒 | 4.1秒 |
| 事务日志增长 | 约1.8GB | 约80MB |
| 逻辑读 | 每行平均重复扫描,总量爆炸 | 全程索引扫描+一次排序 |
| 平均锁等待 | 频繁,峰值阻塞其他会话 | 短暂 |
| 代码可读性 | 排名三兄弟互相嵌套 | 一条CTE链,15分钟能看懂 |
这个案例我每次给团队讲都强调一点:我不是要把游标赶尽杀绝,而是要先问“逻辑能不能集合化”。能集合化就集合化,集合化不了再考虑游标。真要保留游标时,也请把游标声明成最省资源的形态,也就是LOCAL FAST_FORWARD READ_ONLY,能用这三件套绝不动用SCROLL DYNAMIC套件。
5. 踩坑实录与排查清单:游标相关的常见事故
5.1 “游标没关干净”引发的连接池幽灵
我前面提过一个CLOSE和DEALLOCATE的问题,这里再展开一次。在连接池环境下,代码跑完存储过程后连接不会物理断开,而是归还到池里。如果存储过程中OPEN了游标却没CLOSE和DEALLOCATE,这个会话上就会残留游标及其数据缓冲。
症状是数据库内存和tempdb使用量缓慢攀升,连接数正常但整体性能越来越差。排查方式是看当前会话的游标状态:
SELECT s.session_id, c.cursor_id, c.name, c.status, c.model FROM sys.dm_exec_cursors(0) AS c LEFT JOIN sys.dm_exec_sessions AS s ON c.session_id = s.session_id WHERE c.name IS NOT NULL;如果发现大量status = 1(表示打开状态)的游标挂在某些连接上,基本就是代码里漏了关闭。修复方式是在所有使用游标的存储过程里严格成对写CLOSE + DEALLOCATE,如果中间有异常分支,还要包一层TRY...CATCH来确保释放。
5.2@@FETCH_STATUS的最后一行陷阱
@@FETCH_STATUS是全局的,而且不是“只对当前游标有效”。如果你写了嵌套游标,内层游标FETCH失败会把@@FETCH_STATUS置为-1,外层循环判断就可能提前退出或者无限循环,这是嵌套游标最恶心的坑之一。
更隐蔽的问题是“重复处理最后一行”。很多人的循环是这样写的:先FETCH NEXT一次,然后WHILE @@FETCH_STATUS = 0 BEGIN ... FETCH NEXT ... END。这个写法本身没错,错就错在有人把FETCH NEXT写在了BEGIN之前和END之前,导致每次循环结尾都多做一次无意义的FETCH,在动态游标下还可能白白触发一次逻辑读。正确的习惯是把FETCH收拢在一处,用临时变量保存状态:
DECLARE @fetch_status INT; FETCH NEXT FROM cur INTO @col; SET @fetch_status = @@FETCH_STATUS; WHILE @fetch_status = 0 BEGIN -- 业务处理 FETCH NEXT FROM cur INTO @col; SET @fetch_status = @@FETCH_STATUS; END;5.3 动态游标 + 低隔离级别产生“幽灵读”
动态游标直接读源表,在READ COMMITTED(读已提交)隔离级别下,如果别的会话在你FETCH间隙修改了某行,你两次FETCH可能看到同一行在两个不同状态之间的数据,这就是行级的数据不一致。更极端的是,如果删除了某行,动态游标FETCH该位置时可能返回 -2 状态,你的循环没判断-2,就会丢失数据或异常。
规避手段很简单:业务允许就退到STATIC或键集游标,通过快照读数,虽然牺牲实时性,但换来一致性。如果业务非要读取实时数据,那就要把隔离级别提上去,同时处理好@@FETCH_STATUS的-2状态。
5.4 嵌套游标与死锁的相爱相杀
嵌套游标是死锁高发区。外层游标锁定A表、内层游标去UPDATE B表,同时另一个会话用相反顺序操作,死锁立刻出现。我排查过一个报表系统的死锁,阻塞链里就是两个嵌套游标的存储过程互相持有对方等待的锁。
遇到这种问题,优先考虑把内层游标逻辑用集合语句替代,一般都能拆掉。如果实在拆不掉,至少保证所有会话都按照相同的表顺序访问(比如总是先A后B),并在外层游标声明中使用READ_ONLY减少更新锁。
5.5 游标问题排查速查表
| 症状 | 可能原因 | 快速排查方式 | 处理建议 |
|---|---|---|---|
| 内存持续增长 | 游标未DEALLOCATE | 查dm_exec_cursors | 补释放,加TRY-CATCH兜底 |
| tempdb空间暴涨 | STATIC/KEYSET游标快照过大 | 查tempdb使用率与阻塞 | 换FAST_FORWARD,或改集合SQL |
| 循环比预计多处理一行 | FETCH顺序写错 | 打印每次FETCH到的值 | 单点FETCH,统一判断状态 |
| 游标里UPDATE很慢 | 每行触发日志+锁 | 看等待类型 | 集合化重写 |
| 数据读到一半变了 | 动态游标+低隔离级 | 看隔离级别与游标类型 | 快照游标或提高隔离级别 |
| 嵌套游标死锁 | 锁顺序不一致 | 抓死锁图 | 拆内层,统一锁顺序 |
6. 庖丁解牛后的心法:我的游标使用决策流
拆到最后,我想把这几年的经验浓缩成一套决策流,算是“庖丁”解完牛之后的刀谱。现在我在任何项目里看到“循环+SQL”,都会先走一遍这套判断:
第一步,能不能用集合SQL表达?能,绝不犹豫直接写集合SQL。第二步,集合SQL搞不定,能不能用窗口函数、CTE、递归、临时表组合出来?大部分跨行计算都能在这里解决。第三步,连这些都不行,再认真判断是不是要调用外部接口、是否有断点续跑需求、是否涉及串行状态依赖。满足其中一个,才轮到游标登场。
真正确定要上游标之后,还要过三关:一关是类型,能用FAST_FORWARD绝不用SCROLL,能READ_ONLY绝不开UPDATE;二关是资源,LOCAL永远优先于GLOBAL,避免污染连接池;三关是生命周期,OPEN之后必须严格对应CLOSE + DEALLOCATE,最好用TRY...CATCH包住中间的业务处理。
我自己在带新人时还喜欢让他们做一个小实验:拿一张10万行的表,分别用FAST_FORWARD、DYNAMIC、STATIC三种游标全量遍历一遍,再开SET STATISTICS TIME ON和SET STATISTICS IO ON看结果。做过这个实验的人,基本就再也不会在不需要游标的地方乱用游标了,因为他们亲眼看到了“游标的每一次FETCH到底吃掉多少IO”。
这就是我想传递的最终心得:游标本身没有原罪,原罪是“不假思索地逐行”。把游标的结构、类型、生命周期、代价都看得明明白白之后,它就不再是一头让你发怵的庞然大物,而是一头你已经看清骨节纹理的牛——该下刀的地方下刀,不该下刀的地方,你自然知道换个工具更省力。