SQL Server 允许一次存储过程调用连续返回多个表格结果,应用通常只需依次调用 NextResult() 就能读取。PostgreSQL 的执行模型不同:多个数据集需要通过显式游标管理,而且游标只能在所属事务中使用。
这项差异会直接影响大型迁移。面对数百个包含条件分支、嵌套调用和标量返回值的存储过程,逐个手工重写既慢,也容易漏掉某条执行路径。Google Cloud Database Migration Service(DMS)的处理方式,是先分析过程可能产生的结果集数量,再决定将它转换为 PostgreSQL PROCEDURE,还是返回 SETOF refcursor 的 FUNCTION。
转换决策不只看 SELECT 数量
DMS 重点判断两个问题:
| SQL Server 例程行为 | PostgreSQL 目标对象 | 返回机制 |
|---|---|---|
| 只有一个结果集 | PROCEDURE |
使用 INOUT refcursor |
| 只有标量返回值 | PROCEDURE |
使用普通变量或输出参数跟踪 |
| 多个结果集 | FUNCTION |
返回 SETOF refcursor |
| 结果集与标量返回值并存 | FUNCTION |
数据游标集合,加一个独立的标量游标 |
| 结果集数量受循环或条件控制 | 通常按多结果集处理 | 显式返回动态生成的游标集合 |
真正困难的地方在于,结果集不一定直接写在当前过程里。例如,一个患者汇总过程可能先返回基本信息,再调用检验结果过程两次,最后调用就诊记录过程。某个子过程是否执行,还可能取决于上一次调用的标量返回值。
因此,单纯统计当前过程中的 SELECT 并不可靠。DMS 会识别直接结果集、条件或循环中的动态结果集,以及被调用过程产生的间接结果集。
单结果集:用 INOUT refcursor 保留过程语义
对于固定返回一个结果集的子过程,转换结果仍然可以是 PostgreSQL PROCEDURE。下面是一个可直接改造的示例:
CREATE SCHEMA IF NOT EXISTS dbo;
CREATE TABLE IF NOT EXISTS dbo.doctor_visits (
visit_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
patient_id integer NOT NULL,
doctor_name text NOT NULL,
visit_date timestamp NOT NULL,
visit_notes text
);
CREATE OR REPLACE PROCEDURE dbo.get_patient_doctor_visits(
_patient_id integer,
INOUT result_cursor refcursor
)
LANGUAGE plpgsql
AS $$
BEGIN
OPEN result_cursor FOR
SELECT visit_id, patient_id, doctor_name, visit_date, visit_notes
FROM dbo.doctor_visits
WHERE patient_id = _patient_id
AND visit_date >= localtimestamp - interval '6 months'
ORDER BY visit_date DESC;
END;
$$;
调用时必须显式开启事务,因为 refcursor 指向的 portal 会在事务结束时失效:
BEGIN;
CALL dbo.get_patient_doctor_visits(3, 'doctor_visits_cursor');
FETCH ALL FROM doctor_visits_cursor;
COMMIT;
给游标指定稳定名称,比依赖 <unnamed portal 1> 一类自动名称更适合测试脚本和应用集成。匿名 portal 的编号可能受到调用顺序、驱动实现和过程分支影响,不宜成为长期接口契约。
多结果集:函数返回 SETOF refcursor
当一个例程可能返回多个数据集时,单个 INOUT refcursor 已经不够。DMS 会将其组织为返回 SETOF refcursor 的 PL/pgSQL 函数,每打开一个游标,就通过 RETURN NEXT 把游标引用交给调用方。
下面的最小示例同时返回近期检验结果、异常检验结果和一个标量状态。运行前只需把表结构和字段名替换成项目中的实际对象:
CREATE TABLE IF NOT EXISTS dbo.lab_results (
result_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
patient_id integer NOT NULL,
test_name text NOT NULL,
test_date timestamp NOT NULL,
is_exception boolean NOT NULL DEFAULT false
);
CREATE OR REPLACE FUNCTION dbo.get_patient_lab_results(
_patient_id integer,
_query_type integer
)
RETURNS SETOF refcursor
LANGUAGE plpgsql
AS $$
DECLARE
rc refcursor;
_records_found integer := 0;
BEGIN
IF _query_type IN (1, 3) THEN
rc := 'recent_lab_results';
OPEN rc FOR
SELECT result_id, patient_id, test_name, test_date, is_exception
FROM dbo.lab_results
WHERE patient_id = _patient_id
AND test_date >= localtimestamp - interval '6 months'
ORDER BY test_date DESC;
RETURN NEXT rc;
IF EXISTS (
SELECT 1
FROM dbo.lab_results
WHERE patient_id = _patient_id
AND test_date >= localtimestamp - interval '6 months'
) THEN
_records_found := 1;
END IF;
END IF;
IF _query_type IN (2, 3) THEN
rc := 'exception_lab_results';
OPEN rc FOR
SELECT result_id, patient_id, test_name, test_date, is_exception
FROM dbo.lab_results
WHERE patient_id = _patient_id
AND is_exception = true
AND test_date >= localtimestamp - interval '2 years'
ORDER BY test_date DESC;
RETURN NEXT rc;
IF EXISTS (
SELECT 1
FROM dbo.lab_results
WHERE patient_id = _patient_id
AND is_exception = true
AND test_date >= localtimestamp - interval '2 years'
) THEN
_records_found := 1;
END IF;
END IF;
rc := 'return_value';
OPEN rc FOR SELECT _records_found AS return_value;
RETURN NEXT rc;
END;
$$;
测试完整返回链时,函数调用和所有 FETCH 必须位于同一个事务:
BEGIN;
SELECT *
FROM dbo.get_patient_lab_results(3, 3);
FETCH ALL FROM recent_lab_results;
FETCH ALL FROM exception_lab_results;
FETCH ALL FROM return_value;
COMMIT;
这里的 return_value 不是 PostgreSQL 函数本身的普通整数返回值,而是一个只包含一行一列的结果集。这样做的目的,是把 SQL Server 的“多个表格结果加标量状态”统一映射到同一种游标序列中。
应用层不能继续照搬 NextResult
迁移后的数据访问层会先收到一组游标名称,然后逐个执行 FETCH ALL。游标和读取操作必须使用同一个数据库连接、同一个事务;连接池如果在两次语句之间归还或更换连接,后续 FETCH 就无法找到对应 portal。
可以这样用 Python 和 psycopg 3 验证函数输出:
import psycopg
from psycopg import sql
DSN = "postgresql://postgres:postgres@localhost:5432/app"
with psycopg.connect(DSN) as conn:
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"SELECT * FROM dbo.get_patient_lab_results(%s, %s)",
(3, 3),
)
cursor_names = [row[0] for row in cur.fetchall()]
results = {}
for cursor_name in cursor_names:
# Identifier 会正确引用游标名,避免把名称拼进 SQL。
cur.execute(
sql.SQL("FETCH ALL FROM {}").format(
sql.Identifier(cursor_name)
)
)
columns = [column.name for column in cur.description]
results[cursor_name] = [
dict(zip(columns, row)) for row in cur.fetchall()
]
print(results)
安装依赖并运行:
python -m pip install "psycopg[binary]"
python fetch_results.py
生产代码还应定义结果游标的稳定顺序、名称和字段模式。否则,业务分支改变后,调用方只能根据“第几个游标”猜测数据含义,回归测试也很难给出清晰错误。
DMS 如何推导嵌套过程的结果集数量
转换器需要从整个调用网络判断结果集规模,而不是孤立分析单个过程。其分析过程可以概括为四步:
- 扫描过程体,记录直接执行并向客户端返回的
SELECT。 - 识别条件、循环和动态执行中的
SELECT或EXEC;如果执行次数无法静态确定,就把结果集数量标记为动态。 - 根据过程之间的调用关系建立有向图。
- 在调用图上执行深度优先遍历,将子过程的固定数量或动态标记向上传播给调用者。
例如,主过程自身返回一个结果集,子过程固定返回两个结果集,另一个子过程只在条件满足时返回一个结果集,那么主过程不能简单标记为“四个结果集”。更准确的结论是:它具有动态结果集数量,应按多结果集函数生成代码。
调用图分析还能覆盖多层间接调用和递归关系。不过,自动转换保留的是结构和业务分支,不能证明每条分支的运行时语义都完全一致。动态 SQL、临时表、异常处理以及事务控制仍然需要专项验证。
上线前的验证清单
自动转换解决了批量重写问题,但应用协议已经发生变化。落地时至少检查这些事项:
- 为每条主要条件分支准备测试数据,并核对结果集数量、顺序、列名和类型。
- 验证“患者不存在”或“查询无记录”等早退路径是否仍返回预期的状态游标。
- 确保函数调用与所有
FETCH固定在同一个连接和事务中。 - 优先使用稳定的显式游标名,不把匿名 portal 编号写死在生产代码中。
- 检查重复调用和嵌套调用是否可能产生同名游标冲突。
- 对标量状态游标建立明确契约,例如固定名称为
return_value、固定字段也为return_value。 - 比较 SQL Server 与 PostgreSQL 在时间区间、布尔值、空值和排序方面的差异。
- 对 DMS 生成的 PL/pgSQL 执行静态检查和分支覆盖测试,尤其关注变量命名、动态 SQL与递归调用。
DMS 能自动完成 PROCEDURE 与 FUNCTION 的结构选择,并把嵌套结果集映射为确定的游标操作。迁移团队仍需把这次变化当作一次接口协议升级:数据库对象、连接池、数据访问层和 QA 脚本必须一起调整。只有端到端验证游标生命周期和每条执行路径,自动转换的代码才能稳定进入生产环境。