带有子查询的大表更新缓慢


16

对于SourceTable具有> 15MM的记录和Bad_Phrase具有> 3K的记录,以下查询需要将近10个小时才能在SQL Server 2005 SP4上运行。

UPDATE [SourceTable] 
SET 
    Bad_Count=
             (
               SELECT 
                  COUNT(*) 
               FROM Bad_Phrase 
               WHERE 
                  [SourceTable].Name like '%'+Bad_Phrase.PHRASE+'%'
             )

用英语来说,此查询计算的是Bad_Phrase中列出的,是字段Name中的子字符串的不同短语的数量,SourceTable然后将结果放入字段中Bad_Count

我想要一些有关如何使此查询运行得更快的建议。


3
因此,您要扫描表3K次,并可能在所有3K次中更新所有15MM行,您希望它很快吗?
亚伦·伯特兰

1
名称列的长度是多少?您是否可以发布脚本或SQL提琴,以生成测试数据并以我们任何人都可以使用的方式重现此非常慢的查询?也许我只是一个乐观主义者,但是我觉得我们可以做的远远超过10个小时。我确实同意其他评论者的观点,这是一个计算量很大的问题,但是我不明白为什么我们仍然不能以使其“相当快”为目标。
Geoff Patterson

3
马修,您考虑过全文索引吗?您可以使用CONTAINS之类的东西,但仍可以从该索引中受益。
swasheck

在这种情况下,我建议尝试基于行的逻辑(即,不是对15MM行进行1次更新,而是对SourceTable中的每一行进行15MM更新,或者更新一些相对较小的块)。总时间不会变快(即使在这种情况下有可能),但是这种方法可以使系统的其余部分继续工作而不会出现任何中断,让您可以控制事务日志的大小(例如每10k更新提交一次),中断随时更新,而不会丢失所有以前的更新...
a1ex07

2
@swasheck全文考虑是一个好主意(我相信它是2005年的新功能,因此可以在此处应用),但由于全文索引单词而不是全文索引,因此无法提供海报要求的功能任意子字符串。换句话说,全文在单词“ fantastic”中找不到“ ant”的匹配项。但是可能需要修改业务需求,以使全文适用。
Geoff Patterson 2015年

Answers:


21

尽管我同意其他评论者的意见,这是一个计算量很大的问题,但我认为通过调整所使用的SQL,还有很多改进的余地。为了说明这一点,我使用15MM名称和3K短语创建了一个伪数据集,运行了旧方法,并运行了新方法。

完整脚本可生成伪造的数据集并尝试新方法

TL; DR

在我的计算机和这个伪造的数据集上,原始方法需要大约4个小时才能运行。提议的新方法大约需要10分钟,这是一个很大的改进。以下是提议的方法的简短摘要:

  • 对于每个名称,请生成从每个字符偏移量开始的子字符串(并以最长的错误短语的长度为上限,以进行优化)
  • 在这些子字符串上创建聚簇索引
  • 对于每个不良词组,请对这些子字符串执行搜索以识别所有匹配项
  • 对于每个原始字符串,计算与该字符串的一个或多个子字符串匹配的不同坏短语的数量


原始方法:算法分析

从原始UPDATE语句的计划中,我们可以看到工作量与名称数(15MM)和短语数(3K)成线性比例。因此,如果我们将名称和短语的数量乘以10,则总运行时间将降低约100倍。

