SQL Server迁移实战:存量T-SQL批处理兼容性排查与改造
SQL Server数据库迁移是很多老项目绕不过去的一道坎,尤其当系统版本号已经走到V9R4C019这种程度,意味着它背后沉淀了大量历史逻辑。这次迁移的核心难点不在数据搬迁,而在那批运行了好几年的存量T-SQL批处理能不能在新环境里原样跑起来,或者说,能不能以最小的代价把行为和性能都对齐。这篇文章就从头梳理一遍我接手这类迁移时的方法和踩过的坑。
V9R4C019这套系统在我接触时,已经积累了上千个存储过程、几百个定时作业,以及一堆散落在应用层和运维脚本里的T-SQL批处理。迁移目标是从老版本SQL Server搬到新环境,实例版本、排序规则、兼容性级别全部要重新对齐。真正拆解之后才发现,“把数据库搬过去”永远只是第一步,如何让存量T-SQL批处理在新环境下既快又稳地运行,才是整个项目里最花时间的部分。
1. 迁移前,先搞清楚V9R4C019的T-SQL存量到底难在哪
1.1 存量T-SQL批处理的典型构成
V9R4C019这类运行多年的业务系统,T-SQL批处理通常横跨好几个层级。首先是数据库内部的存储过程、函数、触发器、视图,这些是业务逻辑的核心载体,数量最多,排查工作量也最大;其次是SQL Agent作业里的作业步骤,很多作业步骤本身就是一段完整的T-SQL批处理,它们按既定时间表运行,一旦迁移后行为发生变化,影响往往是周期性的、隐蔽的;再往外是应用服务器上的连接字符串和ORM映射,有些系统喜欢在应用层直接用SqlCommand提交多语句批处理,这类逻辑藏在代码里,静态扫描数据库根本发现不了。
还有一类最容易被忽略,就是部署在运维层的PowerShell脚本、bat批处理文件。这类脚本通常调用sqlcmd或Invoke-Sqlcmd执行一段内嵌SQL,平时跑得好好的,数据库一迁移,可能因为版本升级、客户端工具变化、加密方式调整而直接失败。所以接住存量T-SQL批处理,不只是数据库一个层面的事,需要把整个调用链都拉出来看一遍。
1.2 为什么批处理最容易在迁移中出问题
很多人有个误解,觉得T-SQL是标准SQL的扩展,兼容性应该天然有保障。事实上T-SQL里充满了“约定俗成”的写法,这些写法在某个版本上运行正常,换个版本就可能报错,或者更恶心的是不报错但结果不对。
T-SQL批处理的脆弱性来自几个方面:一是历史版本中允许的语法可能在更高版本中被废弃或收紧,比如FROM OPENROWSET的某些参数写法;二是很多老系统依赖未文档化的系统表,比如直接用sys.sysprocesses、sys.sysindexes,新版虽然没有删除这些视图,但字段含义和返回行为有细微变化;三是批处理里大量使用临时表和表变量,它们的元数据缓存策略在不同版本之间差异很大,直接影响执行计划质量;四是SET选项、事务隔离级别、隐式转换规则这些“环境因素”变化后,可能导致同一个批处理跑出完全不同的执行计划。这些问题叠加在一起,就构成了迁移中最棘手的技术债。
2. 版本迁移中最容易翻车的五个T-SQL差异点
2.1 兼容性级别:一键切换的真相和隐藏问题
兼容性级别是迁移中最基础也最容易被误用的一环。以常见的2008 R2到2019/2022迁移为例,老库的兼容性级别通常是100,新库默认是150或160。理论上,你可以把新库的兼容性级别设成100,让老批处理沿用老优化器行为,但这里有个大坑:兼容性级别只影响查询优化器、部分语法行为和部分内置函数的默认值,它不能改变数据库引擎本身的执行机制。
举个实际例子,某段批处理在2008 R2上依赖QUERY_OPTIMIZER_COMPATIBILITY_LEVEL_100下的连接顺序选择,把兼容性级别调回100后,查询计划确实回到老样子,但底层存储引擎、锁管理器、内存分配器的行为已经完全不同,性能特征依然会变。再比如字符串截断报错,从2019开始STRING_AGG和某些隐式转换行为受数据库兼容性级别影响,但CONCAT_NULL_YIELDS_NULL这类SET选项默认值不再完全由兼容性级别控制。
所以正确做法是:兼容性级别是第一个对齐项,但不能指望它一劳永逸,还得继续做逐条批处理的验证。老库迁移建议先设置成与新实例匹配的较高兼容级别,然后通过查询存储来发现回归,而不是一开始就降级——降级只是临时止血,不是长久之计。
2.2 系统表与未文档化存储过程的坑
老系统特别喜欢直接用系统表,因为方便、直观,当年写代码的人根本没想到有一天要迁移。比如sys.sysprocesses里有个sql_handle字段,配合sys.dm_exec_sql_text可以拿到正在运行的SQL文本,很多监控批处理都这么写。新版本里sys.sysprocesses还在,但微软明确建议用sys.dm_exec_sessions和sys.dm_exec_requests替代,而且有些列在新版本里返回NULL,比如last_batch、open_tran在新的DMV里语义不完全一致。
我遇到过最典型的一个案例:一段老监控批处理用sys.sysindexes统计索引碎片,老版本里rows字段直接可读,新版本里同样语句返回结果也正常,但某些分区索引场景下统计口径变了,导致碎片率忽高忽低,DBA排障时被误导了很久。
处理原则很简单:凡是直接查sys.sys*的批处理,一律重写。虽然不重写通常也不会报错,但语义漂移的风险太高,不值得留。
2.3 查询优化器行为变化:从CE 70到CE 150/160
这是所有差异点里最隐蔽、最难排查的。基数估计是SQL Server选择执行计划的依据,老库通常用的是旧版CE(Cardinality Estimator),它建立在“数据均匀分布、列间相互独立”的假设之上。新CE修正了很多假设,更贴近真实数据分布,代价是同一个查询在两个版本下会得到完全不同的计划。
对于批处理来说,这种变化可能带来巨大的性能回退。比如一段每晚跑的分页批处理,老CE下走了嵌套循环,新CE下优化器觉得应该走哈希连接,结果内存溢出到tempdb,整个运行时间从20分钟涨到3小时。
遇到这类问题,最有效的排查工具是查询存储。迁移前如果没开查询存储,我建议迁移前至少提前两周打开,抓一批典型批处理的执行计划基线。迁移后一旦出现性能异常,直接把新旧计划拉出来对比,看哪一步的估计行数和实际行数差得离谱,基本上问题就定位了。
2.4 数据类型和隐式转换行为
老系统里常见一种写法:用varchar存日期、用nvarchar存数字。在迁移后的新版本里,这类隐式转换的处理逻辑会有变化,最典型的是算数溢出和舍入行为。
举一个我真实遇到的例子:某段批处理里有一个表达式CONVERT(DECIMAL(10,2), column1) / column2,老版本返回结果是保留两位小数,新版本在某些边界值下直接报“将numeric转换为数据类型numeric的算术溢出错误”。原因是新版本对DECIMAL除法的精度和标度推导规则做了调整,中间结果的精度要求更严格了。这种问题在兼容性级别100下依然会出现,因为它属于数据类型本身的运算规则变化。
排查方法比较笨但有效:把所有涉及数值计算、日期转换、字符串拼接的表达式列出来,用边界值逐条比对。不用全量测,挑每类运算的代表性脚本测一轮就够了。
2.5 分布式查询和链接服务器的行为差异
老库里大量使用链接服务器做跨库、跨实例查询。迁移后,如果链接服务器指向的老实例还在,而客户端工具和网络库变了,链接服务器的默认选项可能发生变化,比如XACT_ABORT、ANSI_NULLS的默认值,老版本是OFF,新版本在特定连接字符串下可能变成ON,直接导致一些批处理传入链接服务器的语句报错。
最典型的场景是链接服务器上的四部分名称查询,比如SELECT * FROM [linkedserver].[db].[dbo].[table]。老版本支持隐式转换和宽松的排序规则匹配,新版本对排序规则一致性的检查更严格,一旦两个库排序规则不一致,直接报“无法解决排序规则冲突”。这种问题表面上看是查询语句的问题,实际是链接服务器配置的问题,需要在链接服务器属性里显式指定排序规则,或者改写查询。迁移清单里必须包含全部链接服务器重新配置和连通性验证,而且验证不能只测能不能连上,要模拟真实查询才会暴露问题。
3. 实操:V9R4C019迁移前,我怎么做T-SQL批处理的兼容性排查
3.1 建立基线:先用Profiler和扩展事件抓取真实负载
迁移前必须知道系统里到底在跑什么。很多批处理不是每次都会执行,有些月度作业几个月才跑一次,靠人肉翻代码根本看不全。我用扩展事件做了一轮持续采集,重点抓三类事件:sql_batch_completed、rpc_completed、sp_statement_completed。跑了一周,拿到所有批处理和存储过程的执行频率、耗时、逻辑读,然后按“执行次数×平均耗时”排了个优先级。
这一步最大的价值不只是找出热门的批处理,而是找出那些“很少跑但一跑就很久”的冷门批处理。这类往往最容易在迁移后被忽略,直到某天月度作业跑挂才被发现。抓完负载后,我把所有采集到的批处理语句导出,按存储过程、作业步骤、应用层动态SQL三类分别存成基线文件。
采集模板大概长这样:
采集期间我还同步抓了性能计数器,特别是SQL Server:Plan Cache的缓存命中率和SQL Statistics: Batch Requests/sec,用于迁移后做整体吞吐对比。
3.2 静态扫描:用脚本找出高风险T-SQL构造
动态采集负责找到“实际在跑什么”,静态扫描负责找到“代码里藏着什么”。我用一段T-SQL脚本扫了全库的模块定义,正则匹配高风险关键字,包括:sys.sysprocesses、sys.sysindexes、OPENROWSET、OPENQUERY、DBCC命令、CHECKPOINT、RAISERROR(老式写法)、SET ROWCOUNT、SELECT INTO、NOLOCK提示、sp_开头的系统存储过程调用等。
扫出来之后,我没有直接改代码,而是先逐条人工确认。有些匹配到的内容其实是注释里的历史记录,有些是字符串里的日志输出,真正需要处理的通常只有六成左右。但有一个原则:凡是出现DBCC命令的批处理,必须逐条验证,因为DBCC不同版本差异极大,比如DBCC CHECKDB新增了很多参数和输出信息,DBCC MEMORYSTATUS这种老命令新版本已经不再有效。
3.3 兼容性级别测试规则
针对兼容性级别,我的测试策略不是拍脑袋设一个,而是分三步走。第一步,在测试环境把数据库恢复到原样,保持原兼容性级别运行全套批处理,确认基线数据正确;第二步,把兼容性级别提升到目标版本对应级别,重跑全套批处理,记录所有报错和性能变化;第三步,针对第二步中出问题的部分,重新分析,判断到底是改代码还是维持低级别。
这里有一个实操经验:不要用ALTER DATABASE直接改生产库的兼容性级别来测试。正确做法是复制一份数据库到测试实例,然后用数据库范围内的ALTER DATABASE ... SET COMPATIBILITY_LEVEL = 150去验证。更好一点的方法是用查询存储的QUERY_STORE配合ALTER DATABASE ... SET QUERY_STORE = ON,在测试环境抓取计划变化。我在测试库上跑了五个版本的兼容性级别组合,拿每个组合下的批处理执行时间做了个矩阵,才最终敲定迁移后的设置。
3.4 用查询存储和计划缓存做回归对比
迁移最怕的是没有对比依据。我在老库迁移前的两周开启了查询存储,并设置了QUERY_CAPTURE_MODE = AUTO,确保只捕获有价值的查询。然后导出一份所有批处理的平均耗时、逻辑读、执行计划hash值。
迁移完成后,在同一业务时段对比新库查询存储里的数据。重点看两类:一是计划hash变化但耗时没有明显改变的查询,这类基本无害;二是计划hash变化且耗时翻倍或逻辑读暴增的查询,这类需要立即处理。遇到后者,我会用sys.dm_exec_query_stats配合sys.dm_exec_sql_text找到具体语句,再用FORCE ORDER、OPTION (RECOMPILE)等手段做针对性修复。
用查询存储做回归对比相比自己写脚本抓性能数据靠谱得多,因为它是引擎级别的,记录的是实际执行情况,不受采样周期影响。
4. 迁移过程里批处理的改造与验证策略
4.1 批处理改造优先级:先解决“不能用”,再解决“跑得慢”
批处理迁移的改造顺序很重要。我的优先级永远是:语法和报错问题优先,性能问题其次,代码规范最后。因为如果一个批处理在新环境下直接报错,数据集就缺失了,业务影响立竿见影;而性能问题虽然让人头疼,但通常还能跑完或绕过去,可以放到稳定运行后再优化。
按这个优先级,我把V9R4C019里的批处理分成四类:A类是直接报错的,必须立即改;B类是不报错但结果和旧环境不一致的,必须重点验证;C类是能跑但性能明显变差的,标记为待优化;D类是完全正常无需改动的。实际统计下来,A类占两成左右,B类占比接近三成,C类和D类各占剩下的一半。挡在迁移路上的主要是A类和B类,C类可以在迁移后的空窗期慢慢收拾。
4.2 事务与批量提交的调整
老系统有一种不太好的习惯,就是把大量数据操作写在一个超大事务里。比如一个批处理里循环插入十万行,外层包一个BEGIN TRAN,跑一整个晚上。这类批处理在迁移后最容易出现锁等待、日志暴涨、tempdb溢出。新版本的锁管理器、版本存储和日志缓冲区行为和旧版本不同,大事务触发的问题会更明显。
针对这类批处理,我的做法是加批处理控制逻辑:将大事务拆成小事务,每几百行或几千行提交一次,同时加上进度日志输出。示例改造前:
改造后,我加了一个分批提交和一个状态表记录进度,这样每个批次独立提交,单个批次失败不影响已完成的数据,日志文件压力也显著下降:
类似的改动,迁移中我做了几十处。虽然单条数据插入的额外开销增加了,但整体稳定性提升非常明显,日志文件大小从之前的无限膨胀变成了可预测增长。
4.3 调度层的适配:SQL Agent作业和外部批处理脚本
数据库迁移后,SQL Agent作业的调度和运行环境也会变化。尤其是老版本SQL Server上创建维护计划时生成的作业,迁移到新版本可能因为SSIS运行时版本不匹配而失败。所以V9R4C019迁移时,我把所有作业脚本化导出,逐个人工审查运行环境和步骤命令。
运维层的bat/PowerShell批处理更麻烦。我发现有大量脚本写死了sqlcmd -S oldserver或者用了老版本的SQLNCLI驱动连接字符串。迁移后,服务器名变了,驱动版本也可能不再支持,导致大批脚本直接连不上库。我的做法是统一写了一个配置化的PowerShell执行模板,所有外层批处理都改成读取配置文件里的连接字符串,再调用Invoke-Sqlcmd执行。这样以后再做环境切换,只需要改配置文件。
这个时期正好用户也关注批处理代码优化,比如批处理命令复制文件、网络延迟优化这类需求。我建议凡是涉及SQL Server的批处理,都尽量通过sqlcmd加参数的方式执行,不要在脚本里硬编码服务器名和密码,也不要依赖bat里嵌入动态拼接的SQL,一旦涉及转义和特殊字符,就是隐患。
4.4 典型改造案例:一段月度汇总批处理的重写
分享一个具体的改造案例。V9R4C019里有一段月度汇总批处理,原来是在游标里逐行计算每个客户的汇总值,再更新汇总表。逻辑本身没问题,但游标逐行更新在旧版本上已经跑得很吃力,迁移后新版本对临时表和集合操作的优化能力更强了,不改就浪费了新引擎的能力。我把它改成了两个阶段的集合操作,第一阶段用SELECT ... INTO把基础数据拉取到临时表,第二阶段用一次UPDATE基于临时表完成汇总更新。
改造后,这段批处理从45分钟跑完压缩到8分钟。这里的关键不是新版本比旧版本快多少,而是原来游标里做了大量重复计算,改写后充分利用了新优化器的无值扫描和临时表物化能力。这类改造不能盲目做,必须先看执行计划,确认瓶颈在游标逐行操作上,再动手。
5. 常见问题与排查技巧实录
5.1 问题速查表
迁移过程中我整理了一张问题速查表,覆盖了高频出现的几类问题,贴在下面供参考:
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 批处理报“对象名无效” | 视图或存储过程依赖了系统表改名 | 检查sys.sql_expression_dependencies解析依赖 |
重写对象引用,替换为新DMV |
| 批处理报“排序规则冲突” | 新旧实例排序规则不一致 | 对比实例级排序规则和列级排序规则 | 统一排序规则,或用COLLATE强制指定 |
| 批处理执行计划突然变慢 | CE版本变化导致计划回归 | 查询存储对比新旧计划 | 用OPTION (USE HINT('FORCE_LEGACY_CARDINALITY_ESTIMATION'))临时兜底,再改代码 |
DBCC命令执行报错 |
老命令在新版本被移除或参数变化 | 查看官方文档对应版本 | 改写为DMV查询 |
| 链接服务器查询提示无法解析 | 分布式查询元数据未刷新 | 在链接服务器上执行sp_serveroption检查 |
刷新元数据,显式指定排序规则 |
| 外部bat调用sqlcmd失败 | 客户端版本或驱动不匹配 | 检查sqlcmd版本和协议支持 | 升级客户端工具,改用较新驱动 |
5.2 一个容易忽略的Bug:SET选项污染导致的“幽灵报错”
有一种报错很诡异,同一段T-SQL批处理,单独执行完全正常,放到作业里执行就报错,或者换了一个连接会话就报错。这类问题的元凶通常是SET选项污染。T-SQL批处理里如果某段代码设置了SET ANSI_NULLS OFF、SET QUOTED_IDENTIFIER OFF等选项,在同一个会话里会影响后续所有语句的解析行为,大部分时候没有问题,但一旦涉及计算列索引或索引视图,就会出现“索引视图不可更新”之类的奇怪报错。
迁移后我遇到的实际情况是:某段批处理依赖一个索引视图,但作业连接的SET选项和视图定义时的SET选项不一致,导致所有基于该视图的查询走不到索引。排查了很久,最后用sys.dm_exec_sessions一查,发现会话级ANSI_NULLS确实是OFF。解决方案是在批处理开头显式设置正确的SET选项:
这个习惯不仅在迁移时需要,新写任何批处理都应该在前面显式声明SET选项,别依赖默认值。尤其注意存储过程和视图定义时的SET选项,它会影响对象创建后的所有引用。
5.3 回滚与应急方案
即便做了充分测试,迁移后的生产环境依然可能出现预期之外的问题。我的做法是准备两套应急方案:一套是数据库级回滚,用日志备份或者可用性组的方式保证可以快速切回老环境;另一套更实用,是在新环境里搭建一个“兼容性桥接层”,针对问题最集中的几十个批处理,预先改写一版兼容版本,一旦线上出现同类问题,直接调用兼容版本,争取修复时间。
V9R4C019迁移后,我用了一周的观察期,每天晚上对比新老系统当天的批处理执行日志,核对数据结果。前三天问题比较多,主要集中在排序规则和隐式转换上,到第五天基本稳定。一周结束时,我把所有兼容性桥接层的代码整理归档,后续只保留了查询存储监控和定期巡检。
这次迁移给我最大的一个感触是:T-SQL批处理本身不复杂,复杂的是它背后掺杂的历史约定、隐式依赖和环境差异。迁移的成功不是“数据库能连上”,而是“每一段老批处理都能在新环境里继续可靠地承担业务”。有了这一整套排查和验证流程,V9R4C019的批处理迁移才算真正接住了。