游标(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 的脚本关掉,早点下班,生活比代码重要。
(此处应有掌声)