简介Sensitive-word-filtering 是一套基于 Go 语言编写的敏感词过滤工具面向需要在服务端快速接入文本内容审核的开发者与中小型项目团队可处理用户昵称、评论、留言等输入中的敏感词命中检测与替换需求。实现思路沿用 Java 版 DFA 算法并迁移到 Go词库取自百度搜索的公开词表支持替换成其他词库或自行追加词条项目初始化时调用 ReadSwfDict 加载词典检查点可用 Match 做验证、用 Repl 完成替换测试文件给出了调用示例。压缩包共 6 个文件其中 4 个 Go 源码承担核心逻辑与测试1 个 dict 文件提供词库数据1 个 md 文件用于说明整体仅 3KB结构清晰便于直接阅读并嵌入现有工程。已有 1911 人学习下载适合希望以较低成本搭建基础过滤能力、再按业务扩展词库与匹配规则的读者参考。1. Go 敏感词过滤不是“查字典”先看词库、边界和延迟预算Go 服务里接敏感词过滤最容易翻车的不是匹配算法而是词库和边界。这套 Go 敏感词过滤资源把公开词库按行加载进内存用自动机做多模式匹配词库可自行扩展适合社区评论、IM 消息、昵称签名、工单内容校验这类“请求里带一段文本”的场景。它解决的是“给定一段用户输入快速找出命中的词并决定拦截、替换还是送审”不是语义审核。新手能照着把词库跑起来熟手更该关注词库版本、归一化和误杀率否则过滤率很容易被几个零宽字符打回原形。2. 选型先算账Trie、DFA 与 AC 自动机在 Go 里的取舍2.1 词库特征决定匹配模式敏感词过滤本质是字符串多模式匹配。先把业务侧的数摸清楚词库到底是几千条还是几十万条词条平均长度是 2 到 4 个汉字还是更长的短语待过滤文本是几十字的评论还是几 KB 的工单接口延迟预算是 1ms 还是 10ms。这几个数不一样选型完全不一样。词库只有几千条、文本又短Trie 树甚至首字索引加逐词strings.Contains都能撑一阵词库上万、文本长、还要一次扫描返回多个命中AC 自动机更合适。我一般会先统计三个指标词库条数、最大词长、采样文本的 P99 长度再决定要不要上自动机。Go 里用map[rune]*node搭 Trie 写起来快但每个节点一个 map词库大时内存会很难看。双数组 Trie 把状态压进[]int32查询是数组下标跳转延迟低但构建和扩容麻烦词库频繁更新时不够灵活。AC 自动机是在 Trie 上加失败指针匹配时不用回退主串一次扫描能找出所有模式串。它适合“词库相对固定、读多写少、文本里可能散落多个命中”的场景也正好对应大多数业务内容校验。还有一个常被忽略的点命中策略。业务要的是“命中第一个就拦截”还是“返回所有命中词做分级”还是“最长匹配优先做替换”只返回布尔值匹配到就能提前退出要返回命中列表就得把后缀命中也接上要做替换还得保留每个命中的起止索引。选型前先把返回结构定下来比后面在代码里补索引省事得多。2.2 三种结构的复杂度与工程成本结构构建成本匹配复杂度内存特征适用场景首字索引 逐词扫描低O(候选词数 × 文本长度)低词库小、上线快、文本短Trie 前缀树O(总字符数)O(文本长度 × 最大词长) 最坏中节点 map 开销大词库中等、需要前缀扩展AC 自动机O(总字符数)O(文本长度 命中数)中高多一个 fail 指针多词匹配、长文本、通用过滤双数组 Trie/DFA较高O(文本长度)低数组紧凑词库固定、追求低延迟这张表不是让你背复杂度而是用来判断“值不值得”。词库 2000 条、文本 50 字Trie 和 AC 的差距在业务里可能被网络抖动吃掉词库 5 万条、文本 2KB、QPS 几千AC 和双数组的差距就会显出来。Go 的 GC 也会影响体感大量小对象和 map 会让内存分配变碎压测时 P99 更容易抖。常见做法是先用 AC 自动机把功能跑通再根据压测结果决定要不要把节点结构换成数组或分片。工程成本也要算。AC 自动机的失败指针构建是 BFS代码不长但命中重叠、后缀继承、替换区间这些边界要写单测。双数组构建更复杂词库更新时重建成本高适合词库版本少、变更走整包发布的场景。这个资源默认走 AC 自动机我理解是折中实现可读词库可扩展匹配一次扫描后续要换双数组也能保留加载和接入层。2.3 为什么这套资源默认走 AC 自动机这套资源的词库是公开词库加自行扩展词条以文本行组织天然适合“启动时加载、构建一次、读多写少”。AC 自动机在构建阶段把 Trie 和 fail 指针一次算好匹配阶段只做状态跳转不需要对每个词做一次Contains。对业务来说最直观的收益是文本越长越划算一段 500 字的评论逐词扫描要扫很多遍AC 只扫一遍。另一个收益是返回命中位置方便替换和送审都能用同一套结果。它也不是没有代价。节点用 map 存子节点词库大时内存高fail 指针继承命中后同一个位置可能返回多个后缀词业务要去重或按最长匹配裁剪。我的习惯是先在加载层把词条去重、去空、去注释构建完打印词库条数和最大词长匹配层只返回Word/Start/End拦截、替换、送审由业务层决定。这样自动机不掺业务策略词库扩展时也不用改匹配代码。落地前先做三个测量步骤第一统计词库文件行数、去重后条数、最长词条的 rune 数第二从线上日志采样 1000 条文本统计长度分布和包含符号的比例第三用最小实现跑一次基准测试记录构建耗时、内存占用和单次匹配耗时。这三个数出来后面调参数才有依据不然只能凭感觉调。3. 把词库编译进内存加载、构建与匹配的 Go 实现3.1 词库文件格式与加载入口资源自带一份公开词库格式是每行一个词条我一般会把它扩展成“词条 制表符 分类 制表符 级别”加载时只取第一列分类和级别留给业务分层。这样同一个文件既能给过滤用也能给运营看。词库文件要统一 UTF-8去掉 BOM空行和#注释跳过。下面这段加载代码把文件扫描、去注释、去扩展列、构建自动机串在一起。package swfilter import ( bufio os strings sync ) type node struct { next map[rune]*node fail *node hit string depth int } type Filter struct { root *node mu sync.RWMutex maxLen int } func New() *Filter { return Filter{root: node{next: make(map[rune]*node)}} } func (f *Filter) Add(word string) { word strings.TrimSpace(word) if word { return } f.mu.Lock() defer f.mu.Unlock() cur : f.root for _, r : range word { if cur.next[r] nil { cur.next[r] node{next: make(map[rune]*node), depth: cur.depth 1} } cur cur.next[r] } cur.hit word if n : len([]rune(word)); n f.maxLen { f.maxLen n } } func (f *Filter) LoadFile(path string) (int, error) { file, err : os.Open(path) if err ! nil { return 0, err } defer file.Close() scanner : bufio.NewScanner(file) scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) count : 0 for scanner.Scan() { line : strings.TrimSpace(scanner.Text()) if line || strings.HasPrefix(line, #) { continue } // 兼容“词条\t分类\t级别”的扩展格式只取第一列 if i : strings.IndexByte(line, \t); i 0 { line line[:i] } f.Add(line) count } if err : scanner.Err(); err ! nil { return count, err } f.Build() return count, nil }这段代码做三件事打开文件按行扫描跳过空行和注释把扩展列截掉把词条插入 Trie最后调用Build构建失败指针。scanner.Buffer的初始缓冲和最大缓冲都设成 1MB是为了防止某一行异常长导致扫描器报错正常词库行远小于这个值。Add里用f.mu.Lock保护写入LoadFile内部会多次加锁启动阶段可以接受如果词库很大可以再提供一个无锁的addNoLock给加载器专用。maxLen记录最长词条的 rune 数后面可以在匹配前做快速跳过文本长度小于 1 的直接返回。hit存原始词条方便返回给业务做日志和分级不要在这里做脱敏脱敏是日志层的事。depth是当前节点在词条中的深度替换时用来倒推起点但更建议在匹配结果里直接算Start/End避免节点复用后深度语义混乱。3.2 AC 自动机的构建代码Trie 建好后每个节点还缺失败指针。失败指针的含义是当前路径匹配失败时应该跳到哪个最长后缀节点继续匹配。构建用 BFS根节点的直接子节点失败指针都指向根然后逐层给子节点找 fail。下面这段是核心构建逻辑。func (f *Filter) Build() { f.mu.Lock() defer f.mu.Unlock() queue : make([]*node, 0, 1024) for _, child : range f.root.next { child.fail f.root queue append(queue, child) } for len(queue) 0 { cur : queue[0] queue queue[1:] for r, child : range cur.next { fail : cur.fail for fail ! nil fail.next[r] nil { fail fail.fail } if fail nil { child.fail f.root } else { child.fail fail.next[r] } // 后缀命中也算命中时继承 fail 的 hit只做最长匹配时不要继承 if child.hit child.fail ! nil { child.hit child.fail.hit } queue append(queue, child) } } }这段代码按层遍历 Trie给每个子节点找失败指针。cur.fail为 nil 表示已经退到根之前此时子节点的 fail 指向根。child.hit为空时继承child.fail.hit是为了让“后缀词”也能被命中比如词库里有ab和b扫到ab时两个都算命中如果业务只要最长匹配就不要继承匹配时再沿 fail 回溯。参数上queue的初始容量 1024 只是减少扩容实际按词库规模调整f.mu在构建期间写锁构建完成后读请求才能看到完整树。这里有个容易踩的坑构建期间如果已经有读请求进来读到的可能是半成品树。所以加载流程必须是“新 Filter 构建完再原子替换”不要在原树上边加词边构建。第 5 章会展开讲热更新并发问题。3.3 匹配与替换的边界处理匹配函数返回Hit切片包含词条和 rune 索引区间。用 rune 索引而不是 byte 索引是因为中文、emoji 在 Go 里按 byte 算会错位替换时切错位置。下面这段匹配逻辑一次扫描文本沿 fail 指针跳转命中就记录。type Hit struct { Word string Start int End int } func (f *Filter) Match(text string) []Hit { f.mu.RLock() defer f.mu.RUnlock() runes : []rune(text) var hits []Hit cur : f.root for i : 0; i len(runes); i { r : runes[i] for cur ! nil cur.next[r] nil { cur cur.fail } if cur nil { cur f.root continue } cur cur.next[r] if cur.hit ! { n : len([]rune(cur.hit)) hits append(hits, Hit{ Word: cur.hit, Start: i - n 1, End: i, }) } } return hits } func (f *Filter) Replace(text string, mask rune) (string, []Hit) { hits : f.Match(text) if len(hits) 0 { return text, nil } runes : []rune(text) for _, h : range hits { for i : h.Start; i h.End i len(runes); i { runes[i] mask } } return string(runes), hits }Match的逻辑是当前字符没有对应子节点就沿 fail 指针回退直到能走或者退到根能走就进入子节点节点有hit就记录。Start用当前索引减去词条 rune 长度加一得到命中起点。Replace先拿命中列表再把对应区间替换成mask。参数上mask传*就是星号替换传 就是空格如果业务要求替换后长度不变中文和英文都按 rune 替换基本能保持字符数但字符串 byte 长度会变日志里不要拿 byte 长度做对齐。重叠命中时Replace后面的命中会覆盖前面的区间可能出现“短词先替换、长词又覆盖”的情况。更稳的做法是按Start排序同起点取最长或者业务层先裁剪命中列表。匹配结果里的Word是词库原词日志里不要直接打印避免把词库内容泄露出去可以打 hash 或词条 ID。4. 接进业务链路并发安全、热更新与接入点4.1 读多写少下的锁与原子替换敏感词过滤是典型读多写少每个请求都要匹配词库一天可能只更新几次。用sync.RWMutex保护整棵树读请求加读锁更新时加写锁功能上没问题但高 QPS 下读锁本身也有原子操作开销。更轻的做法是整树替换构建一棵新树构建完用atomic.Value或atomic.Pointer把根指针换掉读请求永远读当前指针不加锁。type Holder struct { v atomic.Value // 存 *Filter } func NewHolder(f *Filter) *Holder { h : Holder{} h.v.Store(f) return h } func (h *Holder) Load() *Filter { return h.v.Load().(*Filter) } func (h *Holder) Store(f *Filter) { h.v.Store(f) }这段代码用atomic.Value存*FilterStore换整棵树Load取当前树。atomic.Value要求存入的类型一致第一次Store之后才能Load所以NewHolder里先存一个初始化好的 Filter。参数上*Filter内部可以保留mu给局部修改用但整树替换后旧树不再改读请求拿到的要么是旧树要么是新树不会看到半成品。Go 1.19 之后也可以用atomic.Pointer[Filter]类型更明确。注意整树替换的前提是 Filter 构建完不再修改。如果构建后还往旧树里加词读请求仍可能和写冲突。4.2 热更新词库不中断请求热更新常见做法有三种启动时加载、定时轮询文件 mtime、收到配置中心推送后重建。文件轮询最简单不依赖外部组件适合单机或词库文件由部署系统下发的场景。下面这段定时检查文件修改时间发现变化就新建 Filter 加载加载成功再替换 Holder加载失败保留旧树。func WatchFile(path string, h *Holder, interval time.Duration) { var last time.Time ticker : time.NewTicker(interval) defer ticker.Stop() for range ticker.C { info, err : os.Stat(path) if err ! nil { continue } if !info.ModTime().After(last) { continue } nf : New() if _, err : nf.LoadFile(path); err ! nil { // 加载失败不要替换旧树保留可用版本 continue } h.Store(nf) last info.ModTime() } }这段代码每隔interval检查一次文件修改时间比上次新才加载。nf是独立的新 FilterLoadFile内部构建完才返回成功后才h.Store(nf)。参数上interval不要设得太短几秒到几十秒都行太短会增加文件 stat 和构建次数太长会导致词库生效延迟。多实例部署时词库文件要统一版本否则不同实例过滤结果不一致排查时很费劲。提示热更新日志里记录词库条数、最大词长和加载耗时不要记录词条内容。4.3 业务接入点与返回策略敏感词过滤不要只挂在网关层做全量扫描。网关拿不到业务字段语义容易把正常参数误判全量扫描也浪费性能。常见接入点是评论/帖子发布、昵称和签名修改、IM 单聊群聊消息、工单描述、商品评价。每个接入点只过滤用户可写的文本字段不要过滤 ID、时间戳、JSON key。返回策略分三档拦截、替换、送审。拦截适合明确违规词替换适合展示场景送审适合灰色词和行业词。var ErrSensitive errors.New(content contains sensitive word) func Check(f *Filter, text string) error { hits : f.Match(text) if len(hits) 0 { return nil } // 生产环境不要回显具体命中词避免被探测 return ErrSensitive }这段Check只返回统一错误不回显命中词。参数上f从 Holder 取当前树text是已经做过基础归一化的文本。错误码对外统一日志里可以记录命中数量、文本 hash、词库版本不要记录原文和命中词。替换场景可以调Replace但替换后的文本要再次过一遍匹配防止替换字符本身拼出新的命中这个成本不高能挡住一部分低级绕过。5. 避坑与排查五个把过滤率打回原形的细节5.1 命中词回显方便调试也方便被探测现象接口返回“内容包含某某词”前端直接把这句话展示给用户。原因通常是开发阶段为了调试方便把命中词拼进了错误信息上线后没删。解决对外只返回统一错误码和 request id命中词只进内部日志日志里也尽量只留词条 hash 或词库 ID。更严一点连命中数量都不要对外暴露避免攻击者用二分法试词。血泪经验是错误信息越具体绕过成本越低。5.2 变体绕过空格、零宽字符和全半角现象词库里明明有某个词用户加一个空格、零宽字符或全角字母就绕过去了。原因匹配前没有做归一化只做了精确匹配。解决匹配前做轻量归一化转小写、全角转半角、去掉零宽字符和常见分隔符。但归一化会改变原文长度替换时不能直接拿归一化后的索引去改原文常见做法是保留“归一化字符到原文索引”的映射或者只在检测阶段归一化替换阶段按原文重新定位。func normalize(s string) string { s strings.ToLower(s) return strings.Map(func(r rune) rune { switch { case r r : return r - a case r r : p a hrefhttps://download.csdn.net/download/weixin_42139357/18825904 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p