藏川线前段

--- 摄于 2017 年 9 月 藏川线前段

上一篇提到,项目引入了 vectorscanregex 两个正则库。由于两者在字符集和语法支持范围上存在差异,它们的能力只有部分交集,不能构成严格的包含关系。

在当前架构中,vectorscan 负责预筛选,regex 负责最终匹配。这里有一条必须满足的正确性约束:vectorscan 的匹配集合必须是 regex 的超集。

换句话说,预筛选阶段可以产生假阳性,但绝不能漏掉 regex 能够匹配的内容。假阳性只会增加后续处理成本,假阴性则可能导致漏脱敏,甚至引发敏感数据泄漏。

第一版方案:严格筛选

vectorscan 吞吐量较高,主要是因为它使用 ASCII 字符集,字符长度固定,天然适合通过 SIMD 进行并行处理。

最初,我对正则表达式进行了严格筛选,只有当 vectorscanregex 的语义行为一致时,才让它进入预筛选阶段。不一致的表达式则跳过预筛选,直接交给 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 接受一个包含原始匹配集合的表达式。

为了确认宽化不是凭直觉进行的,我们把测试拆成三类。

第一类是原始实现差异测试:对同一条原始正则,分别交给原始 vectorscanregex 编译并执行,记录编译失败、语法不支持以及匹配结果差异。这个测试的目标是找出两套实现的兼容性边界,而不是证明宽化安全。

第二类是超集关系测试:对每条可以宽化的表达式生成宽化版本,再使用同一批测试输入,分别执行原始 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 classWIDENvectorscan 不接受 \p{…},放宽为 . + DOTALL
非 ASCII 字面量,如 éWIDEN将 Rust 的 \u{NNNN} 转换为 vectorscan 可接受的 \x{NNNN}(PCRE)
包含可宽化项的 bracket classWIDEN整个字符类放宽为 . + DOTALL
集合运算 [a&&b][a--b][a~~b]WIDEN取左侧操作数作为安全超集
开头的 (?i) 前缀WIDEN移除前缀并添加 CASELESS;将 k/Ks/Sa/Aµ 等可能产生语义差异的字符放宽为 . + DOTALL
(?i:…) 作用域分组WIDEN移除分组包装并全局添加 CASELESS;将 k/Ks/Sa/Aµ 等可能产生语义差异的字符放宽为 . + DOTALL

经过宽化处理后,在原先被拒绝的默认规则中,有 95% 可以重新被接受并进入加速队列。

坦率地说,如果没有 AI 辅助,要准确处理这些正则语义差异,凭借我一个人的能力,至少需要一周时间查阅资料,再花一周时间编写测试。中间还可能因为字符集问题走弯路,最后才能得到相对完整的解决方案。

Benchmark 结果

同时,我们也优化了整体实现,主要是改进查询端的字段血缘追溯,并统一缓存多段查询中的重复计算。

写入端开销

指标上一版当前版本
Baseline109,288 rec/s107,525 rec/s
at_ingestion104,166 rec/s102,564 rec/s
开销-4.7%-4.6%

查询端开销

以下 benchmark 用于比较上一版和当前版本。

IDQueryCur-BaseCur-ASΔ上一版-Base上一版-ASΔ
q0Single trace_id lookup - COUNT925+178%259-64%
q0__rowsSingle trace_id lookup - SELECT *3829-24%316-81%
q1Single span_id lookup - COUNT3523-34%2528+12%
q1__rowsSingle span_id lookup - SELECT *3022-27%1527+80%
q2Rare whole-token lookup - COUNT2037+85%1530+100%
q2__rowsRare whole-token lookup - SELECT *3431-9%2713-52%
q3Common token search - COUNT2831+11%2719-30%
q3__rowsCommon token search - SELECT *168169+1%180173-4%
q4Compound filter - COUNT3517-51%2728+4%
q4__rowsCompound filter - SELECT *1831+72%26260%
q5Filter + token - COUNT32320%317-77%
q5__rowsFilter + token - SELECT *1431+121%3319-42%
q6Double token - COUNT2935+21%1716-6%
q6__rowsDouble token - SELECT *4139-5%288-71%
q7High-cardinality (pod_name) - COUNT3436+6%3423-32%
q7__rowsHigh-cardinality (pod_name) - SELECT * LIMIT 100732711-3%7921,448+83%
q8Histogram(1h buckets)4648+4%4650+9%
q9Top-N namespaces4856+17%4860+25%
q10Filtered histogram49490%4719-60%

从查询结果看,当前版本并不是所有场景都更快。例如,q0 的 COUNT 查询增加了 178%,q5_rows 增加了 121%;但高基数字段查询 q7_rows 的额外开销,则从上一版的 83% 降至当前版本的 -3%。

这版实现主要优化了查询端的字段血缘追溯:多段查询中的重复计算被统一缓存,同时也完善了一些边界场景。

至少在目前的测试用例中,查询端的性能波动可能大于脱敏实现本身的性能开销。

最后

关于脱敏实现中遇到的问题,基本算是分享完了。由于代码属于闭源产物,这里不会放出大量源码,主要还是分享一些思路。

山高路远,我们下次再会。

评论区

加载更多

登录后评论