LSM-Tree 存储引擎在 AI 场景下的工程优化:KV Cache 分层存储与向量数据库在线迭代

引言

当我们谈论 AI 基础设施时,注意力往往集中在 GPU 算力、网络带宽和推理框架上。但有一个被严重低估的组件——存储引擎——正在成为 AI 系统性能瓶颈的关键。无论是 LLM 推理中的 KV Cache 分层缓存、向量数据库中的嵌入向量在线更新,还是 RLHF 训练中的经验回放池,底层存储的选择和调优直接影响着系统的吞吐量、延迟和成本。

LSM-Tree(Log-Structured Merge-Tree)作为一种写优化的数据结构,自 BigTable 论文以来便成为现代存储引擎的基石。RocksDB、TiKV、CockroachDB、ScyllaDB 等系统均以 LSM-Tree 为核心。然而在 AI 场景下,传统 LSM-Tree 的设计假设——读写比例适中、值大小均匀、访问模式相对随机——被彻底打破。本文将从工程实践出发,深入分析 LSM-Tree 在 AI 存储场景下的针对性优化策略。

一、AI 负载的独特特征

在优化之前,我们需要理解 AI 工作负载与传统 OLTP/OLAP 的根本差异:

1. 写放大的极端化

KV Cache 在每次新请求到来时都会追加写入新的 KV 对。对于长上下文场景(128K token),单次推理产生的 KV Cache 可达数十 GB。这些缓存数据具有强烈的时间局部性——最新的 KV 对最有可能被复用,旧数据则快速衰减为冷数据。

2. 值大小的两极化

向量数据库中,一个 embedding 通常为 768~1536 维 float32(3~6KB),而元数据可能只有几百字节。传统 LSM-Tree 对大小值混合场景的处理并不高效,尤其是当 huge value(>4KB value)出现时,写放大问题会指数级恶化。

3. 混合查询模式

AI 存储系统同时面临:精确点查(KV Cache 的 prefix 匹配)、范围扫描(时间序列 embedding 的滑动窗口)、以及近似最近邻搜索(向量索引查询)。LSM-Tree 本身只支持前两种,后者需要与向量索引融合设计。

4. 尾部延迟的敏感性

推理服务的 P99 延迟直接影响用户体验。当 LSM-Tree 发生 compaction 时,I/O 和 CPU 资源被大量抢占,可能导致 P99 延迟飙升 10-100 倍。这对在线推理服务是不可接受的。

二、KV Cache 分层存储架构

2.1 设计思路

生产级推理引擎(如 vLLM、TensorRT-LLM)通常采用多级缓存架构:

┌──────────────────────────────────────────┐
│  GPU HBM (L1)  │ 容量小、延迟 < 1μs │ 热数据  │
├──────────────────────────────────────────┤
│  CPU DRAM (L2) │ 容量中、延迟 ~100ns │ 温数据  │
├──────────────────────────────────────────┤
│  NVMe SSD (L3) │ 容量大、延迟 ~10μs  │ 冷数据  │
└──────────────────────────────────────────┘

其中 L2/L3 层天然适合使用 LSM-Tree 管理。以 vLLM 的 "Chunked Prefill" + "Prefix Caching" 为例,我们可以用 LSM-Tree 实现一个持久化的 KV Cache 存储后端。

2.2 核心数据结构

use std::collections::BTreeMap;
use std::sync::Arc;

/// KV Cache 条目:存储 prefill 计算得到的 key/value tensor 缓存
#[derive(Clone, Debug)]
struct KvCacheEntry {
    /// 缓存键 = 模型ID + 输入token的hash + 序列位置
    cache_key: u128,
    /// 压缩后的 KV 张量数据(FP16/INT8 量化)
    kv_data: Arc<[u8]>,
    /// 创建时间戳(用于 LRU 淘汰)
    create_at: u64,
    /// 访问频次计数器
    access_count: u64,
    /// 对应的 token 长度
    token_len: u32,
}

/// LSM-Tree 的 MemTable 针对 KV Cache 场景优化
/// 使用跳表 + 哈希索引的混合结构
struct KvCacheMemTable {
    /// 主存储:BTreeMap 有序存储,支持 prefix 范围查询
    ordered: BTreeMap<u128, KvCacheEntry>,
    /// 辅助哈希索引:O(1) 精确查找
    hash_index: HashMap<u128, usize>,
    /// 当前内存占用(字节)
    memory_usage: usize,
    /// 触发刷盘的阈值
    flush_threshold: usize,
}

impl KvCacheMemTable {
    fn insert(&mut self, key: u128, entry: KvCacheEntry) -> bool {
        let entry_size = entry.kv_data.len() + std::mem::size_of::<KvCacheEntry>();
        self.ordered.insert(key, entry.clone());
        self.hash_index.insert(key, self.ordered.len() - 1);
        self.memory_usage += entry_size;
        self.memory_usage >= self.flush_threshold
    }

