1. 项目概述:为什么我们需要在Spring Boot中处理敏感词?

在任何一个需要用户输入内容的现代Web应用中,敏感词过滤都是一个绕不开的“安全门卫”。无论是社区论坛、即时通讯、电商评论还是内容发布平台,放任未经处理的文本自由流动,轻则影响社区氛围,重则可能引发合规风险,甚至导致服务被关停。我经历过不止一次因为初期忽视了这个环节,导致凌晨被运营同事的电话叫醒,紧急处理一批违规内容的窘境。所以,这件事必须做,而且要做好。

Spring Boot作为Java领域最主流的应用开发框架,其优雅的自动配置和丰富的生态,为我们实现敏感词过滤提供了极大的便利。但“如何实现”却是一个值得深入探讨的话题。简单粗暴的字符串替换?那性能和准确性都无法保障。上复杂的NLP模型?对于大多数业务场景来说又显得杀鸡用牛刀,且维护成本高昂。

因此,本文将聚焦于两种在Spring Boot中 最实用、最经典 的敏感词过滤实现方案: 基于Trie树的本地内存过滤 基于AC自动机的增强过滤 。我不会空谈理论,而是会结合我多年踩坑的经验,从设计思路、核心实现到生产环境下的调优技巧,手把手带你构建一个健壮、高效的过滤组件。无论你是刚接触Spring Boot的新手,还是正在为现有系统寻找优化方案的老鸟,相信都能从中找到可以直接“抄作业”的干货。

2. 核心方案选型与设计思路拆解

面对敏感词过滤,我们首先要回答几个核心问题:词库有多大?过滤的实时性要求多高?需要支持模糊匹配(如拼音、形近字)吗?系统的吞吐量预期是多少?回答这些问题,决定了我们的技术选型。

2.1 方案一:基于Trie树的内存过滤

这是最直观、也是最常用的入门方案。Trie树(前缀树)特别适合用于多模式字符串匹配。它的核心思想是利用字符串的公共前缀来减少查询时间,可以一次性检测文本中是否包含多个敏感词。

为什么选择Trie树?

  1. 初始化快,查询更快 :词库在服务启动时加载到内存中构建成一棵树。查询时,只需遍历一次待检测文本,即可完成所有敏感词的匹配,时间复杂度接近O(n),其中n是文本长度。
  2. 内存占用相对可控 :对于中文敏感词库(通常几万到几十万条),构建的Trie树内存占用在几十MB到几百MB之间,对于现代服务器来说完全可以接受。
  3. 实现简单 :算法逻辑清晰,自己动手实现一个基础版本并不复杂,易于理解和调试。

它的局限性在哪?

  • 仅支持精确匹配 :传统的Trie树只能处理“苹果”匹配“苹果”,对于“苹_果”、“苹果(笑)”这类变体无能为力。
  • 词库更新麻烦 :词库一旦加载进内存,更新就需要重启服务或实现一套复杂的热更新机制,可能造成服务短暂不可用或内存中存在多份数据。
  • 多模式匹配效率 :在匹配过程中,需要对文本的每个字符作为起点进行一遍查询,当文本很长时,仍有优化空间。

2.2 方案二:基于AC自动机的增强过滤

AC自动机(Aho-Corasick automaton)可以看作是Trie树的“威力加强版”。它在Trie树的基础上,增加了失败指针(fail pointer),使得在匹配失败时,不需要回溯到文本的开头重新匹配,而是能够跳转到某个前缀相同的其他分支上继续匹配。

为什么AC自动机更强大?

  1. 真正的单次扫描 :无论文本多长,都只需要从头到尾扫描一遍,就能找出所有出现的敏感词。其时间复杂度是O(n + m),其中n是文本长度,m是所有匹配到的敏感词长度总和,效率比朴素Trie树更高。
  2. 是Trie树的超集 :任何可以用Trie树实现的场景,用AC自动机都能实现,且通常效率更高。从架构上看,升级到AC自动机的成本很低。

那么,为什么不直接都用AC自动机?

  • 实现复杂度更高 :失败指针的构建和理解需要一定的数据结构基础。
  • 对于小词库、低并发场景,优势不明显 :如果词库只有几千条,两种方案的性能差异微乎其微,此时Trie树的简单性就成了优势。

