六月初回杭州后,我一边看看感兴趣的东西,一边做些兼职。全职工作需要同时考虑岗位、方向、工作地点、薪资和双方意愿。一路筛选下来,真正能深入聊的机会并不多,所以目前还没有找到特别合适的全职工作。
这段时间参与的兼职项目,是基于 OpenObserve 的闭源二次开发,主要涉及国内软件适配和企业级功能建设。OpenObserve 是一个开源日志平台,对标 Elasticsearch 等传统方案,主要强调性能、数据压缩率和部署便利性。项目的大部分代码已经开源,少部分功能依赖闭源模块。
最近,我在项目中重新实现了一部分日志脱敏功能。其中,两阶段扫描的性能优化,以及 BlockScanner 缓存涉及的生命周期和 ABA 问题,比较值得展开记录。
从写入前脱敏到查询前脱敏
这个功能在几个月前已经实现过一版。最近需求发生变化,我们重新对齐了脱敏的生效位置,并通过基准测试评估不同方案的性能开销。
早期设计主要遵循以下原则:
- 在写入入口统一进行脱敏,落盘数据和索引中都不保留脱敏前的数据。
- 查询出口不再重复检查,始终返回已经脱敏的数据。
- 脱敏采用可恢复模式;需要返回原始数据时,通过二次查询获取,以减少对常规查询性能的影响。
这套方案从调研、设计到落地都由我负责。设计过程中,我也参考了一些开源实现,例如字节跳动的 godlp。其中通过二次 context 确认匹配结果的设计,给了我不少启发。
这次需求调整后,用户可以选择三个脱敏位置:
- 写入落盘前;
- 查询返回前;
- 写入和查询两个阶段都执行。
这意味着系统允许原始数据落盘,再在查询阶段根据规则进行脱敏。与写入前统一处理相比,查询前脱敏会带来更直接的性能压力:每次查询返回的数据都可能需要扫描规则,这部分开销无法完全消除,只能尽可能压低。
用两阶段扫描降低匹配成本
在这套架构中,业务应用通常会先对日志做一层脱敏,日志平台负责最后兜底。基于这一前提,我们暂时采用了一个业务假设:95% 以上的日志不需要真正执行替换,只需要经过规则扫描。
因此,我引入了 vectorscan。它是 Hyperscan 的 Rust 绑定,可以把正则和字面量规则编译成一个规则集合,再对输入进行统一扫描。与逐条执行正则相比,这种方式更适合承担第一阶段的快速过滤。
整个匹配过程分为两层:
- 使用
vectorscan判断日志是否可能命中任意规则。 - 对候选日志使用
regex和aho_corasick做精确匹配和替换。
第一阶段只负责筛出候选数据。在这一层,我们可以接受假阳性,但不能接受假阴性,也不依赖它返回精确的 span。确认存在候选匹配后,再交给第二阶段定位具体内容。
这里还有一个需要注意的边界:vectorscan 和 regex 对 ASCII、Unicode 以及部分正则语法的支持并不完全一致。同一条规则在两个引擎中可能产生不同的 span,也可能无法同时编译。因此,规则进入系统时,需要验证它能否在两套匹配逻辑中保持一致。
BlockScanner 缓存
vectorscan 对 BlockDatabase 和 BlockScanner 的依赖关系做了明确的生命周期约束:
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,再通过 unsafe 将 BlockScanner 的生命周期转换为 'static,同时保存一个指向 database 的 Weak。
使用缓存前,需要完成两项检查:
- 调用
Weak::upgrade()。如果升级失败,说明原database已经释放,缓存项不能继续使用。 - 升级成功后,通过
Arc::ptr_eq确认当前database与scanner对应的是同一个对象。
只有两项检查都通过,才算缓存命中。否则,需要基于当前 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 则非常接近这组样本中的最大值。
写入端开销
| Metric | Baseline R1 | at_ingestion R3 | Δ |
|---|---|---|---|
| Records | 10,000,000 | 10,000,000 | — |
| Elapsed | 91.50 s | 96.00 s | +4.9% |
| Throughput | 109,288 rec/s | 104,166 rec/s | −4.7% |
| Data rate | 233.1 MB/s | 222.2 MB/s | −4.7% |
| Failed | 0 | 0 | — |
写入端启用脱敏后,总耗时从 91.5 秒增加到 96 秒,吞吐量下降约 4.7%。在当前业务场景中,这个开销处于可以接受的范围。
写入期脱敏还带来了额外的存储收益:相同记录数下,Parquet 体积减少 22.4%,倒排索引体积减少 31.8%,总磁盘占用从 6350 MiB 降至 4682 MiB,减少 26.3%。原因是原日志中的十六进制标识具有较高信息熵,替换为重复的 *** 后,数据和索引都更容易压缩。
查询端开销
| Query | R1 p50 (ms) | R2 p50 (ms) | Δ p50 | R1 p99 (ms) | R2 p99 (ms) | Δ p99 |
|---|---|---|---|---|---|---|
q0 — trace_id COUNT | 25 | 9 | −64% | 125.8 | 58.0 | −54% |
q0 — trace_id SELECT * LIMIT 100 | 31 | 6 | −81% | 38.8 | 59.8 | +54% |
q1 — span_id COUNT | 25 | 28 | +12% | 28.8 | 30.0 | +4% |
q1 — span_id SELECT * LIMIT 100 | 15 | 27 | +80% | 20.8 | 28.0 | +35% |
| q2 — rare token COUNT | 15 | 30 | +100% | 47.5 | 32.0 | −33% |
q2 — rare token SELECT * LIMIT 100 | 27 | 13 | −52% | 30.9 | 50.5 | +63% |
| q3 — common token COUNT | 27 | 19 | −30% | 43.4 | 35.4 | −18% |
q3 — common token SELECT * LIMIT 100 | 180 | 173 | −4% | 299.2 | 293.1 | −2% |
| q4 — compound filter COUNT | 27 | 28 | +4% | 39.6 | 31.9 | −19% |
q4 — compound filter SELECT * LIMIT 100 | 26 | 26 | 0% | 28.9 | 28.9 | 0% |
| q5 — filter + token COUNT | 31 | 7 | −77% | 54.4 | 8.0 | −85% |
q5 — filter + token SELECT * LIMIT 100 | 33 | 19 | −42% | 50.3 | 25.8 | −49% |
| q6 — double token COUNT | 17 | 16 | −6% | 21.0 | 33.6 | +60% |
q6 — double token SELECT * LIMIT 100 | 28 | 8 | −71% | 32.8 | 10.9 | −67% |
| q7 — high-cardinality COUNT | 34 | 23 | −32% | 39.8 | 31.9 | −20% |
q7 — high-cardinality SELECT * LIMIT 100 | 792 | 1,448 | +83% | 865.8 | 1,717 | +98% |
| q8 — histogram | 46 | 50 | +9% | 67.5 | 63.5 | −6% |
| q9 — top-N namespaces | 48 | 60 | +25% | 92.2 | 99.6 | +8% |
| q10 — filtered histogram | 47 | 19 | −60% | 50.0 | 24.9 | −50% |
查询数据中的正负变化都比较大,说明当前测试结果存在明显抖动。R1 和 R2 都在每个查询变体开始前清理了操作系统页缓存,但 OpenObserve 内部组件以及对象存储层的缓存仍可能跨轮次逐渐预热。再加上每组只有 5 次采样,启用脱敏后变快的测试项不能直接解释为脱敏带来了性能提升。
q7 的高基数 SELECT * LIMIT 100 是明显的例外,而且 5 次采样的退化方向一致。该查询会返回 100 条完整记录,每条记录的 message 都需要在查询阶段执行正则脱敏。它的 p50 增加 83%,p99 增加 98%,接近翻倍。相比之下,只返回聚合结果的 COUNT、histogram 和 top-N 查询没有表现出系统性开销。
查询端主要有两部分额外开销:
- 返回的每条数据都需要扫描脱敏规则。
- SQL 解析阶段需要进行类似字段血缘追溯的处理,把原始
field、alias和最终返回给用户的字段名关联起来,才能确定哪些字段需要应用脱敏规则。
因此,目前的数据可以支持两个阶段性判断:写入端约 5% 的性能开销可以接受,并且能显著降低存储占用;查询端方案对聚合查询影响有限,但返回完整行时,开销会随返回记录数和规则复杂度增加,仍需结合实际 SLO 继续评估。
最后
接到这个需求前,我没有做过日志脱敏相关的功能。很多知识都是在推进过程中临时调研,再逐步完成方案论证和落地。
这种完整参与需求调研、方案设计、实现和性能验证的开发体验,还是挺不错的。中间少不了需求协商和一致性对齐,也经历了从裸地址缓存到发现 ABA 风险、再改用 Weak 的过程。

评论区
加载更多