先把丑话说在前面,这一篇是接着前面第14篇的进度来写的,所以不会再去重复讲基础查询、索引、事务这些内容。如果你还在入门阶段,对那些内容还不太熟,建议先翻翻前面的文章。如果你是带着一些基础来看这篇的,那直接进入正题:SQL Server 2019里的存储过程和自定义函数,到底应该怎么设计、怎么写、怎么调,以及它们那些“官方文档不讲,但实际项目里必须知道”的坑。
这篇系列文章的目标读者,是那种已经会写增删改查,开始接手业务逻辑复杂的报表、批量数据处理、接口对接的人。SQL Server 2019企业版下载安装这些就不再啰嗦了,开发版或者Express版足够本机学习,重点是把存储过程和自定义函数彻底弄明白。毕竟在后端服务、报表系统、数据仓库这些场景里,存储过程依然是很多团队离不开的东西。
我记得以前在项目群里,总有人问MySQL存储过程和SQL Server存储过程差异大不大。说句实在话,如果C#那边已经写过执行MySQL存储过程的代码,再来学SQL Server 2019存储过程,很多概念是可以平移的——参数传递、返回值、异常处理、动态SQL,这些核心机制大同小异。Oracle存储过程的编写步骤与SQL Server也有不少相通之处,只是语法细节和包机制不一样。所以,这套东西学会之后,触类旁通是真的。
这次的内容重点放在这么几个地方:存储过程的类型与适用场景、创建和修改时的语法细节、参数和返回值的各种姿势、自定义函数的分类与限制、以及它们之间怎么配合使用。每一块我都会写清楚“为什么要这么做”和“实际项目里最常见的用法”,尽量不是单纯地贴官方文档。
1. 存储过程的整体设计与使用场景拆解
1.1 什么是存储过程,为什么要用存储过程
存储过程就是一段提前编译好并保存在数据库里的T-SQL代码块。你可以理解为它就像后厨里提前配好的半成品菜包:厨师(应用程序)不用每次从头洗菜切菜,只要把菜包丢进锅里,按一下按钮,等几分钟,菜就出来了。数据库里的“按钮”叫EXECUTE,每次执行指定的过程名,SQL Server就会按预设逻辑跑一遍。
那为什么不用应用程序直接拼SQL?我在给业务系统做数据接口的时候,遇到过很多这种对比情况。直接用SQL写业务逻辑当然可以,但问题是你把业务规则散落在C#、Java、Python各种语言里,哪天要改一条计算规则,光找到代码位置就够呛。存储过程把规则收敛到了数据库层,逻辑变更只改数据库,应用端尽可能不动,这对很多老旧系统来说维护成本会低很多。
还有一点就是执行计划复用。SQL Server对存储过程默认会缓存执行计划。同一个过程被几百个请求同时命中时,不需要像即席SQL那样每次都得重新编译。对于那种复杂到十几张表关联的报表逻辑,这个优化非常显著。再加上存储过程在数据库层做权限控制很方便——只给账号EXECUTE权限,用户根本碰不到底层表。安全性上,SQL注入的入口也会少很多。
1.2 存储过程适合做什么,不适合做什么
从我实际接手的项目来看,真正适合放存储过程的场景大概有这些:复杂报表的统计汇总、批量数据的更新归档、带有频繁事务控制的业务操作、跨系统数据同步、后台定时任务的执行体。这些场景的特点是逻辑相对稳定,但计算量不小,用过程化封装正好能发挥数据库引擎的集合运算优势和事务保护能力。
那不适合的情况也要说透。比如非常简单的单表增删改查,用存储过程反而是负担。还有那种SQL逻辑里要拼接几十列动态字段、结构频繁变化的场景,用存储过程做动态SQL会让调试难度陡增。另外,如果团队里没有专门的数据库开发人员,光靠后端工程师顺手维护存储过程,代码规范性一般会比较差,时间一久过程文件就成了谁也看不懂的“祖传代码”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 存储过程的核心语法与实操要点
2.1 基本语法框架:创建、修改、删除的一个完整示例
创建一个存储过程的T-SQL语法其实很直观。很多初学者最喜欢看的就是这种可以直接抄的骨架代码。先给一个典型示例:
sql复制USE YourDatabase;
GO
IF OBJECT_ID(N'dbo.usp_GetUserOrders', N'P') IS NOT NULL
DROP PROCEDURE dbo.usp_GetUserOrders;
GO
CREATE PROCEDURE dbo.usp_GetUserOrders
@UserId INT,
@StartDate DATETIME = NULL,
@OrderCount INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SELECT OrderId, OrderNo, TotalAmount, OrderDate
FROM dbo.Orders
WHERE UserId = @UserId
AND (@StartDate IS NULL OR OrderDate >= @StartDate)
ORDER BY OrderDate DESC;
SELECT @OrderCount = @@ROWCOUNT;
END
GO
这里有几个地方要特别留意。首先是DROP和CREATE之间的判断,用OBJECT_ID检查一下对象是否存在,避免脚本重复执行时报错。其次,参数@StartDate我设置了默认值NULL,这样调用时既可以只传UserId,也可以带上日期范围筛选。第三个参数@OrderCount类型是INT OUTPUT,用于把记录数返回给外部调用方。看到这里你会发现,存储过程不仅能返回结果集,还能通过OUTPUT参数回传单个值,这在分页和状态判断里非常实用。
修改存储过程一般用ALTER PROCEDURE,基本就是改写CREATE PROCEDURE后面的体。实际生产中我很少去DROP再CREATE,因为直接DROP会把对象上的权限一并清掉,重新建好还要逐个赋权限容易遗漏。所以,权限敏感的存储过程推荐用ALTER,保留元数据信息,权限不会丢。
2.2 参数设计与输入输出方向
存储过程的参数可以分为三类方向:INPUT(默认)、OUTPUT、以及返回值。很多人做C#调用SQL Server存储过程的时候,最搞不清楚的就是这三个怎么配。我再补充一个典型示例来说明输出参数和返回值的区别。参数在设计时最好是按业务用途统一风格,前缀@不要省,类型长度选型和表字段保持一致,不然很容易出现隐式转换,最终索引失效。
OUTPUT参数是用来把过程中的某个统计结果返回到外部程序里的。比如刚刚那个计算用户订单数的例子,调用方法如下:
sql复制DECLARE @Cnt INT;
EXEC dbo.usp_GetUserOrders @UserId = 1001, @OrderCount = @Cnt OUTPUT;
PRINT @Cnt;
这里有个非常容易踩的坑:在T-SQL调用端必须显式写OUTPUT关键字,应用程序端用ADO.NET或SqlCommand调用时,参数也要设置Direction = ParameterDirection.Output。很多人明明存储过程写得没错,偏偏外面接收的时候忘记标记方向,结果拿到的一直是默认值。
再说返回值。存储过程可以通过RETURN返回一个整数状态码。习惯上0代表成功,负数或非零值代表异常。RETURN只能返回INT,而且这个值不经过OUTPUT参数通道,在C#里会作为ExecuteNonQuery的返回值被接收到。设计存储过程时,我建议团队统一约定返回值含义,比如0成功、1参数错误、2业务规则校验失败、3数据库异常。这样应用层能快速定位是哪一类问题,不用层层打印日志。
对于多结果集的情况,SqlDataReader可以通过NextResult()来逐段读取。这是一个非常高频的需求,但很多新手以为只执行一次就完了。所以存储过程返回多张表时,一定要检查写法对应了哪一段结果集,避免取数据错位。
2.3 流程控制与动态SQL的关键点
T-SQL里的流程控制其实不难,主要就是BEGIN...END、IF...ELSE、WHILE、CASE、TRY...CATCH。但要说性能杀手,多半是出现在循环里做逐行处理。比如有些统计逻辑,先用游标一行一行更新,再插入临时表,跑起来慢得让人怀疑人生。
我之前处理过一个订单自动归档存储过程,最初用游标循环当月数据,超千万行的生产环境,跑一个批次要四十多分钟。后来改成基于集合的UPDATE + OUTPUT,用了临时表承接,整体压缩到两三分钟。这个优化思路在所有关系型数据库里都通用。所以写过程逻辑时,第一步不是写循环,而是想“能否用一个UPDATE或INSERT...SELECT代替”。能用集合完成的,坚决不要用游标。
动态SQL稍微复杂一点。如果业务上绕不开,需要根据传入参数拼查询字段,那就必须掌握sp_executesql而不是直接用EXEC拼接。两者最大的区别是sp_executesql支持参数化查询,SQL注入风险小很多。下面是个经典示例:
sql复制DECLARE @Sql NVARCHAR(4000);
DECLARE @TableName NVARCHAR(128) = N'dbo.Orders';
DECLARE @UserId INT = 1001;
SET @Sql = N'SELECT OrderId, TotalAmount FROM ' + @TableName + N' WHERE UserId = @UserId;';
EXEC sp_executesql @Sql, N'@UserId INT', @UserId = @UserId;
注意这里表名、列名这种对象标识符不能参数化,只能用白名单去校验。而传入的值则通过参数传入,防止字符串拼接带来的注入风险。不要觉得“反正只有内部系统用,不怕注入”,等到出问题就晚了。
2.4 异常处理与事务的配合
存储过程里写事务,如果不配合TRY...CATCH,一旦中间出错,事务本身往往处于不可提交状态,连接一释放还会回滚。更麻烦的是,客户端捕获到的错误信息非常有限,很难定位是哪个步骤出了问题。所以事务型的存储过程,我通常会在开头就搭一个结构化异常处理框架。
sql复制CREATE PROCEDURE dbo.usp_Transfer
@AccountIdFrom INT,
@AccountIdTo INT,
@Amount DECIMAL(18,2)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ErrorMsg NVARCHAR(400);
BEGIN TRY
BEGIN TRANSACTION;
UPDATE dbo.Accounts SET Balance = Balance - @Amount WHERE AccountId = @AccountIdFrom;
IF @@ROWCOUNT = 0
BEGIN
RAISERROR(N'转出账户不存在', 16, 1);
END
UPDATE dbo.Accounts SET Balance = Balance + @Amount WHERE AccountId = @AccountIdTo;
IF @@ROWCOUNT = 0
BEGIN
RAISERROR(N'转入账户不存在', 16, 1);
END
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
SET @ErrorMsg = ERROR_MESSAGE();
-- 这里可以写日志表
INSERT INTO dbo.ProcedureErrorLog(ObjectName, ErrorNumber, ErrorMessage, OccurTime)
VALUES (N'dbo.usp_Transfer', ERROR_NUMBER(), @ErrorMsg, GETDATE());
THROW;
END CATCH
END
GO
这个结构可以看到几个细节。RAISERROR在事务块里触发,控制流会跳到CATCH块。CATCH里先判断@@TRANCOUNT是否大于0,大于0就回滚,避免事务泄漏。做完回滚还能把错误信息记录到日志表,方便后续排查。THROW重新抛出异常,让应用程序端能看到真实错误,而不是吞掉异常后只返回个“执行失败”。SQL Server 2019里,THROW比RAISERROR更简洁,且能保留原始错误状态。
一个容易忽略的小知识点是:RAISERROR里严重级别(severity)为11到19时,CATCH能捕获到;级别低于11时不一定会跳进CATCH。所以想通过RAISERROR主动抛错给CATCH处理,建议用16。曾经有同事写了一个严重级别10的RAISERROR,结果发现错误既不中断也不进CATCH,排查半天才发现是级别填得不对。
3. 自定义函数详解
3.1 什么是自定义函数,与存储过程的核心差异
自定义函数和存储过程最大的不同,简单说就是“函数必须要有返回值,且函数体中不能有副作用”——不能修改表数据,不能执行DDL,不能调用那些本身会改变数据库状态的存储过程。这个限制看着绑手绑脚,但它保证了函数可以被用在SELECT语句中,和普通的内置函数一样处理一行行数据。对于格式化编号、计算字段、税率换算这种场景,使用起来很方便。
从执行方式上讲,存储过程需要EXEC/EXECUTE单独调用,函数则可以嵌入到SELECT、WHERE、JOIN、CASE表达式等任意允许表达式的位置。比如我想在查询里直接调用一个计算订单折扣金额的函数:
sql复制SELECT OrderNo, dbo.fn_CalcAmount(Quantity, UnitPrice, DiscountRate) AS FinalAmount
FROM dbo.OrderDetails;
这种写法在存储过程里是做不到的。所以实际项目中,我会把“可复用的计算规则”放进函数,而把“包含流程控制、事务、多步骤业务动作”的逻辑放进存储过程。两者不是对立关系,存储过程内部也可以调用自定义函数来简化计算。
3.2 标量函数:写法和几个典型应用场景
标量函数返回单个值。简单到只输入一个日期返回年月字符串,复杂到根据订单ID汇总交易总额,都属于这一类。
看一个简单但很典型的示例。
sql复制CREATE FUNCTION dbo.fn_FormatOrderNo
(
@OrderDate DATETIME,
@Sequence INT
)
RETURNS NVARCHAR(50)
AS
BEGIN
RETURN N'SO' + CONVERT(NVARCHAR(8), @OrderDate, 112) + RIGHT(N'0000' + CONVERT(NVARCHAR(10), @Sequence), 6);
END
GO
如果业务上要求订单号格式是“SO20250115000042”,那这个函数正好能生成第42号订单。
我在真实项目里体会最深的,是一个格式化手机号或身份证号的函数。数据仓库给报表系统导出数据时,运营要求手机号中间四位脱敏。如果查询页面到处写SUBSTRING拼接,一旦规则调整,维护量非常大。抽成一个标量函数后,所有报表直接调用一个函数即可,规则改动只动函数内部,省心很多。
不过要提醒一句:标量函数如果用在WHERE条件或者JOIN关联上,可能会伤害性能。SQL Server在2019里虽然对标量函数做了一定的内联化优化,但前提是函数满足一定的约束条件,比如不访问数据、不做太多递归。如果函数体内存在表查询,就很容易导致逐行调用。所以,不要在筛选条件里用那种带表访问的标量函数,换一种方式比如提前用CTE计算结果,或者直接扩展成表值函数,性能会好很多。
3.3 表值函数:内联表值函数与多语句表值函数
表值函数返回的结果是一个表,可以像视图一样被查询。这个在实际开发中可以说是神器。内联表值函数(Inline TVF)没有BEGIN...END包裹,RETURNS TABLE,直接RETURN一个SELECT结果。因为它的执行计划会被展开到外面主查询中,性能接近视图,很受推荐。
sql复制CREATE FUNCTION dbo.fn_GetOrdersByUser
(
@UserId INT
)
RETURNS TABLE
AS
RETURN
(
SELECT OrderId, OrderNo, TotalAmount, OrderDate
FROM dbo.Orders
WHERE UserId = @UserId
)
GO
用法直接:
sql复制SELECT * FROM dbo.fn_GetOrdersByUser(1001) WHERE OrderDate >= '2025-01-01';
这个查询展开以后,其实和直接查Orders表加上WHERE UserId = 1001的效果差不多。数据库可以对OrderDate、UserId上的索引做很好的利用。
多语句表值函数(Multi-statement TVF)则是先声明一个表变量,然后通过INSERT往里填充数据,最后RETURN。它比内联TVF灵活得多,可以在函数体内写各种复杂过程逻辑,但它也像标量函数一样可能导致性能下降,因为优化器无法把它展开到外部查询。
大多数项目里我更推荐:能用内联TVF解决的事,就不要用多语句TVF。只有在确实需要先算一批数据,再基于这批数据做二次处理时,才考虑多语句TVF。
3.4 函数中的约束与常见限制速查
我打算把“什么不能做”整理成一个表,方便快速查阅。很多人做项目时不看说明书,非得跑一遍SQL发现报错,才知道这个限制。
| 操作类型 | 存储过程 | 自定义函数 |
|---|---|---|
| 修改表数据(INSERT/UPDATE/DELETE) | 支持 | 不支持 |
| 执行DDL(CREATE/ALTER/DROP) | 支持 | 不支持 |
| 返回多个结果集 | 支持 | 不支持 |
| 在SELECT中直接调用 | 不支持 | 支持 |
| 使用动态SQL(EXEC/sp_executesql) | 支持 | 部分受限 |
| 使用临时表 | 支持,推荐 | 只允许表变量 |
| 调用存储过程 | 支持 | 不支持 |
| 使用事务控制 | 支持 | 不支持事务内回滚 |
| 持有OUTPUT参数 | 支持 | 不支持 |
| 返回类型 | 仅INT | 标量值或表 |
这张表里的“调用存储过程”一项非常关键。有时你在函数里想EXEC另一个存储过程来拿点数据,一执行SQL Server直接报错:Invalid use of a side-effecting operator。可以说函数的禁区和纪律都在这里了。
4. 进阶实操:从调试到执行计划分析
4.1 数据库管理工具中查看执行计划的方法
关于执行计划,很多人第一反应是SSMS里按Ctrl+M打开“包含实际执行计划”,然后跑一遍SQL看图形计划。这个做法本身没有错,但在查看某个存储过程的执行计划时,有个细节容易产生误解。另外,像DM管理工具这类第三方数据库管理工具也提供类似的“查看执行计划”入口。图形化的执行计划只能看到“某一步代价占比高”,但看不到这个计划是在什么参数下生成的、参数嗅探造成的偏差在哪里。
比如你在SSMS里执行EXEC dbo.usp_GetUserOrders @UserId=5,生成的执行计划可能并不代表所有的情况。因为存储过程的计划缓存只保留一次编译结果,可能它是根据第一次执行时的参数生成的。如果第一次传入的是全量范围的数据分布特征,后面再来一个只返回几条数据的参数时,走的计划可能就“跑偏”了,这就是著名的参数嗅探问题。
4.2 系统视图与缓存查询
比起单纯看图形计划,我更推荐在排查性能问题时直接查几个关键的DMV(动态管理视图)。比如查看当前缓存里某个存储过程的计划创建时间和参数值,可以这样查:
sql复制SELECT TOP 5
OBJECT_NAME(qt.objectid) AS ProcName,
qs.execution_count,
qs.total_elapsed_time / 1000000.0 AS total_seconds,
qs.last_execution_time,
qp.query_plan
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS qt
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS qp
WHERE OBJECT_NAME(qt.objectid) = N'usp_GetUserOrders'
ORDER BY qs.total_elapsed_time DESC;
查询出来的query_plan是XML格式,点开就能以图形方式看到缓存计划。如果你想让某条存储过程的计划重新编译,可以用:
sql复制EXEC sp_recompile N'dbo.usp_GetUserOrders';
这个操作并不是真正重新编译当前执行,而是标记下次执行时重新编译。有时候线上遇到“昨天还正常,今天突然慢了”,又没有改代码改数据,多半就是统计信息过期或计划被参数嗅探带偏,先sp_recompile一下常常立竿见影。当然这只是应急,长久之计是优化索引、更新统计信息,或者考虑使用WITH RECOMPILE、OPTION(RECOMPILE)这类提示。
4.3 与应用程序联调时的常用参数设置
做C#联调时,SqlCommand调用存储过程的代码模板,我建议统一这样写。Parameters.AddWithValue很方便,但有可能会把参数类型推断为NVARCHAR,导致表字段索引失效,尤其是对VARCHAR类型字段会造成隐式转换。更稳妥的做法是显式指定DbType和长度。
举个例子:
csharp复制using (var conn = new SqlConnection(connectionString))
using (var cmd = new SqlCommand("dbo.usp_GetUserOrders", conn))
{
cmd.CommandType = CommandType.StoredProcedure;
var pUserId = new SqlParameter("@UserId", SqlDbType.Int) { Value = userId };
var pStartDate = new SqlParameter("@StartDate", SqlDbType.DateTime) { Value = (object)startDate ?? DBNull.Value };
var pOrderCount = new SqlParameter("@OrderCount", SqlDbType.Int) { Direction = ParameterDirection.Output };
cmd.Parameters.Add(pUserId);
cmd.Parameters.Add(pStartDate);
cmd.Parameters.Add(pOrderCount);
conn.Open();
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
// 处理结果集
}
}
int count = (int)pOrderCount.Value;
}
这里特别要说明的是可空参数的处理。如果startDate为null,直接赋值会报错,而赋DBNull.Value即可。很多新手在这里会翻车,运行时报“未处理的参数类型”,其实是没把null转成DBNull。
另外一个容易被忽略的点是SqlCommand.CommandTimeout。默认30秒容易超时,但一次性把它调到很大也不是好习惯,最好是前期就去优化存储过程和索引。我一般把CommandTimeout设为120秒,万一某条语句确实复杂,不至于连调整的机会都没有。
5. 常见问题与排查技巧实录
5.1 与函数和存储过程相关的典型报错集合
几乎每个人写自定义函数和存储过程时,都会遇到几个眼熟的报错,这里我列一下最常见的,顺便给出方向。
| 报错信息 | 原因 | 解决思路 |
|---|---|---|
| 'INSERT' is not allowed in a function | 函数中写了修改数据的操作 | 检查函数体是否有DML语句 |
| Invalid use of a side-effecting operator | 函数中调用了存储过程或执行了动态SQL | 将处理逻辑移到存储过程或应用层 |
| Must declare the scalar variable "@xxx" | 参数拼串或作用域不对 | 不推荐字符串拼参数,干脆用sp_executesql参数化 |
| The parameter "@xxx" was not supplied | 调用存储过程时漏传必填参数 | 核对参数名、是否有默认值 |
| Subquery returned more than 1 value | 标量子查询返回多行 | 在子查询中加TOP 1或MAX,检查业务逻辑去重 |
| String or binary data would be truncated | 插入/更新时超过列定义长度 | 检查列长度和数据来源,这一点在2019里的报错信息更友好 |
5.2 参数嗅探和统计信息过期
参数嗅探带来的问题,可以用一个很简单的示例说明。假设有一个订单表,数据分布很不均匀:用户A有20万条订单,用户B只有3条。存储过程按UserId查询并返回订单明细,第一次执行时传的是用户A,SQL Server根据这个参数采样并决定用全表扫描或并行计划。后面再用用户B执行时,如果复用了同一计划,可能本来走索引能毫秒返回的,却用了个并行扫描,肉眼可见的延迟。
解决方式有几种。一是参数本地化:把传入参数复制到本地变量,再让查询使用本地变量,该方法可以阻止优化器参考原始参数值,但也会可能导致其他参数的计划不佳。二是加OPTION(RECOMPILE),每次都重新编译,适合低频但复杂的语句。三是OPTION(OPTIMIZE FOR(@UserId UNKNOWN)),让优化器按一般密度来估算。每种方案都有局限,得根据业务的具体量级和调用频率选择。
5.3 存储过程创建的常见误区与沙箱回归
从工程规范的角度,我还要提一个容易被团队忽略的点:存储过程和函数本质上是数据库对象,变化也必须走版本管理。别在测试环境里改完就算了,连个变更脚本都没留,生产环境上线时根本不敢动。
我的习惯是一个变更一个脚本文件夹,按日期和对象名命名。每个脚本头部加上可重复执行的判断逻辑,比如:
sql复制IF OBJECT_ID('dbo.usp_Transfer', 'P') IS NULL
EXEC('CREATE PROCEDURE dbo.usp_Transfer AS SELECT 1;');
GO
ALTER PROCEDURE dbo.usp_Transfer
...
这样能够保证数据库在重新发布时,脚本不会因为对象不存在而报错。看起来像是多写了一行,但生产发布的时候能少很多麻烦。测试环境的数据量和生产差异很大,所以新增存储过程上线前,最好在仿真数据上做一轮全量回归。这个步骤不能省。
5.4 避免在WHERE条件里用标量函数
这部分实际是性能优化的重点。有些开发人员觉得“函数好用”,于是在查询条件的每一行都调用一下,比如查每个员工所在部门名称。如果部门名称需要从另一个表里取,直接在SELECT里用标量函数,每次触发一次查询,那问题就大了。
在百万级员工表上每行调用一个函数,就是一个函数调用一百万次,至少几秒甚至十几秒。这种场景,要么改成关联查询,要么使用内联表值函数去连接,要么直接在查询阶段做一次处理。拿刚才的员工部门名称举例,正确写法是:
sql复制SELECT emp.EmployeeName, dept.DepartmentName
FROM dbo.Employee emp
LEFT JOIN dbo.Department dept ON dept.DepartmentId = emp.DepartmentId;
如果一定要保留函数接口,那也应该写成内联表值函数加JOIN的方式。反过来,自定义函数在SELECT输出列中的使用,对性能的影响相对小,但也别在里面写复杂的表访问。格式化字符串这种纯计算逻辑,影响就不大。
6. 把存储过程与函数串到一个实际业务场景中
前面讲了那么多语法和避坑点,不如用一个完整的业务场景把它们串起来,就像搭积木一样。这里我讲一个比较典型的例子:会员订单对账。
假设业务需要把每天晚上产生的订单和支付平台账单做一次核对,核对之后生成差异报表。这个逻辑放在应用层也行,但每天一次批量处理,放数据库里执行更高效。处理步骤大概是:先通过存储过程获取当天所有订单,再通过自定义函数格式化账单编号,最后用存储过程做核对并更新状态。
可以先建一个表值函数,返回某一天的所有订单数据,存到变量表里做后续处理。这里先用内联表值函数。
sql复制CREATE FUNCTION dbo.fn_GetDailyOrders
(
@BizDate DATE
)
RETURNS TABLE
AS
RETURN
(
SELECT OrderId, OrderNo, UserId, TotalAmount, PayStatus, PayTime
FROM dbo.Orders
WHERE CONVERT(DATE, OrderDate) = @BizDate
)
GO
然后在存储过程里调用它:
sql复制CREATE PROCEDURE dbo.usp_DailyReconcile
@BizDate DATE
AS
BEGIN
SET NOCOUNT ON;
DECLARE @TodayOrders TABLE
(
OrderId INT PRIMARY KEY,
OrderNo NVARCHAR(50),
TotalAmount DECIMAL(18,2),
IsDiff BIT DEFAULT 0
);
INSERT INTO @TodayOrders (OrderId, OrderNo, TotalAmount)
SELECT OrderId, OrderNo, TotalAmount
FROM dbo.fn_GetDailyOrders(@BizDate)
WHERE PayStatus = 1;
UPDATE o
SET IsDiff = 1
FROM @TodayOrders o
LEFT JOIN dbo.PaymentBill pb ON pb.OrderId = o.OrderId
WHERE ISNULL(pb.PayAmount, 0) <> o.TotalAmount;
SELECT OrderId, OrderNo, TotalAmount, IsDiff
FROM @TodayOrders
ORDER BY IsDiff DESC, OrderNo;
END
GO
这样存储过程里既用到了表值函数的结果,又使用了变量表暂存数据,业务逻辑清晰,后续要对接调度任务,直接调用EXEC dbo.usp_DailyReconcile @BizDate = '2025-02-17'即可。如果团队要改成跑批处理,把它放进SQL Server Agent作业的计划里,也能稳定跑。
这种串在一起用的方式,依赖的是“函数提供数据、过程控制流程”的设计思路。理解这个思路之后,再遇到复杂业务,就不会出现“不知道在哪写逻辑”的问题了。
7. 一段个人实操中总结的经验
做SQL Server开发这些年,见过最狂野的存储过程有四千多行,从最外层不断嵌套子查询,里面还拼了动态SQL、临时表、函数,调试起来真的要命。后来我们订立了一个规矩:单个存储过程超过300行,必须拆分。拆的方式就是今天文章里的思路——把可复用的查询片段抽成表值函数或视图,把复杂计算抽成标量函数,存储过程只负责“编排”和“事务控制”。
另外,我特别想给刚开始学存储过程的人一个建议:不要去背语法。你只需要记住“这段代码的输入是什么、输出要什么、过程里要保护什么”,语法忘了可以查文档。真正值钱的能力,是你拿到一段祖传存储过程时,能一眼看出哪个查询慢、哪个临时表多余、哪个隐式转换在拖后腿。这些能力没有捷径,就是多看计划、多跑不同数据集、多练问题排查。
SQL Server 2019里也多了不少新特性,比如智能查询处理、行模式内存池、加速数据库恢复等等,但存储过程和自定义函数这些基础能力始终是绕不开的核心模块。把这部分吃透,无论你以后转型做数据仓库、还是跟C#后端团队配合接口联调,都不会怯场。
文章里这些代码示例几乎都可以直接复制跑一遍。建议自己在SQL Server 2019开发版里建几张测试表,把存储过程、表值函数、标量函数、错误处理、执行计划查看这些全部走一遍。SQL Server 2019企业版下载一次,本机装开发版也完全够用,选好你的环境,动手试一遍,比看十篇教程都有用。