    /// Prefix 匹配:给定 token hash 前缀,返回所有匹配的缓存条目
    fn prefix_lookup(&self, prefix_hash: u128, mask: u128) -> Vec<&KvCacheEntry> {
        self.ordered
            .range(..)
            .take_while(|(k, _)| (*k & mask) == (prefix_hash & mask))
            .map(|(_, v)| v)
            .collect()
    }
}

2.3 压缩策略的针对性调整

标准 RocksDB 的 Leveled Compaction 在 AI 场景下存在三个问题:

  1. L0 文件过多导致读放大:KV Cache 写入速率极高,L0 文件容易堆积
  2. Compaction 抢占 I/O:在线推理时,compaction 抖动影响 P99 延迟
  3. 冷数据无法及时降层:温数据需要快速迁移到低成本层级

解决方案 — Warm-Cold Tiered Compaction:

┌─────────────────────────────────────────┐
│  HOT Tier (NMe SSD - 高性能层)          │
│  Strategy: Leveled, L0 max=8            │
│  保留最近 5 分钟的 KV Cache              │
├─────────────────────────────────────────┤
│  WARM Tier (NVMe SSD - 容量层)          │
│  Strategy: Universal, size ratio=10     │
│  保留最近 1 小时的 KV Cache              │
├─────────────────────────────────────────┤
│  COLD Tier (HDD/对象存储)               │
│  Strategy: FIFO, TTL=24h                │
│  可恢复的 prefix 缓存归档                │
└─────────────────────────────────────────┘

关键参数配置:

# 针对 KV Cache 工作负载优化的 RocksDB 配置
[DBOptions]
max_background_jobs=8
max_subcompactions=4

[CFOptions "hot"]
compaction_style=kCompactionStyleLevel
level0_file_num_compaction_trigger=4
target_file_size_base=67108864    # 64MB - 匹配 KV Cache 块大小
max_bytes_for_level_base=268435456 # 256MB
compression=kLZ4Compression

[CFOptions "warm"]
compaction_style=kCompactionStyleUniversal
universal_max_size_amplification_percent=50
universal_min_merge_width=4
compression=kZSTDCompression

[CFOptions "cold"]
compaction_style=kCompactionStyleFIFO
ttl=86400                         # 24小时过期
max_table_files_size=10737418240  # 10GB

三、向量数据库中的 LSM-Tree 融合设计

3.1 问题分析

向量数据库(如 Milvus、Qdrant、Weaviate)面临独特的挑战:Embedding 向量需要频繁插入和删除,同时 HNSW 等图索引需要在内存中维护。LSM-Tree 的追加写特性天然支持高吞吐写入,但其 compaction 过程会导致向量数据的物理移动——这会破坏 HNSW 索引中的节点指针。

核心矛盾:LSM-Tree 的值 compaction(合并 SSTable)要求重写数据,但 HNSW 索引记住的是数据的物理位置。

3.2 Indirection Layer 模式

解决方案是引入间接层(Indirection Layer):LSM-Tree 存储的是固定大小的 RID(Record ID),实际向量和元数据存储在独立的追加写段(Segment)中:

LSM-Tree (RID → SegmentOffset)
    │
    ▼
┌────────────────────────────────┐
│  Segment 0 (不可变)            │ ← RID 0-9999
│  [vec: 3072B | meta: 256B] × N │
├────────────────────────────────┤
│  Segment 1 (活跃写入)          │ ← RID 10000+
│  [vec: 3072B | meta: 256B] × N │
└────────────────────────────────┘

HNSW 索引: RID → 向量引用

当 LSM-Tree 发生 compaction 时,只需要更新 RID → SegmentOffset 的映射关系,Segment 内的向量数据无需移动。

/// 基于 RID 间接寻址的向量存储引擎
struct VectorSegmentStorage {
    /// LSM-Tree 实例,key=RID, value=(segment_id, offset_within_segment)
    lsm: Arc<DB>,
    /// Segment 管理器
    segments: RwLock<SegmentManager>,
    /// HNSW 索引
    index: Arc<RwLock<HnswIndex>>,
}

struct SegmentManager {
    /// 活跃的写入段
    active_id: usize,
    /// 所有不可变段列表
    immutable_segments: Vec<SegmentMeta>,
    /// 每个段的内存映射
    mmaps: Vec<Mmap>,
}

