藏川线前段

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

六月初回杭州后,我一边看看感兴趣的东西,一边做些兼职。全职工作需要同时考虑岗位、方向、工作地点、薪资和双方意愿。一路筛选下来,真正能深入聊的机会并不多,所以目前还没有找到特别合适的全职工作。

这段时间参与的兼职项目,是基于 OpenObserve 的闭源二次开发,主要涉及国内软件适配和企业级功能建设。OpenObserve 是一个开源日志平台,对标 Elasticsearch 等传统方案,主要强调性能、数据压缩率和部署便利性。项目的大部分代码已经开源,少部分功能依赖闭源模块。

最近,我在项目中重新实现了一部分日志脱敏功能。其中,两阶段扫描的性能优化,以及 BlockScanner 缓存涉及的生命周期和 ABA 问题,比较值得展开记录。

从写入前脱敏到查询前脱敏

这个功能在几个月前已经实现过一版。最近需求发生变化,我们重新对齐了脱敏的生效位置,并通过基准测试评估不同方案的性能开销。

早期设计主要遵循以下原则:

  • 在写入入口统一进行脱敏,落盘数据和索引中都不保留脱敏前的数据。
  • 查询出口不再重复检查,始终返回已经脱敏的数据。
  • 脱敏采用可恢复模式;需要返回原始数据时,通过二次查询获取,以减少对常规查询性能的影响。

这套方案从调研、设计到落地都由我负责。设计过程中,我也参考了一些开源实现,例如字节跳动的 godlp。其中通过二次 context 确认匹配结果的设计,给了我不少启发。

这次需求调整后,用户可以选择三个脱敏位置:

  • 写入落盘前;
  • 查询返回前;
  • 写入和查询两个阶段都执行。

这意味着系统允许原始数据落盘,再在查询阶段根据规则进行脱敏。与写入前统一处理相比,查询前脱敏会带来更直接的性能压力:每次查询返回的数据都可能需要扫描规则,这部分开销无法完全消除,只能尽可能压低。

用两阶段扫描降低匹配成本

在这套架构中,业务应用通常会先对日志做一层脱敏,日志平台负责最后兜底。基于这一前提,我们暂时采用了一个业务假设:95% 以上的日志不需要真正执行替换,只需要经过规则扫描。

因此,我引入了 vectorscan。它是 Hyperscan 的 Rust 绑定,可以把正则和字面量规则编译成一个规则集合,再对输入进行统一扫描。与逐条执行正则相比,这种方式更适合承担第一阶段的快速过滤。

整个匹配过程分为两层:

  1. 使用 vectorscan 判断日志是否可能命中任意规则。
  2. 对候选日志使用 regexaho_corasick 做精确匹配和替换。

第一阶段只负责筛出候选数据。在这一层,我们可以接受假阳性,但不能接受假阴性,也不依赖它返回精确的 span。确认存在候选匹配后,再交给第二阶段定位具体内容。

这里还有一个需要注意的边界:vectorscanregex 对 ASCII、Unicode 以及部分正则语法的支持并不完全一致。同一条规则在两个引擎中可能产生不同的 span,也可能无法同时编译。因此,规则进入系统时,需要验证它能否在两套匹配逻辑中保持一致。

BlockScanner 缓存

vectorscanBlockDatabaseBlockScanner 的依赖关系做了明确的生命周期约束:

pub unsafe type Database: Send + Sync {
    type CType = hs::hs_database_t;
    fn drop = database_drop;
}

pub struct BlockDatabase {
    inner: wrapper::Database,
}

pub struct BlockScanner<'db> {
    scratch: wrapper::Scratch,
    db: &'db BlockDatabase,
}

impl Scratch {
    pub fn new(database: &Database) -> Result<Self, Error> {
        let mut scratch = MaybeUninit::zeroed();
        unsafe {
            hs::hs_alloc_scratch(database.as_ptr(), scratch.as_mut_ptr())
                .ok()
                .map(|()| Scratch::from_ptr(scratch.assume_init()))
        }
    }
}

从类型定义可以看到,BlockScanner<'db> 的有效期不能超过它引用的 BlockDatabase。这个约束本身没有问题,但如果要跨多次扫描复用 BlockScanner,生命周期就会让缓存很难表达。

最终实现使用了线程本地缓存,并分别保存写入端和查询端使用的 scanner