我的选型心得 : 对于绝大多数中小型项目,我建议直接从 AC自动机 开始。它的实现虽有门槛,但一旦封装成组件,后续使用和扩展都非常省心。网上也有许多成熟的开源实现(如Hutool工具包中的 WordTree 实则是AC自动机)。如果项目处于非常早期的原型阶段,或者词库极小且变动频繁,可以考虑先用简单的Trie树快速上线,但同时要为未来切换到AC自动机留好接口。

3. 核心细节解析与实操要点

选定方案后,我们深入看看实现过程中的核心细节。这里以功能更强大的AC自动机方案为主线进行解析,并会指出Trie树方案的不同之处。

3.1 敏感词库的设计与加载

词库是过滤系统的基石。它的格式和加载方式直接影响系统的灵活性和可维护性。

常见的词库格式:

  • 文本文件 :每行一个敏感词。这是最简单的方式,易于人工维护和版本控制。
    敏感词A
    敏感词B
    测试
    
  • 数据库表 :将敏感词存储在数据库(如MySQL)中。便于后台管理,支持动态增删改查,是实现热更新的基础。
    CREATE TABLE sensitive_word (
      id BIGINT PRIMARY KEY AUTO_INCREMENT,
      word VARCHAR(100) NOT NULL COMMENT '敏感词',
      category VARCHAR(50) COMMENT '分类',
      level TINYINT COMMENT '敏感级别',
      is_deleted TINYINT DEFAULT 0
    );
    
  • 配置中心 :在微服务架构下,可以将词库文件放在Nacos、Apollo等配置中心,实现所有服务实例的集中管理和实时推送。

加载策略与优化:

  1. 启动加载 :在Spring Boot的 @PostConstruct 方法或 CommandLineRunner 中加载词库,构建AC自动机。确保服务准备好之前,过滤功能就已就绪。
  2. 异步加载 :如果词库很大(超过百万),同步加载可能阻塞应用启动。可以考虑使用 @Async 异步加载,或在新线程中加载,加载完成前先使用一个空的或旧的过滤器,并做好状态标记。
  3. 双缓冲机制 :这是实现热更新的关键。维护两个AC自动机实例:当前使用的( current )和正在构建的( backup )。当词库更新时,在 backup 上构建新的自动机,构建完成后,通过原子引用(如 AtomicReference )将 current 指向 backup 。这个过程对正在进行的过滤请求几乎无感。
    private final AtomicReference<AhoCorasick> currentMatcher = new AtomicReference<>();
    
    public void refreshWordLibrary(List<String> newWords) {
        AhoCorasick newMatcher = buildAhoCorasick(newWords); // 构建新的自动机
        currentMatcher.set(newMatcher); // 原子切换
        // 旧的自动机稍后由GC回收
    }
    

注意 :从数据库加载时,务必做好缓存。不要每次过滤都去查数据库,也不要频繁地重建整个自动机。通常采用“定期全量拉取+变更事件触发”的策略来更新本地缓存。

3.2 AC自动机节点的核心结构

理解节点结构是理解整个算法的基础。一个典型的AC自动机节点包含以下信息:

public class AcNode {
    // 当前节点对应的字符 (对于根节点,可以为空)
    private char c; 
    // 子节点映射表, key是下一个字符, value是对应的子节点
    private Map<Character, AcNode> children = new HashMap<>();
    // 失败指针,匹配失败时跳转到的节点
    private AcNode fail; 
    // 如果此节点是某个敏感词的结尾,则存储该敏感词的长度。
    // 这里存储长度而非词本身,是为了节省内存和方便后续替换操作。
    private int wordLength = 0; 
    // 是否为敏感词结尾 (可以用 wordLength > 0 判断,此字段可省略)
    // private boolean isWordEnd; 
}

