在数据库运维中,我们经常遇到这样一幕:业务上线运行几天后,应用开始卡顿,数据库服务器的内存像坐火箭一样往上蹿,CPU使用率居高不下,甚至出现大量锁等待。排查一圈,发现罪魁祸首往往不是SQL本身有多烂,而是代码里那些“打开后就不管”的游标。游标本来是个好东西,但在实际使用中却被滥用得很厉害。这篇文章想跟大家聊聊,在人大金仓KingbaseES里,游标滥用是怎么把内存和锁资源搞紧张的,以及如何从生命周期管理和批量抓取策略两个方向,把结果集处理的效率提上来。

一、游标是怎么把资源吃掉的

游标可以理解成一个“书签”。当你执行一条SQL查询时,数据库会把结果集准备好,而游标就是你拿在手里的“书签”,能让你逐页翻看结果。这个机制在处理大量数据时非常有用,它可以避免一次性把几十万行数据全部塞进应用内存。但问题出在“滥用”上。很多开发者把游标当成普通查询工具,打开后不关,甚至在一个事务里同时打开几十个游标,每个游标都指向一个庞大的结果集。这就好比你在图书馆同时借了上百本书,每本书都摊开放在桌上,还全部占着座位,别人根本没法坐。

在KingbaseES里,游标到底会吃掉什么资源呢?

第一是内存。每一个游标都关联着数据库服务器端的一块上下文区域,里面保存着查询计划、当前行位置以及结果集的缓存。如果结果集很大,比如几百万行,游标打开时就需要攒下一大堆行数据。同时打开十个这种游标,内存压力可想而知。

第二是锁。游标本身不产生锁,但它所依附的事务会产生锁。更关键的是,如果游标声明了FOR UPDATE,那么它在读取每一行的时候都会给那一行加上行级锁,直到事务结束才释放。即便没有FOR UPDATE,如果游标所在的事务一直没有提交,那些被读取过的数据页也可能被锁住,导致其他会话无法更新,甚至引发大面积阻塞。

第三是连接资源。每一个游标都必须依附于一个数据库会话。如果应用层忘记关闭游标,连接池里的连接就得不到释放,时间一长连接池被占满,新请求只能排队等待,整个应用就“假死”了。

可以说,游标滥用是很多数据库性能事故的“隐形杀手”。要解决它,我们先得学会发现它。

二、先确认是不是游标在捣乱

排查游标问题,可以先用金仓提供的系统视图看看当前会话到底开了多少游标、这些游标是否长期存在。

技术栈:KingbaseES 标准SQL

-- 查看当前会话里所有打开的游标
SELECT name, statement, is_holdable, creation_time
FROM pg_cursors;

再查一下数据库里有哪些会话在长时间运行,这往往是游标没有及时关闭的信号:

-- 查看非空闲会话的存活时间
SELECT pid, usename, state, now() - xact_start AS txn_age, query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY txn_age DESC;

如果发现某个会话的txn_age特别大,而且它的query里带着“declare cursor”的字眼,那基本可以断定游标被“晾”在一边了。

锁问题也要同步排查。我们来看一下当前有哪些锁在等待,哪些会话占着锁不放:

-- 查看没有被授予的锁请求,代表正在等待
SELECT pid, locktype, relation::regclass AS rel, mode, granted
FROM pg_locks
WHERE NOT granted;

一旦看到大量granted为false的记录,说明有会话的锁没释放,再结合上面的游标查询,基本就能定位到问题源头。

通过这几个查询,你就能快速摸清当前游标的使用情况,然后对症下药。

三、生命周期管理:让游标用完就走

游标生命周期管理其实就一句话:开,写,关。但现实代码里,很多人只记着“开”和“写”,“关”被忘在了脑后。尤其在业务逻辑有异常的时候,关闭游标的语句往往被跳过。正确的做法是:不管过程有没有报错,游标都要关闭。

举个例子。我们写一个处理用户数据的函数,它需要逐行读取一张表,更新一些字段。下面是规范的生命周期管理写法。

技术栈:KingbaseES PL/pgSQL

-- 技术栈:KingbaseES PL/pgSQL
CREATE OR REPLACE FUNCTION process_users()
RETURNS void AS $$
DECLARE
  -- 声明游标,指向要处理的数据
  cur CURSOR FOR
    SELECT id, last_login
    FROM users
    WHERE status = 'active';

  v_id INT;
  v_last_login TIMESTAMP;
