云计算百科
云计算领域专业知识百科平台

重建索引的那两个小时线上不能停:双写窗口、别名原子切换与新旧索引答案一致性抽样

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

周二上午十点,重建任务报 100%,回填脚本在第九十分钟打印了“已完成”。值班同学把别名从 docs_v1 切到 docs_v2,POST _aliases 返回 200,耗时一秒出头。切换窗口内没有错误,P99 没有抬头。

到了下午,客服从工单里捞出一条:上周三传的采购合同搜不到。

这条事故里没有 5xx,没有超时,没有告警。切换前所有检查都是绿的。问题出在判据上:回填脚本说的“已完成”,意思是历史库里每一份文档都被处理过一遍,它不表示此刻新旧两个索引的内容一致。重建任务在回填之后还跑了二十五分钟,团队按“回填完成”停掉了双写,那二十五分钟里新写进来的文档只进了旧索引。切过去之后,它们在线上不存在。

下面讲的是一套能把这个窗口关掉的运维流程:用别名指向的两套索引承接重建,重建期双写并做写入对账,回填补历史差集,切之前用固定问题集抽样,不一致率超过阈值就放弃切换。所有耗时数字都是量级推演,不是压测结果。

1. 这次重建不解决什么

先把范围收窄,否则后面每一节都会被误解。

重建索引在这里指三件事之一:换分析器(分词、同义词、normalizer)、换切块策略、换向量模型。这三件事的共同点是只改变文档的表达方式,不改变文档的事实内容。这个前提决定了后面所有判定指标长什么样。如果重建同时换了数据源——比如从 PDF 重新解析一遍——文档 ID 集合本身都会变,本文的文档级比对方法就失效了,需要退回到答案级比对,那是另一个问题。

它不解决检索质量本身。换掉一个差的分词器,答案会变好;换成一个不匹配语料的向量模型,答案会变差。抽样判定的目的不是要求新旧答案一致,而是要求差异落在能解释的范围内。把“不一致”当成“失败”是这套流程最常见的误用:你会因为新索引正确修正了一批旧错误而拒绝发布。

它不解决上游数据问题。如果 PDF 解析出来的正文本来就缺页,重建只是把缺页的正文再切一遍。这类问题在抽样里表现为两套索引一起答错,重合率很高,但都不对——所以抽样判定必须配一组带标注答案的问题,否则“新旧一致”可能只是“新旧一起错”。

它不解决容量。重建期间两份索引同时在线,写入放大两倍,回填还要从旧索引按分片顺序读一遍。假设旧索引 2000 万文档、含向量字段、单副本、索引本体加倒排约 60 GB,那么重建期至少要预留 150 GB 的余量(新索引本体 + 合并时的临时空间),并且回填的读 IO 要限速,不然会把线上查询的页缓存挤掉。这个数字随字段数和副本数变化,提前算,不要切到一半发现磁盘水位告警。

它的适用条件是三条:写入路径在你手里,能加一层双写;索引在单集群内,别名可以原子改指向;有一份稳定的查询日志,能构造出固定问题集。三条缺一条,后面的流程就要改造——写入路径不受控时双写无从谈起,跨集群时别名不跨集群,没有查询日志时抽样集只能靠人编,覆盖面和代表性都要打折。

2. 三条写路径抢同一个 doc_id,只有版本号能仲裁

在线重建的复杂度不在“把文档写到新索引”,在于重建窗口内有三条独立的写路径同时改同一批 doc_id:

  • 业务写入,通过双写同时打到新旧两个索引,随时发生;
  • 回填,从旧索引读历史文档写进新索引,按分片推进,读到的可能是几十分钟前的快照;
  • 对账补写,发现两边版本不一致后重投,可能延迟几分钟才到。