关键点解析:

  • 使用 Map 存储子节点 :相比使用固定大小的数组( AcNode[65536] )来应对所有Unicode字符, HashMap 在内存利用上更高效,特别是当字符集分布稀疏时(中文敏感词树通常很深但分支不多)。
  • 失败指针 fail :这是AC自动机的灵魂。它指向的是 当前节点匹配失败后,应该去尝试继续匹配的节点 。这个节点的路径是当前路径的后缀中最长的、且是其他词前缀的那条路径。
  • 存储 wordLength :在匹配到敏感词时,我们通常需要将其替换为 *** 。知道词的长度,我们就能准确地定位文本中需要被替换的区间,这比存储完整的词字符串更节省内存。

3.3 失败指针的构建算法

失败指针的构建是AC自动机实现中最精妙也最复杂的一环。它通过一层一层的广度优先搜索(BFS)来完成。

算法步骤:

  1. 将根节点的所有直接子节点的失败指针指向根节点,并加入队列。
  2. 当队列不为空时,取出队首节点 current
  3. 遍历 current 节点的每一个子节点 child : a. 找到 current 节点的失败指针 failNode 。 b. 查看 failNode 的子节点中,是否有和 child 字符相同的节点 failChild 。 c. 如果存在,则将 child 的失败指针指向 failChild 此外,如果 failChild 是一个敏感词结尾,那么 child 节点也需要继承这个属性 (因为 failChild 代表的词是 child 路径的后缀)。这步很关键,用于处理嵌套敏感词,比如“苹果”和“果”。 d. 如果不存在,则继续查看 failNode 的失败指针,重复步骤b-c,直到回溯到根节点。如果根节点也没有对应子节点,则将 child 的失败指针指向根节点。 e. 将 child 节点加入队列。
  4. 重复步骤2-3,直到队列为空。

这个过程确保了 :在任何节点匹配失败时,都能快速跳转到当前已匹配路径的 最长可能后缀 所对应的节点,继续匹配,避免了回溯。

4. 实操过程与核心环节实现

接下来,我们将在Spring Boot项目中,实现一个完整的、基于AC自动机的敏感词过滤组件。我们将它设计成一个可插拔的Spring Component

4.1 项目结构与依赖

首先,创建一个标准的Spring Boot项目。我们只需要基本的Web依赖即可,AC自动机我们自己实现。

<!-- pom.xml -->
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <!-- 可选,用于测试 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

项目目录结构建议如下:

src/main/java/com/yourdomain/filter/
├── SensitiveWordFilterApplication.java  // 启动类
├── config/
│   └── SensitiveProperties.java         // 词库路径等配置
├── core/
│   ├── model/
│   │   └── AcNode.java                  // AC自动机节点
│   ├── trie/
│   │   ├── AhoCorasick.java            // AC自动机核心实现
│   │   └── Trie.java                   // 简易Trie树实现(可选)
│   └── SensitiveWordFilter.java        // 过滤服务门面
├── service/
│   └── SensitiveService.java           // 业务层,调用过滤
└── controller/
    └── TestController.java             // 测试接口

4.2 AC自动机核心实现

这是最核心的类,包含了构建、匹配和替换的所有逻辑。

package com.yourdomain.filter.core.trie;

import com.yourdomain.filter.core.model.AcNode;
import org.springframework.core.io.ClassPathResource;
import org.springframework.util.StringUtils;

import javax.annotation.PostConstruct;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.*;

/**
 * AC自动机实现敏感词过滤
 */
public class AhoCorasick {

    private AcNode root;
    private boolean isBuilt = false;

    public AhoCorasick() {
        this.root = new AcNode();
    }

