我见过太多这样的 HANA 存储过程:打开定义一看,先是七八个游标声明,接着一层 FOR 循环套一层 FOR 循环,循环体里再塞几条 SELECT,最后把结果一行行 INSERT 进临时表。在开发机上跑几千行数据,秒回;一上生产,几十万行就把会话卡死,调用方超时、连接被回收,DBA 那边只看到一条跑了十几分钟的活动语句。写这类代码的人往往是从 ABAP 或者 Oracle 转过来的,行式思维根深蒂固——在 PL/SQL 里逐行处理是常态,在 HANA 里逐行处理是事故。
这个"X档案"系列想聊的,就是那些官方文档里写得很克制、但项目上一定会撞到的细节。这篇围绕 SAP HANA 存储过程开发,把骨架怎么写、为什么 SQLScript 不能当 PL/SQL 用、慢在哪里、怎么查、怎么部署传输、ABAP 侧怎么调用,一条线走完。刚接手 HANA 项目的开发能照着抄作业,写了几年但总觉得过程"跑得不对劲"的人,也能找到几个值得回头检查的点。
1. 为什么 HANA 的存储过程不能照着 PL/SQL 的思路写
1.1 计算下推才是它存在的理由
要理解 SQLScript 的写法,得先理解 HANA 里存储过程存在的意义。传统架构里数据库负责存和取,业务逻辑放在应用服务器上。数据量到了一定规模,瓶颈就出现了:应用要从数据库捞几十万行上来,在内存里算,再把结果写回去。网络传输、结果集序列化、应用层内存占用,每一环都在烧资源,而且数据量翻倍时这些成本是线性甚至超线性增长的。
HANA 的思路是把计算搬到数据旁边——数据不动,逻辑动,这就是计算下推。存储过程是实现下推最直接的手段:把一段多步骤的加工逻辑整体交给数据库引擎,引擎在一个执行计划里统一调度,中间结果只存在于内存中的表变量或临时表,不需要跨进程搬运。理解了这一层,很多写法上的取舍就自动有了答案:任何让你在数据库和应用之间来回搬数据的写法,都是在跟这套架构对着干。
所以判断一段逻辑该不该写成存储过程,我会问三个问题:这段逻辑处理的数据量是不是远大于输出量?它是不是被多个上层应用重复调用?它对延迟是不是敏感?三个都是"是",写过程划算;只要有一条是"否",就该重新考虑放在应用层还是用视图。
1.2 SQLScript、PL/SQL、MySQL 存储过程的三张面孔
很多人第一次写 SQLScript 会觉得很熟悉,"这不就是 PL/SQL 换个语法吗"。跑通第一个 Demo 确实像,但真到复杂场景,两者的差异会立刻把人打回原形。SQLScript 的设计哲学是SQL 为主、过程逻辑为辅,它更像是一组被拆开的 SQL 查询加上少量的控制流胶水,而不是一门完整的编程语言。
| 维度 | HANA SQLScript | Oracle PL/SQL | MySQL 存储过程 |
|---|---|---|---|
| 语言定位 | SQL 优先,过程逻辑少量 | 完整过程语言 | 过程语言 + SQL |
| 表变量地位 | 一等公民,可参与查询 | 集合类型/游标 | 主要靠临时表 |
| 优化方式 | 整体重写、内联、并行 | 逐段执行 | 逐段解释/执行 |
| 事务控制 | 尽量交给调用方 | 可自由 COMMIT | 可自由 COMMIT |
| 返回结果 | 必须声明 OUT 表参数 | REF CURSOR | 直接 SELECT 即结果集 |
| 调试支持 | 有调试器,需授权 | 工具链成熟 | 支持有限 |
这张表里最容易吃亏的是"优化方式"和"返回结果"两行。SQLScript 的优化器会把你写的一串表变量赋值当成一个整体来看,能内联就内联、能合并就合并,所以你写出来的中间步骤未必真的会一步步执行;反过来,如果你的写法让它没法重写(比如混入游标循环),性能就直接塌方。而 MySQL 那边习惯的"在过程里写一句 SELECT,客户端就能收到结果集"在 HANA 里不成立,必须显式声明 OUT 表参数,调用方才能拿到数据,这一点我在跨数据库迁移的项目里见过不止一次翻车。
1.3 哪些逻辑放进存储过程是给自己挖坑
不是所有逻辑都适合下沉。我自己划的红线有这么几条,都是踩过之后加上的。
第一条是状态机式的复杂分支逻辑。SQLScript 有 IF、WHILE、FOR,语法上什么都能写,但一旦分支层级超过三层,可读性和可维护性会断崖式下跌,而且这类逻辑通常无法被优化器重写成集合运算,性能也不会好。这种逻辑老老实实放在应用层,用真正的编程语言写,测试也更好写。
第二条是需要和外部系统实时交互的逻辑。存储过程里没有合适的机制去调外部接口,硬要用动态 SQL 或者外部程序绕,最后得到的是一段没人敢改的黑盒。第三条是频繁变更的业务规则,比如费率、阈值、映射表。把这类规则硬编码在过程里,意味着每次调整都要走一遍传输流程,业务方等不起。
注意:判断标准不是"这段逻辑能不能写进过程",而是"写进去之后改动成本、排查成本、性能收益三者是不是平衡"。能跑通从来不是理由。
2. 一个能在生产上活下来的过程骨架
2.1 参数签名、SQL SECURITY 与读写声明
先看骨架。下面这个结构我用了很多年,改动最多的只是中间的加工逻辑:
CREATE OR REPLACE PROCEDURE "ZSCM"."P_CALC_STOCK_AGE" ( IN IV_BUKRS NVARCHAR(4), IN IV_DATE DATE, OUT ET_RESULT TABLE (MATNR NVARCHAR(40), WERKS NVARCHAR(4), AGE_DAYS INTEGER) ) LANGUAGE SQLSCRIPT SQL SECURITY INVOKER READS SQL DATA AS BEGIN DECLARE lv_date DATE := IFNULL(:IV_DATE, CURRENT_DATE); lt_stock = SELECT MATNR, WERKS, SUM(MENGE) AS MENGE FROM "ZSCM"."STOCK_DETAIL" WHERE BUKRS = :IV_BUKRS GROUP BY MATNR, WERKS; ET_RESULT = SELECT MATNR, WERKS, DAYS_BETWEEN(MIN_BUDAT, :lv_date) AS AGE_DAYS FROM :lt_stock; END;几个细节值得单独说。参数名加前缀(IV_、EV_、ET_、IT_)不是审美问题,是因为 HANA 里标量变量和表变量同名冲突、参数和局部变量同名遮蔽,都是真会报错的场景,前缀能让这类问题在写的时候就被眼睛拦下来。参数没有默认值,这一点跟很多数据库不一样,别想着写IN IV_DATE DATE DEFAULT CURRENT_DATE,得在过程体里用 IFNULL 之类兜底。
SQL SECURITY这行建议永远显式写出来。不写会走版本默认值(历史上默认是 DEFINER),而 DEFINER 意味着过程以创建者的权限运行,绕过调用者自己的权限检查,安全上是个大开口子。READS SQL DATA这类读写声明也别省略:纯读取的过程一定要标 READS,标了之后引擎才能放心做优化,而且带 READS 声明的过程里不允许出现事务控制语句,这个限制其实是帮你守规矩。
2.2 表变量、标量变量与作用域的几个硬规矩
表变量是 SQLScript 区别于其他数据库过程语言的灵魂。它不是临时表,更像一个"命名好的查询结果",用法上要记住这几条:
- 引用时必须带冒号前缀,
SELECT * FROM :lt_stock,写漏了会当成物理表去找然后报对象不存在。 - 列名重复会直接报错。
SELECT a.MATNR, b.MATNR FROM ...这种在别的数据库可能只是列名冲突,在 HANA 里是硬错误,必须用别名区分。 - 声明必须集中在 BEGIN 之后、任何可执行语句之前,不能写到一半再 DECLARE。
- 中间步骤写
ORDER BY是不保证顺序的。要保证最终顺序,把 ORDER BY 放在最后一步,或者交给调用方处理。
什么时候用表变量、什么时候用局部临时表(#开头的会话级临时表),我的经验分界线是数据量和复用次数。表变量适合小结果集和逻辑分段,好处是不占元数据、不需要清理、优化器看得更透;一旦中间结果动辄几百万行、或者要被多个步骤反复 JOIN,就换临时表——它是真实的物理对象,有统计信息,可以建索引,优化器能更准确地估算。
提示:较新的 HANA 2.0 版本支持对表变量直接做 INSERT/UPDATE/DELETE,但很多老项目和历史环境不支持,只能靠重新赋值来"改写"。如果你的代码要跨多套环境交付,中间结果要反复改写的场景直接用临时表更稳。
2.3 异常处理:让调用方拿到确定的错误语义
生产上的过程必须处理异常,但处理方式要克制。我一般只在过程最外层包一个出口处理器:
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ET_RESULT = SELECT '' AS MATNR, '' AS WERKS, 0 AS AGE_DAYS WHERE 1 = 0; RESIGNAL; END;这里的关键是RESIGNAL。很多人的写法是捕获异常之后打个日志、返回空结果,调用方拿到空结果以为"没数据",实际上是出错了,问题就这样被吞掉几周。正确做法是把原始错误往上抛,让调用方拿到真实的 SQLSTATE 和错误码,同时保证输出参数是个结构完整的空集合,避免调用方解析空对象时再崩一次。
业务规则上的"错误"要不要用异常,我倾向于用返回码字段而不是 SIGNAL。比如"该工厂未配置库存规则"这种事,属于预期内的分支,用 SIGNAL 抛出去会让整套调用链上的人都收到告警;在输出表里带一个状态码和一列消息文本,反而更好处理。只有真正不该发生的情况——比如参数为空的必填项、数据完整性被破坏——才用 SIGNAL 中断。
2.4 结果集返回的两种范式
HANA 过程返回数据只有一条路:声明 OUT 表参数。但组织方式上我常用两种范式,选择取决于数据量和调用方习惯。
第一种是纯结果集范式,过程只负责加工,输出一张宽表,逻辑全部在过程内闭环。适合报表取数、批处理任务,调用方拿到就能用。第二种是状态 + 数据双输出范式,除了数据表,再加一个只有一两行的状态表或标量 OUT 参数,用来回传影响行数、处理批次、跳过条数这些信息。批处理场景下第二种更实用,因为出现部分成功的时候,运维需要知道到底处理到了哪一步。
要提醒的是,一个过程声明多少个 OUT 表参数都行,但每个都会在调用链上产生一次结果集反序列化的开销。如果一个过程输出了七八张表,八成是设计上出了问题,该拆成多个过程,或者该用计算视图/ABAP CDS 来组合。
3. 把行式思维改成集合式:性能分水岭就在这一步
3.1 一段游标循环的实测改写全过程
我接手过一段库存账龄计算的过程,客户反馈"月初跑批要两个小时"。原始逻辑简化后长这样:
-- 改写前:典型的行式写法 DECLARE CURSOR c_mat FOR SELECT DISTINCT MATNR FROM "ZSCM"."STOCK_DETAIL" WHERE BUKRS = :IV_BUKRS; FOR row AS c_mat DO SELECT SUM(MENGE) INTO lv_menge FROM "ZSCM"."STOCK_DETAIL" WHERE BUKRS = :IV_BUKRS AND MATNR = row.MATNR; INSERT INTO #tmp_result VALUES (row.MATNR, lv_menge); END FOR;问题非常典型:外层取所有物料,内层对每个物料扫一次明细表。物料有八万多个,等于把一张两千万行的表扫了八万遍。即使有索引,八万次游标推进、八万次语句调度、八万次结果集构造的开销是省不掉的——这些开销跟数据量无关,纯粹是"次数"带来的。
改写后的版本把所有逻辑压成一条聚合:
-- 改写后:一次集合运算 lt_result = SELECT MATNR, WERKS, SUM(MENGE) AS MENGE FROM "ZSCM"."STOCK_DETAIL" WHERE BUKRS = :IV_BUKRS GROUP BY MATNR, WERKS; ET_RESULT = SELECT * FROM :lt_result;实测下来,同样的数据量从两小时降到四十几秒。差别不在算法,在于执行次数从八万次变成一次。这是我在 HANA 性能优化里反复验证过的规律:绝大多数慢过程的第一根因不是"SQL 写得不够好",而是"SQL 被执行了太多次"。
3.2 UNNEST、JOIN 与窗口函数替代逐行累加
不是所有游标都能这么简单地压成一条聚合。有些场景是"对每个元素做一件稍有不同的事",这时候需要换个思路。
如果循环是在处理一个参数列表,比如"对传入的十个工厂分别计算",正确做法是用 UNNEST 把数组展开成表,然后用 JOIN 把计算逻辑一次性套上去,而不是循环十次。如果是逐行的累加、排名、找上一行,用窗口函数(OVER、PARTITION BY、ROWS BETWEEN)替代变量自增的写法,不但快,而且逻辑更容易验证。如果是逐行判断后写入不同目标,用带条件表达式的 INSERT ... SELECT 一次完成,把判断条件写进 CASE 或者 WHERE 里。
真正没法集合化的场景只剩下极少数:需要调用另一个有副作用的存储过程、需要按顺序做带外部依赖的动作。遇到这种,我的做法是把循环范围压到最小——外层集合化,只对真正需要逐个处理的少数对象开循环,并且把循环体里的逻辑做到最薄。
3.3 表变量内联与物化时机:优化器帮倒忙的时候
SQLScript 优化器有一个反直觉的行为:它可能把你在过程里定义的中间表变量内联掉,直接展开到下游语句里。多数时候这是好事,少了一次物化、少了一次扫描。但有两种情况会变成麻烦。
一种是某个中间结果被多条下游语句共用,内联之后等于每条下游语句都重算一遍,总成本反而上升。另一种是中间结果本身有很强的过滤效果,先算出来再 JOIN 能大幅减少下游数据量,内联之后过滤条件被推到了后面,执行顺序被改写。这两种情况需要通过执行计划确认,必要时用 hint 干预内联行为。要提醒的是,不同版本支持的 hint 名称和语义不完全一致,动手前先在测试库上用执行计划验证效果,别直接照抄网上的 hint 写法。
3.4 列裁剪为什么比加索引还管用
有一个几乎零成本、收益又特别明显的优化点:只取需要的列。我见过太多中间步骤写着lt_tmp = SELECT * FROM big_table;,下游只用了其中三列。列存数据库的最大优势是按列读取,SELECT *等于把这个优势直接扔掉。
同样重要的还有三件事。一是确认表真的在列存里,通过系统视图检查表的存储类型,如果关键大表被建成了行存,后面所有优化都是白费力气。二是确认分区裁剪生效,分区表查询如果没带分区键,会把所有分区扫一遍,执行计划里能看出来。三是避免隐式类型转换,参数声明成 NVARCHAR 却拿去和 DATE 字段比较,可能让索引和分区裁剪同时失效,这类问题在拼接字符串查询的条件下尤其常见。
4. 跑得慢怎么查:从执行计划到语句级监控
4.1 EXPLAIN PLAN 与 PlanViz 的正确读法
排查慢过程的第一步是拿到执行计划。单条语句可以直接用 EXPLAIN PLAN 看,过程整体则要用计划分析工具,它会生成可视化的计划树。看计划重点看三件事。
第一是估算行数和实际行数的偏差。如果某个算子估算一百行、实际出来一百万行,说明优化器对数据分布判断错了,后面的算子选择基本都不靠谱,往上找是哪张表的过滤条件没被正确识别。
第二是有没有出现不该出现的算子。比如行式引擎相关的算子混进来,通常意味着链路上有行存表或者有无法集合化的逻辑;再比如循环、游标相关的算子,说明代码里还有逐个处理的结构没被消除。
第三是中间结果的大小变化。找到数据量最大的那个中间节点,那通常就是优化收益最大的地方——要么提前过滤,要么改变 JOIN 顺序,要么把它物化下来避免重复计算。
注意:计划分析会生成体积不小的跟踪文件,在生产系统上开之前先确认磁盘空间和影响范围,用完及时关掉。这事跟改配置一样,是要留下痕迹的。
4.2 存储过程调试器的可用边界
HANA 的调试器能单步执行、查看变量、观察表变量内容,排查逻辑错误非常有效。但它有几个边界得先搞清楚,不然会白折腾半天。
调试需要相应权限,生产环境还需要显式开启调试会话并获取调试令牌,这个动作会留下审计记录,动手之前最好跟负责基础架构的同事打个招呼。调试会占用独立会话,本来并发就紧张的系统上要挑时间。最容易被忽略的一条是:带加密选项创建的过程无法调试,也无法查看定义,如果代码是从别人手里接过来并且加密了,只能让原团队重新提供未加密版本。
调试器也不是性能工具。它能告诉你逻辑对不对,但告诉不了你哪个算子慢。逻辑和性能要分开查,这两件事的工具链不重合。
4.3 慢因对照表与各自的处置方式
下面这张表是我这些年排查积累下来的速查表,出现对应现象时可以直接往根因上靠。
| 现象 | 常见根因 | 处置方向 |
|---|---|---|
| 小数据量秒回,大数据量爆炸 | 游标逐行循环 | 改写为 JOIN / 集合运算 |
| 执行出现行式算子 | 链路中有行存表 | 检查并迁移到列存 |
| 中间步骤反复被扫描 | SELECT *,无列裁剪 | 只取必要列 |
| 分区表查询很慢 | 未带分区键,全分区扫描 | 补上分区键条件 |
| 每次调用稳定地慢 | 编译或优化耗时长 | 看计划缓存的命中情况 |
| 偶发超时 | 并发、锁等待、内存 | 看昂贵语句与阻塞视图 |
| 只读过程也变慢 | 参数类型不匹配导致转换 | 统一参数与字段类型 |
用法上有个小技巧:先看现象属于哪一类,再去看对应的系统监控视图。昂贵语句视图能告诉你哪条语句累计消耗最大,计划缓存视图能告诉你语句是不是被重复编译,这两个视图配合起来,排查效率比漫无目的地开跟踪高得多。
5. 部署、传输与权限:代码离开开发机之后的事
5.1 HDI 容器与 XS Classic 仓库的两条路线
存储过程的部署方式取决于你的系统版本和架构。老一代是 XS Classic 的仓库模式,过程作为源文件放在指定包路径下,激活后进入系统,传输通过交付单元配合变更管理系统完成。新一代是 HDI 容器模式,过程是一个独立的部署文件,放在容器项目里,靠构建工具部署,依赖关系由框架自动解析。
HDI 相比老模式最大的好处是依赖自动解析和容器隔离:你不用再手工维护"先建表类型再建过程"的顺序,构建工具会算好拓扑顺序;不同应用的对象在各自容器里,命名冲突少了很多。代价是部署流程变了,以前激活一下就能用的东西,现在要走构建命令或者流水线。
在 S/4HANA 侧还有个更省事的选择:用 AMDP 把过程挂在 ABAP 类的方法上,代码跟着 ABAP 传输走,和业务对象同生命周期。这个后面单独讲。
5.2 依赖顺序与重建时的对象失效
不管走哪条路线,有一类问题一定会遇到:被依赖对象重建后,依赖它的过程失效。典型场景是表类型改了字段,所有引用这个表类型的过程都需要重新编译,否则调用时会报错。在 HDI 模式下这个问题被框架挡住了大半,在 XS Classic 模式下就得靠人记住。
我的应对办法是在包里维护一份依赖清单,表类型和共享函数单独建包,不要在业务包之间互相引用。除此之外,过程重建时如果涉及参数签名变化(加参数、改参数类型、改参数顺序),一定要同步通知所有调用方,包括 ABAP 侧、其他过程、以及可能存在的作业调度配置。参数变更在 HANA 里不是向后兼容的,调用方会直接报参数个数或类型不匹配。
5.3 动态 SQL 与 SQL 注入的防线
过程里难免有需要动态拼接的场景,比如按传入的日期区间动态构造条件、按配置决定查哪张分表。动态 SQL 在 HANA 里通过 EXEC 或 EXECUTE IMMEDIATE 执行,这里的风险点和 Web 应用里完全一样。
第一道防线是用参数绑定而不是字符串拼接。新的版本支持在动态语句里用占位符绑定值,能用就一定用,值永远不要拼进 SQL 文本里。第二道防线是标识符白名单。表名、列名这类没法绑定的部分,如果必须动态,就在过程里做一次明确的合法性校验,只允许配置表里存在的对象名通过,绝不接受调用方传进来的任意字符串。第三道防线是拒绝把外部输入直接当对象名,这一条听起来像常识,但在业务系统里各种"灵活配置"的需求下,破例的次数远比想象中多。
5.4 权限设计:DEFINER 该不该用
回到前面提过的 SQL SECURITY。INVOKER 模式下,调用者必须有访问过程内部所有对象的权限,过程本身只是一段被授权的逻辑。DEFINER 模式下,过程以创建者身份运行,调用者只要拿到过程的执行权限就能间接访问底层数据表,不需要被单独授权。
DEFINER 不是不能用,它的正当场景是有意的权限封装:把底层明细表锁死,只通过几个精心设计的过程对外提供聚合结果。但在业务系统里它经常被当成"省事"的手段——授权报错,改成 DEFINER 就好了。代价是权限边界变得模糊,谁通过哪条路径能看到哪些数据,事后很难审计清楚。
我自己的原则是默认 INVOKER,把权限通过角色和权限对象正经授到位;确实要做数据封装的时候才用 DEFINER,并且把这个决定写进设计文档。另外,授权的时候我倾向于把执行权限授给角色而不是用户,员工的岗位一变动就改角色分配,过程这边不用动。
6. 从 ABAP 到 HANA:AMDP 与直接调用过程的选型
6.1 AMDP 适合什么样的活
AMDP 的写法是把 SQLScript 直接写在 ABAP 类方法里,方法声明时指明目标数据库和语言,然后用 USING 列出要访问的表。它在 S/4HANA 项目里几乎是标配,原因很实际:它跑在与 ABAP 相同的那条数据库连接和事务里。
METHOD get_stock BY DATABASE PROCEDURE FOR HDB LANGUAGE SQLSCRIPT OPTIONS READ-ONLY USING zstock_detail. et_out = SELECT matnr, SUM( menge ) AS menge FROM zstock_detail WHERE bukrs = :iv_bukrs GROUP BY matnr; ENDMETHOD.适合用 AMDP 的场景有共同特征:数据量在 ABAP 侧处理明显吃力的聚合和连接,需要在 ABAP 逻辑链中间插一段大数据量运算,或者要给 ABAP CDS 视图做参数化的复杂逻辑。反过来,如果这段逻辑只有几十行数据、纯粹是字段加工,放 ABAP 里就行,为了用 AMDP 而用 AMDP,只会让代码更难读、更难调试。
AMDP 有两个需要注意的限制。一是语法检查能力有限,ABAP 编译期基本看不到 SQLScript 里的错误,很多问题要到运行时才暴露,所以写完必须实际跑一遍。二是它绑定了数据库类型,虽然现在的项目大多只跑 HANA,但迁移或者双数据库环境的项目要提前确认。
6.2 ABAP 侧调用 HANA 过程的性能陷阱
在 ABAP 里直接调数据库过程,一般通过 SQL 语句执行接口来做。这里有个特别容易被忽略的点:用这套接口拿到的连接,可能不是 ABAP 当前那条事务连接。如果它是另开的一条连接,那你实际上开了个新事务,锁的范围、回滚的边界、提交的时机,和 ABAP 的业务逻辑全都不在一条线上。
后果是排查起来非常痛苦的现象:ABAP 侧报错回滚了,数据库里数据却还在;或者 ABAP 侧还没提交,数据库这边已经能看到中间状态。我自己的规则很简单:能用 AMDP 或默认连接完成的事,就不要另开连接。确实需要独立连接处理长时间作业时,要在设计文档里把事务边界写清楚,并且确保异常路径上有明确的清理动作。
6.3 S/4HANA 场景下的取舍
这几年做 S/4HANA 相关项目,报表加速和期间结账是绕不开的两块。我的选型顺序通常是这样的:能用 ABAP CDS 视图表达的,优先用 CDS,因为它在 ABAP 生态里、能被权限和传输体系完整覆盖;需要多步骤中间结果、复杂聚合、参数化逻辑的,用 CDS 加 AMDP 组合,把复杂部分沉到 AMDP 方法里;只有在需要多步骤写入、需要被非 ABAP 系统调用、或者要独立部署的时候,才考虑在数据库侧单独建过程。
这个顺序背后的判断逻辑是可维护性和团队能力:AMDP 跟着 ABAP 传输、被 ABAP 调试工具覆盖,团队里会的人多;独立的数据库过程能力更强,但运维和排查的成本也更高,一旦写代码的人离职,接手成本会成倍上升。选型的时候把这两件事放在一起算,比单纯比性能数字更接近实际情况。
最后分享一个我自己踩过的坑。早年我处理一个跨年度账龄计算,为了少写几行代码,把中间结果默认成了全部 NVARCHAR 类型,想着反正后面还要转。结果一条涉及几千万行的关联,因为两边类型不一致,做了隐式转换,执行计划里多出一大片转换算子,跑了四十多分钟。把字段类型改成与源表严格一致之后,同样的逻辑二十几秒出结果。从那以后我加了一条硬规矩:过程里的参数类型、变量类型、目标字段类型,三者必须严格对齐,一个 DECIMAL 精度、一个字符长度都别将就。这类问题在开发环境的小数据量下永远看不出来,只有到生产才现原形,改起来却要重新走一遍传输流程。