Elasticsearch 的默认写语义是 last-write-wins,谁最后到谁生效。这三条路径的到达顺序不受控,于是就有一个非常隐蔽的失败:回填按分片扫到文档 X 的旧版本(revision 6),此时业务已经把 X 更新到 revision 7 并通过双写写进了新索引,回填的那次写入晚到,把新索引里的 X 覆盖回 revision 6。切过去之后,X 存在、字段齐全、检索能命中,只是内容是旧的。这种错误不会报错,不会丢文档数,抽样时如果只看“能不能查到”,也查不出来。

所以要立一个不变量:

对任意 doc_id,新索引中生效的 revision 必须 ≥ 旧索引中该 doc_id 的 revision。

用 ≥ 不用 ==,是因为旧索引可能刚刚被删除,新索引还没来得及同步墓碑。这个不变量的成立不依赖写入顺序,只依赖每次写入前做一次版本比较。

版本号必须由业务侧提供,不能从存储里取。Elasticsearch 的 _seq_no 在单个索引内单调,但新旧索引的 _seq_no 语义完全不同,重建后原值也丢了,拿来比较没有意义。_version 在文档被删除后重建时会重置,同样不可靠。用 updated_at 时间戳也不行:批量导入时同一秒内几百条写入会撞时间,回填重写会刷新这个字段,机器时钟还可能回拨。唯一可靠的是一个由写路径携带、单调递增的整数 revision。

Elasticsearch 的 external version 正好能表达“严格大于才写”:

PUT /docs_v2/_doc/X?version=7&version_type=external

如果新索引里已有的版本 ≥ 7,返回 409 version_conflict_engine_exception。version_type=external 要求给定版本严格大于当前版本;external_gte 允许相等。回填路径用 external,双写路径用 external,对账补写也用 external,三条路径就统一到了同一个仲裁规则上,谁都不会覆盖更新的版本。

删除要单独处理。 直接 DELETE 会把文档的版本记忆一起清掉,而版本记忆只保留 index.gc_deletes 秒(默认 60 秒)。删除 60 秒之后,一条 revision 很低的回填写入能把这份文档重新“复活”到新索引里。稳的做法是写墓碑:不真删,写一条带 _deleted: true 和 revision 的文档,检索时用 filter 排除,观察期结束后再物理清理。墓碑把删除也纳入了版本仲裁,代价是索引里多留一份占位文档。

还有一个决定比版本号更容易被做错:双写什么时候停。 直觉是“回填完成就停”,这也是开头那次事故的直接原因。正确的停点是“切换完成 + 观察期结束 + 确认不再需要回滚”。在观察期结束之前,旧索引必须保持完整,因为它是唯一能在几秒内退回去的路径。双写多跑一天的成本是一天两倍的写放大,回滚窗口关掉的成本是一次不可控的数据补齐。

3. 写入、回填、抽样三条链路的交汇

