存储过程、函数与动态 SQL
6.1 存储过程
存储过程(Stored Procedure)是预编译的 T-SQL 代码块,存储在数据库中,可被反复调用。相比直接执行 SQL,存储过程具有执行计划复用、安全性隔离、网络流量减少等优势。
6.1.1 创建与调用
-- 基本存储过程
CREATE PROCEDURE dbo.usp_GetOrdersByCustomer
@CustomerID INT,
@StartDate DATE = NULL, -- 可选参数(有默认值)
@EndDate DATE = NULL,
@MaxRows INT = 100,
@OrderCount INT OUTPUT -- 输出参数
AS
BEGIN
SET NOCOUNT ON; -- 禁止返回 "X rows affected" 消息,提升性能
-- 参数默认值处理
SET @StartDate = ISNULL(@StartDate, DATEADD(YEAR, -1, GETDATE()));
SET @EndDate = ISNULL(@EndDate, GETDATE());
-- 主查询
SELECT TOP (@MaxRows)
o.OrderID,
o.OrderDate,
o.TotalAmount,
o.Status,
c.CustomerName
FROM Orders o
JOIN Customers c ON o.CustomerID = c.CustomerID
WHERE o.CustomerID = @CustomerID
AND o.OrderDate BETWEEN @StartDate AND @EndDate
ORDER BY o.OrderDate DESC;
-- 设置输出参数
SELECT @OrderCount = COUNT(*)
FROM Orders
WHERE CustomerID = @CustomerID
AND OrderDate BETWEEN @StartDate AND @EndDate;
RETURN 0; -- 返回值(仅用于状态码)
END;
GO
-- 调用存储过程
DECLARE @Count INT;
EXEC dbo.usp_GetOrdersByCustomer
@CustomerID = 1001,
@StartDate = '2024-01-01',
@EndDate = '2024-12-31',
@MaxRows = 50,
@OrderCount = @Count OUTPUT;
PRINT '订单总数:' + CAST(@Count AS VARCHAR);
-- 修改和删除
ALTER PROCEDURE dbo.usp_GetOrdersByCustomer ... ;
DROP PROCEDURE dbo.usp_GetOrdersByCustomer;
-- 安全写法:
DROP PROCEDURE IF EXISTS dbo.usp_GetOrdersByCustomer;
6.1.2 TRY…CATCH 完整错误处理
CREATE PROCEDURE dbo.usp_TransferFunds
@FromAccountID INT,
@ToAccountID INT,
@Amount DECIMAL(12,2)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON; -- 任何错误立即回滚整个事务(推荐)
-- 参数验证
IF @Amount <= 0
THROW 50001, '转账金额必须大于0', 1;
IF @FromAccountID = @ToAccountID
THROW 50002, '转出和转入账户不能相同', 1;
BEGIN TRY
BEGIN TRANSACTION;
-- 检查余额(使用 UPDLOCK 防止并发下的超额扣款)
DECLARE @CurrentBalance DECIMAL(12,2);
SELECT @CurrentBalance = Balance
FROM Accounts WITH (UPDLOCK, ROWLOCK)
WHERE AccountID = @FromAccountID;
IF @CurrentBalance IS NULL
THROW 50003, '转出账户不存在', 1;
IF @CurrentBalance < @Amount
THROW 50004, '账户余额不足', 1;
-- 执行转账
UPDATE Accounts
SET Balance = Balance - @Amount,
UpdatedAt = GETDATE()
WHERE AccountID = @FromAccountID;
UPDATE Accounts
SET Balance = Balance + @Amount,
UpdatedAt = GETDATE()
WHERE AccountID = @ToAccountID;
IF @@ROWCOUNT = 0
THROW 50005, '转入账户不存在', 1;
-- 记录流水
INSERT INTO Transactions (FromAccountID, ToAccountID, Amount, TranDate)
VALUES (@FromAccountID, @ToAccountID, @Amount, GETDATE());
COMMIT TRANSACTION;
PRINT '转账成功';
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0
ROLLBACK TRANSACTION;
-- 记录错误
INSERT INTO dbo.ErrorLog (
ErrorNumber, ErrorSeverity, ErrorState,
ErrorProcedure, ErrorLine, ErrorMessage, ErrorTime
)
VALUES (
ERROR_NUMBER(), ERROR_SEVERITY(), ERROR_STATE(),
ERROR_PROCEDURE(), ERROR_LINE(), ERROR_MESSAGE(), GETDATE()
);
THROW; -- 将错误传回调用方
END CATCH;
END;
GO
6.1.3 执行计划重用与 WITH RECOMPILE
-- 查看存储过程的执行计划缓存信息
SELECT
p.name AS ProcName,
qs.execution_count,
qs.total_elapsed_time / qs.execution_count / 1000.0 AS AvgMs,
qs.total_logical_reads / qs.execution_count AS AvgLogicalReads,
qs.cached_time,
qp.query_plan
FROM sys.procedures p
JOIN sys.dm_exec_procedure_stats qs ON p.object_id = qs.object_id
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
WHERE p.name LIKE 'usp_%'
ORDER BY qs.total_elapsed_time DESC;
-- 手动清除某个存储过程的缓存计划(用于测试)
DECLARE @PlanHandle VARBINARY(64);
SELECT @PlanHandle = plan_handle
FROM sys.dm_exec_procedure_stats
WHERE object_id = OBJECT_ID('dbo.usp_GetOrdersByCustomer');
DBCC FREEPROCCACHE(@PlanHandle); -- 清除指定计划
-- DBCC FREEPROCCACHE; -- 清除全部(⚠️ 慎用于生产)
6.2 用户自定义函数(UDF)
6.2.1 标量函数(Scalar Function)
标量函数返回单个值,最大的坑是:在查询中逐行调用标量函数会使查询串行化,完全禁用并行计划,严重影响性能。
-- 创建标量函数
CREATE FUNCTION dbo.fn_FormatPhone
(
@Phone NVARCHAR(20)
)
RETURNS NVARCHAR(30)
WITH SCHEMABINDING -- 绑定模式:函数执行期间相关对象不能被修改
AS
BEGIN
-- 处理 NULL
IF @Phone IS NULL RETURN NULL;
-- 去除非数字字符
DECLARE @Cleaned NVARCHAR(20) = '';
DECLARE @i INT = 1;
WHILE @i <= LEN(@Phone)
BEGIN
IF SUBSTRING(@Phone, @i, 1) BETWEEN '0' AND '9'
SET @Cleaned += SUBSTRING(@Phone, @i, 1);
SET @i += 1;
END;
-- 格式化:138-1234-5678
IF LEN(@Cleaned) = 11
RETURN LEFT(@Cleaned, 3) + '-' + SUBSTRING(@Cleaned, 4, 4) + '-' + RIGHT(@Cleaned, 4);
RETURN @Phone; -- 无法格式化则原样返回
END;
GO
-- 调用
SELECT dbo.fn_FormatPhone('13812345678'); -- 138-1234-5678
SELECT dbo.fn_FormatPhone('+86 138-1234-5678'); -- 138-1234-5678
-- ⚠️ 标量函数的性能陷阱:
SELECT CustomerID, dbo.fn_FormatPhone(Phone) AS FormattedPhone
FROM Customers; -- 对每一行调用函数,百万行 = 百万次执行!
-- 解决方案:SQL Server 2019+ 的内联标量函数(Inlined Scalar UDF)
-- 满足条件的标量函数会被自动内联为表达式,恢复并行
-- 查看函数是否被内联
SELECT is_inlineable
FROM sys.sql_modules
WHERE object_id = OBJECT_ID('dbo.fn_FormatPhone');
6.2.2 内联表值函数(Inline TVF)
内联 TVF 是最推荐的函数类型,本质上是参数化视图,优化器可以完全内联,与直接写 SQL 性能相同。
-- 内联表值函数(只有一条 RETURN SELECT 语句)
CREATE FUNCTION dbo.fn_GetCustomerOrders
(
@CustomerID INT,
@StartDate DATE,
@EndDate DATE
)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN (
SELECT
o.OrderID,
o.OrderDate,
o.TotalAmount,
o.Status,
c.CustomerName,
c.Email
FROM dbo.Orders o
JOIN dbo.Customers c ON o.CustomerID = c.CustomerID
WHERE o.CustomerID = @CustomerID
AND o.OrderDate BETWEEN @StartDate AND @EndDate
);
GO
-- 调用(可以在 FROM 子句中使用,性能极佳)
SELECT * FROM dbo.fn_GetCustomerOrders(1001, '2024-01-01', '2024-12-31')
WHERE TotalAmount > 1000
ORDER BY OrderDate DESC;
-- 与 CROSS APPLY 结合(批量处理)
SELECT c.CustomerName, o.*
FROM dbo.Customers c
CROSS APPLY dbo.fn_GetCustomerOrders(c.CustomerID, '2024-01-01', '2024-12-31') o
WHERE o.TotalAmount > 5000;
6.2.3 多语句表值函数(Multi-Statement TVF)
-- 多语句 TVF:比内联 TVF 灵活,但优化器无法内联,需谨慎使用
CREATE FUNCTION dbo.fn_GetTopCustomers
(
@N INT,
@RegionID INT = NULL
)
RETURNS @Result TABLE (
Rank INT,
CustomerID INT,
CustomerName NVARCHAR(100),
TotalAmount DECIMAL(12,2),
OrderCount INT
)
AS
BEGIN
INSERT INTO @Result
SELECT TOP (@N)
ROW_NUMBER() OVER (ORDER BY SUM(o.TotalAmount) DESC),
c.CustomerID,
c.CustomerName,
SUM(o.TotalAmount),
COUNT(o.OrderID)
FROM dbo.Customers c
JOIN dbo.Orders o ON c.CustomerID = o.CustomerID
WHERE (@RegionID IS NULL OR c.RegionID = @RegionID)
GROUP BY c.CustomerID, c.CustomerName
ORDER BY SUM(o.TotalAmount) DESC;
RETURN;
END;
GO
6.3 触发器
6.3.1 DML 触发器(After / Instead Of)
-- AFTER 触发器:在 DML 之后执行
CREATE TRIGGER dbo.trg_Orders_AfterInsert
ON dbo.Orders
AFTER INSERT -- 也可以是 UPDATE, DELETE, 或组合: AFTER INSERT, UPDATE
AS
BEGIN
SET NOCOUNT ON;
-- inserted 和 deleted 是触发器内的虚拟表
-- INSERT → inserted 含新行
-- DELETE → deleted 含旧行
-- UPDATE → inserted 含新行,deleted 含旧行
-- 检查插入的订单金额是否异常
IF EXISTS (SELECT 1 FROM inserted WHERE TotalAmount < 0)
BEGIN
RAISERROR('订单金额不能为负数', 16, 1);
ROLLBACK TRANSACTION; -- 触发器内的回滚会回滚整个触发器+触发DML的事务
RETURN;
END;
-- 记录审计日志(同时处理批量插入)
INSERT INTO dbo.OrderAuditLog (OrderID, Action, ActionTime, ActionBy)
SELECT i.OrderID, 'INSERT', GETDATE(), SYSTEM_USER
FROM inserted i;
-- 更新客户最后订单时间(关联更新)
UPDATE c
SET c.LastOrderDate = GETDATE(),
c.TotalOrderCount = c.TotalOrderCount + 1
FROM dbo.Customers c
JOIN inserted i ON c.CustomerID = i.CustomerID;
END;
GO
-- UPDATE 触发器:使用 UPDATE() 函数检测特定列是否被修改
CREATE TRIGGER dbo.trg_Products_AfterUpdate
ON dbo.Products
AFTER UPDATE
AS
BEGIN
SET NOCOUNT ON;
-- 只在 Price 列被修改时执行
IF UPDATE(Price)
BEGIN
INSERT INTO dbo.PriceHistory (ProductID, OldPrice, NewPrice, ChangedAt)
SELECT
d.ProductID,
d.Price AS OldPrice,
i.Price AS NewPrice,
GETDATE()
FROM deleted d
JOIN inserted i ON d.ProductID = i.ProductID
WHERE d.Price <> i.Price; -- 排除价格未实际变化的情况
END;
END;
GO
-- INSTEAD OF 触发器:替代触发它的 DML 操作
-- 常用于:可更新视图、实现自定义逻辑
CREATE TRIGGER dbo.trg_vwOrders_InsteadOfDelete
ON dbo.vwOrders -- 针对视图
INSTEAD OF DELETE
AS
BEGIN
SET NOCOUNT ON;
-- 软删除:不真正删除,而是标记为已删除
UPDATE o
SET o.IsDeleted = 1, o.DeletedAt = GETDATE()
FROM dbo.Orders o
JOIN deleted d ON o.OrderID = d.OrderID;
END;
GO
6.3.2 DDL 触发器(防止意外 DDL 变更)
-- DDL 触发器:在服务器或数据库级别监控 DDL 操作
CREATE TRIGGER dbo.trg_PreventDropTable
ON DATABASE
FOR DROP_TABLE, ALTER_TABLE, DROP_INDEX
AS
BEGIN
-- 获取 DDL 事件详细信息
DECLARE @EventData XML = EVENTDATA();
DECLARE @EventType NVARCHAR(50) = @EventData.value('(/EVENT_INSTANCE/EventType)[1]', 'NVARCHAR(50)');
DECLARE @ObjectName NVARCHAR(100) = @EventData.value('(/EVENT_INSTANCE/ObjectName)[1]', 'NVARCHAR(100)');
DECLARE @LoginName NVARCHAR(100) = @EventData.value('(/EVENT_INSTANCE/LoginName)[1]', 'NVARCHAR(100)');
DECLARE @TSQLCommand NVARCHAR(MAX) = @EventData.value('(/EVENT_INSTANCE/TSQLCommand)[1]', 'NVARCHAR(MAX)');
-- 记录所有 DDL 操作
INSERT INTO dbo.DDLAuditLog (EventType, ObjectName, LoginName, TSQLCommand, EventTime)
VALUES (@EventType, @ObjectName, @LoginName, @TSQLCommand, GETDATE());
-- 禁止非 DBA 账号的 DROP TABLE(在工作时间段)
IF @EventType = 'DROP_TABLE'
AND SYSTEM_USER NOT IN ('sa', 'DBAAdmin')
AND DATEPART(HOUR, GETDATE()) BETWEEN 8 AND 20
BEGIN
RAISERROR('工作时间禁止 DROP TABLE 操作,请联系 DBA', 16, 1);
ROLLBACK;
END;
END;
GO
-- 禁用/启用 DDL 触发器
DISABLE TRIGGER dbo.trg_PreventDropTable ON DATABASE;
ENABLE TRIGGER dbo.trg_PreventDropTable ON DATABASE;
6.4 动态 SQL 与 SQL 注入防护
6.4.1 sp_executesql:参数化动态 SQL
-- ❌ 危险做法:字符串拼接(SQL注入漏洞)
DECLARE @TableName NVARCHAR(50) = 'Orders';
DECLARE @SQL NVARCHAR(MAX) = 'SELECT * FROM ' + @TableName;
EXEC (@SQL); -- 如果 @TableName = "Orders; DROP TABLE Orders;--" 则灾难!
-- ✅ 安全做法1:sp_executesql 参数化
DECLARE @CustomerID INT = 1001;
DECLARE @SQL NVARCHAR(MAX) = N'
SELECT OrderID, OrderDate, TotalAmount
FROM dbo.Orders
WHERE CustomerID = @CustID
AND Status = @Status';
EXEC sp_executesql
@SQL,
N'@CustID INT, @Status TINYINT', -- 参数定义
@CustID = @CustomerID,
@Status = 1;
-- ✅ 安全做法2:表名/列名用 QUOTENAME 转义(防止注入)
DECLARE @TableName NVARCHAR(50) = N'Orders'; -- 来自用户输入
DECLARE @ColName NVARCHAR(50) = N'OrderDate'; -- 来自用户输入
-- 验证表/列是否真实存在(白名单校验)
IF NOT EXISTS (
SELECT 1 FROM sys.tables WHERE name = @TableName AND schema_id = SCHEMA_ID('dbo')
) THROW 50010, '无效的表名', 1;
IF NOT EXISTS (
SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.' + @TableName) AND name = @ColName
) THROW 50011, '无效的列名', 1;
SET @SQL = N'SELECT TOP 100 ' + QUOTENAME(@ColName) +
N' FROM ' + QUOTENAME('dbo') + '.' + QUOTENAME(@TableName);
EXEC sp_executesql @SQL;
6.4.2 动态 SQL 实战:通用查询生成器
CREATE PROCEDURE dbo.usp_DynamicSearch
@TableName NVARCHAR(100),
@WhereClause NVARCHAR(MAX) = NULL, -- 调用方负责安全;或用结构化参数
@OrderBy NVARCHAR(100) = NULL,
@PageNumber INT = 1,
@PageSize INT = 20,
@TotalRows INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
-- 白名单校验
IF NOT EXISTS (
SELECT 1 FROM sys.tables
WHERE SCHEMA_NAME(schema_id) + '.' + name = @TableName
) THROW 50020, '非法表名', 1;
DECLARE @SQL NVARCHAR(MAX);
DECLARE @CountSQL NVARCHAR(MAX);
DECLARE @Offset INT = (@PageNumber - 1) * @PageSize;
-- 获取总行数
SET @CountSQL = N'SELECT @Cnt = COUNT(*) FROM ' + QUOTENAME(@TableName);
IF @WhereClause IS NOT NULL
SET @CountSQL += N' WHERE ' + @WhereClause;
EXEC sp_executesql @CountSQL, N'@Cnt INT OUTPUT', @Cnt = @TotalRows OUTPUT;
-- 分页查询
SET @SQL = N'SELECT * FROM ' + QUOTENAME(@TableName);
IF @WhereClause IS NOT NULL SET @SQL += N' WHERE ' + @WhereClause;
IF @OrderBy IS NOT NULL SET @SQL += N' ORDER BY ' + @OrderBy;
ELSE SET @SQL += N' ORDER BY (SELECT NULL)';
SET @SQL += N' OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY';
EXEC sp_executesql @SQL,
N'@Offset INT, @PageSize INT',
@Offset = @Offset,
@PageSize = @PageSize;
END;
GO
6.4.3 常见 SQL 注入场景与防御
-- 场景1:IN 子句的注入防御
-- 应用层传来一个逗号分隔的 ID 列表:'1,2,3'
-- 危险写法:直接拼接 'WHERE OrderID IN (' + @IDs + ')'
-- 安全写法:使用表值参数(TVP)或 STRING_SPLIT
CREATE TYPE dbo.IntList AS TABLE (Value INT NOT NULL);
CREATE PROCEDURE dbo.usp_GetOrdersByIDs
@OrderIDs dbo.IntList READONLY -- 表值参数
AS
BEGIN
SELECT o.*
FROM dbo.Orders o
JOIN @OrderIDs ids ON o.OrderID = ids.Value;
END;
-- 调用端(.NET)
-- var table = new DataTable();
-- table.Columns.Add("Value", typeof(int));
-- foreach (var id in orderIds) table.Rows.Add(id);
-- cmd.Parameters.AddWithValue("@OrderIDs", table);
-- 场景2:LIKE 模糊搜索注入防御
-- % _ [ 在 LIKE 中有特殊含义,需转义
DECLARE @SearchTerm NVARCHAR(100) = N'100% 成功'; -- 用户输入
-- 转义特殊字符
SET @SearchTerm = REPLACE(REPLACE(REPLACE(REPLACE(@SearchTerm,
'[', '[[]'), '%', '[%]'), '_', '[_]'), '^', '[^]');
SELECT * FROM Products
WHERE ProductName LIKE '%' + @SearchTerm + '%' ESCAPE '\';
6.5 小结
| 对象 | 最佳实践 |
|---|---|
| 存储过程 | SET NOCOUNT ON + SET XACT_ABORT ON;TRY…CATCH 包裹事务 |
| 标量函数 | SQL 2019+ 可内联;否则避免在大表逐行调用 |
| 内联 TVF | 首选!性能等同直接 SQL,支持优化器统计 |
| 触发器 | 必须考虑批量操作(inserted/deleted 是集合,不是单行) |
| 动态 SQL | 始终用 sp_executesql 参数化;表名/列名用 QUOTENAME + 白名单校验 |
下一章:备份、恢复与日常运维
文章版权声明:除非注明,否则均为边学边练网络文章,版权归原作者所有