Elasticsearch:从词项检索到写入、复制与恢复
固定源码基线:Elasticsearch
v8.15.1、Lucenereleases/lucene/9.11.1。ES version.properties 同时声明这两个版本。固定基线用于解释实现,不代表推荐生产版本;真实实验必须另外记录服务返回的版本。
搜索“包含 apple 的商品”,按价格排序,再统计各类目的数量,包含三种访问方向:按词找文档、按文档读排序值、按值分组聚合。它们共同服务搜索,但不是同一个索引结构。把 ES 当作事实源的异步检索投影时,还要回答四件事:文档是否写入、何时能搜索、哪些副本确认、故障后靠什么恢复。全文按这条问题链展开。
查询契约:mapping 决定能够回答什么问题
假设商品 a 的名称为 Red Apple,使用默认 standard analyzer 的 name:text 会产生 red、apple 等词项。match name:"apple" 分析查询输入后检索词项;term name:"Red Apple" 不把输入重新分词,通常找不到这个完整词项。name.raw:keyword 则可以精确检索完整名称。这里的结果依赖实际 analyzer/normalizer,不能把某个输入的结果推广成“term 不能查询 text”。用 _analyze 核查 token,再核查 mapping 与查询,是排错的第一步。
| 结构 | 查找方向与用途 | 成本或边界 |
|---|---|---|
| terms dictionary + postings | 字段/词项 → segment 内 doc ID;按配置还有词频、位置、offset | AND 可求交、OR 可合并;短语需要位置,词项编码依赖 codec |
| doc values | 文档 → 字段值;排序、聚合、部分脚本 | 增加磁盘与写入成本;text 默认不能像 keyword 一样直接做这些操作 |
stored fields / _source | 命中后返回文档内容 | 取回内容不等于参与倒排匹配 |
| 数值/地理等专用结构 | 适合相应范围、空间等查询 | 不能把所有字段结构概括成字符串倒排 |
例如 postings 中 apple → [a,b],按 created_at 排序还需要读取 a、b 的排序值。doc values 不会代替倒排回答“谁包含 apple”,倒排也不是排序值的唯一存储。Lucene doc ID 是 reader/segment 内部位置,merge 后会变,业务标识应使用稳定 _id,需要排序的业务标识另设 keyword 字段。
对象数组还有更隐蔽的语义差异。下面两个商品都具备 key 集合 {color,size} 和 value 中的 red:
<!-- code-cue: 实验输入 -->
{
"a": [{"key":"color","value":"red"},{"key":"size","value":"large"}],
"b": [{"key":"color","value":"blue"},{"key":"size","value":"red"}]
}普通 object 将数组对象扁平为多值字段,key=color AND value=red 可同时命中 a、b:b 的两个条件来自不同元素。若业务问题是“同一个属性元素的 key 为 color 且 value 为 red”,必须将字段映射为 nested,并把两个条件放入同一个 nested 查询;这时仅 a 应命中。nested 把对象作为独立的隐藏 Lucene 文档保存,通过 block join 关联父文档;增加文档数、查询和更新成本。只把 mapping 改成 nested、却把条件放到两个独立 nested 查询,仍可能分别匹配不同元素。
mapping 是数据契约:任意 JSON 键触发动态字段增长,可能造成字段爆炸;显式字段、受控动态模板、flattened 分别适合稳定 schema、受控扩展和宽松键值检索。flattened 不等价于完整的 nested 或数值字段语义。已存在字段不能直接改成不兼容类型;换 analyzer 也不会替所有历史文档重新分词。需要改访问契约时,通常新建索引、重建并切 alias,而不是只修改应用查询。
来源:analysis、term query、doc values、nested、mapping。
词项定位之后,postings 与计分怎样接起来
一条 ES 查询会生成 Lucene 查询树。以最小的 TermQuery 为例:TermQuery → TermWeight → 每个 LeafReaderContext 的 scorerSupplier → TermsEnum → postings/impacts → TermScorer。LeafReaderContext 对应分段读取上下文;某个段没有词项就无需创建该段 scorer。下面是 Lucene 9.11.1 TermQuery.TermWeight.getTermsEnum 的连续原码(仅统一公共缩进)。
原码:TermQuery.java,releases/lucene/9.11.1,L181–L195。完整文件 SHA-256:4cea404b3677e51b7495d984244d3774cb897148ccc5ff05534dbb1a53e5b5f5。
private TermsEnum getTermsEnum(LeafReaderContext context) throws IOException {
assert termStates != null;
assert termStates.wasBuiltFor(ReaderUtil.getTopLevelContext(context))
: "The top-reader used to create Weight is not the same as the current reader's top-reader ("
+ ReaderUtil.getTopLevelContext(context);
final TermState state = termStates.get(context);
if (state == null) { // term is not present in that reader
assert termNotInReader(context.reader(), term)
: "no termstate found but term exists in reader term=" + term;
return null;
}
final TermsEnum termsEnum = context.reader().terms(term.field()).iterator();
termsEnum.seekExact(term.bytes(), state);
return termsEnum;
}TermState 必须来自对应 reader;缺失时返回 null,存在时按字段取得 TermsEnum,再用 term bytes 和状态定位。删掉 reader 身份约束、把旧 reader 的状态或 doc ID 带到新 reader,不是合法的业务游标。词项字典也不是一个跨全索引的全局哈希表。
定位后,ScorerSupplier.get 按 ScoreMode 选择枚举和评分需求。以下保留原有固定版本片段,来自同文件 L136–L148:
原码:TermQuery.java,releases/lucene/9.11.1,L136–L148。完整文件 SHA-256:4cea404b3677e51b7495d984244d3774cb897148ccc5ff05534dbb1a53e5b5f5。
if (scoreMode == ScoreMode.TOP_SCORES) {
return new TermScorer(
TermWeight.this,
termsEnum.impacts(PostingsEnum.FREQS),
scorer,
topLevelScoringClause);
} else {
return new TermScorer(
TermWeight.this,
termsEnum.postings(
null, scoreMode.needsScores() ? PostingsEnum.FREQS : PostingsEnum.NONE),
scorer);
}TOP_SCORES 使用 impacts 信息帮助 top hits 计算与跳过无竞争力的部分;其他模式通过 postings 取得文档枚举,按 needsScores 决定是否请求词频。TermWeight 还会在需要评分时收集字段/词项统计并构造 Similarity scorer;默认 BM25 结合词频、文档长度与词项稀有程度等因素排序,分数不是业务事实的可信度。
query context 通常需要相关性;filter context 只判断匹配,适合租户、状态、时间等条件。不计分仍要枚举、求交与收集,并非零成本。“放进 filter 就会缓存”也不是保证:查询缓存策略、复用频率与段生命周期共同决定收益。高基数 terms 聚合还要预算分片候选桶、误差与内存,可能触发 circuit breaker;不能因为过滤后命中少,就忽略聚合成本。
同一 ID 的更新先过并发条件,再写 Lucene 与 translog
常规索引更新不是原位覆盖几个字段字节。Lucene 9.11.1 的 IndexWriter.updateDocument 将删除条件与新文档一起交给更新入口;该操作对同一索引的 reader 呈现原子的删除加新增效果。以下是该方法完整原码:
原码:IndexWriter.java,releases/lucene/9.11.1,L1845–L1848。完整文件 SHA-256:7e8ae3fd59ca09a62c4613c1fcb2307cbe69d87c89da85bf653a5bb4b12031b8。
public long updateDocument(Term term, Iterable<? extends IndexableField> doc) throws IOException {
return updateDocuments(
term == null ? null : DocumentsWriterDeleteQueue.newNode(term), List.of(doc));
}调用链继续进入私有 updateDocuments → docWriter.updateDocuments;缓冲写入、段形成、reader 重开与 Lucene commit 是不同阶段。ES 还可使用 soft deletes 保留历史,merge 只会回收允许清理的旧版本。Lucene 本身存在专用 doc-values 更新接口,也不意味着 ES 普通部分更新就是对原文档字段做原位修改。
在 ES 层,InternalEngine.index → indexingStrategyForOperation 区分主操作与非主操作。主操作进入 planIndexingAsPrimary,先核对当前版本,再决定 append/update 或提早失败。下面是 ES v8.15.1 的连续原码,展示 OCC 与外部版本的检查顺序:
原码:InternalEngine.java,v8.15.1,L1347–L1386。完整文件 SHA-256:931dadba6e4bb0de6ecb345936551c5af659540a5bb9bce4e556cea4e10b164a。
if (index.getIfSeqNo() != SequenceNumbers.UNASSIGNED_SEQ_NO && currentNotFoundOrDeleted) {
final VersionConflictEngineException e = new VersionConflictEngineException(
shardId,
index.id(),
index.getIfSeqNo(),
index.getIfPrimaryTerm(),
SequenceNumbers.UNASSIGNED_SEQ_NO,
SequenceNumbers.UNASSIGNED_PRIMARY_TERM
);
plan = IndexingStrategy.skipDueToVersionConflict(e, true, currentVersion, index.id());
} else if (index.getIfSeqNo() != SequenceNumbers.UNASSIGNED_SEQ_NO
&& (versionValue.seqNo != index.getIfSeqNo() || versionValue.term != index.getIfPrimaryTerm())) {
final VersionConflictEngineException e = new VersionConflictEngineException(
shardId,
index.id(),
index.getIfSeqNo(),
index.getIfPrimaryTerm(),
versionValue.seqNo,
versionValue.term
);
plan = IndexingStrategy.skipDueToVersionConflict(e, currentNotFoundOrDeleted, currentVersion, index.id());
} else if (index.versionType().isVersionConflictForWrites(currentVersion, index.version(), currentNotFoundOrDeleted)) {
final VersionConflictEngineException e = new VersionConflictEngineException(
shardId,
index.parsedDoc().documentDescription(),
index.versionType().explainConflictForWrites(currentVersion, index.version(), true)
);
plan = IndexingStrategy.skipDueToVersionConflict(e, currentNotFoundOrDeleted, currentVersion, index.id());
} else {
final Exception reserveError = tryAcquireInFlightDocs(index, reservingDocs);
if (reserveError != null) {
plan = IndexingStrategy.failAsTooManyDocs(reserveError, index.id());
} else {
plan = IndexingStrategy.processNormally(
currentNotFoundOrDeleted,
canOptimizeAddDocument ? 1L : index.versionType().updateVersion(currentVersion, index.version()),
reservingDocs
);
}
}if_seq_no 已指定而文档不存在/已删除,直接形成冲突计划;文档存在但 seqNo 或 primary term 任一不匹配,也冲突。之后才检查 versionType,再获取在途文档额度形成正常计划。因此:
if_seq_no + if_primary_term表达“我读到的这版 ES 文档仍是当前版本吗”,用于一次读改写的乐观并发控制。只带序号不足以表达这个条件,也不是跨索引业务时钟。- 外部版本表达事实源的单调业务版本。迟到 version=1 不能覆盖已写入的 version=2;strict external 下重复 version=2 也冲突,409 不自动等于幂等成功。还要核对操作身份和 payload;删除需要 tombstone/版本屏障与保留策略,不能假定删掉文档后,任意迟到旧事件永远不会把它重建。
- 预检冲突可在分配操作序号之前结束;已经分配序号后的文档失败则不能留下永远无法解释的序号洞。
正常计划分配或登记 seqNo 后进入 Lucene,随后处理 translog。以下连续原码从原有 L1212–L1235 扩到 L1242,补全 no-op 分支:
原码:InternalEngine.java,v8.15.1,L1212–L1242。完整文件 SHA-256:931dadba6e4bb0de6ecb345936551c5af659540a5bb9bce4e556cea4e10b164a。
if (plan.indexIntoLucene || plan.addStaleOpToLucene) {
indexResult = indexIntoLucene(index, plan);
} else {
indexResult = new IndexResult(
plan.versionForIndexing,
index.primaryTerm(),
index.seqNo(),
plan.currentNotFoundOrDeleted,
index.id()
);
}
}
if (index.origin().isFromTranslog() == false) {
final Translog.Location location;
if (indexResult.getResultType() == Result.Type.SUCCESS) {
location = translog.add(new Translog.Index(index, indexResult));
} else if (indexResult.getSeqNo() != SequenceNumbers.UNASSIGNED_SEQ_NO) {
// if we have document failure, record it as a no-op in the translog and Lucene with the generated seq_no
final NoOp noOp = new NoOp(
indexResult.getSeqNo(),
index.primaryTerm(),
index.origin(),
index.startTime(),
indexResult.getFailure().toString()
);
location = innerNoOp(noOp).getTranslogLocation();
} else {
location = null;
}
indexResult.setTranslogLocation(location);
}这里先执行 Lucene 操作或形成相应 IndexResult;不是从 translog 重放时,成功追加 Translog.Index,带有效 seqNo 的失败调用 innerNoOp,记录这个序号已经以失败/空操作结束。否则后续完成的更大序号不能让连续 checkpoint 越过该洞。未分配序号的预检失败不需要凭空补一个序号;translog 重放也不能再重复追加同一日志。
方法随后更新 version map,并调用 localCheckpointTracker.markSeqNoAsProcessed。processed 与 persisted 进度有区别,不能看到 Lucene 操作完成就声称已经 fsync。auto-generated ID 在安全条件下可用 addDocument 优化;重试与时间戳安全边界会让它退化到 updateDocument,吞吐优化必须保留去重正确性。stale operation 的历史保留路径也不表示允许旧业务数据覆盖新版本。
可见性、耐久性与段整理是三条边界
图是机制结构图,箭头表达依赖与分支,不给复制 ACK 与 refresh 安排固定时序。
| 机制 | 改变的状态 | 不能由它单独推出什么 |
|---|---|---|
| refresh | 重开/发布搜索 reader,让已索引变化进入后续搜索视图 | 不等于 Lucene commit、translog fsync 或副本 ACK |
| translog 同步 | 在配置与存储假设下持久化近期操作,支持 commit 后日志重放 | 不负责把变化发布给搜索 reader |
| ES flush | Lucene commit,并启动新的 translog generation,降低恢复重放量 | 不是用户必须在每次成功写后执行的动作 |
| merge | 合并段,回收满足条件的删除/历史版本,调整查询结构 | 不代替业务一致性、复制确认或备份 |
默认 index.translog.durability=request 时,成功响应前按文档要求等待主与已分配副本的 translog 同步和提交条件;改为 async 则允许最近尚未同步的操作在崩溃时丢失。保证依赖参与复制的副本、存储 fsync 行为与故障模型;不能推导任意多节点永久丢盘都无损。
realtime GET by ID 可从当前文档状态取得结果,search 读取已发布的搜索视图,所以“GET 找到,search 暂时没找到”可以完全正常。反过来,一个尚未收到写入响应的请求,也可能因并发 refresh 而已可搜索;客户端超时不能当作没有写入。写 API、搜索 API、协调节点与副本的观察时点需要分别记录。
refresh=true 强制相关分片刷新,可能制造小段并增加 merge 成本;refresh=wait_for 通常等待后续刷新再响应,消耗请求等待与 refresh listener 预算,listener 容量耗尽还可能触发强制刷新。若 refresh_interval=-1,不能依赖周期刷新来结束普通等待,必须设计显式刷新、恢复配置与客户端超时。选择由写吞吐和可搜索延迟目标共同决定。
来源:near real time、translog、refresh 参数。
主副本:前置门槛、连续进度与恢复材料
写入先定位 primary shard,再在 replication group 内协调。下面是 ES v8.15.1 ReplicationOperation.execute 的完整连续方法:
原码:ReplicationOperation.java,v8.15.1,L107–L127。完整文件 SHA-256:0523ec2ab77c183b66e900b0b4cafe0a55fcbdb95a54ae3f9f3a3e8266580706。
public void execute() throws Exception {
final String activeShardCountFailure = checkActiveShardCount();
final ShardRouting primaryRouting = primary.routingEntry();
final ShardId primaryId = primaryRouting.shardId();
if (activeShardCountFailure != null) {
finishAsFailed(
new UnavailableShardsException(
primaryId,
"{} Timeout: [{}], request: [{}]",
activeShardCountFailure,
request.timeout(),
request
)
);
return;
}
totalShards.incrementAndGet();
pendingActions.incrementAndGet(); // increase by 1 until we finish all primary coordination
primary.perform(request, ActionListener.wrap(this::handlePrimaryResult, this::finishAsFailed));
}active shard 数不足时,操作在这里失败;满足门槛才调用 primary.perform。wait_for_active_shards 是开始执行前的可用副本门槛,集群随后仍会变化。默认门槛并不要求所有配置副本已分配,更不能把 all 解释成整个请求过程中固定不变的多数提交协议。
主结果回来后,handlePrimaryResult 取得当时的 replication group,向相应目标复制,并将不能继续作为同步副本的成员纳入 stale/failure 协调。若复用主操作执行前的旧 group,可能遗漏刚进入恢复的目标,使其缺失恢复开始后的操作;副本失败也不能简单忽略后还把它当作可晋升的同步副本。旧主被撤销权限时要停止推进,primary term 与 in-sync allocation 身份参与这条边界。
local checkpoint 是某副本已连续完成操作的边界,不等于见过的最大 seqNo。若 40、42 已完成但 41 尚未完成,checkpoint 仍只能到 40;41 变成有效完成操作或相应 no-op 后,才可推进到 42。global checkpoint 由跟踪到的 in-sync 副本进度约束,表示共同安全处理的连续前缀;它不是“最新文档号”,也不表示某个 reader 已 refresh。序号仅在分片历史中解释,不能作为跨索引事务版本。
恢复则先回答“目标能否从某个序号接上相同历史”。ES v8.15.1 RecoverySourceHandler.recoverToTarget 先持历史保留锁,再做如下判断:
原码:RecoverySourceHandler.java,v8.15.1,L205–L229。完整文件 SHA-256:7d880a717729dcf1325a1790038ef5df43c5f99490c35270dd6b27b4836132b4。
private void recoverToTarget(RetentionLease retentionLease, Consumer<Exception> onFailure) throws IOException {
final Closeable retentionLock = shard.acquireHistoryRetentionLock();
resources.add(retentionLock);
final long startingSeqNo;
final boolean isSequenceNumberBasedRecovery = request.startingSeqNo() != SequenceNumbers.UNASSIGNED_SEQ_NO
&& isTargetSameHistory()
&& shard.hasCompleteHistoryOperations("peer-recovery", request.startingSeqNo())
&& ((retentionLease == null && shard.useRetentionLeasesInPeerRecovery() == false)
|| (retentionLease != null && retentionLease.retainingSequenceNumber() <= request.startingSeqNo()));
// NB check hasCompleteHistoryOperations when computing isSequenceNumberBasedRecovery, even if there is a retention lease,
// because when doing a rolling upgrade from earlier than 7.4 we may create some leases that are initially unsatisfied. It's
// possible there are other cases where we cannot satisfy all leases, because that's not a property we currently expect to hold.
// Also it's pretty cheap when soft deletes are enabled, and it'd be a disaster if we tried a sequence-number-based recovery
// without having a complete history.
if (isSequenceNumberBasedRecovery && retentionLease != null) {
// all the history we need is retained by an existing retention lease, so we do not need a separate retention lock
retentionLock.close();
logger.trace("history is retained by {}", retentionLease);
} else {
// all the history we need is retained by the retention lock, obtained before calling shard.hasCompleteHistoryOperations()
// and before acquiring the safe commit we'll be using, so we can be certain that all operations after the safe commit's
// local checkpoint will be retained for the duration of this recovery.
logger.trace("history is retained by retention lock");
}四个条件必须同时成立:有效 startingSeqNo、相同 history、所需操作完整、lease 覆盖或代码允许无 lease 的恢复模式。即使 lease 存在,也检查完整历史,不能由“有 lease”直接推出数据必齐。只有已有 lease 覆盖所需范围时才释放独立保留锁;否则锁保证判断后到恢复期间历史不会被清掉,避免“检查时齐,读取时没了”的竞争。
选择结果在后面分叉,以下是另一段连续原码,不能删掉两段间的行后伪装为一个连续方法:
原码:RecoverySourceHandler.java,v8.15.1,L239–L254。完整文件 SHA-256:7d880a717729dcf1325a1790038ef5df43c5f99490c35270dd6b27b4836132b4。
if (isSequenceNumberBasedRecovery) {
logger.trace("performing sequence numbers based recovery. starting at [{}]", request.startingSeqNo());
startingSeqNo = request.startingSeqNo();
if (retentionLease == null) {
createRetentionLease(startingSeqNo, sendFileStep.map(ignored -> SendFileResult.EMPTY));
} else {
sendFileStep.onResponse(SendFileResult.EMPTY);
}
} else {
final Engine.IndexCommitRef safeCommitRef;
try {
safeCommitRef = acquireSafeCommit(shard);
resources.add(safeCommitRef);
} catch (final Exception e) {
throw new RecoveryEngineException(shard.shardId(), 1, "snapshot failed", e);
}true 分支从 startingSeqNo 做基于操作的恢复,必要时建立 lease;else 取得 safe index commit 作为文件基线,随后 phase1 传文件,再补所需操作。因此“离线只有半小时”不是增量恢复的充分条件。history 不同、历史已回收、lease 不能覆盖等,都可能转为文件基线。复制恢复所需的历史可依靠 Lucene soft deletes 保留;translog 的本地崩溃重放职责与 peer recovery 的历史保留不能混成“所有复制只读 translog”。
纸面输入:同 history,目标需要 101 起,源完整保留 90 起,lease retainingSequenceNumber=100,满足上述范围条件;lease=120 时不覆盖 101,不能选该操作恢复分支。即使 lease=100,若完整历史检查失败,仍不能按操作恢复。有限模型会核查这些条件,不模拟文件校验、网络复制或选主。
文件恢复要估 segment 字节、有效磁盘与网络吞吐、限速、校验和在线 merge 竞争。副本还不是备份:误删可以正常复制;snapshot repository 与定期恢复演练承担误操作、整集群损毁等另一类故障。来源:复制模型、peer recovery、snapshot。
分片检索与分页:从候选预算到稳定游标
协调节点向相关分片发 query,各片收集局部候选,再合并全局结果、fetch 所需内容。普通 from+size 分页要为丢弃的前缀付成本:假设 12 片、各片命中足够、允许将 result window 调大,from=100000,size=20 的候选预算为:
<!-- code-cue: 教学算例 -->
每片候选深度 = 100000 + 20 = 100020
12 片候选槽位 = 12 × 100020 = 1,200,240
最终页面只有 20 条默认 index.max_result_window=10000 会先拒绝这个窗口;调高只是允许请求,不会取消这项开销。1,200,240 是充分命中假设下的朴素候选规模,不是精确内存字节:实际成本还受命中、索引排序、跳过优化和收集器影响。不要把深分页问题处理成“改一个配置然后看 CPU”。
search_after 让下一页从上一页最后的完整排序元组之后继续;它不支持无代价随机跳第 5000 页,也不会单独固定跨页视图。PIT 则保留读取视图,避免中途刷新带来的集合变化。对三条文档 a/b/c,排序值分别 (100,a)、(100,b)、(200,c),只保存时间 100 无法定位 a 与 b 之间的边界。
<!-- code-cue: 请求形状示例 -->
{
"size": 1,
"pit": {"id":"<最近一次响应中的 PIT ID>","keep_alive":"1m"},
"sort": [{"created_at":"asc"},{"item_id":"asc"}],
"search_after": [100,"a",4294967296]
}最后一个数仅示意 PIT 添加的 _shard_doc tiebreaker,不是要硬编码的 ID。应将上页最后 hit 的 完整 sort 数组原样带回,保持相同查询与排序,使用最新响应中的 PIT ID,并在 finally 关闭 PIT。若多索引字段日期精度不同,还要统一 sort 的 numeric_type/format。PIT 外新增 d 并 refresh 后,已有 PIT 仍应只遍历初始 a/b/c;重新打开 PIT 才形成新视图。
PIT 保留旧段与必要状态,占文件描述符、磁盘和上下文资源;keep_alive 与并发数要有预算,过期后要重开并决定是否重启业务遍历。它不是长期一致性备份,也不会给两个独立 PIT 之间提供一个全局事务。
routing 可将租户查询缩到一片,却可能把大租户的读写全压到同一片。增加 shard 数不会拆开同一 routing 值;还会增加 reader、文件、恢复和 fan-out 成本。副本主要增加可用性与读取能力,写入仍需复制,不能按副本数线性估算写扩容。来源:分页与 PIT。
用有限模型和真实 HTTP 实验区分证据
入口为 ES 实验说明 与 可下载运行脚本。在仓库根目录使用:
<!-- code-cue: 实验命令 -->
bash labs/data-senior/elasticsearch/run.sh --model
bash labs/data-senior/elasticsearch/run.sh --service--model 只需要 Python 3,核查预置的可见性、OCC、PIT 游标、数组关联与 history/lease 有限状态,以及深分页候选算例。恢复模型只涵盖给定非空 lease 的子集;允许无 lease 的源码分支、checkpoint 连续性仍属上文源码/纸面推导,不在该模型的运行范围。它不是 Lucene、复制集或掉电模拟器。--service 通过 Python 标准库 HTTP 访问已有 ES,先记录根响应中的 ES/Lucene 版本,再创建唯一命名空间;它只操作本次创建的索引和 PIT,finally 关闭 PIT、清理这些索引。连接地址、认证等具体参数见实验说明,不需要为模型安装或启动 ES。
本轮实际运行 --model 返回成功:候选槽位为 1,200,240,PIT 集合为 a/b/c、当前集合为 a/b/d/c,object 匹配 a/b、同元素匹配 a,恢复条件分别返回 true/false/false/false。这些是有限输入的模型输出,不能据此宣称观察到了真实 ES 的同名行为。
服务输入固定为 a/b/c 三条文档:created_at=100/100/200,item_id=a/b/c;name 同时有 text 与 raw keyword;attrs 为 object,nested_attrs 保存相同内容但映射为 nested。索引使用 1 primary/0 replica、refresh_interval=-1 来隔离变量,步骤和断言为:
- 写 a/b/c 后,realtime GET 能读取已写 ID;搜索仍为 0 条。显式 refresh 后搜索为 3 条。禁用周期刷新隔离了可见性,不在测默认一秒刷新的准时性。
match name:apple命中 2 条,term name:"Red Apple"命中 0,term name.raw:"Red Apple"命中 1。普通 object 的跨元素条件命中 2,单个 nested 查询命中 1。- 取 a 的 seqNo/primary term,合法更新一次,再用旧二元组提交不同内容,预期 409,且旧请求没有覆盖新值。这验证 OCC,不是外部业务版本或跨索引事务。
- 以 created_at、item_id 排序打开 PIT,每次携上次最后 hit 的完整 sort 数组与最新 PIT ID 续页。打开后新增 d 并 refresh,已有 PIT 仍恰好取到初始 a/b/c,各一次,最后关闭 PIT。
T18 首次探测时两个端口均拒绝连接;T37 于 2026-10-24 在本机已有 127.0.0.1:9200 的 ES 8.19.23 / Lucene 9.12.2 完成真实 API 复核。使用 --server-version 8.19.23 明确运行版本,源码仍固定 8.15.1/9.11.1。实际得到 refresh 前后0/3、match/term/keyword 2/0/1、object/nested 2/1、旧版本条件409、实时搜索4而PIT仍为a/b/c;PIT和自己的UUID索引已清理。完整命令与范围见实验说明。单节点结果不能证明复制、选主、lease 续期或故障恢复。segments/translog stats 可作结构观测,不能证明断电无损;如改用 bulk 输入,还必须检查每个 item,HTTP 200 不代表每项成功。
故障与条件变化:怎样改变方案
“写成功却搜不到”先定位事实源提交、投影事件水位、bulk item 结果、目标索引/alias、GET 与 search,再核查 refresh、analyzer、nested 和过滤条件。若事实源事件尚未到达,强制 refresh 无法制造缺失文档;若查到旧索引,调 refresh_interval 也没有修正目标。保留稳定 ID、业务版本、事件位置和查询条件,才能把延迟与错误分开。
| 条件变化 | 可选方案与代价 | 需要补的证据 |
|---|---|---|
| 可搜索延迟由 5 秒收紧到写后 200ms | 关键请求 wait_for、缩短刷新间隔,或写后详情走事实源;前两者增加等待/小段成本,后者不等于全文搜索立即更新 | 源提交→投影到达→可搜索的分段延迟与尾延迟,不能只测 refresh |
| 总量由 200GB 到 4TB,要求快速恢复 | 更多且适度大小的 primary shard 缩小单片恢复单位,同时增加文件、协调与运维成本;副本和 snapshot 对应不同故障 | 100GB ÷ 有效 100MB/s ≈ 1000s 只是十进制传输下界,还要加校验、重放与竞争 |
| 一户占 50% 流量 | routing 降 fan-out,却聚集热点;为大租户拆 routing/索引并改应用查询,增加路由与聚合管理 | 每片字节、命中、写入率、队列与恢复时长,不能只看集群平均 CPU |
| mapping/analyzer 需要不兼容变更 | 新索引按业务版本回填并接增量,核对水位和关键查询后原子切 alias;双写/回滚增加运行成本 | 切换期间增量边界、迟到事件、删除屏障,回滚前旧索引是否也追平 |
alias 的原子切换只改变一次索引指向,不会令事实源提交与投影写入变成跨系统原子事务。跨存储证据链可继续读 全链路性能案例;MongoDB 的文档与复制保证见 MongoDB 正文,不能将其 write concern 套到 ES。
高级口述与三轮追问
主问题:“写入返回成功,用户为什么可能还搜不到?”参考回答应串联事实源/事件、目标索引和写响应、translog 耐久、reader refresh、查询契约;GET 与 search 对照可定位可见性,不能用前者证明全文检索已更新。四个评分点各 1 分:状态链完整、可见与耐久分开、核查 mapping/查询、给出可执行证据。
第一轮:“排序聚合能只靠 postings 吗?”答案:倒排按词找 doc,doc values 按 doc 读列值;text/keyword 的结构配置和成本不同,filter 不计分仍有枚举工作。方向与成本各 1 分。
第二轮:“价格 version=2 已写,迟到 version=1 怎么办?”答案:稳定文档身份加事实源单调版本约束,明确重复与删除策略;seqNo+primary term 只保护当前 ES 文档的读改写,不能替代跨索引业务版本。约束/删除与两类版本区别各 1 分。
第三轮:“副本离线半小时,一定操作恢复吗?”答案:检查 history、startingSeqNo、完整历史与 lease/允许无 lease 条件;不满足就以 safe commit 传文件再补操作,RTO 需测恢复字节与有效吞吐。条件与失败分支各 1 分。
总计 10 分,8 分可作口述训练目标;把 refresh 当 fsync、把 active shard 门槛当固定多数、或把模型说成真实复制实测,都应回到相应机制重讲。自评分数不是客观能力认证。
闭卷验收
本页 9 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。