#mermaid-svg-hxszyzqjghCJGMDI{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hxszyzqjghCJGMDI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hxszyzqjghCJGMDI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hxszyzqjghCJGMDI .error-icon{fill:#552222;}#mermaid-svg-hxszyzqjghCJGMDI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hxszyzqjghCJGMDI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hxszyzqjghCJGMDI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hxszyzqjghCJGMDI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hxszyzqjghCJGMDI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hxszyzqjghCJGMDI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hxszyzqjghCJGMDI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hxszyzqjghCJGMDI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hxszyzqjghCJGMDI .marker.cross{stroke:#333333;}#mermaid-svg-hxszyzqjghCJGMDI svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hxszyzqjghCJGMDI p{margin:0;}#mermaid-svg-hxszyzqjghCJGMDI .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-hxszyzqjghCJGMDI .cluster-label text{fill:#333;}#mermaid-svg-hxszyzqjghCJGMDI .cluster-label span{color:#333;}#mermaid-svg-hxszyzqjghCJGMDI .cluster-label span p{background-color:transparent;}#mermaid-svg-hxszyzqjghCJGMDI .label text,#mermaid-svg-hxszyzqjghCJGMDI span{fill:#333;color:#333;}#mermaid-svg-hxszyzqjghCJGMDI .node rect,#mermaid-svg-hxszyzqjghCJGMDI .node circle,#mermaid-svg-hxszyzqjghCJGMDI .node ellipse,#mermaid-svg-hxszyzqjghCJGMDI .node polygon,#mermaid-svg-hxszyzqjghCJGMDI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hxszyzqjghCJGMDI .rough-node .label text,#mermaid-svg-hxszyzqjghCJGMDI .node .label text,#mermaid-svg-hxszyzqjghCJGMDI .image-shape .label,#mermaid-svg-hxszyzqjghCJGMDI .icon-shape .label{text-anchor:middle;}#mermaid-svg-hxszyzqjghCJGMDI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hxszyzqjghCJGMDI .rough-node .label,#mermaid-svg-hxszyzqjghCJGMDI .node .label,#mermaid-svg-hxszyzqjghCJGMDI .image-shape .label,#mermaid-svg-hxszyzqjghCJGMDI .icon-shape .label{text-align:center;}#mermaid-svg-hxszyzqjghCJGMDI .node.clickable{cursor:pointer;}#mermaid-svg-hxszyzqjghCJGMDI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hxszyzqjghCJGMDI .arrowheadPath{fill:#333333;}#mermaid-svg-hxszyzqjghCJGMDI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hxszyzqjghCJGMDI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hxszyzqjghCJGMDI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hxszyzqjghCJGMDI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hxszyzqjghCJGMDI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hxszyzqjghCJGMDI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hxszyzqjghCJGMDI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hxszyzqjghCJGMDI .cluster text{fill:#333;}#mermaid-svg-hxszyzqjghCJGMDI .cluster span{color:#333;}#mermaid-svg-hxszyzqjghCJGMDI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hxszyzqjghCJGMDI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hxszyzqjghCJGMDI rect.text{fill:none;stroke-width:0;}#mermaid-svg-hxszyzqjghCJGMDI .icon-shape,#mermaid-svg-hxszyzqjghCJGMDI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hxszyzqjghCJGMDI .icon-shape p,#mermaid-svg-hxszyzqjghCJGMDI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hxszyzqjghCJGMDI .icon-shape .label rect,#mermaid-svg-hxszyzqjghCJGMDI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hxszyzqjghCJGMDI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hxszyzqjghCJGMDI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hxszyzqjghCJGMDI :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

切换门禁

回填与对账

写路径

主写 external version

影子写 external version

影子写异常

原 revision 重投

external version 写入

mget 取 _version

mget 取 _version

版本不一致

直查 docs_v1

直查 docs_v2

上界 ≤ 阈值

上界 > 阈值

超阈值

业务写入携带 doc_id + revision

双写分发

docs_v1别名 docs 当前指向

docs_v2重建目标

补偿队列保留 doc_id + revision

回填器按 _id 分片顺序读

对账器

固定问题集版本化 200 条

抽样器

文档级重合率严重不一致条数Wilson 上界

POST _aliases 原子切换

保持别名不动不产生回滚成本

观察期错误率 / P99 / 无答案率

反向 POST _aliases

这张图里有三条边值得单独说。

影子写失败不能阻塞主写。 如果双写是同步的,影子索引一次 500 ms 的超时会让整体写延迟翻倍;影子索引整体不可用时,主索引的可用性会被一个“本来只是过渡用”的索引拖死。所以主写同步、影子写旁路,失败落补偿队列,由后台重投。代价是新索引允许短时滞后——这在重建场景里可以接受,因为滞后最终会被对账和回填补齐。但这也意味着“新索引此刻和旧索引一致”这个判断不能靠写入返回值得出,只能靠对账水位和抽样。

抽样器直查两个具体索引名,不走别名。 别名同一时刻只指向一个索引,走别名就只能看到一套。这也是门禁能在切换之前完成判定的原因:不切别名就没有回滚成本,判定不通过时,线上的状态一点没变。

