SQL Server函数性能差

4
我们建立的一个应用程序在其数据库模式中经历了巨大的变化,特别是在财务数据存储方式上。我们有一些函数根据各种情况计算账单总金额;而这个变化在函数需要连续运行多次时会导致巨大的性能问题。
我将包括解释、函数和相关模式,并希望有人能够看到更好的编写函数的方法。这是SQL Server 2008。
首先,业务基础:想象一种医疗程序。执行该程序的医疗保健提供者发送一个或多个账单,每个账单可能有一个或多个项目(BillItems)。
该程序重新向另一方进行了再次结算。向第三方收取的费用可能是:
1.提供者的全部账单金额 2.提供者的全部账单金额加上Copay金额,或者 3.完全不同的金额(Rebill金额)
当前用于计算程序账单的函数考虑了所有三种情况:
CREATE FUNCTION [dbo].[fnProcTotalBilled]  (@PROCEDUREID INT)
    RETURNS MONEY AS
BEGIN
DECLARE @billed MONEY
    SELECT @billed = (SELECT COALESCE((SELECT COALESCE(sum(bi.Amount),0)
    FROM BillItems bi INNER JOIN Bills b ON b.BillID=bi.BillID
        INNER JOIN Procedures p on p.ProcedureID=b.ProcedureID
    WHERE b.ProcedureID=@PROCEDUREID
    AND p.StatusID=3
    AND b.HasCopay=0
    AND b.Rebill=0),0))
-- the total of the provider's billing, with no copay and not rebilled
    +
    (SELECT COALESCE((SELECT sum(bi.Amount) + COALESCE(b.CopayAmt,0)
    FROM BillItems bi INNER JOIN Bills b ON b.BillID=bi.BillID
        INNER JOIN Procedures p on p.ProcedureID=b.ProcedureID
    WHERE b.ProcedureID=@PROCEDUREID
    AND p.StatusID=3
    AND b.HasCopay=1
    GROUP BY b.billid,b.CopayAmt),0))
-- the total of the provider's billing, plus a Copay amount
    +
    (SELECT COALESCE((SELECT sum(COALESCE(b.RebillAmt,0))
    FROM Bills b
        INNER JOIN Procedures p on p.ProcedureID=b.ProcedureID
    WHERE b.ProcedureID=@PROCEDUREID
    AND p.StatusID=3
    AND b.Rebill=1),0))
-- the Rebill amount, instead of the provider's billing
    RETURN @billed
END

我将省略该过程的DDL。可以说,它必须具有特定的状态(在函数中表示为p.StatusID = 3)。

这是账单及相关账单项的DDL:

CREATE TABLE dbo.Bills (
    BillID int IDENTITY(1,1) NOT NULL,
    InvoiceID int DEFAULT ((0)),
    CaseID int NOT NULL,
    ProcedureID int NOT NULL,
    TherapyGroupID int DEFAULT ((0)) NOT NULL,
    ProviderID int NOT NULL,
    Description varchar(1000),
    ServiceDescription varchar(255),
    BillReferenceNumber varchar(100),
    TreatmentDate datetime,
    DateBilled datetime,
    DateBillReceived datetime,
    DateBillApproved datetime,
    HasCopay bit DEFAULT ((0)) NOT NULL,
    CopayAmt money,
    Rebill bit DEFAULT ((0)) NOT NULL,
    RebillAmt money,
    IncludeInDemand bit DEFAULT ((1)) NOT NULL,
    CreateDate datetime DEFAULT (getdate()) NOT NULL,
    CreatedByID int,
    ChangeDate datetime,
    ChangeUserID int,
    PRIMARY KEY (BillID)
);


CREATE TABLE dbo.BillItems (
    BillItemID int IDENTITY(1,1) NOT NULL,
    BillID int NOT NULL,
    ItemDescription varchar(1000),
    Amount money,
    WillNotBePaid bit DEFAULT ((0)) NOT NULL,
    CreateDate datetime DEFAULT (getdate()),
    CreatedByID int,
    ChangeDate datetime,
    ChangeUserID varchar(25),
    PRIMARY KEY (BillItemID)
);

