游标 Cursor 的使用方法:处理结果集的实战案例

游标 Cursor 的使用方法:处理结果集的实战案例

游标(Cursor)那些事儿:当SQL把世界交给Java/Python时,我们需要这把“小刷子”

各位,早上好!请坐,别把咖啡洒在键盘上,我看过太多程序员因为按错 Ctrl+S 而在黑屏面前痛哭流涕了。

今天我们不聊高并发架构,不聊微服务那一堆乱七八糟的容器,咱们来聊聊一个稍微“古老”但极其好用的概念——游标(Cursor)。

我知道,你们现在的脑海里可能浮现出的是某个IDE里的代码高亮,或者是那个长得像放大镜的搜索图标。不,不,不!在这里,我们要聊的是数据库里那个能让你把“集合思维”变成“流水线思维”的神器。

为什么我们要聊这个?因为这个世界充满了“数据太大,没法一口气吞下”的尴尬时刻。数据库最擅长的是“批量处理”(比如 SELECT * FROM users),但咱们写代码的时候,很多时候只想一行一行地玩(比如处理每一张订单、处理每一个日志条目)。这时候,游标就是那把救命的小刷子,它负责在浩瀚的数据海洋里,给你挑出那一桶最脏的水,然后一点一点洗。

好了,废话不多说,让我们直接切入正题。

第一部分:灵魂拷问——你为什么需要游标?

首先,咱们得有个共识:SQL 是一个“集合论”专家。它的梦想就是 SELECT 出来一堆数据,然后你拿回去进行批量处理。

但是,生活不是批量处理的。生活是一行一行过的。比如,你在处理一个巨大的数据报表:

你得读取第一条数据,判断它是不是VIP,如果是,发个优惠券。

读取第二条数据,看看它有没有异常订单,如果有,打个标记。

读取第三条数据,如果不合格,直接删除。

在代码里,这叫 for (data in list) { ... }。但在数据库服务器里,这些数据散落在不同的页、不同的磁盘扇区上。如果你让数据库把所有数据一次性拉回来(SELECT *),你的内存可能会在 0.01 秒内溢出,或者数据库连接池直接罢工,给你报个 OutOfMemoryError。

这时候,游标 就登场了。它就像是一个有了自我意识的“打印机头”:

声明(Declare): 告诉数据库,“嘿,我要去那个表里溜达了。”

打开(Open): “啪”的一声,打印机头启动,准备工作。

获取(Fetch): “拿数据!”数据库吐出一行,你接住。

处理: 你的业务逻辑开始工作(发券、删记录、算数)。

关闭(Close): 工作结束,释放资源,别占着茅坑不拉屎。

这就是游标的生命周期。听起来很简单,是吧?但别急,游标就像一把瑞士军刀,用好了是神器,用不好就是堆内存的核武器。

第二部分:SQL 里的“老古董”——PL/SQL 实战

咱们先从最经典的数据库——Oracle/PL/SQL 开始。这是游标的老家,也是最能体现“过程化控制”的地方。

案例背景:给老用户发“怀旧礼包”

假设你是个某宝的程序员,老板让你给所有注册超过 10 年且最近 3 个月没登录过的用户,发一条短信:“十年不见,甚是想念!点击领取优惠券。”

你手里的表叫 users,字段有 id, username, register_date, last_login_date。

错误示范(全量加载):

-- 这很危险!

SELECT * FROM users WHERE register_date < DATE '2013-01-01'

AND last_login_date > ADD_MONTHS(SYSDATE, -3);

如果这张表有 5000 万条数据,这条语句会让你的数据库 CPU 瞬间飙红,然后你的老板找你谈话。

正确示范(游标处理):

在 PL/SQL 里,我们需要一个显式的游标。

DECLARE

-- 1. 声明游标:定义查询范围,但不执行

-- 使用 FOR UPDATE NOWAIT 是个好习惯,防止死锁

CURSOR user_cursor IS

SELECT id, username

FROM users

WHERE register_date < ADD_MONTHS(SYSDATE, -120)

AND last_login_date > ADD_MONTHS(SYSDATE, -3);

-- 定义一个变量来接收每一行的数据

v_user_id users.id%TYPE;

v_username users.username%TYPE;

-- 记录一下发了多少条

v_count NUMBER := 0;

BEGIN

-- 2. 打开游标

OPEN user_cursor;

-- 开始循环

LOOP

-- 3. 获取下一行数据

-- FETCH 是把数据从缓冲区拉到变量里

FETCH user_cursor INTO v_user_id, v_username;

-- 检查 FETCH 后的%NOTFOUND 属性,如果为真,说明没数据了

