上一篇提到,项目引入了 vectorscan 和 regex 两个正则库。由于两者在字符集和语法支持范围上存在差异,它们的能力只有部分交集,不能构成严格的包含关系。
在当前架构中,vectorscan 负责预筛选,regex 负责最终匹配。这里有一条必须满足的正确性约束:vectorscan 的匹配集合必须是 regex 的超集。
换句话说,预筛选阶段可以产生假阳性,但绝不能漏掉 regex 能够匹配的内容。假阳性只会增加后续处理成本,假阴性则可能导致漏脱敏,甚至引发敏感数据泄漏。
第一版方案:严格筛选
vectorscan 吞吐量较高,主要是因为它使用 ASCII 字符集,字符长度固定,天然适合通过 SIMD 进行并行处理。
最初,我对正则表达式进行了严格筛选,只有当 vectorscan 和 regex 的语义行为一致时,才让它进入预筛选阶段。不一致的表达式则跳过预筛选,直接交给 regex 处理。
这一版实现没有正确性问题。在两个库都能正常运行的情况下,性能也确实很快。
但很快,我们发现了新的问题:预设的 156 个常用脱敏正则中,有 70% 因兼容性筛选而只能交给 regex 处理。
这个比例过高,意味着大部分预设规则无法获得 vectorscan 的加速。考虑到用户很可能大量使用内置规则,vectorscan 的实际收益会非常有限。
常见的拒绝原因包括:
(?i)等内联 flag\b、\B词边界\w、\d、\s- 字符类中的非 ASCII 字面量
- 表达式中包含非 ASCII 字符
- 非 ASCII 字符或
\uXXXX字面量 - 字符类中的
\w、\d、\s
第二版方案:通过安全宽化扩大覆盖率
于是,我提出了一个相对激进的方案:对语义不完全一致的正则表达式进行宽化,让修改后的表达式成为原始表达式的严格超集。
例如,\bfoo\w+ 可以宽化为 foo.+:去掉词边界,并将 \w+ 放宽为 .+。在转换正确的前提下,这会增加预筛选阶段的假阳性,但不会漏掉原始正则能够匹配的内容。
如果约 90% 的日志本来就不需要脱敏,那么宽化产生的额外假阳性通常不会带来过高成本。因此,在这一工作负载假设下,该方案具备获得整体收益的可能。
于是,项目进入了第二版开发阶段:逐一确认哪些语法行为存在差异,以及哪些差异可以通过安全宽化处理。
如何验证安全宽化
这部分工作借助 AI 辅助梳理相关语义,并通过大量测试进行验证。宽化的目标不是让两个库的行为完全一致,而是让 vectorscan 接受一个包含原始匹配集合的表达式。
为了确认宽化不是凭直觉进行的,我们把测试拆成三类。
第一类是原始实现差异测试:对同一条原始正则,分别交给原始 vectorscan 和 regex 编译并执行,记录编译失败、语法不支持以及匹配结果差异。这个测试的目标是找出两套实现的兼容性边界,而不是证明宽化安全。
第二类是超集关系测试:对每条可以宽化的表达式生成宽化版本,再使用同一批测试输入,分别执行原始 regex 和宽化后的 vectorscan。只要原始 regex 命中,宽化后的 vectorscan 就必须命中;宽化版本额外命中的输入允许存在,因为这些属于假阳性。这个测试直接验证了 vectorscan ⊇ regex 的核心不变量。
以原始表达式 \bfoo\w+ 和宽化后的 foo.+ 为例:
| 测试输入 | 原始 regex | 宽化后的 vectorscan | 说明 |
|---|---|---|---|
foo123 | 命中 | 命中 | 原始结果必须被保留 |
foo-123 | 不命中 | 命中 | 宽化后新增的假阳性,可以接受 |
bar | 不命中 | 不命中 | 不影响超集关系 |
从集合关系看,原始 regex 的全部匹配结果都包含在宽化后的 vectorscan 匹配结果中。测试不要求两边完全相等,只要求不能出现“原始 regex 命中,但宽化后的 vectorscan 不命中”的情况;一旦出现这种漏匹配,宽化就不安全。
第三类是可编译性测试:确认宽化后的表达式能够被 vectorscan 成功编译。编译失败的表达式不能进入加速队列,继续回退到 regex 单独处理。
三类测试分别回答三个问题:两套库原本哪里不同、宽化后是否仍然是安全超集、以及宽化结果能否真正进入 vectorscan 执行。只有三类测试全部通过,规则才会进入加速队列。
可以安全宽化的正则语法
对于无法保证严格超集关系、又无法安全宽化的正则表达式,仍然只能由 regex 单独处理。
| 语法 | 处理方式 | 原因 |
|---|---|---|
\b、\B(word boundaries) | WIDEN | 移除边界约束,剩余表达式成为超集 |
\w、\d、\s(positive) | WIDEN | 放宽为 . + DOTALL |
\p{…} Unicode property class | WIDEN | vectorscan 不接受 \p{…},放宽为 . + DOTALL |
非 ASCII 字面量,如 你、é | WIDEN | 将 Rust 的 \u{NNNN} 转换为 vectorscan 可接受的 \x{NNNN}(PCRE) |
| 包含可宽化项的 bracket class | WIDEN | 整个字符类放宽为 . + DOTALL |
集合运算 [a&&b]、[a--b]、[a~~b] | WIDEN | 取左侧操作数作为安全超集 |
开头的 (?i) 前缀 | WIDEN | 移除前缀并添加 CASELESS;将 k/K、s/S、a/A、µ 等可能产生语义差异的字符放宽为 . + DOTALL |
(?i:…) 作用域分组 | WIDEN | 移除分组包装并全局添加 CASELESS;将 k/K、s/S、a/A、µ 等可能产生语义差异的字符放宽为 . + DOTALL |
经过宽化处理后,在原先被拒绝的默认规则中,有 95% 可以重新被接受并进入加速队列。
坦率地说,如果没有 AI 辅助,要准确处理这些正则语义差异,凭借我一个人的能力,至少需要一周时间查阅资料,再花一周时间编写测试。中间还可能因为字符集问题走弯路,最后才能得到相对完整的解决方案。
Benchmark 结果
同时,我们也优化了整体实现,主要是改进查询端的字段血缘追溯,并统一缓存多段查询中的重复计算。
写入端开销
| 指标 | 上一版 | 当前版本 |
|---|---|---|
| Baseline | 109,288 rec/s | 107,525 rec/s |
at_ingestion | 104,166 rec/s | 102,564 rec/s |
| 开销 | -4.7% | -4.6% |
查询端开销
以下 benchmark 用于比较上一版和当前版本。
| ID | Query | Cur-Base | Cur-AS | Δ | 上一版-Base | 上一版-AS | Δ |
|---|---|---|---|---|---|---|---|
| q0 | Single trace_id lookup - COUNT | 9 | 25 | +178% | 25 | 9 | -64% |
| q0__rows | Single trace_id lookup - SELECT * | 38 | 29 | -24% | 31 | 6 | -81% |
| q1 | Single span_id lookup - COUNT | 35 | 23 | -34% | 25 | 28 | +12% |
| q1__rows | Single span_id lookup - SELECT * | 30 | 22 | -27% | 15 | 27 | +80% |
| q2 | Rare whole-token lookup - COUNT | 20 | 37 | +85% | 15 | 30 | +100% |
| q2__rows | Rare whole-token lookup - SELECT * | 34 | 31 | -9% | 27 | 13 | -52% |
| q3 | Common token search - COUNT | 28 | 31 | +11% | 27 | 19 | -30% |
| q3__rows | Common token search - SELECT * | 168 | 169 | +1% | 180 | 173 | -4% |
| q4 | Compound filter - COUNT | 35 | 17 | -51% | 27 | 28 | +4% |
| q4__rows | Compound filter - SELECT * | 18 | 31 | +72% | 26 | 26 | 0% |
| q5 | Filter + token - COUNT | 32 | 32 | 0% | 31 | 7 | -77% |
| q5__rows | Filter + token - SELECT * | 14 | 31 | +121% | 33 | 19 | -42% |
| q6 | Double token - COUNT | 29 | 35 | +21% | 17 | 16 | -6% |
| q6__rows | Double token - SELECT * | 41 | 39 | -5% | 28 | 8 | -71% |
| q7 | High-cardinality (pod_name) - COUNT | 34 | 36 | +6% | 34 | 23 | -32% |
| q7__rows | High-cardinality (pod_name) - SELECT * LIMIT 100 | 732 | 711 | -3% | 792 | 1,448 | +83% |
| q8 | Histogram(1h buckets) | 46 | 48 | +4% | 46 | 50 | +9% |
| q9 | Top-N namespaces | 48 | 56 | +17% | 48 | 60 | +25% |
| q10 | Filtered histogram | 49 | 49 | 0% | 47 | 19 | -60% |
从查询结果看,当前版本并不是所有场景都更快。例如,q0 的 COUNT 查询增加了 178%,q5_rows 增加了 121%;但高基数字段查询 q7_rows 的额外开销,则从上一版的 83% 降至当前版本的 -3%。
这版实现主要优化了查询端的字段血缘追溯:多段查询中的重复计算被统一缓存,同时也完善了一些边界场景。
至少在目前的测试用例中,查询端的性能波动可能大于脱敏实现本身的性能开销。
最后
关于脱敏实现中遇到的问题,基本算是分享完了。由于代码属于闭源产物,这里不会放出大量源码,主要还是分享一些思路。
山高路远,我们下次再会。

评论区
加载更多