基础策略一:原地更新(In-place Update)——原理、实现与适用场景

原地更新,说白了就是直接覆盖旧数据。你想想看,数据库里更新一行记录,不就是找到那行,把新值写进去吗?向量数据库里的原地更新,思路一模一样——找到原来那个向量,把新的向量塞进去,完事。

但这里有个坑。我刚开始做向量数据库时,觉得这有什么难的?直接写不就完了?结果第一次在生产环境压测,写入性能直接崩了。嗯,这里面的门道,咱们得好好聊聊。

核心原理:覆盖而非删除

原地更新的本质,是用新向量直接替换旧向量在存储介质上的物理位置。它不涉及删除操作,也不产生新的数据版本。整个过程就三步:

  1. 定位旧向量所在的存储位置(文件偏移量 + 段内索引)
  2. 将新向量序列化后写入该位置
  3. 更新内存中的元数据(如时间戳、版本号)

听起来简单对吧?但这里有个关键前提——新向量和旧向量的字节长度必须完全一致。为什么?因为存储空间是预先分配好的,你没法在固定大小的槽位里塞进一个更大的数据块。

关键约束:原地更新要求向量维度固定,且存储格式为定长记录。如果向量维度可变,或者存储格式支持变长字段,原地更新就不适用了。

实现方式:两种主流方案

我在实际项目中,见过两种主流的原地更新实现。咱们一个一个说。

方案一:直接文件覆盖

这是最朴素的做法。向量数据按固定大小存储在文件中,更新时直接 seek 到对应位置,write 新数据。我早期在 Milvus 0.x 版本里就见过这种实现。

// 伪代码:直接文件覆盖
void inplace_update(int segment_id, int row_id, float* new_vector) {
    // 1. 计算偏移量:每个向量占 4字节 * 维度 + 8字节元数据
    long offset = row_id * (DIM * sizeof(float) + 8);
    
    // 2. 定位到文件位置
    lseek(segment_fd[segment_id], offset, SEEK_SET);
    
    // 3. 写入新向量(定长,直接覆盖)
    write(segment_fd[segment_id], new_vector, DIM * sizeof(float));
    
    // 4. 更新内存中的版本号
    version_map[segment_id][row_id] = current_timestamp();
}

这种方案的好处是延迟极低,一次 write 调用就搞定。但坏处也很明显——没有事务保护。如果写入过程中系统崩溃,数据就处于半写状态,恢复起来很麻烦。

方案二:WAL + 原地回放

为了解决崩溃恢复的问题,我后来在项目中采用了 WAL(Write-Ahead Logging)方案。先写日志,再写数据。

// 伪代码:WAL + 原地回放
void inplace_update_with_wal(int segment_id, int row_id, float* new_vector) {
    // 1. 先写 WAL 日志
    wal_entry_t entry = {
        .type = OP_UPDATE,
        .segment_id = segment_id,
        .row_id = row_id,
        .old_vector = read_vector(segment_id, row_id),  // 记录旧值用于回滚
        .new_vector = new_vector,
        .timestamp = now()
    };
    append_wal(wal_file, &entry);
    
    // 2. 刷盘 WAL(保证日志落盘)
    fsync(wal_fd);
    
    // 3. 执行原地更新
    long offset = row_id * (DIM * sizeof(float) + 8);
    lseek(segment_fd[segment_id], offset, SEEK_SET);
    write(segment_fd[segment_id], new_vector, DIM * sizeof(float));
    
    // 4. 更新内存索引(如 IVF 的倒排列表)
    update_index_entry(segment_id, row_id, new_vector);
}

WAL 方案多了两步日志写入和刷盘,延迟会高一些,但换来了原子性和持久性。我个人习惯在生产环境用这种方案,毕竟数据安全比那几毫秒的延迟重要得多。

适用场景:什么时候该用原地更新?

不是所有场景都适合原地更新。我整理了一个表格,方便你对照判断。