    /**
     * 插入一个敏感词到Trie树中
     */
    public void insert(String word) {
        if (!StringUtils.hasText(word)) {
            return;
        }
        AcNode current = root;
        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);
            current = current.getChildren().computeIfAbsent(c, key -> new AcNode(c));
        }
        // 在单词结尾节点标记长度
        current.setWordLength(word.length());
    }

    /**
     * 构建失败指针,必须在所有词插入完成后调用
     */
    public void buildFailureLinks() {
        Queue<AcNode> queue = new LinkedList<>();
        // 第一层(根节点的子节点)的失败指针都指向根节点
        for (AcNode child : root.getChildren().values()) {
            child.setFail(root);
            queue.offer(child);
        }

        // BFS构建剩余节点的失败指针
        while (!queue.isEmpty()) {
            AcNode current = queue.poll();

            for (AcNode child : current.getChildren().values()) {
                AcNode failNode = current.getFail();
                // 不断回溯失败指针,直到找到匹配的子节点或到达根节点
                while (failNode != null && !failNode.getChildren().containsKey(child.getC())) {
                    failNode = failNode.getFail();
                }
                if (failNode == null) {
                    child.setFail(root);
                } else {
                    AcNode failChild = failNode.getChildren().get(child.getC());
                    child.setFail(failChild);
                    // 关键!如果失败指针指向的节点是某个词的结尾,当前节点也需要“继承”这个属性
                    // 这用于处理“苹果”和“果”这类嵌套词。
                    if (failChild.getWordLength() > 0) {
                        // 这里通常处理逻辑是:如果当前节点本身不是结尾,则标记。
                        // 但更常见的做法是在匹配过程中,沿着失败指针链检查,见match方法。
                        // 为简化,我们不在构建时处理,在匹配时处理。
                    }
                }
                queue.offer(child);
            }
        }
        isBuilt = true;
    }

    /**
     * 匹配文本,返回所有敏感词的起始位置和长度
     * @param text 待检测文本
     * @return 列表,每个元素是一个二元组 [startIndex, wordLength]
     */
    public List<int[]> match(String text) {
        if (!isBuilt || !StringUtils.hasText(text)) {
            return Collections.emptyList();
        }
        List<int[]> results = new ArrayList<>();
        AcNode current = root;

        for (int i = 0; i < text.length(); i++) {
            char c = text.charAt(i);
            // 如果当前节点没有对应子节点,则通过失败指针跳转
            while (current != root && !current.getChildren().containsKey(c)) {
                current = current.getFail();
            }
            // 跳转后,查看是否有对应子节点
            current = current.getChildren().getOrDefault(c, root);

            // 检查当前节点及其失败链上的节点是否为敏感词结尾
            AcNode temp = current;
            while (temp != root) {
                if (temp.getWordLength() > 0) {
                    // 找到一个敏感词!起始位置 = 当前位置 - 词长 + 1
                    results.add(new int[]{i - temp.getWordLength() + 1, temp.getWordLength()});
                }
                temp = temp.getFail();
            }
        }
        return results;
    }

    /**
     * 过滤文本,将敏感词替换为指定字符(如*)
     * @param text 原始文本
     * @param replacement 替换字符,默认为'*'
     * @return 过滤后的文本
     */
    public String filter(String text, char replacement) {
        List<int[]> matches = match(text);
        if (matches.isEmpty()) {
            return text;
        }
        char[] chars = text.toCharArray();
        for (int[] match : matches) {
            int start = match[0];
            int length = match[1];
            for (int i = start; i < start + length; i++) {
                chars[i] = replacement;
            }
        }
        return new String(chars);
    }

    public String filter(String text) {
        return filter(text, '*');
    }
}

代码关键点解读:

  1. buildFailureLinks() 方法:这是构建失败指针的BFS实现。注意在将子节点入队前,已经为其设置好了失败指针。
  2. match() 方法:这是核心的匹配算法。注意内层的 while (temp != root) 循环,它沿着失败指针链向上查找,确保能捕获到所有嵌套的、作为其他词后缀的敏感词(例如“苹果”中的“果”)。
  3. filter() 方法:先调用 match() 获取所有敏感词位置,然后直接操作字符数组进行替换。这种方式比使用 StringBuilder replace 方法在性能上更优,尤其是当敏感词较多时。

4.3 集成到Spring Boot:配置与服务封装

接下来,我们将AC自动机包装成一个Spring Bean,并支持从配置文件指定词库路径。

4.3.1 定义配置属性

package com.yourdomain.filter.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "sensitive")
@Data
public class SensitiveProperties {
    /**
     * 敏感词库文件路径,默认在classpath:sensitive-words.txt
     */
    private String wordFilePath = "sensitive-words.txt";
    /**
     * 是否启用敏感词过滤
     */
    private boolean enabled = true;
}

application.yml 中配置:

sensitive:
  word-file-path: classpath:sensitive-words.txt
  enabled: true

4.3.2 构建过滤服务门面 这个类负责在应用启动时加载词库、构建自动机,并提供对外的过滤API。

package com.yourdomain.filter.core;

import com.yourdomain.filter.config.SensitiveProperties;
import com.yourdomain.filter.core.trie.AhoCorasick;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.annotation.PostConstruct;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.List;

@Component
@Slf4j
public class SensitiveWordFilter {

    private final SensitiveProperties properties;
    private AhoCorasick ahoCorasick;

    public SensitiveWordFilter(SensitiveProperties properties) {
        this.properties = properties;
    }

    @PostConstruct
    public void init() {
        if (!properties.isEnabled()) {
            log.warn("敏感词过滤功能已禁用。");
            this.ahoCorasick = null;
            return;
        }
        long start = System.currentTimeMillis();
        this.ahoCorasick = new AhoCorasick();
        try {
            ClassPathResource resource = new ClassPathResource(properties.getWordFilePath());
            if (!resource.exists()) {
                log.error("敏感词库文件未找到: {}", properties.getWordFilePath());
                // 可以加载一个内置的默认空词库或抛出异常
                return;
            }
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource.getInputStream()))) {
                String word;
                int count = 0;
                while ((word = reader.readLine()) != null) {
                    word = word.trim();
                    if (StringUtils.hasText(word) && !word.startsWith("#")) { // 支持用#注释
                        ahoCorasick.insert(word);
                        count++;
                    }
                }
                ahoCorasick.buildFailureLinks();
                log.info("敏感词过滤引擎初始化完成,加载词条数: {}, 耗时: {} ms", count, System.currentTimeMillis() - start);
            }
        } catch (Exception e) {
            log.error("初始化敏感词过滤引擎失败", e);
            this.ahoCorasick = null; // 初始化失败,禁用过滤
        }
    }

    /**
     * 过滤文本中的敏感词
     * @param text 原始文本
     * @return 过滤后的文本
     */
    public String filter(String text) {
        if (!properties.isEnabled() || ahoCorasick == null || !StringUtils.hasText(text)) {
            return text;
        }
        return ahoCorasick.filter(text);
    }

    /**
     * 检查文本是否包含敏感词
     * @param text 待检查文本
     * @return true 包含, false 不包含或未启用
     */
    public boolean containsSensitive(String text) {
        if (!properties.isEnabled() || ahoCorasick == null || !StringUtils.hasText(text)) {
            return false;
        }
        List<int[]> matches = ahoCorasick.match(text);
        return matches != null && !matches.isEmpty();
    }

    /**
     * 获取文本中所有敏感词及其位置(用于审核等高阶需求)
     * @param text 待检查文本
     * @return 匹配结果列表
     */
    public List<int[]> match(String text) {
        if (!properties.isEnabled() || ahoCorasick == null || !StringUtils.hasText(text)) {
            return Collections.emptyList();
        }
        return ahoCorasick.match(text);
    }
}

4.3.3 创建业务层和控制器

package com.yourdomain.filter.service;

import com.yourdomain.filter.core.SensitiveWordFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class SensitiveService {
    private final SensitiveWordFilter filter;

    public String createComment(String content) {
        String filteredContent = filter.filter(content);
        // 这里将过滤后的内容存入数据库
        // commentRepository.save(new Comment(filteredContent));
        return filteredContent;
    }

    public boolean validateContent(String content) {
        return !filter.containsSensitive(content);
    }
}
package com.yourdomain.filter.controller;

import com.yourdomain.filter.service.SensitiveService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/content")
@RequiredArgsConstructor
public class TestController {
    private final SensitiveService sensitiveService;

    @PostMapping("/comment")
    public String postComment(@RequestBody String content) {
        String safeContent = sensitiveService.createComment(content);
        return "发布成功, 过滤后内容: " + safeContent;
    }

    @GetMapping("/check")
    public String checkContent(@RequestParam String text) {
        boolean isValid = sensitiveService.validateContent(text);
        return isValid ? "内容安全" : "内容包含敏感信息";
    }
}

