跳到主内容

十亿级日志降本 50%:ELK 索引拆分与冷热分层实战 ​

问题背景:十亿级日志为什么会把 ELK 拖垮 ​

很多团队的 ELK 上线初期只有一个按天滚动的索引,例如 app-log-2026.01.01。当日志从每天千万级涨到十亿级后,典型症状会集中出现:单索引分片超过 100GB,热点节点磁盘告警;Kibana 查询频繁触发 circuit_breaking_exception;保留 30 天日志后,SSD 成本是预算的数倍;凌晨新索引创建时还有短暂写入抖动。

本质原因不是 Elasticsearch 不能存,而是把“热写入、热查询、冷归档”三种生命周期完全不同的数据放在同一层硬件和同一种索引策略里。十亿级场景下,日志平台优化的核心不是盲目加机器,而是同时解决三件事:按业务和时间拆分索引、用 ILM 做冷热分层、把低频访问数据迁移到对象存储。

方案对比:单索引、按天拆分与冷热分层怎么选 ​

方案写入方式查询表现存储成本适用阶段
单一大索引所有服务写一个索引元数据简单,但段文件巨大,删除只能按文档删最高仅适合千万级以下
按天索引每天一个索引时间范围查询清晰,可整天删除中等亿级以内、日志类型少
业务+日期拆分服务、环境、日期组合索引能裁剪索引和分片中等偏低多业务线、十亿级
冷热温冻分层热节点 SSD,冷节点 HDD,冻结层快照近期数据快,历史数据低成本最低长周期保留、审计合规

推荐组合是“业务字段 + 日期模板 + ILM 自动分层 + 快照归档”。不要过早按 traceId、userId 这类高基数字段拆索引,否则索引数量爆炸,集群状态更新反而成为瓶颈。

应用与 FilebeatKafka 缓冲Logstash/IngestHot 热节点 SSDWarm/Cold HDDFrozen 对象存储

索引拆分策略:用业务、环境和日期控制单索引体量 ​

建议索引名采用 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.json

ILM 冷热分层配置:让数据自动迁移而不是人工搬索引 ​

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 个问题 ​

  1. 只创建 ILM 策略,却没有绑定索引模板;或写入模式与 rollover 混用。 现象是索引一直停在 Hot,explain lifecycle 中看不到自动迁移。修正方法是在模板 settings 中配置 index.lifecycle.name;写入端直接按日期命名索引时不要配置 rollover(它只对别名写入或 Data Stream 生效),两种模式二选一。

  2. 分片数量按峰值拍脑袋设置,产生海量小分片。 现象是集群状态变绿但查询很慢,主节点堆压力高。修正方法是让单分片接近 30GB 到 50GB,低流量服务合并到公共索引,高流量服务再单独拆分。

  3. 在 Cold 阶段直接把副本设为 0,但节点没有冗余。 现象是单块磁盘损坏导致部分历史日志不可查。修正方法是至少保证副本分片分布在不同节点,或先把数据放入对象存储快照,再降低在线副本。

  4. message 中写入高基数和敏感信息。 现象是字段映射膨胀、磁盘暴涨,甚至出现安全审计问题。修正方法是将 userId、traceId 设计为 keyword,URL 使用模板化路径,采集层通过 dissect/reroute 做脱敏和丢弃。

十亿级日志平台的目标不是“所有日志都秒查”,而是让最近排障数据足够快、历史审计数据足够便宜、全链路 trace_id 能在需要时串联起来。按业务拆分索引、用 ILM 自动迁移、再配合快照归档和采集降噪,通常可以在不牺牲核心查询体验的前提下,把整体日志成本降低 50% 以上。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。