1. 为什么我还在用 SQL Server 游标遍历结果集SQL Server 里做批量数据处理很多人第一反应是写个 WHILE 循环配临时表或者干脆把逻辑挪到应用层。但有些场景就是绕不开游标比如要逐行调用存储过程、逐行拼接动态 SQL、逐行做带业务规则的更新而且这些逻辑必须留在数据库端完成。这时候DECLARE CURSORFETCH NEXTFETCH_STATUS这套组合就是最直接的工具。游标循环遍历结果集说白了就是把SELECT查出来的一批行一行一行地取出来处理。它适合谁适合需要在 T-SQL 里对每一行做差异化操作的场景比如按账户逐条生成凭证、按订单逐条计算折扣、按配置表逐条执行维护脚本。不适合谁不适合纯集合运算能搞定的事——那种情况用UPDATE ... FROM或MERGE性能会好得多。我试过在一个对账脚本里用游标逐行比对两套科目表结果集大概几千行跑下来完全能接受。但如果结果集上万行游标逐行处理的代价就会明显上升这时候要么加过滤条件缩小结果集要么考虑改成集合操作。这篇要解决的问题有两个层面第一层是游标本身的写法——怎么声明、怎么 FETCH、怎么判断FETCH_STATUS、怎么正确关闭和释放第二层是连接配置的管理——当你需要从外部工具或脚本连到 SQL Server 执行这些游标脚本时连接串、认证方式、Key 怎么统一管理。第二层我会结合 TaoToken 的统一 Key 通道来讲把数据库连接配置和 API 通道配置放在一个地方管理避免到处散落明文密码。先说清楚TaoToken 不是数据库它不替代 SQL Server。它是一个统一 Key 的 API 通道用来管理你调用各种模型服务时的认证配置。你在写游标脚本时如果需要让外部脚本或 AI 辅助工具连到数据库连接信息的管理可以借助它来统一。下面会给出可复制的配置片段。2. TaoToken 统一 Key 的前置准备与连接配置管理在讲游标脚本之前先把连接配置这块理清楚。因为很多人在本地写游标脚本时连接串是硬编码在脚本里的换台机器就得改一遍密码还容易泄露。TaoToken 的统一 Key 思路是把认证信息集中管理脚本里只引用一个 Key 或一个配置项。你需要先拿到一个可用的 Key。访问 API Keys 页面https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite创建一个。创建后你会得到一个以sk-开头的字符串这就是统一 Key。注意这个 Key 是给 API 通道用的不是 SQL Server 的登录密码。SQL Server 本身的连接还是用你自己的数据库账号TaoToken 管的是你调用外部服务时的认证。为什么要把这两件事放一起说因为实际工作流里你可能是用一个 Python 脚本或 Node 脚本去连 SQL Server 执行游标逻辑同时这个脚本还要调用模型服务做数据处理或日志分析。如果数据库密码和 API Key 分散在多个文件里维护起来很痛苦。统一到一个配置文件里改一处就行。下面是一个可复制的 JSON 配置片段路径放在项目根目录的config/connections.json{ sqlserver: { server: localhost, port: 1433, database: FAC_DB, user: sa, password: YourLocalDbPassword, options: { encrypt: true, trustServerCertificate: true } }, taotoken: { base_url: https://taotoken.net/api, api_key: sk-你的统一Key, default_model: claude-sonnet-4-20250514 } }注意几个点trustServerCertificate在本地开发时设为true可以避免自签名证书报错生产环境应该配正式证书。base_url用https://taotoken.net/api不要加多余路径。api_key从环境变量读取更安全这里写出来是为了让你看清结构。如果你用的是环境变量方式可以这样export TAOTOKEN_API_KEYsk-你的统一Key export TAOTOKEN_BASE_URLhttps://taotoken.net/api export SQLSERVER_CONNServerlocalhost,1433;DatabaseFAC_DB;User Idsa;PasswordYourLocalDbPassword;TrustServerCertificateTrue;这样脚本里就不用出现明文了。TaoToken 的 Coding Plan 页面https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite里有更完整的长期编码场景配置说明如果你要跑持续性的 Agent 任务可以参考那里的参数。配置准备好之后下一步就是写游标脚本本身。游标脚本在 SSMS 或 Azure Data Studio 里直接跑不需要 TaoToken 介入但如果你要通过外部脚本执行就需要用上面的连接配置去建立连接。两种方式我都会给。3. 可复制的游标声明、FETCH 循环与 FETCH_STATUS 判断脚本这一节是核心。我会从最简单的单列游标开始逐步加到多列、带临时表、带异常处理的完整版本。每一段都可以直接复制到 SSMS 里执行。先建一个测试表模拟科目表USE FAC_DB; GO IF OBJECT_ID(dbo.FAC_COA, U) IS NOT NULL DROP TABLE dbo.FAC_COA; GO CREATE TABLE dbo.FAC_COA ( SN INT PRIMARY KEY, Accnt_Code VARCHAR(20) NOT NULL, Accnt_Name VARCHAR(100) NOT NULL, Balance DECIMAL(18,2) DEFAULT 0 ); GO INSERT INTO dbo.FAC_COA (SN, Accnt_Code, Accnt_Name, Balance) VALUES (2537, 1001, 库存现金, 15000.00), (2538, 1002, 银行存款, 82000.50), (2539, 1122, 应收账款, 43000.00), (2540, 1123, 预付账款, 12000.00), (2541, 2202, 应付账款, 67000.00); GO3.1 单列游标最基本的遍历DECLARE A1 VARCHAR(100); DECLARE YOUCURNAME CURSOR FOR SELECT Accnt_Code FROM FAC_COA WHERE SN 2537; OPEN YOUCURNAME; FETCH NEXT FROM YOUCURNAME INTO A1; WHILE FETCH_STATUS 0 BEGIN PRINT A1; FETCH NEXT FROM YOUCURNAME INTO A1; END CLOSE YOUCURNAME; DEALLOCATE YOUCURNAME;执行后你会看到五行科目代码依次打印出来。关键点FETCH NEXT在循环外先执行一次进入循环后每次处理完再 FETCH 下一行。FETCH_STATUS 0表示 FETCH 成功等于 -1 表示失败或没有更多行等于 -2 表示被提取的行不存在。3.2 多列游标变量顺序必须和 SELECT 列对应DECLARE A1 VARCHAR(100), A2 VARCHAR(100); DECLARE YOUCURNAME CURSOR FOR SELECT Accnt_Code, Accnt_Name FROM FAC_COA WHERE SN 2537; OPEN YOUCURNAME; FETCH NEXT FROM YOUCURNAME INTO A1, A2; WHILE FETCH_STATUS 0 BEGIN PRINT A1 - A2; FETCH NEXT FROM YOUCURNAME INTO A1, A2; END CLOSE YOUCURNAME; DEALLOCATE YOUCURNAME;这里最容易踩的坑是变量类型和列类型不匹配。Accnt_Code是VARCHAR(20)你用INT变量去接就会报转换错误。变量顺序也要和 SELECT 的列顺序一致否则会把名字赋给代码。3.3 带临时表的游标先快照再遍历有时候你不想直接遍历原表而是先把结果集落到临时表再对临时表开游标。这样做的好处是遍历期间原表数据变化不影响游标结果。IF OBJECT_ID(tempdb..#interim) IS NOT NULL DROP TABLE #interim; SELECT SN, Accnt_Code, Accnt_Name, Balance INTO #interim FROM FAC_COA WHERE SN 2537; DECLARE SN INT, Code VARCHAR(20), Name VARCHAR(100), Bal DECIMAL(18,2); DECLARE cur_coa CURSOR FOR SELECT SN, Accnt_Code, Accnt_Name, Balance FROM #interim; OPEN cur_coa; FETCH NEXT FROM cur_coa INTO SN, Code, Name, Bal; WHILE FETCH_STATUS 0 BEGIN PRINT CONCAT(SN, SN, Code, Code, Name, Name, Balance, Bal); FETCH NEXT FROM cur_coa INTO SN, Code, Name, Bal; END CLOSE cur_coa; DEALLOCATE cur_coa; DROP TABLE #interim;临时表用#前缀只在当前会话可见。用完记得DROP虽然会话结束会自动清理但显式删除是好习惯。3.4 带更新的游标逐行修改数据DECLARE SN INT, Bal DECIMAL(18,2); DECLARE cur_update CURSOR FOR SELECT SN, Balance FROM FAC_COA WHERE SN 2537; OPEN cur_update; FETCH NEXT FROM cur_update INTO SN, Bal; WHILE FETCH_STATUS 0 BEGIN UPDATE FAC_COA SET Balance Bal * 1.05 WHERE SN SN; FETCH NEXT FROM cur_update INTO SN, Bal; END CLOSE cur_update; DEALLOCATE cur_update;这个脚本把每个科目的余额上浮 5%。执行前后你可以用SELECT * FROM FAC_COA对比结果。3.5 通过外部脚本执行游标逻辑如果你不想在 SSMS 里手动跑可以用 Python 脚本连 SQL Server 执行上面的游标 SQL。这里用pyodbc举例import pyodbc import json with open(config/connections.json, r) as f: cfg json.load(f) conn_str ( fDRIVER{{ODBC Driver 18 for SQL Server}}; fSERVER{cfg[sqlserver][server]},{cfg[sqlserver][port]}; fDATABASE{cfg[sqlserver][database]}; fUID{cfg[sqlserver][user]}; fPWD{cfg[sqlserver][password]}; fTrustServerCertificateyes; ) cursor_sql DECLARE A1 VARCHAR(100), A2 VARCHAR(100); DECLARE YOUCURNAME CURSOR FOR SELECT Accnt_Code, Accnt_Name FROM FAC_COA WHERE SN 2537; OPEN YOUCURNAME; FETCH NEXT FROM YOUCURNAME INTO A1, A2; WHILE FETCH_STATUS 0 BEGIN PRINT A1 - A2; FETCH NEXT FROM YOUCURNAME INTO A1, A2; END CLOSE YOUCURNAME; DEALLOCATE YOUCURNAME; conn pyodbc.connect(conn_str) cur conn.cursor() cur.execute(cursor_sql) conn.commit() cur.close() conn.close()注意PRINT的输出在 pyodbc 里不会直接返回你需要用SELECT或者把结果写入临时表再查。如果要捕获游标处理结果建议在游标循环里把数据插入一个日志表脚本执行完再查日志表。4. 验证请求与执行前后结果集对比写完游标脚本怎么确认它真的按预期遍历了所有行最直接的办法是执行前后对比结果集。4.1 执行前快照SELECT SN, Accnt_Code, Accnt_Name, Balance FROM FAC_COA WHERE SN 2537 ORDER BY SN;记下这个结果。假设初始余额是 15000、82000.50、43000、12000、67000。4.2 执行游标更新脚本跑 3.4 节那个上浮 5% 的脚本。4.3 执行后对比SELECT SN, Accnt_Code, Accnt_Name, Balance FROM FAC_COA WHERE SN 2537 ORDER BY SN;预期结果每行余额都变成原来的 1.05 倍。15000 变成 1575082000.50 变成 86100.525以此类推。如果某一行没变说明游标没遍历到它检查 WHERE 条件是否覆盖了所有目标行。4.4 用 CURSOR_ROWS 验证游标行数在游标打开后、FETCH 之前可以查一下CURSOR_ROWSDECLARE cur_check CURSOR FOR SELECT Accnt_Code FROM FAC_COA WHERE SN 2537; OPEN cur_check; SELECT CURSOR_ROWS AS CursorRowCount; FETCH NEXT FROM cur_check; SELECT CURSOR_ROWS AS AfterFirstFetch; CLOSE cur_check; DEALLOCATE cur_check;如果返回 -1说明游标是动态的行数不确定。如果返回正数说明游标已完全填充这个数就是总行数。如果返回 0说明没有打开的游标或结果集为空。4.5 用 CURSOR_STATUS 检查游标状态DECLARE status SMALLINT; DECLARE cur_test CURSOR FOR SELECT Accnt_Code FROM FAC_COA WHERE SN 2537; SET status CURSOR_STATUS(global, cur_test); PRINT Before open: CAST(status AS VARCHAR(10)); OPEN cur_test; SET status CURSOR_STATUS(global, cur_test); PRINT After open: CAST(status AS VARCHAR(10)); CLOSE cur_test; DEALLOCATE cur_test; SET status CURSOR_STATUS(global, cur_test); PRINT After deallocate: CAST(status AS VARCHAR(10));返回值含义1 表示游标至少有一行0 表示结果集为空-1 表示游标关闭-2 表示不可用-3 表示游标不存在。这个函数在存储过程里判断游标是否有效时很有用。4.6 通过 TaoToken 模型对话验证脚本逻辑如果你不确定游标逻辑写得对不对可以把脚本贴到模型对话页面https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite让模型帮你检查。比如问它“这段游标的 FETCH 顺序有没有问题FETCH_STATUS 判断放在 WHILE 条件里对不对”模型会逐行分析并指出潜在问题。实测下来这种方式对排查FETCH_STATUS的全局性陷阱特别有效。因为FETCH_STATUS是整个连接级别的如果你在游标循环里调用了另一个存储过程而那个存储过程也开了游标并执行了 FETCH那么你外层的FETCH_STATUS会被覆盖。模型能帮你识别这种隐蔽问题。5. 本篇常见报错与排查5.1 报错A cursor with the name YOUCURNAME already exists完整报错信息Msg 16915, Level 16, State 1, Line 1 A cursor with the name YOUCURNAME already exists.原因上一次执行时游标没有正确关闭和释放同名游标还在会话里。解决办法在声明之前先检查并释放。IF CURSOR_STATUS(global, YOUCURNAME) -1 BEGIN CLOSE YOUCURNAME; DEALLOCATE YOUCURNAME; END或者直接用DEALLOCATE在声明前清理。更稳妥的做法是每次脚本末尾都写CLOSEDEALLOCATE不要省略。5.2 报错Must declare the scalar variable A1完整报错Msg 137, Level 15, State 2, Line 1 Must declare the scalar variable A1.原因变量没声明就用了或者声明的位置在游标声明之后。T-SQL 里变量必须先声明再使用。检查DECLARE A1 VARCHAR(100);是否在DECLARE YOUCURNAME CURSOR FOR之前。5.3 报错Error converting data type varchar to int完整报错Msg 245, Level 16, State 1, Line 1 Conversion failed when converting the varchar value 1001 to data type int.原因游标 SELECT 出来的列是VARCHAR但你用INT变量去接。检查FETCH NEXT FROM ... INTO A1里变量的类型是否和 SELECT 列类型一致。Accnt_Code是VARCHAR(20)变量就应该是VARCHAR(20)或更大。5.4 报错local proxy failed / connection refused如果你是通过外部脚本连 SQL Server可能遇到pyodbc.OperationalError: (HYT00, [HYT00] [Microsoft][ODBC Driver 18 for SQL Server]Login timeout expired)或者error: local proxy failed to connect原因SQL Server 的 TCP/IP 协议没启用或者端口不对。检查 SQL Server Configuration Manager 里 TCP/IP 是否启用默认端口 1433 是否在监听。如果是命名实例端口可能是动态的需要在连接串里指定SERVERlocalhost\\INSTANCENAME。如果你用的是 TaoToken 的 API 通道去调用模型服务遇到 401 报错{error: {message: Invalid API key, type: invalid_request_error}}检查api_key是否以sk-开头是否有多余空格是否在 TaoToken 控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite里被禁用或删除。5.5 报错reading choices of undefined如果你在脚本里调用模型 API 后解析响应遇到TypeError: Cannot read properties of undefined (reading choices)原因通常是响应体不是预期的 JSON 结构可能是认证失败返回了错误对象或者 base_url 配错了。检查base_url是否是https://taotoken.net/api请求头里Authorization: Bearer sk-xxx是否正确。用 curl 先测一下curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer sk-你的Key \ -H Content-Type: application/json \ -d {model:claude-sonnet-4-20250514,messages:[{role:user,content:test}]}如果返回正常 JSON说明 Key 和地址没问题问题在脚本的解析逻辑。5.6 游标循环里 FETCH_STATUS 被覆盖这是最隐蔽的坑。看这个例子DECLARE A1 VARCHAR(100); DECLARE cur_outer CURSOR FOR SELECT Accnt_Code FROM FAC_COA; OPEN cur_outer; FETCH NEXT FROM cur_outer INTO A1; WHILE FETCH_STATUS 0 BEGIN EXEC dbo.SomeProcThatOpensAnotherCursor; -- 这个存储过程里也 FETCH 了 FETCH NEXT FROM cur_outer INTO A1; END CLOSE cur_outer; DEALLOCATE cur_outer;如果SomeProcThatOpensAnotherCursor里执行了 FETCH那么外层FETCH_STATUS会被内层的 FETCH 结果覆盖。解决办法在调用存储过程后立即把FETCH_STATUS存到本地变量里或者避免在游标循环里调用会操作游标的存储过程。DECLARE fetchStatus INT; WHILE FETCH_STATUS 0 BEGIN SET fetchStatus FETCH_STATUS; -- 先保存 EXEC dbo.SomeProcThatOpensAnotherCursor; IF fetchStatus 0 BREAK; FETCH NEXT FROM cur_outer INTO A1; END5.7 游标没关闭导致锁表如果游标打开后没有CLOSE事务一直不提交可能会持有共享锁阻塞其他会话的更新操作。排查方法SELECT session_id, cursor_name, creation_time, properties FROM sys.dm_exec_cursors(0) WHERE session_id SPID;这个查询会列出当前会话所有打开的游标。如果发现游标一直存在检查脚本里是否有CLOSE和DEALLOCATE。6. 把游标脚本和统一 Key 配置串起来回到实际工作流。你写了一个游标脚本做批量对账脚本跑在 SQL Server 上。同时你有一个 Python 调度脚本负责在每天凌晨触发这个游标逻辑并把执行日志发给模型做异常检测。这时候连接配置的管理就很重要。我的做法是数据库连接串和 TaoToken 的 Key 都放在环境变量里调度脚本从环境变量读取。这样换环境时只需要改环境变量不用动代码。TaoToken 的接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite里有完整的认证说明和错误码对照表遇到 401 或 429 时可以查一下具体原因。如果你要长期跑这类调度任务Coding Plan 页面https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite里有针对持续编码和 Agent 场景的配置建议包括超时设置、重试策略、模型选择。这些参数对稳定性影响很大尤其是游标处理大批量数据时脚本执行时间可能超过默认超时需要调大。最后给一个完整的调度脚本骨架把游标执行和模型调用串起来import pyodbc import requests import os import json TAOTOKEN_KEY os.environ.get(TAOTOKEN_API_KEY) TAOTOKEN_URL os.environ.get(TAOTOKEN_BASE_URL, https://taotoken.net/api) SQL_CONN os.environ.get(SQLSERVER_CONN) def run_cursor_job(): conn pyodbc.connect(SQL_CONN) cur conn.cursor() cur.execute( DECLARE SN INT, Bal DECIMAL(18,2); DECLARE cur_update CURSOR FOR SELECT SN, Balance FROM FAC_COA WHERE SN 2537; OPEN cur_update; FETCH NEXT FROM cur_update INTO SN, Bal; WHILE FETCH_STATUS 0 BEGIN UPDATE FAC_COA SET Balance Bal * 1.05 WHERE SN SN; FETCH NEXT FROM cur_update INTO SN, Bal; END CLOSE cur_update; DEALLOCATE cur_update; ) conn.commit() cur.close() conn.close() def check_anomaly(log_text): resp requests.post( f{TAOTOKEN_URL}/v1/chat/completions, headers{Authorization: fBearer {TAOTOKEN_KEY}}, json{ model: claude-sonnet-4-20250514, messages: [{role: user, content: f检查以下日志是否有异常\n{log_text}}] }, timeout60 ) return resp.json() if __name__ __main__: run_cursor_job() result check_anomaly(游标执行完成影响行数 5) print(json.dumps(result, ensure_asciiFalse, indent2))这个骨架可以直接改成你的业务逻辑。游标部分负责数据库端的逐行处理模型调用部分负责日志分析。两边的认证配置都从环境变量走统一管理。游标本身不复杂难的是边界情况空结果集、FETCH 失败、游标未关闭、FETCH_STATUS被覆盖。把这些坑都处理掉游标循环遍历结果集就是一个稳定可靠的工具。