点 Redis 8 发布了——它是开源的

了解更多

如何使用 Redis 进行内容过滤

最近,我的一个同事提出了一个请求:如何在 Redis 中创建一个内容过滤器?他希望根据一个不良词汇列表来过滤传入的消息。这是一个非常常见的用例——任何接受用户输入的应用程序可能都希望至少进行粗略的扫描,以查找不当词汇。我的第一个想法是他应该使用布隆过滤器 (Bloom filter),但我想知道是否有更好的选择,所以我决定验证我的假设。我从这里获取了一个不良词汇列表,并使用詹姆斯·乔伊斯 (James Joyce) 的小说《尤利西斯》(Ulysses) 的文本进行了一些初步测试(这是一本公共领域的大部头著作,包含许多有趣的语言!)。

对于不熟悉的人来说,布隆过滤器 (Bloom filter) 是一种概率性数据结构,可以测试元素是否存在。这种方法乍一看可能令人畏惧,但实际上非常简单:你向布隆过滤器添加元素,然后检查元素在布隆过滤器中是否存在。布隆过滤器的概率性体现在它们不能确定地回答是否存在——它们要么告诉你某个元素肯定不在过滤器中,要么告诉你它可能在过滤器中。换句话说,使用布隆过滤器可能出现误报 (false positives),但不会出现漏报 (false negatives)。布隆过滤器真正酷的地方在于它们占用的空间只是实际存储元素所需空间的一小部分,并且可以用适度的计算复杂度确定元素的可能存在性。我的妻子是一位文学教授,她得知我选择《尤利西斯》进行这项测试时感到非常有趣,并提醒我书中的主人公就叫布鲁姆 (Bloom)。真是巧合。

回到手头的问题。你需要做的是取出给定消息中的所有词语,并找到它们与已知不良词汇列表的交集。然后识别出有问题词语,并进行拒绝或人工筛选。

为了尽可能易于使用并避免任何网络瓶颈,我用 Lua 实现了所有解决方案,并在 Redis 内部运行它们。我使用了一个 Lua 表,其中键为不当词语,值为“true”,以防止在所有脚本中发出不必要的 Redis 命令。

关于消息处理的注意事项:在脚本中,我使用了一种非常简单的方法,仅按空格进行分割。诚然,这在实践中单独使用是不可行的,因为你需要处理标点符号、数字等。在这种情况下,由于我们对所有脚本都采用了相同的方法,并且没有试图测量 Lua 字符串分割的速度,所以这样做没问题。

使用 Set 进行内容过滤

除了布隆过滤器之外,解决这个问题的另一种方法是使用 Redis Set。我做的第一件事是使用 SADD 命令将不良词汇添加到 SET 键中,该命令有超过 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 Set 中。然后,我使用 SINTER 命令将这个临时 Set 与我们的不良词汇 Set 取交集。最后,我使用 UNLINK 命令删除了临时 Set,并返回了 SINTER 的结果。使用这种方法时,请记住您正在写入 Redis。这会涉及到持久化和分片的一些问题,因此 Lua 脚本的相对简单性隐藏了一些操作上的复杂性。

使用布隆过滤器进行内容过滤

对于我的第二种方法,我将所有不良词汇添加到了一个布隆过滤器中。与 SADD 类似,您可以使用 BF.MADD 命令一次向布隆过滤器添加多个元素。实际上,我拿来了包含所有不良词汇和 SADD 命令的文本文件,然后只替换了命令(现在是 BF.MADD)和键名。

使用这种解决方案,重要的是要记住布隆过滤器是概率性的——它们可能返回误报 (false positives)。根据您的用例,这可能需要对结果进行二次检查,或者仅仅将它们用作人工流程的筛选工具。

首先,让我们看一个 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个),然后使用 unpack 函数将它们塞进一个 BF.MEXISTS 命令中。但这没有奏效,因为 Lua 的固定堆栈大小远小于我的 45k 个参数。这迫使我将 BF.MEXIST 分割成更小的词语块。通过反复试验,我发现我可以安全地向 BF.MEXISTS 命令添加大约 7,000 个参数而不会让 Lua 报错。这增加了 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 函数,该函数检查一组值是否在过滤器中,并将这些不良词语添加到临时表中。最后,它返回所有可能的不良词语。

