news 2026/10/7 3:40:22

基于DFA的Java敏感词筛选系统:从Trie树到高性能内容审核

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于DFA的Java敏感词筛选系统:从Trie树到高性能内容审核

简介:Java敏感词筛选系统源码包是一份面向Java开发者的文本过滤实战项目,适合正在学习字符串匹配、数据结构与并发编程的初中级工程师参考。项目围绕敏感词库构建、高效匹配算法、分词处理等核心模块展开,覆盖Aho-Corasick、Trie树、KMP、正则表达式等常见技术点,可帮助读者理解并复用在社区、论坛等内容安全场景下的敏感词过滤方案。资源包共1454个文件,大小16.01MB,主体为306个Java源码文件,同时包含458个JavaScript、235个HTML与92个CSS,推测带有管理界面与前端展示;另有29个XML、14个VM、5个YAML等配置文件,以及若干图片与字体资源,可支撑项目运行与二次开发。目前已有267人学习下载,包内目录结构清晰,便于按功能模块检索。通过研读源码,可掌握完整敏感词系统的工程实现思路,包括词库动态更新、AC自动机构建、多线程批量文本处理、异常处理与JUnit测试等;结合前端页面可快速调试接口并验证过滤效果,适合作为毕业设计、课程项目或企业内部分享的参考素材。

1. 先说结论:这套 Java 敏感词筛选系统源码,好用的不是词库,是 DFA

做过内容审核的同学都有这种体验:线上评论区被广告和垃圾内容刷屏,运营拿着截图来找你,你打开代码一看,用的还是String.contains()一段一段地判断,逻辑写得跟连环画一样。敏感词筛选这事儿,最容易翻车的不是词库不够全,而是算法选错了。这套Java敏感词筛选系统源码.zip拿到手,核心是一个基于 DFA(确定性有限自动机)的敏感词筛选器,配合词库加载、替换、命中检测一套完整流程。它解决的是「词库量大、文本量高的时候,匹配还能不能快」这个真问题,不是新手练手的玩具代码。适合正在做评论系统、聊天室、内容审核后端的 Java 开发,也适合面试前拿来做项目经验的应届生。

2. 为什么是 DFA 而不是正则和 contains:先把匹配原理吃透再用源码

2.1 三连问:contains 为什么慢,正则为什么更靠不住

很多人一上来就写if (text.contains("词A") || text.contains("词B")),词库只有几十个词的时候,这段代码能跑,词库一上规模就彻底露馅。contains每次调用都是从文本头部开始逐字符找子串,时间复杂度是 O(n * m),n 是文本长度,m 是词条长度。如果词库有 5000 个词、每条用户评论 200 个字符,最坏情况下一次检查要做5000 * 200 * 平均词长次字符比较,一分钟几百条评论就扛不住了。

正则更坑。Pattern.matches()看起来挺高级,实际上正则引擎在匹配分叉路径时有回溯机制,遇到.*或者|并列这种模式,在长文本里可能指数级膨胀。我有一次在生产环境看到Pattern.compile("(赌|毒|枪|爆|代|办){2}")这种写法,线上 CPU 直接被打满,查了半天才发现是某条 300 字的评论触发了回溯爆炸。更重要的是,contains和正则都是「每个词单独跑一遍文本」,词与词之间没有任何复用,等于同一篇文本被反复扫了几千次。

2.2 DFA 的核心思路:把词库压缩成一张状态转移图

这套源码里用的是 DFA 的思想:把所有敏感词构建成一棵 Trie 树(前缀树),扫描文本时只走一遍,每个字符最多查一次哈希表,整体匹配复杂度是 O(n) 级别。举个例子,词库里有两个词「AD」和「广告」,构建出来的树长这样:

根节点 -> A -> D(结束) 根节点 -> 广 -> 告(结束)

匹配文本「这里有一条广告信息」时,从头开始取字符,第一个字符「这」在根节点下找不到,跳过;「里」也找不到;「有」找不到;「一」找不到;取到「广」时发现根节点下有这个分支,走进去;取「告」,发现当前节点的子节点里有,且标记了结束状态。此时就知道「广告」命中了,整个扫描只遍历了文本一遍。

