上个月接了一个数据库国产化迁移的评估任务,业务 SQL 的兼容性问题提前过了,语法层面基本没有大阻碍。真正让我头疼的是那几十个在生产环境跑了好多年的 Shell 脚本——清一色的 sqlplus 调用,输出格式、退出码判断、SPOOL 文件解析全是按 Oracle 的习惯写的。把它们迁移到 GaussDB 的 gsql 上,不是把命令名换掉就完事,中间各种隐蔽差异个个都能让脚本在凌晨跑批时悄悄挂掉。
这篇文章就围绕“Shell 中执行 SQL 文件”这个场景,把 sqlplus 到 gsql 的迁移改造过程完整拆开讲。内容包括存量脚本梳理、两者差异对照、统一封装方案、真实踩坑记录和回归验证方法,适合正在做 GaussDB 迁移的 DBA、运维和开发同学参考。
1. 迁移之前:梳理存量脚本里的 sqlplus 调用形态
在动手改任何脚本之前,我先把所有线上脚本翻了一遍。这一步看起来枯燥,但直接决定了后面改造的工作量。很多人一上来就搜“gsql 怎么替代 sqlplus”,然后对着单个脚本改,结果改了十几个脚本之后发现每个写法都不一样,越改越乱,所以我建议第一步永远是盘点。
1.1 最常见的六种 sqlplus 调用形态
我梳理了生产环境里的存量脚本,sqlplus 的调用方式基本逃不出下面六种:
- 静默执行单个 SQL 文件:
sqlplus -S $DB_USER/$DB_PASS@$DB_SID @nightly_report.sql- 用 heredoc 把 SQL 直接喂给 sqlplus:
sqlplus -S -L $DB_USER/$DB_PASS@$DB_SID <<EOF SET PAGESIZE 0 SET FEEDBACK OFF SELECT * FROM tab; EXIT; EOF- 通过 SPOOL 命令在 SQL 内部生成报表文件:
SPOOL /data/reports/result_20240101.csv SELECT ...; SPOOL OFF- 带位置参数传给 SQL 文件:
sqlplus -S $DB_USER/$DB_PASS@$DB_SID @daily_stat.sql 20240101SQL 文件里用&1引用这个参数。
- 在 sqlplus 脚本内部控制退出码:
WHENEVER SQLERROR EXIT SQL.SQLCODE EXIT SUCCESS- 连接串里带服务名或主机端口:
sqlplus -S $DB_USER/$DB_PASS@//127.0.0.1:1521/orcl这六种形态在 gsql 里的处理方式完全不同。其中第 1、2 种是壳层的替换,第 3、6 种涉及 SQL 文件内容的改造,第 4、5 种最容易在迁移后被忽略,却恰恰是批处理脚本里最要命的部分。
1.2 迁移清单里必须确认的几个前置问题
我把整理的清单列在这里,建议迁移前逐条过一遍:
- 统计脚本数量:
grep -rln "sqlplus" /opt/scripts/ | wc -l,先知道要动多少文件。 - 检查是否有 login.sql 或 glogin.sql。这个文件是 sqlplus 启动时自动加载的,里面可能设置了默认的格式参数,迁移到 gsql 后没有自动加载机制,这些设置就全丢了。
- 排查 SQL 文件里是否依赖 SPOOL 生成文件,以及下游是否有按 SPOOL 输出格式做解析的脚本。
- 确认是否用了
&1、&2这类位置参数,以及DEFINE变量。gsql 没有完全对应的机制,需要在 Shell 层用-v定义。 - 检查 SQL 文件中的 Oracle 专有函数和语法,比如 SYSDATE、ROWNUM、CONNECT BY、DUAL 表、
NVL等。GaussDB 对其中一部分做了兼容,但远非全部。 - 确认 Shell 脚本对退出码的判断逻辑。sqlplus 的错误返回和 gsql 的错误返回机制差异很大,不确认清楚,日志里就会出现“报表已生成”但实际文件只有半个表的情况。
这一步做完之后,我对改造范围基本心里有数了:大约三分之二的脚本可以通过封装的函数直接替换,剩下的需要逐个改 SQL 文件。
2. sqlplus 与 gsql 的本质差异:连接、执行语义与输出
gsql 不是 sqlplus 的克隆品,它是 GaussDB 自带的命令行交互工具,设计理念更接近 PostgreSQL 的 psql。很多在 sqlplus 里被视为理所当然的行为,在 gsql 里完全没有对应物,或者行为完全相反。这一章把最关键的三类差异讲透。
2.1 连接信息与命令行参数对照
这是最表层的差异,但也是最容易被忽略的。sqlplus 把连接信息塞进一个连接串:user/pass@sid;gsql 则是一堆独立参数:-h指定主机,-p指定端口,-U指定用户,-d指定数据库,密码通过-W或环境变量PGPASSWORD传入。
| 功能点 | Oracle sqlplus | GaussDB gsql |
|---|---|---|
| 连接数据库 | user/pass@sid | -h host -p port -U user -d dbname |
| 密码传递 | 写在连接串里 | -W参数或环境变量PGPASSWORD |
| 静默模式 | -S | -q |
| 执行 SQL 文件 | @file.sql | -f file.sql |
| 执行单条 SQL | -c "sql" | -c "sql" |
| 非对齐输出 | 需SET组合命令 | -A |
| 仅输出数据 | 需SET HEADING OFF等 | -t |
| 错误即停 | WHENEVER SQLERROR EXIT | -v ON_ERROR_STOP=1 |
注意一个细节:sqlplus 的密码直接写在连接串里,用ps查看进程列表会直接暴露密码;gsql 如果用-W传密码也有同样问题。迁移到 gsql 后,我建议统一改用环境变量PGPASSWORD,这样进程列表里看不到密码,Shell 脚本里也可以把密码集中放在配置文件中统一管理。这也是这次迁移中顺手补上的一个安全改进。
2.2 执行语义差异:回显、停止策略与退出码
sqlplus 默认ECHO OFF,执行@file.sql时不会回显 SQL 语句本身,输出相对干净。gsql 则相反,执行-f file.sql时会把 SQL 语句原样打到输出里,如果不加-q,报表文件的开头会混入一堆 SQL 文本,下游解析直接崩。
更隐蔽的是错误停止策略。sqlplus 脚本里如果写了WHENEVER SQLERROR EXIT,中间任何一条 SQL 报错都会立刻终止;但 gsql 的默认行为是“报错继续”,一条 UPDATE 失败了,后面的 INSERT 照样执行,最终返回码还是 0。这在数据修复类脚本里是灾难级的隐患。所以给 gsql 加-v ON_ERROR_STOP=1应该成为所有 Shell 调用的默认配置,没有例外。
退出码方面也有差异。sqlplus 支持EXIT SUCCESS、EXIT FAILURE、EXIT SQL.SQLCODE这类显式退出码控制;gsql 的\q命令不带退出码参数,脚本执行完毕后返回 0。想判断 gsql 是否真的成功,依靠的就是ON_ERROR_STOP触发非零返回。换句话说:sqlplus 的退出码是“主动声明”的,gsql 的退出码是“错误触发”的。Shell 脚本里的判断逻辑要按这个思路重写。
2.3 输出格式的差异与好消息
sqlplus 的报表输出需要一堆SET命令组合才能控制干净,常见组合是:
SET PAGESIZE 0 SET HEADING OFF SET FEEDBACK OFF SET TRIMSPOOL ON SET LINESIZE 500gsql 把这个过程简化了很多。命令行层面直接加三个参数就能拿到最干净的纯数据输出:
gsql -q -A -t -f query.sql-q去掉欢迎信息和 SQL 回显;-A关闭对齐,去掉表格边框的竖线;-t只输出查询结果元组,不带列头和行数统计。
这三个组合是替代 sqlplus 报表输出最核心的手段。如果你的 SQL 文件里还有SPOOL /path/file.csv这类命令,在 gsql 里不需要了——直接在 Shell 层用>重定向到文件,效果一样,还省去了SPOOL OFF的配对维护。这是移植过程中最值得做的简化操作。
3. Shell 脚本统一改造:封装一个双引擎执行函数
搞清楚差异之后,我没有一头扎进脚本堆里逐个改,而是先封装了一个统一的 Shell 函数。这样改造成本最低:外层脚本只改一行调用,SQL 文件按需做兼容性调整,未来的回滚和双跑验证也都有了一个稳定的入口。
3.1 改造原则:先隔离差异,再批量替换
我给自己定的原则很简单:所有连接参数、输出参数、错误处理逻辑,全部收敛到一个函数里,业务脚本只负责传入“用哪个引擎、执行哪个文件、输出到哪”。这样做的好处有几点:
- 参数统一管理,不会出现这个脚本忘了加
-q、那个脚本忘了加ON_ERROR_STOP的情况。 - 如果想要切回 Oracle 做双跑验证,只需要把函数里的
engine参数从gaussdb改成oracle,业务脚本完全不用动。 - 后续如果 GaussDB 的 gsql 版本升级导致参数有变化,改一个函数比改几十个脚本省事得多。
封装本身不复杂,核心是把这个函数写得足够稳。
3.2 一个可以直接抄走的统一执行函数
下面这个函数是我在实际迁移中用的版本,你可以直接拿过去改改参数名就能用:
#!/bin/bash # lib_exec_sql.sh —— 数据库 SQL 文件统一执行函数 # 从配置文件加载连接参数 source /etc/db_migrate/db_env.sh run_sql_file() { local engine="$1" # oracle 或 gaussdb local sql_file="$2" local out_file="$3" local biz_date="$4" # 业务日期,透传给 SQL 内变量 shift 4 case "$engine" in oracle) sqlplus -S -L "$ORA_USER/$ORA_PASS@$ORA_SID" "@$sql_file" "$biz_date" > "$out_file" 2>&1 return $? ;; gaussdb) export PGPASSWORD="$GS_PASS" gsql -h "$GS_HOST" -p "$GS_PORT" -U "$GS_USER" -d "$GS_DB" \ -q -A -t \ -v ON_ERROR_STOP=1 \ -v biz_date="$biz_date" \ -f "$sql_file" > "$out_file" 2>&1 return $? ;; *) echo "unknown engine: $engine" >&2 return 2 ;; esac }调用方式统一为:
run_sql_file gaussdb /opt/scripts/daily_stat.sql /data/out/stat.csv 20240101几个参数设计的考虑:
biz_date通过-v biz_date=20240101传进 gsql,SQL 文件里用:biz_date引用,替代原来 sqlplus 的&1。这个变量名你完全可以按自己的习惯调整,但建议全项目统一。- 输出用
> "$out_file" 2>&1统一捕获,既拿到结果,也拿到错误日志。排查问题的时候,错误信息和结果在同一个文件里,定位非常方便。 - 函数末尾
return $?把 sqlplus/gsql 的退出码原样返回给外层脚本,业务脚本里的if [ $? -eq 0 ]判断逻辑基本不用改。
3.3 改造实战:一份日终报表脚本的前后对比
拿一个典型的日终报表脚本举例。改造前的 Oracle 版本如下:
#!/bin/bash DB_USER=scott DB_PASS=tiger DB_SID=orcl SQL_FILE=/opt/scripts/nightly_report.sql OUT_FILE=/data/reports/report_$(date +%Y%m%d).csv sqlplus -S $DB_USER/$DB_PASS@$DB_SID @$SQL_FILE > $OUT_FILE if [ $? -eq 0 ]; then echo "$(date '+%F %T') report success" else echo "$(date '+%F %T') report failed" >&2 exit 1 fi对应的nightly_report.sql是:
SET PAGESIZE 0 SET HEADING OFF SET FEEDBACK OFF SET TRIMSPOOL ON SET LINESIZE 500 SPOOL /dev/null SELECT 'store_id,order_cnt,sales_amount' FROM DUAL; SPOOL OFF SELECT store_id || ',' || order_cnt || ',' || sales_amount FROM daily_sales WHERE stat_date = TO_DATE('&1', 'YYYYMMDD'); EXIT SUCCESS迁移到 GaussDB 后,Shell 脚本改成:
#!/bin/bash source /etc/db_migrate/db_env.sh export PGPASSWORD="$GS_PASS" SQL_FILE=/opt/scripts/nightly_report.sql OUT_FILE=/data/reports/report_$(date +%Y%m%d).csv BIZ_DATE=$(date +%Y%m%d) gsql -h "$GS_HOST" -p "$GS_PORT" -U "$GS_USER" -d "$GS_DB" \ -q -A -t \ -v ON_ERROR_STOP=1 \ -v biz_date="$BIZ_DATE" \ -f "$SQL_FILE" > "$OUT_FILE" 2>&1 if [ $? -eq 0 ]; then echo "$(date '+%F %T') report success" else echo "$(date '+%F %T') report failed" >&2 exit 1 fiSQL 文件改成:
SELECT 'store_id,order_cnt,sales_amount'; SELECT store_id || ',' || order_cnt || ',' || sales_amount FROM daily_sales WHERE stat_date = TO_DATE(:biz_date, 'YYYYMMDD');注意这里删掉了SPOOL /dev/null和SPOOL OFF这一对命令。原来在 sqlplus 里写SPOOL /dev/null是为了把开头的 SQL 回显拦掉,现在-q已经把回显消掉了,SPOOL 就没有存在意义了。FROM DUAL也顺手删了,GaussDB 查询常量可以直接SELECT 'xxx'。
如果使用前面封装好的统一函数,外层脚本更简洁:
source /opt/scripts/lib/lib_exec_sql.sh run_sql_file gaussdb /opt/scripts/nightly_report.sql \ /data/reports/report_$(date +%Y%m%d).csv "$(date +%Y%m%d)" if [ $? -eq 0 ]; then echo "$(date '+%F %T') report success" else echo "$(date '+%F %T') report failed" >&2 exit 1 fi4. 改造时踩过的四个坑:从错误堆栈到输出解析
这一章写的都是我在真实迁移中踩过的坑。有的坑是跑批到凌晨两点才暴露的,有的坑是下游数据分析团队找上门才发现的。写出来希望你能绕过。
4.1 为什么 ON_ERROR_STOP 是最容易被漏掉的参数
第一次改造完成后,我跑通了一个 UPDATE 类脚本,返回码是 0,日志显示“执行成功”。但我随手翻了翻输出文件,发现里面有一条 SQL ERROR 的记录——也就是说 SQL 文件中间其实报了一个错,后面的语句继续执行了,最后退出码却是 0。
这就是 gsql 默认行为最坑的地方。sqlplus 的WHENEVER SQLERROR EXIT是写在 SQL 文件里的,而 gsql 的ON_ERROR_STOP必须在启动时传入,两条路完全不同。如果你只是把sqlplus @file.sql机械地换成gsql -f file.sql,这个坑百分百踩。
血的教训是:-v ON_ERROR_STOP=1必须作为 gsql 启动参数的标配。要么写进封装函数,要么写进 Shell 脚本的统一变量。不要指望每个人都能记住这条。
另外要注意,ON_ERROR_STOP只对 SQL 执行错误有效,对 gsql 元命令的报错不一定都生效。所以我在封装函数里不仅加了-v ON_ERROR_STOP=1,还加了-q -A -t,并在函数注释里写明这三件套缺一不可。
4.2 输出文件里多了东西:回显、Banner 与格式差异
迁移后的第一版报表文件,打开一看,前面多了一大段文本,包括 gsql 的版本信息、SQL 语句原文,以及结果列头。下游同事拿着这个文件直接去解析,第一行就报了格式错误。
根源是两个:
- gsql 执行
-f时会默认回显 SQL 文本,sqlplus 默认不回显; - 不加
-q时,gsql 在开始阶段会打印 banner 和连接信息。
解决方式就是-q -A -t三件套。但还有一个细节:如果你用-c执行单条 SQL,加上-t会去掉列头;如果你用-f执行 SQL 文件,文件里的SELECT也会受-t影响。这很好,但要提醒你:-t去掉列头后,如果某个 SQL 文件是专门用来生成表头报告的(比如我上面例子里的第一行SELECT 'store_id,order_cnt,sales_amount'),仍然能正常输出,因为这条结果本身就是数据。
另外一个格式差异是 NULL 值的显示。sqlplus 中 NULL 默认显示为空白,gsql 中默认也是空白,看起来一致。但如果 SQL 里用了NVL或COALESCE,注意 GaussDB 中空字符串和 NULL 的语义与 Oracle 有细微差别,尤其在做字符串拼接时,NULL || 'abc'在 Oracle 里是abc,在 GaussDB 里结果是 NULL,这种差异非常隐蔽,建议在 SQL 改写时统一用COALESCE显式处理。
4.3 中文乱码:又一个字符集问题
报表文件里有中文,Oracle 环境下正常,切到 GaussDB 后导出文件打开全是乱码。排查过程并不复杂:先确认数据库本身的字符集,再确认 Shell 终端和客户端的NLS_LANG或client_encoding是否一致。
GaussDB 默认 UTF8,而生产环境里不少老的 Oracle 库为了兼容历史业务用的是 GBK/ZHS16GBK。sqlplus 能正常显示中文,是因为它从NLS_LANG拿了字符集;gsql 则更依赖数据库和客户端的编码设置。
处理方法:
export PGCLIENTENCODING=UTF8或者在 gsql 连接时加参数指定客户端编码。如果数据库本身是 GBK,而你要导出 UTF8 的文件,可以做一次显式转换:
iconv -f GBK -t UTF8 input.csv > output_utf8.csv这个坑不大,但容易在联调时被人忽略,建议写进迁移自查清单里,和端口、权限一起检查。
4.4 残留的 Oracle 语法在 gsql 下挣扎
Shell 脚本层面改造完,SQL 文件里的 Oracle 专有语法也得清理。这块我整理了一批高频出现的兼容性差异:
| Oracle 写法 | GaussDB 建议写法 | 说明 |
|---|---|---|
SYSDATE | CURRENT_TIMESTAMP/now() | GaussDB 兼容SYSDATE但不推荐 |
SELECT 1 FROM DUAL | SELECT 1 | GaussDB 支持 DUAL,但无谓的 DUAL 最好删掉 |
ROWNUM = 1 | LIMIT 1 | 分页/取首行逻辑重写 |
CONNECT BY层级查询 | WITH RECURSIVE递归 CTE | 改写工作量较大,需要认真测 |
NVL(a, b) | COALESCE(a, b) | GaussDB 兼容 NVL,但 COALESCE 更通用 |
''和 NULL 混用 | 显式区分 | Oracle 中空字符串即 NULL,GaussDB 中两者不同 |
(+)外连接 | LEFT JOIN | 老 SQL 常用,需人工改写 |
这些语法问题爆发的时间点不固定。SQL 里没有它,执行顺利;一遇到边界数据,比如某列为空触发NVL逻辑,结果就不对了。所以我在迁移前加了一个步骤:把所有 SQL 文件里的SYSDATE、ROWNUM、CONNECT BY、(+)、NVL这些关键词全部grep出来,提前人工评审,而不是等报错再处理。
5. 回归验证与灰度切换:确保迁移没有跑偏
改造完成不代表迁移完成。Shell 脚本的改造风险在于:一件事看起来跑通了,但输出结果和原来不一致,可能是一列数据没查出来,也可能是数字格式变了但肉眼看不出来。所以我要做差异化的双跑验证。
5.1 双库双跑,结果文件的标准化比对
我的做法是准备一套相同的测试数据,分别灌到 Oracle 测试库和 GaussDB 测试库,然后让同一个业务脚本在两个引擎下各跑一遍,比较输出文件。
但直接 diff 几乎不可能通过,因为两个工具的默认输出还是有一些细微差异。我的标准化步骤是:
- 去掉文件首尾的空白行和空行:
sed -i '/^\s*$/d' file.csv - 统一行尾符号:
dos2unix或sed -i 's/\r$//' - 去掉行内所有空白字符做纯文本比对:
tr -d '[:space:]'
第三步最实用。报表里的数字、中文经过tr -d去空格后,只要内容一致,diff 就是干净的。当然,这只能保证“文本一致”,要保证“语义一致”,还得做数据聚合校验,比如对两个库的报表结果分别做COUNT(*)、SUM(金额),比对聚合值。
对数据修复类脚本,我还额外做了一步:执行前导出受影响表的全量快照,执行后对比变更行数、变更前后的 SUM。这一步很笨,但能最大程度避免“UPDATE 执行成功但影响行数不对”这种问题。
5.2 分批灰度切换的顺序与回滚策略
就算双跑验证通过,我也不建议一次性把所有脚本切换过去。生产环境的数据分布和测试库不一样,某些 SQL 在测试库跑得飞快,到了生产环境可能因为数据倾斜直接走全表扫。
我采用的顺序是:
- 第一批切只读查询类脚本,比如报表、统计查询。这些脚本即使出问题,影响也仅限读操作,不会污染数据。
- 第二批切 DML 类脚本,比如 UPDATE、DELETE、INSERT 的批处理。这批必须先确认
ON_ERROR_STOP生效、事务提交方式正确。 - 第三批才切 DDL 和涉及事务多步操作的脚本。这类脚本影响最大,要配合数据库侧的审计日志做观察。
每一批都预留回滚按钮:封装函数里engine参数由gaussdb改回oracle,重新跑一遍输出,对比一下就知道是不是 GaussDB 侧的问题。这个双引擎设计让回滚成本几乎为零,这也是我在第三章坚持封装统一函数的原因——它不单是为了改造方便,更是为了给运维留一条后路。
灰度期间我还会盯几个关键指标:脚本执行时长、日志中的错误数、输出文件行数是否稳定。如果某天凌晨的批处理执行时间比 Oracle 时代突然翻倍,即使结果没问题,也要查一下是不是执行计划走了全表扫。
经历过这次迁移之后,我最大的体会是:数据库切换的难点从来不在“连接方式变了”,而在那些被默认行为掩盖住的隐性差异。sqlplus 和 gsql 的表面对齐只是第一步,把错误处理、输出格式、变量传递、字符集这些细节逐一敲实,脚本才能真正在凌晨三点安稳地跑完。上面这套封装和验证方法,我已经沉淀到团队公共脚本库里,后续再遇到其他数据库的 CLI 工具替换,直接复用同样的思路就能少走很多弯路。