T-SQL动态查询(4)——动态SQL

时间:2023-03-08 20:51:36

接上文:T-SQL动态查询(3)——静态SQL

前言:


前面说了很多关于动态查询的内容,本文将介绍使用动态SQL解决动态查询的一些方法。

为什么使用动态SQL:


在很多项目中,动态SQL被广泛使用甚至滥用,很多时候,动态SQL又确实是解决很多需求的首选方法。但是如果不合理地使用,会导致性能问题及无法维护。动态SQL尤其自己的优缺点,是否使用需要进行评估分析:

本文出处:http://blog.****.net/dba_huangzj/article/details/50202371

动态SQL优点:

  • 动态SQL提供了强大的扩展功能,能够应付复杂的需求,即使在需求增加时也能应对,并且不会因为需求的增加而导致代码的线性增长。
  • 执行计划可以缓存查询字符串,意味着大部分查询条件可以重用执行计划缓存而不会导致不必要的重编译。

动态SQL缺点:

  • 不合理的编码会导致代码的维护陷入困境。
  • 动态SQL是用于应对较高级的问题,对于简单问题,会变得大材小用。
  • 动态SQL的测试显然比其他代码困难,特别是对最终执行的语句的获取,同时容易因为编码的不规范导致语法错误。
  • 相对于前面的OPTION(RECOMPILE),动态SQL需要加入对权限控制的考虑。
  • 动态SQL的计划缓存并不总是你想象的那样,有时候因为输入的参数值而导致不同的计划生成。

静态SQL其实可以应对大部分的日常需求,但是随着需求的增加,静态SQL会变得越来越复杂,同时可能带来过多的重编译,此时应该考虑动态SQL。

动态SQL简介:


概述:


在SQL Server中,动态SQL可以由三种方式实现:

  1. T-SQL存储过程
  2. CLR存储过程
  3. 客户端语句

本文着重介绍T-SQL中的存储过程。针对用户的输入,有两种方式进行处理:

  • 把参数通过字符串拼接实现,如:' AND col = ' + convert(varchar, @value)'
  • 使用sp_executesql进行参数化查询,可以把上面的参数变成:' AND col = @value'

基于很多理由,在日常使用中,推荐使用第二种方法也就是sp_executesql。但是需要提醒的是上面提到的三种实现动态SQL的方式没有本质上的好和坏,只有根据实际情况而定才是最有效的。本文将使用静态SQL篇中的需求作为演示,即针对不同的查询条件、不同的排序甚至不同的汇总需求演示。

本文出处:http://blog.****.net/dba_huangzj/article/details/50202371

权限:


对于存储过程中使用静态SQL,权限问题并无大碍。只要存储过程的调用者和表的拥有者是相同的,由于所有权链(ownership chaining,https://msdn.microsoft.com/zh-cn/library/ms188676.aspx),可以无障碍地执行存储过程。但是动态SQL中不存在所有权链,即使把它们放在存储过程中也一样,因为动态SQL有自己的权限范围。

如果在客户端程序或CLR存储过程中创建动态SQL,还需要额外授予用户具有查询中涉及到的表、视图、自定义函数上的SELECT权限。根据客户端程序和CLR存储过程的不同,权限链可能会非常混乱和失控。但是可以使用下面两种方式来应付:

  1. 创建一个证书,对存储过程使用这个证书进行签名。然后使用证书创建一个用户,并由于用户所需的SELECT权限。
  2. 在存储过程中添加EXECUTE AS ‘用户’。然后授予SELECT权限。

动态SQL的参数化查询形式:


本部分使用第一篇中提到的模版进行改造演示,为了能清晰地描述,使用博客自带的行号来标号:

USE [AdventureWorks2008R2]
GO
CREATE PROCEDURE [dbo].[sp_Get_orders]
     @salesorderid    int           = NULL,
     @fromdate        datetime      = NULL,
     @todate          datetime      = NULL,
     @minprice        money         = NULL,
     @maxprice        money         = NULL,
     @custid          int           = NULL,
     @custname        nvarchar(40)  = NULL,
     @prodid          int           = NULL,
     @prodname        nvarchar(40)  = NULL,
     @employeestr     varchar(MAX)  = NULL,
     @employeetbl     intlist_tbltypeREADONLY,
     @debug           bit           =0
AS                                                                                              

    DECLARE @sql        nvarchar(MAX),
           @paramlist  nvarchar(4000),
           @nl         char(2) = char(13) + char(10)                                            

    SELECT @sql='
       SELECT o.SalesOrderID, o.OrderDate, od.UnitPrice,od.OrderQty,
             c.CustomerID, per.FirstName as CustomerName,p.ProductID,
             p.Name as ProductName, per.BusinessEntityID as EmpolyeeID
       FROM  Sales.SalesOrderHeader o
       INNER JOIN   Sales.SalesOrderDetail od ON o.SalesOrderID= od.SalesOrderID
       INNER JOIN   Sales.Customer c ON o.CustomerID =c.CustomerID
       INNER JOIN   Person.Person per onc.PersonID=per.BusinessEntityID
       INNER JOIN   Production.Product p ON p.ProductID =od.ProductID
       WHERE 1=1'+@nl                                                                           

    IF @salesorderidIS NOT NULL
       SELECT @sql+= ' AND o.SalesOrderID=@SalesOrderID'+
                     ' ANDod.SalesOrderID=@SalesOrderID'+@nl                                    

    IF @fromdateIS NOT NULL
       SELECT @sql+= ' AND o.OrderDate >= @fromdate'+@nl                                        

    IF @todateIS NOT NULL
       SELECT @sql+= ' AND o.OrderDate <= @todate'+@nl                                          

    IF @minpriceIS NOT NULL
       SELECT @sql += 'AND od.UnitPrice >= @minprice' + @nl                                     

    IF @maxpriceIS NOT NULL
       SELECT @sql += 'AND od.UnitPrice <= @maxprice' + @nl                                     

    IF @custidIS NOT NULL
       SELECT @sql += 'AND o.CustomerID = @custid' +
                    ' AND c.CustomerID = @custid' +@nl                                          

    IF @custnameIS NOT NULL
        SELECT@sql += ' AND per.FirstName LIKE @custname + ''%''' + @nl                         

    IF @prodidIS NOT NULL
       SELECT@sql += ' AND od.ProductID = @prodid' +
                    ' AND p.ProductID = @prodid' +@nl                                           

    IF @prodnameIS NOT NULL
       SELECT@sql += ' AND p.Name LIKE @prodname + ''%''' + @nl                                 

    IF @employeestrIS NOT NULL
       SELECT@sql += ' AND per.BusinessEntityID IN' +
                    ' (SELECT number FROM dbo.intlist_to_tbl(@employeestr))'+ @nl               

    IF EXISTS(SELECT * FROM @employeetbl)
       SELECT@sql += ' AND per.BusinessEntityID IN (SELECT val FROM @employeetbl)'+ @nl         

    SELECT @sql+= ' ORDER BYo.SalesOrderID' + @nl                                               

    IF @debug= 1
       PRINT @sql                                                                               

    SELECT @paramlist=  '@salesorderid    int,
                      @fromdate   datetime,
                      @todate     datetime,
                      @minprice   money,
                      @maxprice   money,
                      @custid     nchar(5),
                      @custname   nvarchar(40),
                      @prodid     int,
                      @prodname   nvarchar(40),
                      @employeestr varchar(MAX),
                      @employeetbl intlist_tbltype READONLY'                                    

    EXEC sp_executesql@sql, @paramlist,@salesorderid, @fromdate, @todate, @minprice,
                   @maxprice,  @custid, @custname,@prodid, @prodname, @employeestr, @employeetbl

代码分析:


在上面代码中的第18行,定义了一个变量@sql,用于存储查询字符串。由于sp_executesql要求参数必须为NVARCHAR,所以这里使用NVARCHAR(MAX),以便足够存放所有最终字符串。

在第20行,使用了一个变量@nl,通过赋值char(13)+char(10)实现Windows上的换行符功能。虽然它是变量,但是在存储过程中实际上是一个常量。

在第22 到31行,包含了动态SQL的核心部分,并存放在@sql变量中。通过后续的参数拼接实现整个动态SQL查询。注意代码中均使用了两部命名(即架构名.表名),因为由于性能原因,SQL Server在编译和优化时需要精确定位对象,如果表A存在dbo.A和Sales.A这两个架构名,那么SQL Server需要花时间去判断究竟使用的是哪个表,这会带来不小的开销,注意,即使只有几十毫秒,但是对于一个频繁被执行的存储过程或语句,整体性能会被明显拉低,所以不管基于性能还是编程规范的考虑,都应该带上架构名,当然如果你的系统只有dbo这个默认架构,不带也行,但是建议还是要规范化编程提高可读性和可维护性。这里再插一句,在本人优化的代码中,经常看到很多语句中,表名使用了别名,但是在ON、WHERE中又没有带上别名前缀,咋一看上去很难知道字段来自于哪个表,要一个一个相关表去检查,花了不该花的时间,为了维护代码的人,你们就行行好吧。

在第31行是一句“WHERE 1=1”,类似编程语言中的占位符,使WHERE语句即使单独存在也不会报错,下面会介绍为什么也要加上@nl。

在第33行开始,针对所有单值查询参数进行检查,如果参数不为NULL,则添加到最终的SQL字符串的对应列中。从这里开始就要注意对单引号、双引号的使用,同时留意在每次拼接后面都加上了@nl。

在第67 行,对@employeestr参数进行处理,处理方式和上一篇静态SQL一样。其他剩余部分相对简单,不做过多解释。

在第72行,添加了一个参数@debug,默认为0,当用户调用传入1时,输出SQL字符串,这在调试和检查错误时非常有用,因为动态SQL往往很难直接从代码中看出最终语句,如果在开发过程没有注意引号、空格、类型转换等问题时,都会在后续调用过程中报错。通过@debug参数,可以在未执行语句(即还不至于报错停止之前)就把需要执行的语句打印出来,注意顺序很重要,如果在执行报错后你再想打印就不一定能打印出来了。

对于几乎每行后面都添加的@nl,当然是有意图的,如果不加换行符,代码可能会变成单行很长的字符串,print出来不直观。甚至看起来很痛苦,虽然现在有格式化工具,但是不是每次都破解成功,对单串字符串的美化还是比较浪费时间的。

最后,通过sp_executesql执行SQL字符串,这是一个系统存储过程,需要提供两个固定参数,第一个是SQL字符串,第二个是参数列。这些参数必须是nvarchar类型。在这个例子中,调用语句在存储过程内部。你也可以在外部调用存储过程。但是需要记住的是动态SQL不能得知任何调用参数。

注意存储过程最后的参数列@paramlist,是静态的,也就是参数集是固定的,即使有些参数并不是每次都会使用到。

测试:

可以使用下面语句对存储过程进行测试:

EXEC [sp_Get_orders]@salesorderid = 70467
EXEC [sp_Get_orders]@custid  = 30097
EXEC [sp_Get_orders]@prodid  = 936
EXEC [sp_Get_orders]@prodid  = 936, @custname = 'Carol'
EXEC [sp_Get_orders]@fromdate = '2007-11-01 00:00:00.000', @todate = '2008-04-18 00:00:00.000'
EXEC [sp_Get_orders]@employeestr = '20124,759,1865', @custid = 29688

DECLARE @tbl intlist_tbltype
INSERT @tbl(val) VALUES(20124),(759),(1865)
EXEC [sp_Get_orders]@employeetbl = @tbl, @custid = 29688

对于这类情况,需要对所有参数进行测试,最好是能知道实际使用中哪些参数的使用频率最高。
本文出处:http://blog.****.net/dba_huangzj/article/details/50202371

动态SQL的编译和缓存:


每当用户以相同查询参数集进行调用这个存储过程时,执行计划会被重用。如果调用上一章的存储过程sp_get_orders_1时,如:

EXEC sp_get_orders_1@salesorderid = 70467
EXEC sp_get_orders_1@salesorderid = 70468
EXEC sp_get_orders_1@salesorderid = 70469

由于OPTION(RECOMPILE),所以不缓存任何执行计划并且每次都重编译。但是对于本文中的存储过程:

EXEC [sp_Get_orders]@salesorderid = 70467
EXEC [sp_Get_orders]@salesorderid = 70468
EXEC [sp_Get_orders]@salesorderid = 70469

只会针对第一次调用进行编译并缓存执行计划,后续两次调用将使用第一的执行计划进行直接运行。但是当调用的参数变化时,如:

EXEC [sp_Get_orders]@salesorderid = 70467,@prodid  = 870

会发生新的编译并产生新的缓存条目,但原有的用于查询SalesOrderID的执行计划不受影响。

特殊的查询条件:


在上一篇静态SQL中,已经展示了如何用静态SQL实现某些特殊的查询条件,本部分将演示用动态SQL来完成这些工作,前面提到过,静态SQL针对简单的查询条件,足以应付自如,但是当需求数量和复杂度逐步增加时,静态SQL将变得不可控。此时就需要考虑动态SQL。

数据分布不均的情况:


在很多系统中,常见的一类情况是,订单表上有一个状态列Status,里面有4个值:N(新订单)、P(处理中)、E(异常订单)、C(已处理订单),同时几乎99%的数据都是为C。

这种情况下可以使用对该列中C值的过滤索引/筛选索引(filterindex)来过滤不必要的数据或需要经常查询的数据。但是如果在动态SQL中这样写:

IF @status IS NOT NULL
  SELECT @sql += ' AND o.Status = @status'

由于动态SQL的执行计划是针对所有情况进行优化的,所以这种写法是不会专门针对过滤索引起效,需要额外制定一些操作逻辑来“指示”优化器使用这个过滤索引,如:

IF @status IS NOT NULL
   SELECT @sql += ' AND o.Status = @status' +
                  CASE WHEN @status <> 'C'
                       THEN ' AND o.Status <> ''C'''
                       ELSE ''
                  END

这种情况是针对单值参数,如果@status为多值,即用户需要筛选某些类型的数据,则需要按这种方式添加更多的处理逻辑。

自定义排序:

在动态SQL中,很常见的应用常见是使用自定义的排序规则,通过用户前端输入的排序条件进行结果集排序,比如:

@sql += ' ORDER BY ' + @sortcol

这种写法可以满足多列排序。比如’SalesOrderID, OrderTime Desc’。虽然对于满足功能来说,已经足够了,但是由于客户端不知道查询本身,可能导致传入的参数不属于相关的表或其他因素导致报错,特别是ORDER BY在T-SQL的逻辑处理中属于接近最后部分,SELECT语句可能把原始列进行重命名、运算等,导致前端无法得知SELECT的最终列名。另外即使是使用了正确的名字,但是在后续可能因为表结构的变更、列名变更等因素又带来报错。这种情况其实很难避免,不过多考虑一下问题可能就没有那么严重,比如可以用下面的方式来预处理:

SELECT @sql += ' ORDER BY ' +
               CASE @sortcol WHEN 'OrderID'      THEN 'o.OrderID'
                             WHEN 'EmplyoeeID'   THEN 'o.EmployeeID'
                             WHEN 'ProductID'    THEN 'od.ProductID'
                             WHEN 'CustomerName' THEN 'c.CompanyName'
                             WHEN 'ProductName'  THEN 'p.ProductName'
                             ELSE 'o.OrderID'
               END + CASE @isdesc WHEN 0 THEN ' ASC' ELSE ' DESC' END

备用表:


在上一章备用表中,提到了关于不同参数访问不同表的情况,这种情况在动态SQL中实现也不难,可以把FROM部分改写成:

ROM dbo.' + CASE @ishistoric
                  WHEN 0 THEN 'Orders'
                  WHEN 1 THEN 'HistoricOrders'
             END + ' o
JOIN dbo.' + CASE @ishistoric
                  WHEN 0 THEN '[Order Details]'
                  WHEN 1 THEN 'HistoricOrderDetails'
             END + ' od

但是为了避免SQL注入的风险,不建议通过前端程序传入表名,而是传入某些标识参数然后在存储过程内部进行表名选择。

本文出处:http://blog.****.net/dba_huangzj/article/details/50202371

缓存问题:


参数化动态SQL的其中一个优势是可以通过计划重用而减少编译次数。但是缓存并不总是好的,比如在上一章基础技能部分提到的:

1. exec sp_Get_orders_1@fromdate='20050701',@todate ='20050701'
2. exec sp_Get_orders_1@fromdate='20050101',@todate ='20051231'  

虽然参数集相同,但是当值不同的时候,如果这些不同的值的数据分布严重不均匀,会导致执行计划无法高效支持所有查询。这种情况在动态SQL和静态SQL中都比较常见,下面来介绍一下处理方法:

OPTION(RECOMPILE):

对,你又见到它了。在上面提到的特定情况下,如果查询条件是@fromdate和@todate,添加OPTION(RECOMPILE):

IF (@fromdate IS NOT NULL OR @todate IS NOT NULL)
   SELECT @sql += ' OPTION(RECOMPILE)' + @nl

通常来说,当你发现查询条件上有合适的索引,并且选择度非常依赖于实际值的输入,那么可以添加OPTION(RECOMPILE),以便你总能通过编译得到关于当前统计信息的最佳执行计划。但是显然这种方式会添加一部分不必要的编译,比如两次执行的值完全一样时,依旧还会编译。

索引提示和其他提示:

有时候可以尝试使用“提示,hints”,可以通过CASE WHEN 判断需要传入什么参数,并且对这些参数额外指定需要走的索引。但是正如前面提到过的,提示要慎用,特别是索引提示,除非你确保索引名永不变更:

FROM   dbo.Orders o ' + CASE WHEN @custid IS NOT NULL AND
                                  (@fromdate IS NOT NULL OR
                                  @todate IS NOT NULL)
                             THEN 'WITH (INDEX = CustomerID) '
                             ELSE ''
                         END

另外一种提示是使用OPTIMIZE FOR。如果你希望执行计划总是使用占用最多的情况来编译,比如前面提到的status类型中的C,那么可以添加:

IF @status IS NOT NULL
   @sql += ' OPTION (OPTIMIZE FOR (@status = ''C''))'

如果你不想优化器通过嗅探参数来产生执行计划,可以使用:

IF @fromdate IS NOT NULL AND @todate IS NOT NULL
   @sql += ' OPTION (OPTIMIZE FOR (@fromdate UNKNOWN, @todate UNKNOWN))'

这样优化器就不会使用标准假设,即10%左右来编译查询。

本文出处:http://blog.****.net/dba_huangzj/article/details/50202371

总结:


动态SQL很强大,但是如果读者归纳能力比较强的话,可以看到,动态SQL的问题主要是在不能很好地利用计划缓存或使用的是不合适的执行计划,导致性能问题。

对于这类情况,有很多方法可以使用,而且如果可以,不妨考虑非数据库层面的其他技术。但是我们的目的还是一个:保证执行计划针对任何参数,最起码绝大部分参数都是最佳的,并且能够尽可能重用。

最后,需要提醒的是,任何技术、技巧,都应该在尽可能贴近实际环境的测试环境中做充分的测试,以便得到你希望的结果。

本文出处:http://blog.****.net/dba_huangzj/article/details/50202371