最近,我的一位同事提出了一个请求:如何使用 Redis 创建内容过滤器?他希望根据不良词语列表过滤传入的消息。这是一个很常见的用例——任何接受用户输入的应用程序可能都需要至少进行一次初步扫描,以查找不适当的词语。我最初的想法是使用布隆过滤器,但我想知道是否有更好的选择,所以我想要测试我的假设。我从 这里 获取了一个不良词语列表,并对詹姆斯·乔伊斯的《尤利西斯》(一本公共领域的巨著,包含大量有趣的语言!)进行了初步测试。
对于那些不熟悉的人来说,布隆过滤器是一种概率(p11c)数据结构,可以测试存在性。这种方法乍一看可能令人望而生畏,但实际上非常简单:您将项目添加到布隆过滤器中,然后您检查布隆过滤器中是否存在项目。布隆过滤器是概率性的,因为它们不能确定地回答存在性——它们要么告诉你一个项目肯定不在过滤器中,要么可能在过滤器中。**换句话说,使用布隆过滤器,可能会出现假阳性,但不会出现假阴性。**布隆过滤器真正令人惊奇的是,它们只使用一小部分空间(与实际存储项目相比),并且可以使用适度的计算复杂度来确定可能的出现。我的妻子是一名文学教授,她对选择《尤利西斯》进行这个测试感到非常有趣,提醒我,这本书的主人公叫布鲁姆。很巧合。
回到手头的难题。您要做的就是获取给定消息中的所有单词,并找到它们与已知不良词语列表的交集。然后,您识别出有问题词语,并拒绝它们或对其进行人工筛选。
为了尽可能简化使用并避免任何网络瓶颈,我在 Lua 中实现了所有解决方案,并在 Redis 内部运行它们。我使用了一个 Lua 表,其中键为不适当的单词,值为“true”,以防止在所有脚本中发出不必要的 Redis 命令。
关于消息处理的一点说明:在脚本中,我使用了一种非常简单的方法,只是按空格分割。诚然,仅此一项在实际应用中无法奏效,因为您需要处理标点符号、数字等。在这种情况下,它运行良好,因为我们对所有脚本的处理方式相同,并且没有试图衡量 Lua 字符串分割的速度。
除了布隆过滤器之外,还可以使用 Redis 集合来解决这个问题。我做的第一件事是将不良词语添加到 SET 键中,使用 SADD 命令和 1600 多个参数来执行该命令。我获取了不良词语列表,将每个单词用双引号括起来,然后添加 SADD 和键的前缀,最后将所有内容保存到一个文本文件(badwords-set.txt)中。我使用以下 redis-cli 管道技巧来执行它
$ cat ./badwords-set.txt | redis-cli -a YourRedisPassword
一种简单的方法是将消息分解为单词,并对消息中的每个单词运行 SISMEMBER 以识别这些单词。我最初的想法是,大量命令会使这种方法效率低下。让我们看看它的实现方式
local words = {}
for i in string.gmatch(ARGV[1], "%S+") do
words[string.lower(i)] = true
end
local badwords = {}
for word,v in pairs(words) do
if redis.call('SISMEMBER', KEYS[1], word) == 1 then
table.insert(badwords,word)
end
end
return badwords
我们在这里做的只是将第一个参数(在本例中为消息)分解,然后遍历这些单词,并对每个单词运行 SISMEMBER。如果 SISMEMBER 返回 1,那么我们就知道这是一个不良词语,并将它添加到一个表中,然后将该表返回到 Redis。
之前,我谈到了不良词语与消息词语之间的交集。让我们用 SADD 和 SINTER 来真正实现这一点。同样,这看起来像是大量的命令,但下面是 Lua 中的示例
local words = {}
for i in string.gmatch(ARGV[1], "%S+") do
words[string.lower(i)] = true
end
for word,v in pairs(words) do
redis.call('SADD',KEYS[1],word)
end
local badwords = redis.call('SINTER',KEYS[1],KEYS[2])
redis.call('UNLINK',KEYS[1])
return badwords
这与之前的分割例程相同,但不是对每个单词发出 SISMEMBER,而是使用 SADD 将它添加到一个临时的 Redis 集合中。然后,我使用 SINTER 命令将这个临时集合与我们的不良词语集合进行交集。最后,我使用 UNLINK 删除了临时集合,并返回了 SINTER 的结果。使用这种方法,请记住您正在写入 Redis。这会对持久性和分片产生一些影响,因此 Lua 脚本的相对简单性掩盖了一些操作复杂性。
对于我的第二种方法,我将所有不良词语添加到布隆过滤器中。与 SADD 一样,您可以使用 BF.MADD 命令一次将多个项目添加到布隆过滤器中。事实上,我获取了包含所有不良词语和 SADD 命令的文本文件,并将命令(现在为 BF.MADD)和键替换掉。
对于这种解决方案,请务必记住,布隆过滤器是概率性的——它们可能会返回假阳性。根据您的用例,这可能需要对结果进行二次检查,或者只是将其用作手动过程的筛选器。
首先,让我们看一下用于检查不良词语的 Lua 脚本
local words = {}
for i in string.gmatch(ARGV[1], "%S+") do
words[string.lower(i)] = true
end
local badwords = {}
for word,v in pairs(words) do
if redis.call('BF.EXISTS', KEYS[1], word) == 1 then
table.insert(badwords,word)
end
end
return badwords
与 SISMEMBER 方法类似,我们可以将传入的字符串分解为唯一的单词,然后对每个单词运行 BF.EXISTS。如果一个单词可能存在于过滤器中,BF.EXISTS 将返回 1;如果它肯定不存在,则返回 0。这种解决方案将消息中的每个单词与过滤器进行比较,将任何不良词语插入到一个表中,并在最后将其返回。
我们还可以使用 ReBloom 模块,它提供了 BF.MEXISTS 命令来检查多个字符串与过滤器是否匹配。它的功能与 BF.EXISTS 相同,但允许您一次执行更多操作。但是,这里事情变得有点棘手。即使从 Lua 运行多个命令,Redis 也需要花费一些开销,因此理想情况下,您希望运行尽可能少的命令,让 Redis 实际检查数据,而不是花费资源来解释数千个命令。最好通过最大化参数数量(至少在理论上)来最小化命令调用。
我的第一次尝试是将《尤利西斯》的所有唯一单词(45,834 个)塞进一个 BF.MEXISTS 命令中,使用 unpack 函数。这行不通,因为 Lua 的固定堆栈大小远远小于我的 45k 参数。这迫使我将 BF.MEXIST 分割成更小的单词块。反复试验帮助我发现,在 Lua 报错之前,我可以安全地将大约 7,000 个参数添加到 BF.MEXISTS 命令中。这以 Lua 脚本复杂性为代价,但意味着我可以运行 66 个命令,而不是 45k 个以上。
以下是脚本的最终样子
local words = {}
for i in string.gmatch(ARGV[1], "%S+") do
words[string.lower(i)] = true
end
local function bloomcheck(key,wordstocheck,dest)
for k, v in ipairs(redis.call('BF.MEXISTS',key,unpack(wordstocheck))) do
if v == 1 then
table.insert(dest,wordstocheck[k])
end
end
end
local possiblebad = {}
local temp = {}
local i = 0;
for word,v in pairs(words) do
table.insert(temp,word)
i = i+1;
if i == 7000 then
bloomcheck(KEYS[1],temp,possiblebad)
temp = {}
i = 0;
end
end
bloomcheck(KEYS[1],temp,possiblebad)
return possiblebad
如您所见,这开始成为大量的 Lua 代码。它遍历所有单词以获取 7,000 个单词的块,然后运行 bloomcheck 函数,该函数检查过滤器中的块值,并将这些不良词语添加到 temp 表中。最后,它返回所有可能的不良词语。
从 CLI 运行 Lua 脚本并不好玩,因为您需要处理大量参数,而且事情可能会变得很麻烦。因此,我使用 benchmark.js 框架编写了一个小的 node.js 脚本,以多次运行我的测试,并获取每次运行的适当统计信息。除了《尤利西斯》之外,我还对 1,000 个和 500 个单词的随机 lorem ipsum 文本集进行了测试,以了解更短的消息。所有这些测试都在我的开发笔记本电脑上运行,因此除了相对差异之外,它们几乎没有意义。
让我们看一下结果
平均值 | 标准差 | 误差幅度 | |
---|---|---|---|
SINTER(第 4 位) | 0.252477065875 秒 (252.478 毫秒) | 0.06331450637410971 | 0.026739796333907027 |
SISMEMBER(第 1 位) | 0.17936577490625 秒(179.356 毫秒) | 0.034054703041132506 | 0.011799352611322679 |
BF.MEXISTS(第 2 位) | 0.19251170375862064(192.512 毫秒) | 0.05158925843435185 | 0.01961960405252156 |
BF.EXISTS(第 3 位) | 0.21505565530769238(215.056 毫秒) | 0.041931428091619434 | 0.016940265013395354 |
平均值 | 标准差 | 误差幅度 | |
---|---|---|---|
SINTER(第 2 位) | 0.0005533430385665705(0.55 毫秒) | 0.00004782064272951418 | 0.00001047916037135063 |
SISMEMBER(第 1 位) | 0.0004876821613932592(0.49 毫秒) | 0.00007453275890098545 | 0.000018260525930741436 |
BF.MEXISTS(第 3 位) | 0.0005750750453153705(0.58 毫秒) | 0.00008423403428371551 | 0.000019593611749182812 |
BF.EXISTS(第 4 位) | 0.0006705186154166669(0.67 毫秒) | 0.000035960357334688716 | 0.000008810287546998735 |
平均值 | 标准差 | 误差幅度 | |
---|---|---|---|
SINTER(第 4 位) | 0.0007199102990995225(0.72 毫秒) | 0.00026621924403751274 | 0.00006281610037055691 |
SISMEMBER(第 1 位) | 0.0005127359608872813(0.51 毫秒) | 0.000042812670318675034 | 0.000010328955827771373 |
BF.MEXISTS(第 2 位) | 0.0005467939453535285(0.55 毫秒) | 0.00004135890147573503 | 0.00000948775881996386 |
BF.EXISTS(第 3 位) | 0.0007052701613834288(0.71 毫秒) | 0.00013297426439345952 | 0.000032836237873305535 |
我认为这些结果非常有趣。我的最初假设在这种情况下是错误的——布隆过滤器并不是最快的。在所有测试中,普通的 SISMEMBER 都运行得更快。除了这些之外,我还有一些重要的发现,包括
那么为什么要使用布隆过滤器呢?存储效率。让我们看最后一件事情
127.0.0.1:6379> MEMORY USAGE badwordsset
(integer) 68409
127.0.0.1:6379> MEMORY USAGE badwords
(integer) 4228
这里,“badwords” 是布隆过滤器,而 “badwordset” 是集合结构。布隆过滤器的大小是集合的 16 倍以上。在本例中,大小并不重要,因为它是一个单一列表。但是,想象一下,如果网站上的每个用户都有自己的脏话过滤器?使用集合结构会真的累积起来,但使用布隆过滤器会更容易适应。而且测试表明,响应时间非常相似。