Java敏感词过滤实战:用AhoCorasickDoubleArrayTrie实现高效匹配(附完整代码)
在内容审核、聊天系统、搜索引擎等场景中,敏感词过滤是一个绕不开的技术需求。想象一下,你正在开发一个日活百万的社交应用,用户每秒产生数万条消息,每条消息都需要实时过滤敏感词。如果采用传统的暴力匹配或简单的Trie树,系统很可能在高并发下崩溃。这时候,你需要的是一个能在海量词库下依然保持高性能的解决方案——Aho-Corasick自动机结合双数组Trie(AhoCorasickDoubleArrayTrie)。
我第一次在项目中遇到敏感词过滤的性能瓶颈是在处理一个电商平台的评论系统时。当时我们用的是基于HashMap的简单匹配,当敏感词数量超过5000条后,CPU使用率直接飙升到80%以上。后来切换到AC自动机方案,性能提升了近20倍,而内存占用反而减少了60%。这种从“勉强能用”到“游刃有余”的转变,让我深刻理解了算法选择的重要性。
1. 为什么AC自动机是敏感词过滤的最佳选择?
在深入代码之前,我们先来理解为什么AC自动机(Aho-Corasick Automaton)在多模式匹配场景下如此高效。传统的字符串匹配算法如KMP虽然优秀,但只能处理单模式匹配。当需要同时匹配成千上万个模式串时,KMP需要为每个模式串单独扫描文本,时间复杂度为O(n×m),其中n是文本长度,m是模式串数量。
AC自动机的核心优势在于它只需要扫描一次文本,就能找出所有模式串的出现位置。其时间复杂度为O(n+z),其中n是文本长度,z是所有匹配的总数。这意味着无论你有100个还是10万个敏感词,匹配时间只与文本长度线性相关。
1.1 算法原理的精髓
AC自动机可以看作是在Trie树上增加了失败指针(failure links)的有限状态自动机。这个设计巧妙之处在于:
- Trie树结构:将所有敏感词构建成一棵前缀树,共享相同前缀的词汇只存储一次
- 失败指针:当匹配失败时,不是回到根节点重新开始,而是跳转到另一个可能匹配的状态
- 输出函数:每个节点记录以该节点结尾的所有模式串
让我用一个简单的例子说明。假设我们有敏感词集合:{"he", "she", "his", "hers"},构建的AC自动机如下图所示(简化表示):
状态转移表(部分):
状态 | 输入h | 输入e | 输入r | 输入s | 输入i
-----|------|------|------|------|------
0 | 1 | 0 | 0 | 3 | 0
1 | 1 | 2 | 0 | 3 | 8
2 | 1 | 0 | 5 | 3 | 0
3 | 4 | 0 | 0 | 3 | 0
4 | 1 | 2 | 0 | 3 | 8
5 | 1 | 0 | 0 | 6 | 0
6 | 4 | 0 | 0 | 3 | 0
7 | 1 | 0 | 0 | 3 | 8
8 | 1 | 0 | 0 | 9 | 0
9 | 4 | 0 | 0 | 3 | 0
当扫描文本"ushers"时:
- 从状态0开始,输入'u'无转移,保持状态0
- 输入's'转移到状态3
- 输入'h'转移到状态4
- 输入'e'转移到状态2,发现匹配"he"和"she"
- 输入'r'转移到状态5
- 输入's'转移到状态6,发现匹配"hers"
整个过程只扫描文本一次,就找到了所有匹配。
1.2 双数组Trie的威力
标准的AC自动机实现通常使用链表或HashMap来存储状态转移,这在内存使用和缓存友好性上都不是最优的。双数组Trie(Double-Array Trie)通过两个数组base和check来压缩存储状态转移,实现了:
- 极低的内存占用:相比传统Trie减少80%-90%内存
- 快速的查找速度:O(1)时间复杂度的状态转移
- 缓存友好:连续数组访问,充分利用CPU缓存
下面是传统Trie节点与双数组Trie的内存对比:
| 存储方式 | 每个节点内存 | 10万节点总内存 | 查找复杂度 |
|---|---|---|---|
| HashMap | ~48字节 | ~4.8MB | O(1)平均 |
| 链表 | ~32字节 | ~3.2MB | O(k) |
| 双数组 | 8字节 | ~0.8MB | O(1) |
注意:这里的双数组指每个状态需要两个int(base和check),在64位JVM中每个int占4字节,所以每个状态8字节。
2. 实战:构建高性能敏感词过滤系统
现在让我们进入实战环节。我将带你一步步构建一个完整的敏感词过滤系统,从基础实现到生产级优化。
2.1 环境准备与依赖引入
首先,我们需要引入AhoCorasickDoubleArrayTrie库。这里推荐hankcs的实现,它经过了多年优化,性能非常出色。
<dependency>
<groupId>com.hankcs</groupId>
<artifactId>aho-corasick-double-array-trie</artifactId>
<version>1.2.3</version>
</dependency>
如果你不能使用Maven,也可以直接下载jar包,或者从源码构建:
git clone https://github.com/hankcs/AhoCorasickDoubleArrayTrie.git
cd AhoCorasickDoubleArrayTrie
mvn clean package
2.2 基础使用:从简单示例开始
让我们从一个最简单的例子开始,了解API的基本用法:
import com.hankcs.algorithm.AhoCorasickDoubleArrayTrie;
import java.util.*;
public class BasicExample {
public static void main(String[] args) {
// 1. 准备敏感词字典
TreeMap<String, String> sensitiveWords = new TreeMap<>();
sensitiveWords.put("暴力", "violent");
sensitiveWords.put("敏感词", "sensitive");
sensitiveWords.put("测试", "test");
sensitiveWords.put("违法", "illegal");
// 2. 构建AC自动机
AhoCorasickDoubleArrayTrie<String> acdat =
new AhoCorasickDoubleArrayTrie<>();
acdat.build(sensitiveWords);
// 3. 测试文本
String text = "这是一段包含暴力和敏感词的测试文本,请注意内容安全。";
// 4. 执行匹配
List<AhoCorasickDoubleArrayTrie.Hit<String>> hits =
acdat.parseText(text);
// 5. 输出结果
System.out.println("在文本中发现 " + hits.size() + " 个敏感词:");
for (AhoCorasickDoubleArrayTrie.Hit<String> hit : hits) {
System.out.printf("位置[%d:%d] = %s (原词: %s)%n",
hit.begin, hit.end, hit.value,
text.substring(hit.begin, hit.end));
}
}
}
运行这个程序,你会看到输出:
在文本中发现 3 个敏感词:
位置[7:9] = violent (原词: 暴力)
位置[10:13] = sensitive (原词: 敏感词)
位置[14:16] = test (原词: 测试)
2.3 生产级敏感词过滤器实现
基础示例虽然简单,但离生产环境还有距离。下面是一个完整的、可配置的敏感词过滤器:
import com.hankcs.algorithm.AhoCorasickDoubleArrayTrie;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* 生产级敏感词过滤器
* 支持热更新、多级过滤、性能监控
*/
public class SensitiveWordFilter {
// 双数组Trie核心
private volatile AhoCorasickDoubleArrayTrie<FilterRule> acdat;
// 敏感词规则映射
private final Map<String, FilterRule> ruleMap = new ConcurrentHashMap<>();
// 读写锁,支持并发安全的热更新
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 性能统计
private final FilterStats stats = new FilterStats();
/**
* 过滤规则类
*/
public static class FilterRule {
private final String word; // 原始敏感词
private final int level; // 敏感级别:1-警告,2-替换,3-拦截
private final String replacement; // 替换文本
private final Set<String> synonyms; // 同义词
public FilterRule(String word, int level, String replacement) {
this.word = word;
this.level = level;
this.replacement = replacement;
this.synonyms = new HashSet<>();
}
// 添加同义词
public FilterRule addSynonym(String synonym) {
this.synonyms.add(synonym);
return this;
}
// Getters省略...
}
/**
* 初始化过滤器
* @param rules 初始过滤规则
*/
public void init(Collection<FilterRule> rules) {
lock.writeLock().lock();
try {
ruleMap.clear();
TreeMap<String, FilterRule> map = new TreeMap<>();
for (FilterRule rule : rules) {
// 主词条
ruleMap.put(rule.getWord(), rule);
map.put(rule.getWord(), rule);
// 同义词
for (String synonym : rule.getSynonyms()) {
ruleMap.put(synonym, rule);
map.put(synonym, rule);
}
}
rebuildTrie(map);
stats.reset();
} finally {
lock.writeLock().unlock();
}
}
/**
* 热更新:添加新规则
*/
public void addRule(FilterRule rule) {
lock.writeLock().lock();
try {
ruleMap.put(rule.getWord(), rule);
// 重建Trie(实际生产中可以优化为增量更新)
TreeMap<String, FilterRule> map = new TreeMap<>(ruleMap);
rebuildTrie(map);
} finally {
lock.writeLock().unlock();
}
}
/**
* 执行过滤
* @param text 待过滤文本
* @return 过滤结果
*/
public FilterResult filter(String text) {
if (text == null || text.isEmpty()) {
return FilterResult.pass(text);
}
lock.readLock().lock();
try {
long startTime = System.nanoTime();
FilterResult result = doFilter(text);
long cost = System.nanoTime() - startTime;
stats.record(cost, result.getHitCount());
return result;
} finally {
lock.readLock().unlock();
}
}
private FilterResult doFilter(String text) {
List<AhoCorasickDoubleArrayTrie.Hit<FilterRule>> hits =
acdat.parseText(text);
if (hits.isEmpty()) {
return FilterResult.pass(text);
}
// 按起始位置排序
hits.sort(Comparator.comparingInt(h -> h.begin));
StringBuilder filtered = new StringBuilder();
int lastEnd = 0;
int hitCount = 0;
List<FilterHit> filterHits = new ArrayList<>();
for (AhoCorasickDoubleArrayTrie.Hit<FilterRule> hit : hits) {
// 处理重叠匹配(取最长的)
if (hit.begin < lastEnd) {
continue;
}
FilterRule rule = hit.value;
hitCount++;
// 记录命中信息
filterHits.add(new FilterHit(
hit.begin, hit.end,
text.substring(hit.begin, hit.end),
rule.getLevel()
));
// 添加非敏感部分
filtered.append(text, lastEnd, hit.begin);
// 根据级别处理
switch (rule.getLevel()) {
case 1: // 警告级别,用*替换部分字符
filtered.append(maskWord(
text.substring(hit.begin, hit.end)));
break;
case 2: // 替换为指定文本
filtered.append(rule.getReplacement());
break;
case 3: // 拦截,直接返回
return FilterResult.block(text, filterHits);
default:
filtered.append("***");
}
lastEnd = hit.end;
}
// 添加剩余部分
if (lastEnd < text.length()) {
filtered.append(text.substring(lastEnd));
}
return FilterResult.filtered(

862

被折叠的 条评论
为什么被折叠?