补偿队列存的是完整文档加重试次数,不是只存 doc_id。 只存 ID 的话,重试时要去旧索引读最新版本,读到的可能是比重试时更旧的版本;而且如果文档在重试间隙又被改了一次,重试会写入一个过期内容。存下原始 revision 和 payload,重投时仍然走版本比较,重复投递就变成了幂等操作。

4. 实现:双写的版本仲裁与增量对账

下面这段代码钉住一个决定:版本比较放在写入侧做,三条写路径共用同一个 put_if_newer。真实实现里它对应 PUT /{index}/_doc/{id}?version=N&version_type=external,捕获 409 视为“版本落后”而不是错误。

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Protocol

@dataclass(frozen=True)
class Doc:
doc_id: str
revision: int
source: dict
deleted: bool = False

class IndexSink(Protocol):
def current_revision(self, doc_id: str) –> int: ...
def put_if_newer(self, doc: Doc) –> bool: ...

class MemorySink:
"""替身:语义与 external version 一致,版本严格大于才写。"""

def __init__(self) –> None:
self.docs: dict[str, Doc] = {}

def current_revision(self, doc_id: str) –> int:
doc = self.docs.get(doc_id)
return doc.revision if doc else 0

def put_if_newer(self, doc: Doc) –> bool:
if doc.revision > self.current_revision(doc.doc_id):
self.docs[doc.doc_id] = doc
return True
return False

@dataclass
class DualWriteResult:
primary_written: int = 0
shadow_written: int = 0
shadow_stale: int = 0
pending: list[Doc] = field(default_factory=list)

class DualWriter:
def __init__(self, primary: IndexSink, shadow: IndexSink) –> None:
self.primary = primary
self.shadow = shadow

def write(self, docs: list[Doc]) –> DualWriteResult:
result = DualWriteResult()
for doc in docs:
if self.primary.put_if_newer(doc):
result.primary_written += 1
try:
if self.shadow.put_if_newer(doc):
result.shadow_written += 1
else:
result.shadow_stale += 1
except Exception:
result.pending.append(doc)
return result

def reconcile(primary: IndexSink, shadow: IndexSink, doc_ids: list[str]) –> list[tuple[str, int, int]]:
gaps = []
for doc_id in doc_ids:
a, b = primary.current_revision(doc_id), shadow.current_revision(doc_id)
if a != b:
gaps.append((doc_id, a, b))
return gaps

def demo() –> None:
v1, v2 = MemorySink(), MemorySink()
writer = DualWriter(v1, v2)

writer.write([Doc("X", 7, {"title": "采购合同-A"})])
assert v2.current_revision("X") == 7

# 回填器拿着 T+30 之前的快照晚到,revision 6,必须被拒绝
late = Doc("X", 6, {"title": "采购合同-旧"})
assert v2.put_if_newer(late) is False
assert v2.docs["X"].source["title"] == "采购合同-A"

# 影子写失败:只有 v1 有新文档,对账要能发现
v1.put_if_newer(Doc("Y", 3, {"title": "发票-B"}))
assert reconcile(v1, v2, ["X", "Y"]) == [("Y", 3, 0)]

# 删除走墓碑,仍然受版本仲裁
writer.write([Doc("X", 8, {"_deleted": True}, deleted=True)])
assert v2.current_revision("X") == 8
assert v2.put_if_newer(Doc("X", 7, {"title": "采购合同-A"})) is False

demo()

shadow_stale 不是错误计数,它是版本仲裁生效的次数。把它当错误报警会在重建期间刷屏。真正需要报警的是 pending 的长度和年龄:队列里堆了十条超过五分钟没投出去的写入,说明影子索引侧有持续性的问题,继续下去会导致切换时对账差出一大截。

对账要覆盖两类差异,成本差别很大。双写窗口内的差异按 revision 水位对:记下上次核对到的最大 revision,只比对这个水位之后写过的 doc_id,量级和写入 QPS 成正比。回填覆盖的历史文档没法按 revision 对,因为回填不增加 revision,只能按分片进度对:每个分片回填完成后,从该分片里抽一批 doc_id 做 mget 比对。两类对账都输出 (doc_id, v1_revision, v2_revision),非空的就进补偿队列重投,重投仍然走 put_if_newer。

