一台 OpenSearch 3.5.0 单节点存着 qa、stg、preprod 三个非生产环境的日志,保留 14 天,共 47.5 GB、1,456 个索引(文中的 GB、MB 按 OpenSearch 的口径,指 GiB、MiB)。512 GB 的磁盘只用了 18%,容量并不紧张,这次审计要回答的是这 47.5 GB 花在了哪里、哪些可以在源头去掉。

最大的 8 个索引族共约 41 GB,占全部数据的 86%,估算其中约 34 GB 可以在源头优化掉。最大的 api-request-logs-stg(26 GB)里,一个接口的完整响应体就占了约 23 GB。

Lucene 文件后缀的占比#

一个分片由多个段(segment)组成,每个段是一组后缀不同的文件,分别存放原文、倒排索引和 doc values(见 Lucene 的文件格式说明)。各取两个索引族 9 月 25 日的单日索引,按 Lucene 文件类型统计磁盘占比:api-request-logs-stg 这一天是 1,770 MB,stg-bet-processing-general 是 559 MB;compound 文件(小段打包存放的格式)没有计入,它们的构成和其余段相近。

文件后缀 存放的内容 api-request-logs-stg(API 请求日志) stg-bet-processing-general(业务应用日志)
.fdt stored fields,也就是 _source 原文的压缩块 42.9% 73.4%
.pos 词项在文档中的位置,短语查询和邻近查询要用 32.5% 11.1%
.tim 词典(term dictionary) 11.0% 3.9%
.doc 倒排表:每个词项对应的文档 ID 和词频 6.0% 7.3%
.dvd doc values,列式存储,用于聚合和排序 6.5% 2.0%

业务应用日志(右列)里 73.4% 是 _source 原文,倒排和位置信息(.pos、.tim、.doc)合计约 22%。api-request-logs-stg(左列)里,.pos 一项就占了近三分之一(32.5%)。

这说明这个索引里有大量很长的文本字段做了全文分词,每个词在文本里的位置都被记了下来。.pos 只在短语查询和邻近查询时才会用到(见 index_options 文档),后面查询模式一节会看到,记录里没有指定 responseBody 字段的查询,但自由文本搜索会连它一起搜。

抽样定位大字段#

文件后缀说明问题出在长文本分词上,下一步要找出是哪个字段。抽样方式会影响结论。按文档顺序取样时,得出每条平均 17.5 KB、responseBody 占原文的 97%;改成从 9 月 25 日的索引里随机抽 3,000 条以后,是每条 5.7 KB、占 89%。随机抽样可以用 function_score 的 random_score,要同时给 seed 和 field:只给 seed 的写法已经弃用,OpenSearch 会改用 _id,要加载它的 fielddata,很占内存(见 function score 文档)。field 用 _seq_no 就可以:

GET api-request-logs-stg.2026.09.25/_search
{
  "size": 3000,
  "query": {
    "function_score": {
      "random_score": { "seed": 42, "field": "_seq_no" }
    }
  },
  "_source": true
}

随机样本里,responseBody 一个字段就占了原文字节的约 89%。再按 URL 路径汇总,/game/list 只占样本 16% 的条数,却占了 89.5% 的原文字节:上游应用记录访问日志时,把这个接口的完整响应体都写了进去。9 月 25 日这个接口全天被调用 124,890 次,响应体中位数 21 KB、最大 184 KB。

Dynamic Mapping 与 .keyword 的开销#

字段本身之外,动态映射(Dynamic Mapping)默认把字符串映射成带 keyword 子字段的 text(见 mappings 文档):

{
  "responseBody": {
    "type": "text",
    "fields": {
      "keyword": {
        "type": "keyword",
        "ignore_above": 256
      }
    }
  }
}

这样 responseBody 可以做分词全文检索,responseBody.keyword 可以做精确匹配和聚合。存储上的开销分三部分:

  1. _source(.fdt)存一份完整文本;
  2. text 字段建一套倒排和位置索引(.tim、.doc、.pos);
  3. .keyword 子字段只收不超过 ignore_above(这里是 256)个字符的值,超过的值不会被索引,只留在 _source 里(见 ignore_above 文档);收进来的值会再建一份倒排,并写一份 doc values(.dvd),OpenSearch 默认给除 text 以外几乎所有字段类型开启 doc values(见 doc_values 文档)。