4.4 简易Trie树方案实现对比

为了完整性,这里给出一个简易Trie树过滤的实现,以体现其与AC自动机的区别。

package com.yourdomain.filter.core.trie;

import java.util.HashMap;
import java.util.Map;

/**
 * 简易Trie树实现(仅用于对比,不支持失败指针)
 */
public class Trie {
    private TrieNode root;

    public Trie() {
        root = new TrieNode();
    }

    public void insert(String word) {
        TrieNode node = root;
        for (char c : word.toCharArray()) {
            node.children.putIfAbsent(c, new TrieNode());
            node = node.children.get(c);
        }
        node.isEnd = true;
    }

    public String filter(String text, char replacement) {
        if (text == null || text.isEmpty()) return text;
        char[] chars = text.toCharArray();
        for (int i = 0; i < chars.length; i++) {
            TrieNode node = root;
            int j = i;
            // 从位置i开始,尝试匹配最长的敏感词
            while (j < chars.length && node.children.containsKey(chars[j])) {
                node = node.children.get(chars[j]);
                j++;
                if (node.isEnd) {
                    // 匹配到一个词,替换从i到j-1的字符
                    for (int k = i; k < j; k++) {
                        chars[k] = replacement;
                    }
                    i = j - 1; // 跳过已替换部分,继续外层循环
                    break;
                }
            }
        }
        return new String(chars);
    }

    static class TrieNode {
        Map<Character, TrieNode> children = new HashMap<>();
        boolean isEnd;
    }
}

与AC自动机的核心区别

  • 匹配逻辑 :Trie树在 filter 方法中,对于文本的每个位置 i ,都尝试从该位置开始,逐字符向下匹配树。一旦失配,就跳出内层循环,从 i+1 位置重新开始。这导致了大量的回溯和重复比较。
  • 无失败指针 :无法实现跳跃匹配,效率低于AC自动机。

5. 常见问题与排查技巧实录

在实际开发和运维中,敏感词过滤组件会遇到各种各样的问题。下面是我总结的一些典型场景和解决方案。

5.1 性能问题:过滤速度突然变慢

现象 :接口响应时间变长,CPU使用率升高,排查发现时间主要消耗在 sensitiveWordFilter.filter() 方法上。

排查思路与解决:

  1. 检查输入文本长度 :是否有用户提交了超长文本(如一篇完整的文章)?AC自动机的时间复杂度虽然是O(n),但当n极大时(例如超过10万字符),单次过滤耗时也会很可观。 解决方案 :在过滤前,对输入文本长度做限制。例如,评论不得超过500字,文章正文可以分段过滤。
  2. 检查词库大小 :是否在运营后台不小心导入了一个巨大的词库文件(例如包含所有成语的词典)?词库过大会导致内存中的Trie树/AC自动机节点数暴增,不仅占用内存,也会稍微降低匹配速度(因为每个字符查找子节点的Map操作耗时增加)。 解决方案 :定期审计词库,移除无效、过时的词汇。对于确实需要海量词库的场景,考虑使用 布隆过滤器(Bloom Filter)进行前置粗筛 ,快速排除绝对不包含敏感词的文本,再走AC自动机精确匹配。
  3. 检查GC情况 :频繁的 filter 方法调用是否产生了大量临时对象(如 List<int[]> char[] )? 解决方案 :考虑使用对象池或线程局部变量( ThreadLocal )来复用一些中间数据结构,减少GC压力。对于 match 方法返回的列表,如果只是做布尔判断(是否包含),可以修改为在匹配过程中直接返回 true ,避免构建完整列表。

5.2 内存问题:服务内存占用过高

现象 :服务运行一段时间后,堆内存持续增长,甚至发生OOM。