我完全意识到这个函数有多么复杂;但我找不到另一种解决所有场景的方法。
我希望更优秀的SQL程序员或DBA能够看到更高效的解决方案。
非常感谢任何帮助。
谢谢,
汤姆
更新:
感谢大家的回复。我试图在评论中添加一些澄清,但我也会在这里这样做。
首先,定义:一个Procedure是Provider在单个服务日期提供的医疗服务。我们只关心一个Procedure的总计费用;多个人不会收到账单。
一个“Case”可以有多个Procedure。
通常,单个Procedure将有一个账单 - 但并非总是如此。一个账单可能有一个或多个BillItems。Copay(如果存在)将添加到BillItems的总和中。重发金额胜过一切。
性能问题出现在更高的层面上,当计算整个Case(多个Procedure)的总计时以及需要显示同时显示数百个Case的网格数据时。
我的查询是在Procedure级别进行的,因为描述问题更简单。
至于示例数据,在@Serpiton的SQL Fiddle中的数据是一个很好的、简洁的例子。非常感谢您提供它。
在审查答案时,我认为@Serpiton和@GarethD的CTE方法都是对我的原始方法的强有力改进。目前,我将使用CTE方法,仅为了避免处理SELECT的多个结果的必要性。
我修改了@Serpiton的CTE以在Case级别上工作。如果他或其他人能看一下它,我会很感激。在我的测试中,它运行良好,但我希望其他人也能看一看。
它的操作如下:
WITH Normal As (
SELECT b.BillID
   , b.CaseID
   , sum(coalesce(n.Amount * (1 - b.Rebill), 0)) Amount
FROM   Procedures p
     INNER JOIN Bills b ON p.ProcedureID = b.ProcedureID
     LEFT  JOIN BillItems n ON b.BillID = n.BillID
WHERE  b.CaseID = 3444
AND  p.StatusID = 3
GROUP BY b.CaseID,b.BillID, b.HasCopay
)
SELECT Amount = Sum(b.Amount) 
          + Sum(Coalesce(c.CopayAmt, 0)) 
          + Sum(Coalesce(r.RebillAmt, 0))
FROM   Normal b
   LEFT  JOIN Bills c ON b.BillID = c.BillID And c.HasCopay = 1
   LEFT  JOIN Bills r ON b.BillID = r.BillID And r.Rebill = 1
GROUP BY b.caseid

一个ProcedureID是否可以对应于三种情况中的多个情况?或者至少两个子查询总是返回0? - MatBailie
我针对部分问题进行了回答,并要求提供澄清、样本数据等。您能否查看并编辑您的问题以便我们可以进一步协助您。 - DRapp
@tmcneer - 在连接之前聚合BillItems(就像我的答案中所示)是否可以简化逻辑,消除对其他字段进行分组的需求,并消除对Bills的两个额外连接的要求? - MatBailie
@MatBailie - 并不是我不欣赏你的回答。昨天我非常匆忙,试图解决这个问题以及其他问题。当我复制代码(在您的编辑下面)并运行它时,出现了语法错误。我没有时间进行故障排除,而其他建议的解决方案都很好用。我仍在寻找最佳解决方案,所以如果您能看到示例中的错误,我将很乐意与之合作。 - tmcneer
@tmcneer 缺少 END 关键字。 - MatBailie
@MatBailie - 谢谢。我应该注意到这点 - 除了这是 Case 语句中我一直犯的同样错误。我会测试你的方法并回复结果。 - tmcneer
5个回答

2

一个非常快速的解决方法是使用(TABLE VALUED) (INLINE) FUNCTION代替(SCALAR) (MULTI-STATEMENT) FUNCTION。

CREATE FUNCTION [dbo].[fnProcTotalBilled]  (@PROCEDUREID INT)
AS
RETURN (
        SELECT
          (sub-query1)
          +
          (sub-query2)
          +
          (sub-query3)   AS amount
       );

这可以按照以下方式使用:
SELECT
  something.*,
  totalBilled.*
FROM
  something
CROSS APPLY            -- Or OUTER APPLY
  [dbo].[fnProcTotalBilled](something.procedureID)   AS totalBilled

在处理大型数据集时,使用表值函数比使用标量函数要明显更快。
- 必须是内联的(非多语句)
- 必须是表值的(非标量)
如果您为计算编写更好的业务逻辑,将获得更多的性能优势。 编辑: 这可能在功能上与您描述的相同,但很难确定。请在我的问题中添加评论以进一步调查。
SELECT
  SUM(
    CASE WHEN b.HasCopay = 0 AND b.Rebill = 0 THEN               COALESCE(bi.TotalAmount, 0)
         WHEN b.HasCopay = 1                  THEN b.CopayAmt  + COALESCE(bi.TotalAmount, 0)
         WHEN                    b.Rebill = 1 THEN b.RebillAmt
                                              ELSE 0
    END
  )  AS Amount
