一个 CT 证书搜索引擎的架构:Rust + LSM + 24GB mmap Bloom

CT Search Engine 持续从八九条主流证书透明度日志(Google Argon/Xenon、Cloudflare Nimbus、DigiCert Wyvern/Sphinx、Sectigo Elephant、TrustAsia 等)摄取证书,单机 32GB 内存的盒子上承载 15 亿+ 条证书的子域前缀搜索。下面把整套架构按数据流自上而下讲一遍。

整体架构

三个进程,端口分开:

客户端 → SaaS Proxy (:3000) / Web UI (:3001) → Rust Engine (:8081, 仅 localhost)

Rust 引擎只绑 127.0.0.1,所有入向请求必须带 X-Internal-Secret。src/main.rs:37-40 的 INTERNAL_SECRET 是 LazyLock<String>,启动时环境变量缺失直接 panic(fail-closed),中间件 src/main.rs:62-77 比对失败一律 401 加计数。外层 Web UI(Next.js 14)和 SaaS Proxy(66 行 Node 脚本)都先 delete req.headers['x-internal-secret'],再注入可信值——header smuggling 防御是免费午餐,不写就等着被人伪造内部头直连引擎。

存储层:单 SkipMap 复合键

src/storage/memtable.rs 的 MemTable 是一个 SkipMap<String, Arc<CertificateRecord>>,key 是 {reversed_domain}\0{id} 文本拼接。一张证书展成 N 条记录(每个 SAN 一条,共享 id、domain 各异),按 (reversed_domain, id) 唯一性自然容纳,不会因为 last-write-wins 把 N-1 个 SAN 字段丢掉。

WAL 格式极简:每条记录前 2 字节 little-endian u16 长度前缀,后面是 serialize() 字节流。重启 with_wal() 顺序解析回放(memtable.rs:51-69),断电只丢未 fsync 的尾部。flush_threshold = 200_000(src/main.rs:276)触发 flush——MemTable 满 20 万条就 swap 出新表,旧表 spawn_blocking 后台落盘。20 万是个折衷数字:满速摄取时 16 条 CT 日志总共能跑到三十多万条/秒,再大单次 flush 时长会盖过摄取节奏。

倒序域名 + FST 索引

域名按倒序存(com.example.www),SkipMap 字典序就把同根域的子域名聚在一起。子域前缀扫是 O(命中长度),不需要额外索引结构。

边界细节:子域名扫描必须做两段独立 bounded range,不能 take_while。\0 是 0x00、. 是 0x2E,两段 key 在字典序上不相邻,任何首字节落在 (0x00, 0x2E) 区间的 sibling reversed-domain 都能把扫描提前截断(imfht-x.com 反向 com.imfht-x,- 是 0x2D,正好坐在中间,会让 blog.imfht.com 一条不返回)。scan_domain_prefix 写成两次 SkipMap range:[prefix+"\0", prefix+"\x01") 抓精确匹配,[prefix+".", prefix+"/")('.'+1 == '/')抓子域。文件末尾 sibling_does_not_truncate_subdomain_scan 测试守门防回归。

落盘段对应的 FST 用 fst::MapBuilder 直接消费同样的 {reversed_domain}\0{id} key,因为 SkipMap 已字典序,省一次 sort。查询时 fst::automaton::Str::new(reversed_prefix).starts_with() 一次扫完(src/storage/index/fst_index.rs:43)。FST 文件 mmap 后配 Advice::Random(同文件第 36 行):FST 是按字节随机跳转的索引,内核默认 read-ahead 是纯浪费,关掉之后只载真正访问的 4KB 页,大段 FST 不会撑爆 page cache。

24GB mmap Bloom