排查与解决:

  1. 词库内存泄漏 :这是最常见的原因。确保AC自动机实例是单例的,并且只在初始化时加载一次。如果实现了热更新,要确保旧版本的自动机能被正确回收。 检查双缓冲切换的代码 ,确保 AtomicReference 的赋值操作能解除对旧对象的引用。
  2. 节点数据结构优化 :我们之前用 HashMap<Character, AcNode> 存储子节点。对于节点数极多的情况,可以尝试使用更紧凑的数据结构,如 SparseArray (Android风格)或者对于纯中文词库,使用 AcNode[] next = new AcNode[65536] (牺牲空间换时间)。 但不要过早优化 ,先用 HashMap ,在真实性能测试证明其是瓶颈后再考虑更换。
  3. 敏感词本身存储 :我们在节点中只存储了 wordLength 。如果业务需要知道具体是哪个词被匹配(例如审核日志),就需要存储词本身。这时不要在每个结尾节点都存一个 String ,而是存储一个指向词表索引的 int id ,将完整的词存在一个单独的 List<String> 中,可以节省大量内存。

5.3 功能问题:该过滤的没过滤,不该过滤的误杀了

现象 :用户反馈“敏感词漏过滤”或“正常词汇被屏蔽”。

排查与解决:

  1. 编码问题 :确保读取词库文件和接收用户输入时,字符编码一致(强烈建议统一使用UTF-8)。一个中文词在GBK和UTF-8下字节表示不同。
  2. 大小写与全半角 :我们的基础实现是 区分大小写和全半角 的。“Apple”和“apple”会被视为不同的词。 解决方案 :在插入词库和匹配前,对文本进行 标准化 处理。例如,统一转为小写,将全角字符转为半角。
    public String normalize(String input) {
        if (input == null) return null;
        // 全角转半角,大写转小写
        char[] chars = input.toCharArray();
        for (int i = 0; i < chars.length; i++) {
            if (chars[i] == ' ') {
                chars[i] = ' '; // 全角空格转半角
            } else if (chars[i] >= 'A' && chars[i] <= 'Z') {
                chars[i] = (char)(chars[i] - 'A' + 'A');
            } else if (chars[i] >= 'a' && chars[i] <= 'z') {
                chars[i] = (char)(chars[i] - 'a' + 'a');
            }
            // 还可以处理数字等
        }
        return new String(chars).toLowerCase(); // 最后统一小写
    }
    
    insert match/filter 前,都对字符串调用此方法。
  3. 模糊匹配需求 :用户会用“苹*果”、“苹-果”来绕过。基础实现无法处理。 解决方案 :这属于更高级的对抗。可以在标准化阶段,移除或统一替换掉文本中的干扰符号(如 * , - , _ , ),再进行匹配。但要注意,这可能会误伤正常使用这些符号的文本(如代码片段)。通常需要结合业务场景权衡。
  4. 词库更新延迟 :新加的敏感词没有立刻生效。 解决方案 :实现可靠的热更新机制,并确保所有应用实例都能收到更新通知(如通过配置中心、消息队列或定时拉取)。

5.4 生产环境部署建议

  1. 监控与告警 :为过滤服务添加关键指标监控,如:过滤请求量、平均过滤耗时、敏感词命中率、词库大小、JVM内存使用情况(特别是存放自动机的老年代)。设置合理的告警阈值。
  2. 降级策略 :在 SensitiveWordFilter init filter 方法中,我们已经做了基本的降级(如初始化失败则 ahoCorasick=null ,过滤时直接返回原文本)。在生产环境中,可以考虑更细粒度的降级,例如当过滤耗时超过100ms时,记录告警日志并跳过本次过滤(或返回“内容待审核”状态)。
  3. 测试用例 :编写完善的单元测试和集成测试,覆盖以下场景:
    • 空文本、null值处理。
    • 精确匹配、重叠词匹配(“苹果手机”中包含“苹果”和“果手”)。
    • 长文本性能测试。
    • 词库加载失败时的行为。
    • 热更新功能测试。
  4. 词库管理后台 :开发一个简单的管理后台,允许运营人员安全地添加、删除、查询敏感词,并查看操作日志。这是保证过滤系统持续有效的运营保障。

通过以上从原理到实现,再到问题排查的完整梳理,一个基于Spring Boot的、生产级可用的敏感词过滤组件就构建完成了。它不再是黑盒,你可以清晰地掌控其每一个环节,并能根据自己业务的特殊需求进行定制和优化。记住,没有一劳永逸的方案,只有与业务共同演进的技术组件。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