简介:面向SQL Server数据库初学者与需要巩固可编程对象的中级开发者,这份资源以真实业务场景中的存储过程与用户自定义函数为切入点,帮助读者理解预编译SQL语句集合在性能、复用与权限控制上的实际价值。压缩包共3个文件,均为sql脚本,整体约4KB,体积轻量便于快速导入SQL Server Management Studio中调试运行;脚本分别覆盖供应链管理报表生成、订单与库存相关业务处理,以及批次号序列化函数等典型用途,可对照学习参数传递、结果集返回与函数调用的差异。资源已有2023人学习下载,适合作为课堂示例或项目模板参考。读者可从中获得可直接执行的建库建过程脚本、报表类存储过程的参数设计思路,以及用用户自定义函数生成唯一批次号的实现方式,并借此理解存储过程在封装复杂逻辑、减少重复代码和限制直接数据访问方面的作用,为后续学习事务控制与异常处理打下基础。
1. 从一次批量结算卡死说起:为什么存储过程值得单独设计
去年帮某公司做一套内部结算系统的数据库改造,业务侧反馈“每月初跑批时页面全卡死”,排查下来发现应用层把两万条明细逐条INSERT再逐条UPDATE,一次结算要发四万多条 SQL,网络往返和锁等待把连接池占满。后来把这套逻辑收进一个存储过程,循环改成集合操作,跑批时间从四十多分钟压到两分半。这件事让我重新审视“存储过程”这个被很多人当成老古董的东西——它不玄学,关键看你怎么设计。这份 SQLSERVER 存储过程例子资源,覆盖的就是从基础语法到事务、游标、异常处理、分页、批量更新的完整写法集合,适合正在做数据库课设、毕设,或者要给现有系统补一套可维护数据访问层的同学直接对照改。
2. 需求拆解与技术选型:存储过程到底该承担哪一层职责
2.1 先把业务需求翻译成数据库侧的四类任务
拿到“写一套存储过程例子”这个题目,很多人第一反应是打开 SSMS 敲一个CREATE PROCEDURE带个SELECT就交差。这样交上去的作业在答辩时基本撑不过三个问题。我一般会先把需求拆成四类任务,再决定哪些放进存储过程:
第一类是数据完整性任务,比如插入订单主表后必须同步写明细、扣库存,这类跨表操作放在应用层容易出现“主表成功、明细失败”的脏数据,收进存储过程用事务包住最稳。第二类是批量计算任务,像月度汇总、积分结算,涉及大量行级运算,逐条走应用层网络开销太大。第三类是复杂查询封装,多表关联加动态条件筛选,参数组合多,写在存储过程里便于统一调优。第四类是权限收敛任务,只给应用账号存储过程的执行权限,不给直接改表的权限,减少误操作面。
这四类任务对应到资源里的例子,基本能一一对上:事务与异常处理对应第一类,游标与集合更新对应第二类,动态 SQL 与分页对应第三类,GRANT EXECUTE对应第四类。选型时我的判断标准很朴素——这段逻辑是否要求“要么全成要么全败”,或者是否要处理超过千行的数据,满足其一就优先考虑存储过程,否则留在应用层反而更好维护。
2.2 为什么是 SQLSERVER 而不是把逻辑全推给应用层
有同学会问,现在 ORM 框架这么成熟,为什么还要写存储过程。这个问题在课设答辩里几乎必被问到,得提前想清楚。我的回答通常分三层:执行计划复用、网络往返削减、权限边界清晰。
SQLSERVER 对存储过程有执行计划缓存机制,第一次编译后计划留在计划缓存里,后续调用直接复用,参数化查询还能避免拼接 SQL 带来的注入风险和计划污染。网络往返这块,前面那个结算案例就是活教材,四万次往返和一次调用的差距是数量级的。权限边界则是工程上的考量,应用账号只能EXECUTE,改表结构、删数据的口子被堵死。
当然也有代价:调试不如应用层方便,版本管理要单独做脚本迁移,复杂业务逻辑写进 T-SQL 后可读性下降。所以我的做法是把“数据密集 + 强一致”的部分下沉,把“流程编排 + 外部调用”留在应用层,两边各干各擅长的事。资源里的例子基本遵循这个边界,你在改的时候也别把所有逻辑一股脑塞进去。
2.3 例子集合的组织方式:按能力分层而不是按语法罗列
一份能直接参考的例子集合,组织方式很关键。按CREATE、ALTER、DROP这种语法顺序罗列,看着整齐,实际用的时候找不到。我倾向按能力分层组织:
| 层级 | 覆盖内容 | 典型例子 |
|---|---|---|
| 基础层 | 参数定义、默认值、输出参数、返回值 | 带输入输出参数的查询过程 |
| 控制层 | IF/ELSE、WHILE、CASE、游标 | 逐行处理与条件分支 |
| 事务层 | BEGIN TRAN、TRY/CATCH、回滚 | 主从表同步写入 |
| 查询层 | 动态 SQL、分页、临时表 | 多条件筛选分页 |
| 运维层 | 权限授予、执行计划查看、重编译 | 账号授权脚本 |
这样分层的好处是,你拿到一个具体需求,能快速定位到对应层的例子去改,而不是从头翻到尾。下面几章就按这个思路往下拆。
3. 总体设计:一个可复用的存储过程骨架长什么样
3.1 参数设计与返回值约定
先看一个我常用的骨架,几乎所有业务过程都从这个模板改出来:
CREATE PROCEDURE dbo.usp_OrderSettle @OrderId INT, @SettleDate DATE = NULL, -- 不传则取当天 @RowsAffected INT = 0 OUTPUT, -- 输出实际影响行数 @ErrMsg NVARCHAR(4000) = '' OUTPUT AS BEGIN SET NOCOUNT ON; -- 抑制行数消息,减少网络包 SET XACT_ABORT ON; -- 出错自动回滚整个事务 IF @SettleDate IS NULL SET @SettleDate = CAST(GETDATE() AS DATE); BEGIN TRY BEGIN TRANSACTION; -- 业务逻辑占位:更新主表、写明细、扣库存 SET @RowsAffected = @@ROWCOUNT; COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; SET @ErrMsg = ERROR_MESSAGE(); -- 记录错误日志表,便于事后排查 INSERT INTO dbo.ErrorLog(ProcName, ErrMsg, ErrTime) VALUES (OBJECT_NAME(@@PROCID), @ErrMsg, GETDATE()); THROW; -- 向上抛出,应用层能感知失败 END CATCH END这段骨架里有几个设计点值得说清楚。SET NOCOUNT ON是血泪经验,不加的话每条 DML 都会往客户端发一个“n 行受影响”的消息,批量调用时这些消息本身就很占带宽。SET XACT_ABORT ON保证运行时错误能自动回滚,不用在每个可能出错的地方手动判断。输出参数@RowsAffected和@ErrMsg让调用方能拿到执行结果,而不是只能靠异常判断。THROW放在 CATCH 末尾,既记了日志又把错误抛给上层,应用层的事务边界才能正确响应。
参数默认值这块,@SettleDate DATE = NULL配合过程内的空值判断,比直接写= GETDATE()更灵活——默认值在编译期求值,跨天调用会拿到旧日期,这个坑我踩过。
3.2 事务边界与异常处理的配合方式
事务边界画在哪里,是存储过程设计里最容易翻车的地方。常见错误有两种:一种是把事务开在过程外面,过程内部又开一层,嵌套事务的COMMIT只减计数不真正提交,回滚时行为容易误判;另一种是 CATCH 里只记日志不回滚,事务挂着不释放,连接归还连接池后还带着未提交事务,后续请求全部阻塞。
我的约定是事务边界跟着业务原子操作走,开在过程内部,TRY 开头 BEGIN TRAN,TRY 结尾 COMMIT,CATCH 里先判断@@TRANCOUNT再 ROLLBACK。如果确实需要外层控制事务,过程内部就用SAVE TRANSACTION设保存点,出错回滚到保存点而不是整个事务,这样外层还能决定是继续还是整体放弃。
异常处理还有一层要考虑:THROW和RAISERROR的区别。THROW会保留原始错误号和行号,RAISERROR需要自己拼错误信息。新写的例子统一用THROW,除非要兼容很老的版本。CATCH 块里取错误信息用ERROR_MESSAGE()、ERROR_NUMBER()、ERROR_LINE()这几个函数,比@@ERROR可靠,后者只反映上一条语句的状态,中间插一条赋值就被覆盖了。
3.3 命名规范与版本管理
例子多了以后,命名混乱比逻辑错误更折磨人。我一般用usp_前缀区分用户存储过程,后面跟模块名加动作,比如usp_OrderSettle、usp_StockDeduct。系统自带的sp_前缀绝对不要用,SQLSERVER 会先在 master 库找,找不到才找当前库,白白多一次查找,还可能和系统过程撞名。
版本管理这块,存储过程不像代码文件能直接进 Git 对比,我的做法是每个过程脚本头部写变更注释块,记录版本号、修改人、修改内容和日期,同时把建表、建过程、授权脚本按序号命名放在同一个迁移目录里,部署时按序执行。资源里的例子如果没带这套注释,建议你补上,答辩时展示版本管理意识是加分项。
4. 核心模块实现:从游标处理到动态分页的落地写法
4.1 游标与集合操作的取舍
游标是课设里出镜率最高的东西,也是最容易被滥用的。先看一个逐行处理的典型写法:
DECLARE @OrderId INT, @Amount DECIMAL(18,2); DECLARE cur CURSOR LOCAL FAST_FORWARD FOR SELECT OrderId, Amount FROM dbo.Orders WHERE Status = 0; OPEN cur; FETCH NEXT FROM cur INTO @OrderId, @Amount; WHILE @@FETCH_STATUS = 0 BEGIN UPDATE dbo.Orders SET Status = 1 WHERE OrderId = @OrderId; INSERT INTO dbo.SettleLog(OrderId, Amount) VALUES (@OrderId, @Amount); FETCH NEXT FROM cur INTO @OrderId, @Amount; END CLOSE cur; DEALLOCATE cur;这段能跑,但两万行数据就是两万次UPDATE加两万次INSERT,锁持有时间长,日志膨胀快。LOCAL FAST_FORWARD这两个选项要带上,LOCAL限定作用域,FAST_FORWARD是只进只读的最优游标类型,不加的话默认游标可能变成动态游标,性能差一截。
同样的逻辑用集合操作改写:
BEGIN TRANSACTION; UPDATE dbo.Orders SET Status = 1 OUTPUT inserted.OrderId, inserted.Amount INTO dbo.SettleLog(OrderId, Amount) WHERE Status = 0; COMMIT TRANSACTION;一条语句搞定,OUTPUT子句把更新前的行直接写进日志表,事务只开一次。我的原则是能用集合操作就不用游标,游标只留给“每行处理逻辑不同、依赖上一行结果”的场景,比如逐级汇总、树形结构展开。资源里两种写法都有,对照着看能理解什么时候该换。
4.2 动态 SQL 与参数化防注入
多条件筛选查询,参数组合一多,静态 SQL 写起来就是一堆IF拼字符串。动态 SQL 能解决,但拼接方式不对就是注入漏洞:
CREATE PROCEDURE dbo.usp_OrderQuery @CustomerId INT = NULL, @StartDate DATE = NULL, @EndDate DATE = NULL, @PageSize INT = 20, @PageIndex INT = 1 AS BEGIN SET NOCOUNT ON; DECLARE @sql NVARCHAR(MAX) = N' SELECT OrderId, CustomerId, Amount, CreateTime FROM dbo.Orders WHERE 1 = 1'; DECLARE @params NVARCHAR(MAX) = N' @CustomerId INT, @StartDate DATE, @EndDate DATE, @Offset INT, @PageSize INT'; IF @CustomerId IS NOT NULL SET @sql += N' AND CustomerId = @CustomerId'; IF @StartDate IS NOT NULL SET @sql += N' AND CreateTime >= @StartDate'; IF @EndDate IS NOT NULL SET @sql += N' AND CreateTime < DATEADD(DAY, 1, @EndDate)'; SET @sql += N' ORDER BY OrderId OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY'; EXEC sp_executesql @sql, @params, @CustomerId = @CustomerId, @StartDate = @StartDate, @EndDate = @EndDate, @Offset = (@PageIndex - 1) * @PageSize, @PageSize = @PageSize; END关键在sp_executesql加参数声明,所有外部输入都走参数绑定,不拼进字符串。WHERE 1 = 1是个小技巧,让后续每个条件都能以AND开头拼接,不用判断是不是第一个条件。分页用OFFSET ... FETCH NEXT,SQLSERVER 2012 以后都支持,比ROW_NUMBER()套子查询干净。
日期范围这里有个细节:CreateTime < DATEADD(DAY, 1, @EndDate)而不是<= @EndDate,因为CreateTime如果是DATETIME类型带时分秒,<=会漏掉当天最后那部分数据。这个坑我在做报表时踩过,数据对不上查了半天。
4.3 临时表与表变量的选择
中间结果集放临时表还是表变量,也是常被问的点。我的经验判断:
| 对比项 | 临时表#tmp | 表变量@tmp |
|---|---|---|
| 统计信息 | 有,优化器能估行数 | 无,固定估 1 行 |
| 事务参与 | 参与,可回滚 | 不参与回滚 |
| 数据量 | 适合大结果集 | 适合小结果集(几百行内) |
| 索引 | 可建索引 | 只能建主键/唯一约束 |
数据量上千行、需要参与事务回滚、需要建索引加速关联的,用临时表。几百行以内的小结果集、不需要回滚的,用表变量减少 tempdb 竞争。资源里两种都有例子,改的时候按数据量选,别一律用表变量图省事——优化器估错行数导致的执行计划翻车,排查起来很费劲。
4.4 批量更新与 MERGE 的适用边界
批量同步两张表的数据,MERGE语句看着很优雅,一条搞定插入、更新、删除:
MERGE dbo.ProductStock AS tgt USING (SELECT ProductId, Qty FROM dbo.StockStaging) AS src ON tgt.ProductId = src.ProductId WHEN MATCHED AND tgt.Qty <> src.Qty THEN UPDATE SET Qty = src.Qty, UpdateTime = GETDATE() WHEN NOT MATCHED BY TARGET THEN INSERT (ProductId, Qty, UpdateTime) VALUES (src.ProductId, src.Qty, GETDATE());但MERGE有几个已知的坑:并发场景下可能死锁,OUTPUT子句在带触发器时行为不一致,执行计划有时不如分开写UPDATE加INSERT稳定。我的做法是数据量不大、并发不高、逻辑简单的场景用 MERGE,生产环境高并发批量同步还是拆成 UPDATE + INSERT 两条语句,各自加好索引。资源里的 MERGE 例子适合学习语法,真上生产前建议做并发压测。
5. 避坑与排查:存储过程上线前最容易翻车的五个点
5.1 参数嗅探导致执行计划跑偏
现象:同一个存储过程,传某些参数值执行很快,换一组参数慢几十倍,执行计划里估算行数和实际行数差几个数量级。
原因:SQLSERVER 第一次执行时根据传入参数生成计划并缓存,后续复用这个计划。如果第一次传的是选择性很高的值,计划按小结果集优化,后来传低选择性值就用了不合适的计划,这就是参数嗅探。
解决:对数据分布倾斜严重的查询,在过程内把参数赋给局部变量再使用,阻断嗅探;或者用OPTION (RECOMPILE)让每次执行重新编译,代价是编译开销;SQLSERVER 2016 以后还可以用OPTION (OPTIMIZE FOR UNKNOWN)。我一般先看执行计划确认是嗅探问题,再选局部变量方案,RECOMPILE留给执行频率低但参数变化大的过程。
5.2 嵌套事务回滚不彻底
现象:过程 A 调用过程 B,B 里出错回滚了,A 继续执行并提交,结果数据只回滚了一半。
原因:SQLSERVER 不支持真正的嵌套事务,内层BEGIN TRAN只是把@@TRANCOUNT加一,内层ROLLBACK会把整个事务回滚并把计数清零,外层再COMMIT就报“没有对应 BEGIN TRAN”的错,或者外层没判断计数直接继续跑。
解决:内层过程用SAVE TRANSACTION 保存点名,出错时ROLLBACK TRANSACTION 保存点名,只回滚到保存点;外层统一控制最终提交或回滚。调用链上的每个过程都要判断@@TRANCOUNT,别假设自己是事务的起点。
5.3 游标未释放导致连接资源泄漏
现象:跑批跑一段时间后连接池耗尽,或者临时表空间持续增长。
原因:游标OPEN后中途RETURN或异常跳出,没走到CLOSE和DEALLOCATE,游标占用的资源不释放。
解决:游标操作放进 TRY/CATCH,CATCH 里补CLOSE和DEALLOCATE;或者用LOCAL游标,过程结束时自动释放。更彻底的做法是能改集合操作就改掉,从源头减少游标使用。
5.4 动态 SQL 拼接漏了 schema 前缀
现象:本地测试正常,部署到另一套环境报“对象名无效”。
原因:动态 SQL 里的表名没带 schema 前缀,依赖调用者的默认 schema,不同账号默认 schema 不同就找不到表。
解决:动态 SQL 里所有对象名统一写dbo.表名全限定形式,别依赖默认 schema。这个习惯看着啰嗦,但能省掉大量环境差异导致的排查时间。
5.5 权限授予过宽
现象:应用账号能直接改表、删数据,一次误操作把生产数据清了。
原因:图省事直接给账号db_owner或者对表给了UPDATE、DELETE权限。
解决:应用账号只给存储过程的EXECUTE权限,表级权限收回。授权脚本单独维护,部署时执行。资源里的授权例子就是按这个思路写的,GRANT EXECUTE ON dbo.usp_xxx TO 应用账号,表权限一律不给。
6. 验收与答辩:怎么证明这套存储过程真的能用
验收这件事,别等到最后一天才想。我一般从三个维度准备材料,答辩时按这个顺序讲,评委基本挑不出漏洞。
第一维度是功能正确性。每个存储过程配一组测试用例,覆盖正常路径、边界值、异常路径。比如结算过程,正常单、金额为零的单、订单不存在的单,三种输入各跑一遍,把执行前后的数据快照对比贴出来。测试脚本用BEGIN TRAN ... ROLLBACK包住,跑完不留脏数据,这个技巧答辩时讲出来很加分。
第二维度是性能数据。挑数据量最大的那个过程,造十万行测试数据,对比存储过程版本和逐条 SQL 版本的耗时。用SET STATISTICS TIME ON和SET STATISTICS IO ON抓逻辑读和 CPU 时间,把执行计划截图附上。有对比数据,性能优化就不是空话。
第三维度是异常与回滚验证。故意在事务中间制造错误,比如插入违反唯一约束的数据,验证 CATCH 是否记了日志、事务是否完整回滚、连接是否正常归还。这一步很多人跳过,但恰恰是生产环境最看重的。
答辩陈述我习惯按“需求背景 → 分层设计 → 关键实现 → 测试数据 → 踩坑与改进”五段走,每段控制在两分钟内。评委提问集中在参数嗅探、事务嵌套、游标性能这三个点,提前把这三块的回答准备好,基本稳了。
提示:答辩演示时提前把 SSMS 连好、测试脚本打开,现场敲代码容易紧张出错,演示脚本跑通比现场发挥可靠。
从那以后我每次交付存储过程方案,都强制走一遍“测试用例 + 性能对比 + 回滚验证”这三件套,哪怕时间紧也至少把异常回滚那条跑通——数据这东西,没有后悔药。希望这套结构和例子能帮到你,照着改改就能交出一份站得住脚的方案。
本文还有配套的精品资源,点击获取