这套源码里树的实现没有用标准 Trie 库,而是用了HashMap<Character, Object>嵌套的方式,每一层节点就是一个 HashMap,键是字符,值要么是下一层节点,要么是结束标记。这种实现的直接好处是不需要引入第三方依赖,坏处是内存用得比专门的数据结构多一点,但词库在一两万个词这个量级完全没问题。

匹配流程拆开看是这样的:

public boolean check(String text) { // 从根节点开始,curNode 代表当前所在层的 HashMap // 遍历文本的每一个字符,逐个走下状态转移 // 一旦碰到词条结束标记,立刻返回 true }

这段逻辑的关键在于:扫描指针不用回退到起点重新找,词库的公共前缀被共享了。比如「广告」和「广告位」这两个词共享「广告」前缀,匹配完「广告」之后,如果下一个字符正好是「位」,直接顺着树往下走就命中第二个词,不需要重新匹配一遍「广告」。

2.3 最小匹配与最大匹配:同一个词库,两种结果

这套源码里有一个选项决定了敏感词怎么被命中:命中「最小词条」还是「最大词条」。最小匹配的逻辑是:只要当前节点标记了结束,立刻返回命中,不再往下走。最大匹配是:即使当前节点能结束,也要继续往下走完整个分支,找到最长的那个词条。两者在实战中的差异很明显。

举个例子,词库里有「广告」和「广告位」两个词,输入文本是「这个广告位出租」。最小匹配的结果是命中「广告」,替换后变成「这个位出租」;最大匹配的结果是命中「广告位」,替换后变成「这个出租」、这两个结果放到线上是完全不同的体验——最小匹配更激进,漏放的风险小,但容易把「广告联盟」这种相对中性的词也干掉;最大匹配更贴近语义,但因为要往下试探更深的分支,碰到词库里有超长词条时耗时略高。

参数说明:源码里控制这个行为的变量是一开始构建树时设置的模式开关,调一次全局生效。小词库选最小匹配,大词库或内容偏严肃的平台建议最大匹配。

3. 源码工程拆解:从 zip 解压到跑通第一个 Demo

3.1 解压后的工程结构:每个文件是干嘛的

把 zip 解开之后,目录结构不复杂,标准的 Maven 工程布局,核心代码集中在src/main/java下面。整套工程不用额外装什么中间件,JDK 1.8 以上直接能跑,这也是当初我选它的原因之一——不需要私有仓库,不需要配 redis,纯 JDK + HashMap 打通。

文件/目录作用
src/main/java/filter/core/DfaFilter.javaDFA 核心类,负责词库加载、Trie 构建、匹配检测
src/main/java/filter/core/WordFilter.java对外门面类,封装过滤、替换、命中词列表
src/main/java/filter/util/FileUtil.java词库文件读取,编码处理在这
src/main/resources/dict/sensitive_words.txt默认敏感词库,每行一个词
src/main/resources/application.properties匹配模式、替换符参数配置
src/test/java/filter/DfaFilterTest.javaJUnit 测试用例,跑通验证用的

先打开application.properties看一眼,里面两个参数是上线前必须要确认的:

# 匹配模式:0 最小匹配,1 最大匹配 filter.maxMatchMode=true # 替换敏感词的占位符 filter.replaceChar=*

maxMatchMode=false对应最小匹配,true对应最大匹配。replaceChar是脱敏后用来顶替敏感词的字符,默认星号,实际项目里有人改成「□」或者「***」,看业务方要求。

3.2 核心类 DfaFilter 的构建流程:一树一 Map 一方法

DfaFilter是整套源码的中枢,内部结构可以概括成「一个根节点、一个加载方法、一个扫描方法」。初始化逻辑包含在带参构造器里,类初始化时把词库文件路径传进来,构造器内部完成文件读取、文本清洗、Trie 构建三个动作。

public class DfaFilter { /** 根节点,每一层子节点都是一个 HashMap */ private Map<Character, Object> root; public DfaFilter(String dictPath, boolean maxMatchMode) { this.maxMatchMode = maxMatchMode; this.root = new HashMap<>(); List<String> words = FileUtil.readLines(dictPath); for (String word : words) { addWord(word.trim().toLowerCase()); } } private void addWord(String word) { Map<Character, Object> cur = root; for (char c : word.toCharArray()) { Object node = cur.get(c); if (node == null) { // 当前层级里没有这个字符,新建一层 Map<Character, Object> child = new HashMap<>(); child.put("isEnd", false); cur.put(c, child); cur = child; } else { // 已有该字符分支,顺着往下走 cur = (Map<Character, Object>) node; } } // 最后一个字符对应的节点标记为词条结尾 cur.put("isEnd", true); } }

这里有个代码层面值得注意的地方:每一层节点 HashMap 里都用字符串键"isEnd"来存结束标记,而不是单独用一个布尔变量。这样做的目的是让「结束标记」也变成节点 Map 的一个普通键值,扫描时统一走cur.get(key),不用额外判断数据结构类型。代价是 HashMap 里混入了字符串键和 Character 键,强转的时候容易踩坑,后面在避坑章节里会细说。

3.3 词库加载与编码处理:不处理 BOM 就等着翻车

FileUtil.readLines()这个工具方法看着简单,实际上线的坑全藏在里面。textarea 记事本在 Windows 上保存的 txt 大概率是 GBK 编码,而源码里默认用 UTF-8 读,读出来直接乱码。另外还有一个更隐蔽的:UTF-8 文件如果带 BOM(字节序标记),FileReader读出来的第一行字符串开头会带着一个不可见的\uFEFF字符,导致词库第一个词条永远匹配不上。

项目里对这两件事的处理方式比较省心:加载词库时强制指定 UTF-8,读入后先判断 BOM 再去掉。

public static List<String> readLines(String path) { List<String> lines = new ArrayList<>(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { // 去 BOM:首行可能带 \uFEFF,不影响后续拼接 if (line.startsWith("\uFEFF")) { line = line.substring(1); } // 去首尾空白、跳过注释和空行 line = line.trim(); if (line.isEmpty() || line.startsWith("#")) { continue; } lines.add(line.toLowerCase()); } } catch (IOException e) { throw new RuntimeException("词库加载失败", e); } return lines; }

其中StandardCharsets.UTF_8是 JDK 7+ 自带的常量,不需要额外引包。line.startsWith("#")让词库文件支持注释行,方便维护的人在词条后面写业务标记。toLowerCase()是为了匹配时忽略大小写。

4. 实战接入:把筛选器塞进 Spring Boot 的评论接口

4.1 集成方式:把 DfaFilter 做成 Spring 单例

源码包本身是纯 Java 的,没有绑定 Spring,但实际项目里接入 Spring Boot 也就十几行代码的事。正确做法是把它注册成一个单例 Bean,在应用启动时加载词库,后续所有请求复用同一个实例。不要每次请求都 new 一个 DfaFilter——词库上万个词,构建 Trie 树是有开销的,每次 new 等于把构建成本摊到每次请求上。

@Component public class SensitiveWordService { private DfaFilter filter; @PostConstruct public void init() { // 生产环境词库路径放到 application.yml 里配置 // 这里直接用 classpath 下的默认词库 String dictPath = "classpath:dict/sensitive_words.txt"; filter = new DfaFilter(loadPath(dictPath), true); } private String loadPath(String path) { try { return ResourceUtils.getFile(path).getAbsolutePath(); } catch (IOException e) { throw new RuntimeException("词库文件找不到", e); } } }

这里说一下loadPath这个方法的必要性:new FileInputStream()只认绝对路径或者相对路径,不认classpath:前缀,如果你的词库在 jar 包内部,直接用ResourceUtils.getFile会在打包后报错。这种把 classpath 资源转成绝对路径的写法只在开发环境可靠——生产环境里 jar 包内的资源不是文件系统文件,真正上线建议把词库放到外部配置目录,比如/data/config/sensitive_words.txt。

4.2 对外接口设计:过滤、替换、命中检测三件套

WordFilter门面类对外暴露三个方法:isSensitive(text)判断是否含敏感词、filterText(text)返回替换后的文本、findHitWords(text)返回所有命中的词条。接口划分到这个粒度,业务方用起来不需要知道底层是 DFA 还是正则。

public String filterText(String text) { StringBuilder result = new StringBuilder(text.length()); StringBuilder hitWord = new StringBuilder(); Map<Character, Object> cur = root; int start = 0; // 当前尝试匹配的起点 int i = 0; // 当前扫描位置 while (i < text.length()) { char c = Character.toLowerCase(text.charAt(i)); Object node = cur.get(c); if (node != null) { // 能顺着树往下走,记录字符 hitWord.append(c); cur = (Map<Character, Object>) node; i++; if ((Boolean) cur.get("isEnd")) { // 命中了词条,用替换符顶掉原文区域 int begin = start; int end = i; while (begin < end) { result.append(replaceChar); begin++; } // 重置状态,从命中位置之后继续扫 start = i; hitWord.setLength(0); cur = root; } } else { // 走不动了:要么当前位置开头匹配失败,要么匹配到一半失败 result.append(text.charAt(start)); start++; i = start; hitWord.setLength(0); cur = root; } } // 循环结束后补上剩余字符 result.append(text.substring(start)); return result.toString(); }

这段代码是整个系统最容易写错的地方,我拆开讲几个关键点。start和i两个指针的关系是重点:start指向当下这一轮匹配的起点,i指向正在试探的字符。当某一轮从头匹配失败时,start++移动起点,i = start让扫描回到新的起点重新开始,这个回退逻辑保证了漏网率最低。命中词条时,hitWord.setLength(0)清空 StringBuilder 缓冲——不要用new StringBuilder()替代,因为前者复用数组,GC 压力小得多。

4.3 参数调优:命中词白名单与词库热更新

第 4.2 节的实现是纯内存操作,一个方法跑完不用落库,对评论短文本足够用了。很多从这套源码起步的项目后来都会遇到两个进阶需求:白名单和热更新。

白名单的场景很典型:某段时间平台在推广「兼职理财」课程,运营希望「理财」不作为敏感词拦截,但「理财诈骗」要拦。实现思路是给filterText加一个参数:豁免词集合,匹配结束后把豁免词从结果里恢复回来。源码里没有内置这个功能,我一般会硬编码一个Set<String> whitelist,在命中词条时先查一次白名单,命中白名单就不走替换逻辑。

热更新则更关键:生产环境词库不可能一直不更新,每逢大促、节假日,运营会持续往词库里塞新词。直接在原 HashMap 上增删词条也能生效,但并发场景下多个线程同时读写 HashMap 会直接死循环。源码里没有涉及并发,但你在 Spring Boot 里做成单例之后,就必须考虑这个问题。标准做法是:词库更新时全量重建一棵树,用 volatile 引用指向新树,写操作替换引用之后读请求自动切换到新树上。

public void reload(String dictPath) { DfaFilter newFilter = new DfaFilter(dictPath, true); this.filter = newFilter; // filter 用 volatile 修饰 }

这段代码把「构建」和「发布」拆成两步:构建期间老树照常服务,构建完成后把引用一换,新老词库无缝切换,不需要暂停服务。词库一万个词构建耗时大约几百毫秒,一个请求周期内就能完成。

5. 避坑盘点:编码、绕检、热更新,五个真实翻车现场

5.1 现象一:词库加载后首词匹配永远失败,后续词条正常

原因出在 UTF-8 BOM。Windows 用户拿记事本编辑词库,另存为 UTF-8 时记事本会自动在文件头塞一个\uFEFF,这个字符肉眼看不见,BufferedReader会把它当作第一个字符读进来。拼接后的第一个词条变成\uFEFF广告,树里对应分支根本不会被走到。解决方式在 3.3 节已经写了:读取时判断line.startsWith("\uFEFF")并摘除。从那以后我写词库加载,BOM 检测这一行永远放在刚读完文件的第一道工序里。

5.2 现象二:词库量到五万级,构建时 JVM 直接 OOM

原因倒不是 DFA 这个思路错了,而是默认 HashMap 初始化容量太小导致不断扩容。每个节点 HashMap 默认容量 16、负载因子 0.75,一万个词、平均词长 4 个字符,整棵树几百个字节的节点全是零碎对象,内存碎片率高得吓人。解决方式分两层:第一层,构建节点时明确指定小容量,比如三分支以下的节点初始容量给 4,分支多的节点给 16;第二层,词库清洗时把完全重复的词、用前缀能包含的超长词去掉,例如「广告」和「广告广告」后者就可以删。这套源码里没做前缀压缩,遇到长词库内存确实吃紧,但 2 万词以内完全够用。

5.3 现象三:「代!购」这种带符号的变体词没拦住,被运营拿来当证据

原因是匹配逻辑是严格的字符级比对,词库里存的是「代购」,文本里是「代!购」,走到感叹号时发现树里没有这个分支,直接回退导致漏检。解决思路是在匹配层加一个「噪音字符跳过」机制:当前字符不是中文和字母数字时,不把它当成匹配树的一部分,而是记录它的位置和类型,忽略后继续尝试走到下一个有效字符。代价是漏检率低了,但误伤率会升高——「买了个表」这种完全正常的句子可能因为含有敏感词子序列而被拦下。解决方式是只对词条首尾字符生成「变形模板」,比如「购」的变形模板管住「!购」「+购」,不扩大到整句。

5.4 现象四:词库某些词条被拆成两个词命中,替换结果多了一半废话

原因是词库文件里有的行末尾带着空格,addWord之前虽然调了trim(),但如果词条本身带有全角空格,trim()不管用,构建出的树里就多了一个空格分支。匹配时正常文本不可能出现全角空格,这个分支永远不会命中。看着无害,但更隐蔽的版本是:词条中间有不可见字符,比如从网页复制词库时混入了软换行符\u200B。处理办法有两个:加载时把所有不可见字符统一替换掉,或者干脆对词库文件在导入前做一次sed清洗。

5.5 现象五:运营改完词库调用 reload,线上还是旧词库

这个问题十次有八次出在「引用没换」上。reload 方法内部如果是在原 HashMap 上put新词条,而不是重新构建整棵树,那并发请求持有的还是旧引用——如果 DfaFilter 作为单例,多线程读的是同一个对象引用,看起来改了,实际读操作看到的还是原始状态。另一层原因是:DfaFilter被注册成 Spring 单例之后,类内部持有root这个非 volatile 引用,但 Spring 容器对单例的可见性不保证线程间实时可见。解决方式在 4.3 已经写过了:reload 里重建整棵树,再把引用赋给一个 volatile 字段。如果团队里有人改了词库发现不生效,第一件事就是让他检查树是不是重建的。

6. 性能压测与词库热更新:让筛选器稳定跑在百万词量级

6.1 压测方法:用真实的词库量和文本长度去跑

这套源码值不值得接手,最关键的是知道它在真实负载下的表现。我惯用的压测方式非常简单:词库量分两档,一档 5000 词模拟日常,另一档 50000 词压边界;文本样本取线上评论的平均长度,我用的是 200 字符和 2000 字符两档,分开看短文本和超长评论的表现。

@Test public void perfTest() { // 首次调用先把类加载和初始化耗掉,JIT 预热后再计时 DfaFilter filter = new DfaFilter("dict/sensitive_words.txt", true); String longText = "这是一段模拟的评论内容,包含大量正常文字和提高转化率的业务描述"; int warmup = 10000; for (int i = 0; i < warmup; i++) { filter.filterText(longText); } long start = System.nanoTime(); int count = 100000; for (int i = 0; i < count; i++) { filter.filterText(longText); } long cost = System.nanoTime() - start; System.out.println("平均耗时(ms): " + cost / 1000.0 / count); }

跑完结果大致落在单次过滤 0.02ms 到 0.1ms 之间,两万词的词库对匹配耗时的拉高远小于网络 IO,瓶颈根本不在过滤这块。如果出现明显劣化,优先检查是不是构建树时 HashMap 扩容次数太多,而不是匹配逻辑本身的问题。

6.2 进阶技巧:命中词上下文捕获与脱敏保留

基础版 Filter 会把整段命中的字符全部换成*,这个在使用中不够灵活。很多做内容审核后台的人需要看到上下文才能判断「广告」是正常业务描述还是垃圾内容。我在这套源码之上加过一个方法:findHitContext(text, radius),命中词前后各截取 10 个字符和原文一起返回。

实现思路是在filterText循环里记录命中时的start和i,然后调用text.substring(Math.max(0, start - 10), Math.min(text.length(), i + 10))截取上下文。保留的半径做成了可配置项,运营反馈看上下文比看单独词条有效得多。

还有一个从这套源码延伸出来的习惯:上线之前把编码、BOM、噪音符号绕检、最大匹配四条用例全部固化进 JUnit,每次改词库都强制跑一遍。从那以后我每次接内容审核类需求都先把这几条边界用例摆出来,再谈功能,这套源码让我少走了几个月弯路,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 3:39:17

MySQL六种约束详解:从NOT NULL到CHECK,打造可靠表设计

约束这词听起来像限制&#xff0c;实际是给数据库表结构“定规矩”。我在做 MySQL 表设计时&#xff0c;见过太多因为约束缺失导致的脏数据问题&#xff1a;重复的订单号、为空的外键、超出范围的数值。数据库不是 Excel&#xff0c;它应该替你挡住非法数据&#xff0c;而不是事…

作者头像 李华
网站建设 2026/10/7 3:39:03

DIY超声波阵列定向声波发射器:从压电换能器到参量阵实战

前一阵子动手做了一个超声波阵列定向声波发射器&#xff0c;算是我玩电子DIY以来最烧脑也最有成就感的一件事。外形上它只是一块亚克力板&#xff0c;上面密密码码焊了十几个银色的圆形探头&#xff0c;可通电之后你和它正常说话的距离&#xff0c;站在正前方两三米能听清声音&…

作者头像 李华
网站建设 2026/10/7 3:38:39

page-break-inside与break-inside:彻底解决CSS打印分页截断问题

1. 打印页面被拦腰截断&#xff1a;page-break-inside 到底在解决什么问题1.1 一次报价单打印翻车&#xff0c;让我重新审视这个属性我最早在 page-break-inside 上翻车&#xff0c;是在给客户做报价单打印的时候。客户把商品明细拉得很长&#xff0c;页面上看排版也还行&#…

作者头像 李华
网站建设 2026/10/7 3:37:51

tcpreplay 依赖链全解析:从 libpcap 到 libnl 的编译避坑指南

简介&#xff1a;这份资源面向需要在Linux服务器上离线部署tcpreplay的网络运维与测试人员&#xff0c;解决内网环境无法直接联网安装依赖的问题。压缩包共4个文件&#xff0c;以gz、tar源码包和sh安装脚本为主&#xff0c;整体约93.4MB&#xff0c;涵盖gcc、Bison、flex、libp…

作者头像 李华
网站建设 2026/10/7 3:37:41

贝塞尔曲线驱动RecyclerView滚动到位波纹动效的工程实践

做列表滚动结束后的波纹效果&#xff0c;这件事起初不是我自己想出来的。当时有个产品需求&#xff1a;在分类列表里滚动到指定位置&#xff0c;也就是自动吸附到某个分组的锚点&#xff0c;希望在停下来的那一瞬间&#xff0c;锚点位置冒出一圈像水面波纹一样扩散的光圈&#…

作者头像 李华