【问题标题】:Find the row associated with a Min/Max, without inner loop查找与最小值/最大值关联的行,没有内部循环
【发布时间】:2016-01-23 17:06:10
【问题描述】:

我有一个关于 T-SQL 和 SQL Server 的问题。

假设我有一个包含 2 列的表 Orders:

  • ProductId int
  • CustomerId int
  • 日期日期时间

我想要每个产品的第一个订单的日期,所以我执行这种类型的查询:

SELECT ProductId, MIN(Date) AS FirstOrder 
FROM Orders
GROUP BY ProductId

我在ProductId 上有一个索引,包括CustomerIdDate 列以加快查询速度(IX_Orders)。查询计划看起来像是对IX_Orders 的非聚集索引扫描,然后是流聚合(由于索引没有排序)。

现在我的问题是我还想检索与每个产品的第一个订单关联的CustomerId(产品 26 是在 25 日星期二由客户 12 首次订购的)。棘手的部分是我不希望在执行计划中有任何内部循环,因为这意味着表中每个ProductId 的额外读取,效率非常低。

这应该可以使用相同的非聚集索引扫描,然后是流聚合,但是我似乎找不到可以做到这一点的查询。有什么想法吗?

谢谢

【问题讨论】:

    标签: sql sql-server tsql


    【解决方案1】:

    这将处理具有重复日期的产品:

    DECLARE @Orders table (ProductId int
                          ,CustomerId int
                          ,Date datetime
                          )
    
    INSERT INTO @Orders VALUES (1,1,'20090701')
    INSERT INTO @Orders VALUES (2,1,'20090703')
    INSERT INTO @Orders VALUES (3,1,'20090702')
    INSERT INTO @Orders VALUES (1,2,'20090704')
    INSERT INTO @Orders VALUES (4,2,'20090701')
    INSERT INTO @Orders VALUES (1,3,'20090706')
    INSERT INTO @Orders VALUES (2,3,'20090704')
    INSERT INTO @Orders VALUES (4,3,'20090702')
    INSERT INTO @Orders VALUES (5,5,'20090703')  --duplicate dates for product #5
    INSERT INTO @Orders VALUES (5,1,'20090703')  --duplicate dates for product #5
    INSERT INTO @Orders VALUES (5,5,'20090703')  --duplicate dates for product #5
    
    ;WITH MinOrders AS
    (SELECT
         o.ProductId, o.CustomerId, o.Date
             ,row_number() over(partition by o.ProductId order by o.ProductId,o.CustomerId) AS RankValue
         FROM @Orders o
         INNER JOIN (SELECT
                         ProductId
                             ,MIN(Date) MinDate 
                         FROM @Orders 
                         GROUP BY ProductId
                    ) dt ON o.ProductId=dt.ProductId AND o.Date=dt.MinDate
     )
    SELECT
        m.ProductId, m.CustomerId, m.Date
        FROM MinOrders  m
        WHERE m.RankValue=1
        ORDER BY m.ProductId, m.CustomerId
    

    这将返回相同的结果,只需使用与上述代码相同的声明和插入:

    ;WITH MinOrders AS
    (SELECT
         o.ProductId, o.CustomerId, o.Date
             ,row_number() over(partition by o.ProductId order by o.ProductId,o.CustomerId) AS RankValue
         FROM @Orders o
     )
    SELECT
        m.ProductId, m.CustomerId, m.Date
        FROM MinOrders  m
        WHERE m.RankValue=1
        ORDER BY m.ProductId, m.CustomerId
    

    您可以尝试每个版本,看看哪个版本运行得更快...

    【讨论】:

    • 很好,只有一次索引扫描,但它在查询执行计划中给出了排序。
    【解决方案2】:
    declare @Orders table (
        ProductId int,
        CustomerId int,
        Date datetime
    )
    
    insert into @Orders values (1,1,'20090701')
    insert into @Orders values (2,1,'20090703')
    insert into @Orders values (3,1,'20090702')
    insert into @Orders values (1,2,'20090704')
    insert into @Orders values (4,2,'20090701')
    insert into @Orders values (1,3,'20090706')
    insert into @Orders values (2,3,'20090704')
    insert into @Orders values (4,3,'20090702')
    insert into @Orders values (5,5,'20090703')
    
    select O.* from @Orders O inner join 
    (
        select ProductId,
        MIN(Date) MinDate 
        from @Orders 
        group by ProductId
    ) FO
    on FO.ProductId = O.ProductId and FO.MinDate = O.Date
    

    估计的查询计划没有用,因为我用表变量来模拟它,但是匿名内部连接应该在子选择上进行优化。

    【讨论】:

    • 您的选择需要包含 FO.MinDate。
    • 我从未听说过“匿名”连接,我一直使用派生表这个术语。
    • 如果同一产品有多个具有相同最短日期的行,这将不起作用。试试看,将此代码添加到示例中: insert into @Orders values (5,1,'20090703');插入@Orders 值(5,5,'20090703')您将在结果集中多次获得产品 5。
    • 但这不是有效的吗?您想要任何产品的第一个订单的客户 ID,但鉴于数据,如果多个客户在同一天订购同一产品,我会说您应该将它们全部取回。如果客户两次订购相同的产品,那么这可能不应该发生 - 在这种情况下,您可以设置 O.* distinct
    • OP 说“我想要每个产品的第一个订单日期”,对我来说这意味着只列出一次产品。 OP 还想要订购它的客户。如果 OP 使用具有实际时间的 DATETIME 列(不仅仅是测试数据中的天数),您可能会得到第一个,因为同时两个订单的概率很低,但仍然可能发生。我认为,如果有一个平局,同时订购了相同的产品,OP 仍然只想要结果集中的一行,但这是我的看法。
    【解决方案3】:

    SQL Server 2005+:

    SELECT  oo.*
    FROM    (
            SELECT  DISTINCT ProductId
            FROM    Orders
            ) od
    CROSS APPLY
            (
            SELECT  TOP 1 ProductID, Date, CustomerID
            FROM    Orders oi
            WHERE   oi.ProductID = od.ProductID
            ORDER BY
                    Date DESC
            ) oo
    

    名义上,查询计划包含Nested Loops

    但是,外部循环将使用Index ScanStream Aggregate,而内部循环将包含Index Seek 用于ProductIDTop

    事实上,第二个操作几乎是免费的,因为在内循环中使用的索引页很可能会驻留在缓存中,因为它刚刚用于外循环。

    这是1,000,000 行的测试结果(带有100 DISTINCT ProductID's):

    SQL Server parse and compile time: 
       CPU time = 0 ms, elapsed time = 1 ms.
    
    (строк обработано: 100)
    Table 'Orders'. Scan count 103, logical reads 6020, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
    Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
    
    SQL Server Execution Times:
       CPU time = 234 ms,  elapsed time = 125 ms.
    

    ,虽然这只是 SELECT DISTINCT 查询的结果:

    SELECT  od.*
    FROM    (
            SELECT  DISTINCT ProductId
            FROM    Orders
            ) od
    

    还有统计数据:

    SQL Server parse and compile time: 
       CPU time = 0 ms, elapsed time = 1 ms.
    
    (строк обработано: 100)
    Table 'Orders'. Scan count 3, logical reads 5648, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
    
    SQL Server Execution Times:
       CPU time = 250 ms,  elapsed time = 125 ms.
    

    正如我们所看到的,性能是相同的,CROSS APPLY 只需要400 额外的logical reads(很可能永远不会是physical)。

    看不到如何改进这个查询了。

    此查询的另一个好处是它可以很好地并行化。您可能会注意到CPU 的时间是elapsed time 的两倍:这是由于我的旧Core Duo 上的并行化。

    4-coreCPU 将在一半的时间内完成此查询。

    使用窗口函数的解决方案不并行化:

    SELECT  od.*
    FROM    (
            SELECT  ProductId, Date, CustomerID, ROW_NUMBER() OVER (PARTITION BY ProductID ORDER BY Date DESC) AS rn
            FROM    Orders
            ) od
    WHERE   rn = 1
    

    ,以下是统计数据:

    SQL Server Execution Times:
       CPU time = 0 ms,  elapsed time = 1 ms.
    
    (строк обработано: 100)
    Table 'Orders'. Scan count 1, logical reads 5123, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
    
    SQL Server Execution Times:
       CPU time = 406 ms,  elapsed time = 415 ms.
    

    【讨论】:

    • 需要更改位置:WHERE oi.ProductID = od.ProductID。我的查询计划显示了一个排序和嵌套循环。
    • 我得到 Msg 4104, Level 16, State 1, Line 2 无法绑定多部分标识符“oo.ProductID”。 修复更改 WHERE 为 @Shannon遣散费建议。
    • 对了,忘记修了。 @Shannon:您是否按照@op 所说的那样创建了索引? CREATE INDEX IX_orders_pdc ON Orders (ProductID, Date, CustomerID)
    • 我的索引是 (ProductId, Date, CustomerId) 我将检查 (ProductId, Date, CustomerId)
    • @Quassnoi:移动索引中的列,我确实失去了排序。正如您在回答中指出的那样,仍然是带有搜索的循环。
    【解决方案4】:

    如果不执行子查询或窗口函数(例如 row_number、rank),我看不出有什么方法可以很好地做到这一点,因为 max 只显示在一列中。

    但是你不能很好地做到这一点。

    SELECT
        productid, 
        min(date), 
    cast(
        substring( 
            min(convert(varchar(23),date,21) + cast(customerid as varchar(20)))
                  , 24, 44)
        as int) customerid
    from 
        orders
    group by
        productid 
    

    这仅适用于您的客户 ID 少于 20 位数字。

    编辑: 添加了 group by 子句

    【讨论】:

    • Msg 8120,Level 16,State 1,Line 51 列 '@orders.ProductId' 在选择列表中无效,因为它既不包含在聚合函数中,也不包含在 GROUP BY 子句中。跨度>
    • 糟糕,忘记添加 group by 子句
    【解决方案5】:
    SELECT
        o1.productid, 
        o1.date, 
        o1.customerid
    FROM
        Orders o1
    JOIN
        (select productid, min(date) as orderDate
         from Orders
         group by productid
        ) firstOrder
    ON o1.productid = firstOrder.productid
    

    这是我能想到的最好的方法,但老实说,我不知道这个查询的性能特征是什么。如果不好,我可能会建议运行两个查询来获取您想要的信息。

    【讨论】:

    • +1:我认为您需要在匿名加入中为 min(date) 定义一个别名;否则,这正是我得到的。很高兴知道是否有更好的方法。
    • 此查询得到错误答案,因为它在 o1 和 firstOrder 之间的连接中不包含 Date
    • 您不需要在联接中包含日期。您只需要子查询的产品 ID,因为它具有与之关联的最短日期。返回实际结果的select返回日期。
    【解决方案6】:

    IX_Orders 是按 ProductId,然后是 CutomerId,然后是 Date,还是按 ProductId,然后是 Date,然后是 CustomerId?如果是前者,就换成后者。

    换句话说,不要使用这个:

    create index IX_Orders on Orders (ProductId, CustomerId, Date) 
    

    改用这个:

    create index IX_Orders on Orders (ProductId, Date, CustomerId)
    

    如果你这样做:

    SELECT o1.* 
    FROM [Order] o1
    JOIN
        (
            SELECT ProductID, Min(Date) as Date
            FROM [Order]
            GROUP BY ProductID
        ) o2
        ON o1.ProductID = o2.ProductID AND o1.Date = o2.Date
    ORDER BY ProductID
    

    您最终只对 IX_Orders 进行了一次索引扫描,但是如果两个客户可以同时订购相同的产品,您可以获得每个产品的多行。您可以使用以下查询来解决这个问题,但它的效率低于第一个:

    WITH cte AS
    (
        SELECT ProductID, CustomerID, Date, 
            ROW_NUMBER() OVER(PARTITION BY ProductID ORDER BY Date ASC) AS row
        FROM [Order]
    )
    SELECT ProductID, CustomerId, Date
    FROM cte
    WHERE row = 1
    ORDER BY ProductID
    

    【讨论】:

      猜你喜欢
      • 2022-09-29
      • 1970-01-01
      • 1970-01-01
      • 2018-09-21
      • 2020-12-08
      • 2016-06-07
      • 2021-06-17
      • 2019-05-15
      • 2013-10-05
      相关资源
      最近更新 更多