如何在 SQL Server 中更新具有数百万行的大表?

Posted

技术标签:

【中文标题】如何在 SQL Server 中更新具有数百万行的大表?【英文标题】:How to update large table with millions of rows in SQL Server? 【发布时间】:2016-03-09 21:52:21 【问题描述】:

我有一个UPDATE 语句,它可以更新超过百万条记录。我想分批更新它们 1000 或 10000。我尝试使用 @@ROWCOUNT,但无法获得所需的结果。

出于测试目的,我选择了包含 14 条记录的表并将行数设置为 5。此查询应该更新 5、5 和 4 中的记录,但它只更新前 5 条记录。

查询 - 1:

SET ROWCOUNT 5

UPDATE TableName 
SET Value = 'abc1' 
WHERE Parameter1 = 'abc' AND Parameter2 = 123

WHILE @@ROWCOUNT > 0
BEGIN
    SET rowcount 5

    UPDATE TableName 
    SET Value = 'abc1' 
    WHERE Parameter1 = 'abc' AND Parameter2 = 123

    PRINT (@@ROWCOUNT)
END

SET rowcount 0

查询 - 2:

SET ROWCOUNT  5

WHILE (@@ROWCOUNT > 0)
BEGIN
    BEGIN TRANSACTION

    UPDATE TableName 
    SET Value = 'abc1' 
    WHERE Parameter1 = 'abc' AND Parameter2 = 123

    PRINT (@@ROWCOUNT)

    IF @@ROWCOUNT = 0
    BEGIN
        COMMIT TRANSACTION

        BREAK
    END

    COMMIT TRANSACTION
END

SET ROWCOUNT  0

我在这里错过了什么?

【问题讨论】:

query2 有什么问题? 不要像这样使用 ROWCOUNT。它已被弃用。 msdn.microsoft.com/en-us/library/ms188774.aspx @JuanCarlosOropeza 命令成功完成,但实际上没有任何记录更新。 所有答案都在一遍又一遍地更新相同的记录。您需要区分更新的记录和未触及的记录。我认为这就是问题所在。也许您想在 where 子句中添加“AND Value 'abc1'” PRINT 语句破坏逻辑,删除该行。 【参考方案1】:

    除非您确定该操作正在获得页面锁定,否则您不应更新一组中的 10k 行(因为每页多行是 UPDATE 操作的一部分)。问题是锁升级(从行锁或页锁到表锁)发生在 5000 个。所以将其保持在 5000 以下是最安全的,以防万一操作使用了 Row Locks。

    您应该使用SET ROWCOUNT 来限制将被修改的行数。这里有两个问题:

      自 SQL Server 2005 发布(11 年前)以来,它已被弃用:

      使用 SET ROWCOUNT 不会影响 SQL Server 未来版本中的 DELETE、INSERT 和 UPDATE 语句。避免在新的开发工作中将 SET ROWCOUNT 与 DELETE、INSERT 和 UPDATE 语句一起使用,并计划修改当前使用它的应用程序。对于类似的行为,请使用 TOP 语法

      它不仅会影响您正在处理的语句:

      设置 SET ROWCOUNT 选项会导致大多数 Transact-SQL 语句在受到指定行数的影响时停止处理。这包括触发器。 ROWCOUNT 选项不影响动态游标,但它会限制键集和不敏感游标的行集。应谨慎使用此选项。

    改为使用TOP () 子句。

    在这里进行显式事务没有任何目的。它使代码复杂化,您无需处理 ROLLBACK,甚至不需要处理,因为每个语句都是其自己的事务(即自动提交)。

    假设您找到了保留显式事务的理由,那么您没有 TRY / CATCH 结构。有关处理事务的TRY / CATCH 模板,请参阅我在 DBA.StackExchange 上的回答:

    Are we required to handle Transaction in C# Code as well as in Store procedure

我怀疑问题中的示例代码中没有显示真正的WHERE 子句,因此仅依靠已显示的内容,更好模型(请参阅注释下面关于性能)将是:

DECLARE @Rows INT,
        @BatchSize INT; -- keep below 5000 to be safe
    
SET @BatchSize = 2000;

SET @Rows = @BatchSize; -- initialize just to enter the loop