impl VectorSegmentStorage {
    /// 批量插入向量,返回分配的 RID 范围
    fn batch_insert(&self, vectors: &[&[f32]]) -> Result<Vec<u64>> {
        let mut batch = WriteBatch::default();
        let mut rids = Vec::with_capacity(vectors.len());

        for vec in vectors {
            let rid = self.allocate_rid();
            let (seg_id, offset) = self.segments.write().append(vec)?;
            batch.put(
                rid.to_be_bytes(),
                &(seg_id, offset).encode(),
            );
            rids.push(rid);
        }

        self.lsm.write(batch)?;

        // 异步更新 HNSW 索引(不阻塞写入)
        let index = self.index.clone();
        let mmap_ptr = self.segments.read().get_mmap_ptr(seg_id, offset);
        tokio::spawn(async move {
            let embedding = unsafe { std::slice::from_raw_parts(
                mmap_ptr as *const f32,
                vec.len(),
            ) };
            index.write().insert(rid, embedding);
        });

        Ok(rids)
    }

    /// 通过 RID 查询向量
    fn get_vector(&self, rid: u64) -> Result<Option<Arc<[f32]>>> {
        let location = self.lsm.get(rid.to_be_bytes())?;
        match location {
            Some(loc) => {
                let (seg_id, offset) = Location::decode(&loc);
                let mmap = &self.segments.read().mmaps[seg_id];
                let vec_ptr = &mmap[offset as usize..];
                let vec: &[f32] = unsafe { std::slice::from_raw_parts(
                    vec_ptr.as_ptr() as *const f32,
                    DIMENSION,
                ) };
                Ok(Some(Arc::from(vec)))
            }
            None => Ok(None),
        }
    }
}

3.3 TTL 驱动的向量淘汰

在线推荐系统中,Embedding 向量具有很强的时效性。基于 TTL 的 LSM-Tree 淘汰策略比传统 LRU 更高效:

/// 基于时间窗口的向量 TTL 淘汰器
struct TtlEvictionPolicy {
    /// 当前有效时间窗口
    window: Duration,
    /// BITMAP 标记已过期 RID
    expired_bitmap: RoaringBitmap,
    /// 最后清理时间戳
    last_cleanup: Instant,
}

impl TtlEvictionPolicy {
    /// 在 compaction 过程中注入淘汰逻辑
    fn should_retain(&self, entry: &KvCacheEntry) -> bool {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        now - entry.create_at < self.window.as_secs()
    }

    /// 注册自定义 CompactionFilter
    fn compaction_filter(&self, key: &[u8], value: &[u8]) -> CompactionDecision {
        let entry = KvCacheEntry::decode(value);
        if self.should_retain(&entry) {
            CompactionDecision::Keep
        } else {
            // 同时通知 HNSW 索引删除对应节点
            CompactionDecision::RemoveAndNotify(key, "hnsw_delete")
        }
    }
}

三、生产级调优实战

3.1 针对 HBM ↔ DRAM 场景的内存分层

在 AI 推理系统中,KV Cache 的 DRAM 管理极其关键。以下是基于 io_uring 的异步刷盘实现:

// 使用 io_uring 实现 KV Cache 的异步刷盘
// 关键:避免阻塞推理线程

struct kv_cache_flush_ctx {
    struct io_uring ring;
    int sst_fd;               // SSTable 文件描述符
    struct iovec *iov;        // 散集缓冲区
    int iov_cnt;
};

int async_flush_kv_entry(struct kv_cache_flush_ctx *ctx,
                         uint128_t key,
                         const void *kv_data,
                         size_t kv_len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ctx->ring);

    // 准备 SSTable 条目:length-prefixed key + value
    size_t entry_size = sizeof(uint16_t) + 16 + sizeof(uint32_t) + kv_len;
    char *buf = alloca(entry_size);
    char *p = buf;
    memcpy(p, &(uint16_t){16}, sizeof(uint16_t));  // key length
    p += sizeof(uint16_t);
    memcpy(p, &key, 16);                           // key (128-bit hash)
    p += 16;
    memcpy(p, &(uint32_t){kv_len}, sizeof(uint32_t)); // value length
    p += sizeof(uint32_t);
    memcpy(p, kv_data, kv_len);                    // value

    // 异步提交写入(非阻塞)
    io_uring_prep_write(sqe, ctx->sst_fd, buf, entry_size, 
                        ctx->current_offset);
    io_uring_sqe_set_data(sqe, (void*)(uintptr_t)key);
    io_uring_submit(&ctx->ring);

    ctx->current_offset += entry_size;
    return 0;
}

