从企业级项目学敏感词过滤:DFA算法与双层缓存实战

背景

企业微信SaaS产品,每个企业可以自己配置敏感词库。员工在跟客户聊天时,如果发送的消息包含敏感词,系统会拦截或者记录下来。

需求其实很明确:

  • 支持多租户(每个企业有自己的词库)

  • 匹配要快(不能影响聊天体验)

  • 更新要及时(修改敏感词后尽快生效)

  • 不能丢日志(谁发了什么敏感词都要记录)

核心技术选型

一开始我以为他们会用什么Lucene或者ES做全文检索,结果看了代码发现,就用了个Hutool的WordTree,加上Redis版本控制。

简单,但有效。

DFA算法到底是什么?

DFA(Deterministic Finite Automaton)其实就是个状态机。想象一下,把所有敏感词构建成一棵树:

复制代码
        根
       / | \
      赌  毒  诈
     /    |    \
    博    品    骗

匹配的时候,从根节点开始,一个字一个字往下走。走到叶子节点,说明匹配上了。

相比正则或者逐个contains(),这种方式的优势是:文本只需要遍历一次,不管有多少个敏感词

Hutool的WordTree就是这种算法的封装,用起来超简单:

java 复制代码
WordTree tree = new WordTree();
tree.addWords("敏感词1", "2", "3");
List<String> result = tree.matchAll("今天有人邀我敏感词1");
// 输出: [敏感词1]

系统架构

三层缓存设计?不,是两层

项目里用了「本地缓存+Redis版本控制」的设计,挺巧妙的。

一开始我以为会用Redis存整个词库,但那样每次匹配都要查Redis,太慢了。

实际的做法是:

  1. 本地Map 存每个企业的WordTree(热点数据,极速访问)

  2. Redis 只存一个版本号(用来判断本地缓存是否过期)

java 复制代码
// 本地缓存
private static final Map<Long, SensitiveWordVo> SENSITIVE_CACHE = new HashMap<>();
​
// 获取当前版本
String versionKey = RedisEnum.SENSITIVE_WORD_VERSION_.name() + customId;
Long version = Long.parseLong(redisHelper.get(versionKey));
​
// 版本比对
SensitiveWordVo cached = SENSITIVE_CACHE.get(customId);
if (cached == null || !Objects.equals(cached.getVersion(), version)) {
    // 过期了,重新从数据库加载
    cached = init(customId, version);
}

多租户怎么搞?

很简单,就是在所有Key上加上customId

  • 本地缓存Key:customId

  • Redis版本Key:SENSITIVE_WORD_VERSION_:{customId}

  • 数据库查询条件:customId = xxx

这样每个企业的数据完全隔离,互不影响。

完整流程走一遍

发送消息时的拦截过程

  1. 员工发送消息 → 消息到了HookChatService

  2. 提取文本内容 → 如果是文本/混合文本,提取内容

  3. 校验敏感词 → 调用CommonService.checkSensitiveWord()

  4. 匹配词库SensitiveWordUtil.getFoundAllSensitive()

  5. 判断是否拦截 → 看规则里的isIntercept字段

  6. 记录日志 → 存到robot_sensitive_rule_log

  7. 返回结果 → 拦截的话给前端提示

修改敏感词后的刷新过程

管理员在后台加了个敏感词:

  1. 保存数据库RobotSensitiveRuleService.saveModel()

  2. 更新版本号redisHelper.incr(),版本号+1

  3. 等待下次使用 → 下次匹配时发现版本变了,重新加载

这里用的是懒加载,不是推送,简单但有效。

代码里值得借鉴的细节

1. 规则与匹配分离

每个敏感词规则是一条记录,包含多个关键词:

java 复制代码
public class RobotSensitiveRule {
    private String ruleTitle;    // 规则名称,比如"政治敏感"
    private String ruleKeys;     // "铭感词1|敏感词2|敏感词3"(用|分隔)
    private Integer isIntercept; // 1=拦截,0=只记录
    private Integer status;      // 1=生效
}

然后每个规则对应一个WordTree,这样日志里能知道是触发了哪条规则。

2. 敏感词和违规词是两套

看代码时发现有ruleType字段:

  • 1 = 敏感词(可以配置是否拦截)

  • 2 = 违规词(强制拦截,还支持包含匹配)

一开始觉得没必要,后来想了想,这是业务需求的差异:

  • 敏感词:比如内部机密,可能只记录不拦截

  • 违规词:比如赌博,一定不能发出去

3. 日志里的高亮显示

记录日志时,他们会把敏感词用HTML标签包起来:

java 复制代码
String highlighted = content;
for (String word : matchedWords) {
    highlighted = highlighted.replace(word, 
        "<font color='red'>" + word + "</font>");
}
log.setSendMsg(highlighted);

这样在后台看日志时,敏感词是红色的,一目了然。

总结

这套敏感词过滤系统,技术上并不复杂,但每一个设计决策都有它的道理:

  • DFA算法 → 匹配快

  • 双层缓存 → 性能与一致性的平衡

  • 多租户隔离 → 简单直接的方案

  • 规则灵活配置 → 满足不同业务需求

很多时候,好的架构不是用了多么牛的技术,而是用最简单的方案解决了问题。

希望这篇文章对你有帮助。如果觉得有用,欢迎分享~

相关推荐
cfm_29142 小时前
JVM新一代垃圾收集器深度解析:G1与ZGC
java·jvm
x***r1512 小时前
.NET 10 SDK 安装教程(dotnet-sdk-10.0.100-win-x64详细步骤)
java·服务器·前端
bIo7lyA8v2 小时前
算法中的随机化思想及其复杂度收益评估的技术8
算法
摇滚侠2 小时前
MyBatis 入门到项目实战 MyBatis 的缓存 56-61
java·缓存·mybatis
让我上个超影吧2 小时前
Claude code:Hooks
java·数据库·ai编程
数据法师2 小时前
视频文件重复检测工具:基于哈希与视频指纹的三级筛选机制
算法·音视频·哈希算法
RH2312112 小时前
2026.6.8Linux
java·数据库·中间件
其实防守也摸鱼2 小时前
软件安全与漏洞--Windows底层原理与软件逆向工程基础
linux·网络·数据库·算法·安全·安全架构·软件安全与漏洞
于指尖飞舞2 小时前
java后端面试题(多线程极简)
java·开发语言