FROM
  Procedures p
INNER JOIN
  Bills      b
    ON  b.ProcedureID = p.ProcedureID
LEFT JOIN
(
  SELECT BillID, SUM(Amount) AS TotalAmount
    FROM BillItems
GROUP BY BillID
)
  AS bi
    ON  bi.BillID     = b.BillID
WHERE
      p.ProcedureID=@PROCEDUREID
  AND p.StatusID=3

“技巧”在于使用子查询将所有的BillItems聚合到每个BillID的一条记录中。优化器实际上不会为整个表执行此操作,而只会针对基于您的JOINWHERE子句的相关记录执行此操作。
这意味着Bill:BillItem1:0..1,一切都变得简单了。我相信 ;)

我已经在上下文中尝试了你的代码,进行了一次非常不科学的测试。也就是说,我把它放在函数被重复使用并且返回值与其他查询值一起的地方,然后计时。虽然这不是一个精心计时的性能测试,但它肯定可以表明代码在应用程序的最关键领域中的表现如何。我无法告诉你为什么,但是你的解决方案大约需要比@Serpiton建议的CTE方法长3倍的时间。实际上,它似乎比我的原始代码还要慢一点。非常感谢你的帮助。 - tmcneer

1

我注意到的第一件事情是,如果一个procedureID有多个billID(虽然我不知道这在你的设计中是否可能),那么你的查询可能会失败。如果真的发生了这种情况,下面这部分代码就会失效:

(SELECT COALESCE((SELECT sum(bi.Amount) + COALESCE(b.CopayAmt,0)
FROM BillItems bi INNER JOIN Bills b ON b.BillID=bi.BillID
    INNER JOIN Procedures p on p.ProcedureID=b.ProcedureID
WHERE b.ProcedureID=@PROCEDUREID
AND p.StatusID=3
AND b.HasCopay=1
GROUP BY b.billid,b.CopayAmt),0))

由于分组,子查询将返回多个结果,这是不允许的。但我认为这不会影响我如何修改您的模式的整体决策。
当您将其作为标量UDF操作时,我建议将其转换为视图。每行执行一次,而使用视图时,定义将扩展到外部查询并进行相应的优化。
您还可以将此转换为单个选择,第一步是获取所有三个子查询共有的组件:
SELECT  p.ProcedureID,
        bi.Amount,
        b.HasCopay,
        b.CopayAmt,
        b.Rebill,
        b.RebillAmt,
FROM    (   SELECT  BillID, Amount = SUM(Amount)
            FROM    Billitems 
            GROUP BY BillID
        ) bi
        INNER JOIN Bills b
            ON b.BillID = bi.BillID
        INNER JOIN Procedures p
            ON p.ProcedureID = b.ProcedureID
WHERE   p.StatusID = 3;

现在,您可以将三个子查询的逻辑组合起来,以获得相同的总数:
SELECT  p.ProcedureID,
        Amount = CASE WHEN b.Rebill = 0 THEN bi.Amount ELSE 0 END,
        CopayAmt = CASE WHEN b.HasCopay = 1 THEN b.CopayAmt ELSE 0 END,
        RebillAmt = CASE WHEN b.Rebill = 1 THEN b.RebillAmt ELSE 0 END,
FROM    (   SELECT  BillID, Amount = SUM(Amount)
            FROM    Billitems 
            GROUP BY BillID
        ) bi
        INNER JOIN Bills b
            ON b.BillID = bi.BillID
        INNER JOIN Procedures p
            ON p.ProcedureID = b.ProcedureID
WHERE   p.StatusID = 3;

你现在可以将聚合和移动到视图中以便重复使用(我已经将案例语句移到APPLY中,只是为了避免在总列中重复使用案例语句):
CREATE VIEW dbo.ProcTotalBilled
AS
    SELECT  p.ProcedureID,
            Amount = SUM(calc.Amount),
            CopayAmt = SUM(calc.CopayAmt),
            Rebill = SUM(cal.RebillAmt),
            Total = SUM(calc.Amount +  calc.CopayAmt + cal.RebillAmt)
    FROM    (   SELECT  BillID, Amount = SUM(Amount)
                FROM    Billitems 
                GROUP BY BillID
            ) bi
            INNER JOIN Bills b
                ON b.BillID = bi.BillID
            INNER JOIN Procedures p
                ON p.ProcedureID = b.ProcedureID
            CROSS APPLY
            (   SELECT  Amount = CASE WHEN b.Rebill = 0 THEN bi.Amount ELSE 0 END,
                        CopayAmt = CASE WHEN b.HasCopay = 1 THEN b.CopayAmt ELSE 0 END,
                        RebillAmt = CASE WHEN b.Rebill = 1 THEN b.RebillAmt ELSE 0 END
            ) calc
    WHERE   p.StatusID = 3
    GROUP BY p.ProcedureID;

