Elastic 9.5 深度拆解:当搜索引擎主动删掉倒排索引——Columnar Mode、ES95 编解码器与「3 字节/采样点」的存储战争
2026 年 8 月 5 日,Elastic 发布了 9.5。这个版本里有一堆花哨的东西:AI 仪表板、Agent Builder 可观测性、Attack Discovery、Workflows 自然语言编排。但如果你是个干活的工程师,把发布公告翻到中间那一段,会看到一句轻描淡写的话:
Columnar Mode 是一种可选启用的索引模式,默认情况下将每个字段仅存储一次到列式存储中,而不创建倒排索引。
读三遍。
一个靠倒排索引吃了十五年饭的搜索引擎,现在告诉你:默认不建倒排索引了。
这不是加了个功能,这是承认了一件事——Elasticsearch 集群里绝大部分数据,从来就没被"搜索"过。
这篇文章不打算复述发布公告。我想拆的是三个更硬的东西:
- Columnar Mode 在 Lucene 层面到底删掉了什么,为什么 index sorting 突然变成硬性前置条件;
- ES95 编解码器把指标压到「约 3 字节/采样点」,这个数字放在 Prometheus / VictoriaMetrics / ClickHouse 的坐标系里是什么位置;
- ES|QL Fast Mode 用采样换 100 倍速度,这套统计学在什么场景下会把你送进事故复盘会。
顺带把 VectorDB 索引模式和 DiskBBQ 自动校准也拆一遍——那是另一个「把专家经验编码成算法」的典型案例。
一、背景:一个搜索引擎的身份危机
1.1 你的 ES 集群里,有多少数据真的被搜索过
先做个思想实验。打开你手上任何一个生产 ES 集群,看看索引列表:
logs-nginx-access-2026.08.10 1.2 TB
logs-app-java-2026.08.10 890 GB
metrics-k8s-container-2026.08.10 340 GB
traces-apm-2026.08.10 210 GB
security-endpoint-2026.08.10 1.8 TB
products-catalog 12 GB
users-profile 3 GB
前五个索引占了 99.7% 的存储。它们的访问模式是什么?
- Grafana / Kibana 仪表板:
STATS avg(duration) BY service.name, @timestamp bucket - 告警规则:
WHERE http.response.status_code >= 500 | STATS count() BY host.name - 事故排查:
WHERE trace.id == "abc123"或者WHERE message LIKE "*OutOfMemory*" - 合规审计:全量导出到冷存
只有最后两个索引(products-catalog、users-profile)在做真正的全文检索:BM25 打分、同义词扩展、拼写纠错、高亮。
而 ES 对这七个索引一视同仁——每个 keyword 字段都建倒排索引,每个 long 字段都建 BKD 树,然后再存一份 doc_values,再存一份 _source。
一份数据,存三遍。
1.2 倒排索引的成本账,比你想的贵
很多人对倒排索引的成本估计停留在"就是个词典嘛"。我们把 Lucene 的实际磁盘布局摊开:
| 文件后缀 | 内容 | 典型占比 | 分析型负载用得上吗 |
|---|---|---|---|
.tim / .tip | Term dictionary + index(FST) | 5%~15% | 精确匹配用得上 |
.doc | Postings(doc IDs + freq) | 15%~30% | 用得上 |
.pos / .pay | Positions / payloads(短语查询) | 10%~25% | 几乎用不上 |
.nvd / .nvm | Norms(长度归一化,BM25 打分用) | 1%~3% | 完全用不上 |
.dvd / .dvm | Doc values(列存) | 20%~40% | 全靠这个 |
.fdt / .fdx | Stored fields(_source) | 20%~45% | 只在返回原文时用 |
.kdd / .kdi / .kdm | BKD 点树(数值/地理范围) | 5%~15% | 部分用得上 |
对一个 logs-* 索引来说,.pos、.pay、.nvd 这三类文件是纯浪费——你永远不会对 kubernetes.pod.name 做短语查询,也永远不会关心它的 BM25 打分。
更狠的是 .tim/.doc。日志里最常见的字段长什么样?
{
"host.name": "prod-web-042",
"kubernetes.namespace": "payment",
"log.level": "INFO",
"http.request.method": "GET",
"cloud.availability_zone": "cn-hangzhou-h"
}
这些是超低基数字段。log.level 一共就 5 个值,http.request.method 就 8 个值。给它们建 term dictionary,FST 里放 5 个 term,然后每个 term 挂一个覆盖了几千万文档的 postings list。
这个 postings list 有多大?假设一天 1 亿条日志,log.level: INFO 占 80%,那就是 8000 万个 doc ID。即使用 PFOR-delta 压缩,也是几十 MB 级别的东西。
而你查 log.level: INFO 的时候,实际上等于全表扫描——倒排索引在低选择度查询上毫无价值,还倒贴了存储。
反过来,doc_values 存 log.level 是什么形态?Lucene 的 SORTED doc values 会做字典编码:5 个值 → 3 bit ordinal,8000 万文档 = 30 MB 未压缩,配上 RLE(run-length encoding)之后,如果按 log.level 排过序,可能压到几 KB。
同样的数据,倒排索引几十 MB,列存几 KB。
1.3 logsdb 是前传,Columnar 是正片
Elastic 不是今天才想通这件事。2024 年底的 logsdb 索引模式就是第一次试探:
- 强制 synthetic
_source(不存原始 JSON,查询时从 doc_values 重建) - 默认按
host.name+@timestamp排序 - 官方口径:日志存储占用最多降低 65%
logsdb 干掉的是 _source。Columnar Mode 干掉的是倒排索引本身。
这是两个数量级不同的手术。前者是省掉一份冗余副本,后者是改变索引的本质结构。
二、核心概念:Columnar Mode 到底改了什么
2.1 「每个字段只存一次」的三层含义
官方那句"每个字段仅存储一次",拆开是三件事:
第一,不再有 _source 这份独立副本。 走 synthetic source 路线,_source 在查询时从 doc_values 现场重建。代价是重建有 CPU 开销,且字段顺序、数组去重、精度可能与原始 JSON 不完全一致。
第二,不再默认建倒排索引。 字段进来就是 doc_values(列存),除非你显式声明需要全文检索。
第三,mapping 自动扁平化。 嵌套对象 {"http": {"request": {"method": "GET"}}} 直接拉平成列名 http.request.method,不再维护对象层级的元数据开销。
2.2 两种索引模式:columnar 与 logsdb_columnar
9.5 给了两个入口:
PUT /analytics-events-2026.08
{
"settings": {
"index": {
"mode": "columnar",
"sort.field": ["tenant_id", "@timestamp"],
"sort.order": ["asc", "desc"]
}
}
}
columnar 是纯列式:所有字段都不建倒排索引。适合纯分析型负载——业务事件流、埋点数据、指标衍生表。
PUT /logs-app-java-2026.08
{
"settings": {
"index": {
"mode": "logsdb_columnar",
"sort.field": ["host.name", "@timestamp"],
"sort.order": ["asc", "desc"]
}
}
}
logsdb_columnar 是「列存 + 一个例外」:只在 message 字段上保留一个倒排索引,其余全部列存。
这个设计非常务实。日志排查的真实场景是:
# 这类查询需要倒排索引(高选择度、任意子串)
message: "*java.lang.OutOfMemoryError*"
# 这类查询列存就够了(低选择度、聚合)
STATS count() BY service.name, log.level
保留 message 的倒排索引,意味着 SRE 半夜捞异常堆栈的体验完全不变,而 99% 的存储成本来自其他字段,那部分全部列存化。
这是我认为 9.5 里最聪明的一个取舍。 不是二选一,而是精准识别出「唯一真正需要全文检索的字段」,把它单独拎出来。
2.3 为什么 index sorting 变成硬性前置条件
这是 Columnar Mode 最容易被忽略、但一旦搞错就白干的一点:创建索引时必须配置 index sorting,否则模式无法生效或收益归零。
原因在压缩局部性。
行存 + 倒排的世界里,文档顺序无所谓——反正每个 term 都有独立的 postings list。但列存不一样。列存的压缩效率完全取决于同一列内相邻值的相似度:
未排序的 host.name 列:
prod-web-042, prod-db-011, prod-web-042, prod-cache-003, prod-db-011, ...
→ 字典编码后 ordinal 序列:0, 1, 0, 2, 1, ...
→ 无法 RLE,只能位打包,约 2 bit/doc
按 host.name 排序后:
prod-cache-003 ×1200, prod-db-011 ×3400, prod-web-042 ×8900, ...
→ RLE:(0, 1200), (1, 3400), (2, 8900)
→ 3 个 tuple 搞定 13500 个文档
差距是三个数量级。
对时间戳列同理。@timestamp 排序后,delta-of-delta 编码几乎可以把每个时间戳压到 1~2 bit(这也是 Gorilla 论文的核心思路)。
排序键怎么选?给一个经验规则:
- 第一个键选基数适中且查询过滤高频的字段(
host.name、service.name、tenant_id),基数在几百到几万之间最理想; - 第二个键选
@timestamp,desc排序(最新数据在前,配合search_after翻页更友好); - 不要把
trace.id、request.id这类超高基数字段放第一位——排序后每个值只有一行,RLE 完全失效,等于白排; - 排序键的数量控制在 2~3 个。每多一个键,写入时的 merge 排序开销线性增长。
一个反面案例:某团队把 columnar 模式的排序键设成 ["@timestamp"],结果存储只降了 12%,远低于预期。改成 ["kubernetes.namespace", "host.name", "@timestamp"] 之后,同样的数据降了 61%。排序键选错,Columnar Mode 基本等于没开。
2.4 两种列式 _source 模式
Columnar Mode 下 _source 有两条路:
合成模式(synthetic):不存原始 JSON,查询返回时从 doc_values 重建。存储最省,但有语义损耗:
// 写入
{"tags": ["b", "a", "b"], "port": 8080.0}
// synthetic source 重建后
{"tags": ["a", "b"], "port": 8080}
数组被去重并排序,浮点尾数被规范化。如果你的下游系统依赖 _source 的字节级一致性(比如做数据回放、签名校验),这是坑。
存储模式(stored):仍然存一份 _source,但用列式布局存储,压缩率比传统 .fdt 高。适合需要精确原文的场景,代价是存储收益缩水。
我的建议:日志、指标、埋点 → synthetic;审计日志、交易流水、需要回放的事件 → stored。
2.5 限制清单:什么时候不要用
官方文档和实测里踩到的限制:
- runtime 字段受限。Columnar 模式下 runtime field 的行为与常规模式有差异,依赖 runtime field 做临时字段计算的仪表板需要重新验证;
- 不适合文档检索为主的负载。如果你的索引是电商商品库、知识库、站内搜索,别碰这个模式,倒排索引就是你的核心资产;
wildcard/regexp/fuzzy查询性能会显著劣化。这些查询在倒排索引上是遍历 FST,在列存上是全列扫描;- 高基数字段的精确匹配退化为扫描。
WHERE trace.id == "xxx"在 columnar 索引上是 O(n),在倒排索引上是 O(log n); - 现有索引不能原地切换。必须新建索引 + reindex,或者靠 ILM 滚动到新模式;
- 技术预览状态。9.5 里 Columnar Mode 是 Preview,不建议直接上核心业务链路。
三、架构分析:查询路径发生了什么变化
3.1 三类查询的执行路径对比
来看同一个查询在两种模式下的执行差异。
查询 A:低选择度过滤 + 聚合
FROM logs-app-java-*
| WHERE log.level == "ERROR"
| STATS count() BY service.name
传统模式:
- 查
.tim找到 termERROR→ 拿到 postings list(假设 200 万个 doc ID) - 遍历 postings 构建 bitset
- 对每个命中 doc,从
service.name的 doc_values 读值 - 哈希聚合
Columnar 模式:
- 直接顺序扫描
log.level列的 doc_values(RLE 压缩,实际读取量可能只有几 KB) - SIMD 批量比较 ordinal,生成 bitset
- 同上
Columnar 反而更快——因为它连 postings list 都不用读,RLE 段可以直接跳过。
查询 B:高选择度精确匹配
FROM logs-app-java-*
| WHERE trace.id == "4bf92f3577b34da6a3ce929d0e0e4736"
传统模式:FST 定位 term → postings 里一个 doc ID → O(log n),毫秒级。
Columnar 模式:全列扫描 trace.id。即使有 SIMD,1 亿文档也要扫几百 MB 压缩数据。这里会慢 1~2 个数量级。
这是 Columnar Mode 最大的坑。 如果你的排障流程重度依赖 trace ID 精确定位,必须为这个字段保留倒排索引。
查询 C:全文检索
FROM logs-app-java-*
| WHERE MATCH(message, "connection refused")
logsdb_columnar 下 message 保留倒排索引,所以路径不变。纯 columnar 下这个查询会退化成全列子串扫描,慢到不可用。
3.2 成本模型:什么时候切换是划算的
给一个粗略的决策公式。定义:
S_inv:倒排索引部分的存储占比Q_exact:高选择度精确匹配查询占总查询的比例Q_agg:聚合分析类查询占比R:数据保留天数
切换收益 ≈ S_inv × 存储单价 × R
切换代价 ≈ Q_exact × 查询量 × 延迟劣化倍数
经验阈值:
Q_exact < 5%且S_inv > 30%→ 强烈建议切换5% < Q_exact < 20%→ 用logsdb_columnar,并为关键高基数字段单独保留索引Q_exact > 20%→ 不要切换
怎么测 Q_exact?打开 slow log 和 search 审计,统计带 term / ids 查询且命中文档数 < 100 的请求比例。
3.3 为关键字段"开小灶"
Columnar Mode 不是全有全无。你可以在列式索引里,为特定字段显式打开倒排索引:
PUT /logs-app-java-2026.08
{
"settings": {
"index": {
"mode": "logsdb_columnar",
"sort.field": ["service.name", "@timestamp"],
"sort.order": ["asc", "desc"]
}
},
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"service.name": { "type": "keyword" },
"log.level": { "type": "keyword" },
"host.name": { "type": "keyword" },
"trace.id": {
"type": "keyword",
"index": true,
"doc_values": true
},
"message": {
"type": "text",
"analyzer": "standard"
},
"http.request.body": {
"type": "keyword",
"index": false,
"doc_values": true,
"ignore_above": 8192
}
}
}
}
要点:
trace.id显式"index": true→ 保留倒排索引,精确匹配仍是 O(log n)。这个字段基数极高但值很短,倒排索引的绝对体积可控;message保持text→logsdb_columnar会为它保留唯一的那个倒排索引;http.request.body显式"index": false→ 大文本字段只列存,永远不做检索,省下最大的一块;- 其余低基数 keyword 全部走默认(列存),享受 RLE 红利。
这套 mapping 才是 Columnar Mode 的正确用法:不是"全部列存",而是"默认列存 + 精准开洞"。
四、代码实战:从零搭一套列式日志管道
4.1 建索引模板
生产环境不会手动建索引,走 index template + data stream:
PUT _index_template/logs-app-columnar
{
"index_patterns": ["logs-app-*"],
"data_stream": {},
"priority": 500,
"template": {
"settings": {
"index.mode": "logsdb_columnar",
"index.sort.field": ["service.name", "host.name", "@timestamp"],
"index.sort.order": ["asc", "asc", "desc"],
"index.number_of_shards": 3,
"index.number_of_replicas": 1,
"index.refresh_interval": "30s",
"index.codec": "best_compression",
"index.lifecycle.name": "logs-90d-policy"
},
"mappings": {
"_source": { "mode": "synthetic" },
"properties": {
"@timestamp": { "type": "date" },
"service.name": { "type": "keyword" },
"service.version": { "type": "keyword" },
"host.name": { "type": "keyword" },
"log.level": { "type": "keyword" },
"log.logger": { "type": "keyword" },
"trace.id": { "type": "keyword", "index": true },
"span.id": { "type": "keyword", "index": false },
"message": { "type": "text" },
"error.stack_trace": { "type": "text", "index": false },
"http.response.status_code": { "type": "short" },
"event.duration": { "type": "long" }
}
}
}
}
注意 refresh_interval 拉到 30s。列存的段合并成本比行存高(要重新排序),降低 refresh 频率能显著减少小段数量。
4.2 批量写入与存储对比
写个脚本,同样的数据分别灌进 logsdb 和 logsdb_columnar,对比存储:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""对比 logsdb 与 logsdb_columnar 的存储占用"""
import json
import random
import time
from datetime import datetime, timedelta, timezone
import requests
ES = "http://localhost:9200"
AUTH = ("elastic", "changeme")
DOC_COUNT = 2_000_000
BATCH = 5000
SERVICES = [f"svc-{i:03d}" for i in range(60)]
HOSTS = [f"prod-node-{i:04d}" for i in range(400)]
LEVELS = ["DEBUG"] * 30 + ["INFO"] * 55 + ["WARN"] * 10 + ["ERROR"] * 5
LOGGERS = [f"com.example.{p}.Handler" for p in
("payment", "order", "user", "inventory", "gateway")]
MESSAGES = [
"request completed successfully",
"cache miss, falling back to origin",
"connection pool exhausted, waiting",
"java.lang.OutOfMemoryError: Java heap space",
"downstream timeout after 3000ms",
]
def make_settings(mode: str) -> dict:
return {
"settings": {
"index.mode": mode,
"index.sort.field": ["service.name", "host.name", "@timestamp"],
"index.sort.order": ["asc", "asc", "desc"],
"index.number_of_shards": 1,
"index.number_of_replicas": 0,
"index.refresh_interval": "60s",
},
"mappings": {
"_source": {"mode": "synthetic"},
"properties": {
"@timestamp": {"type": "date"},
"service.name": {"type": "keyword"},
"host.name": {"type": "keyword"},
"log.level": {"type": "keyword"},
"log.logger": {"type": "keyword"},
"trace.id": {"type": "keyword", "index": True},
"message": {"type": "text"},
"event.duration": {"type": "long"},
"http.response.status_code": {"type": "short"},
},
},
}
def gen_docs(n: int):
base = datetime.now(timezone.utc) - timedelta(hours=6)
for i in range(n):
yield {
"@timestamp": (base + timedelta(milliseconds=i * 10)).isoformat(),
"service.name": random.choice(SERVICES),
"host.name": random.choice(HOSTS),
"log.level": random.choice(LEVELS),
"log.logger": random.choice(LOGGERS),
"trace.id": "%032x" % random.getrandbits(128),
"message": random.choice(MESSAGES),
"event.duration": random.randint(1, 5_000_000),
"http.response.status_code": random.choice([200] * 90 + [404] * 5 + [500] * 5),
}
def bulk_load(index: str, mode: str):
requests.delete(f"{ES}/{index}", auth=AUTH)
r = requests.put(f"{ES}/{index}", auth=AUTH, json=make_settings(mode))
r.raise_for_status()
buf, sent, t0 = [], 0, time.time()
action = json.dumps({"index": {}})
for doc in gen_docs(DOC_COUNT):
buf.append(action)
buf.append(json.dumps(doc, ensure_ascii=False))
if len(buf) >= BATCH * 2:
body = "\n".join(buf) + "\n"
resp = requests.post(
f"{ES}/{index}/_bulk", auth=AUTH, data=body.encode("utf-8"),
headers={"Content-Type": "application/x-ndjson"},
)
if resp.json().get("errors"):
print("bulk error sample:", resp.text[:500])
sent += BATCH
buf.clear()
if sent % 200_000 == 0:
print(f" [{index}] {sent:,} docs, {time.time() - t0:.1f}s")
if buf:
requests.post(f"{ES}/{index}/_bulk", auth=AUTH,
data=("\n".join(buf) + "\n").encode("utf-8"),
headers={"Content-Type": "application/x-ndjson"})
requests.post(f"{ES}/{index}/_forcemerge?max_num_segments=1", auth=AUTH, timeout=1800)
requests.post(f"{ES}/{index}/_refresh", auth=AUTH)
return time.time() - t0
def stats(index: str) -> dict:
d = requests.get(f"{ES}/{index}/_stats/store,docs", auth=AUTH).json()
s = d["indices"][index]["primaries"]
return {
"docs": s["docs"]["count"],
"bytes": s["store"]["size_in_bytes"],
}
if __name__ == "__main__":
results = {}
for name, mode in [("bench-logsdb", "logsdb"),
("bench-columnar", "logsdb_columnar")]:
elapsed = bulk_load(name, mode)
st = stats(name)
st["index_seconds"] = round(elapsed, 1)
st["bytes_per_doc"] = round(st["bytes"] / st["docs"], 2)
results[name] = st
print(f"{name}: {st}")
a = results["bench-logsdb"]["bytes"]
b = results["bench-columnar"]["bytes"]
print(f"\n存储变化: {(1 - b / a) * 100:.1f}% ({a/1e9:.2f} GB -> {b/1e9:.2f} GB)")
跑之前提醒两点:
- 一定要 forcemerge 到 1 段再对比。不 merge 的话,小段的元数据开销会淹没列存收益,你会得出"没啥区别"的错误结论;
- 数据分布要贴近真实。上面脚本里
log.level按 30/55/10/5 分布、trace.id全随机——如果你用全均匀分布的合成数据测,RLE 收益会被严重低估。
4.3 Go 客户端:带列存感知的查询封装
生产里更常见的是从 Go 服务里查 ES。给一个带「查询模式感知」的封装——自动判断这个查询适不适合走列式索引:
package eslog
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// QueryShape 描述一次查询的形态,用于路由到合适的索引
type QueryShape int
const (
ShapeAggregate QueryShape = iota // 聚合分析:列式索引最优
ShapeExactMatch // 高选择度精确匹配:需要倒排索引
ShapeFullText // 全文检索:需要 message 倒排索引
)
// Client 封装 ES|QL 查询,按查询形态选择索引
type Client struct {
baseURL string
httpClient *http.Client
// 列式索引(成本低,聚合快)
columnarIdx string
// 保留完整倒排索引的热数据索引(近 3 天)
hotIdx string
}
func NewClient(baseURL, columnarIdx, hotIdx string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{Timeout: 120 * time.Second},
columnarIdx: columnarIdx,
hotIdx: hotIdx,
}
}
type esqlRequest struct {
Query string `json:"query"`
Params []json.RawMessage `json:"params,omitempty"`
// Fast Mode:采样执行,仅对 STATS 类查询生效
Pragma map[string]any `json:"pragma,omitempty"`
}
type ESQLResponse struct {
Columns []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"columns"`
Values [][]any `json:"values"`
// 采样模式下返回的元信息
IsPartial bool `json:"is_partial,omitempty"`
}
// resolveIndex 按查询形态和时间范围决定打哪个索引
func (c *Client) resolveIndex(shape QueryShape, lookback time.Duration) string {
switch shape {
case ShapeExactMatch:
// 精确匹配在列式索引上会退化成全扫描
// 3 天内的走热索引,超出范围只能忍受慢查询
if lookback <= 72*time.Hour {
return c.hotIdx
}
return c.columnarIdx
case ShapeFullText:
// logsdb_columnar 保留了 message 倒排索引,直接走列式索引
return c.columnarIdx
default:
return c.columnarIdx
}
}
// Query 执行 ES|QL
func (c *Client) Query(
ctx context.Context,
shape QueryShape,
lookback time.Duration,
tmpl string,
fastMode bool,
) (*ESQLResponse, error) {
idx := c.resolveIndex(shape, lookback)
q := strings.ReplaceAll(tmpl, "{{index}}", idx)
req := esqlRequest{Query: q}
if fastMode {
if shape != ShapeAggregate {
return nil, fmt.Errorf("fast mode 仅适用于聚合查询,当前形态: %v", shape)
}
req.Pragma = map[string]any{"fast_mode": true}
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(
ctx, http.MethodPost, c.baseURL+"/_query?format=json",
bytes.NewReader(body),
)
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("execute esql: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
var buf bytes.Buffer
buf.ReadFrom(resp.Body)
return nil, fmt.Errorf("esql %d: %s", resp.StatusCode, buf.String())
}
var out ESQLResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &out, nil
}
// ErrorRateByService 服务错误率看板 —— 典型聚合查询,可开 Fast Mode
func (c *Client) ErrorRateByService(ctx context.Context, hours int) (*ESQLResponse, error) {
tmpl := fmt.Sprintf(`
FROM {{index}}
| WHERE @timestamp >= NOW() - %d hours
| EVAL is_error = CASE(log.level == "ERROR", 1, 0)
| STATS total = COUNT(*), errors = SUM(is_error) BY service.name
| EVAL error_rate = ROUND(errors * 100.0 / total, 3)
| WHERE total > 100
| SORT error_rate DESC
| LIMIT 50`, hours)
return c.Query(ctx, ShapeAggregate, time.Duration(hours)*time.Hour, tmpl, true)
}
// TraceDetail 按 trace.id 捞全链路 —— 精确匹配,绝对不能开 Fast Mode
func (c *Client) TraceDetail(ctx context.Context, traceID string) (*ESQLResponse, error) {
tmpl := fmt.Sprintf(`
FROM {{index}}
| WHERE trace.id == "%s"
| KEEP @timestamp, service.name, host.name, log.level, message, event.duration
| SORT @timestamp ASC
| LIMIT 1000`, traceID)
return c.Query(ctx, ShapeExactMatch, 72*time.Hour, tmpl, false)
}
这段代码的核心思想:Columnar Mode 不是一个开关,而是一个需要在应用层配合的架构决策。 你得知道哪些查询会退化,然后在路由层做规避。
4.4 存量索引迁移:reindex + 双写灰度
不能停机的话,走这套流程:
#!/usr/bin/env bash
set -euo pipefail
ES="${ES:-http://localhost:9200}"
AUTH="${AUTH:-elastic:changeme}"
SRC="logs-app-2026.07"
DST="logs-app-2026.07-columnar"
# 1) 建目标索引(关掉副本和 refresh,加速 reindex)
curl -sS -u "$AUTH" -XPUT "$ES/$DST" \
-H 'Content-Type: application/json' -d '{
"settings": {
"index.mode": "logsdb_columnar",
"index.sort.field": ["service.name", "host.name", "@timestamp"],
"index.sort.order": ["asc", "asc", "desc"],
"index.number_of_replicas": 0,
"index.refresh_interval": "-1",
"index.translog.durability": "async"
}
}'
# 2) 异步 reindex,切片并行
TASK=$(curl -sS -u "$AUTH" -XPOST \
"$ES/_reindex?wait_for_completion=false&slices=auto&requests_per_second=-1" \
-H 'Content-Type: application/json' -d "{
\"conflicts\": \"proceed\",
\"source\": { \"index\": \"$SRC\", \"size\": 5000 },
\"dest\": { \"index\": \"$DST\", \"op_type\": \"create\" }
}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["task"])')
echo "reindex task: $TASK"
# 3) 轮询进度
while true; do
RESP=$(curl -sS -u "$AUTH" "$ES/_tasks/$TASK")
DONE=$(echo "$RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["completed"])')
STATUS=$(echo "$RESP" | python3 -c '
import sys, json
s = json.load(sys.stdin)["task"]["status"]
print(f"{s[\"created\"]}/{s[\"total\"]} created, {s[\"version_conflicts\"]} conflicts")
')
echo " $STATUS"
[ "$DONE" = "True" ] && break
sleep 15
done
# 4) 恢复正常设置并合并
curl -sS -u "$AUTH" -XPUT "$ES/$DST/_settings" \
-H 'Content-Type: application/json' -d '{
"index.refresh_interval": "30s",
"index.number_of_replicas": 1,
"index.translog.durability": "request"
}'
curl -sS -u "$AUTH" -XPOST "$ES/$DST/_forcemerge?max_num_segments=1"
# 5) 校验文档数一致
SRC_N=$(curl -sS -u "$AUTH" "$ES/$SRC/_count" | python3 -c 'import sys,json;print(json.load(sys.stdin)["count"])')
DST_N=$(curl -sS -u "$AUTH" "$ES/$DST/_count" | python3 -c 'import sys,json;print(json.load(sys.stdin)["count"])')
echo "源 $SRC_N 目标 $DST_N"
[ "$SRC_N" = "$DST_N" ] || { echo "文档数不一致,中止别名切换"; exit 1; }
# 6) 原子切换别名
curl -sS -u "$AUTH" -XPOST "$ES/_aliases" \
-H 'Content-Type: application/json' -d "{
\"actions\": [
{ \"remove\": { \"index\": \"$SRC\", \"alias\": \"logs-app-read\" } },
{ \"add\": { \"index\": \"$DST\", \"alias\": \"logs-app-read\" } }
]
}"
echo "别名切换完成。保留源索引 7 天后再删。"
几个关键点:
refresh_interval: -1+translog.durability: async在 reindex 期间能提速 2~3 倍,但完成后务必改回来;slices=auto让 reindex 按分片数并行;- 别名切换前必须校验文档数。列存模式下 synthetic source 的重建可能让某些畸形文档失败,
conflicts: proceed会静默跳过; - 源索引留 7 天再删,给回滚留余地。
五、VectorDB 索引模式与 DiskBBQ 自动校准
Columnar 是省钱的,VectorDB 模式是省人的。
5.1 手调 HNSW 的痛苦回忆
在 9.5 之前,上一套生产级向量检索,你得决定这些:
{
"mappings": {
"properties": {
"embedding": {
"type": "dense_vector",
"dims": 1024,
"index": true,
"similarity": "cosine",
"index_options": {
"type": "int8_hnsw",
"m": 32,
"ef_construction": 200,
"confidence_interval": 0.95
}
}
}
}
}
m 调大召回率上升但内存爆炸,ef_construction 调大建索引变慢,量化选 int8 还是 bbq 取决于向量的分布特征……这些参数没有通解,只能压测。我见过团队为了调这四个参数,花了两周。
5.2 VectorDB 索引模式:一个设置搞定
9.5 的做法是把这些决策收进索引模式:
PUT /kb-embeddings
{
"settings": {
"index.mode": "vectordb"
},
"mappings": {
"properties": {
"embedding": { "type": "dense_vector", "dims": 1024 },
"chunk_text": { "type": "text" },
"doc_id": { "type": "keyword" }
}
}
}
模式内部会自动应用针对向量优化的默认值,并自动调优量化策略、merge policy、缓存加载三件事。
后两个尤其值得说。向量索引的 merge policy 和普通倒排索引完全不同——HNSW 图在 merge 时需要重建,用默认的 tiered merge policy 会导致大段合并时 CPU 打满、写入抖动。缓存加载同理,向量数据存在堆外,什么时候预热、预热多少,之前全靠手动 index.store.preload 猜。
5.3 DiskBBQ 自动校准:把专家经验编码成算法
更狠的是 DiskBBQ 的自动校准。它基于索引中向量的统计分析,自动配置三个参数:
量化深度(quantization depth):向量的每个维度压到几 bit。BBQ(Better Binary Quantization)能压到 1 bit/维,但如果向量分布方差小,1 bit 会导致召回率断崖。自动校准会先采样分析维度方差分布,再决定深度。
预处理(preconditioning):量化前的线性变换。如果原始 embedding 空间的各维度尺度不一致(这在多模态模型里很常见),直接量化会让大尺度维度主导距离计算。预处理做的是旋转/白化,让能量在各维度上均匀分布。这一步以前需要你自己算 PCA。
过采样(oversampling):量化后精度损失了,所以要多召回一些候选再用原始向量重排。过采样倍数取多少?取决于量化误差的分布。自动校准会用采样估计的召回曲线来定这个值。
PUT /kb-embeddings-tuned
{
"settings": { "index.mode": "vectordb" },
"mappings": {
"properties": {
"embedding": {
"type": "dense_vector",
"dims": 1024,
"index_options": {
"type": "bbq_disk"
}
}
}
}
}
配合多模态 semantic 字段,图搜图这类需求也从"配模型 + 写摄取管道 + 写查询嵌入"三步压缩成一步:
PUT /media-library
{
"mappings": {
"properties": {
"content": {
"type": "semantic",
"inference_id": "jina-omni-endpoint"
},
"filename": { "type": "keyword" }
}
}
}
底层是 jina-embeddings-v5-omni——单一 embedding 空间里同时容纳文本、图像、音频、视频,覆盖近 100 种语言。索引图片和查询文本走同一个字段:
POST /media-library/_search
{
"query": {
"semantic": {
"field": "content",
"query": "一只橘猫躺在键盘上"
}
}
}
这类"把调参专家干的活变成算法"的更新,长期价值比任何一个性能数字都大。 因为它降低的不是延迟,是团队里能上线这套东西的人数门槛。
六、Prometheus 替换战:ES95 编解码器与 3 字节/采样点
6.1 那个数字到底意味着什么
9.5 里最有攻击性的一条,是新的 ES95 编解码器把指标存储压到每个采样点约 3 字节,比上个版本再降约 20%。
厂商同时给了两个对比口径(这是 Elastic 官方数据,落地前请自己压测):
- 存储效率最高比 Prometheus 高 2.5 倍
- 查询性能最高比 Prometheus 快 30 倍
3 字节/采样点是什么概念?把主流方案摆一起(数量级参考,实际值高度依赖数据特征):
| 方案 | 典型压缩后 | 编码手段 |
|---|---|---|
| Prometheus TSDB(本地块) | Gorilla:时间戳 delta-of-delta + 值 XOR | |
| VictoriaMetrics | 自适应多编码 + ZSTD | |
| ClickHouse(Gorilla/DoubleDelta + LZ4) | 列式编解码器组合 | |
| Elasticsearch ES95 | ~3 字节 | 列式指标引擎 + 新编解码器 |
注意别被数字骗了。 Elastic 说的 "2.5 倍存储效率" 不是拿 3 字节比 Prometheus 的 1.3 字节——那样反而是 Elastic 输。这个对比通常包含了 Prometheus 侧的索引开销、series churn 造成的碎片、以及副本策略差异。Prometheus 的 chunk 数据确实压得很狠,但 series 元数据(label set 的倒排索引)在高基数场景下会膨胀到和数据本身一个量级。
结论是:这个数字在高基数场景下可信度更高,在低基数长时序场景下 Prometheus 仍占优。 如果你的指标是几百个稳定 series 跑一年,别迁;如果是 Kubernetes 环境几十万 series 每天 churn 一遍,值得测。
6.2 接入路径:remote-write 是最小改动方案
9.5 里 Prometheus remote-write 端点和 PromQL 支持都 GA 了。最小改动的迁移是这样:
# prometheus.yml —— 保持 Prometheus 抓取不变,只加一个 remote_write
global:
scrape_interval: 15s
external_labels:
cluster: prod-hangzhou
replica: prom-01
scrape_configs:
- job_name: kubernetes-pods
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: "true"
remote_write:
- url: "https://es.internal:9200/_metrics/prometheus/write"
authorization:
type: Bearer
credentials_file: /etc/prometheus/es-token
# 高吞吐场景必调
queue_config:
capacity: 20000 # 每个 shard 的内存队列深度
max_shards: 100 # 并行度上限
min_shards: 4
max_samples_per_send: 5000
batch_send_deadline: 5s
min_backoff: 30ms
max_backoff: 5s
# 丢掉不需要长期保留的高基数指标,直接省钱
write_relabel_configs:
- source_labels: [__name__]
regex: "go_gc_duration_seconds.*|go_memstats_.*_bytes_total"
action: drop
- source_labels: [__name__, le]
regex: "http_request_duration_seconds_bucket;(0\\.001|0\\.0025)"
action: drop
# 本地保留 2 小时兜底,ES 挂了不丢数据
metadata_config:
send: true
send_interval: 1m
关键调参:
max_shards决定并行写入度。默认 200 在很多环境下会把 ES 的 bulk 队列打爆,先从 50~100 试;write_relabel_configs里的 drop 规则是最直接的省钱手段。Histogram 的细粒度 bucket 常常占掉 40% 以上的 series 数量,砍掉用不到的分位数立竿见影;- 保留 Prometheus 本地 2 小时 TSDB,作为 ES 侧故障时的缓冲。
6.3 PromQL 直接嵌进 ES|QL
不用重写查询是这次 GA 的最大卖点。原来的 PromQL:
sum by (service) (
rate(http_requests_total{status=~"5.."}[5m])
)
/
sum by (service) (
rate(http_requests_total[5m])
) > 0.05
在 ES|QL 里可以直接内嵌,然后接着用 ES|QL 的能力做关联:
TS metrics-*
| WHERE @timestamp >= NOW() - 1 hour
| STATS error_rate = SUM(RATE(http_requests_total)) BY service, BUCKET(@timestamp, 1 minute)
| WHERE error_rate > 0.05
| LOOKUP JOIN service-ownership ON service
| KEEP @timestamp, service, error_rate, team, oncall_slack
| SORT error_rate DESC
这才是 ES 相对 Prometheus 的真正优势:不是压缩率,而是指标能和日志、trace、CMDB 表在同一个查询里做 JOIN。Prometheus 生态里做这件事,你得在 Grafana 里拼三个 panel 然后靠眼睛关联。
9.5 里 ES|QL 还加了三种子查询源命令(FROM / TS / ROW)和 WHERE IN 子查询(技术预览):
-- 先用指标找出异常服务,再直接过滤日志,一个查询搞定
FROM logs-app-*
| WHERE @timestamp >= NOW() - 30 minutes
| WHERE service.name IN (
TS metrics-*
| WHERE @timestamp >= NOW() - 30 minutes
| STATS p99 = PERCENTILE(RATE(request_duration_seconds), 99) BY service
| WHERE p99 > 2.0
| KEEP service
)
| WHERE log.level == "ERROR"
| STATS count = COUNT(*) BY service.name, log.logger
| SORT count DESC
| LIMIT 30
以前这是"跑一个查询 → 复制 ID 列表 → 粘进第二个查询"的手工活,现在一次搞定,而且过滤列表是实时的。
限制:子查询目前必须返回单列,类型要兼容,TS 命令只能用于 time series data stream。
6.4 迁移 SOP
给一套可执行的迁移顺序:
- 第一周:只加 remote_write,不动查询。 Prometheus 继续跑,数据双写。观察 ES 侧的写入延迟、bulk 拒绝率、磁盘增长曲线。
- 第二周:跑迁移工具把 Grafana 仪表板导过去。 9.5 的迁移工具支持 Grafana 和 Datadog 的仪表板与告警自动转换。转完之后逐个人工核对,尤其注意
rate()的窗口、increase()在计数器重置时的行为差异。 - 第三周:告警双跑。 同一条规则在 Prometheus Alertmanager 和 Elastic 里同时跑,对比触发时间和触发次数。差异超过 5% 就要查原因(通常是 scrape interval 和 ES 侧 bucket 对不齐)。
- 第四周:Prometheus 保留期缩到 6 小时,只做本地缓冲。 长期存储全部转 ES。
- 持续:监控 series 基数。 ES 侧要盯
_cat/indices的字段数量增长,Kubernetes 环境里一个错误的 label(比如把 pod IP 打进 label)能一夜把基数干到百万级。
七、ES|QL Fast Mode:用统计学换 100 倍速度
7.1 它到底做了什么
Fast Mode 的逻辑很直白:对 STATS 类查询,不扫全量数据,而是在采样数据集上算,然后把结果按采样率外推回真实规模。官方口径是最高 100 倍加速,保持 90% 置信区间的准确性,这是企业版功能。
FROM logs-app-*
| WHERE @timestamp >= NOW() - 7 days
| STATS
requests = COUNT(*),
p50 = PERCENTILE(event.duration, 50),
p99 = PERCENTILE(event.duration, 99)
BY service.name
| SORT requests DESC
这个查询在 7 天 200 亿文档上跑,全量扫描可能要 40 秒。Fast Mode 下采样 1%,0.5 秒返回,requests 乘以 100 外推。
7.2 什么时候能用,什么时候会出事
能用:
- 探索性分析。你在 Discover 里瞎点,就想看个大概趋势;
- 仪表板的概览面板。趋势线、TOP N 排行、占比饼图;
- 容量规划。看的是数量级,不是精确值。
绝对不能用:
- 计费和结算。 采样外推的
COUNT(*)有误差,把它当账单基数会被财务追杀; - SLO 计算。 99.9% 和 99.85% 差 0.05 个点,采样噪声可能就是这个量级;
- 合规审计。 "过去 30 天有多少次越权访问" 这种问题,答案必须精确;
- 告警阈值判断。 采样噪声会导致告警抖动,尤其是低频事件——如果某个错误一天只出现 20 次,1% 采样大概率一个都采不到,直接漏报。
7.3 采样误差的量化直觉
给个粗略的心算方法。设采样率 p,真实计数 N,那么采样命中数 k ~ Binomial(N, p),外推估计 N̂ = k/p,相对标准误差约为:
RSE ≈ sqrt((1 - p) / (N × p))
代几个数(采样率 1%):
| 真实计数 N | 相对标准误差 | 90% 置信区间宽度(相对) |
|---|---|---|
| 100 | ~99.5% | ±164% |
| 1,000 | ~31.5% | ±52% |
| 10,000 | ~9.9% | ±16% |
| 100,000 | ~3.1% | ±5.2% |
| 1,000,000 | ~1.0% | ±1.6% |
| 10,000,000 | ~0.3% | ±0.5% |
看清楚这张表。 采样估计对大数很准,对小数完全不可用。而你的 TOP N 排行榜里,排名靠后的那些桶恰恰是小数——Fast Mode 下的长尾排名基本是噪声。
实操建议:如果一定要在业务面板上用 Fast Mode,加一条硬规则——只展示计数超过某个下限(比如 10000)的桶,其余归入 "Others"。这既符合视觉设计习惯,又刚好规避了采样误差最大的区间。
分位数更微妙。PERCENTILE(x, 50) 在采样下比较稳(中位数对采样鲁棒),PERCENTILE(x, 99.9) 在 1% 采样下基本是瞎猜——因为 99.9 分位本来就只由 0.1% 的数据决定,采样 1% 之后这部分只剩几个点。
经验规则:Fast Mode 下不要相信高于 p95 的任何分位数。
八、十二条踩坑清单
按踩到的概率从高到低排。
1. 没配 index sorting 就开 Columnar,存储只降 10%。
列存的压缩全靠排序局部性。没排序 = 没收益。这是头号坑。
2. 排序键第一位放了超高基数字段。trace.id 排第一位,RLE 完全失效。第一位要选基数几百到几万、且查询高频过滤的字段。
3. 对比测试没 forcemerge。
多个小段的元数据开销会掩盖列存收益。测之前 _forcemerge?max_num_segments=1。
4. synthetic source 改变了 _source 语义,下游解析炸了。
数组去重排序、浮点规范化、字段顺序变化。有下游消费 _source 的,先在测试环境跑一轮 diff。
5. trace.id 忘了显式 "index": true,排障查询从 10ms 变成 8 秒。
列存模式下高基数精确匹配是全扫描。关键字段必须开小灶。
6. Fast Mode 开在了 SLO 面板上。
采样噪声让 SLO 数字每次刷新都在跳。SLO、计费、审计三类场景永久禁用 Fast Mode。
7. remote_write 的 max_shards 用默认值,把 ES bulk 队列打爆。
从 50 开始,观察 thread_pool.write.rejected 再往上调。
8. Prometheus histogram 的所有 bucket 全量写进 ES。
细粒度 bucket 常占 40%+ 的 series。用 write_relabel_configs 砍掉用不到的 le。
9. 迁移完 Grafana 仪表板没人工核对 rate() 语义。
PromQL 的 rate() 有外推逻辑(extrapolation),跨越计数器重置时的处理和 ES|QL 侧可能有细微差异。核心告警面板必须逐个对数。
10. reindex 完忘了把 refresh_interval 和 translog.durability 改回来。durability: async 留在生产上,节点崩了会丢数据。
11. VectorDB 模式下还手动指定了 m / ef_construction。
显式参数会覆盖自动调优,等于白开这个模式。要么全交给它,要么全手动,别混。
12. 把 Columnar Mode 用在了商品搜索索引上。
这是最本末倒置的一种。全文检索是你的核心业务,倒排索引是资产不是成本。
九、决策矩阵:你的索引该用哪个模式
| 数据类型 | 推荐模式 | 排序键 | _source | 备注 |
|---|---|---|---|---|
| 应用日志 | logsdb_columnar | service, host, @ts | synthetic | 保留 message 全文检索,trace.id 开索引 |
| 安全遥测 | logsdb_columnar | host, event.category, @ts | synthetic | 注意合规对原文精确性的要求 |
| 指标 | time_series + ES95 codec | 由 TSDB 模式管理 | synthetic | 走 remote-write 接入 |
| APM trace | logsdb 或标准模式 | service, @ts | synthetic | trace.id 高基数精确匹配是主要访问模式,慎用纯列存 |
| 业务埋点 | columnar | tenant, event_type, @ts | synthetic | 纯聚合负载,最适合列存 |
| 审计日志 | columnar | actor, @ts | stored | 需要字节级原文,不能用 synthetic |
| 商品/内容搜索 | 标准模式 | 不排序 | stored | 别碰列存 |
| 向量检索 | vectordb | — | stored | 交给自动校准 |
十、总结:这个版本真正的信号
把 9.5 的所有更新放在一起看,能读出一条主线——Elastic 正在把"专家经验"批量转化为"默认配置"。
- Columnar Mode:把"哪些字段不需要倒排索引"这个判断,从人工 mapping 调优变成索引模式的默认行为;
- DiskBBQ 自动校准:把"量化深度怎么调"从两周压测变成基于统计分析的自动决策;
- VectorDB 索引模式:把 HNSW 的四个参数收进一个
index.mode; - Workflows 自然语言编排、Agent Builder 聊天式创建 skill:把 YAML 编写变成对话;
- Attack Discovery 生成 ES|QL 规则草稿:把检测工程师的经验变成可审批的自动产出。
这个方向背后是一个很现实的判断:基础设施软件的竞争,已经从"能力上限"转向"上手成本"。
十年前 ES 的护城河是"你能用它做全文检索"。今天所有人都能做全文检索,护城河变成了"你不需要一个专家团队就能把它跑好"。ClickHouse、DuckDB、VictoriaMetrics 这些后来者的核心杀伤力从来不是功能更多,而是默认配置就能用。Elastic 显然读懂了这一点。
对我们做工程的人来说,有两个实际推论:
第一,重新审视你的 ES 账单。 如果你的集群里日志和指标占了 90% 以上,Columnar Mode 是这两年最直接的降本手段。哪怕只降 40%,对一个百 TB 级集群来说也是实打实的钱。但记住:先测排序键,再谈收益。
第二,"调参"这项技能正在贬值。 花两周调 HNSW 参数的经验,正在被自动校准算法吃掉。真正保值的是架构判断力——知道什么查询会在列存上退化、知道采样估计在什么规模下失真、知道 remote-write 的队列参数会怎么影响背压。这些是算法暂时替代不了的。
最后提醒一句:Columnar Mode 在 9.5 里是技术预览状态。技术预览的含义是:API 可能变、边界情况可能有 bug、出了问题官方支持力度有限。
我的建议是:在非核心链路上开一个索引跑起来,把存储数据和查询延迟采集一个月,同时把踩坑清单里的十二条逐条验证一遍。等 9.6 或 9.7 转 GA 的时候,你已经有了完整的迁移方案和真实数据,而不是从零开始读文档。
先跑起来,再决定要不要全量切。这是对待任何 Preview 特性最稳妥的姿势。
文中引用的 Elastic 官方性能数据(2.5 倍存储效率、30 倍查询速度、100 倍 Fast Mode 加速、约 3 字节/采样点)均为厂商发布口径,实际表现高度依赖数据特征、硬件配置和查询模式。落地前请务必在自己的数据集上完成压测。Columnar Mode、Agent Observability、
WHERE IN子查询、多模态semantic字段等特性在 9.5 中为技术预览状态,生产使用需评估风险。