游标性能暴跌的根本原因是执行模型冲突,每次fetch触发状态切换、内存拷贝、锁等待,1000行即1000次开销,远不如update join等集合操作单次批量执行高效。

游标在存储过程中导致性能暴跌,根本不是写法问题,而是执行模型本身和数据库优化器的天然冲突。
每次 FETCH 都是一次微型事务+状态切换
MySQL 或 SQL Server 的游标不是“指针”,而是服务端维护的一整套执行上下文:每次 FETCH 都要重定位结果集位置、拷贝当前行数据到变量、更新游标内部状态、触发隐式锁检查。1000 行 = 至少 1000 次控制流跳转 + 1000 次内存赋值 + 1000 次锁等待机会。而一条 UPDATE ... JOIN 只需一次解析、一次执行计划、一次批量 IO。
- 现象:单独跑
SELECT很快,但塞进游标循环后整体耗时翻几十倍 - 原因:
EXPLAIN显示游标内嵌的SELECT每次都被当成新查询重编译,无法复用执行计划 - 陷阱:即使只读游标,
DECLARE CURSOR FOR SELECT ...也会把整个结果集预加载进内存(尤其STATIC类型),撑爆tmp_table_size
游标循环里嵌套 SQL 是性能核弹
最常见也最危险的写法:在 FETCH 循环体内再执行 SELECT 或 UPDATE。这会把 O(N) 变成 O(N×M),比如游标扫 500 行,每行查一次日志表 → 实际执行 500 次独立查询,全表扫描反复触发。
- 错误示例:
FETCH cur INTO @user_id; SELECT count(*) FROM user_actions WHERE user_id = @user_id; - 正确做法:提前
JOIN或LEFT JOIN把所有需要字段一次性查出,游标只负责取值 - 监控信号:
SHOW GLOBAL STATUS LIKE 'Created_tmp_disk_tables'突增 → 游标结果集或嵌套查询已落地磁盘临时表
触发器里用游标比存储过程更致命
触发器没有独立事务生命周期,它被绑死在父语句的事务上下文中。游标一旦打开,就会持续持有元数据锁(Waiting for table metadata lock)、阻塞 DDL,且无法利用查询缓存 —— 因为 CURSOR FOR SELECT 中的 NEW 或 OLD 表引用让整个语句变成非确定性。
- 典型报错:
Lock wait timeout exceeded或长时间卡在Writing to net - 关键差异:存储过程可显式
COMMIT控制事务粒度;触发器中每行FETCH都延长主事务锁持有时间 - 真实代价:插入 100 行触发器带游标,实测平均延迟从 2ms 升至 120ms+,并发 50 QPS 时直接锁死
真正难的不是写游标,是识别哪一行本不该用游标
90% 的游标需求其实能被集合操作覆盖:用 UPDATE ... JOIN 替代逐行更新,用 INSERT INTO ... SELECT 替代遍历插入,用窗口函数替代累计计算。但开发者常因“逻辑看起来要一行一行走”就默认选游标,却忽略 MySQL 8.0+ 的 SUM() OVER (ORDER BY ...) 或 SQL Server 的 MERGE 完全能表达相同语义。
- 绕不开的信号只有两个:必须调外部 API(如
sys_exec())、或强依赖上一行计算结果(非窗口函数能建模的滚动逻辑) - 如果游标里只做
INSERT/UPDATE/DELETE,没调系统函数、没跨库、没发 HTTP,那它大概率就是设计缺陷 - 最容易被忽略的点:游标声明里的
SELECT是否走了索引?EXPLAIN看type是ALL还是ref—— 游标慢,往往第一层就裸奔了











