编程 Elastic 9.5 深度拆解:当搜索引擎主动删掉倒排索引——Columnar Mode、ES95 编解码器与「3 字节/采样点」的存储战争

2026-08-11 03:18:13 +0800 CST views 5

Elastic 9.5 深度拆解:当搜索引擎主动删掉倒排索引——Columnar Mode、ES95 编解码器与「3 字节/采样点」的存储战争

2026 年 8 月 5 日,Elastic 发布了 9.5。这个版本里有一堆花哨的东西:AI 仪表板、Agent Builder 可观测性、Attack Discovery、Workflows 自然语言编排。但如果你是个干活的工程师,把发布公告翻到中间那一段,会看到一句轻描淡写的话:

Columnar Mode 是一种可选启用的索引模式,默认情况下将每个字段仅存储一次到列式存储中,而不创建倒排索引

读三遍。

一个靠倒排索引吃了十五年饭的搜索引擎,现在告诉你:默认不建倒排索引了。

这不是加了个功能,这是承认了一件事——Elasticsearch 集群里绝大部分数据,从来就没被"搜索"过

这篇文章不打算复述发布公告。我想拆的是三个更硬的东西:

  1. Columnar Mode 在 Lucene 层面到底删掉了什么,为什么 index sorting 突然变成硬性前置条件;
  2. ES95 编解码器把指标压到「约 3 字节/采样点」,这个数字放在 Prometheus / VictoriaMetrics / ClickHouse 的坐标系里是什么位置;
  3. 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-catalogusers-profile)在做真正的全文检索:BM25 打分、同义词扩展、拼写纠错、高亮。

而 ES 对这七个索引一视同仁——每个 keyword 字段都建倒排索引,每个 long 字段都建 BKD 树,然后再存一份 doc_values,再存一份 _source

一份数据,存三遍。

1.2 倒排索引的成本账,比你想的贵

很多人对倒排索引的成本估计停留在"就是个词典嘛"。我们把 Lucene 的实际磁盘布局摊开:

文件后缀内容典型占比分析型负载用得上吗
.tim / .tipTerm dictionary + index(FST)5%~15%精确匹配用得上
.docPostings(doc IDs + freq)15%~30%用得上
.pos / .payPositions / payloads(短语查询)10%~25%几乎用不上
.nvd / .nvmNorms(长度归一化,BM25 打分用)1%~3%完全用不上
.dvd / .dvmDoc values(列存)20%~40%全靠这个
.fdt / .fdxStored fields(_source20%~45%只在返回原文时用
.kdd / .kdi / .kdmBKD 点树(数值/地理范围)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 干掉的是 _sourceColumnar 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 两种索引模式:columnarlogsdb_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 论文的核心思路)。

排序键怎么选?给一个经验规则:

  1. 第一个键选基数适中且查询过滤高频的字段(host.nameservice.nametenant_id),基数在几百到几万之间最理想;
  2. 第二个键选 @timestampdesc 排序(最新数据在前,配合 search_after 翻页更友好);
  3. 不要trace.idrequest.id 这类超高基数字段放第一位——排序后每个值只有一行,RLE 完全失效,等于白排;
  4. 排序键的数量控制在 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

传统模式:

  1. .tim 找到 term ERROR → 拿到 postings list(假设 200 万个 doc ID)
  2. 遍历 postings 构建 bitset
  3. 对每个命中 doc,从 service.name 的 doc_values 读值
  4. 哈希聚合

Columnar 模式:

  1. 直接顺序扫描 log.level 列的 doc_values(RLE 压缩,实际读取量可能只有几 KB)
  2. SIMD 批量比较 ordinal,生成 bitset
  3. 同上

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_columnarmessage 保留倒排索引,所以路径不变。纯 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 保持 textlogsdb_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 批量写入与存储对比

写个脚本,同样的数据分别灌进 logsdblogsdb_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)")

跑之前提醒两点:

  1. 一定要 forcemerge 到 1 段再对比。不 merge 的话,小段的元数据开销会淹没列存收益,你会得出"没啥区别"的错误结论;
  2. 数据分布要贴近真实。上面脚本里 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(本地块)1.32 字节Gorilla:时间戳 delta-of-delta + 值 XOR
VictoriaMetrics0.41 字节自适应多编码 + ZSTD
ClickHouse(Gorilla/DoubleDelta + LZ4)13 字节列式编解码器组合
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

给一套可执行的迁移顺序:

  1. 第一周:只加 remote_write,不动查询。 Prometheus 继续跑,数据双写。观察 ES 侧的写入延迟、bulk 拒绝率、磁盘增长曲线。
  2. 第二周:跑迁移工具把 Grafana 仪表板导过去。 9.5 的迁移工具支持 Grafana 和 Datadog 的仪表板与告警自动转换。转完之后逐个人工核对,尤其注意 rate() 的窗口、increase() 在计数器重置时的行为差异。
  3. 第三周:告警双跑。 同一条规则在 Prometheus Alertmanager 和 Elastic 里同时跑,对比触发时间和触发次数。差异超过 5% 就要查原因(通常是 scrape interval 和 ES 侧 bucket 对不齐)。
  4. 第四周:Prometheus 保留期缩到 6 小时,只做本地缓冲。 长期存储全部转 ES。
  5. 持续:监控 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_intervaltranslog.durability 改回来。
durability: async 留在生产上,节点崩了会丢数据。

11. VectorDB 模式下还手动指定了 m / ef_construction
显式参数会覆盖自动调优,等于白开这个模式。要么全交给它,要么全手动,别混。

12. 把 Columnar Mode 用在了商品搜索索引上。
这是最本末倒置的一种。全文检索是你的核心业务,倒排索引是资产不是成本。


九、决策矩阵:你的索引该用哪个模式

数据类型推荐模式排序键_source备注
应用日志logsdb_columnarservice, host, @tssynthetic保留 message 全文检索,trace.id 开索引
安全遥测logsdb_columnarhost, event.category, @tssynthetic注意合规对原文精确性的要求
指标time_series + ES95 codec由 TSDB 模式管理synthetic走 remote-write 接入
APM tracelogsdb 或标准模式service, @tssynthetictrace.id 高基数精确匹配是主要访问模式,慎用纯列存
业务埋点columnartenant, event_type, @tssynthetic纯聚合负载,最适合列存
审计日志columnaractor, @tsstored需要字节级原文,不能用 synthetic
商品/内容搜索标准模式不排序stored别碰列存
向量检索vectordbstored交给自动校准

十、总结:这个版本真正的信号

把 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 中为技术预览状态,生产使用需评估风险。

推荐文章

PHP 允许跨域的终极解决办法
2024-11-19 08:12:52 +0800 CST
Shell 里给变量赋值为多行文本
2024-11-18 20:25:45 +0800 CST
mysql删除重复数据
2024-11-19 03:19:52 +0800 CST
程序员茄子在线接单