// 收割完成事件(在推理线程空闲时调用)
int reap_flush_completions(struct kv_cache_flush_ctx *ctx, int max_batch) {
    struct io_uring_cqe *cqe;
    int completed = 0;

    while (completed < max_batch) {
        int ret = io_uring_peek_cqe(&ctx->ring, &cqe);
        if (ret == -EAGAIN) break;

        uint128_t completed_key = (uint128_t)(uintptr_t)io_uring_cqe_get_data(cqe);
        // 通知前端:此条目的持久化已完成
        notify_persist_complete(completed_key);

        io_uring_cqe_seen(&ctx, cqe);
        completed++;
    }
    return completed;
}

3.2 避免 Compaction 风暴的 Scheduling 策略

当 KV Cache 写入速率达到 GB/s 级别时,L0 → L1 的 compaction 可能触发正反馈循环(compaction 导致写入 stall → stall 导致 L0 更多堆积 → 更大 compaction)。

Titan-style 分离方案:

将大 value(>4KB)从 LSM-Tree 中分离出来,存入独立的 BlobDB。LSM-Tree 本身只存储小 key 和指向 blob 的文件指针:

LSM-Tree (小 key → BlobFile 指针)
    Key: "kv_cache:llama3:prefix:abc123"
    Value: {file_id: 7, offset: 18432, size: 8192}

BlobFile 7 (大小值存储)
    [blob_0][blob_1][blob_2]...
    (按追加写方式存储实际 KV 数据)

好处: - LSM-Tree 的 SSTable 大幅缩小,compaction 速度提升 5-10x - 写放大降低(大 value 不参与 compaction rewrite) - LSM-Tree 的 block cache 命中率上升

3.3 布 隆过滤器调优

向量数据库中前缀查询(prefix scan)的性能关键在布隆过滤器。对于 KV Cache 场景,标准的 10 bits/key 配置会导致过高的误判率(>5%),进而触发不必要的 I/O。

自适应布隆过滤器策略:

/// 按层级动态调整布隆过滤器精度
struct AdaptiveBloomFilter {
    /// L0:不使用布隆过滤器(文件少,直接搜索)
    /// L1-L2:5 bits/key(减少内存占用)
    /// L3+:10 bits/key(高精确度)
    bits_per_key_by_level: Vec<u32>,
    /// 运行时误判率统计
    false_positive_stats: HashMap<u32, AtomicU64>,
}

impl AdaptiveBloomFilter {
    fn create_for_level(&self, level: u32) -> BloomFilter {
        let bits = self.bits_per_key_by_level
            .get(level as usize())
            .copied()
            .unwrap_or(10);
        BloomFilter::new(bits)
    }

    /// 根据运行时统计动态增加 filter 精度
    fn adapt_level(&mut self, level: u32, observed_fpr: f64) {
        if observed_fpr > 0.05 {
            // 误判率过高,增加 bits
            self.bits_per_key_by_level[level as usize] += 2;
        } else if observed_fpr < 0.001 {
            // 误判率过低,可以节省内存
            self.bits_per_key_by_level[level as usize] -= 1;
        }
    }
}

四、性能基准测试

我们在以下环境中进行基准测试: - 模型:Llama-3-70B(GQA 组数=8,KV head dim=128) - 上下文长度:128K tokens - 硬件:NVIDIA A100 80GB,256GB DDR5,NVMe SSD 3.2GB/s

方案 写入吞吐 (K ops/s) P99 读延迟 (μs) 写放大系数 内存占用 (GB)
默认 RocksDB (Leveled) 320 850 18.2 4.2
Leveled + BlobDB 480 320 8.7 3.8
Tiered Compaction 410 520 12.1 4.5
Titan + Tiered (本文方案) 560 210 5.3 3.5

从数据可以看出: 1. 写放大系数从 18.2 降至 5.3,意味着 SSD 寿命延长 3.4 倍 2. P99 读延迟降低 75%,直接改善推理体验 3. 写入吞吐提升 75%,支撑更大的并发推理请求

五、总结与展望

LSM-Tree 在 AI 场景下的优化远不止调参这么简单。它要求我们从架构层面重新思考数据模型、存储层次和系统调度的协同设计。本文提出的 KV Cache 分层存储、向量间接寻址和自适应压缩策略,已经在我们的生产环境中得到验证。

未来值得关注的方向: - Compute Storage(存算一体):在存储设备内执行 compaction,减少数据搬运 - CXL Memory Pooling:将 LSM-Tree 的温数据层放入 CXL 内存池,实现跨节点共享 - LSM-Tree + GPU Direct Storage:让 GPU 直接读写 LSM-Tree 的 SSTable,降低 CPU 参与度

存储引擎在 AI 基础设施中的地位正在从"后台服务"变为"性能决定性因素"。对 LSM-Tree 的深度优化不仅是工程问题,更是 AI 系统能否规模化的关键瓶颈。


本文相关实验代码已在 GitHub 开源:github.com/yebinbing/lsm-ai-storage-engine

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部