thread_local! {
    static INGESTION_SCRATCH: Cell<Vec<ScratchCache>> = const { Cell::new(Vec::new()) };
    static SEARCH_SCRATCH:    Cell<Vec<ScratchCache>> = const { Cell::new(Vec::new()) };
}

struct ScratchCache {
    /// Weak reference to the `VectorScanDatabase` whose patterns are compiled into `scanner`.
    /// `upgrade()` returns `None` once all strong Arcs are dropped, making stale entries
    /// detectable before the underlying memory could ever be reused by a new allocation.
    db: Weak<VectorScanDatabase>,
    // SAFETY: the real lifetime is tied to the `VectorScanDatabase` held weakly above.
    // We only hand out the scanner when `db.upgrade()` succeeds AND Arc::ptr_eq confirms
    // identity, so the underlying `BlockDatabase` is guaranteed live for the scan's duration.
    scanner: BlockScanner<'static>,
}

创建缓存时,先把 database 放进 Arc,再通过 unsafeBlockScanner 的生命周期转换为 'static,同时保存一个指向 databaseWeak

使用缓存前,需要完成两项检查:

  1. 调用 Weak::upgrade()。如果升级失败,说明原 database 已经释放,缓存项不能继续使用。
  2. 升级成功后,通过 Arc::ptr_eq 确认当前 databasescanner 对应的是同一个对象。

只有两项检查都通过,才算缓存命中。否则,需要基于当前 database 重新创建 scanner

这里真正需要守住的安全条件是:Weak::upgrade() 得到的强引用必须一直保留到本次扫描结束。只有这样,scanner 使用期间底层的 BlockDatabase 才不会被释放。这是把 BlockScanner 视为 'static 的关键运行时约束。

从地址比较到 Weak:一个隐蔽的 ABA 问题

这个设计并不是一次完成的。早期缓存使用过更直接的结构:

struct ScratchCache {
    ptr: usize,
    scanner: BlockScanner<'static>,
}

它把 database 的指针转换成 usize 存进缓存,使用前再比较地址。这个方案看上去可行,在常规测试中也能正常运行,但它存在一个隐蔽的 ABA 问题。

database 被释放后,内存分配器可能把相同的地址分配给一个新的 database。此时,单纯比较 usize 会误以为两次看到的是同一个对象,但新的 database 可能已经编译了完全不同的规则。继续使用旧 scanner,不仅可能造成规则错配,还可能触发未定义行为。

一种解决方式是自行维护版本号,让对象身份由“地址 + 版本”共同决定。这里选择了 Arc/Weak:旧对象的强引用全部释放后,原来的 Weak 无法再升级;它也不会因为另一个对象使用了相似的地址,就自动指向新对象。再配合 Arc::ptr_eq 校验身份,可以避免单纯比较裸地址带来的 ABA 风险。

性能与存储测试

实现完成后,我对写入端和查询端分别做了基准测试。测试用例和数据生成器来自 openobserve-clickhouse-benchmark,本次在它的 OpenObserve 测试流程上增加了脱敏策略对照。

机器使用 13th Gen Intel Core i7-13700K,共 24 核、62.6 GiB 内存,操作系统为 Linux 7.1.4-arch1-1。数据集包含 1000 万条合成 Kubernetes 日志,固定随机种子为 13303278,batch size 为 8000,并发度为 6。

三轮测试分别对应:

  • R1:k8s_logs,不配置脱敏策略,作为 baseline。
  • R2:在同一份 k8s_logs 数据上启用 at_search,测试查询期脱敏开销。
  • R3:向 k8s_logs_redacted 写入相同规模的数据,并启用 at_ingestion,测试写入期脱敏开销和存储变化。

两种脱敏模式使用同一条规则:在 message 字段中匹配 [0-9a-f]{32}|[0-9a-f]{16},并将匹配内容替换为 ***。每条日志中都有类似下面的内容:

trace_id=b48ed38baa61593a833dda3db11c0ff2 span_id=43dd7e679e6c0bc7 x_request_id=aebff4b4e34582065e94cae271bc1ab2

匹配并脱敏后,内容会变成:

trace_id=*** span_id=*** x_request_id=***

每个查询变体开始前,测试脚本都会执行下面的命令同步数据并清理系统页缓存:

sync && sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'