测试

从 CLI 运行 Lua 脚本并不有趣,因为您需要处理大量参数,而且事情可能会变得棘手。因此,我编写了一个小型 node.js 脚本,使用 benchmark.js 框架来多次运行我的测试,并获得每次运行的准确统计数据。除了《尤利西斯》,我还在包含 1,000 和 500 个 lorem ipsum 随机词语的文本上运行了测试,以便了解较短消息的情况。所有这些测试都在我的开发笔记本电脑上运行,因此它们除了相对差异之外,只有很小的意义。

让我们看看结果

Ulyses Bad Word Detection
 平均值标准差误差范围
SINTER(第 4 名)0.252477065875

(252.478毫秒)
0.063314506374109710.026739796333907027
SISMEMBER(第 1 名)0.17936577490625 秒 (179.356毫秒)0.0340547030411325060.011799352611322679
BF.MEXISTS(第 2 名)0.19251170375862064 (192.512毫秒)0.051589258434351850.01961960405252156
BF.EXISTS(第 3 名)0.21505565530769238 (215.056毫秒)0.0419314280916194340.016940265013395354
1000 words of Lorem Ipsom Bad Word Detection
 平均值标准差误差范围
SINTER(第 2 名)0.0005533430385665705 (0.55毫秒)0.000047820642729514180.00001047916037135063
SISMEMBER(第 1 名)0.0004876821613932592 (0.49毫秒)0.000074532758900985450.000018260525930741436
BF.MEXISTS(第 3 名)0.0005750750453153705 (0.58毫秒)0.000084234034283715510.000019593611749182812
BF.EXISTS(第 4 名)0.0006705186154166669 (0.67毫秒)0.0000359603573346887160.000008810287546998735
500 words of Lorem Ipsom Bad Word Detection
 平均值标准差误差范围
SINTER(第 4 名)0.0007199102990995225 (0.72毫秒)0.000266219244037512740.00006281610037055691
SISMEMBER(第 1 名)0.0005127359608872813 (0.51毫秒)0.0000428126703186750340.000010328955827771373
BF.MEXISTS(第 2 名)0.0005467939453535285 (0.55毫秒)0.000041358901475735030.00000948775881996386
BF.EXISTS(第 3 名)0.0007052701613834288 (0.71毫秒)0.000132974264393459520.000032836237873305535

结论

我认为这些结果非常有趣。我的最初假设在这种情况下是错误的——布隆过滤器并不是最快的。在所有测试中,普通的 SISMEMBER 都运行得更快。除此之外,我的一些重要心得包括

  • 《尤利西斯》对于这类事情来说是一个不寻常的工作负载,您不太可能会有很多詹姆斯·乔伊斯提交史诗般的小说
  • 所有 1000 字和 500 字的测试都产生了亚毫秒级的响应,将它们归入“即时”范围。响应时间差异仅在毫秒的零头,因此差异是无法察觉的。
  • 如果 SISMEMBER 具有接受可变参数的能力(它没有),那么在这种情况下可能会更快。
  • 一些 500 字测试的平均值比 1000 字测试的平均值长。我怀疑在这些相对较小的工作负载上花费的大部分时间是由于开销。

那么为什么还要使用布隆过滤器呢?存储效率。让我们看看最后一点

127.0.0.1:6379> MEMORY USAGE badwordsset
(整数) 68409
127.0.0.1:6379> MEMORY USAGE badwords
(整数) 4228

在这里,“badwords”是布隆过滤器,而“badwordset”是 Set 结构。布隆过滤器比 Set 小 16 倍以上。在这种情况下,大小并不是那么重要,因为它只是一个列表。但是,想象一下,如果网站上的每个用户都有自己的不良词语过滤器呢?使用 Set 结构会大大增加存储,但使用布隆过滤器会更容易应对。而且这项测试表明,响应时间非常相似。