CT 摄取必须做去重,(fingerprint, domain) 元组规模 200 亿级,1% FP 的 Bloom 算下来要 24GB 比特位。src/storage/mmap_bloom.rs 把这个数据结构当文件用:

  • 文件结构 = 4096 字节 header(magic、version、bit_count、hash_count、双 sip-hash seed)+ 原始 bitmap 字节,bitmap 区按 4KB 对齐。
  • sparse file:set_len(total_size) 但只有真正写入的页才占盘。
  • MAP_SHARED 让 OS page cache 接管热度判断。working set 经验值在几百 MB——大多数比特位是冷的,OS 把它们挪出物理内存。从应用视角看 bloom.bin 是 24GB,但实际 RSS 贡献远低于这个数;FST mmap 仍能拿到充分的 page cache。
  • 并发 set 完全无锁:bitmap_atomics() 把 &[u8] 强转 &[AtomicU8],每个比特位通过 fetch_or(1u8 << bit_idx, Relaxed) 原子翻起。AtomicU8 与 u8 内存布局兼容,强转安全。
  • 多哈希用 Kirsch–Mitzenmacher 双哈希,单次 sip 派生 (h1, h2),再 (h1 + i*h2) % bit_count 取 k 个位置。

配套是 systemd cgroup 约束(ct-search.service):MemoryHigh=24G 触发 v2 throttle 提前给摄取背压,MemoryMax=26G 是硬 OOM 线。MemorySwapMax 故意保持默认(允许换出):bloom 必须能换出去,OS 才能把 page cache 留给 FST mmap。

查询路径:ArcSwap + 段元数据双层剪枝

src/storage/query_executor.rs:34 的段集合是 ArcSwap<Vec<Arc<DiskSegment>>>。读路径 segments.load_full() 拿 Arc 快照,零锁;写路径 swap_segments 走 RCU:移除一批旧 path、追加一批新段,segments.rcu(|cur| ...) 一次原子替换,O(k)。在飞 query 仍持着旧 Arc<DiskSegment>,对应 mmap 不会立刻 unmap;Linux 上对已 mmap 文件做 unlink() 是安全的,所以 compactor 紧接着删旧文件也不影响在路上的读。

每个 DiskSegment 旁挂一个 .meta 文件(src/storage/segment_meta.rs,bincode 序列化,v3 格式)。当前结构存 min/max key + tld|sld 组合 Bloom(FP 0.1%)。Query 进来按 reversed prefix 做两层剪枝:

  1. min/max 字节比较:query 的 reversed prefix 与段的 min/max key 切片比,越界直接砍。便宜,但对 com.* 这种集中分布 key 几乎无效。
  2. SLD bloom:从 reversed prefix 提取 {tld}|{sld} 标记(如 com|github),bloom 不含就跳过这个段。组合 key 比单 SLD 精准——避免 io.github.user 段被误命中 github.com 查询。

DiskSegment.meta 是 ArcSwapOption<SegmentMeta>:少数老段可能没生成 .meta,启动时为 None,后台任务异步补,写入也是 meta.store(...) 一次原子置换,query 读完全无锁。新段在 compactor 合成的同一瞬间生成 .meta(FST 已经在内存,~10ms),段一上架立刻享受 prefix-pruning。

整段查询路径加 500ms 硬超时,每 256 条记录才查一次时钟避免热路径上的 syscall。

摄取流水线

src/main.rs:141-150 给每条 CT 日志单独算 max_concurrency:DigiCert/Sectigo 限速很严(~5 req/s)上 2;TrustAsia 没公开限速保守 3;Cloudflare Nimbus 4;Google Argon 服务端友好直接 6。8 条日志在飞峰值 ~44 个请求、~400MB 响应体。max_concurrency < 5 时插 50ms 间隔避免 burst 触发 ban。

src/ingestion/ct_client.rs:68-71 检查响应 content_length,超过 500MB 直接 reject,防止恶意/坏掉的 CT 日志拖崩内存。fetch_entries 解 base64、解 X.509 都用 Rayon par_iter 并行(同文件 97 行)。计算 (fingerprint, domain) 哈希也提前到 bloom 写入之前(115-121 行),整段 bloom 操作无锁。

src/ingestion/worker.rs 解 X.509 取 SAN,关键防御在 12 行:MAX_SANS_PER_CERT = 1000。世面上有故意造的退化证书塞几万个 DNS 名,截断到 1000 之后通过原子计数器记录"被截断的证书数",每 100 张打一次告警,避免日志刷屏。

回到主 loop,背压由三个信号合成(src/main.rs:161-163):memtable 超过 100 万条、磁盘占用 >85%、bloom 饱和度 >95%。任何一条触发就停发新批次,等 flush 把 memtable 清空。bloom 饱和后 FP 率会陡升,dedup 失效,继续吸入只是浪费 IO。