然后,不要使用类似以下的内容:
SELECT  Total = dbo.fnProcTotalBilled(p.ProcedureID)
FROM    dbo.Procedures p;

你会使用

SELECT  Total = ISNULL(ptb.Total, 0)
FROM    dbo.Procedures p
        LEFT JOIN dbo.ProcTotalBilled ptb
            ON ptb.ProcedureID = p.ProcedureID;

稍微冗长一些,但如果它不能显著优于您的标量UDF,我会感到惊讶。

1

更新的答案
为了提高性能,您可以创建一个与CTE相同定义的视图,以便存储和重用查询计划。
如果您需要计算多个总金额,请不要尝试单独获取它们,更好的方案是使用单个查询获取所有总金额,并编写条件。

WHERE b.CaseID IN (list of cases)

或者满足您需求的其他条件,并在主查询中添加更多信息,至少包括CaseID。
更新 @DRapp指出了我之前解决方案的问题(我没有测试就写了,抱歉),为了解决这个问题,我已经从主查询中删除了BillItems,现在只能与Bills一起使用。
WITH Normal As (
  SELECT b.BillID
       , b.ProcedureID
       , sum(coalesce(n.Amount * (1 - b.Rebill), 0)) Amount
  FROM   Procedures p
         INNER JOIN Bills b ON p.ProcedureID = b.ProcedureID
         LEFT  JOIN BillItems n ON b.BillID = n.BillID
WHERE  p.ProcedureID = @PROCEDUREID
  AND  p.StatusID = 3
GROUP BY b.ProcedureID, b.BillID, b.HasCopay
)
SELECT @Billed = Sum(b.Amount) 
               + Sum(Coalesce(c.CopayAmt, 0)) 
               + Sum(Coalesce(r.RebillAmt, 0))
FROM   Normal b
       LEFT  JOIN Bills c ON b.BillID = c.BillID And c.HasCopay = 1
       LEFT  JOIN Bills r ON b.BillID = r.BillID And r.Rebill = 1
GROUP BY b.ProcedureID
工作原理
Normal CTE获取与ProcedureID相关的所有账单,并计算账单总额,Amount * (1 - Rebill)如果账单需要重新计费,则将金额设置为0。
在主查询中,Normal CTE与特殊类型的账单连接,因为Normal包含所选ProcedureID的所有账单,所以表Procedures不在其中。

演示使用随机数据。

旧查询
没有数据来测试我们的查询,这是盲目尝试。
SELECT @billed = Sum(Coalesce(n.Amount, 0)) 
               + Sum(Coalesce(c.CopayAmt, 0)) 
               + Sum(Coalesce(r.RebillAmt, 0))
FROM   Procedures p on 
       INNER JOIN Bills b ON p.ProcedureID = b.ProcedureID And b.Rebill = 0
       INNER JOIN BillItems n ON b.BillID = n.BillID
       INNER JOIN Bills c ON p.ProcedureID = b.ProcedureID And c.HasCopay = 1
       INNER JOIN Bills r ON p.ProcedureID = b.ProcedureID And r.Rebill = 1
Where  p.ProcedureID = @PROCEDUREID
  AND  p.StatusID = 3

其中b是“正常”账单的别名(使用n表示账单项目),c表示共付账单,r表示重新计费的账单。
bJOIN条件仅检查b.Rebill = 0,以获取“正常”账单和共付账单的账单项目。
我假设没有账单可以同时具有HasCopayRebill为1。


