Weaviate 于 2026 年 9 月 25 日发布 v1.39.7:3 节点集群引导期并发 Notify 会触发并发 map 写并直接 panic 节点,改用 candidatesMu 叶子锁与 atomic 指针修复;过滤批删单次解析上限固定为 QUERY_MAXIMUM_RESULTS 默认 10000 加 1 个 UUID,并行解析把过滤查询冷读压掉 24% 至 37%
Weaviate 于 2026 年 9 月 25 日发布 v1.39.7,修掉引导期 raft 节点发布竞态与候选表并发写 panic,并给过滤批删加上限。
前置部署 FDE 为什么要读这条 v1.39.7 修复
前置部署 Forward Deployed Engineer(FDE)在企业里跑向量库做企业 RAG enterprise RAG 时,最贵的故障不是「检索不准」,而是集群在客户现场扩缩容或滚动重启的那一刻节点直接退出。Weaviate 在 2026 年 9 月 25 日 13:18 UTC 发布 v1.39.7,把三处只在集群引导窗口里出现的并发缺陷一次修掉,同时给长期被当成「玄学超时」的过滤批删加了一个确定的上限。
这条发布的实用价值在于它给出了两份可直接抄的工程证据:一份是并发缺陷的定位方法(用 -race 编译的二进制跑真实 3 节点集群),另一份是并行化改造的完整基准(含两个被否掉的方案与它们各自的数字)。对前置部署 FDE 来说,第二份比第一份更值钱,因为「为什么选了慢一点的那条路」在多数 release note 里是看不到的——前置部署 FDE 尤其该留意这一层。
本文只覆盖版本与规范级证据,全部数字来自官方 release note 与对应 PR 的一手描述,不采信任何无授权转载或二手转述。
v1.39.7 的发布口径,以及它相对 v1.39.6 的差异
v1.39.7 的 GitHub Release 标题是 RAFT boot-time race, async replication and batch delete Fixes,正文的 Breaking Changes 与 New Features 两栏都写 none,即没有破坏性变更、没有新功能,全部是修复与性能条目,Full Changelog 指向 v1.39.6...v1.39.7。
这是同平台不同版本的更新条目,与 2026 年 9 月 22 日发布的 v1.39.6 是两件不同的事:v1.39.6 的重心是存储与索引的性能(HNSW 单顶点从 48 字节压到 24 字节、LSM 写入路径分配优化),v1.39.7 的重心是集群引导期的并发正确性与批量删除的资源边界。两条没有重叠的修复项,v1.39.7 也不是 v1.39.6 的性能回退补丁。
升级判断很直接:如果客户环境里 Weaviate 是 3 节点以上的集群,并且做过「先删一批再重启」这类运维动作,v1.39.7 属于应当尽快跟进的版本;如果只是单节点、数据量在百万级以下,它的主要收益会落在慢查询日志与错误码语义上,紧急度低——前置部署 Forward Deployed Engineer(FDE)可以按这个分界决定是否占用一次上线窗口。
根因一:raft 节点在引导窗口里被并发发布(PR 12623)
第一个缺陷由竞态检测器在真实集群上抓出来:Store.Open 在集群引导期给 raft 字段赋值,而同时在服务的 RPC 会读它——调用链是 JoinPeer → Leader() → st.raft。一个 peer 的 join 请求如果正好落在引导窗口内,它对指针的读取与 NewRaft 内部写入之间没有任何 happens-before 边。
修法是把字段改成 atomic.Pointer[raft.Raft],每个读取方只 Load 一次并使用本地副本(rn := st.raft.Load()),这样「判空再用」在同一次调用内保持一致;两个写入点(Open 与单节点恢复)都在 NewRaft 返回后通过 Store 发布。另外 Join 与 Remove 现在也容忍 nil 的 raft,返回 ErrNotLeader 而不是只看 open 标志,joiner 会按既有的非 leader 流程重试。
这个缺陷是同一家族里的第三个成员:同一批修复还包括 PR 12533(candidates 候选表)与 PR 12534(memberlist 节点变更)。作者的定位方法值得照搬——不是读代码找竞态,而是用带 -race 的二进制跑一个活的 3 节点集群,让检测器自己报。该 PR 由 jeroiraz 提交;回归测试是 TestStoreLeaderConcurrentWithRaftAssignment,在旧实现上必红(DATA RACE),改完必绿。
根因二:候选表并发写是 fatal error,不是可重试的错误(PR 12533)
第二个缺陷的严重度比第一个高一个档。集群引导时每个启动中的节点都会通知它的 peer,于是 NotifyPeer 这个 gRPC handler 会并发调用 Store.Notify,而 Notify 对候选表做的是无同步的「读—改—抽干」。在非 -race 构建下,并发 map 写不是值层面的数据竞争,而是 Go 运行时的 fatal throw:节点会在 join 中途直接 panic 退出,这个错误没有任何一层能捕获或重试。
修法是加一把专用叶子锁 candidatesMu,而不是复用已有的 store 锁。关键的细节是锁的粒度必须覆盖整个 insert → 阈值判断 → drain → BootstrapCluster 序列:如果只保护 map 操作,两个并发通知会同时观察到阈值已满足,然后带着各自只抽干了一部分的候选集去抢 BootstrapCluster。
第二个细节是 bootstrapped / Leader() 这道闸门必须在拿到锁之后重新求值。一个在锁外通过了检查、然后在锁上等待的通知,如果期间已有别的通知完成了 bootstrap,它就会在 drain 之后继续改 map,留下统计里的陈旧条目,并以一个可能不包含本节点的候选集再次调用 BootstrapCluster,报错形态是 node is not a voter。锁内复查把这类等待者降级成空操作。
并发规模是量化过的:TestNotifyConcurrentCandidates 用 16 个 goroutine 各发 200 次通知、打到不可达的阈值上,复现方式与真实 NotifyPeer 并发一致,无修复时报 fatal error: concurrent map writes;TestNotifyConcurrentBootstrapOnce 用 16 乘 50 次通知去竞争一个可达阈值(BootstrapExpect = 3),无锁内复查时观测到 6 次 bootstrap 尝试,其中包含带 stale drain 的 node is not a voter 报错,有复查时恰好 1 次。
根因三:一次文本上干净的合并让 stable/v1.38 编译不过(PR 13225)
第三个缺陷形态很典型。前置部署 FDE 在维护分支时值得把这一条记下来。PR 12623 把 Store.raft 从 *raft.Raft 改成 atomic.Pointer[raft.Raft] 并转换了它当时知道的所有调用点;与此同时分支上已经落了两个提交,各自新增了调用点:leader-FSM 屏障新增 waitLeaderFSMCaughtUp(cluster/store.go),数据库用户导出导入屏障新增 cluster/store_query.go 里的调用。
两组改动没有碰同一个文件,所以合并在文本层面完全干净,问题只在编译时才暴露。原文给出的报错是逐行的,例如 cluster/store.go:1067:16 的 invalid operation: st.raft == nil (mismatched types "sync/atomic".Pointer[raft.Raft] and untyped nil),以及 store.go:1074:18 的 st.raft.CurrentTerm undefined、store.go:1080:20 的 st.raft.Barrier undefined、store_query.go:119:17 的 invalid operation: st.raft != nil、store_query.go:125:23 的 st.raft.Barrier undefined。另有两个测试文件漏了同样的转换,所以这个包连 vet 都过不了。
影响面是 stable/v1.38 上的 CI 全红,覆盖四个作业:unit tests (adapters)、unit tests (non-adapters)、integration tests (without vector)、docker build (multi-arch);所有指向该分支的 PR 都会继承这个失败。修复就是把那两个提交本该带的转换补上,行为不变,验证口径是 go build 全清、go vet 无编译错误、go test -race -count 1 ./cluster/ 绿。
过滤批删的三条 PR,以及一个固定的合并顺序
批量删除这一族的修复由三条 PR 组成,提交者都是 byronvoorbach,它们都落在 stable/v1.39 上,合并顺序是硬约束:先 13103(在 gRPC 回复里加 limit 字段),再 13104(给过滤批删的解析加上界),最后 13106(把过滤与批删的 doc id 解析并行化)。顺序不能换的原因是 13104 需要 13103 的字段才能把上限通过 gRPC 报出去,而 13106 重写了 13104 动过的同一个函数。
前置部署 Forward Deployed Engineer(FDE)在评估这个上限时会先撞上一个问题:以前为什么删不掉。要修的问题在 PR 13104 里描述得很清楚:一次过滤批删会为过滤命中的每一个对象解析一个 UUID,然后只保留前 QUERY_MAXIMUM_RESULTS 个。每次解析都是对 objects bucket 的一次读,而在一个副本化租户上整次查找必须落在副本协调者的 30 秒窗口内。于是一个命中几十万对象的过滤器永远跑不完——一次「本来只会删掉 10000 个对象」的删除,会在每次尝试时都失败。
修法是让每个分片最多被要求 limit + 1 个 UUID,其中 limit 就是 QUERY_MAXIMUM_RESULTS;多要的那一个用来把回复里的 matches 字段变成一个可判读的信号:matches 小于等于 limit 时它是精确计数且命中的全部都被处理了,matches 大于 limit 时说明命中的对象比一次调用能删的多,客户端应当再调一次。
这里有个反直觉的取舍被明确写下来了:过滤器只解析一次且不加界。如果把 limit 直接交给底层 searcher,任何叶子级 allow-list 过滤器都会因为行读取器在 allow list 触到给定上限时提前停止而被截断(原文给了 adapters/repos/db/inverted/searcher_doc_bitmap.go:96 这个位置),落在那个窗口里、而对象行已被删除的 doc id 就没有东西可以重试,回复会正好落在 limit 上,也就是被契约解读为「精确、全部处理完」的那个值。另有一条 doc id 水位线规则:丢掉一个 doc id 只允许在 shard 初始化时读到的 doc id 计数器以下做,因为在这条线以上,一个 id 可能已经被某次插入占用但对象行还没写,丢掉它会让一个活对象在下一次 shard 初始化之前对所有 deny-list 过滤器隐身。
上限的默认值、环境变量与客户端要改的写法
默认值的权威位置是源码而不是文档:stable/v1.39 分支的 usecases/config/environment.go 里写着 DefaultQueryMaximumResults = int64(10000),并允许用环境变量 QUERY_MAXIMUM_RESULTS 覆盖。测试栈把它设成 10005,验收用例断言 gRPC 回复里的 limit 等于这个值;另一个验收用例把它设成 10(单节点容器),跑 dry run 报 11、然后按官方记录的 repeat-until-zero 循环删完 50 个对象。
字段本身在 PR 13103 里以 optional int64 limit = 6 加入 BatchDeleteReply。选 optional 而不是普通标量是有理由的:proto3 对普通标量的隐式 presence 无法区分「服务端没报上限」与「服务端报的上限是零」,两者在线路上都编码为零字节;滚动升级时一个重新生成过 proto 的客户端会面对一个旧服务端,普通字段会让它把每一次删除都读成「被上限截断」,而实际上没有任何截断。用 optional 之后,旧服务端的缺失字段解码为 nil,新服务端总是给非 nil 值(包括零)。代价是生成的 Go 类型变成 *int64。
客户端侧的适配没有包含在这条 PR 里,原文把它列为 out of scope——前置部署 Forward Deployed Engineer(FDE)在滚动升级时最容易踩的就是这一条:每个客户端要自己重新生成 proto 并把新字段暴露出去;对 Python 来说这改的是 delete_many 的返回类型,而且判定必须用 HasField 而不是真值判断,因为「上限为零」是一个真实的取值。
并行解析的实测基准,以及两个被否掉的方案
PR 13106 是本轮数字最密的一条。它修的是同一个慢点:对象搜索与批删查找这两条读路径都是一次一个二级键、单 goroutine 串行解析。官方在生产集群上的实测是过滤查询取 5000 个对象要 10 到 20 秒,而大租户上的过滤删除根本跑不完那个 30 秒窗口。
最终采用的形状是:新增一个 bucket 方法,在一次一致的视图下解析一批二级键;因为二级键就是小端序的 doc id,按键做字节序排序恰好等于索引的存储顺序;键按 32 个一组切块,由最多 16 个 worker 从共享游标认领,并发预算沿用包内其它扇出的做法挂在 context 上;每个键仍然走既有的单键查找,所以最新版本仍然胜出、先删后加仍然按今天的行为解析、memtable 刷写窗口不变;一个共享循环每次 bucket 调用最多喂 500 个 doc id,并以 limit 为上界。
基准环境写得很完整,可复现:GCE n2-standard-8 配 pd-balanced 磁盘,1000 万个对象分布在 652 个对象段上,28.6 GB 对象数据放在 35 GB 卷里、机器内存 31 GB;查询前由一个特权 sidecar 丢掉页缓存(sync; echo 3 > /proc/sys/vm/drop_caches)并验证了驱逐;查询集是不相交的 BM25 与 limit 100 / limit 500 的过滤查询,每个格子跑 10 轮冷查询与 3 轮热查询,所有分支的结果集逐字节比对。
被否掉的第一个方案是把工作拆成「索引阶段 + 取值阶段」。它在测到的每个尺寸上都更慢:1000 万对象上冷查询中位墙钟时间是基线的 1.45 倍(limit 100)与 1.24 倍(limit 500),在 50 万语料上是 1.93 倍与 1.80 倍;而磁盘字节数与读到的树页在每一个格子里都与基线完全相同,说明它只是把成本搬了位置,没有消除成本。
被否掉的第二个方案是在同一个段内并行跑索引下降。它让页错误翻倍:BM25 limit 500 下测到 524 次 major fault 事件,基线是 222 次,前面那个分阶段方案是 196 次,而常驻页、载荷页与磁盘字节三者的比值都是 1.00。原因是所有 worker 都从根开始触碰同一批冷树页,而设备本身会把 page-in 串行化。
最终方案的数字是:BM25 与串行基线持平(1000 万对象上 limit 100 为 1.03 倍、limit 500 为 1.00 倍),过滤查询的冷读在 limit 100 下砍掉 24%、在 limit 500 下砍掉 37%,major fault 落在基线的 0.88 到 1.14 倍之间。
一条容易被误判的局限:首次调用随死前缀线性增长,而不是随上限增长
最需要提前知道的一条局限是:前置部署 FDE 要记住,上限约束的是「分片产生的 UUID 数量」,不是「读到的 doc id 数量」。因为过滤器要无界解析一次,一个 deny-list 过滤器会对每个死 doc id 读一次对象,直到碰上一个活的。官方在单节点上直接给了测量值,测量的构造是先把除 5 个以外的对象全部删掉再重开数据库。
死前缀 495 个时,首次调用 1.00 毫秒、第二次 0.41 毫秒;死前缀 1995 个时,首次 2.28 毫秒、第二次 0.44 毫秒;死前缀 7995 个时,首次 7.33 毫秒、第二次 0.47 毫秒。规律很清楚:第二次调用都在半毫秒量级,首次调用随死前缀线性增长。原文给出的外推是:在 1000 万个死 id 上,首次调用是秒级的对象 bucket 读,而对上的仍然是同一个 30 秒协调者窗口。
这条局限的运维含义是:如果客户环境里长期累积了大量「已删未压实」的 doc id,第一次清理会明显变慢,但会自行恢复,不需要人工干预,也不应该把它误判成新版本的性能回退。
其他三处值得直接抄进运维手册的改动
第一处是 PR 13202(jeroiraz 提交),修的是异步复制被静默关闭。lsmkv 的 replace cursor 在段损坏或读失败时是 panic 而不是返回错误(cursor_bucket_replace.go 的 330、457、484 行),initHashtree 的 panic 绕过了它的具名 err 返回,于是 metrics 的 defer 记到的是一段成功的耗时、重试循环从未运行、GoWrapper 在 goroutine 边界上恢复并退出;分片被留在一个装了一半的树、hashtreeFullyInitialized 为 false、也没有向 scheduler 注册的状态。更麻烦的是 enableAsyncReplication 的快路径把「hashtree 不等于 nil」当成「已经在跑」,所以后续任何开关翻转或 class 更新都救不回来,这个分片的异步复制会一直关到进程重启,而且没有任何地方计过这次失败。修法是在 initHashtree 最外层 defer 里 recover 并把 panic 转成这次尝试的错误,让既有的 metrics defer 与重试循环重新生效;同时把「就绪」当成注册的判据,在三个层次上各校验一次。
第二处是 PR 13260,把四个端点的错误码从服务端错误改成客户端错误:DELETE /v1/objects/{className}/{id} 从 500 改成 422,PUT /v1/schema/{className}/shards/{shardName} 从 500 改成 422,GET /v1/schema/{className}/shards 从 500 改成 404,GET /v1/schema/{className}/indexes 从 403 改成 404。触发条件是一个集群不认识的命名空间前缀类名(例如没有开命名空间的集群上的 a:Foo)。对写客户重试逻辑的人来说,这四处改动的意义是:以前会被当成「服务端有问题、可以重试」的调用,现在明确是「入参不对、重试无用」。
第三处是 PR 13124(dirkkul 提交),把慢查询日志的时序条目从「可能几兆字节宽的一行」收敛成百分位摘要;前置部署 Forward Deployed Engineer(FDE)把这一条直接抄进值班手册即可。原来这些二级键查找的条目只打算在 Shard.ObjectVectorSearch 结尾被压缩,但其它搜索路径会产生体积以兆字节计的日志行;改动之后每条时序在记录时就注册自己的 reducer,摘要在真正读日志或 profile 时才在副本上计算。第二个提交则是在没人会读的时候干脆不收集:只有当慢日志打开、或者查询显式要了 profile 时,索引才把收集器挂到查询上。
作者承认的局限与未覆盖场景
第一条是 gRPC 侧的覆盖缺口:被上限截断的那条路径没有端到端测试,PR 13103 把这件事直接写在文末——BatchDeleteReply 在 13103 落地之前没有 limit 字段,所以那个传输层上没有东西可以用来断言这个界,端到端覆盖留给 13104 带的 test/acceptance/batch_delete_limit 验收包。
第二条是 matches 语义的变化:它现在会停在上限之上一个数,不再随命中数增长。以前用 dry_run 去数总数的调用方拿到的是一个被夹住的值,原文给的替代方案是用聚合计数,那是精确且更便宜的定尺寸方式。
第三条是漂移状态下的行为差异:滚动升级期间,新协调者配旧分片节点与今天完全一致,但旧协调者仍然会向新分片节点发 limit 为零,所以无界解析与它的超时在那个节点协调的删除上会一直保留到它被升级。
第四条是可观测性的缺口:只有「命中上限的分片数量」才能区分一个「快删空」的集合与一个「几乎没开始」的集合,而这个数字不在回复里;改动之后每一次被截断的调用会写一行 info 日志来承载它。
第五条是另一个调用方的行为改变:对象 TTL 清扫是 Shard.FindUUIDs 的另一个调用者,它现在每一批都无界解析自己的过滤器,之前被 ObjectsTTLBatchSize 限制;对于一个只有可过滤索引的 delete-on 属性、或者时间戳索引,每一批都要重建整个剩余过期积压的 allow list,而一个范围索引以前也没有上限。
最后,并行方案的全部基准都在单机 rig 上取得(1000 万对象、单节点),多节点或副本化租户下的表现原文没有给出;而 PR 12623 自称「竞态之外没有行为变化」的同时,Join 与 Remove 实际上新增了容忍 nil raft 并返回 ErrNotLeader 的分支,这是一个行为变化。前置部署 FDE 应把它单独写进回归清单验证。
前置部署 Forward Deployed Engineer(FDE)的实用结论
该行动的人:在客户现场部署 3 节点以上 Weaviate 集群、且运维流程里存在批量删除或滚动重启的 Forward Deployed Engineer(FDE)。单节点、百万级数据集的环境可以缓一步,收益主要在错误码语义与慢查询日志上。
第一步先确认上限的现值:查 usecases/config/environment.go 里的 DefaultQueryMaximumResults(v1.39 上是 10000),再确认环境里有没有用 QUERY_MAXIMUM_RESULTS 覆盖过;这一步是前置部署 FDE 在客户现场必须留痕的变更前检查。上限为零或负数等于关闭上限,那样照旧什么也删不掉,这一点在变更前必须先排除。
第二步把删除循环改成按 matches 判读而不是按返回条数判读:matches 小于等于 limit 时这一轮已经删完,matches 大于 limit 时继续调下一轮。Python 客户端要等它把 delete_many 的返回类型暴露出来,并且用 HasField 判定,因为上限为零是合法取值。
第三步针对第一条局限做一次预清理:如果环境里累积了大量已删未压实的 doc id,第一次清理会出现秒级调用,可在低峰期先跑一轮 dry run 观察,再执行真实删除;重复调用会回落到亚毫秒量级。
第四步把并发族的验证方式写进自己的准入测试:用一个带 -race 的构建跑真实 3 节点集群,并在引导窗口内并发发起 join 与 Notify。这不是 Weaviate 专属手法,但对任何自研 raft 或成员管理层的团队都适用,而且它是唯一能抓住「fatal error: concurrent map writes」这类不可捕获故障的做法。
适用条件:Weaviate v1.39 线(stable/v1.39);客户端能重新生成 proto 或愿意等官方客户端跟进;集群满足 3 节点以上。
回滚方式:v1.39.7 没有破坏性变更、没有新功能,回滚到 v1.39.6 不需要数据迁移,唯一要留意的是客户端如果已经开始读 gRPC 回复里的 limit 字段,回滚后该字段缺失会解码为 nil,客户端必须按「未上报上限」处理,而不是按零处理,这也正是当初选 optional 的原因。
不适用:已经把 Weaviate 换成不支持批量删除语义的部署、或者集群规模在单节点且数据量百万级以下时,这一版的边际收益很低,不值得单独排一次上线窗口。
关键实体中英对照
领域与角色:前置部署 Forward Deployed Engineer(FDE)、企业 RAG enterprise RAG、向量数据库 vector database、批量删除 batch delete、gRPC 回复 reply。
本次出现的版本、字段与代码标识:Weaviate v1.39.7、Weaviate v1.39.6、QUERY_MAXIMUM_RESULTS、DefaultQueryMaximumResults、BatchDeleteReply、optional int64 limit、candidatesMu、atomic.Pointer、hashtree、hashtreeFullyInitialized、FindUUIDs、Store.Notify、BootstrapCluster、BootstrapExpect、waitLeaderFSMCaughtUp、errAsyncReplicationNotActive、ObjectsTTLBatchSize、doc id、secondary key、memtable。
上游技术栈:RAFT 共识算法 raft consensus、memberlist、HNSW、ACORN、LSM 存储,测试与验证工具 go test -race、golangci-lint、protoc 35.1、protoc-gen-go v1.36.11。
原始来源:
https://github.com/weaviate/weaviate/releases/tag/v1.39.7
https://github.com/weaviate/weaviate/pull/12623
https://github.com/weaviate/weaviate/pull/12533
https://github.com/weaviate/weaviate/pull/13225
https://github.com/weaviate/weaviate/pull/13103
https://github.com/weaviate/weaviate/pull/13104
https://github.com/weaviate/weaviate/pull/13106
https://github.com/weaviate/weaviate/pull/13202
https://github.com/weaviate/weaviate/pull/13260
https://github.com/weaviate/weaviate/pull/13124
https://github.com/weaviate/weaviate/blob/stable/v1.39/usecases/config/environment.go
评论(0)
- 暂无评论。