查询实际上也与的长度成正比name。虽然这在查询计划中有点隐藏,但它通过“执行次数”进入表假脱机。在实际计划中,我们可以看到,这不仅发生一次name,而且实际上在中每个字符偏移发生一次name。因此,这种方法的运行时复杂度为O(# names* # phrases* name length)。

在此处输入图片说明


新方法:代码

完整的pastebin中也提供了此代码,但为方便起见,我将其复制到此处。pastebin还具有完整的过程定义,其中包括@minId@maxId变量,您将在下面看到这些变量来定义当前批次的边界。

-- For each name, generate the string at each offset
DECLARE @maxBadPhraseLen INT = (SELECT MAX(LEN(phrase)) FROM Bad_Phrase)
SELECT s.id, sub.sub_name
INTO #SubNames
FROM (SELECT * FROM SourceTable WHERE id BETWEEN @minId AND @maxId) s
CROSS APPLY (
    -- Create a row for each substring of the name, starting at each character
    -- offset within that string.  For example, if the name is "abcd", this CROSS APPLY
    -- will generate 4 rows, with values ("abcd"), ("bcd"), ("cd"), and ("d"). In order
    -- for the name to be LIKE the bad phrase, the bad phrase must match the leading X
    -- characters (where X is the length of the bad phrase) of at least one of these
    -- substrings. This can be efficiently computed after indexing the substrings.
    -- As an optimization, we only store @maxBadPhraseLen characters rather than
    -- storing the full remainder of the name from each offset; all other characters are
    -- simply extra space that isn't needed to determine whether a bad phrase matches.
    SELECT TOP(LEN(s.name)) SUBSTRING(s.name, n.n, @maxBadPhraseLen) AS sub_name 
    FROM Numbers n
    ORDER BY n.n
) sub
-- Create an index so that bad phrases can be quickly compared for a match
CREATE CLUSTERED INDEX IX_SubNames ON #SubNames (sub_name)

-- For each name, compute the number of distinct bad phrases that match
-- By "match", we mean that the a substring starting from one or more 
-- character offsets of the overall name starts with the bad phrase
SELECT s.id, COUNT(DISTINCT b.phrase) AS bad_count
INTO #tempBadCounts
FROM dbo.Bad_Phrase b
JOIN #SubNames s
    ON s.sub_name LIKE b.phrase + '%'
GROUP BY s.id

-- Perform the actual update into a "bad_count_new" field
-- For validation, we'll compare bad_count_new with the originally computed bad_count
UPDATE s
SET s.bad_count_new = COALESCE(b.bad_count, 0)
FROM dbo.SourceTable s
LEFT JOIN #tempBadCounts b
    ON b.id = s.id
WHERE s.id BETWEEN @minId AND @maxId


新方法:查询计划

首先,我们从每个字符偏移开始生成子字符串

在此处输入图片说明

然后在这些子字符串上创建聚簇索引

在此处输入图片说明

现在,对于每个不良词组,我们都探究这些子字符串以识别任何匹配项。然后,我们计算与该字符串的一个或多个子字符串匹配的不同坏短语的数量。这确实是关键的一步。由于我们已为子字符串建立索引,因此我们不再需要检查不良短语和名称的完整叉积。该步骤进行实际计算,仅占实际运行时间的大约10%(其余是子字符串的预处理)。

在此处输入图片说明

最后,执行实际的更新语句,使用a LEFT OUTER JOIN来为没有发现错误短语的任何名称分配0计数。

在此处输入图片说明


新方法:算法分析

新方法可以分为两个阶段:预处理和匹配。让我们定义以下变量:

  • N =名称数
  • B =错误词组数
  • L =平均名称长度,以字符为单位

预处理阶段是O(N*L * LOG(N*L))为了创建N*L子字符串然后对其进行排序。

实际匹配是O(B * LOG(N*L))为了寻找每个不良短语的子字符串。

通过这种方式,我们创建了一种算法,该算法不会随不良短语的数量线性增加,而是随着我们扩展到3K短语甚至更多而获得关键性能。换句话说,只要我们从300个不良词组变为3K不良词组,原始实现大约需要10倍的时间。同样,如果要从3K错误短语转换为30K,则需要再花10倍的时间。但是,新的实现将亚线性扩展,并且当扩展到30K错误短语时,实际上只需不到2倍的时间来测量3K错误短语。


假设/警告

  • 我将整体工作分为中等大小的批次。对于这两种方法,这可能都是一个好主意,但是对于新方法而言,这尤其重要,这样SORT子串上的on对于每个批次都是独立的,并且很容易装入内存。您可以根据需要控制批量大小,但是在一批次中尝试所有15MM行并不是明智的。
  • 我使用的是SQL 2014,而不是SQL 2005,因为我无法访问SQL 2005计算机。我一直小心谨慎,不要使用SQL 2005中不提供的任何语法,但是我仍然可以从SQL 2012+中的tempdb惰性写入功能和SQL 2014中的并行SELECT INTO功能中受益。
  • 名称和词组的长度对于新方法都非常重要。我假设坏短语通常很短,因为这很可能与现实中的用例匹配。名称比坏短语长很多,但假定不是数千个字符。我认为这是一个合理的假设,较长的名称字符串也会减慢您的原始方法。
  • 改进的某些部分(但无所不能)是由于新方法比旧方法(运行单线程)可以更有效地利用并行性。我使用的是四核笔记本电脑,因此很高兴拥有可以使用这些核心的方法。


相关博客文章

Aaron Bertrand在他的博客文章中更详细地探讨了这种解决方案,一种获取领先的%wildcard索引的方法


6

让我们搁置一下Aaron Bertrand在评论中提出的明显问题:

因此,您要扫描表3K次,并可能在所有3K次中更新所有15MM行,您希望它很快吗?

您的子查询在两侧都使用通配符的事实极大地影响了可保留性。要引用该博客文章的报价:

这意味着SQL Server必须读取Product表中的每一行,检查名称中是否有“ nut”字样,然后返回结果。

将每个“坏词”和“产品” 换成“螺母”一词SourceTable,然后将其与Aaron的注释结合起来,您应该开始理解为什么要使用当前算法使其快速运行非常困难难以理解)。

