
作为在 SQL Server 上摸爬滚打了十几年的老家伙我见过太多因为存储过程写得烂导致线上炸锅的现场。存储过程这个东西写好了是性能利器写不好就是维护黑洞。我们团队前两年定了一套《SQL Server 存储过程开发规范》目的很简单让新人照着模板写就能写出质量及格、不会给线上惹麻烦的存储过程让老人 review 代码时有据可依不用靠嘴吵架。这份规范在公司内部迭代过三个版本期间顺手解决了不少生产事故今天把完整模板和背后的思考一起整理出来。如果你也在带数据库开发团队、或者在维护一堆历史遗留存储过程这份东西值得你收藏。1. 为什么要有一份存储过程开发规范1.1 没有规范时的混乱现场我在实际项目里见过的“野生”存储过程大概有这些毛病命名随心所欲sp_XXX、proc_XXX、P_XXX还有一些完全没有前缀的。一个存储过程塞了两千行SELECT、UPDATE、DELETE 混在一起没人敢动。没有参数校验传 NULL 就报错传负数也不拦线上数据被“铅笔刀”割得七零八落。错误处理靠“执行完看结果集”根本没有 TRY/CATCH出错了也不知道错在哪一行。事务开了不提交COMMIT/ROLLBACK 写在奇怪的分支里一个分支漏了连接池就慢慢被拖死。变量命名叫a、b、c三个月后作者自己也看不懂。依赖默认排序做逻辑判断结果集顺序一变整个报表全错。这些问题的共同点是当时能跑后来全炸。改需求的 DBA 骂娘查问题的运维熬夜接手的开发直接想提离职。我印象最深的一次一个订单统计存储过程因为没有统一规范三个版本换三个人维护每人往里加了一段自己的风格最后变成一坨只能整体重写、没人敢碰的“祖传代码”。说白了存储过程不是给个人炫技用的它是团队资产是会被人接手的代码。没有规范就是给后人埋雷。1.2 这份规范要解决的核心问题所以这份规范在起草时我只定了三个底线目标可读性任何人接手十五分钟内能看懂这个存储过程在干什么。可维护性改需求时知道在哪里改、改完怎么测、影响哪些分支。性能不失控不要求每个存储过程都是性能冠军但堵死常见的性能自杀写法。我明确说规范不是为了限制创造力而是为了把容错率低的场景变成肌肉记忆。应用层代码写错了可以灰度回滚数据库这一层出问题影响的不是某个接口而是整条业务链路。这个账必须算清楚。2. 规范模板的整体结构与核心约定2.1 命名规范让人一眼看懂对象归属命名是第一道防线也是成本最低的规范。我们内部对存储过程的命名做了硬性约定前缀统一使用usp_User Stored Procedure避免和系统存储过程sp_混淆。sp_前缀在 SQL Server 里有特殊含义用户存储过程如果也叫sp_开头有可能导致系统优先去 master 库查找带来不必要的性能开销和命名冲突风险。这一点在 SSMS 里反复踩过所以直接一刀切禁止。格式统一为usp_业务模块_动作_业务对象例如usp_Order_Get_List、usp_Order_Save_Detail、usp_Report_Get_DashboardData。动词统一Get查询、Save新增或更新、Delete删除、Export导出、Maintain定期维护。名词统一业务对象用单数表名是什么就用什么不要发明自定义缩写除非缩写已经写进团队的领域词典。变量参数这块我们也有约定入参统一p_开头例如p_OrderId。内部变量统一v_开头例如v_TotalAmount。游标变量c_开头输出参数o_开头。这套前缀规则看着简单但极端实用。在几百行存储过程里看到v_知道是本地变量看到p_知道是入参不会搞混。而且 IDE 智能提示里敲个前缀就能过滤变量列表不用在一堆变量名里翻。2.2 标准头部注释每个存储过程都要有“身份证”我要求每个存储过程文件的开头必须有标准注释模板不允许裸奔。模板长这样/* * 存储过程名usp_Order_Get_List * 功能描述分页查询订单列表支持按订单状态/时间范围过滤 * 作者张三 * 创建日期2025-01-15 * 修改记录 * 2025-03-01 李四 增加 p_Channel 渠道过滤参数对应需求单 REQ-2025-0123 * 2025-06-11 王五 修复订单时间索引失效问题将 BillingTime 外层函数去掉 * */别小看这个头部它有四个实际价值防止“这段逻辑是谁写的”这种考古行为。修改记录能追踪到需求单出了问题知道回溯哪个变更。强制每个过程明确自己的定位它到底干什么哪些需求它负责。Review 时扫一眼就知道改了哪里、改动范围多大。这里有个细节修改记录不要写“优化性能”这种大实话要写清楚“做了什么、为什么”。比如“修复订单时间索引失效问题去掉函数包裹”这才是有效记录。我见过写“优化”但完全看不出来优化了什么的记录等于没写。2.3 标准代码骨架统一的结构不让个人风格发挥我们规定的存储过程主体骨架是这样的CREATE PROCEDURE dbo.usp_Order_Get_List p_OrderId BIGINT NULL, p_Status INT NULL, p_PageNumber INT 1, p_PageSize INT 20 AS BEGIN SET NOCOUNT ON; -- 第一部分参数校验 IF p_PageNumber 1 OR p_PageSize 1 OR p_PageSize 200 BEGIN RAISERROR(N分页参数非法PageNumber%d, PageSize%d, 16, 1, p_PageNumber, p_PageSize); RETURN; END; -- 第二部分初始化变量赋值、临时表准备 DECLARE v_TotalCount INT; -- 第三部分业务处理核心逻辑 SELECT ... FROM dbo.Orders WHERE OrderId p_OrderId; -- 第四部分输出结果或返回码 SELECT v_TotalCount; END; GO为什么按这个顺序因为数据库处理的特点是“越早拦截越便宜”。参数校验放在最前面避免无效参数进入后面的复杂逻辑初始化放在业务前保证业务分支里不会临时到处翻变量业务处理保持线性流程不做 GOTO 跳转不搞多层嵌套到地狱级缩进。遇到特别复杂的过程允许拆成多个部分但每个部分必须有块级注释开头例如-- 业务处理生成对账单 这条规范帮了大忙。Review 时直接按块跳着看不用从头怼到尾接手的人也不需要把整个过程通读一遍才能定位逻辑。3. 错误处理与事务控制的实操约定这是存储过程最容易翻车的地方。我对团队的要求是所有可能失败的存储过程必须套统一的 TRY/CATCH 模板不允许自己写奇奇怪怪的错误判断。3.1 统一错误捕获机制标准模板如下CREATE PROCEDURE dbo.usp_Order_Save_Detail p_OrderId BIGINT, p_DetailJson NVARCHAR(MAX) AS BEGIN SET NOCOUNT ON; DECLARE v_TranCount INT TRANCOUNT; BEGIN TRY IF v_TranCount 0 BEGIN TRANSACTION; ELSE SAVE TRANSACTION SavePoint_SaveDetail; -- 业务逻辑处理INSERT/UPDATE/DELETE IF v_TranCount 0 COMMIT TRANSACTION; END TRY BEGIN CATCH IF v_TranCount 0 BEGIN IF TRANCOUNT 0 ROLLBACK TRANSACTION; END ELSE BEGIN IF TRANCOUNT 0 ROLLBACK TRANSACTION SavePoint_SaveDetail; END; -- 写错误日志 EXEC dbo.usp_Common_WriteErrorLog p_ProcedureName Nusp_Order_Save_Detail, p_ErrorMessage ERROR_MESSAGE(), p_ErrorNumber ERROR_NUMBER(), p_ErrorLine ERROR_LINE(); -- 抛出统一错误 THROW; END CATCH; END; GO这里有个关键点很多人不知道存储过程是可以在事务里被调用的。如果外面已经开了事务你里面直接COMMIT会把外层事务提前提交一部分破坏整个事务的原子性如果直接ROLLBACK则会把外层事务一起回滚掉。所以我用TRANCOUNT判断自己是事务发起者还是参与者自己是发起者TRANCOUNT 0事务最终由我 COMMIT 或 ROLLBACK没问题。自己只是参与者TRANCOUNT 0只保存一个事务存档点SAVE TRANSACTION出错时只回滚到存档点不影响外层事务继续使用错误信息抛给外层处理。这个模式在公司内部被称为“嵌套事务安全写法”。自从写进规范后因为“存储过程把整个大事务回滚导致数据全丢”的生产事故基本绝迹。3.2 错误日志表与统一记录函数错误处理不能只是吞掉或抛错必须留痕。我们配套建了一个统一的错误日志表CREATE TABLE dbo.ErrorLog ( LogId BIGINT IDENTITY(1,1) PRIMARY KEY, LogTime DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(), ProcedureName SYSNAME NOT NULL, ErrorMessage NVARCHAR(MAX) NULL, ErrorNumber INT NULL, ErrorSeverity INT NULL, ErrorState INT NULL, ErrorLine INT NULL, UserName SYSNAME NULL, HostName NVARCHAR(128) NULL );配套存储过程usp_Common_WriteErrorLog负责写日志。我不允许每个存储过程自己写INSERT INTO ErrorLog因为那样每个过程都要记一遍字段很容易写漏参数格式也会五花八门。统一函数保证日志格式永远一致。查询日志直接用视图配合聚合报表每周自动统计错误 Top10 的过程我们周一 review 这些数据数据库健康度一目了然。3.3 参数与结果的边界约定参数校验不要只在应用层做存储过程自己也要做。因为直接连数据库执行的人运维、DBA、临时跑脚本的可不会经过你的应用校验。必给的参数没有默认值传了 NULL 直接抛错。范围参数分页大小、金额、数量必须有上下限。字符参数订单号、手机号这类要做长度和模式校验。输出约定查询类统一用结果集返回不允许用拼接字符串返回更不允许用 PRINT 输出调试信息带到生产。非查询类用返回值返回影响行数或业务码比如 0 成功、-1 参数错误、-2 业务冲突具体业务码写进头部注释。需要给应用层传递状态时优先使用 OUTPUT 参数而不是返回值。SQL Server 的 RETURN 只能返回 int语义有限而且负值范围历史上容易和系统错误码混淆。OUTPUT 参数可以返回 NVARCHAR、BIGINT甚至状态描述文本应用层拿来做提示更方便。4. 性能底线写存储过程的硬性要求规范里性能部分最容易被当成“建议”但我不接受。存储过程写出来是给生产跑的性能不是优化需求是底线。我整理了一份“防坑清单”每条都来自真实事故。4.1 索引杀手禁写隐式转换与函数包裹第一条硬性要求WHERE 条件中索引列严禁出现函数包裹和隐式类型转换。用例子说明。假设订单表 OrderId 是 BIGINT-- 错误把索引列用函数包了一圈索引直接失效 WHERE CONVERT(VARCHAR(20), OrderId) 12345; -- 错误类型不匹配BIGINT列和字符串比较会触发隐式转换 WHERE OrderId 12345; -- 推荐类型一致直接比较能走索引 WHERE OrderId 12345;很多开发者觉得“数据库会自动转换没问题”实际上确实能转换但转换发生之后索引就废了走全表扫描数据量大时直接拖垮 IO。我见过最夸张的一个案例一张 2000 万行的流水表因为查询条件写了个CONVERT(VARCHAR(10), CreateTime, 120) 2025-06-01整整扫了全表磁盘 IO 打满调度任务全部阻塞。换成CreateTime 2025-06-01 AND CreateTime 2025-06-02之后毫秒级返回。这不是技巧问题是基本功。与日期相关的规范我单独强调不要用DATEDIFF、YEAR()、CONVERT()这类函数直接包日期列。日期范围查询统一用左闭右开区间CreateTime p_Start AND CreateTime DATEADD(DAY, 1, p_End)。如果需要按年/月分组保留计算列并建索引而不是在查询时临时函数包裹。4.2 游标、动态SQL与SELECT * 的使用限制这三样是存储过程里的高风险操作规范对它们的处理是分层管理操作规约原因游标禁止在 OLTP 核心链路中使用只有数据量小且无法用集合逻辑替代时才允许且必须加LOCAL STATIC READ_ONLY FORWARD_ONLY选项逐行处理是性能杀手绝大多数游标都可以改写成 JOIN 或窗口函数动态SQL重要业务禁止只能在报表/配置类场景使用必须用sp_executesql参数化严禁字符串拼接拼接 SQL 是注入漏洞和生产事故的高发区SELECT *一律禁用多余列的读取浪费 IO还破坏结果集结构的稳定性关于游标很多人不服气觉得“我数据不就一千行嘛”。但我要说的是一千行的游标可能要跑 500ms改成集合操作可能只要 30ms而且还隐藏在循环里套游标的场景那就是 n 乘 m 的乘法爆炸。SQL Server 的优化器对集合逻辑有大量优化手段对游标完全无能为力。存储过程的表达方式应该是“我要什么数据”而不是“我一行一行怎么处理”。4.3 大结果集场景临时表、表变量与统计信息检查临时表和表变量的选择在团队里经常吵。我们的规范给出一刀切的判断标准中间结果如果超过几千行用临时表如果就是几十行配置量级表变量更快也避免 tempdb 压力。原因是表变量的统计信息很弱SQL Server 对表变量行数的预估假设非常保守数据量一上来就是灾难临时表有统计信息还能在表上建索引适合做大数据量的中转。另外还有一条强制要求任何存储过程提交上线前必须检查执行计划并确认关键表的数据量级。Review 的人照着下面的清单过是否出现表扫描Table Scan / Index Scan且涉及表超过 10 万行必须说明原因。是否存在明显行数误判estimated 和 actual 差一个数量级需要考虑更新统计信息。是否需要加OPTION (RECOMPILE)来规避参数嗅探但要确认参数分布确实不均衡不能无脑加。排序操作是否出现在不该出现的位置如果是为了 DISTINCT 或 UNION考虑是否可以直接优化。这条规范执行之后新上线的存储过程很少再出现“上线第一天把数据库拖垮”的情况。5. 版本管理与上线流程规范模板的配套机制一份规范如果不包含上线流程等于教了怎么开车但没教交规。存储过程的上线比其他代码更危险因为它是直接作用在数据库里的错了没有编译期兜底一执行就生效。5.1 存储过程也要版本管理我们的做法是把所有存储过程脚本纳入版本库管理目录结构如下/SQLScripts /001_Schema /001_20250101_Create_ErrorLog.sql /002_StoredProcedures /usp_Order_Get_List.sql /usp_Order_Save_Detail.sql要求每个存储过程一个独立 SQL 文件文件名与存储过程名完全一致避免“找半天不知道哪个文件对应哪个过程”。文件内容是一个可重复执行的CREATE OR ALTER语句SQL Server 2016 SP1 都支持这样应用发布时可以幂等更新脚本重跑不会报错。一次变更一个 PRPR 描述必须关联需求单号变更记录同步更新到过程头部的修改记录里两边对得上。以前团队的习惯是把所有过程放在一个巨型 SQL 脚本里改一次牵一发动全身经常有开发说“我只是改了一个过程为什么另一个过程也变了”。用独立文件后这类问题基本消失。5.2 上线脚本模板与检查单新存储过程上线时我们要求走下面这个流程准备阶段在测试环境执行脚本跑通核心业务流程记录执行时间。预发布检查确认过程引用的所有表、字段、函数、依赖过程都存在避免上线时遇到运行时错误。发布窗口在低峰期执行。要求执行前先BEGIN TRAN执行后先不COMMIT用另一个会话做冒烟验证验证通过再提交事务。这是防止“过程里有个运行时才发现的小问题一执行就污染线上数据”的保险手段。验证阶段上线后查询性能计数器对比旧版本和新版本的关键指标至少观察一个业务周期。回滚预案每个上线变更必须写回滚方案。新过程就是删除过程修改过程就是保留旧版本脚本一个 SQL 文件搞定。我特别强调冒烟验证那一步。很多团队上线存储过程很粗暴SSMS 里按一下 F5结果一执行就发现少了个字段。这类问题在开发环境抓不出来吗能。但生产环境是数据和业务都在实跑的地方容错率极低。所以哪怕是几十行的过程上线也要走“事务包裹 冒烟验证 回滚预案”这是流程问题不是技术问题。6. 常见问题与排查技巧实录规范执行过程中团队踩过不少坑我挑几个高频的记录下来给后面接手的人当速查手册用。6.1 问题一编译通过一执行就性能崩盘症状过程写完跑测试环境没问题上生产后第一次执行直接超时。排查思路第一步看执行计划里有没有表扫描、索引查找偏差。第二步对比参数和统计信息。生产数据量级和分布往往和测试环境完全不同同一个查询在 200 万行和 2 万行的表上优化器决策完全不同。第三步看参数嗅探。如果同一个过程传不同参数时执行计划差异巨大考虑OPTION (RECOMPILE)或者把关键查询拆成独立过程。实战建议线上排查时顺手开SET STATISTICS IO ON; SET STATISTICS TIME ON;看逻辑读。逻辑读骤增就是问题点别上来就改代码先定位到具体语句再改不然越改越乱。6.2 问题二死锁与阻塞存储过程跑着跑着报死锁这是 OLTP 环境最常见的事故。规范层面的应对建议统一代码里表的访问顺序。所有过程按相同顺序访问表减少死锁概率。事务尽可能短。一个大事务包了一堆查询和业务处理锁持有时间长碰撞概率就高。我们的规范里要求事务里只放真正的写操作查询和计算尽量放事务外。使用READ COMMITTED SNAPSHOT或SNAPSHOT ISOLATION降低读写阻塞但要评估 tempdb 负载与业务语义。排查死锁看系统视图sys.dm_exec_requests和错误日志或者开 1204/1222 跟踪标记把死锁图 dump 出来分析。我亲历的一个案例某过程在事务里先更新一张汇总表再查一个大视图本来没事但另一个过程反着顺序更新于是两个过程互相等锁。最后不是靠代码技巧解决的而是规范了所有过程“先汇总表后明细表”的访问顺序死锁直接消失。分享这个是为了说明有时候死锁不是代码智商问题是“访问顺序没对齐”这种低层规范问题。6.3 问题三参数嗅探导致同一个过程一会快一会慢症状同一个存储过程传订单号 A 跑得飞快传订单号 B 就慢得要死。原因SQL Server 生成执行计划时会把第一次的参数值作为“样本”后续参数都沿用这个计划。一旦参数分布不均就会出现“挑了个坏样本所有人跟着遭殃”。解决办法按优先级优先把查询写得更理想让优化器更容易生成多版本计划。局部加OPTION (RECOMPILE)只针对那一条语句不针对整个过程。如果分布差异实在太大考虑用条件分支拆过程分别执行不同计划。定期更新统计信息保持优化器“视力”正常。一条经验之谈不要一看到参数嗅探就给整个过程加WITH RECOMPILE那会让过程每次调用都重编译CPU 扛不住。正确做法是先定位到具体语句单独处理。我们把“能否缩窄到单语句”作为基本要求写进了规范。6.4 常见问题速查表症状可能原因首选排查方向过程运行特慢索引失效 / 统计信息过期查看执行计划的扫描算子检查索引使用偶发超时参数嗅探对比不同参数下的执行计划死锁表访问顺序不一致 / 长事务死锁图分析规范表访问顺序数据结果时对时错过程里缓存了不变量检查是否错误使用了临时表或表变量错误日志大量重复日志表阻塞或权限异常确认错误日志过程的隔离级别与权限这些速查可以打印出来贴在工位上比放一堆理论文档实用。7. 规范落地从文档到团队习惯说实话写规范不难难在让团队真正用起来。我总结几条实用的落地经验。7.1 怎么推行规范而不被抵制最有效的不是靠行政命令而是靠“降低门槛、给出模板”。我们做三件事所有规范文件不光讲“不要做什么”还配了“照着写就行”的完整示例。人在看到标准示例时阻力最小。提供脚手架存储过程生成脚本团队内部写了一个一键生成符合规范的过程骨架的工具开发只需要填业务逻辑。这一步直接消灭了一半违规写法。Code Review 把规范变成勾选清单。不搞“凭经验打分”只做“合不合规”的判断题。新人和老人都按同一张单子过公平。7.2 Code Review 检查清单模板分享我们实际的 Review 清单简化版可以直接抄[ ] 命名是否符合 usp_模块_动作_对象 规范 [ ] 头部注释是否更新了修改记录含需求单号 [ ] 参数是否有校验边界值是否明确 [ ] 是否有统一的 TRY/CATCH事务是否有嵌套安全写法 [ ] 是否有隐式转换 / 函数包裹索引列 [ ] 是否使用游标 / 动态SQL / SELECT *若有是否有书面理由 [ ] 是否检查关键表执行计划扫描有没有解释 [ ] 上线脚本是否有回滚预案 [ ] 新旧版本对比验证是否有记录 [ ] 错误日志是否会被记录且不影响主流程每次 Review 按这个清单过效率极高。不是每一项都必须满足才算好但每一项都必须有答案有问题写进 PR 评论不通过就不合入。7.3 个人心得最后说点主观的体会。我在实际推行这套规范的过程中发现真正让存储过程质量变好的不是规范本身的内容而是“规范的执行机制”。你定了再漂亮的规范不落地、不 Review、不追责它就是一纸空文。而落地的关键就是上面说的提供模板让人省心、提供清单让人有抓手、提供工具让按规范写比不按规范写更容易。当团队里“照着模板写”变成默认选择质量自然就上来了。另一个体会是存储过程开发规范要跟着业务和基础设施演进。比如以前团队坚持所有过程不用动态 SQL后来上了报表平台很多报表场景必须拼查询我们就把规则细化成“OLTP 核心链路禁止动态 SQL报表模块必须参数化 白名单校验”。规范是活的不是刻在石头上的。如果有团队想直接借鉴这套模板我的建议是第一版不要追求大而全先把命名、头部注释、错误处理、性能防坑这四件事定死其余慢慢补。这四件事能解决 80% 的维护痛苦。后面等团队用顺了、有反馈了再迭代第二版、第三版。规范的价值不在于文档厚而在于每一条都有人执行、有人检查、有人受益。