随后,同一个查询连续执行 5 次:第一次是冷缓存,后 4 次是页缓存已经预热后的结果。表格中的 p50 和 p99 都基于这 5 次采样,因此更适合用来观察方向,而不能当作稳定的生产尾延迟指标。其中,p50 主要反映热缓存结果,p99 则非常接近这组样本中的最大值。

写入端开销

MetricBaseline R1at_ingestion R3Δ
Records10,000,00010,000,000
Elapsed91.50 s96.00 s+4.9%
Throughput109,288 rec/s104,166 rec/s−4.7%
Data rate233.1 MB/s222.2 MB/s−4.7%
Failed00

写入端启用脱敏后,总耗时从 91.5 秒增加到 96 秒,吞吐量下降约 4.7%。在当前业务场景中,这个开销处于可以接受的范围。

写入期脱敏还带来了额外的存储收益:相同记录数下,Parquet 体积减少 22.4%,倒排索引体积减少 31.8%,总磁盘占用从 6350 MiB 降至 4682 MiB,减少 26.3%。原因是原日志中的十六进制标识具有较高信息熵,替换为重复的 *** 后,数据和索引都更容易压缩。

查询端开销

QueryR1 p50 (ms)R2 p50 (ms)Δ p50R1 p99 (ms)R2 p99 (ms)Δ p99
q0 — trace_id COUNT259−64%125.858.0−54%
q0 — trace_id SELECT * LIMIT 100316−81%38.859.8+54%
q1 — span_id COUNT2528+12%28.830.0+4%
q1 — span_id SELECT * LIMIT 1001527+80%20.828.0+35%
q2 — rare token COUNT1530+100%47.532.0−33%
q2 — rare token SELECT * LIMIT 1002713−52%30.950.5+63%
q3 — common token COUNT2719−30%43.435.4−18%
q3 — common token SELECT * LIMIT 100180173−4%299.2293.1−2%
q4 — compound filter COUNT2728+4%39.631.9−19%
q4 — compound filter SELECT * LIMIT 10026260%28.928.90%
q5 — filter + token COUNT317−77%54.48.0−85%
q5 — filter + token SELECT * LIMIT 1003319−42%50.325.8−49%
q6 — double token COUNT1716−6%21.033.6+60%
q6 — double token SELECT * LIMIT 100288−71%32.810.9−67%
q7 — high-cardinality COUNT3423−32%39.831.9−20%
q7 — high-cardinality SELECT * LIMIT 1007921,448+83%865.81,717+98%
q8 — histogram4650+9%67.563.5−6%
q9 — top-N namespaces4860+25%92.299.6+8%
q10 — filtered histogram4719−60%50.024.9−50%

查询数据中的正负变化都比较大,说明当前测试结果存在明显抖动。R1 和 R2 都在每个查询变体开始前清理了操作系统页缓存,但 OpenObserve 内部组件以及对象存储层的缓存仍可能跨轮次逐渐预热。再加上每组只有 5 次采样,启用脱敏后变快的测试项不能直接解释为脱敏带来了性能提升。

q7 的高基数 SELECT * LIMIT 100 是明显的例外,而且 5 次采样的退化方向一致。该查询会返回 100 条完整记录,每条记录的 message 都需要在查询阶段执行正则脱敏。它的 p50 增加 83%,p99 增加 98%,接近翻倍。相比之下,只返回聚合结果的 COUNT、histogram 和 top-N 查询没有表现出系统性开销。

查询端主要有两部分额外开销:

  1. 返回的每条数据都需要扫描脱敏规则。
  2. SQL 解析阶段需要进行类似字段血缘追溯的处理,把原始 fieldalias 和最终返回给用户的字段名关联起来,才能确定哪些字段需要应用脱敏规则。

因此,目前的数据可以支持两个阶段性判断:写入端约 5% 的性能开销可以接受,并且能显著降低存储占用;查询端方案对聚合查询影响有限,但返回完整行时,开销会随返回记录数和规则复杂度增加,仍需结合实际 SLO 继续评估。

最后

接到这个需求前,我没有做过日志脱敏相关的功能。很多知识都是在推进过程中临时调研,再逐步完成方案论证和落地。

这种完整参与需求调研、方案设计、实现和性能验证的开发体验,还是挺不错的。中间少不了需求协商和一致性对齐,也经历了从裸地址缓存到发现 ABA 风险、再改用 Weak 的过程。

评论区

加载更多

登录后评论