记录 ID 编码 64 位:高 8 位是日志源 ID,低 56 位是该日志内的偏移(src/main.rs:197-198)。一个 u64 同时承载"哪条日志"和"哪条记录",省一张关联表。

月分区与 compactor 字节预算

段按"摄入当月"分区到 data/{YYYY-MM}/(memtable.rs:155),不按证书的 not_before。一次 flush 写一个文件、一个 FST、一次 fsync。单次 flush 时长在秒级。

src/storage/compactor.rs 跑 30 秒 cycle(49 行)。普通批 6 段,月内段数 ≥16 切到 surge 模式跑 12 段。单段 >2GB 视为已固化,跳过参与合并,留给未来 leveled compactor 的流式 K-way merge 处理。单批输入字节封顶 4GB(29 行):compactor 把整批反序列化进 Vec<CertificateRecord>,4GB on-disk 大概对应 6-8GB anon RAM 峰值,留 ~10GB 给 page cache。跨月用 tokio::sync::Semaphore 限并发到 3,避免 N 个月同时合并把 cgroup 撞爆。

dedup 在 compactor 端做,不在 query 端:一次合并把整批读进内存,按 (domain, fingerprint) 排序去重再回写(compactor.rs:210-216)。读盘用 mmap + Advice::Sequential,扫完立刻 MADV_DONTNEED 把页释放,不污染 page cache。合并完直接 QueryExecutor::open_segment(...) 拿到新 Arc<DiskSegment>,swap_segments(old_paths, vec![new_seg]) 一次原子替换。

序列化与字典编码

src/models/record.rs:22-46 的 CertificateRecord 序列化是手写的。固定 48 字节 header(u64 id + u32 not_before + u32 not_after + 32 字节 SHA256 指纹),加上长度前缀的 domain、organization 字符串(各封顶 255 字节),最后是 issuer 的 u16 字典 ID。

CA issuer 字符串平均 80-150 字节、全网真实 issuer 数量 ~2000。直接存原串 50 亿条记录就额外多 ~500GB;走全局 ISSUER_DICT 字符串驻留 + u16 ID(src/models/issuer_dict.rs,双端持久化到 issuer_dict.json)每条记录省 50+ 字节,整盘小一个数量级。

I/O 与运行时

段读用 memmap2:NUMA 友好、错误处理简单、内核 page cache 共享给所有读者,命中率比应用层 BlockCache 高得多。src/io/engine.rs 的 O_DIRECT DirectIoReader 与 src/io/uring_reader.rs 的 tokio_uring 都做了原型,但生产路径不走它们——O_DIRECT 适合"我比内核更懂访问模式"的极端场景,CT 段读不在其中。

全局分配器换成 tikv-jemallocator(src/main.rs:9-10)。glibc malloc 长跑 12 小时后会保留 ~16GB anon 堆不还给 OS,page cache 被挤掉;jemalloc 主动 madvise(MADV_DONTNEED) 释放,长时间运行 RSS 稳定在 5-6GB。一行代码、效果立刻看见的优化。

SaaS 层简记

web-ui/src/lib/db.ts:54-59 把 free/pro/enterprise 的每日额度、最大 offset、单页上限写进 SQLite tiers 表(free 100/d、pro 10K/d、enterprise 9.9M/d)。搜索路径用 INSERT ... ON CONFLICT(user_id, usage_date) DO UPDATE SET count = count + 1 一句 SQL 原子计数。匿名 IP 走 anon-rate-limit.ts 的内存 token bucket,1 req/min/IP 加 burst 3,50K IP 上限走 LRU 淘汰。Web UI 的 /api/search/route.ts 同时支持 OAuth session 与 X-API-Key 两条认证路径,后者拿 users.api_token(ctr_ 前缀)匹配。


整套系统最反直觉的部分是 mmap Bloom:把一个看起来"应该塞内存"的 24GB 数据结构直接当文件用,让 OS 替你做冷热判断;应用进程的 RSS 反而稳定。配上倒序域名 + FST,子域名前缀搜索这件事在 CT 这个量级根本不需要倒排索引——文件系统的页缓存就是你的索引。

Comments (0)

No comments yet

imfht
May 8, 2026 · Updated May 8, 2026