这段话可能有点凡尔赛,但我刚接手了一套运行了十年的SQL Server系统,库里六百多个存储过程。我一度以为DBA最有成就感的工作是写SQL调优脚本,后来才发现,最先要面对的是给存储过程立规矩。如果你所在团队已经尝够了存储过程失控的苦,那这份SQL Server存储过程开发规范(公司内部模板)也许能帮你少踩几个坑。我会从命名、错误处理、事务、动态SQL、上线检查这些维度拆开讲,DBA、数据架构师、后端开发都能直接用。
这份模板不是拍脑袋定的条条框框,而是从真实事故里长出来的。最早团队里有人用sp_前缀,导致系统存储过程命名冲突;有人习惯把一大段动态SQL拼成字符串再执行,结果一个参数注入差点把订单表清了。吃了几次亏以后,我们才把规范沉淀下来。下面就把这套内部模板完整拆解一遍。
1. 为什么存储过程必须“讲规矩”:场景、冲突与成本
1.1 没有规范时的几种典型现场
先看几个我实际遇到过的情况。第一个是命名混乱:同一个订单模块,查询过程叫sp_Order_Get,另一个同事写的叫proc_GetOrder,还有一个人直接叫p_0002。到了后期维护,想找一个“根据订单号取客户信息”的过程,用SSMS的对象搜索刷了七八页才找到,打开一看,里面还在查一张已经废弃三年的旧表。
第二个是错误处理各写各的。有人直接在CATCH块里RAISERROR('系统错误',16,1)扔给前端,连是哪个过程、哪个参数出了问题都不知道;有人则在存储过程里嵌套了BEGIN TRAN,外层失败后里层已经提交了一半数据,对账的时候怎么都对不上。这些问题的根源不是写代码的人水平差,而是没有一份大家能统一执行的规则。
第三个是性能隐患被隐藏在“本地测试没事”的假象里。比如写WHERE CreateDate = @date的时候,CreateDate字段在表里是varchar,开发机器上数据量小没感觉,一上生产,几十万行的表直接全表扫描,夜间跑批从半小时变成三小时。这类问题在代码Review阶段靠肉眼很容易漏掉,必须有规范里的关键字和写法红线兜底。
1.2 规范管什么、不管什么
这份内部模板的定位是“不干涉业务逻辑,只约束工程边界”。它管四类事情:第一,对象怎么命名、代码怎么排版、注释怎么维护;第二,参数、变量、临时表这些资源怎么声明和释放;第三,错误处理、事务控制、返回码怎么统一;第四,哪些SQL写法禁用、哪些场景必须走DBA评审。
它不管的也很重要。我们不在规范里规定业务报表具体怎么取值,也不强制每个过程都必须用某种“高级写法”。比如窗口函数、CTE、临时表这些,只要不违反性能红线,允许开发同学自己选。规范一旦管得太细,写存储过程就变成八股文,团队会本能地抗拒。
1.3 不同版本与工具链带来的妥协
这里必须提一下版本兼容问题。公司内部并不是所有人都用同一个SQL Server版本,有的业务线还在跑2008 R2,新项目已经上了2022。规范里的语法必须做一个下限约定:默认兼容SQL Server 2012及以上,2016及以上可以使用DROP TABLE IF EXISTS,2017及以上可以使用STRING_AGG。如果代码里用到更新版本的功能,必须在注释里显著标注“仅适用于SQL Server 2019+”,避免复制到老环境后直接编译失败。
工具链同样影响规范落地。团队里有人用SSMS,有人用Navicat for SQL Server,还有人习惯在Azure Data Studio里写脚本。这些工具对语法提示、格式化、批处理的处理方式略有差异。我们的经验是:不要强制统一工具,但把规范对应的代码模板做成一个.sql文件,让所有人从同一份模板开始改,至少能避免缩进混乱、分号缺失、GO批处理错误这类低级问题。
还有一类冲突来自有Oracle或MySQL存储过程经验的同事。Oracle的IN OUT参数、MySQL的DELIMITER写法,在SQL Server里并不适用。新加入的同事往往会把“过程内部提交事务”的习惯带进来。规范里专门有一节强调:SQL Server存储过程默认不会自动提交显式事务,事务结束必须由代码明确控制,不要指望数据库替你收拾烂摊子。
2. 命名、格式与注释:从代码风格到对象命名的强制约定
2.1 存储过程命名:它是接口,不是随便起的文件名
存储过程一旦上线,就会被应用层、报表、定时任务反复调用,本质上是一个接口。接口的名字必须让人一眼猜出是哪个模块、什么用途,而不需要点开代码才能确认。我建议统一规则:前缀usp_+ 业务模块 + 业务对象 + 动作。比如:
usp_Order_GetById usp_Order_Create usp_Report_Sales_Daily usp_Admin_DeleteExpiredToken这里有几个细节值得解释。为什么前缀不用sp_?因为SQL Server会优先在master库中查找以sp_开头的过程,如果系统库里有同名对象,业务库里的可能不会被触发,这属于历史遗留问题。另外,用usp_(user stored procedure)能明确表达“这是用户自定义过程”,避免和系统过程混淆。
动作词要尽量统一:查询类用Get/Query,写入类用Create/Update/Delete,报表类用Report,后台维护类用Admin。禁止使用Proc、Test、New这类无意义后缀。团队里如果已经存在老命名,宁可做一次批量改名映射,也不要让新旧两套规则长期并存,否则规范就变得形同虚设。
2.2 参数与变量:同名冲突是事故的温床
参数和变量命名的第一原则是“不得同名”。虽然SQL Server允许参数和变量重名,但代码里一旦出现@Status既来自参数又被后续赋值,维护的人很难分清楚哪一次赋值才是最终结果。我们采用的约定是:输入参数用@+ PascalCase(如@OrderID、@StartDate),局部变量增加Local后缀或前缀(如@OrderIDLocal、@vCount)。
变量前缀要不要保留@是必须的,但类型前缀就看团队喜好。我们虽然不强制@iCount、@sName这种匈牙利式写法,但建议变量名一定要可读。比如下面这种代码就会被打回:
DECLARE @s AS VARCHAR(20) SET @s = (SELECT TOP 1 Status FROM dbo.Orders WHERE OrderID = @OrderID)问题不在于@s短,而在于后续引用@s时,读者必须回看赋值语句才知道它代表什么。更合理的写法是@StatusLocal,即使代码再长,看到名字就知道含义。
表别名也属于命名的一部分。单表操作允许用表的首字母做别名,比如FROM dbo.Orders AS o;多表关联时,别名必须能映射到表名。SELECT ... FROM Orders o JOIN Customers c这种能看懂,但FROM Orders a JOIN Customers b JOIN Products c就完全不行。规范里直接禁止了a/b/c这种纯字母序号别名。
2.3 头部注释块与变更记录:别让后人靠git log猜业务
每个存储过程顶部必须放一个固定格式的注释块,包含:过程用途、涉及业务模块、依赖的关键表、创建人、创建日期、修改记录。修改记录是重点,哪怕只是改了一个WHERE条件,也要追加一行:修改人、日期、修改原因、关联需求单号。
我们甚至把模板注释直接写到代码片段里,所有新过程一开始就带注释骨架:
-- ===================================================================== -- 存储过程:usp_Order_GetById -- 用途:根据订单ID返回订单主表信息 -- 依赖表:dbo.Orders, dbo.Customers -- 创建人:张三 -- 创建日期:2024-06-01 -- 修改记录: -- 2024-08-15 李四 增加IsDeleted过滤,避免返回软删除数据 -- =====================================================================为什么要强制写修改原因?因为数据库对象不像代码文件,很难在IDE里追踪每次历史改动。仅仅是ALTER PROCEDURE脚本在版本库里,但存储过程代码里如果不留线索,下次接手的人很可能被一段“看起来多余”的过滤条件误导。我们吃过这个亏:一个同事为了临时修数据加了WHERE OrderID = 123,上线后忘记删,后续所有调用都只返回那一条订单。如果有注释记录修改原因,这种问题至少能早一步暴露。
2.4 格式化:让每条SQL都能被快速审阅
格式化规则不需要标新立异,关键在于统一。我们的硬性要求包括:SQL关键字统一大写;每个主要子句另起一行;缩进使用4个空格,禁止Tab;JOIN条件与WHERE条件对齐;一条语句不要写超过120字符,超长就换行。
还有一个容易被忽略的点:所有存储过程开头必须写SET NOCOUNT ON;。如果不写,INSERT/UPDATE影响行数会被当成结果集返回给客户端,在ORM框架里很可能被误认为查询结果,导致数据读取错位。这属于写入规范第一条就强制执行的“保命操作”。
下面这段代码是规范内的标准格式:
CREATE PROCEDURE usp_Report_Sales_Daily @BusinessDate DATE AS BEGIN SET NOCOUNT ON; SELECT o.OrderDate, COUNT(DISTINCT o.OrderID) AS OrderCount, SUM(od.Quantity) AS TotalQuantity FROM dbo.Orders AS o INNER JOIN dbo.OrderDetails AS od ON od.OrderID = o.OrderID WHERE o.OrderDate >= @BusinessDate AND o.OrderStatus IN ('Paid', 'Shipped') GROUP BY o.OrderDate ORDER BY o.OrderDate; END这种格式一眼能看出语句结构,Review的时候扫读代价低。我们不会强制每个人都用SQL Prompt的自动格式化,但要求提交前在SSMS里做一次标准“美化SQL”,再做人工检查。
3. 参数、游标与临时表:规范中的算法与资源使用规则
3.1 参数默认值与校验:提前失败比运行时报错好一万倍
存储过程的参数就是对外接口的契约。规范要求:每个输入参数必须明确类型和长度,不推荐省略长度(比如VARCHAR必须写成VARCHAR(50)),否则默认长度为1,容易出现截断。所有参数必须考虑是否有默认值,对于“必填”参数,不应给默认值,而是在过程开头校验。
参数校验的常见写法是先用IF检查违反条件,然后直接返回统一状态码。比如:
IF @OrderID IS NULL OR @OrderID <= 0 BEGIN SET @ReturnCode = 2; -- 参数错误 RETURN @ReturnCode; END这里有两个容易踩的坑。第一个是只判断NULL,不判断业务上的非法值(如负数、空字符串);第二个是在校验失败时直接RETURN,却忘了如果前面已经开启事务会留下未提交事务。所以规范里建议所有校验放在开头、事务开启之前完成,这样“提前失败,失败了就干净退出”。
3.2 游标:允许用,但有前置条件和固定释放顺序
很多优化文章把游标骂得一文不值,但在SQL Server里,游标并不是完全不能用。公司规范是这样定义的:游标只允许在“必须行级处理且无法用集合操作替代”的场景使用,比如逐行调用另一个存储过程、逐行执行某种复杂计算。所有纯查询统计类需求,如果可以用ROW_NUMBER()、LAG()、聚合加窗口函数实现,一律禁止游标。
确实要用游标时,声明语句必须带LOCAL READ_ONLY FORWARD_ONLY FAST_FORWARD。这三组选项分别表示局部游标、只读、只能前进且快进,能把游标的资源开销和锁风险降到最低。释放顺序规范为:先CLOSE,再DEALLOCATE。很多人只CLOSE不DEALLOCATE,临时游标也会一直占用内存,直到过程结束。
如果游标循环里还需要做UPDATE,建议改成集合更新或者临时表加批量更新。我们的实际经验是,很多“必须逐行更新”的逻辑,最终都发现是写代码的人对集合操作不熟悉。规范给了一个缓冲通道:提交Review时如果选择游标方案,必须在注释里说明为什么不能用UPDATE ... FROM或MERGE替代,这个门槛已经劝退了不少偷懒写法。
3.3 临时表与表变量:不是一层不变的选择题
表变量和本地临时表的取舍,是存储过程规范里争议最大的部分。我们的原则是:数据量小(通常几百行以内)、不与大表做复杂JOIN、不需要索引时,用表变量;数据量大、需要建立索引、会被多次重用,用本地临时表#Temp。
原因在于表变量没有统计信息,优化器对它内部行数的预估永远从1行开始。如果后续JOIN一张几百万行的表,优化器可能选择嵌套循环,结果慢到让人怀疑机器坏了。本地临时表有统计信息,且支持CREATE INDEX,但写入时会增加tempdb的开销和日志压力。
规范明确禁止全局临时表##TempTable,也禁止在存储过程里直接CREATE TABLE dbo.SomeTempTable。前者容易造成跨会话污染,后者会污染业务库结构。所有临时表都要在结束前显式删除,最好用DROP TABLE IF EXISTS #Temp;,以确保下次执行不会被残留定义干扰。
3.4 那些容易让优化器“摆烂”的写法
规范里列了三条最基础的性能红线,每条都出现过真实事故。第一条是禁止SELECT *。存储过程的结果集是隐式接口,一旦表结构增加字段,应用层和报表里的列映射可能直接崩掉;即便不崩,也可能多传了没必要的大字段,白白浪费网络IO和内存。
第二条是禁止在索引列上使用函数或隐式转换。比如WHERE CONVERT(VARCHAR, CreateDate, 112) = '20240815'会让索引失效,正确写法是WHERE CreateDate >= '2024-08-15' AND CreateDate < '2024-08-16'。另外还有典型的WHERE VarcharColumn = @IntValue,SQL Server会做隐式类型转换,索引同样失效。我们会在Review时要求开发人员用SSMS的“包含实际执行计划”检查一下每张表最终是Seek还是Scan,如果出现CONVERT_IMPLICIT警告,一律打回。
第三条是别在WHERE里用不等于、LIKE '%xxx'或对列做运算。这些不一定全表扫描,但会显著限制优化器利用索引的能力。如果业务确实需要前模糊匹配,要考虑全文索引或单独的表设计,而不是靠存储过程硬扛。
4. 错误处理与事务控制:数据一致性优先于一切
4.1 一套统一的TRY-CATCH骨架,减少“异常裸奔”
我们强制所有存储过程主体包在BEGIN TRY ... BEGIN CATCH里,CATCH块承担三个职责:回滚事务、记录错误日志、返回统一状态码。代码骨架大概是:
BEGIN TRY BEGIN TRANSACTION; -- 业务逻辑 COMMIT TRANSACTION; END TRY BEGIN CATCH IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; INSERT INTO dbo.ErrorLog ( ProcedureName, ErrorNumber, ErrorSeverity, ErrorState, ErrorLine, ErrorMessage, LogTime ) VALUES ( OBJECT_NAME(@@PROCID), ERROR_NUMBER(), ERROR_SEVERITY(), ERROR_STATE(), ERROR_LINE(), ERROR_MESSAGE(), GETDATE() ); SET @ReturnCode = 9; -- 未知异常 RETURN @ReturnCode; END CATCH这里有个关键点:@@PROCID只能拿到当前存储过程的ID,如果异常发生在过程内部调用的另一个过程里,OBJECT_NAME(@@PROCID)返回的是内层过程名,而外层记录的日志可能就不够精确。为了快速定位链路,规范要求错误消息或日志里额外记录应用层传入的入口参数,至少记录关键业务主键。
4.2 XACT_ABORT 和 XACT_STATE:事务回滚的保命符
很多开发同学不知道SET XACT_ABORT ON的作用。默认情况下,运行时错误(如除零错误、违反约束)只回滚出错的语句,不回滚整个事务。结果是用户提交一个批量操作,第5条数据失败,前4条已经落库,业务上出现半截数据。开启SET XACT_ABORT ON后,只要会话中有活动事务,任何运行时错误都会让整个事务自动回滚。
所以在任何包含事务的存储过程开头,规范强制增加一行SET XACT_ABORT ON;。但这还不够,CATCH块里必须配合XACT_STATE()做判断。XACT_STATE()返回 -1 表示事务已被提交或回滚中止,无法继续;返回0表示没有活动事务;返回1表示事务可提交。我们的CATCH块模板判断的是“如果不为0就回滚”,也就是说即使事务已经被判定为XACT_STATE() = -1,回滚语句本身也安全,因为此时未提交事务仍需清理。
这里再提醒一个不太常见的坑:THROW语句不能直接跟在一个BEGIN TRANSACTION和COMMIT之后不带分隔符的位置,有些版本会报错。我们的模板统一用RAISERROR配合RETURN返回状态码,而不是把异常直接抛到应用层,这样处理链路更可控。
4.3 错误日志:记录、报警、但别回滚主业务
错误日志表是必须的,但设计上有讲究。我们采用一张dbo.ErrorLog表,字段包括LogID、ProcedureName、Parameters、ErrorNumber、ErrorMessage、ErrorLine、LogTime。在CATCH块中记录日志时要注意:如果日志部分也在事务内,并且当前事务已经处于不可提交状态,日志写入会失败。正确做法是先用变量把错误信息保存下来,回滚事务后再插入日志。上面骨架里的顺序正是“先ROLLBACK,再INSERT”,避免日志写入跟脏事务绑定。
错误日志表本身要有节制,不要记录到应用层排查压力过大,也不要在存储过程里把ERROR_MESSAGE()原样返回给前端。我们的约定是:存储过程只返回状态码和一条简短描述,详细错误信息一律留在日志表。生产环境如果出现异常,DBA通过日志表定位,应用层只展示“操作失败,请稍后重试”之类的文案。
4.4 统一返回码和错误消息:应用层不用再猜
这套模板里有一个全局状态码表,所有存储过程都要遵循。目前约定如下:
| 返回码 | 含义 | 典型场景 |
|---|---|---|
| 0 | 成功 | 正常完成 |
| 1 | 通用错误 | 未分类异常 |
| 2 | 参数错误 | 参数缺失、类型非法、数值越界 |
| 3 | 无权限 | 用户无执行权限 |
| 4 | 业务规则冲突 | 库存不足、状态不允许 |
| 5 | 数据不存在 | 主键找不到记录 |
| 9 | 未知异常 | CATCH块捕获 |
这么做的直接好处是,应用层不用依赖异常文本去判断错误类型,而是通过返回码走分支。比如前端判断“库存不足”时可以弹提示,判断“数据不存在”时引导刷新列表。过去我们没做统一,有的过程返回1表示成功,有的返回-1,还有的直接用SELECT 'ok'当结果,导致接口联调成本极高。
5. 性能红线、安全与运维:规范能不能扛住生产环境
5.1 参数嗅探不是玄学:什么时候允许RECOMPILE
存储过程默认会沿用第一次编译时的执行计划,这在数据分布不均匀时会出问题。比如一个订单状态字段,99%的订单是已完成,只有1%是待付款。过程第一次执行时传入“待付款”,生成了适合少量数据的执行计划;后续传入“已完成”,这个计划却可能导致扫描大量数据。
针对这种情况,规范给出的不是一刀切禁用参数嗅探,而是区分场景。对低频但单次执行代价高的查询,允许在关键语句上加OPTION(RECOMPILE),让每次执行都重新生成计划;对高频小查询,不建议使用,因为重新编译的开销可能超过查询本身。还有一种处理方式是OPTION(OPTIMIZE FOR(@Status = N'已完成')),把执行计划固定为最常出现的参数值,但这需要DBA和开发一起评估。
上线前,每个新存储过程都要求至少跑一次执行计划,检查是否有表扫描、键查找、隐式转换图标。我们用一句标准的话术卡“玩家”:SET STATISTICS IO, TIME ON;后观察逻辑读数量,如果一个报表过程逻辑读超过10万次,没有合理分页或过滤,直接打回。
5.2 动态SQL的两条生路和三道关卡
动态SQL是存储过程规范里最容易被滥用的部分。有人为了省事,把所有查询条件拼进字符串,然后在过程里EXEC(@sql)。这种代码可读性差、难调优,更关键的是SQL注入风险极高。
规范允许动态SQL的两种场景:第一,表名或列名无法参数化,比如按月分表,只能在运行期决定查哪一张表;第二,用户需要动态排序字段和排序方向。除此之外一律不允许。
即便属于允许场景,也要过三道关卡:一是所有外部输入不能直接拼接,必须传入参数给sp_executesql,或者对表名/列名做严格的白名单校验;二是动态SQL文本必须放在注释里说明“为什么不能静态化”;三是涉及排序字段时,只允许开发人员从一个允许列表里选择,比如OrderBy = 'OrderDate',而不是直接接受用户传入的任意列名。
我们在Review时对EXEC(关键字几乎是零容忍。如果看到动态SQL没有满足这两个场景,GP直接打回。这个红线救了公司不止一次,最危险的一次是同事把用户输入的搜索关键词直接拼进了LIKE '%' + @Keyword + '%',一旦遇到引号和分号组合,后果不敢想。
5.3 权限最小化和敏感信息保护
存储过程的权限模型,理想状态是:用户只被授予EXECUTE权限,不做任何表的直接访问。这样所有数据访问都经过过程内部的逻辑,便于审计。规范要求每个过程在创建时明确执行权限,禁止为了省事直接把db_owner授权给应用账号。
对于敏感性特别高的过程,比如涉及资金、个人隐私数据的更新,建议通过EXECUTE AS结合权限签名来处理,而不是直接给过程设置EXECUTE AS OWNER。后者虽然能绕过表权限,但如果表的属主权限过大,风险也集中在一个点。
还有一条不太起眼的规则:禁止在存储过程里硬编码连接字符串、口令、加密密钥。有的过程会把第三方接口的Token写在代码里,每次改密钥都要改存储过程,这是灾难。密钥应该放在配置表或密钥管理服务中,过程通过外部接口读取。
5.4 提交前的checklist:哪些词一旦出现就打回
我们把存储过程上线前的Review做成了一个固定checklist,供DBA和架构师使用。检查清单包括:是否包含SELECT *;是否存在表名/列名的隐式转换;是否使用sp_前缀;是否缺少SET NOCOUNT ON;是否开启事务却缺少SET XACT_ABORT ON;CATCH块是否记录错误日志;是否使用未释放的游标;是否存在动态SQL且未满足允许场景。
这个清单还可以通过脚本自动扫描。我们会在下一部分给出参考脚本,把常见违规词直接从OBJECT_DEFINITION里捞出来。这比人工翻代码高效得多,至少能把低级错误挡在上线之前。
6. 落地,才是规范真正的开始:模板和配套工具
6.1 老代码改造节奏与新人培训
规范落地最怕“一步到位”。公司里几百个存量存储过程,不可能一个月全部改完,强行改造反而容易引入新问题。我们的方案是“增量强制、存量渐进”:新需求、新存储过程必须完全符合规范;老代码在每次因为需求变更而要修改时,顺带把命名、注释、错误处理补齐,做到“改一行,规范一行”。
新人培训也不需要开大课。我们把模板文件放到共享库,里面包含标准注释块、标准TRY-CATCH骨架、状态码说明、常用临时表和游标写法。每次CR时,最痛苦的反而是那些“只可意会”的隐性要求。后来我们把checklist做成代码Review模板,在提合并请求时自动带上,审核人只需要逐项勾选并指出违规位置,沟通成本大幅下降。
6.2 公司内部模板的完整示例
下面给出一份可以直接拷贝到团队文档里的简化但完整的存储过程模板。它包含了我们前面讲到的所有核心约束:
CREATE PROCEDURE dbo.usp_Order_GetById @OrderID INT, @IncludeDetails BIT = 0 AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; DECLARE @ReturnCode INT = 0; -- 参数校验 IF @OrderID IS NULL OR @OrderID <= 0 BEGIN SET @ReturnCode = 2; RETURN @ReturnCode; END BEGIN TRY SELECT o.OrderID, o.OrderNo, o.CustomerID, c.CustomerName, o.OrderDate, o.OrderAmount, o.OrderStatus FROM dbo.Orders AS o INNER JOIN dbo.Customers AS c ON c.CustomerID = o.CustomerID WHERE o.OrderID = @OrderID AND o.IsDeleted = 0; IF @ReturnCode = 0 SET @ReturnCode = 5; IF @IncludeDetails = 1 BEGIN SELECT od.DetailID, od.ProductID, p.ProductName, od.Quantity, od.UnitPrice, od.LineTotal FROM dbo.OrderDetails AS od INNER JOIN dbo.Products AS p ON p.ProductID = od.ProductID WHERE od.OrderID = @OrderID AND od.IsDeleted = 0; END SET @ReturnCode = 0; RETURN @ReturnCode; END TRY BEGIN CATCH DECLARE @ProcName SYSNAME = OBJECT_NAME(@@PROCID); DECLARE @ErrMsg NVARCHAR(2048) = ERROR_MESSAGE(); INSERT INTO dbo.ErrorLog ( ProcedureName, Parameters, ErrorNumber, ErrorSeverity, ErrorState, ErrorLine, ErrorMessage ) VALUES ( @ProcName, CONCAT('@OrderID=', @OrderID), ERROR_NUMBER(), ERROR_SEVERITY(), ERROR_STATE(), ERROR_LINE(), @ErrMsg ); SET @ReturnCode = 9; RETURN @ReturnCode; END CATCH END GO这里额外说明一点:模板中IF @ReturnCode = 0 SET @ReturnCode = 5;这种写法有争议,因为如果查询确实返回了数据,后续会覆盖为0。这种写法并不推荐,纯粹为了演示。实际我们更推荐用IF NOT EXISTS单独判断数据是否存在。模板最好是贴近真实合理逻辑,避免把奇怪技巧当成标准。
另外,上面的CATCH块里没有处理事务,是因为这个示例本身没有开启事务。如果过程里有写操作,一定要按第4节的顺序:先回滚事务,再记录错误日志。
6.3 自动扫描违规存储过程的参考脚本
规范不能只靠人盯,还要让数据库自己“举报”。下面这个查询可以在当前库里找出所有包含SELECT *的存储过程:
SELECT p.name AS ProcedureName, m.definition AS ObjectDefinition FROM sys.procedures AS p INNER JOIN sys.sql_modules AS m ON m.object_id = p.object_id WHERE m.definition LIKE '%SELECT *%' ORDER BY p.name;还能扩展出其他筛查项:包含sp_前缀的对象、包含NOLOCK提示、包含EXEC(动态SQL、包含CREATE TABLE dbo.等。把这些查询组合成一个“存储过程规范体检”脚本,每次发布前跑一遍,输出疑似违规清单,然后人工甄别。甄别过程要注意,注释里也可能出现这些关键字,所以不能直接全量封禁,脚本只做初筛,最终判断还是由Review人员完成。
我还见过更省事的做法:在SSDT数据库项目的规则或SQL Server的上线审核工具里挂上这些条件,发布时自动拦截。不过这需要团队已经用上持续集成流水线。如果还没有,先从一段筛查脚本开始也完全够用。
说回这套模板本身,它最核心的价值其实不是“强制大家怎么写”,而是“减少用存储过程时那些想当然的坑”。从命名到错误处理,再到性能红线,每一条背后都有真实事故当证据。团队每次不同意某条规则时,我都会让对方想想是不是只想和稀泥。如果真的出现新问题,规范也会更新——它是活的,不是贴在墙上的摆设。
最后分享一个我自己的习惯:每用模板创建一个新存储过程,我会在代码顶部注释里写下“为什么要有这个过程”,而不是只写“做什么”。三个月后你回头看,这段注释往往比那一百行SQL更救命。如果你也想在公司推这套东西,建议少谈大道理,直接把模板文件发到群里,从让所有人用同一份骨架开始。