我看到一些选择:

  1. 说服企业购买具有强大功能的巨型服务器,以至于无法克服横冲直撞的查询。(那不会发生,所以用手指交叉,其他选择会更好)
  2. 使用您现有的算法,一次接受痛苦,然后将其分散开。这将涉及计算插入时的坏词,这会降低插入速度,并且仅在输入/发现新的坏词时才更新整个表。
  3. 拥抱Geoff的答案。这是一个很棒的算法,比我想出的任何算法都要好得多。
  4. 做选项2,但用Geoff代替算法。

根据您的要求,我建议选择3或4。


0

首先,这只是一个奇怪的更新

Update [SourceTable]  
   Set [SourceTable].[Bad_Count] = [fix].[count]
  from [SourceTable] 
  join ( Select count(*) 
           from [Bad_Phrase]  
          where [SourceTable].Name like '%' + [Bad_Phrase].[PHRASE] + '%')

就像'%'+ [Bad_Phrase]。[PHRASE]杀死了您
无法使用索引

数据设计不是最佳的速度
吗?您可以将[Bad_Phrase]。[PHRASE]分解为单个词组/单词吗?
如果相同的短语/字比一个显得更可以更进入比,如果你想一次它有一个较高的计数
因此中行不良pharase数量会上升
。如果你能那么这将大大快很多

Update [SourceTable]  
   Set [SourceTable].[Bad_Count] = [fix].[count]
  from [SourceTable] 
  join ( select [PHRASE], count(*) as count 
           from [Bad_Phrase] 
          group by [PHRASE] 
       ) as [fix]
    on [fix].[PHRASE] = [SourceTable].[name]  
 where [SourceTable].[Bad_Count] <> [fix].[count]

不确定2005是否支持它,而是全文索引并使用包含


1
我不认为OP希望统计坏词表中的坏词实例,我认为他们希望计算源表中隐藏的坏词的数量。例如,原来的代码可能给计数2为“shitass”的名字,但你的代码将给予0的数
埃里克

1
@Erik“您可以将[Bad_Phrase]。[PHRASE]分解为一个词组吗?” 您真的不认为数据设计可以解决问题吗?如果目的是查找不良的东西,那么计数为一个或多个的“ eriK”就足够了。
狗仔队
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.