尽管我同意其他评论者的意见,这是一个计算量很大的问题,但我认为通过调整所使用的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索引的方法。