BEGIN TRY    
  WHILE (@Rows = @BatchSize)
  BEGIN
      UPDATE TOP (@BatchSize) tab
      SET    tab.Value = 'abc1'
      FROM  TableName tab
      WHERE tab.Parameter1 = 'abc'
      AND   tab.Parameter2 = 123
      AND   tab.Value <> 'abc1' COLLATE Latin1_General_100_BIN2;
      -- Use a binary Collation (ending in _BIN2, not _BIN) to make sure
      -- that you don't skip differences that compare the same due to
      -- insensitivity of case, accent, etc, or linguistic equivalence.

      SET @Rows = @@ROWCOUNT;
  END;
END TRY
BEGIN CATCH
  RAISERROR(stuff);
  RETURN;
END CATCH;

通过针对@BatchSize 测试@Rows,您可以避免最终的UPDATE 查询(在大多数情况下),因为最终集合通常比@BatchSize 少一些行数,在这种情况下我们知道有不再需要处理(这是您在answer 中显示的输出中看到的内容)。只有在最终行集等于@BatchSize 的情况下,此代码才会运行最终的UPDATE,影响0 行。

我还在WHERE 子句中添加了一个条件,以防止已经更新的行再次被更新。

关于性能的说明

我在上面强调了“更好”(例如,“这是一个 更好 模型”),因为这对 O.P. 的原始代码有一些改进,并且在许多情况下都可以正常工作,但是并不适合所有情况。对于至少有一定大小的表(由于几个因素而变化,所以我不能更具体),性能会下降,因为需要修复的行更少:

    没有支持查询的索引,或者 有索引,但是WHERE子句中至少有一列是不使用二进制排序规则的字符串数据类型,因此这里的查询中增加了COLLATE子句强制二进制排序规则,这样做会使索引无效(对于此特定查询)。

这是@mikesigs 遇到的情况,因此需要不同的方法。更新的方法将所有要更新的行的 ID 复制到一个临时表中,然后使用该临时表到 INNER JOIN 到在聚集索引键列上更新的表。 (捕获和连接 聚集索引 列很重要,无论这些列是否是主键列!)。

详情请见下方@mikesigs answer。该答案中显示的方法是一种非常有效的模式,我自己在很多场合都使用过。我要做的唯一改变是:

    显式创建#targetIds 表而不是使用SELECT INTO... 对于#targetIds 表,在列上声明一个聚集主键。 对于#batchIds 表,在列上声明一个聚集主键。 要插入#targetIds,请使用INSERT INTO #targetIds (column_name(s)) SELECT 删除ORDER BY,因为它是不必要的。

因此,如果您没有可用于此操作的索引,并且无法临时创建一个可以实际工作的索引(过滤索引可能有效,具体取决于您的 WHERE 子句 @987654353 @query),然后尝试@mikesigs answer 中显示的方法(如果您使用该解决方案,请投票)。

【讨论】:

如果我不更新一列而是假设更新 10 列怎么办?我是否必须比较所有列的值?什么是最高效的方法? @asemprini87 尽可能多地进行比较,以减少不必要的更新,因为它们需要更长的时间并更多地增加日志文件。我刚刚更新了我的答案,包括为Value 过滤器强制使用二进制排序规则,但是您可以在任何其他字符串列上使用COLLATE Latin1_General_100_BIN2 来加速字符串匹配,假设您只是在寻找完全匹配并且不需要考虑大小写差异等。我正在考虑为此操作创建过滤索引,但如果必须为每个批次更新它可能不会更快。不过可能值得测试。 @SolomonRutzky :说在更新数百万条记录时,如果很少有记录失败,那么您将如何记录那些未能更新的记录。示例。在 100 万条记录中,5 百万条记录成功更新,接下来的 5 条记录更新失败,其余记录更新成功,那么你将如何从一百万条记录中记录这 5 条失败的记录。 @RockingDev 要检测单个错误行,您需要在循环中添加额外的逻辑来处理较小的批量大小,直到您到达出错的行,然后我猜将该键值放入临时表,然后将批量大小重置回原始值。这需要先创建临时表,然后通过AND NOT EXISTS (SELECT * FROM #ErrorRows err WHERE err.KeyVal = tab.ClusteredIndexKey) 将其添加到WHERE 子句中。类似的东西。处理完成后,#ErrorRows 中的任何行都需要处理。【参考方案2】:
WHILE EXISTS (SELECT * FROM TableName WHERE Value <> 'abc1' AND Parameter1 = 'abc' AND Parameter2 = 123)
BEGIN
UPDATE TOP (1000) TableName
SET Value = 'abc1'
WHERE Parameter1 = 'abc' AND Parameter2 = 123 AND Value <> 'abc1'
END

【讨论】:

我已经将它从 1000 更新到了 4000,到目前为止似乎工作正常。在一张表中,我更新了 500 万条记录(似乎每 10 分钟更新约 744,000 条记录)。我在开发服务器上运行它,接下来将尝试更新 2600 万条记录。目前正在寻找是否有办法通过“多线程”加快进程。 这是低效的,因为存在检查是多余的。相反,您可以在运行 UPDATE 后检索 @@ROWCOUNT,如果 @@ROWCOUNT BatchSize 则您已完成并可以退出循环。 @Shiv @@ROWCOUNT 是一个全局变量。 USE、SET @Kramb 我知道 - 您可以根据答案缓存行数 ***.com/a/55054293/1519839【参考方案3】:

我昨天遇到了这个线程,并根据接受的答案编写了一个脚本。结果证明执行速度非常慢,需要 12 个小时来处理 33M 行中的 25M。我今天早上取消了它,并与 DBA 一起改进它。

DBA 指出,我的 UPDATE 查询中的 is null 检查在 PK 上使用了聚集索引 Scan,正是扫描使查询速度变慢。基本上,查询运行的时间越长,它就越需要在索引中查找正确的行。

事后看来,他想出的方法是显而易见的。本质上,您将要更新的行的 ID 加载到临时表中,然后在更新语句中将其连接到目标表中。这使用索引 Seek 而不是扫描。天哪,它确实加快了速度!更新最后 8M 条记录需要 2 分钟。

使用临时表进行批处理

SET NOCOUNT ON

DECLARE @Rows INT,
        @BatchSize INT,
        @Completed INT,
        @Total INT,
        @Message nvarchar(max)

SET @BatchSize = 4000
SET @Rows = @BatchSize
SET @Completed = 0

-- #targetIds table holds the IDs of ALL the rows you want to update
SELECT Id into #targetIds 
FROM TheTable 
WHERE Foo IS NULL 
ORDER BY Id

-- Used for printing out the progress
SELECT @Total = @@ROWCOUNT

-- #batchIds table holds just the records updated in the current batch
CREATE TABLE #batchIds (Id UNIQUEIDENTIFIER);

-- Loop until #targetIds is empty
WHILE EXISTS (SELECT 1 FROM #targetIds)
BEGIN
    -- Remove a batch of rows from the top of #targetIds and put them into #batchIds
    DELETE TOP (@BatchSize)
    FROM #targetIds
    OUTPUT deleted.Id INTO #batchIds  

    -- Update TheTable data
    UPDATE t
    SET Foo = 'bar'
    FROM TheTable t
    JOIN #batchIds tmp ON t.Id = tmp.Id
    WHERE t.Foo IS NULL
    
    -- Get the # of rows updated
    SET @Rows = @@ROWCOUNT

    -- Increment our @Completed counter, for progress display purposes
    SET @Completed = @Completed + @Rows

    -- Print progress using RAISERROR to avoid SQL buffering issue
    SELECT @Message = 'Completed ' + cast(@Completed as varchar(10)) + '/' + cast(@Total as varchar(10))
    RAISERROR(@Message, 0, 1) WITH NOWAIT    

    -- Quick operation to delete all the rows from our batch table
    TRUNCATE TABLE #batchIds;
END

-- Clean up
DROP TABLE IF EXISTS #batchIds;
DROP TABLE IF EXISTS #targetIds;

批处理慢的方式,不要用!

作为参考,这里是原始执行速度较慢的查询:

SET NOCOUNT ON

DECLARE @Rows INT,
        @BatchSize INT,
        @Completed INT,
        @Total INT

SET @BatchSize = 4000
SET @Rows = @BatchSize
SET @Completed = 0
SELECT @Total = COUNT(*) FROM TheTable WHERE Foo IS NULL

WHILE (@Rows = @BatchSize)
BEGIN

    UPDATE t
    SET Foo = 'bar'
    FROM TheTable t
    JOIN #batchIds tmp ON t.Id = tmp.Id
    WHERE t.Foo IS NULL

SET @Rows = @@ROWCOUNT
SET @Completed = @Completed + @Rows
PRINT 'Completed ' + cast(@Completed as varchar(10)) + '/' + cast(@Total as varchar(10))

END

【讨论】:

+1 我同意这是一种非常有效的方法,人们应该尝试一下。如果他们遇到像您在我的方法中遇到的性能问题,我会在此处更新我对直接读者的回答。我以前使用过这种模式,所以也许我专注于解决 O.P. 方法中的缺陷。我确实有一些关于对您的方法进行细微更改的建议,我在答案的末尾详细说明了这些建议。我没有提到的一件事是简化输出,您可以通过以下方式完成:DECLARE @Completed INT = 5, @Total INT = 37; RAISERROR('Completed %d / %d', 10, 1, @Completed, @Total) WITH NOWAIT;【参考方案4】:

我想分享我的经验。几天前,我必须用 7600 万条记录更新表中的 2100 万条记录。我的同事建议了下一个变体。 例如,我们有下一个表“Persons”:

Id | FirstName | LastName | Email            | JobTitle
1  | John      |  Doe     | abc1@abc.com     | Software Developer
2  | John1     |  Doe1    | abc2@abc.com     | Software Developer
3  | John2     |  Doe2    | abc3@abc.com     | Web Designer

任务:将人员更新为新职位:“软件开发人员”->“Web 开发人员”。

1.创建临时表“Persons_SoftwareDeveloper_To_WebDeveloper (Id INT Primary Key)”

2. 选择您要使用新职位更新的临时表人员:

INSERT INTO Persons_SoftwareDeveloper_To_WebDeveloper SELECT Id FROM
Persons WITH(NOLOCK) --avoid lock 
WHERE JobTitle = 'Software Developer' 
OPTION(MAXDOP 1) -- use only one core

取决于行数,此语句将需要一些时间来填充您的临时表,但它会避免锁定。在我的情况下,大约需要 5 分钟(2100 万行)。

3.主要思想是生成微sql语句来更新数据库。所以,让我们打印它们:

DECLARE @i INT, @pagesize INT, @totalPersons INT
    SET @i=0
    SET @pagesize=2000
    SELECT @totalPersons = MAX(Id) FROM Persons

    while @i<= @totalPersons
    begin
    Print '
    UPDATE persons 
      SET persons.JobTitle = ''ASP.NET Developer''
      FROM  Persons_SoftwareDeveloper_To_WebDeveloper tmp
      JOIN Persons persons ON tmp.Id = persons.Id
      where persons.Id between '+cast(@i as varchar(20)) +' and '+cast(@i+@pagesize as varchar(20)) +' 
        PRINT ''Page ' + cast((@i / @pageSize) as varchar(20))  + ' of ' + cast(@totalPersons/@pageSize as varchar(20))+'
     GO
     '
     set @i=@i+@pagesize
    end

执行此脚本后,您将收到数百个批处理,您可以在 MS SQL Management Studio 的一个选项卡中执行这些批处理。

4. 运行打印的 sql 语句并检查表上的锁。您始终可以停止进程并使用 @pageSize 来加快或减慢更新速度(不要忘记在暂停脚本后更改 @i)。

5. 删除 Persons_SoftwareDeveloper_To_AspNetDeveloper。删除临时表。

次要注意:此迁移可能需要一些时间,并且在迁移过程中可能会插入包含无效数据的新行。因此,首先修复行添加的位置。在我的情况下,我修复了 UI,“软件开发人员”->“Web 开发人员”。

【讨论】:

【参考方案5】:

这是@Kramb 解决方案的更高效版本。存在检查是多余的,因为 update where 子句已经处理了这个问题。相反,您只需获取行数并与批量大小进行比较。

另请注意@Kramb 解决方案没有从下一次迭代中过滤掉已经更新的行,因此它将是一个无限循环。

还使用现代批量大小语法而不是使用行数。

DECLARE @batchSize INT, @rowsUpdated INT
SET @batchSize = 1000;
SET @rowsUpdated = @batchSize; -- Initialise for the while loop entry

WHILE (@batchSize = @rowsUpdated)
BEGIN
    UPDATE TOP (@batchSize) TableName
    SET Value = 'abc1'
    WHERE Parameter1 = 'abc' AND Parameter2 = 123 and Value <> 'abc1';

    SET @rowsUpdated = @@ROWCOUNT;
END

【讨论】:

循环不会是无限的,因为它使用与过滤相同的参数更新结果集。因此,以下结果集不会包含之前更新的行。 @Kramb 您正在更新 Value 并查看 Parameter1 和 Parameter2。所以不,你没有过滤你实际更新的字段。我添加了第三个过滤器术语来检查您在答案中缺少的 Value 'abc1'。 再试一次...我的回答清楚地表明,我的Exists 查询中的第一个条件实际上是检查Value 是否等于内部查询设置的值@ 987654324@到。 @Kramb 如果更新查询中的前 1000 行已设置值,但前 1000 行之外的行未设置值,则您的解决方案将无限循环。您的解决方案存在缺陷,因为您缺少对实际 UPDATE 调用的 where 检查。存在检查不是问题。【参考方案6】:

您的print 搞砸了,因为它重置了@@ROWCOUNT。每当您使用@@ROWCOUNT 时,我的建议是始终 立即将其设置为变量。所以:

DECLARE @RC int;
WHILE @RC > 0 or @RC IS NULL
    BEGIN
        SET rowcount 5;

        UPDATE TableName
            SET Value  = 'abc1'
            WHERE Parameter1  = 'abc' AND Parameter2  = 123 AND Value <> 'abc1';

        SET @RC = @@ROWCOUNT;
        PRINT(@@ROWCOUNT)
    END;

SET rowcount = 0;

而且,另一个不错的功能是您无需重复 update 代码。

【讨论】:

查看我上面发布的关于使用 ROWCOUNT 控制更新行的链接。 @Gordon 我使用这个逻辑并且查询运行了 2 分钟(仅适用于 14 条记录!!!)。它进入无限循环。 @CSharper 。 . .嗯,如果没有更新行,那么 @@ROWCOUNT 应该是 0,而不是 NULL。无限循环的原因并不明显。 “印刷品”在生产什么?如果update 产生NULL,则可以通过将@RC 设置为某个任意值然后从WHILE 中删除@RC IS NULL 条件来解决此问题。 @GordonLinoff print 无限生成(5 row(s) affected) 1 @CSharper 。 . . where 子句需要排除已更新的行。【参考方案7】:

首先,感谢大家的投入。我调整了我的Query - 1 并得到了我想要的结果。 Gordon Linoff 是对的,PRINT 搞砸了我的查询,所以我将其修改如下:

修改后的查询 - 1:

SET ROWCOUNT 5
WHILE (1 = 1)
  BEGIN
    BEGIN TRANSACTION

        UPDATE TableName 
        SET Value = 'abc1' 
        WHERE Parameter1 = 'abc' AND Parameter2 = 123

        IF @@ROWCOUNT = 0
          BEGIN
                COMMIT TRANSACTION
                BREAK
          END
    COMMIT TRANSACTION
  END
SET ROWCOUNT  0

输出:

(5 row(s) affected)

(5 row(s) affected)

(4 row(s) affected)

(0 row(s) affected)

【讨论】:

以上是关于如何在 SQL Server 中更新具有数百万行的大表?的主要内容,如果未能解决你的问题,请参考以下文章

如何使用 pandas 或 python 将具有数百万行的表从 PostgreSQL 复制到 Amazon Redshift

在 Python 中合并具有数百万行的两个表

具有数百万行的 Django 表

在 MySQL 中计算特定日期之间数百万行的最佳方法

在 SQL Server 2017 上创建具有 800+ 百万行的现有分区表的列存储索引

从C#中的数据中删除特殊字符后如何将dbf文件中的数百万行数据上传到SQL Server