外观
十亿级日志降本 50%:ELK 索引拆分与冷热分层实战
问题背景:十亿级日志为什么会把 ELK 拖垮
很多团队的 ELK 上线初期只有一个按天滚动的索引,例如 app-log-2026.01.01。当日志从每天千万级涨到十亿级后,典型症状会集中出现:单索引分片超过 100GB,热点节点磁盘告警;Kibana 查询频繁触发 circuit_breaking_exception;保留 30 天日志后,SSD 成本是预算的数倍;凌晨新索引创建时还有短暂写入抖动。
本质原因不是 Elasticsearch 不能存,而是把“热写入、热查询、冷归档”三种生命周期完全不同的数据放在同一层硬件和同一种索引策略里。十亿级场景下,日志平台优化的核心不是盲目加机器,而是同时解决三件事:按业务和时间拆分索引、用 ILM 做冷热分层、把低频访问数据迁移到对象存储。
方案对比:单索引、按天拆分与冷热分层怎么选
| 方案 | 写入方式 | 查询表现 | 存储成本 | 适用阶段 |
|---|---|---|---|---|
| 单一大索引 | 所有服务写一个索引 | 元数据简单,但段文件巨大,删除只能按文档删 | 最高 | 仅适合千万级以下 |
| 按天索引 | 每天一个索引 | 时间范围查询清晰,可整天删除 | 中等 | 亿级以内、日志类型少 |
| 业务+日期拆分 | 服务、环境、日期组合索引 | 能裁剪索引和分片 | 中等偏低 | 多业务线、十亿级 |
| 冷热温冻分层 | 热节点 SSD,冷节点 HDD,冻结层快照 | 近期数据快,历史数据低成本 | 最低 | 长周期保留、审计合规 |
推荐组合是“业务字段 + 日期模板 + ILM 自动分层 + 快照归档”。不要过早按 traceId、userId 这类高基数字段拆索引,否则索引数量爆炸,集群状态更新反而成为瓶颈。
索引拆分策略:用业务、环境和日期控制单索引体量
建议索引名采用 logs-{service}-{env}-%7Bnow%2Fd%7Byyyy.MM.dd%7D%7D 这种模式。例如订单服务生产环境写入 logs-order-prod-2026.01.01。这样做有三个收益:查询时按服务和时间裁剪索引;删除历史数据直接删整索引,不产生删除标记;不同重要级别服务可以绑定不同 ILM 策略。
分片大小建议控制在 30GB 到 50GB,热阶段主分片数不要频繁变化。如果每天一个服务产生 200GB 日志,可配置 5 个主分片;如果只有 5GB,则使用 1 个主分片,避免大量小分片消耗堆内存。
分片数不适合在单一模板里一刀切。Elasticsearch 的索引模板支持 priority 优先级:同一名同时命中多个模板时,priority 高的生效。实践中通常配置两层——通用模板兜底低流量服务,高流量服务用更高 priority 的模板单独覆盖分片数。
通用兜底模板(低流量服务,每天日志量小于 30GB,单分片即可):
json
{
"index_patterns": ["logs-*-*"],
"template": {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1,
"index.routing.allocation.include._tier_preference": "data_hot",
"index.lifecycle.name": "logs-hot-warm-cold-delete"
},
"mappings": {
"properties": {
"@timestamp": {"type": "date"},
"service": {"type": "keyword"},
"env": {"type": "keyword"},
"level": {"type": "keyword"},
"trace_id": {"type": "keyword"},
"message": {
"type": "text",
"analyzer": "standard"
}
}
}
},
"priority": 100
}高流量服务覆盖模板(订单、支付等每天 200GB 级别,按 40GB 单分片拆 5 主分片):
json
{
"index_patterns": ["logs-order-*", "logs-pay-*"],
"template": {
"settings": {
"number_of_shards": 5,
"number_of_replicas": 1,
"index.routing.allocation.include._tier_preference": "data_hot",
"index.lifecycle.name": "logs-hot-warm-cold-delete"
}
},
"priority": 200
}覆盖模板无需重复声明 mappings,继承通用模板的映射定义;priority: 200 保证高流量服务命中自己的分片配置。后续新增大流量服务时,只需要追加一条高 priority 模板,不改动存量索引。
执行命令:
bash
curl -X PUT "http://es:9200/_index_template/logs_template" \
-H "Content-Type: application/json" \
--data-binary @index-template.jsonILM 冷热分层配置:让数据自动迁移而不是人工搬索引
ILM 的关键是每阶段都明确目标、触发条件和索引动作。一个常见保留策略是:0 到 2 天驻留 Hot,使用 SSD 并保留副本;3 到 7 天进入 Warm,收缩分片、Force Merge 为 1 个段并设为只读;8 到 30 天进入 Cold,迁移到 HDD,可将副本降为 0;超过 30 天 Delete 或进入 Frozen 快照。
注意本方案不使用 rollover 动作:rollover 面向「应用固定写一个别名、由 ILM 决定何时切新索引」的写入模式,要求索引配置 index.lifecycle.rollover_alias 或使用 Data Stream。而本文写入端直接按 logs-{service}-{env}-日期 命名索引,索引天然按天滚动,再配 rollover 既不生效也会让策略卡在 hot 阶段。两种模式二选一:直接写日期索引用下面这份「无 rollover」策略,各阶段 min_age 从索引创建时间起算。
json
{
"policy": {
"phases": {
"hot": {
"actions": {
"set_priority": {"priority": 100}
}
},
"warm": {
"min_age": "2d",
"actions": {
"set_priority": {"priority": 50},
"allocate": {
"include": {"data_tier": "data_warm"},
"number_of_replicas": 1
},
"forcemerge": {"max_num_segments": 1},
"shrink": {"number_of_shards": 1}
}
},
"cold": {
"min_age": "7d",
"actions": {
"set_priority": {"priority": 0},
"allocate": {
"include": {"data_tier": "data_cold"},
"number_of_replicas": 0
},
"freeze": {}
}
},
"delete": {
"min_age": "30d",
"actions": {
"delete": {}
}
}
}
}
}节点角色必须和策略匹配,例如热节点启动参数中包含 data_hot,冷节点包含 data_cold。否则 ILM 会显示无法分配分片,索引长期停留在上一阶段。
Go 写入端示例:按服务生成索引并控制字段基数
下面示例使用 Go 官方推荐的 Elasticsearch v8 客户端,将结构化日志写入按服务、环境和日期拆分的索引。代码可直接运行,只需替换 ES 地址。
go
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
elasticsearch "github.com/elastic/go-elasticsearch/v8"
)
type LogEvent struct {
Timestamp time.Time `json:"@timestamp"`
Service string `json:"service"`
Env string `json:"env"`
Level string `json:"level"`
TraceID string `json:"trace_id"`
Message string `json:"message"`
}
func indexName(service, env string, t time.Time) string {
// 按服务、环境、天拆分,避免单索引无限膨胀
return fmt.Sprintf("logs-%s-%s-%s", service, env, t.Format("2006.01.02"))
}
func main() {
client, err := elasticsearch.NewClient(elasticsearch.Config{
Addresses: []string{"http://127.0.0.1:9200"},
})
if err != nil {
log.Fatalf("创建 ES 客户端失败: %v", err)
}
event := LogEvent{
Timestamp: time.Now(),
Service: "order",
Env: "prod",
Level: "ERROR",
TraceID: "tr-20260101-0001",
Message: "inventory deduct timeout",
}
body, err := json.Marshal(event)
if err != nil {
log.Fatalf("日志序列化失败: %v", err)
}
index := indexName(event.Service, event.Env, event.Timestamp)
resp, err := client.Index(index, bytes.NewReader(body),
client.Index.WithOpType("create"),
)
if err != nil {
log.Fatalf("写入日志失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode() != http.StatusCreated {
log.Fatalf("ES 返回异常状态: %s", resp.Status())
}
log.Printf("写入成功,索引=%s", index)
}初始化模块:
bash
go mod init logsink
go get github.com/elastic/go-elasticsearch/v8@latest
go run main.go生产环境建议在应用前增加 Kafka 或 OpenTelemetry Collector。应用只负责本地异步落盘和采集,不直接把 ES 作为同步依赖,避免 ES 抖动放大到业务接口。
成本优化:把钱花在最近 7 天的数据上
成本下降主要来自四层手段。第一,热节点只保留最近 1 到 3 天,SSD 用于写入和高频查询;第二,Warm/Cold 使用大容量 HDD 或低配比云盘,关闭频繁刷新;第三,Warm 阶段执行 Force Merge,日志类只读索引通常能减少 20% 到 40% 的磁盘占用;第四,30 天以上数据通过 Searchable Snapshot 放到对象存储,满足偶发审计即可。
还需要在采集端降噪:DEBUG 日志默认采样或不采集;重复堆栈只保留首条和计数;访问日志只记录状态码、耗时、URI 模板,不记录完整 URL,避免把 token、手机号和订单号打进 message。字段治理往往比换节点规格更直接,十亿级日志中减少 20% 无效字段,就能省下同样比例的索引、副本和网络成本。
避坑总结:生产环境最容易踩的 4 个问题
只创建 ILM 策略,却没有绑定索引模板;或写入模式与 rollover 混用。 现象是索引一直停在 Hot,
explain lifecycle中看不到自动迁移。修正方法是在模板 settings 中配置index.lifecycle.name;写入端直接按日期命名索引时不要配置 rollover(它只对别名写入或 Data Stream 生效),两种模式二选一。分片数量按峰值拍脑袋设置,产生海量小分片。 现象是集群状态变绿但查询很慢,主节点堆压力高。修正方法是让单分片接近 30GB 到 50GB,低流量服务合并到公共索引,高流量服务再单独拆分。
在 Cold 阶段直接把副本设为 0,但节点没有冗余。 现象是单块磁盘损坏导致部分历史日志不可查。修正方法是至少保证副本分片分布在不同节点,或先把数据放入对象存储快照,再降低在线副本。
message 中写入高基数和敏感信息。 现象是字段映射膨胀、磁盘暴涨,甚至出现安全审计问题。修正方法是将
userId、traceId设计为keyword,URL 使用模板化路径,采集层通过 dissect/reroute 做脱敏和丢弃。
十亿级日志平台的目标不是“所有日志都秒查”,而是让最近排障数据足够快、历史审计数据足够便宜、全链路 trace_id 能在需要时串联起来。按业务拆分索引、用 ILM 自动迁移、再配合快照归档和采集降噪,通常可以在不牺牲核心查询体验的前提下,把整体日志成本降低 50% 以上。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。