☰
Go 敏感词过滤实战:Trie+DFA 降低误杀与漏放
2026/10/30 5:29:05 网站建设 项目流程

简介:面向 Go 开发者的轻量级敏感词过滤组件,主要用于评论、聊天、昵称或表单内容的安全校验。实现思路沿用 Java 版 DFA 算法并迁移至 Go,默认词库取自百度搜索整理结果,可通过 ReadSwfDict 加载词库,使用 Match 完成检测、Repl 执行替换,也支持替换词库或自行追加词条,适合在项目中按初始化、检查点两步接入。压缩包共 6 个文件,主体为 4 个 Go 源码文件、1 个 dict 词库和 1 个 md 说明文档,整体约 3KB,体量小、依赖少,便于快速阅读和嵌入现有工程。已有 1911 人学习下载,可参考 swf_test.go 了解调用方式,并据此扩展业务词表、验证匹配效果。

1. 敏感词过滤不是“查字典”,误杀和漏放要同时压低

上线前的自测里,一条写着「加 微 信 详聊」的评论大摇大摆过了审,同一批词库里另一条规则却把「微信息处理」这种完全正常的表述给拦了——一边漏放,一边误杀。这就是敏感词过滤这个项目要面对的真实处境:它不是拿一份词表做strings.Contains那么简单,中间隔着一层「用户会想尽办法把词拆开、全角化、加符号」的现实。

这份资源给的是一套 Go 写的最小可用实现:用一份公开整理的通用词库打底,启动时把词表灌进 Trie 树常驻内存,请求进来先做字符归一化,再走一遍 DFA 扫描拿到所有命中片段,最后按策略替换或直接判定拦截。词库按行存文本文件,加词就是加一行,改完 reload 一次就生效,不用重新编译。适合谁:做社区、IM、评论区、表单提交这类需要实时文本校验的后端同学;也适合想找一个足够小、能一口气读完的 Go 工程练手的开发者——核心逻辑就三个文件,trie.go、normalize.go、filter.go,加起来不到四百行。

2. 技术选型与工程骨架:Go + Trie 的目录分层

2.1 为什么这个场景 Go 比脚本语言顺手

敏感词过滤的访问特征是极其典型的「读多写少」:词库几个小时才变一次,但校验接口可能每秒被调几千次。这个特征决定了三件事——词库必须常驻内存、构建成本只能付一次、并发读不能互相挡。

用 Go 写这件事的好处很直接。第一,Trie 树构建完之后是只读的,多个 goroutine 同时遍历map[rune]*node完全安全,不需要任何锁;就算要热更新,把整棵树换掉再原子替换指针就行,用atomic.Value存一个*Filter就够,读路径上一次原子加载,没有锁竞争。第二,部署形态是单个静态二进制加几个文本词库文件,扔进容器里挂个 volume 就能跑,不需要为了一个文本校验再拉一个 Python 运行时。第三,真出性能问题时有pprof可以直接看,堆上是几万个 map 节点还是字符串切片,一眼就清楚。

常见的翻车思路是用 Python 或 Node 现写一个for word in words: if word in text的接口挂上去。几万词库、每条评论几百字,这个双层循环的量级是「词数 × 文本长度」,机器一忙起来 CPU 直接打满。真要优化,最后还是得回到状态机或者 Trie,那不如一开始就用 Go 把树建好。

我一般会把「词库加载」和「匹配」彻底分开:加载是一次性的、允许慢、允许报错退出;匹配是每请求都要跑的、必须快、不允许 panic。这条边界划清楚,后面加缓存、加白名单、加热更新都不会互相牵扯。

2.2 目录结构与一次请求的数据流

工程按「可执行入口 / 内部包 / 数据文件」三段切,标准布局:

sensitive/ ├── cmd/ │ └── server/ │ └── main.go # 参数解析、信号监听、HTTP 注册 ├── internal/ │ ├── filter/ │ │ ├── trie.go # 节点定义与插入 │ │ ├── normalize.go # 归一化:全角、大小写、分隔符 │ │ ├── filter.go # FindAll / Replace / 白名单 │ │ └── filter_test.go # 用例 + benchmark │ ├── store/ │ │ └── wordstore.go # 词库文件加载与去重 │ └── httpapi/ │ └── handler.go # 请求/响应结构与参数校验 ├── data/ │ ├── general.txt # 通用词库,打底 │ ├── business.txt # 业务自定义词库 │ └── white.txt # 白名单 ├── deploy/ │ └── Dockerfile └── go.mod

一次/filter请求走完的链路是:handler解析 JSON 拿到text→ 从atomic.Value里取出当前的*Filter→FindAll在归一化后的字符序列上扫出所有命中区间(记录的是原串下标)→ 白名单逐条对照,命中的整段跳过 → 按剩余区间做替换,同时把命中词收集出来 → 序列化成{"hit":true,"words":[...],"result":"..."}返回。

注意这里的顺序不能反。先归一化再匹配,是为了解决「加 空 格」「全角字母」「大小写混写」这类绕过;而记录原串下标,是为了替换时能准确落到原文位置上,不会把用户的正常文本结构冲掉。这两件事如果合成一步(边匹配边替换),遇到需要回退或者最长匹配覆盖时就没法处理了。

2.3 词库分层:基础词库、业务词库、白名单

词库分成三层,各有各的维护节奏:

文件内容谁维护更新频率
general.txt通用违规词,公开整理的词表打底技术侧统一引入极低,季度级
business.txt业务线自己的词,比如竞品名、站内禁止的导流话术运营/产品周级
white.txt白名单,明确不该被拦的正常表达运营提,技术审按投诉来

分层的实际价值在于「不互相踩」。如果只有一张大表,每次升级通用词库都得把业务词重新合并一遍,合并脚本一写错就丢词;线上出了误杀,也定位不到是哪一次改动引入的。拆成三个文件之后,加载器按顺序读、用一个map[string]struct{}全局去重,重复的词只保留第一次出现的,日志里打一行「general.txt: 42113 条,business.txt: 386 条,去重后 42377 条」,加词减词都能追溯。

白名单单独放,是因为它的语义和词库相反——词库是「命中就处置」,白名单是「命中但豁免」。文件名独立、加载后单独存一个 set,处置逻辑里先查白名单再决定要不要动手,这条规则在代码里必须显式可见,不能靠「把词从词库里删掉」这种土办法绕,删词是治标,下次同步词库又被覆盖回来。

3. 核心模块拆解:从词库加载到 DFA 匹配的完整链路

3.1 词库加载与 Trie 构建

加载这一步看着简单,坑却不少:BOM 头、行尾\r、空行、#注释、超长行、两列格式。加载器统一按字符流处理,输出一个去重后的[]string:

// internal/store/wordstore.go func LoadWords(paths ...string) ([]string, error) { seen := make(map[string]struct{}, 1<<15) words := make([]string, 0, 1<<15) for _, p := range paths { f, err := os.Open(p) if err != nil { return nil, fmt.Errorf("open %s: %w", p, err) } sc := bufio.NewScanner(f) // 默认 64KB 上限,词库里混进一整段日志就会直接报 ErrTooLong sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) for sc.Scan() { line := strings.TrimSpace(strings.TrimPrefix(sc.Text(), "\ufeff")) if line == "" || strings.HasPrefix(line, "#") { continue } // 兼容「词\t分类」两列格式,只取第一列 if i := strings.IndexByte(line, '\t'); i > 0 { line = line[:i] } if _, ok := seen[line]; ok { continue } seen[line] = struct{}{} words = append(words, line) } if err := sc.Err(); err != nil { f.Close() return nil, fmt.Errorf("scan %s: %w", p, err) } f.Close() } return words, nil }

sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)这行是把单行上限从默认的 64KB 提到 1MB。词库文件是人工维护的,谁手滑把一整段话粘成一行很正常,默认上限下Scan()会直接返回bufio.Scanner: token too long,整个服务启动失败,报错信息还看不出是哪个文件哪一行。TrimPrefix单独处理 BOM,是因为 Windows 上编辑过的词库第一行经常带\ufeff,不剥掉的话第一个词永远匹配不上,排查起来极费时间。