一个不错的尝试,有点符合我的想法,但是对于多行billItems条目的copay总和可能会有问题。此外,还要等待我回答/提问OP关于账单ID的问题。他们是想要所有按照过程代码进行的程序的全局总数,还是仅仅为了重新计费而针对单个人员? - DRapp
我还没有时间去仔细研究所有慷慨的建议,但是为了澄清一些问题——在这种情况下不存在多个人的问题。Procedure 是指单个医疗服务提供者在单个服务日期内提供的医疗服务。对于单个 Procedure 可能会有多个账单,但通常不会有。账单条目可能会有多个,因为账单可能会被拆分成多个行项目。Copay 适用于整个账单,因此如果有 Copay,则总额为 Copay 金额 + 各个 BillItems 的总额。Rebill 金额最高。希望这样更清楚明白。 - tmcneer
还有一件事要补充:函数内的数据集不是很大。对于给定的过程,只有一个或两个账单。但是相同类型的函数必须查看案例中的所有账单以获取总数。(一个案例可能有20个过程。)在将聚合数据拉入网格时,我们可能需要同时为几百个案例执行此操作。此外,在为单个案例显示信息时,我们可能需要计算每个过程的总数并将其汇总到每个提供者,从而显示案例上单个提供者的所有过程的总数。 - tmcneer

0

毕竟,您需要从账单中获取一些值以及账单项目的总和。您可以简化查询如下:

select sum
(
  coalesce( case when b.rebill = 1 then b.rebillamt end , 0 ) +
  coalesce( case when b.rebill = 0 then (select sum(bi.amount) from billitems bi where bi.billid = b.billid) end , 0 ) +
  coalesce( case when b.rebill = 0 and b.hascopay = 1 then b.copayamt end , 0 )
) as value
from procedures p
inner join bills b on b.procedureid = p.procedureid
where p.ProcedureID = @PROCEDUREID
and p.StatusID = 3;

但是在这方面,T-SQL存在缺陷,并会出现“无法对包含聚合或子查询的表达式执行聚合函数”的错误。因此,您需要使用内部和外部选择语句。

select sum(value) as total
from
(
  select
    coalesce( case when b.rebill = 1 then b.rebillamt end , 0 ) +
    coalesce( case when b.rebill = 0 then (select sum(bi.amount) from billitems bi where bi.billid = b.billid) end , 0 ) +
    coalesce( case when b.rebill = 0 and b.hascopay = 1 then b.copayamt end , 0 ) as value
  from procedures p
  inner join bills b on b.procedureid = p.procedureid
  where p.ProcedureID = @PROCEDUREID
  and p.StatusID = 3
) allvalues;

你甚至不需要将表过程与账单表连接,而是在内部选择中获取过程ID。但我用Serpiton的SQL fiddle(感谢Serpiton)尝试了一下,T-SQL处理速度比连接慢。你可以尝试一下。也许在你的SQL Server版本和你的表中速度更快:
select sum(value) as total
from
(
  select
    coalesce( case when b.rebill = 1 then b.rebillamt end , 0 ) +
    coalesce( case when b.rebill = 0 then (select sum(bi.amount) from billitems bi where bi.billid = b.billid) end , 0 ) +
    coalesce( case when b.rebill = 0 and b.hascopay = 1 then b.copayamt end , 0 ) as value
  from bills b
  where b.procedureid = 
  (
    select p.procedureid
    from procedures p
    where p.ProcedureID = @PROCEDUREID
    and p.StatusID = 3
  )
) allvalues;

编辑:这里还有另一个选项。如果给定的过程标识始终存在,并且您只想检查状态标识是否为3,则可以编写语句,以便在状态标识= 3的情况下仅执行账单选择。这不一定会更快;它甚至可能变得更慢。这只是您可以尝试的另一个选项。

select
  case when p.StatusID = 3 then
    (
      select sum(value)
      from
      (
        select
          coalesce( case when b.rebill = 1 then b.rebillamt end , 0 ) +
          coalesce( case when b.rebill = 0 then (select sum(bi.amount) from billitems bi where bi.billid = b.billid) end , 0 ) +
          coalesce( case when b.rebill = 0 and b.hascopay = 1 then b.copayamt end , 0 ) as value
        from bills b 
        where b.procedureid = p.procedureid
      ) allvalues
    )
  else
    0
  end as value 
from procedures p
where p.ProcedureID = @PROCEDUREID;

0

你能展示一些涵盖样本种类的样本数据吗?此外,我期望的过程更像是一个查找表,许多人可能会因为同一个过程而被计费,因此BillID对于函数来说非常关键。已经针对给定过程向特定人员进行了什么样的计费。然后,该函数将具有两个参数,一个用于您感兴趣的过程,另一个用于患者实际的账单。

然后,内部查询将被限制在一个人的账单上...除非该过程对每个进行该过程的人都是唯一的,但由于未提供过程的DDL,这一点不清楚。

我对查询还有其他想法,但需要从上下文中得到澄清,以便我不会只是为了显示查询而添加垃圾信息。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接