EXIT WHEN user_cursor%NOTFOUND;

-- 业务逻辑:发短信(这里只是个模拟函数)

-- dbms_output.put_line('给用户 ' || v_username || ' 发送短信...');

-- send_sms(p_phone => '13800138000', p_content => '十年不见...');

-- 模拟耗时操作

DBMS_LOCK.SLEEP(0.1);

v_count := v_count + 1;

-- 进度汇报,防止老板以为卡死了

IF v_count = 1000 THEN

DBMS_OUTPUT.PUT_LINE('已经处理了 1000 条,请稍候...');

v_count := 0;

END IF;

END LOOP;

-- 4. 关闭游标

CLOSE user_cursor;

DBMS_OUTPUT.PUT_LINE('任务完成!一共处理了 ' || SQL%ROWCOUNT || ' 条数据。');

EXCEPTION

WHEN OTHERS THEN

-- 异常处理:如果出错了,记得关游标,别留后患

IF user_cursor%ISOPEN THEN

CLOSE user_cursor;

END IF;

RAISE;

END;

/

专家点评:

看懂了吗?这就是“逐行处理”。FETCH 这一行代码是核心,它把你从“数据库服务器”的苦力变成了“聪明的程序员”。你在内存里拿数据,想慢点慢点,想快点快点,想发短信就发短信,想删记录就删记录。

不过,上面的代码有个小瑕疵:DBMS_LOCK.SLEEP(0.1) 只是演示用的。在真实场景中,如果发短信调用的是第三方接口,可能需要几秒甚至几十秒。如果用这种小循环,你的 Oracle 会瞬间被压垮,因为每发一条都要消耗一次网络往返。

这时候,PL/SQL 的进阶玩法来了:BULK COLLECT + FORALL。这就像是用铲车代替勺子。

-- 假设咱们不需要逐行处理,只需要更新数据库

CURSOR user_cursor IS

SELECT id FROM users WHERE status = 'EXPIRED';

BEGIN

-- 把结果集一次性塞进内存数组

OPEN user_cursor;

FETCH user_cursor BULK COLLECT INTO -- 定义一个集合变量,比如 TYPE t_ids IS TABLE OF users.id%TYPE;

v_ids;

CLOSE user_cursor;

-- 批量更新,快如闪电!

FORALL i IN 1..v_ids.COUNT

UPDATE users SET status = 'RENEWED' WHERE id = v_ids(i);

COMMIT;

END;

这里虽然用了 BULK COLLECT,但本质上还是把数据从数据库取出来了。如果你想避免取数据到应用层,那就得用存储过程的参数化更新,那是另一门玄学了,咱们今天先讲“取出来处理”的游标。

第三部分:Python 的优雅与粗暴——Pandas vs Cursor

Python 是个多面手。处理这种事情,它有两种流派:Pandas 流派 和 原生游标流派。

流派一:Pandas(你的数据分析师朋友)

如果你觉得 Python 原生的游标写起来太啰嗦,Pandas 绝对是你的好兄弟。它把“取数据”和“处理数据”合并成了一个简单的 DataFrame 操作。

import pandas as pd

import pymysql

def pandas_approach():

conn = pymysql.connect(host='localhost', user='root', password='123', database='test')

# 一行代码搞定:SQL查询 -> 转成DataFrame -> 过滤 -> 写回数据库

# 这看起来是不是很爽?不用写 for loop,不用 fetchone。

query = """

SELECT id, balance

FROM accounts

WHERE balance < 100

"""

# 读取数据

df = pd.read_sql(query, conn)

# 处理逻辑:给所有余额小于100的人涨10块钱(模拟)

df['balance'] = df['balance'] + 10

# 更新回去(这会生成一个巨大的 UPDATE 语句)

# 注意:如果你的数据量是百万级,这行代码会生成百万条SQL,依然会慢死!

df.to_sql('accounts', conn, if_exists='append', index=False)

conn.close()

pandas_approach()

点评: Pandas 简单、快速、适合分析师。但是,如果你要处理几亿行数据,Pandas 会把你的内存撑爆,电脑风扇转得像直升机起飞。这时候,老老实实用游标吧。

流派二:原生游标(Python DB-API)

Python 的 cursor 对象有两个核心方法:fetchone() 和 fetchmany(size)。

案例:慢速日志清洗器

假设你有一个数据库存了每天产生的 100 万条日志,格式乱七八糟,你需要把它们清洗一下存到新表里。

import psycopg2 # 假设用的是 PostgreSQL

def cursor_approach():

# 1. 建立连接

conn = psycopg2.connect("dbname=test user=postgres")

cur = conn.cursor()