BEGIN
  OPEN cur;               -- 1. 打开游标

  LOOP
    FETCH NEXT FROM cur INTO v_id, v_last_login;   -- 2. 抓取一行
    EXIT WHEN NOT FOUND;   -- 没有行就退出

    -- 3. 处理业务逻辑
    UPDATE users
    SET login_count = login_count + 1
    WHERE id = v_id;
  END LOOP;

  CLOSE cur;               -- 4. 正常关闭游标

EXCEPTION WHEN OTHERS THEN
  -- 出异常时也要确保游标被关闭,否则资源会一直挂着
  IF cur%ISOPEN THEN
    CLOSE cur;
  END IF;
  RAISE;                   -- 重新抛出异常,让上层感知
END;
$$ LANGUAGE plpgsql;

注意异常处理里那个IF cur%ISOPEN判断,这是很多人容易漏掉的。没有这一段,一旦函数中途报错,游标就会一直留在会话里,直到连接断开才能被回收。

再看一个反面教材,你一眼就能看出问题:

技术栈:KingbaseES PL/pgSQL(错误示例)

-- 技术栈:KingbaseES PL/pgSQL(错误示例)
CREATE OR REPLACE FUNCTION bad_process_users()
RETURNS void AS $$
DECLARE
  cur CURSOR FOR SELECT id FROM users;
  v_id INT;
BEGIN
  OPEN cur;
  LOOP
    FETCH NEXT FROM cur INTO v_id;
    EXIT WHEN NOT FOUND;
    -- 这里如果发生异常,游标就没人关了
    PERFORM * FROM order_logs WHERE user_id = v_id;
  END LOOP;
  -- 没有 CLOSE cur,也没有异常处理
END;
$$ LANGUAGE plpgsql;

这个函数执行完之后,游标不一定会被关闭。在金仓数据库里,游标会一直存在到事务结束或者会话结束。如果这个函数在一个长事务里被反复调用,每次调用都会漏掉一个游标,内存和锁就这么一点点“漏”光了。

生命周期管理带来最直接的好处就是资源可预期。你打开一个游标,处理完就关,内存占用曲线是平稳的。缺点也很明显:代码变得啰嗦,尤其是异常处理那段,看起来像防御性编程。但正是这一点点啰嗦,能帮你避免凌晨三点的电话告警。

这种管理方式特别适合数据清洗、批量更新、报表生成等场景:数据量大、处理逻辑复杂、单个会话需要长时间占用游标,只有严格的生命周期管理才能保证数据库的稳定性。

四、批量抓取策略:别一次拖回一卡车

说完生命周期,再来看第二个问题:抓取策略。

很多开发者在循环里逐行地FETCH,一行行处理。这种方式在数据量小的时候没问题,但一旦碰到百万行级别的结果集,每一行都要走一次FETCH调用,网络往返、上下文切换、状态判断全部叠加起来,效率非常低。

更糟糕的是,有些场景下,开发者为了“方便”,直接把整个结果集一股脑儿捞到内存里。比如一次SELECT * FROM big_table,然后交给程序去慢慢处理。这在KingbaseES里同样危险,因为服务器端要准备所有数据,客户端也要接收所有数据,两边内存同时被撑爆。

批量抓取的意思,就是像“批发”一样,一次拿一百行,处理完再拿下一百行。这样既能保持流式处理的内存优势,又能大幅减少FETCH次数。

下面看一个批量抓取的完整示例:

技术栈:KingbaseES PL/pgSQL(批量抓取示例)

-- 技术栈:KingbaseES PL/pgSQL(批量抓取示例)
CREATE OR REPLACE FUNCTION batch_process_orders()
RETURNS void AS $$
DECLARE
  cur CURSOR FOR
    SELECT order_id, total_amount
    FROM orders
    WHERE status = 'pending'
    ORDER BY order_id;

  v_order_id BIGINT;
  v_total NUMERIC;
  v_batch_rows INT;                -- 记录本次批处理抓到的行数
  BATCH_SIZE CONSTANT INT := 100;  -- 每次抓取100行
BEGIN
  OPEN cur;

  LOOP
    v_batch_rows := 0;

    -- 内层循环连续抓取最多100行
    FOR i IN 1..BATCH_SIZE LOOP
      FETCH NEXT FROM cur INTO v_order_id, v_total;
      EXIT WHEN NOT FOUND;         -- 没数据了,跳出内层

      -- 处理单行业务
      UPDATE orders
      SET processed_time = now()
      WHERE order_id = v_order_id;

      v_batch_rows := v_batch_rows + 1;
    END LOOP;

    -- 如果内层循环一行都没抓到,说明已经到末尾了
    EXIT WHEN v_batch_rows = 0;

    -- 在实际应用中,可以在这里记录“本批处理了多少行”这类进度日志。
    -- 注意:这里没有COMMIT,因为PL/pgSQL函数内不能直接提交事务。
    -- 如果希望每批提交一次,需要把循环逻辑放到外部事务控制里。
    -- 核心思想是:每处理完一批,就尽快结束事务,释放锁和快照资源。
  END LOOP;

  CLOSE cur;