所以 /game/list 那种几十 KB 的响应体不会进 .keyword;其余接口的短响应体会进,全索引 responseBody.keyword 约有 6.2 万个不同值。requestBody、operatorResponse 的值也大多在 256 字符以内,见下表。

高基数 .keyword 的使用情况#

另外抽了 2,000 条,统计各字段不超过 256 字符(会生成 .keyword)的条数,以及其中的不同取值数:

字段 ≤ 256 字符的条数 其中不同取值数 说明
message 1,647 19(全索引约 2,800) 基数低,体积很小
requestBody 1,943 1,937 几乎每条都不同,聚合没有意义
operatorResponse 1,294 1,218 同上
responseBody — 全索引约 6.2 万 基数高

Dashboards 里保存的对象共 379 个,其中 257 个 index pattern、38 个可视化、16 个保存的搜索、2 个 dashboard。responseBody、requestBody、operatorResponse、operatorData 只出现在保存的搜索的显示列里,没有任何对象用这些字段的 .keyword 做过滤或可视化,也没有对象引用 message.keyword。

查询模式与优化措施#

调整 mapping 之前,先看查询习惯。Query Insights 只记录按延迟、CPU 或内存排序的 top N 查询,默认每 5 分钟的窗口取 10 条(见 top N queries 文档),所以下面的分布不一定代表全部查询。它记录的用户查询里,api-request-logs-stg* 共 58 条:

  • 48 条(约 83%)是 Discover 的自由文本搜索,不指定字段,会连 responseBody 一起搜;
  • 其余 10 条是按 agentId 的短语过滤和按 requestTime 的时间范围;
  • 没有指定 responseBody 字段的查询。

据此有四项措施,可以叠加使用:第一项在源头减少写入,另外三项只改 OpenSearch 的模板,其中 zstd(2.9 起)和 match_only_text(2.12 起)在 3.5.0 上都能用。这些措施都还没有执行,下表是按抽样数据的估算;只改模板的几种组合是按经验比例估的,要先用一天的数据 reindex 到测试索引里验证:

措施组合 全部数据(现在 47.5 GB) 搜索行为
zstd + 长文本字段去掉 .keyword 约 40 GB(−16%) 不变
上一行,再让 responseBody 不存位置信息 约 32 GB(−33%) responseBody 上带引号的短语搜索失效
第一行,再让 responseBody 不建索引 约 29.5 GB(−38%) 自由文本搜不到 responseBody 的内容
8 个大索引族做源头治理 约 13–15 GB,再叠加第一行约 11–13 GB 其余数据的搜索行为不变

在源头截断或不写入完整响应体#

空间最大的一块来自上游应用把 /game/list 的完整响应体写进访问日志,首选的措施是在源头不再记录它,只记长度,以及响应体里 data 部分的哈希:抽样 400 次,data 只有 266 种,响应体每条都不同是因为各自带着 traceId。估算能省约 23 GB,这个索引族减少约 85%,而且只影响这一个接口的日志内容。源头改不了的时候,再用下面改模板的做法。

改用 zstd 压缩#

codec 决定 stored fields 怎么压缩(见 index codecs 文档):default 用带预设字典的 LZ4,偏重速度;best_compression 用 zlib,压缩比高但更耗 CPU;OpenSearch 从 2.9 起多了 zstd 和 zstd_no_dict,后者去掉了字典压缩,写入和查询更快、体积稍大。文档给的基准数据来自 nyc_taxi 数据集:和 default 相比,zstd 磁盘占用少 35%、写入吞吐高 7%,zstd_no_dict 分别是少 30%、高 14%。codec 只作用于 stored fields,对 api-request-logs-stg 来说只压得到 .fdt 那 42.9%。

通过组件模板配置:

PUT _component_template/logs-settings
{
  "template": {
    "settings": {
      "index.codec": "zstd"
    }
  }
}

组件模板要被 index template 的 composed_of 引用才会生效,而且只影响之后新建的索引(见 index templates 文档)。已有索引要换 codec,只能先关闭索引、改设置再打开,之后新写的段才用新 codec;或者 reindex(见 index codecs 文档)。

