简介一份面向SQL Server初学者与开发人员的存储过程示例包以供应链管理场景为载体展示预编译SQL语句如何接收参数、执行系列操作并返回结果覆盖报表生成、订单处理、批次号分配等常见业务诉求。包内共有3个SQL文件压缩后仅4KB文件类型针对性强其中报表类脚本可按日期范围筛选数据并返回格式化结果业务处理类脚本涉及订单与供应数据更新批次号函数则用于生成唯一递增编号同时还提供用户自定义函数与存储过程对比的入门视角。资源已有2021人学习适合正在学习数据库编程的学生、需要编写报表或业务逻辑的开发人员阅读通过研读这些精炼示例可快速掌握CREATE PROCEDURE、EXEC的基本调用方式理解参数传递、代码复用、权限控制等技巧并进一步延伸到事务控制与异常处理等实际项目应用。1. 存储过程不是玄学一套能直接跑的SQL Server订单管理存储过程集有一次接手一个老项目业务方催一份订单报表我在应用层拼了一条几百行的SQL执行速度惨不忍睹还把线上订单表锁了半天。后来把核心逻辑收进存储过程加了事务、错误处理和索引性能稳了代码也干净了。这套SQLSERVER存储过程例子就是干这个的一份围绕订单管理场景编写的SQL Server存储过程集合从建库建表、订单创建、库存扣减、日志记录到统计查询都有完整脚本。适合刚接触存储过程的人照着跑通流程也适合有经验的开发拿来当模板换套表名和字段就能做二次开发。2. 项目结构拆解存储过程按业务域拆分而不是按页面堆SQL2.1 脚本文件清单从建库到测试数据一条龙这套资源的形态是一批.sql脚本按执行顺序编号。目录结构大致如下SQLSERVER存储过程例子/ ├── 01_Init_Database.sql ├── 02_Create_Tables.sql ├── 03_Create_Indexes.sql ├── 04_StoredProcedures/ │ ├── usp_Order_Create.sql │ ├── usp_Order_GetByCustomer.sql │ ├── usp_Stock_Deduct.sql │ ├── usp_Order_Stats_Daily.sql │ └── usp_System_Log_Write.sql ├── 05_TestData.sql └── 06_Test_Calls.sql这种命名和目录安排是我个人比较习惯的写法。之所以把每个存储过程单独放一个文件是因为在版本管理工具里单独文件可以独立标注修改记录和评审人回滚时也只针对一个对象不会像一个大脚本那样牵一发动全身。你可以直接沿用这个结构在团队项目里也推荐这样做。脚本内容上01_Init_Database.sql负责创建数据库本身设置了初始大小、自动增长和日志文件位置。02_Create_Tables.sql建了四张核心表订单主表Orders、订单明细表OrderItems、库存表Inventory、操作日志表OperationLog。03_Create_Indexes.sql针对查询条件建立了对应的非聚簇索引。04_StoredProcedures里是五个存储过程覆盖了写入、查询、统计、日志四类操作。05_TestData.sql插入一些模拟客户、商品和库存数据。06_Test_Calls.sql则把每个存储过程的调用方式都写了一遍包括传参、获取输出参数和查看返回码。对于表结构我画一张简洁的表单供你参考表名用途关键字段Orders订单主表OrderId, OrderNo, CustomerId, Status, TotalAmountOrderItems订单明细ItemId, OrderId, ProductId, Quantity, PriceInventory商品库存ProductId, WarehouseId, AvailableQty, LockedQtyOperationLog操作日志LogId, BusinessType, BusinessId, Action, Detail订单主表和明细表是一对多关系。库存表用ProductId WarehouseId做联合主键表示不同仓库的库存是独立维度的。日志表设计成通用结构任何业务模块都能往里写方便后续做审计和排障。索引方面本项目重点关注三个非聚簇索引IX_Orders_CustomerId支撑按客户查询订单IX_OrderItems_OrderId支撑按订单查明细IX_OperationLog_BusinessType_BusinessId支撑日志按业务维度查。索引不是越多越好订单主表只有聚簇索引和唯一约束外加一个客户ID索引就够了。如果再加状态索引可能因为选择率太低而无法使用这也是存储过程优化的一个原则索引跟查询走不要提前建一堆用不上的索引。2.2 为什么把业务逻辑放进存储过程三个现实理由第一个理由是减少网络往返。一个订单创建动作如果全由应用层执行SQL至少要发七八次请求插主表、插明细、查库存、扣库存、更新金额、记日志。每一次都是网络往返在公网环境下耗时尤其明显。把这些操作放进一个存储过程应用层只需要一次EXEC所有工作都在数据库服务端完成响应时间能缩短一个数量级。第二个理由是事务边界可控。订单创建必须同时完成主表和明细的写入任何一个失败都要把已经插入的数据全部回滚。应用层写事务逻辑往往要管理连接、命令、事务对象稍不留神就会漏掉回滚。存储过程内部把BEGIN TRAN和COMMIT放在同一段代码里配合TRANCOUNT做判断事务边界非常明确。第三个理由是安全与复用。存储过程可以只给业务账号授EXECUTE权限应用层拿不到表结构减少越权风险。统计报表这类被多个页面复用的逻辑写成一个过程后所有页面调用同一个入口避免每个前端各自拼SQL导致逻辑漂移。但这个项目里并没有把所有逻辑都塞进过程。比如用户登录认证、复杂的业务规则编排这些还是放在应用层。存储过程适合做事务性强、集中在同一数据源的操作不适合做需要调用外部API、发送消息、跟文件系统交互的跨域业务边界要清楚。这个项目里所有存储过程都避免了动态SQL。这不是因为动态SQL不能写而是它会把SQL注入风险带进数据库层并且让执行计划难以复用。如果业务上确实需要动态排序或动态列我通常会使用CASE WHEN来模拟排序条件或者使用OPTION (RECOMPILE)配合参数化查询。动态SQL不是不可以但必须用sp_executesql加参数化不能直接字符串拼接。2.3 参数设计表值参数如何把明细集合传进过程这个项目里最有参考价值的设计之一是usp_Order_Create接收一个表值参数OrderItems。表值参数的定义是这样的CREATE TYPE dbo.OrderItemType AS TABLE ( ProductId INT NOT NULL, Quantity INT NOT NULL, Price DECIMAL(18,2) NOT NULL );有了这个类型应用层就可以把订单商品列表作为一个整体传给存储过程而不是拼接字符串。在C#里只需要把DataTable赋值给SqlParameter的Value并指定TypeName dbo.OrderItemType即可。在SSMS中调试时也可以先用表变量填充数据再调用存储过程。很多人一开始容易混淆CREATE TYPE和临时表。表值参数的生命周期只在存储过程调用期间存在执行完自动释放不需要手工清理。临时表则需要显式删除或等待会话结束且每个会话的临时表是独立的无法作为参数直接传递。实际项目中如果同一个存储过程要接收大量行比如超过几千行表值参数的效率会下降那时候可以考虑先把数据批量插入临时表再在过程内部JOIN临时表。但对于订单这种单笔几十行的场景表值参数是最合适的。为了让表值参数真正起作用存储过程内部把它当作只读表来使用不能对它执行INSERT/UPDATE/DELETE。SQL Server会强制这个约束如果你试图修改它会收到错误 352。这个限制是设计使然目的是保证调用方传进来的数据在过程内不被意外改动。如果需要调整数据先把它SELECT到临时表再改。3. 核心存储过程实战订单创建这条完整链路3.1 主表写入SCOPE_IDENTITY与事务边界usp_Order_Create是整套项目的核心它把订单创建、明细写入、库存扣减和日志记录全部串在一个事务里。我们从主表写入开始看。CREATE PROCEDURE [dbo].[usp_Order_Create] CustomerId INT, WarehouseId INT, OrderItems dbo.OrderItemType READONLY, OutOrderNo VARCHAR(20) OUTPUT, OutMessage NVARCHAR(200) OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT OFF; DECLARE OrderId INT; DECLARE Now DATETIME2 SYSDATETIME(); BEGIN TRY BEGIN TRAN; INSERT INTO dbo.Orders (CustomerId, WarehouseId, Status, TotalAmount, CreatedTime) VALUES (CustomerId, WarehouseId, 1, 0, Now); SET OrderId SCOPE_IDENTITY(); -- 后续逻辑明细插入和库存扣减 -- 暂以占位注释表示 COMMIT TRAN; SET OutOrderNo SO CONVERT(VARCHAR, OrderId, 112) RIGHT(0000 CONVERT(VARCHAR, OrderId - 10000), 4); SET OutMessage SUCCESS; RETURN 0; END TRY BEGIN CATCH IF TRANCOUNT 0 ROLLBACK TRAN; SET OutMessage ERROR_MESSAGE(); SET OutOrderNo NULL; RETURN -1; END CATCH; END这个片段里有几个关键参数需要说清楚。NOCOUNT ON用来抑制DML语句的行数消息否则调用方收到的信息里会混入 2 rows affected 这类文本干扰程序判断。XACT_ABORT OFF是故意设置的因为ON会让任何运行错误都自动回滚整个事务但我们希望错误能被TRY...CATCH捕获后集中处理决定是否回滚以及记录什么日志。SCOPE_IDENTITY()返回的是当前存储过程这个作用域内最后一次插入自增值。如果你使用IDENTITY而表上有触发器触发器如果又插入另一张自增表IDENTITY就会被覆盖成触发器的自增值导致OrderId取错。SCOPE_IDENTITY()没有这个问题这也是本项目里坚持用它的原因。这里订单号生成用了日期加序号的拼接方式只是一个演示更稳妥的做法是用独立的序列表或SEQUENCE对象避免在并发时生成重复号。如果有一天你发现订单号重复优先考虑是不是序号生成逻辑里有并发竞态而不是怀疑唯一约束失效。3.2 明细与库存扣减UPDLOCK锁防止超卖主表写入只是第一步真正的业务挑战在明细写入和库存扣减。假设两个用户同时买同一个商品初始库存是10如果两个事务都先读到10再各自减去2最后库存会变成8而不是6这就是超卖。为了避免这个问题必须在读取库存时加上更新锁。DECLARE AvailableQty INT; SELECT AvailableQty AvailableQty FROM dbo.Inventory WITH (UPDLOCK, ROWLOCK) WHERE ProductId ProductId AND WarehouseId WarehouseId; IF AvailableQty Quantity THROW 51000, 库存不足, 1; UPDATE dbo.Inventory SET AvailableQty AvailableQty - Quantity, UpdatedTime SYSDATETIME() WHERE ProductId ProductId AND WarehouseId WarehouseId;在SELECT加UPDLOCK后这把锁会一直保持到事务结束另一个事务要想读同一行会被阻塞。ROWLOCK则把锁粒度限制在行级减少对相邻行的锁竞争。如果不加UPDLOCK两个会话可能同时通过SELECT读到同样的可用库存随后双双执行UPDATE虽然锁最终会让其中一个等待但业务层已经基于旧值做了判断出来的结果就是错的。这里的THROW会在TRY...CATCH内触发错误错误号51000落在用户自定义错误区间。应用层捕获到这个错误号后可以精确提示库存不足。如果在CATCH里用ERROR_NUMBER()分辨代码会清晰很多。为什么这里用游标而不是一条UPDATE语句因为业务要求逐项检查库存如果某项不足整个订单回滚而不是部分成功。游标遍历订单明细便于在每一项上做判断和抛错。订单明细行数通常很少性能差异可以忽略。如果真的要批量扣库存也是先把表值参数SELECT到临时表再用基于集合的UPDATE同时关联库存表检查。3.3 日志与OUTPUT参数让调用方看到过程和结果一个优秀的存储过程不仅要完成数据操作还要向调用方交代发生了什么。usp_Order_Create在正常和异常路径都填充输出参数并且把关键操作写入日志表。-- 异常路径 DECLARE ErrorMsg NVARCHAR(200) ERROR_MESSAGE(); INSERT INTO dbo.OperationLog (BusinessType, BusinessId, Action, Status, Detail, CreatedTime) VALUES (ORDER, OrderId, CREATE, 0, ErrorMsg, SYSDATETIME()); SET OutMessage 订单创建失败 ErrorMsg; SET OutOrderNo NULL; RETURN -1;注意BusinessId这里使用了OrderId但当事务已经回滚时OrderId对应的订单数据并不会存在。日志表不受回滚影响吗实际上日志插入如果在CATCH块中执行而外层事务已经被回滚那么日志插入就会作为独立语句自动提交。但如果没有先回滚就插日志日志也会跟着回滚这样日志就丢了。所以顺序很重要先回滚再写日志。在CATCH块里的顺序是先判断并回滚然后插入日志最后设置输出参数。RETURN -1只是返回码它本身不传消息给应用层。应用层要拿具体的错误文本必须依赖OutMessage这个输出参数。返回码适合做程序分支判断输出参数适合做用户提示。两者结合使用比单纯使用RAISERROR更容易让客户端代码阅读。在正常路径下输出参数这样设置SET OutOrderNo SO CONVERT(VARCHAR, OrderId, 112) RIGHT(CONVERT(VARCHAR, OrderId 10000), 4); SET OutMessage SUCCESS; RETURN 0;我习惯把成功消息统一定为SUCCESS失败消息直接放ERROR_MESSAGE()原文方便联调时一眼看到底哪里出错。如果业务需要区分超卖、参数错误、系统错误可以用错误号在应用层翻译而不是在消息里带一堆前缀。4. 从脚本到运行建库、部署、调用与调试4.1 初始化脚本建表、索引、默认约束拿到资源后先在SSMS中新建查询按顺序执行前三个脚本。01_Init_Database.sql创建数据库并配置文件增长02_Create_Tables.sql建表03_Create_Indexes.sql建索引。建表语句可以参考这个片段CREATE TABLE dbo.Orders ( OrderId INT IDENTITY(1,1) NOT NULL, OrderNo VARCHAR(20) NOT NULL, CustomerId INT NOT NULL, WarehouseId INT NOT NULL, Status TINYINT NOT NULL DEFAULT 1, TotalAmount DECIMAL(18,2) NOT NULL DEFAULT 0, CreatedTime DATETIME2 NOT NULL DEFAULT SYSDATETIME(), UpdatedTime DATETIME2 NULL, CONSTRAINT PK_Orders PRIMARY KEY CLUSTERED (OrderId), CONSTRAINT UK_Orders_OrderNo UNIQUE (OrderNo) );OrderNo的唯一约束是订单表的重要防线。即使应用层和存储过程都做了订单号生成还是有可能因为并发或时钟问题生成重复号唯一约束可以把错误暴露在写入时而不是等后续业务发现数据脏。Status用TINYINT而不是VARCHAR一方面节省空间另一方面避免应用层要用字符串匹配。状态的含义统一在应用层枚举里定义数据库里不写魔法值描述。部署顺序建议先执行01_Init_Database.sql再执行02_Create_Tables.sql接着03_Create_Indexes.sql然后执行04_StoredProcedures下的存储过程文件最后执行05_TestData.sql填充测试数据。如果先建存储过程它会因为找不到表而报错。虽然SQL Server允许创建的存储过程引用不存在的对象但执行时才报错建议还是按顺序执行省得排查时自我怀疑。4.2 执行存储过程EXEC语法与OUTPUT参数捕获部署存储过程脚本本身直接打开04_StoredProcedures下的文件按顺序执行即可。但能创建成功不代表会调用。来看06_Test_Calls.sql中的一段完整调用DECLARE items dbo.OrderItemType; INSERT INTO items (ProductId, Quantity, Price) VALUES (1001, 2, 19.99), (1002, 1, 39.99); DECLARE outNo VARCHAR(20); DECLARE outMsg NVARCHAR(200); DECLARE ret INT; EXEC ret dbo.usp_Order_Create CustomerId 88, WarehouseId 3, OrderItems items, OutOrderNo outNo OUTPUT, OutMessage outMsg OUTPUT; PRINT Return CAST(ret AS VARCHAR); PRINT OrderNo ISNULL(outNo, NULL); PRINT Message ISNULL(outMsg, NULL);有些新手在调用时会漏写OUTPUT关键字导致执行完outNo还是NULL然后以为存储过程没写对。记住调用侧要声明变量传参时必须带OUTPUT定义侧已经标注了OUTPUT两边都要对齐。EXEC ret 这种形式可以把RETURN返回值赋给变量如果你不关心返回码直接写EXEC dbo.usp_Order_Create ...也行。如果表值参数在执行时报 传入的表格格式数据流(TDS)远程过程调用(RPC)协议流不正确优先检查调用方是否设置了TypeName。例如在C#中SqlParameter param new SqlParameter(OrderItems, SqlDbType.Structured); param.TypeName dbo.OrderItemType; param.Value dt;不要省略dbo否则在某些连接字符串设置下会解析到错误的schema。另外表值参数的结构必须和CREATE TYPE完全一致字段顺序无所谓但名称要匹配类型不兼容同样会报错。4.3 调试与观测SSMS单步、消息和日志表调试存储过程最直接的方式是在SSMS里右键选择执行存储过程。如果参数中有表值参数SSMS的向导界面很难直接编辑我会先写一个调试脚本把表值参数赋值好再调用。另一种常用手段是在过程内部临时加PRINT把中间变量的值打到消息选项卡PRINT Before Update, AvailableQty CAST(AvailableQty AS VARCHAR);但PRINT只对SSMS可见应用层不会收到。如果要从应用层了解过程内部状态就得靠日志表。本项目的OperationLog表记录了每次失败操作的业务类型、业务ID、动作和错误详情。排查问题时我通常会查询最近一小时的日志SELECT TOP 100 LogId, BusinessType, BusinessId, Action, Status, Detail, CreatedTime FROM dbo.OperationLog ORDER BY LogId DESC;如果要看一个存储过程到底慢在哪里我还会在SSMS中开启包含实际执行计划执行调用脚本然后查看执行计划中每个运算符的预估行数和实际行数。如果出现明显偏差可能是统计信息过期或隐式转换这时可以用UPDATE STATISTICS刷新统计信息。注意在生产库执行统计更新需要挑业务低峰期因为会占用IO资源。另外SSMS里的单步调试功能虽然能用但对表值参数和OUTPUT参数的支持并不友好很多时候调试到最后反而在跟SSMS的界面较劲不如直接打日志和观察输出参数来得快。5. 避坑与排查存储过程开发里的血泪经验5.1 事务计数判断出错CATCH里多回滚了一次现象某个存储过程在CATCH块中写了IF TRANCOUNT 0 ROLLBACK TRAN但遇到错误时却报没有对应的事务。原因TRANCOUNT是整个会话的事务嵌套计数不是当前过程的计数。如果外层调用方之前已经开启了一个事务计数为1过程内部再BEGIN TRAN计数变2。如果在CATCH里直接判断计数大于0就回滚确实会把事务回滚掉但如果CATCH里自己又BEGIN TRAN并且没有提交后续的ROLLBACK会报错。更隐蔽的是连接池复用连接时TRANCOUNT可能残留导致判断错误。解决在过程开始处用一个变量记录当时的TRANCOUNT事务回滚条件写成IF TRANCOUNT tranCountAtStart。这样只回滚自己开启的事务不干扰调用方的事务状态。代码风格上我推荐所有存储过程都遵循这个模式。顺手贴一个标准写法DECLARE tranCountAtStart INT TRANCOUNT; BEGIN TRAN; -- 业务逻辑 BEGIN CATCH IF TRANCOUNT tranCountAtStart ROLLBACK TRAN; -- 写日志 END CATCH;5.2 参数嗅探导致同一个过程时快时慢现象同一个存储过程在测试环境执行毫秒级一到生产就偶发十几秒执行计划看起来也变了。原因SQL Server 在第一次执行存储过程时会根据传入的参数值生成执行计划并缓存。当参数值分布差异很大时比如一次传入1个商品另一次传入1万个商品优化器按第一次的值选计划第二次用同样计划就容易效果差。这就是参数嗅探。典型场景是统计报表存储过程按日期范围查询小范围和大范围的计划完全不一样。解决最常见做法是给查询加OPTION (RECOMPILE)让每次执行都重新编译适合参数分布极不均匀且执行频率不高的情况。另一种是使用OPTION (OPTIMIZE FOR UNKNOWN)让优化器忽略具体参数值生成一个通用计划。我一般先看执行计划确认是嗅探问题再加RECOMPILE而不是盲目加。加完还要实测如果查询频率很高RECOMPILE的编译开销反而会拖慢整体。5.3 隐式转换让索引彻底失效现象usp_Order_GetByCustomer明明建了IX_Orders_CustomerId执行计划却显示索引扫描行数很多时极慢。原因存储过程的参数类型和表列类型不一致。比如CustomerId参数定义成了VARCHAR(20)表列CustomerId是INT。SQL Server 会把参数隐式转换成INT但优化器有时会认为转换后无法使用索引。另一种常见隐式转换发生在字符串列和数字参数比较比如OrderNo num。解决参数类型必须和列类型严格一致。检查过程定义中每个参数CustomerId就应该是INT不要图省事全用VARCHAR。如果列是VARCHAR(20)参数也必须是VARCHAR(20)长度不一致同样可能产生隐式转换。排查时可以在SSMS里打开包含实际执行计划看索引查找旁边有没有带黄色警告的转换运算符如果有优先修参数类型。5.4 存储过程执行权限报错账号能连库却不能跑现象给报表账号只授了EXECUTE权限它执行usp_Order_GetPage时提示对象名无效。原因存储过程默认以调用者身份访问内部对象。报表账号虽然能执行存储过程但对内部表对象没有SELECT权限所以报错。解决如果确定存储过程封装的查询必须开放给报表账号可以在过程定义中使用WITH EXECUTE AS OWNER让过程以所有者身份访问表。但这个权限隔离会变弱调用者等于间接拿到了表数据访问能力。更稳妥的做法是创建一个数据库角色授予该角色对特定表的只读权限再把账号加入角色。SQL示例CREATE ROLE [ReportUser]; GRANT SELECT ON dbo.Orders TO [ReportUser]; GRANT SELECT ON dbo.OrderItems TO [ReportUser]; EXEC sp_addrolemember ReportUser, some_login;这套做法能保持调用者上下文同时让最小权限原则落地。5.5 表值参数数据类型不匹配报错信息看不懂现象C#调用存储过程时报错传入的表格格式数据流(TDS)远程过程调用(RPC)协议流不正确半天查不出来。原因这个错误听起来很底层实际90%的情况是TypeName配不上或者DataTable的列名/类型和CREATE TYPE不一致。SQL Server对表值参数的元数据要求非常严格列名不对都会报类似的错。解决在C#中定义DataTable时列名不需要完全一致但类型必须兼容。建议把TypeName写为dbo.OrderItemTypeSqlDbType使用Structured。如果还是报错在SSMS中先手动声明变量填充数据后调用若SSMS能过再回头查C#代码。我遇到过最离谱的一次是DataTable里某一列允许了空值而CREATE TYPE定义的是NOT NULL也报同样的错。把列的AllowDBNull设置为false后就好了。6. 二次开发给存储过程加一个分页查询和导出功能这套存储过程集合已经覆盖了写操作的典型链路。实际开发中你拿到手后第一个要改的往往是查询。比如订单管理页面需要按客户、时间范围、状态筛选并且分页。如果每一个页面写一条动态SQL后面维护会很痛。这里给你一个可以直接借鉴的改造方式。先放一个基于OFFSET...FETCH的分页查询存储过程样板SQL Server 2012 可用CREATE PROCEDURE dbo.usp_Order_GetPage PageIndex INT 1, PageSize INT 20, CustomerId INT NULL, Status TINYINT NULL, TotalCount INT OUTPUT AS BEGIN SET NOCOUNT ON; SELECT TotalCount COUNT(*) FROM dbo.Orders WHERE (CustomerId IS NULL OR CustomerId CustomerId) AND (Status IS NULL OR Status Status); SELECT OrderId, OrderNo, CustomerId, Status, TotalAmount, CreatedTime FROM dbo.Orders WHERE (CustomerId IS NULL OR CustomerId CustomerId) AND (Status IS NULL OR Status Status) ORDER BY CreatedTime DESC OFFSET (PageIndex - 1) * PageSize ROWS FETCH NEXT PageSize ROWS ONLY; END这里的关键点有两个。第一个是WHERE (CustomerId IS NULL OR CustomerId CustomerId)这种写法叫可选参数过滤它避免了字符串拼接动态SQL但需要注意当参数为NULL时SQL Server的优化器可能无法准确估计行数。如果你的查询特别复杂可以考虑加OPTION (RECOMPILE)让每次执行都重新编译在小数据量下没问题。第二个是OFFSET分页适合数据量在几十万以内的场景超过百万行会越来越慢那时候就要换成键集分页或临时表方案。把它应用到这个项目里我的经验是三步走先在04_StoredProcedures/下新建usp_Order_GetPage.sql再在06_Test_Calls.sql里写一个调用用例验证返回行数和OUTPUT参数最后在应用层把原来用ORM拼的分页查询替换成EXEC usp_Order_GetPage。至于导出功能我通常不会做成存储过程而是在应用层用这个分页查询循环取出数据或者用OPENROWSET导出文件但那样涉及文件路径权限比存储过程复杂得多。所以我一般把导出这一层留在应用代码里存储过程只负责稳定地输出数据。从那以后我每次新增查询都强制想一遍这段逻辑会被几个页面复用如果会就收进存储过程并且给每个参数写上注释避免过两个月忘了Status是数字几。希望这个例子能帮你在SQL Server存储过程这条路上少走几步弯路。本文还有配套的精品资源点击获取