拿到词表后构建 Trie。节点用map[rune]*node,跑在 rune 上而不是 byte 上,中文和英文共用一套逻辑:

// internal/filter/trie.go type node struct { children map[rune]*node end bool // 从根走到这里构成一个完整词 } func newNode() *node { return &node{children: make(map[rune]*node, 4)} } func (n *node) insert(word []rune) { cur := n for _, r := range word { next, ok := cur.children[r] if !ok { next = newNode() cur.children[r] = next } cur = next } cur.end = true }

make(map[rune]*node, 4)的容量 4 是拍出来的经验值:中文词里绝大多数前缀节点只有一两个后继,给太大反而浪费。四万词库建完,堆上大概三四十万个节点,实测在 60~120MB 之间浮动,跟词库的平均词长强相关——这个数字后面在避坑章节还会提到。

构建本身是 O(总字符数),四万词、平均四个字,建一次在两百毫秒上下。所以这个动作只能放在启动和 reload 时做,绝不能放进请求路径。插入时不区分大小写、不做归一化,是因为normalize.go在匹配侧已经把输入规整成小写半角了,词库这边如果也做一次,等于双份开销还容易不一致。

3.2 归一化预处理:先解决全角、大小写和分隔符

绕过过滤的手段九成集中在这一层:加 微 信、加*微*信、全角VX、大写混写、中间插零宽字符。统一在匹配前压成「干净字符序列」,同时记下每个字符在原串里的下标:

// internal/filter/normalize.go var skipSet = map[rune]struct{}{ ' ': {}, '\t': {}, '\n': {}, '\r': {}, '\u00a0': {}, '\u3000': {}, '*': {}, '-': {}, '_': {}, '.': {}, '·': {}, '|': {}, '/': {}, '\u200b': {}, '\u200c': {}, '\u200d': {}, '\ufeff': {}, } // normalize 返回两个等长切片:chars 用于匹配,idx 记录对应字符在原串中的 rune 下标 func normalize(src []rune) (chars []rune, idx []int) { chars = make([]rune, 0, len(src)) idx = make([]int, 0, len(src)) for i, r := range src { if _, ok := skipSet[r]; ok { continue } chars = append(chars, fold(r)) idx = append(idx, i) } return chars, idx } // fold 只做字符级规整,不做任何语义判断 func fold(r rune) rune { if r >= 0xFF01 && r <= 0xFF5E { // 全角 ASCII 区间,整体平移到半角 r -= 0xFEE0 } return unicode.ToLower(r) }

idx这个「位置映射表」是整个设计的关键。归一化把原串压短了(比如「加 微 信」五个 rune 变成三个),但替换时要动的是原文。有了idx,匹配到的第i到第j个归一化字符,对应的原串区间就是[idx[i], idx[j]+1),中间那些被跳过的空格、星号一并被替换掉——用户看到的加 微 信会整体变成***,而不是留下空格。

fold里只处理全角区间和大小写。常见做法是继续往上加形近字映射(比如把0当o、1当l),但那属于「看业务要不要」的选项:加得越多,误杀率越高,一个正常的产品型号A100可能就被打成违规。我的建议是先不加,等线上真的出现这类绕过,再按词库维度加——只对特定几个词做变体扩展,比全局映射安全得多。

提示:skipSet是过滤强度和误伤率的直接旋钮。去掉-和.会让138-xxxx-xxxx这种正常内容更难被误判,但也会放过用连字符拆词的绕过。这个集合必须有测试用例覆盖,改一次跑一次。

3.3 DFA 扫描:一次遍历拿到所有命中片段

匹配的逻辑是:以每个归一化字符为起点,沿 Trie 往下走,走不动就换下一个起点,途中遇到end就记一次命中。

// internal/filter/filter.go type Match struct { Start int // 原串 rune 起下标(含) End int // 原串 rune 止下标(不含) } func (f *Filter) FindAll(text string) []Match { src := []rune(text) chars, idx := normalize(src) var out []Match for i := 0; i < len(chars); i++ { cur := f.root for j := i; j < len(chars); j++ { next, ok := cur.children[chars[j]] if !ok { break } cur = next if cur.end { m := Match{Start: idx[i], End: idx[j] + 1} // 同一位置既命中短词又命中长词时,保留更长的那个 if n := len(out); n > 0 && m.Start < out[n-1].End { out[n-1] = m } else { out = append(out, m) } } } } return out }

