状态:✅ 已完成 前置阅读:03-技术架构与业务模块分析(模块全景与两个时期划分);02-Mac本地部署实操(实验环境可复现) 素材来源:safety/safety-skills/safety-keywords 源码逐行核实 + safety-api 侧 KeywordFunction + admin 表结构与 API 文档 读者定位:想搞懂"敏感词检测为什么能做到毫秒级、万级词库"的读者;准备维护词库、写自定义检测能力的读者
〇、先建立全局认识
0.1 服务定位:第一道安检
safety-keywords 是整个审核链路里最便宜也最快的一环,类比机场安检的"第一道闸机"——X 光粗筛,成本低、速度快,拦下明显违禁品,拿不准的交给后面的精检仪器(BERT)。
| 属性 | 值 | 出处 |
|---|---|---|
| 技术栈 | Java / Spring Boot(@EnableScheduling 开启定时任务) | LlmSecSensitiveApplication.java:14 |
| 容器内端口 | 8005(根 compose 里 ports 被注释,仅容器网络可达) | docker-compose.yaml:145 |
| 对外接口 | 仅一个:POST /keyword/query | contoller/SensitiveWordsController.java:24(包名 contoller 是仓库原样的拼写) |
| 数据源 | MySQL sensitive_words 表,只加载 status=online | SensitiveWordsManageServiceImpl.java:359 |
| 匹配引擎 | AC 自动机(org.ahocorasick 0.4.0)+ 正则倒排加速 | pom.xml:73 |
它在全链路中的位置(配置期与执行期分开,和 03 篇的框架一致):
0.2 数据模型:一张表讲清四个维度
safety/safety-admin/docs/tables.sql:1 的 sensitive_words 表,核心字段:
| 字段 | 作用 | 取值 |
|---|---|---|
word | 词本体(普通词或正则) | varchar(1000) |
match_type | 匹配方式 | contain(包含)/ equal(完全相等)/ regex(正则) |
handle_strategy | 命中后处置 | black(拦截)/ white(放行)/ grey(观察)/ exclude(豁免) |
business_scene | 生效业务范围 | all 或空 = 全局;否则逗号分隔的业务名列表 |
status | 上线状态 | online 才会被检测服务加载 |
first_class_no/name | 风险分类(如 1006/辱骂) | 命中后透传给 DAG 做 riskCode |
表上有 ON UPDATE CURRENT_TIMESTAMP 的 update_time 字段(tables.sql:22)——这是热更新机制的关键伏笔,见第五章。
0.3 ⚠️ 文档/提纲与源码的出入(本篇一律以源码为准)
核实源码时发现几处"传说中的设计"与实际实现不符,先立此存照:
| 常见说法 | 源码实际 |
|---|---|
| match_type 有 compound(组合) | 只出现在 tables.sql:9 的注释里;MatchType.java:12 枚举只有 contain/equal/regex/exclude,全仓库无 compound 实现 |
| handle_strategy 有 gray | 代码里拼写是 grey(WordsUtils.java:29、BwgLabelUtils.java:17)。词库里写 “gray” 会落到 default 分支:bwgLabel=0、排序优先级最低,等于不生效 |
| exclude 是一种 match_type | 枚举里确实声明了(MatchType.java:31),但 doContainTask 把它当 handleStrategy 用(SensitiveWordsManageServiceImpl.java:124);match_type=exclude 的词根本不会被加载(contains 引擎只查 contain/equal,见 :282) |
| 双引擎各有一个线程池 | 实际是一个 200 线程的共享池,提交两个任务(:51、:65-66) |
一、匹配算法核心:AC 自动机
1.1 为什么不用笨办法
词库 1 万个词、文本 1000 字,逐个词 String.contains() 是 O(词库规模 × 文本长 × 词长),千万级字符操作,单次请求就要几百毫秒。AC 自动机(Aho-Corasick)把所有模式串先编译成一棵树,文本只扫一遍,复杂度 O(文本长 + 命中数),与词库规模基本无关。
1.2 三件套:goto / fail / output
以词库 {王八蛋, 八蛋, 坏蛋} 为例:
fail 指针的含义:“当前匹配走不下去时,已匹配串的最长后缀在哪里”。比如在 (王八) 节点读到"蛋"走到 (王八蛋),它的 fail 指向 (八蛋)——因为"王八蛋"的最长后缀"八蛋"本身也是模式串。扫文本"老王八蛋"的过程:
老:root 没有这条边,留在 root(没有任何词被"部分命中")王→(王);八→(王八);蛋→(王八蛋),output:命中"王八蛋"- 沿 output 链接补报 fail 链上的 ★:同时命中"八蛋"
一句话理解:AC 自动机 = Trie 树 + KMP 的 fail 思想推广到多模式串。命中可重叠(“王八蛋"和"八蛋"都会报),这正是敏感词场景想要的。
1.3 为什么不选 DFA
把上万条规则编译成一个确定性自动机(DFA),理论匹配也是 O(n),但状态数会爆炸、构建慢、内存大,词库每分钟都要热重建的场景下不可行。AC 建树是 O(总词长),万级词库重建只需毫秒级,重建完整体替换引用即可(见第五章)。
1.4 代码:一个 34 行的薄封装
service/data/SensitiveWordDetector.java:14-33 全部职责就是把词库交给 ahocorasick 库建树、查询透传:
| |
两个使用注意:builder 没有调 ignoreCase(),匹配大小写敏感;parseText 返回所有重叠命中(“王八蛋"会同时报出"八蛋”),后续靠排序归约(见第三章)。
二、请求处理全流程:双引擎并行 + 150ms 预算
2.1 入口与预处理
POST /keyword/query → SensitiveWordsController.java:24 → KeywordApiServiceImpl.invoke()(rpc/KeywordApiServiceImpl.java:30)。预处理有两个值得知道的点:
- 超长截断:文本超过
MAX_LEN = 10000字时,取前 5000 字 + 后 5000 字拼接(:52)——中间部分直接不检。写超长文本绕过检测在理论上是可能的,别把它当唯一防线。 - 全程 fail-open:任何异常都返回
statusCode=500但文案"正常文本"的响应(:69-70)。上游KeywordFunction发现!result.success()后返回 null(KeywordFunction.java:86-89),该节点等于没结果。敏感词服务挂了不会阻断业务,这是内容安全"宁可漏过不可误杀"哲学的体现——但也意味着监控告警很重要。
2.2 双引擎并行与短路逻辑(全篇最重要的一段代码)
SensitiveWordsManageServiceImpl.parallelDetection()(:63-96),逐行批注:
| |
决策逻辑画成表:
| AC 结果 | 正则结果 | 最终返回 |
|---|---|---|
| black | 任意(被取消) | AC 结果(短路,不等正则) |
| 非 black / null | black | 正则结果 |
| 非 black / null | 非 black | 优先 AC,其次正则 |
| 超时 | — | null(放行) |
| 任意 | 超时 | null(放行,连 AC 的非黑命中也丢弃!) |
设计解读:
- 150ms 是这个服务对自己的 SLA 承诺。关键词是链路第一道,不能拖慢整条 DAG(对比:KeywordFunction 注册超时 1000ms,见第六章)。
- black 短路是性能优化:黑词已是终局,正则算完也改变不了结果,cancel 掉省 CPU。
- 正则超时会连累 AC 的非黑命中(最后一行)——白/灰词在正则引擎卡死时会被一起丢弃。这是代码的真实行为,用词时心里有数。
2.3 AC 引擎内部:命中后的四道过滤
doContainTask()(:98-135)在 AC 扫出命中词后还要过四关:
注意 ③:equal 词也进了 AC 树,AC 粗筛出"包含"之后再用字符串相等做精确确认——复用一套索引,省一棵树。
三、match_type × handle_strategy:名单体系
3.1 三种生效的 match_type
| match_type | 加载进哪个引擎 | 语义 | 典型场景 |
|---|---|---|---|
| contain | AC 树(:282) | 文本包含该词即命中 | “王八蛋”(演示词,example/safety-admin/data/sensitive_words.sql:18) |
| equal | AC 树 + 相等二次确认 | 整条文本恰好等于该词 | 验证码类垃圾"加V:xxxx"整句 |
| regex | 正则引擎(倒排加速,第四章) | 正则 find() 命中 | 手机号、身份证号、变体广告 |
3.2 四种 handle_strategy 与 bwgLabel
| handle_strategy | bwgLabel(BwgLabelUtils.java:7) | 语义 | 排序优先级(WordsUtils.java:20-34) |
|---|---|---|---|
| exclude | 0 | 豁免:该词在本业务不算敏感,压制其他命中 | -4(最高) |
| black | 1 | 黑名单,命中即拦 | -3 |
| white | 2 | 白名单,明确放行(策略里可跳过下游模型,省 BERT 开销) | -2 |
| grey | 3 | 灰名单,观察记录不拦截 | -1 |
优先级排序器的逐行解读(WordsUtils.java:20-34):先按处置策略升序(exclude 最前),同策略词越长越靠前(thenComparing(-word.length()))。所以"自慰”(black)和"自慰高潮X"(white)同时命中时,返回的是更长的白词——更长更具体的规则优先,这是词库设计的常识:具体规则应当覆盖笼统规则。
exclude 的正确用法:它是 handleStrategy(不是 matchType!见 0.3 节)。比如全局词库有"枪"(black),军事业务的正常讨论就会被误伤;给军事业务配一条 word=“枪”、handleStrategy=exclude、business_scene=military 的记录,该业务命中"枪"时排序器把它排第一,doContainTask 看到 exclude 直接 return null——为特定业务在全局词库上"打孔"。
3.3 业务场景过滤的细节
isSceneMatch()(:180-191):businessScene 为 "all" 或空串 → 对所有业务生效;否则按逗号 split 后精确包含。两个坑:
- 方法第一行是
businessScene.equals("all"),businessScene 为 null 会 NPE(null 检查写在后半句,顺序反了)。upsert 时 businessScene 必填,正常不会触发,但直接往 MySQL 灌数据时注意。 - 匹配是包含语义:业务名是
test时,business_scene="test2"不会命中(用的是列表 contains,精确相等)。
四、正则倒排加速(亮点设计)
4.1 问题:为什么不能全量正则扫文本
假设词库有 5000 条正则,每来一次文本就 for (rule : rules) rule.pattern.matcher(text).find():
- 复杂度 O(规则数 × 文本长),单次请求几十上百毫秒起步,150ms 预算必爆;
- 正则引擎是 NFA 回溯,恶意构造的正则(嵌套量词)有灾难性回溯风险,一条烂规则就能打满 CPU。
4.2 解法:借搜索引擎的倒排索引思想
核心洞察:正则再花,里面总有字面量片段。1[3-9]\d{9} 必须包含 “1”,(赌博|博彩)网站 必须包含"赌博"或"博彩"或"网站"。文本连这些字面量都不含,正则绝不可能命中。于是:
| |
类比:AC 粗筛是搜索引擎用倒排索引找"可能含这些词的文档",Pattern 精匹配是打开文档确认。绝大多数文本一个 token 都命中不了,候选集为空直接返回——把"5000 条正则全扫"变成"几条候选正则精验"。
4.3 代码批注
| |
4.4 这个设计的边界(如实记录)
- 纯元字符/无字面量的正则会失效:如
^\d+$切完没有 token,updateRegex会 warn “正则表达式分词结果为空"并跳过该规则(:252-255)——这条规则永远不会命中。配 regex 词时必须保证有可切的字面量片段。 - token 太常见则粗筛失效:如 “1” 这种 token 几乎每条文本都命中,挂在上面的规则每次都要精匹配。切分粒度无法控制,这是简易实现的代价。
- 正则侧没有 exclude 短路(doContainTask 才有),exclude 策略的 regex 命中后照常返回 VO,靠 bwgLabel=0 自然放行。
五、词库热更新:60s 轮询 + 全量重建
5.1 机制全貌
| |
5.2 设计要点与启示
- “先探测、再重建”:每分钟一次的变更探测只是一条
LIMIT 1的轻查询,没变更时开销忽略不计;有变更才付出全量重建成本。这是轮询系统的标准省流姿势。 - 无锁热切换:读请求直接读成员变量,更新在后台另起炉灶建完再换引用——靠 JVM 引用赋值的原子性保证读到的是完整快照。没有加 volatile,严格说有可见性瑕疵,工程上可接受。
- 与 03 篇 api 侧 60s 配置轮询异曲同工:JoySafety 的配置下发哲学统一是"pull + 全量替换”,不依赖推送,admin 挂了执行层照跑。
- 时延特性:词变更最长约 60s 生效(探测周期)+ 重建耗时。变更探测用
update_time(MySQL 服务器时钟)对比lastRefreshTime(应用System.currentTimeMillis()),两台机器时钟有偏移时可能漏判——生产部署注意 NTP 对时。
5.3 ⚠️ 两个真实的坑
- 清空词库不会生效:
updateContains里if (newWordsMap.size() != 0)才替换(:319)——把词全下线后查询结果为空,老 Trie 继续服役。想清空只能重启或留一条废词。 - contain 与 regex 的刷新时间戳互相独立(
containsLastRefreshTime/regexLastRefreshTime),一类失败不影响另一类,但也意味着两类词库可能短暂不一致。
六、业务链路串联:一个词从录入到拦截的完整旅程
| |
KeywordFunction 还有两个工程细节值得抄作业:
- OkHttpClient 全局复用:
FunctionResourcePool.getFunc(type, url)按 url 缓存连接池(:58-65),多个 DAG 节点调同一个 keywords 服务共享连接,配 Keep-Alivetimeout=20, max=5000(:82)。 - 默认超时 200ms(:51),演示环境注册时改成了 1000ms(skills_init.py:10)——与 keywords 服务自己的 150ms 预算形成"外层兜底"。
七、动手实验
前提:按 02 篇部署完成,容器网络内 keywords 可达。根 compose 里 keywords 的
ports被注释(docker-compose.yaml:145),直连验证用docker exec进容器 curl,或解注释后重建容器。
实验 1:加词 → 观察 60s 生效
| |
实验 2:端到端走 DAG
实验 3:exclude 豁免与业务场景过滤
| |
实验 4:压测词库规模 vs 匹配耗时
| |
预期观察:词库从 10 涨到 1000、10000,未命中查询的耗时应基本不变(AC 复杂度与词库规模无关,只与建树的内存和文本长有关)——这就是 AC 自动机的价值,可以亲手验证。KVResponse 里的 latency 字段是服务内部耗时,比 curl 总耗时(含 docker exec 开销)更准。
八、速查附录
8.1 关键源码坐标
| 内容 | 坐标 |
|---|---|
| HTTP 入口 POST /keyword/query | safety/safety-skills/safety-keywords/.../contoller/SensitiveWordsController.java:24 |
| 预处理/截断/fail-open | .../rpc/KeywordApiServiceImpl.java:30(MAX_LEN :24,截断 :52) |
| AC 薄封装 | .../service/data/SensitiveWordDetector.java:14 |
| 150ms 预算 / 双任务并行 / black 短路 | .../service/data/SensitiveWordsManageServiceImpl.java:37 / :63-96 |
| contain 引擎四道过滤 | 同上 :98-135 |
| 正则倒排(构建 / 查询 / 切分) | 同上 :205-272 / :137-178 / :274-278 |
| 60s 轮询 / 分页全量拉取 / 只取 online | 同上 :194 / :356-363(:359) |
| 处置优先级排序器 | .../utils/WordsUtils.java:20-34 |
| bwgLabel 映射(black1/white2/grey3) | .../utils/BwgLabelUtils.java:7-25 |
| api 侧调用方 | safety/safety-api/.../functions/KeywordFunction.java:69 |
| 管理接口 | safety/safety-admin/.../contoller/data/SensitiveWordsController.java:26(upsert/updateWithId/getById/queryByWord/upgrade/online/offline) |
8.2 管理 API(:8006,文档见 safety-admin/docs/api/sensitive_words_api.md)
/data/sensitive_words/upsert(新增/更新)· /queryByWord(模糊查)· /getById · /online /offline(上下线)· /updateWithId · /upgrade
8.3 本篇要点回顾
- AC 自动机让匹配耗时与词库规模解耦;正则靠"字面量 token 倒排"避免全量扫描——一个服务里用了两遍倒排/树思想。
- black 短路、exclude 豁免、白词跳过下游模型,处置策略是排序器+短路共同实现的。
- 150ms 预算 + 全程 fail-open:安全组件自我定位为"不能拖累业务"。
- 60s 轮询 = 轻探测(update_time DESC LIMIT 1)+ 全量重建 + 引用替换,时延上限约一分钟。
- 用词规范提醒:handle_strategy 用 grey 不是 gray;regex 词必须含字面量片段;exclude 当 handleStrategy 用。