全量对账——把两千万文档逐个 mget 一遍——在重建期间跑不动,也不必要。版本仲裁保证了“只要每条写入都经过它,v2 就不会比 v1 旧”,对账的作用是发现没经过仲裁的写入,也就是补偿队列丢消息、影子索引侧写超时被吞、回填任务中断这几类问题,它们都发生在特定的时间窗口或分片范围内,按窗口对就够。

5. 切换门禁判定的是“新索引能不能接”

“重建跑完了”和“可以切了”之间隔着三件事,前两件靠对账,第三件只能靠抽样。

第一件,重建期间的所有业务写入有没有都进新索引。这由双写和对账覆盖。

第二件,回填有没有把新索引里的新版本覆盖回旧版本。这由版本仲裁覆盖。

第三件,新索引的检索行为是不是可接受。这一件没有便宜的静态检查方法。分析器、切块、向量都变了,同一个查询在新旧索引上召回的文档集合本来就会不同,你只能拿一批固定问题去问,然后看差异的形状。

抽样的指标要分三层,混在一起会得出错误结论。

覆盖层:固定问题集中,新索引返回零结果的条数占比,和旧索引比。这个指标最敏感,也最容易被忽略。换分词器之后,一类原先能命中的查询突然全部零结果,重合率会告诉你“差异很大”,但不会告诉你“是往丢失的方向差”。零结果率上升超过两三个百分点,基本可以判定新索引对某类查询是坏的,不需要再往下看。

重合层:文档级召回集合的重合率。这里必须用文档级,不能用 chunk 级。切块策略一变,chunk ID 全部重排,chunk 级重合率会接近零,这个数字没有任何信息量。文档级聚合的做法是:对新索引取 top-50 个 chunk,按 doc_id 去重、每个文档取最高分,再截 top-10 文档;旧索引同样处理;然后比较两个 10 元素集合。这样比的是“用户能不能找到同一份材料”,与切法无关。

严重不一致单列:旧索引有、新索引没有的文档(丢失),和新索引有、旧索引没有的文档(新增)。这两个方向不能混进平均值。丢失是回归,必须逐条看;新增可能是新索引修正了旧索引的漏召回,也可能是错误召回,要抽人看。平均不一致率 4% 听起来可以接受,但如果其中 3% 都是单向丢失,就不能切。

问题集本身要有版本号,并且从真实查询日志里分层抽。分层维度至少包括:查询类型(事实型、比较型、长尾描述型)、是否有明确答案、命中数量档位(零命中、1–5 命中、大于 5 命中)、语言。零命中的问题必须占一定比例——重建后“原本答不出的问题开始答得出”和“原本答得出的开始答不出”都是信号,只抽有答案的问题会把前者漏掉。

合成问题不适合做这件事。由模型生成的问题往往句式相近、用词集中,覆盖不到真实日志里的口语化查询和错别字;更麻烦的是,如果答案也由同一个模型生成,它会系统性地偏向某一种召回结果,把差异抹平。

6. 抽样判定:阈值要落在置信上界上

“不一致率超过 5% 就回滚”这句话,如果 5% 是拿观测到的比例直接比的,那它在小样本上没有意义。200 条样本里观察到 6 条不一致,点估计是 3%,看起来安全;但这个观测对应的真值 95% 置信上界超过 6%,也就是说真实不一致率完全可能高于 5%,你只是没抽到。

判定要落在区间上界上,而不是点估计上。

import math
from dataclasses import dataclass

Z = 1.96 # 双侧 95%,判定规则里必须写死用的是哪种侧别

def wilson_upper(k: int, n: int, z: float = Z) –> float:
if n <= 0:
raise ValueError("n must be positive")
p = k / n
denom = 1 + z * z / n
centre = (p + z * z / (2 * n)) / denom
half = z * math.sqrt(p * (1 – p) / n + z * z / (4 * n * n)) / denom
return centre + half