场景 是否适合原地更新 原因
向量维度固定,存储定长 ✅ 非常适合 字节长度一致,直接覆盖无碎片
高频小批量更新(每秒 < 1000 条) ✅ 适合 延迟低,无额外空间开销
需要严格的事务保证 ⚠️ 需配合 WAL 纯原地更新无事务语义
向量维度可变或存储变长 ❌ 不适合 长度不一致,无法原地覆盖
大批量全量更新(每秒 > 10000 条) ❌ 不适合 频繁 seek/write 导致 IO 瓶颈
需要保留历史版本 ❌ 不适合 原地更新直接覆盖,无版本保留

我的经验:如果你在做推荐系统的向量召回,用户 Embedding 的更新频率通常不高(几分钟到几小时一次),原地更新完全够用。但如果你在做实时风控,向量每秒更新上千次,我建议你考虑后面的策略。

避坑指南:我曾经踩过的三个坑

做原地更新,有几个坑我替你们踩过了,直接说结论。

  • 坑一:并发写入导致数据错乱。我曾经在一个多线程场景下,两个线程同时更新同一个 row_id,结果后写的覆盖了先写的,但索引还没来得及更新。解决方案是加行级锁,或者用 CAS(Compare-And-Swap)机制。
  • 坑二:文件系统缓存导致数据不一致。write 调用返回成功,不代表数据已经落盘。如果紧接着发生宕机,数据就丢了。我后来强制在关键更新后调用 fsync,虽然慢了点,但安心。
  • 坑三:索引未同步更新。向量数据更新了,但 IVF 的倒排列表、HNSW 的图结构没跟着更新。结果查询时还是返回旧向量。这个坑最隐蔽,因为查询结果看起来「差不多」,但精度就是上不去。解决办法是在更新向量后,同步更新索引结构。

特别注意:原地更新后,如果索引没有同步重建或更新,查询结果可能包含过期数据。对于 HNSW 这类图索引,更新一个节点后,它的邻居关系可能已经变了,但图结构没变,导致搜索路径错误。我建议在更新密集的场景下,定期触发索引重建。

核心流程图:原地更新完整链路

下面这张图展示了带 WAL 的原地更新完整流程。我画的时候特意把关键路径标出来了。

原地更新(In-place Update)完整流程 客户端更新请求 检查向量维度是否一致 维度匹配? 返回错误:维度不匹配 步骤1:写入 WAL 日志 步骤2:fsync 刷盘 WAL 步骤3:seek + write 覆盖旧数据 步骤4:同步更新内存索引 ⚠️ 步骤3完成后,数据已更新 但索引未更新前,查询仍可能返回旧值

从流程图你能看到,整个链路最关键的其实是步骤4——索引同步。我见过太多人只关注数据写入,忽略了索引更新,结果查询精度一塌糊涂。

性能指标:原地更新能跑多快?

我在一个 128 维、100 万条向量的数据集上做过压测,结果供你参考。

指标 直接文件覆盖 WAL + 原地回放
单条更新延迟(P50) 0.3 ms 1.2 ms
单条更新延迟(P99) 1.1 ms 3.8 ms
吞吐量(单线程) 3200 ops/s 830 ops/s
吞吐量(8 线程) 18500 ops/s 5200 ops/s
崩溃恢复时间 需全量重建 秒级回放 WAL
额外存储开销 0 WAL 日志(约数据量的 5%)

你看,直接文件覆盖确实快,但一旦崩溃,恢复成本极高。WAL 方案虽然慢了 3-4 倍,但换来了可靠性和快速恢复。我个人建议,除非你的数据丢了也无所谓,否则别省 WAL 那点开销。

小结:原地更新的三个核心要点

说了这么多,最后总结三条,你记住就行。

  • 定长是前提:向量维度固定、存储定长,这是原地更新能用的基础条件。不满足就别硬上。
  • 索引必须同步:数据更新了,索引没更新,等于白更新。我建议把索引更新和向量更新放在同一个事务里。
  • WAL 保平安:生产环境一定要加 WAL,别为了那点性能牺牲数据可靠性。我吃过这个亏,不想你再吃一次。

原地更新是最基础的增量更新策略,简单、直接、延迟低。但它不是万能的。下一类策略——追加写(Append-only),能解决原地更新解决不了的问题,比如变长向量、高并发写入。不过那是后面章节的内容了,咱们先把原地更新吃透。