去掉长文本字段的 .keyword#

没有被任何保存的对象用来过滤或可视化的长文本字段,在模板里只保留 text,不要 .keyword 子字段;其它字段保持现状,避免改变查询语义:

PUT _component_template/logs-long-text
{
  "template": {
    "mappings": {
      "properties": {
        "message": { "type": "text" },
        "stacktrace": { "type": "text" },
        "requestBody": { "type": "text" },
        "responseBody": { "type": "text" },
        "operatorResponse": { "type": "text" },
        "operatorData": { "type": "text" }
      }
    }
  }
}

这一项的收益在 responseBody(其余接口的短响应体,全索引约 6.2 万个不同值)和 requestBody 这类高基数字段上;message.keyword 全索引只有约 2,800 个不同值,去掉它省得很少,放进来是因为没有对象用到它。

用 match_only_text 或 index_options: docs 缩减 .pos#

text 字段默认按 positions 建索引,记录文档 ID、词频和位置;改成 docs 就只存文档 ID,不再产生 .pos,但这个字段上也不能再做短语查询(见 index_options 文档):

{
  "responseBody": {
    "type": "text",
    "index_options": "docs"
  }
}

另一种做法是 2.12 起提供的 match_only_text:不存位置、词频和 norms,也不计算相关性得分,短语查询仍然能用,只是效率不如 text(见 match_only_text 文档)。如果搜索栏里的自由文本会查到这个字段,用 match_only_text 更稳妥,带引号的短语查询不会因此失效:

{
  "responseBody": {
    "type": "match_only_text"
  }
}

还有一个更彻底的选项:responseBody 设为 "index": false,内容仍存在 _source 里可以查看,但搜不到(见 index 参数文档)。这几种写法对自由文本搜索的影响不同,选哪一种由日志的使用者决定。

八个索引族的估算#

8 个最大的索引族都按同样的方法估算:从 9 月 25 日的索引里各随机抽 3,000 条,按各类消息占原文字节的比例,乘以这个索引族 14 天的大小;每条文档固定开销的变化没有精确计算。

索引族 14 天大小 源头可省(估算) 主要原因
api-request-logs-stg 26.0 GB 约 23 GB /game/list 只占 16% 的条数,却占 89.5% 的原文字节
stg-bet-processing-general 5.1 GB 约 4.4 GB 76% 的条数是 Hibernate SQL DEBUG;7% 的条数带着同一个 13.6 KB 的 DNS 解析失败堆栈,占 65% 的字节
stg-vendor-api-general 2.9 GB 1.8–2.9 GB 60% 是同一条过时配置警告,每次查询都打一次;39% 是 SQL 打印
staging-wallet-balance 2.0 GB 约 1.1 GB(不含 9 月 26 日起的暴涨) 原始 JSON 存在 message 里(占 58%),又解析成独立字段,存了两份
stg-game-launcher-launcher 1.6 GB 约 1.0 GB 同样是原始 JSON 加解析字段;apiResponse 和 response 疑似重复;44% 是 DEBUG
stg-bo-sas-general 1.2 GB 约 0.3 GB 68% 是缓存逐出的 INFO 日志
qa-bet-processing-general 1.1 GB 约 0.85 GB 45% 是 Hibernate SQL DEBUG;约 30% 是 qa 库缺表的报错,一直在报
stg-retry-general 1.0 GB 约 0.45 GB 多行堆栈没有合并,每一行都是一条文档,条数可减约 90%
合计 40.9 GB 约 33–34 GB 处理后估计剩 7–9 GB

小结#

这次审计分四步:看 Lucene 文件后缀的占比,确认空间落在原文、倒排还是 doc values 上;随机抽样找出占空间的字段和接口;检查 .keyword 这类子字段有没有被用到;再看查询模式,判断能不能不存位置信息。47.5 GB 里最大的一块(估算约 23 GB)来自 /game/list 把完整响应体写进日志,首选在源头不再记录它;OpenSearch 这一侧可以叠加 zstd 和去掉长文本的 .keyword(估算全部数据减少约 16%),responseBody 要不要存位置信息,按日志使用者的查询习惯决定。这些措施都还没有执行。