EXCEPTION WHEN OTHERS THEN
  IF cur%ISOPEN THEN
    CLOSE cur;
  END IF;
  RAISE;
END;
$$ LANGUAGE plpgsql;

你看,批量抓取把原来的“抓一行处理一行”变成了“抓一批处理一批”。BATCH_SIZE你可以根据业务调整,100只是一个参考值。理论上,批量越大,FETCH次数越少,但单批在客户端累积的数据也越多,内存又会上升。所以找到合适的中间值很重要。

批量抓取还有一个好处:它天然契合“每批提交”的模式。在存储过程或应用代码里,你可以在每个批次结束后提交事务,这样锁不会长时间被一个事务持有。比如在应用层使用JDBC时,可以通过设置fetchSize为100,然后循环提交。这是典型的批量抓取应用。不过需要注意的是,PL/pgSQL函数内部不能直接写COMMIT,所以“每批提交”通常要放到应用层或外部事务里实现。

那么批量抓取的优缺点是什么?优点很明显:第一,FETCH次数从“一行一次”变成“一百行一次”,大大降低通信开销;第二,处理压力被分摊到多个批次,内存占用不会突然飙升;第三,配合事务分段提交,锁竞争显著下降。缺点则是代码复杂度上升,批次大小的选择需要经过测试,而且如果没有外层事务控制,批处理中途失败会导致部分数据已处理、部分未处理,需要额外处理“断点续跑”的问题。

这种策略非常适用于大表全量扫描、ETL数据抽取、定时清理任务等场景。尤其是当结果集超过几十万行时,批量抓取的效果立竿见影。

五、几个必须注意的坑

光知道方法还不够,在实际落地时,有几个坑一定要避开。

第一,游标用完必须关,最好用异常块统一处理。如果你用PL/pgSQL写函数,异常处理里别忘了判断游标是否还开着。

第二,不要长时间持有事务。游标所在事务结束后才能真正释放快照和锁。哪怕你及时关闭了游标,只要事务不提交,锁和旧版本数据依然会留在数据库里。所以,别再让一个事务折腾一整天了。

第三,批量抓取的批次大小要合适。太小了,比如一次10行,效果不明显;太大了,比如一次10万行,内存一样会被撑爆。建议从100、500、1000这几个台阶开始测试,找到最稳的那个值。

第四,避免使用FOR UPDATE游标。很多时候其实不需要锁行,只是想按顺序处理数据。一旦给游标加上FOR UPDATE,每读一行就锁一行,而且只能等事务结束才能释放,在高并发下几乎必然出问题。如果确实需要锁,可以考虑先SELECT ... FOR UPDATE SKIP LOCKED,一次性锁一批行,而不是用游标逐行锁。

第五,定期监控。不要等问题爆发了才去查。把查询pg_cursors和pg_locks的SQL做成巡检脚本,每天跑一遍,看到异常的会话及时杀掉,能避免很多事故。

第六,连接池的使用要小心。如果你的应用使用数据库连接池,游标没有关闭时,连接归还到池子里,游标可能还挂在上面。这时候连接并没有真正释放,还是占用着数据库资源。所以,编码时必须确保游标在连接归还前被关闭。

第七,能不用游标就别用游标。很多“逐行处理”的逻辑,其实可以用一条UPDATE语句加子查询搞定。只有在处理逻辑极其复杂、必须逐行判断时,才值得引入游标。毕竟,集合操作永远比逐行操作快一个级别。

六、总结

游标不是洪水猛兽,滥用才是。生命周期管理解决的是“打开不关”的问题,它让游标像水龙头一样,用完就拧紧;批量抓取解决的是“一次吃太多”的问题,它让结果集像流水一样,一点一点流到处理端。把这两个策略落地,你就能在人大金仓KingbaseES里,既享受游标带来的灵活性,又不用担心内存和锁资源被拖垮。

最后说一句:数据库性能优化没有银弹,每一个规则背后都是一次次故障换来的经验。希望你在看完这篇文章后,能回去翻翻自己的代码,看看那些游标是不是都安分地关闭了。如果还没有,那今天就是个好时机。