def min_samples_for_zero_failure(threshold: float, z: float = Z, cap: int = 10000) –> int:
for n in range(1, cap):
if wilson_upper(0, n, z) <= threshold:
return n
raise ValueError("threshold unreachable")

@dataclass(frozen=True)
class GateDecision:
n: int
mismatches: int
upper: float
threshold: float
verdict: str

def gate(mismatches: int, n: int, threshold: float = 0.05) –> GateDecision:
need = min_samples_for_zero_failure(threshold)
if n < need:
return GateDecision(n, mismatches, float("nan"), threshold, "insufficient")
upper = wilson_upper(mismatches, n)
return GateDecision(n, mismatches, upper, threshold, "promote" if upper <= threshold else "hold")

def demo() –> None:
assert min_samples_for_zero_failure(0.05) == 74
assert gate(0, 60).verdict == "insufficient"
assert gate(0, 74).verdict == "promote"
assert gate(2, 200).verdict == "promote"
assert gate(6, 200).verdict == "hold"
assert min_samples_for_zero_failure(0.01) > 200

demo()

按这个口径算出来的几组数:

样本数 n不一致条数 kWilson 95% 上界能否声称真值 < 5%
60 0 6.0% 不能
74 0 4.9% 勉强能
200 0 1.9% 能,还能顺带声称 < 2%
200 2 3.6% 能
200 6 6.4% 不能

min_samples_for_zero_failure(0.05) == 74 这条断言是这套判定的核心约束:一条不一致都没有的前提下,也得凑够 74 条才能说真值在 5% 以下。这个数和“三次法则”给的 3/n ≈ 0.05 对得上,但 Wilson 的上界比它略严,所以实际算出来不是 60 而是 74。把阈值调成 1%,需要的零失败样本量超过 200——这就是为什么阈值不能随手定,它和你能不能凑出足够样本直接挂钩。

还有两个容易被忽略的细节。

侧别必须写死。 上面用的是双侧 95%,对应的 z 是 1.96。如果改成单侧 95%(z=1.645),同样的观测算出来的上界会小一截,更容易判过。两种口径都不算错,但同一次发布里不能换着用,否则前后两次重建的判定结果不可比。

样本量下限由统计决定,实际条数由覆盖面决定。 74 条是统计下限,不是“抽 74 条就够了”。真实的问题集要按第 5 节的分层维度铺开,每个格子至少几条,200 条是覆盖面推出来的,不是算出来的。两个数各有各的来源,别混。

7. 别名切换与回滚:原子的是别名,不是你的客户端

POST /_aliases 一次提交里的多个 action 是一条原子变更:

{
"actions": [
{"remove": {"index": "docs_v1", "alias": "docs"}},
{"add": {"index": "docs_v2", "alias": "docs"}}
]
}

不会出现别名短暂消失、查询打到零个索引的窗口。变更由主节点串行化进集群状态,客户端要么看到变更前,要么看到变更后,看不到中间态。这一层是可靠的。

不可靠的是这一层之外的三个地方。

客户端缓存了索引解析结果。 有些客户端、网关或者查询代理会把别名解析成具体索引名再发请求,并在本地缓存。这类缓存不会因为集群状态变了就失效。验证办法不是看 POST 的返回值,而是往新索引写一条带 build_id 的探测文档,通过别名去查它。查得到,说明别名在当前请求链路上生效了;查不到但直查 docs_v2 查得到,说明中间有缓存。探测文档用独立的 ID 前缀,观察期结束后清理。

已经打开的滚动查询和 PIT 不会迁移。 别名切换只影响新的请求解析,已经持有的 search context 仍然绑在旧索引上。这在重建场景里通常是好事:正在跑的导出任务不会被打断。但它带来一个硬约束——观察期内不能删除旧索引,否则这些上下文会直接失败。把“删除旧索引”排进观察期结束后的清理任务,不要放在切换脚本里。