外层i遍历起点,内层j沿树下行,最坏复杂度是 O(n × 最长词长),但因为 Trie 共享前缀,实际绝大多数起点走一两步就break,四万词库、两百字文本实测在几微秒量级。

if m.Start < out[n-1].End这段是「最长匹配」的处理。假设词库里同时有「加微」和「加微信」两个词,扫到「加微信」时会先命中短的、再命中长的,两次命中的起点相同。不处理的话返回的是两个重叠区间,替换时第二个区间的起点落在第一个区间内部,cursor会往回退,输出直接错乱。这里用「起点在上一段区间内就覆盖」的方式保证输出区间严格递增不重叠。

注意:这段逻辑依赖「同一内层循环里命中是按长度递增产生的」这个前提,所以 Trie 的每层只能有一个end标记,不能把不同长度的词塞进同一个节点。改 Trie 结构前先把这条约束想清楚。

3.4 替换与位置还原

输出阶段做两件事:白名单豁免、按区间替换。白名单对照的是「归一化后的片段」,这样用户写成加 微 信也能命中白名单里的加微信:

func (f *Filter) Replace(text string) (string, []string) { src := []rune(text) matches := f.FindAll(text) if len(matches) == 0 { return text, nil } var b strings.Builder b.Grow(len(text)) // 替换后长度只会变短或持平,按原长预分配不会反复扩容 cursor := 0 hits := make([]string, 0, len(matches)) for _, m := range matches { seg := src[m.Start:m.End] if f.isWhite(seg) { continue // 白名单命中也算匹配,但不动原文,cursor 不前进 } b.WriteString(string(src[cursor:m.Start])) b.WriteString(strings.Repeat(string(f.repl), m.End-m.Start)) hits = append(hits, string(seg)) cursor = m.End } b.WriteString(string(src[cursor:])) return b.String(), hits } func (f *Filter) isWhite(seg []rune) bool { chars, _ := normalize(seg) _, ok := f.white[string(chars)] return ok }

b.Grow(len(text))是按原串字节长度预分配。替换虽然会把内容变短,但strings.Builder只会扩容不会缩容,按原长给一次到位,避免长文本上反复拷贝。strings.Repeat(string(f.repl), m.End-m.Start)是按原片段长度生成等长的替换符,加 微 信三个字变成***而不是*,用户在页面上看到的位置关系不会错乱,这一条在评论区场景很重要——等长替换能让高亮定位直接复用原下标,不用二次计算。

白名单命中时continue跳过、cursor不前进,读者容易在这里写错成「先写原文再前进 cursor」,那样会在后面拼出重复内容。记住cursor的语义是「已输出到的原串位置」,只有真正写了才前进。

hits里可能重复(同一句话命中两次同一个词),如果接口要对外返回命中词列表,建议在 handler 层做一次去重,或者直接在Replace里用一个map[string]struct{}收着,返回时再展开。词本身没有顺序要求,去重能少传不少字节。

4. 跑起来:编译、接口联调与压测

4.1 环境与编译参数

Go 版本用 1.21 及以上(strings、slices这些标准库顺手),依赖只有标准库,不需要额外拉包,go mod tidy之后go.sum是空的也算正常。

# 拉平依赖并整理 go.mod go mod tidy # 本地开发:直接跑,改完重启即可 go run ./cmd/server -addr :8080 -dict ./data/general.txt,./data/business.txt -white ./data/white.txt # 出包:去掉符号表和调试信息,体积能小一半 go build -trimpath -ldflags "-s -w" -o bin/sensitive-server ./cmd/server

-trimpath去掉编译时写入的绝对路径,产物在不同机器上可复现;-ldflags "-s -w"删掉符号表和 DWARF 调试信息,二十多兆的二进制能压到十兆出头。注意这之后panic的堆栈里函数名还在,但行号信息会弱一些,线上排查如果依赖精确定位,就别加-s -w,或者只在正式镜像里加。

-dict参数设计成逗号分隔的路径列表,加载器按顺序读、全局去重。这样通用词库和业务词库可以分开放、分开挂载,容器里各自 mount 一个文件,互不干扰。

4.2 HTTP 接口与返回结构

接口只做一件事:给定文本,返回是否命中、命中词、替换后文本。请求响应用最朴素的结构体,不引框架:

// internal/httpapi/handler.go type filterReq struct { Text string `json:"text"` Replace string `json:"replace"` // 可选,默认 '*',只取第一个字符 } type filterResp struct { Hit bool `json:"hit"` Words []string `json:"words"` Result string `json:"result"` CostUS int64 `json:"cost_us"` } func (h *Handler) Filter(w http.ResponseWriter, r *http.Request) { start := time.Now() var req filterReq if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&req); err != nil { http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) return } repl := '*' if req.Replace != "" { repl = []rune(req.Replace)[0] } f := h.holder.Get() // atomic.Value 取当前版本,热更新期间也不会读到半截的词库 result, words := f.Replace(req.Text) writeJSON(w, filterResp{ Hit: len(words) > 0, Words: words, Result: result, CostUS: time.Since(start).Microseconds(), }) }

http.MaxBytesReader(w, r.Body, 32<<10)把单请求体卡在 32KB。这行不是可选项——没有它,一个人就能用几兆的长文本把单核 CPU 占满,而且 Trie 扫描的复杂度是跟文本长度线性相关的,长度不设防等于给了对方一个放大器。[]rune(req.Replace)[0]取第一个字符而不是第一个字节,否则传个中文字符会切出半个 rune 变成乱码。

CostUS这字段上生产我建议留着,出问题的时候能直观看到是哪一类请求把耗时拉高了,比事后加日志快得多。当然对外接口要不要暴露这个字段看情况,内部服务之间传没什么问题。

联调:

curl -s localhost:8080/filter \ -H 'Content-Type: application/json' \ -d '{"text":"加 微 信 详聊,价格好商量"}' # {"hit":true,"words":["加微信"],"result":"*****详聊,价格好商量","cost_us":7}

注意返回里命中词是加微信(归一化后的形态),result里被替换掉的是原文的加 微 信五个字符位置。返回归一化形态是有意为之:调用方拿去做审核记录、词频统计时,不希望同一类绕过在报表里变成十几条不同的词。

4.3 用 benchmark 摸清单次耗时和内存

性能这件事不要靠感觉。加一个 benchmark,改任何逻辑之前先跑一遍留个基线:

// internal/filter/filter_test.go func BenchmarkFilter_Replace(b *testing.B) { f := NewFilter(loadBenchWords(b), '*') // 四万词库 text := strings.Repeat("这是一段接近真实长度的用户评论,夹杂着正常表述和少量违规内容。", 4) b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { f.Replace(text) } }
go test ./internal/filter -bench=Replace -benchmem -run=^$ -count=3

-run=^$把普通测试过滤掉,-count=3跑三轮看波动。参考量级:两百字文本、四万词库,单次Replace在 3~8 微秒、四五次内存分配。如果跑到几十微秒甚至毫秒,八成是词库里混进了超长词(有些词库会把整句话当词收进去),Trie 深度暴涨,这时候看benchmem的B/op同时也会飙高。

b.ReportAllocs()不加的话看不到分配次数,而分配恰恰是这类纯计算逻辑最容易退化的地方——某一版给hits加了个append到结构体切片,分配次数立刻翻倍。基准跑完之后记得把词库换成「最长词」「最短词」「全部命中」这几种极端输入再测一遍,正常文本的耗时往往不是瓶颈。

4.4 容器化与词库挂载

镜像用多阶段构建,产物只带二进制和词库:

FROM golang:1.22-alpine AS build WORKDIR /src COPY go.mod ./ COPY . . RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/server ./cmd/server FROM alpine:3.19 WORKDIR /app COPY --from=build /out/server /app/server COPY data/ /app/data/ EXPOSE 8080 ENTRYPOINT ["/app/server"]

启动参数:

docker run -d --name sensitive \ -p 8080:8080 \ -v /data/words/general.txt:/app/data/general.txt:ro \ -v /data/words/business.txt:/app/data/business.txt:ro \ -v /data/words/white.txt:/app/data/white.txt:ro \ sensitive:latest \ -addr :8080 -dict /app/data/general.txt,/app/data/business.txt -white /app/data/white.txt

词库用:ro只读挂载,容器自己不改文件,改词都在宿主机上做,再由外部触发 reload。这么做的好处是词库变更和镜像版本彻底解耦——运营加一个词不需要走一次发布流程。

5. 避坑与排查:上线后最容易翻车的 5 个点

5.1 词库明明加了,线上就是不生效

现象:business.txt里新加的词,本地测没问题,线上怎么调都不命中,日志里加载条数也涨了。

原因通常有三层。一是容器挂载的还是旧文件——改了宿主机的文件,但挂进去的是COPY进镜像的那份,路径根本没对上;二是-dict参数漏了这个文件,加载条数涨是因为另一个文件改过;三是本地和线上的归一化表现不同,本地测的词是纯中文,线上用户输的内容里混了全角字符。

解决:启动日志必须把每个文件的「路径 + 条数」分行打出来,不要只打一个总数。加载完之后再补一行采样日志,把第一万个词打出来看看是不是乱码。容器场景下在容器里cat一下文件头两行,确认挂载生效。第三层靠用例覆盖,本地自测时故意用全角、带空格的版本各测一遍。

5.2 长词被短词吃掉,替换出来还是“看得懂”

现象:词库里同时有「加微」和「加微信」,用户发「加微信」,替换结果只剩前面两个字被打码,后面那个「信」孤零零留着,语义上照样能猜到。

原因:匹配时先命中了短词就立刻记录并结束内层循环,或者记录了短词之后长词的区间没做覆盖处理。Trie 里短词节点就是长词路径上的一个中间节点,这个结构本身没问题,问题出在「命中即停」的写法上。

解决:内层循环必须走到底,直到children里找不到下一个字符才break;同一个起点上的多次命中,用「起点落在上一段区间内就覆盖」的方式保留最长。改完之后在测试里加一组「短词 + 长词」的固定用例,包括「只有短词」「两者都有」「长词在文本末尾」三种排布,不然下次重构很容易改回去。

5.3 内存从几十兆涨到几百兆

现象:服务起来 RSS 八十兆,跑了半天涨到三百多,pprof看着堆上全是map[rune]*node。

原因:Trie 的节点实现是每个节点一个map,Go 的 map 即使只有一个元素也有相当可观的基础开销,四万词库建出来三四十万节点,每个节点光 map 头部就好几十字节。词越长、分叉越多,节点数越夸张。

解决:三条路,按投入排序。最省事的是把children从map[rune]*node换成map[rune]*node但初始化容量给小一点(比如make(..., 2)),能省一部分;中等投入是改成排序后的[]child,节点内二分查找,内存能降一半以上;投入最大的是双数组 Trie(Double-Array Trie),内存和速度都是最优,但构建逻辑复杂,改词库后重建成本也高。我的经验是词库在十万以内先用第二种,够用了。

5.4 正常内容被误杀,投诉比违规还多

现象:某个产品型号、某个英文单词、某个地名连续被拦,运营天天来问为什么。

原因:一是短英文词没有词边界判断,ass这种词出现在class、pass里被当成命中;二是中文词没有上下文,词库里的地名和后面的正常词组合成了一个不存在的词;三是白名单没维护,明知道误杀但没人往white.txt里加。

解决:对纯 ASCII 的词加词边界校验,命中位置的前后字符必须是「非字母数字」才算数,这一步能解决大半英文误杀;中文这边不搞词边界,靠白名单兜。白名单的维护流程要定下来——谁提、谁审、多久生效,比技术方案更重要。另外给命中结果加一个「来源文件」标记,投诉来的时候能立刻看出是通用词库还是业务词库的问题,责任清晰了处理就快。

5.5 单条请求把 CPU 打满

现象:监控上某个实例的 CPU 周期性冲到 100%,日志里能看到几条几十 KB 的超长文本。

原因:扫描复杂度是「文本长度 × 最长词长」,文本长度不设上限就等于把放大倍数交到调用方手里。另外有些调用方会拿整篇文章来调接口,几百 KB 的请求体过一遍,单核被打满是必然的。

解决:入口处用http.MaxBytesReader卡请求体,业务侧对文本长度做截断(比如超过 5000 字只校验前 5000 字并返回标记),超长文本走异步队列离线处理。再补一条:给Replace加个context或者自己实现一个「扫描步数上限」,步数超了就提前返回已命中的部分,避免最坏情况被单条请求拖死。

6. 二次开发:热更新、分级处置与一套能回归的用例

真正上手改这个项目,最先要动的不是匹配算法,而是「词库怎么变、变了怎么生效」这条链路。最土的办法是改完词库重启进程,社区类产品一天加十几个词,重启就意味着短暂的服务中断和连接抖动,不现实。

我一般用信号触发重载,配合atomic.Value做无锁切换:

// cmd/server/main.go 片段 var holder atomic.Value // 存 *filter.Filter func reload(paths, whitePaths []string, repl rune) error { words, err := store.LoadWords(paths...) if err != nil { return err } white, err := store.LoadWords(whitePaths...) if err != nil { return err } holder.Store(filter.NewFilter(words, white, repl)) log.Printf("reload ok, %d words, %d white", len(words), len(white)) return nil } func main() { // ... 首次加载,失败直接退出 sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGHUP) go func() { for range sig { // 加载失败保留旧版本,绝不能用空词库顶上 if err := reload(dictPaths, whitePaths, '*'); err != nil { log.Printf("reload failed, keep old version: %v", err) } } }() // ... 起 HTTP }

这段有两个细节值得说。一是加载失败时直接return,旧版本继续服务——如果加载失败就把空词库塞进去,等于瞬间关掉所有过滤,这是最严重的生产事故之一。二是把白名单和词库放在同一次 reload 里切换,避免出现「新词库 + 旧白名单」的中间态。

# 改完词库后触发重载,pid 从容器外拿到 kill -HUP $(pgrep -f sensitive-server)

再往下是做分级处置。一个词命中之后不是只有「打码」一种选择,业务上至少有三档:轻的替换、中的进人工审核队列、重的直接拒绝并记录。做法是在词库文件里加一列等级,加载时把map[string]struct{}换成map[string]int,Replace每次命中顺带把最高等级带出来:

type Result struct { Text string Words []string Level int // 0 正常,1 替换,2 待审,3 拒绝 }

这个改动很小,但把「过滤器」升级成了「风控链路里的一个判断节点」,后面接限流、接打标、接黑名单都能顺着这个结构往下走。返回值里带上Level之后,调用方不需要自己判断命中了什么词该干什么,策略收敛在一处,改规则不用动业务代码。

最后是验证。我现在改这类项目,一定先攒一份回归用例集,格式就三列:原文、期望的替换结果、期望的命中词。至少覆盖这些:纯中文命中、全角绕过、空格/符号拆词、大小写混写、短词长词重叠、白名单豁免、超长文本截断、纯英文词边界。用例放在filter_test.go里用表驱动跑,改完归一化规则或者替换逻辑,go test ./internal/filter -run=TestReplace -count=1一把梭,两秒钟就知道有没有把老行为改坏。

这套东西的价值不在写代码的那一天,而在三个月后有人提「能不能把某某词也拦掉」的时候。之前吃过一次亏:为了修一个误杀临时把某类词从词库里删了,没留用例,半年后同步新词库又覆盖回来,同一个投诉第二次找上门,翻聊天记录才想起来当时怎么处理的。从那以后我每次动词库或者改归一化规则,都强制先跑一遍用例集、再在测试文件里补上这次场景,确认红绿灯都正常才允许上线。希望这套骨架也能帮到你,从跑通第一个curl开始,把词库换成自己业务的,再一步步加策略。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询