# 2. 准备接收数据的容器(元组列表)

# 这样可以减少数据库和网络往返次数

insert_list = []

# 3. 执行查询

cur.execute("SELECT raw_log FROM logs WHERE is_processed = FALSE")

print("开始洗刷刷...")

# 4. 逐行获取

# 注意:fetchone() 一次只拿一条

while True:

row = cur.fetchone()

if row is None:

break

raw_data = row[0]

# --- 业务处理 ---

# 模拟:把 "ERROR: connection lost" 变成 "status=error"

cleaned_data = raw_data.replace("ERROR:", "status=error")

# 模拟耗时操作

# time.sleep(0.01)

# 把处理好的数据放入列表

insert_list.append((cleaned_data,))

# 5. 批量插入(优化性能)

# 如果每次 fetch 都去 commit,那是找死。我们要攒够一批再写库。

if len(insert_list) >= 1000:

try:

# 执行批量插入 SQL

cur.executemany("INSERT INTO clean_logs VALUES (%s)", insert_list)

conn.commit() # 提交事务

print(f"写入了 1000 条数据...")

insert_list.clear() # 清空列表,准备下一波

except Exception as e:

conn.rollback()

print(f"插入失败: {e}")

insert_list.clear()

# 5. 把最后剩下的那点数据也写进去

if insert_list:

cur.executemany("INSERT INTO clean_logs VALUES (%s)", insert_list)

conn.commit()

# 6. 收尾工作

cur.close()

conn.close()

cursor_approach()

这里的精髓在于 executemany 和 commit 的时机。

如果你 fetchone 一次就 commit 一次,那就是在羞辱数据库的性能。

如果你 fetchall 一次把一百万行全拿回来,那就是在羞辱你的 32G 内存。

最佳实践: 用 fetchmany(size=1000) 或者 while 循环配合一个 1000 条的 buffer,攒够了再 executemany。

第四部分:Java 的“罗嗦”——JDBC 游标

如果你在写后端接口,大概率逃不掉 Java。Java 里的游标叫 ResultSet。这玩意儿在 JDBC 4.0 之前非常痛苦,但现在(JDBC 4.2+)支持流式查询了。

案例:生成 CSV 报表导出

场景:老板要导出一份用户列表,格式是 CSV。用户数 50 万。

import java.sql.*;

import java.io.FileWriter;

import java.io.IOException;

public class CursorExportCSV {

public static void main(String[] args) {

String url = "jdbc:mysql://localhost:3306/test";

String user = "root";

String password = "root";

// 必须加上 useCursorFetch=true 参数,否则默认是批量取数据,会OOM!

String connProps = "?useCursorFetch=true&defaultFetchSize=1000";

try (Connection conn = DriverManager.getConnection(url + connProps, user, password);

Statement stmt = conn.createStatement(

ResultSet.TYPE_FORWARD_ONLY,

ResultSet.CONCUR_READ_ONLY)) {

stmt.setFetchSize(1000); // 关键!设置每次取多少行

String sql = "SELECT id, name, email FROM users";

// 执行查询,得到 ResultSet

ResultSet rs = stmt.executeQuery(sql);

// 写入文件

try (FileWriter writer = new FileWriter("users.csv")) {

// 写表头

writer.write("id,name,emailn");

int count = 0;

// 遍历结果集

while (rs.next()) {

int id = rs.getInt("id");

String name = rs.getString("name");

String email = rs.getString("email");

// 写入一行 CSV

writer.write(id + "," + name + "," + email + "n");

count++;

if (count % 1000 == 0) {

System.out.println("已导出 " + count + " 条数据...");

}

}

}

System.out.println("导出完成,共 " + count + " 条");

} catch (SQLException e) {

e.printStackTrace();

} catch (IOException e) {

e.printStackTrace();

}

}

}

专家点评(Java 特有):

在 Java 里,rs.next() 是那个像小怪兽一样的 while 循环。最重要的是 stmt.setFetchSize(1000)。

如果不设: MySQL 默认会把 50 万行全部加载到内存里。你的 JVM 会报 OutOfMemoryError: Java heap space。这是新手最常踩的坑。

如果设了: MySQL 每次只把 1000 行从数据库 Buffer Pool 传给 Java 的 JVM。内存占用保持稳定,只有几 MB。这叫“流式查询”。

第五部分:游标的“七宗罪”与防御策略

聊了这么多怎么用,咱们得聊聊怎么不用。因为资深专家都知道,能用 SQL 批量处理的地方,千万别用游标。

罪宗一:性能杀手(N+1 问题)

你肯定听过 N+1 查询问题。

假设你有 10 个部门,你想打印每个部门的人数。