写请求的落点。 蓝绿方案里别名同一时刻只指向一个索引,所以不存在 is_write_index 的歧义。但如果重建期间有人手工往别名下加过一个索引做临时实验,写请求就会报“别名指向多个索引且未指定写索引”。切换脚本执行前要断言别名下的索引数恰好为一。

回滚就是反向的 POST /_aliases。它的成本取决于一件事:双写还开着吗。 双写开着的时候,旧索引一直是最新的,回滚是一次原子改写,秒级完成,不需要补任何数据。双写停了之后再回滚,就要先把停写期间的增量反向追回来,耗时取决于停了多久、这段时间写了多少,可能从几分钟到几小时不等。

这也是为什么“停双写”这个动作的权重比“切别名”高得多。切别名可以随便切,切错了切回来;停双写是一个单向门,停了之后回滚从秒级变成不可控。把停双写放在观察期结束、且明确不再回滚之后,作为清理流程的第一步,而不是重建流程的最后一步。

观察期至少覆盖一个完整的业务高峰。搜索引擎的查询分布在工作日和周末、白天和夜间差别很大,用凌晨两点的半小时观察期去判断白天高峰的表现,等于没观察。观察期内新索引承接全部读流量,旧索引保持只读但完整,双写继续。

8. 失败模式与适用边界

按发生位置列一遍,这些是在实际切换里最容易撞上的。

回填覆盖新版本。 症状是切换后部分文档内容陈旧,检索正常,无报错。根因是回填读到了扫描时刻的快照,写入时没有做版本比较。第 4 节的 put_if_newer 就是为它准备的,回填路径必须走同一个函数,不能绕过。

删除被回填复活。 症状是切换后出现一批“已经删掉但还能搜到”的文档。根因是真删除了版本记忆,超过 gc_deletes 窗口之后,低版本写入能重新创建文档。用墓碑替代真删除。

mapping 少字段导致过滤失效。 新索引的 mapping 里漏了一个用于权限过滤或时间过滤的字段,重建期间不报错,回填写入时该字段被静默丢弃(如果 dynamic 是 false),切过去之后所有带该字段的过滤条件都命中零条。这个问题不需要抽样就能发现:切换前把两套索引的 mapping 拉出来做一次结构化 diff,字段名、类型、index 选项、doc_values 逐项比。这是纯静态检查,成本几乎为零,却不常做。

向量字段的维度或归一化不一致。 症状是抽样时新索引的零结果率大面积上升,或者答案整体发散。这类问题在覆盖层就能看出来,不需要等到答案比对。

样本集漏掉某类查询。 症状是切换后某一类用户(比如英文查询、长尾口语查询)的投诉集中出现,而抽样判定是通过的。根因是问题集分层不足,或者问题集版本半年没更新,跟当前的查询分布脱节。问题集要跟查询日志同步更新,每次重建记录用的是哪个版本。

阈值定得太松。 症状是切过去几天之后才从业务指标上发现质量下降。根因是拿点估计比阈值,或者样本量不足。第 6 节的两个函数就是为它准备的。

回滚后忘了关双写。 症状是回滚之后写放大一直存在,磁盘水位缓慢上升,几周后才被发现。回滚流程里要显式记录双写状态,把“关闭双写”作为回滚清单的一项,不能靠人记。

适用边界。 这套流程要求写入路径可控、单集群、文档 ID 稳定。跨集群场景下别名不跨集群,需要另一层路由(比如应用侧的索引名配置或独立的查询网关),判定方法可以复用,切换机制要重做。文档 ID 不稳定的场景(重建时重新生成 ID)无法做文档级比对,只能退到答案级比对,样本量要显著加大,因为答案级比对的噪声更大。写入路径完全不受控(第三方应用直连 Elasticsearch 写)时双写无从谈起,只能走停机窗口或者让第三方配合改造。

9. 切换耗时和回滚耗时分开验收

这两个指标经常被合成一个“切换耗时”,实际上它们测的是两件不同的事,一个是正常路径的性能,一个是异常路径的性能,混在一起会让回滚能力被高估。

