百万级向量并行建索引:VexDB-Lite PostgreSQL 并行构建的 DSM 与 LWLock 实现细节
百万级向量并行建索引VexDB-Lite PostgreSQL 并行构建的 DSM 与 LWLock 实现细节【免费下载链接】VexDB-LiteA cross-platform vector database, which can be integrated into existing databases as a plugin.项目地址: https://gitcode.com/gh_mirrors/ve/VexDB-LiteVexDB-Lite 是一款可嵌入现有数据库的跨平台向量数据库其 PostgreSQL 扩展vexdb_lite支持以 HNSW 图索引加速向量检索。当数据量达到百万、千万级时单线程CREATE INDEX往往要跑上数小时——本文将拆解 VexDB-Lite 如何用DSM 动态共享内存与条带化 LWLock实现并行建索引让多个并行 Worker 同时安全地往磁盘图中插入节点。为什么需要并行建索引传统做法是 leader 进程单线程扫描堆表、逐条把向量插入内存图直到内存放不下再刷盘。问题在于内存建图阶段是纯串行的核数再多也只能用一个 当maintenance_work_mem装不下整张图时会退化为单线程磁盘构建慢上加慢 百万级向量 × 768 维 float 的场景索引构建时间经常以小时计。VexDB-Lite 的策略是能并行就并行能落盘才落盘内存阶段单线程高速建图一旦内存预算耗尽立即把已有图刷到磁盘再启动 PostgreSQL 并行 Worker 分头构建磁盘图。这个延迟启动的设计正是 DSM 与 LWLock 协同工作的舞台。整体架构延迟启动的并行 Worker核心实现在 graph_index_build.cpp 中流程分四步预创建 ParallelContextleader 在构建开始时通过prepare_parallel_context()创建 DSM 段并分配共享状态但不启动 Worker内存阶段单线程建图leader 独占build_ctx内存上下文快速插入避免多线程触碰 PostgreSQL 的全局进程状态内存耗尽触发切换build_callback检测到插入条数超过estimated_limit由maintenance_work_mem减去 200MB 预留除以每条向量开销估算执行flush()把内存图整体刷盘随后调用LaunchParallelWorkers()Worker 并行落盘构建各 Worker 加入同一张并行表扫描从共享状态里读到 heap/index 的 OID 后独立打开 Relation各自持有独立的DiskStore并发写盘。 临时表会自动关闭并行模式maintenance_work_mem低于 1GB 时则直接走磁盘构建路径避免小内存下的无谓开销。DSM 动态共享内存最小化共享状态并行构建中最常见的错误是往 DSM 里塞太多东西——VexDB-Lite 的做法是slim shared state。共享结构GraphIndexShared只包含struct GraphIndexShared { Oid heaprelid; // 堆表 OID Oid indexrelid; // 索引 OID BlockNumber metablkno; // 元页号 bool isconcurrent; // 是否 CONCURRENTLY pg_atomic_uint32 tuples_done; // 进度计数原子 pg_atomic_uint32 worker_reltuples; // Worker 各自扫描行数 /* ParallelBlockTableScanDescData 跟在后面变长必须最后*/ };关键设计点传 OID 而不是传对象Worker 在 graph_index_parallel_build_main() 中用heaprelid/indexrelid自己打开 Relation并正确加锁并发模式用ShareUpdateExclusiveLockRowExclusiveLock普通模式用ShareLockAccessExclusiveLock原子计数器做进度tuples_done用pg_atomic_fetch_add_u32累加每 4096 条更新一次PROGRESS_CREATEIDX_TUPLES_DONEpg_stat_progress_create_index里能实时看到全团队进度并行扫描描述符共享table_parallelscan_initialize()让 leader 与所有 Worker 共同消费同一份扫描状态PG 内核负责把堆表块公平地切分给各 Worker。DSM 段通过shm_toc_estimate_chunk预估大小、以PARALLEL_KEY_GRAPH_INDEX_SHARED值0x76580001为键注册到 shared memory 目录中Worker 侧通过shm_toc_lookup取回。如果 DSM 分配失败如dynamic_shared_memory资源紧张leader 会优雅降级为单线程构建构建不会失败。LWLock 设计五组条带化命名锁跨进程互斥是并行构建最棘手的问题。注释在 parallel_build_locks.h 开头说得很清楚并行 Worker 加入了 leader 的重锁组所以LockPage()和LockRelationForExtension()无法区分兄弟 Worker——它们对彼此都合法。因此需要一组进程级命名 LWLock 来补齐这块缺口同时避免把整张图的插入串行化在一把锁上。VexDB-Lite 定义了五组锁全部采用哈希条带化striping设计锁组条带数保护对象粒度VexGraphBuildEntryLocks128图入口点entry point读取/升级每索引VexGraphBuildEntryWaitLocks128入口点共享持有者的闸门每索引VexGraphBuildStorageLocks128图容器元数据ID 分配、容量扩张每索引VexGraphBuildExtensionLocks128磁盘向量文件扩展每索引VexGraphBuildPointLocks4096单个节点的邻居表读写每节点锁的选择用了一个 64 位混合函数vex_graph_build_mix64splitmix64 变体把RelationGetRelid 32与用途盐值异或后再哈希取模保证同一索引稳定命中同一把锁、不同索引打散到不同条带。点锁的 key 里还额外异或了base/upper盐值使同 ID 的底层节点与上层节点落到不同的锁上进一步降低冲突。这些锁的 tranche 在 vector_smgr.cpp 的init_vector_smgr()中通过GetNamedLWLockTranche()注册如graph_build_entry、graph_build_point因此pg_locks/ 调试工具能看到它们的名字。三个关键场景的加锁细节场景一节点 ID 分配与容器扩张storage lock每个新向量需要分配一个图节点 ID 并可能扩张底层DiskVector容量。这段路径在 graph_index_storage.h 的assign_vector_id()中持EXCLUSIVE storage lock完成free_id_list复用或base_layer.append()并同步更新元页的num_vectors注释解释了为什么必须用外层 LWLock 而不是复用页锁DiskVector::append()内部会嵌套获取已有的重锁页锁而非可重入的 LWLock 直接套进去会自死锁锁只覆盖ID 与容量发布这一小段节点向量写入与图搜索完全在锁外进行。场景二入口点升级entry lock 降锁优化HNSW 插入时若新向量的层级高于当前入口点层级必须独占地升级入口点。这是最容易出现惊群thundering herd的地方多个 Worker 会同时观察到同一个过期的入口层级。get_entry()graph_index_storage.h实现了完整的读-升级协议先以 EXCLUSIVE 拿一下entry_wait_lock再立即释放——作用是关门阻止新的共享持有者穿过正在升级的锁再拿entry_lock 的共享模式读取入口点若发现需要升级释放共享锁按 wait → entry 的顺序升级为 EXCLUSIVE同时把页锁从 ShareLock 升到 ExclusiveLock降锁优化关键拿到独占锁后重查元页若别的 Worker 已经发布了目标层级入口层级在CREATE INDEX中单调递增就立即把 entry_lock 降回共享模式走普通插入路径。这样只有第一个升级者付独占代价其余 Worker 不会串行排队做多次完整的高层插入。场景三邻居表读写point lock4096 条带写入节点 A 的邻居表时可能正在修改节点 B 的邻居表复合邻居更新与此同时其他 Worker 正在遍历 B 做距离计算。lock_point()/unlock_point() 提供细粒度保护读者复制 打分一份邻居列表持SHARED point lock写者读取→剪枝→写回的完整序列持EXCLUSIVE point lock由于是构建专用路径parallel_build标志为 true 时才走这套 LWLock运行时查询扫描保持原有仅缓冲锁的快路径不会给每次查询多付一次命名锁的开销。Worker 端最小回调 严格一致的前处理Worker 的回调故意做得极简跳过所有 OOM 检查leader 已经刷完盘了直接走磁盘插入。但有两条纪律值得强调同一套 detoast / 对齐 / cosine 归一化路径Worker 若直接喂原始向量而 leader 喂的是归一化向量图中会混入两种向量表示大规模下召回率会被悄悄摧毁——所以 Worker 复用与 leader 完全相同的read_vec()逻辑PG_TRY/PG_CATCH 保护任何 ERROR 都要先释放临时向量内存再重抛保证 Worker 崩溃不泄漏build_ctx之外的 palloc。构建结束后leader 通过WaitForParallelWorkersToFinish()回收全部 Worker销毁 ParallelContext 并退出并行模式最终进度与行数从 DSM 原子计数器汇总。实践建议如何调到最佳吞吐⚙️并行度建索引并行度通过graph_index_get_build_parallel()读取建表时指定临时表会被自动置 0maintenance_work_mem它决定内存阶段能跑多远。预算越大单线程高速阶段覆盖的向量越多之后才切到磁盘并行阶段。刷盘时会输出类似graph no longer fits into maintenance_work_mem after N tuples的 WARNING并提示调大该参数观察进度SELECT * FROM pg_stat_progress_create_index;中的tuples_done由 DSM 原子计数器驱动无论单线程还是并行阶段都准确正确性验证仓库内置了成体系的并行构建测试规格如 graph_index_parallel_build.yaml、graph_index_parallel_build_ctx_race.yaml、graph_index_parallel_rabitq_id_cap.yaml以及 OOM 触发路径的 graph_index_oom_trigger.yaml覆盖 Worker 上下文竞态、ID 耗尽等边界场景。关键源码索引模块路径职责构建主流程vexdb_pg/src/graph_index/graph_index_build.cpp内存/磁盘双阶段、DSM 准备、Worker 启动并行锁声明vexdb_pg/include/graph_index/parallel_build_locks.h五组条带化 LWLock 与哈希选锁图存储与加锁common/include/graph_index/graph_index_storage.hID 分配、入口点升级、点锁读写锁 tranche 注册vexdb_pg/src/vector_buffer/vector_smgr.cppGetNamedLWLockTranche初始化测试规格tests/spec/pg/index/并行构建/竞态/OOM 回归用例一句话总结DSM 只放 OID 和原子计数器把重活留给每个 Worker 的私有磁盘句柄LWLock 按入口/存储/扩展/节点四级条带化把锁粒度压到单节点级别——这就是 VexDB-Lite 在 PostgreSQL 里既快又稳地并行建出百万级向量图索引的全部秘密。【免费下载链接】VexDB-LiteA cross-platform vector database, which can be integrated into existing databases as a plugin.项目地址: https://gitcode.com/gh_mirrors/ve/VexDB-Lite创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考