-- 1. 查出所有部门

SELECT * FROM departments; -- 这一步 1 次数据库交互

-- 2. 在代码里循环 10 次,去查每个部门的人

-- SELECT COUNT(*) FROM employees WHERE dept_id = 1; -- 第1次

-- SELECT COUNT(*) FROM employees WHERE dept_id = 2; -- 第2次

-- ...

这就是 N+1。如果用游标,你在数据库里遍历部门,然后嵌套循环查员工,那就是 N + N 的灾难。

防御: 用 JOIN 和 GROUP BY。

罪宗二:长事务

游标通常会“锁”住底层的行。如果你打开一个游标,在里面处理了 2 个小时(比如在等某个外部接口回调),那么这 2 个小时内,数据库里的这行数据是锁定的。其他想读这条数据的用户会被阻塞,想改这条数据的用户会被报错。

防御:

尽量缩短游标的生命周期。

在处理循环中适时 COMMIT(Python 例子里的策略)。

如果必须长事务,用乐观锁。

罪宗三:内存泄漏

如果你在 Java 或 C# 里开了个游标,循环里 new 了对象,结果忘了关连接或者忘了 close(),你的连接池会迅速枯竭。

防御: 总是使用 try-with-resources 语法。

// Java 7+

try (Connection conn = ...; Statement stmt = ...; ResultSet rs = ...) {

// 代码

} // 自动关闭

罪宗四:结果集一致性

当你打开一个游标时,如果底层数据被其他人修改了,你的游标读到的是什么?

READ COMMITTED:读到你开启时刻提交的数据,之后的数据变化你看不到(幻读)。

REPEATABLE READ:数据库会把之前读过的数据快照保存下来,哪怕别人改了,你读的还是旧的。

SERIALIZABLE:最严格,像串行执行一样。

防御: 明确你的隔离级别。通常 READ COMMITTED 足够,除非你在做财务计算。

第六部分:进阶技巧——动态 SQL 与 复杂逻辑

有时候,你的逻辑太复杂,SQL 写不出来,代码里写起来太啰嗦,这时候游标是最后的手段。

案例:动态条件更新

假设你有一个任务表,每个任务需要根据不同的状态执行不同的操作。你不能写一堆 IF 判断去查数据库,太慢了。你可以在游标里直接做判断。

DECLARE

CURSOR task_cursor IS

SELECT task_id, status, amount

FROM payment_tasks

WHERE create_time > SYSDATE - 1;

v_task_id NUMBER;

v_status VARCHAR2(20);

v_amount NUMBER;

BEGIN

OPEN task_cursor;

LOOP

FETCH task_cursor INTO v_task_id, v_status, v_amount;

EXIT WHEN task_cursor%NOTFOUND;

-- 在内存里做逻辑判断,比查数据库快多了

IF v_status = 'PENDING' THEN

-- 模拟:自动通过

UPDATE payment_tasks SET status = 'SUCCESS' WHERE task_id = v_task_id;

-- 模拟:扣款逻辑

-- update_balance(user_id, -v_amount);

ELSIF v_status = 'FAILED' THEN

-- 模拟:重试逻辑

-- retry_task(v_task_id);

END IF;

END LOOP;

COMMIT;

CLOSE task_cursor;

END;

注意:这里依然是在应用层做逻辑判断,避免了频繁的 SQL 交互,这是游标的最高境界——“把计算从服务器挪到应用层”。

第七部分:总结与“生存指南”

好了,讲了这么多,咱们来总结一下“资深专家”的游标生存法则:

能不动游标,就不动: 能用 UPDATE ... WHERE id IN (1,2,3) 的,别用游标循环更新。能存过程的,别在 Java 代码里吐 SQL。

批量处理: fetchone 是给演示用的,fetchmany(1000) 或 bulk collect 才是生产环境。

及时释放: 游标打开久了就是垃圾。每次循环后检查状态,或者设置一个合理的超时时间。

不要在循环里 COMMIT: 除非你真的必须这么做(比如分批次提交日志),否则把 COMMIT 放在循环外,或者攒够了再放。

善用异常处理: 如果你的 LOOP 里抛了异常,游标很可能还开着。

最后,送给大家一句话:

游标就像一把匕首,拿在手里有美感,但用不好会伤到自己。SQL 是优雅的剑气,游标是贴身的匕首。在挥舞 SQL 这把大剑之前,先想想,有没有必要拔出这把小刀?

好了,今天的讲座就到这里。谁还有问题?没有问题的话,大家把那个还在跑 SELECT * FROM 的脚本关掉,早点下班,生活比代码重要。

(此处应有掌声)

相关推荐