切换耗时要拆成三段量:T0 是发出 POST /_aliases 的时刻;T1 是接口返回且别名验证通过(直查 GET _alias/docs 确认指向新索引)的时刻;T2 是通过别名写入的探测文档能被别名查到的时刻。T1-T0 通常在一秒以内,这一段几乎没有优化空间。真正有波动的是 T2-T0,它反映的是客户端缓存、连接池、网关配置这些外围因素,可能从几十毫秒到几十秒。验收要盯 T2-T0,不是 T1-T0。

回滚耗时同样三段:决策时刻、反向 POST 返回时刻、旧索引重新承接查询的时刻。在双写未停的前提下,这三段加起来应当在秒级。验收的方法不是估算,是在预发环境用同一套脚本演练一次,记录真实数字,并且这个数字要写进发布检查单——如果某次演练发现回滚要四十秒,那说明外围缓存比预期严重,切换方案本身要重新评估。

切换窗口的可用性单独记录:切换前后各三十分钟的查询错误率、P99、超时数。别名切换本身不应该引起任何一项变化,如果引起了,说明有请求路径没走别名。

数据完整性在切换瞬间做一次定点检查:从新旧索引各抽一批 doc_id(包含双写窗口内的新文档、回填覆盖的历史文档、墓碑文档三类),比对 revision。这一批必须全等,不等就不能继续观察期。

验收记录要落盘,作为下一次重建的基线。下一次的切换耗时如果比这次高出一个数量级,说明基础设施变了,值得查。

总结

这套流程的判定可以压缩成三个同时成立的条件,缺一条就不切:

  • 对账差值为空,且连续若干分钟没有新的差异产生——说明双写和对账把增量对齐了;
  • 固定问题集的 Wilson 上界低于阈值,且单向丢失条数为零——说明新索引的检索行为可接受;
  • 双写仍在运行,旧索引完整——说明回滚窗口还开着。
  • 三个条件里,第三条最容易被提前放弃。它不产生任何指标,看起来只是“多跑一倍的写入”,但它是唯一能在切换出问题时把损失控制在秒级的机制。观察期结束之前,旧索引的价值不是“备份”,是“退路”。

    如果只从这篇文章里带走一件事:把“停双写”当成一个比“切别名”重得多的决定,单独审批,单独记录,不要让它混在重建脚本的最后一行。

    参考资料

  • Elasticsearch 别名参考文档(检索于 2026-10):https://www.elastic.co/guide/en/elasticsearch/reference/current/aliases.html
  • Elasticsearch 别名 API(_aliases 原子 action):https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-aliases.html
  • Elasticsearch 乐观并发控制与 version_type=external:https://www.elastic.co/guide/en/elasticsearch/reference/current/optimistic-concurrency-control.html
  • Elasticsearch 索引 API(version 与 version_type 参数):https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html
  • Elasticsearch Reindex API 与版本行为:https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-reindex.html
  • Elasticsearch 多文档查询 API(对账用 mget):https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-multi-get.html
  • Elasticsearch Point-in-Time API(回填快照):https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html
  • Elasticsearch 分页与 search_after(回填分片推进):https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html
  • Elasticsearch 集群状态 API(确认别名生效):https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-state.html
  • OpenSearch 索引别名文档(对照实现):https://opensearch.org/docs/latest/im-plugin/index-alias/
  • Brown, Cai, DasGupta, Interval Estimation for a Binomial Proportion, Statistical Science 16(2), 2001:https://projecteuclid.org/journals/statistical-science/volume-16/issue-2/Interval-Estimation-for-a-Binomial-Proportion/10.1214/ss/1009213286.full
  • NIST/SEMATECH e-Handbook 中二项比例的置信区间一节:https://www.itl.nist.gov/div898/handbook/prc/section2/prc241.htm
  • 赞(0)
    未经允许不得转载:网硕互联帮助中心 » 重建索引的那两个小时线上不能停:双写窗口、别名原子切换与新旧索引答案一致性抽样
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!