- 引言 — 日志成本超过计算成本的那一天
- 日志能回答的问题,和回答不了的问题
- 结构化日志 — 什么该做成字段
- 映射爆炸 — 索引真正被拖死的路径
- 索引生命周期 — 滚动与分层迁移
- 采样 —— 有原则地丢弃日志
- 用日志模拟指标,真实成本有多高
- 故障模式与排查
- 结语 —— 日志成本由字段设计决定,而不是数量
引言 — 日志成本超过计算成本的那一天
在整理季度基础设施账单时,发现日志存储和索引的花费比应用计算本身还贵,这种事并不少见。而那些日志里的大多数,根本没人搜索过。
原因通常不是压缩率或存储单价。往往是这样一条路径:一开始「先把所有东西都塞进去再说」,字段越来越多,索引越来越大,想缩短保留期又撞上审计要求,最后集群变得不稳定。
本文会沿着这条路径倒着走一遍,讲清每个节点本该做出什么决策。内容以 OpenSearch 3.5 和 OpenTelemetry Collector v0.157.0 为准验证。Elasticsearch 系列概念相通,只是对应 ISM 的功能名字换成了 ILM。
日志能回答的问题,和回答不了的问题
先划清界限。日志是单个事件的记录,所以它回答「为什么」。哪个值传进来、走了哪条分支、抛了什么异常,只有日志知道。
反过来,也有一些问题,用日志来回答代价很高。
| 问题 | 合适的信号 | 如果用日志来做 |
|---|---|---|
| 这个请求为什么失败了 | 日志 | 正是它的用途 |
| 现在错误率是百分之多少 | 指标 | 每次都要全量扫描,成本随查询次数线性增长 |
| 和昨天比怎么样 | 指标 | 保留期短,很多时候根本没有可比对象 |
| 请求在哪个服务变慢的 | trace | 靠按时间对齐各服务日志来猜 |
| 30 天前某个订单的处理记录 | 日志 | 这也正是日志的用途 |
削减日志的第一个办法不是压缩,而是把能挪到其他信号上的问题挪走。
结构化日志 — 什么该做成字段
结构化日志用键值对代替字符串。原因不是解析成本,而是可搜索性。
# 差 —— 要搜索就得写正则,格式一变那个正则就废了
log.info(f"order {order_id} for tenant {tenant} failed after {ms}ms: {err}")
# 好 —— 按字段搜索和聚合
log.info(
"order.failed",
extra={
"order.id": order_id,
"tenant.id": tenant,
"duration_ms": ms,
"error.type": type(err).__name__,
"error.message": str(err)[:500],
"http.route": route,
},
)
把事件名放在消息的位置上是关键。像 order.failed 这样取值有限的名字,光凭它就能做分组和聚合。一旦消息里混进了 ID,同一类事件就会变成各不相同的字符串,聚合就无从谈起。
字段名要一开始就定好约定。直接沿用 OpenTelemetry 的语义约定,日志和 trace 的属性名就能对齐,关联查询也会变得容易。
{
"@timestamp": "2026-08-02T04:11:52.418Z",
"severity_text": "ERROR",
"severity_number": 17,
"body": "order.failed",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"service.name": "checkout-api",
"service.version": "2.7.1",
"deployment.environment.name": "prod",
"http.route": "/v1/orders/:id",
"http.response.status_code": 502,
"error.type": "UpstreamTimeout",
"order.id": "A-99183",
"tenant.id": "t-8871",
"duration_ms": 1421
}
带着 trace_id 很关键。仅凭这一个字段,就能从 trace 跳到日志,再从日志跳回 trace。如果自动埋点支持日志关联,不改一行代码它就会自动带上。
# Python 自动埋点会把 trace_id 和 span_id 注入到日志里
export OTEL_PYTHON_LOG_CORRELATION=true
export OTEL_PYTHON_LOG_LEVEL=info
字段设计有三条原则。
- 名字固定,值可变。 绝不要把 ID 或日期塞进字段名本身。光这一条规则就能挡住九成的映射爆炸。
- 一个字段一种类型。 如果
duration在某条日志里是数字,在另一条里是字符串 "1.4s",映射会固定成先被索引的那种类型,剩下的都会当作索引失败被丢弃。 - 大块内容是正文,不是字段。 堆栈跟踪、请求体、响应负载,不要把它们做成可搜索字段,而是放进不做索引的正文字段里。
映射爆炸 — 索引真正被拖死的路径
动态映射会自动注册没见过的字段。这很方便,但一旦字段名是从数据里长出来的,就危险了。
{
"message": "cache stats",
"cache": {
"user_8871_hits": 12,
"user_8871_misses": 3,
"user_9902_hits": 7
}
}
每个用户都会催生一个新字段。用户有 10 万,字段就有 20 万个。映射会进入集群状态,而集群状态由所有节点共享,每次变更都要广播出去。结果会按下面的顺序显现出来。
- 索引延迟开始上升。每个新字段都需要一次映射更新,而这要经过主节点
- 主节点的 CPU 和堆内存开始升高
- 集群状态广播变慢,节点开始掉线
- 分片重新分配开始,索引和搜索一起变慢
- 触及字段数上限,索引被拒绝
最糟糕的是,一旦进展到第 3 步就很难回头了。映射没法删除,唯一的办法是重建索引。
要筑三道防线。
PUT _index_template/logs-app
{
"index_patterns": ["logs-app-*"],
"data_stream": {},
"priority": 200,
"template": {
"settings": {
"index.number_of_shards": 3,
"index.number_of_replicas": 1,
"index.refresh_interval": "30s",
"index.codec": "zstd_no_dict",
"index.mapping.total_fields.limit": 1500,
"index.mapping.depth.limit": 8,
"index.mapping.nested_fields.limit": 20
},
"mappings": {
"dynamic": "strict",
"properties": {
"@timestamp": { "type": "date" },
"severity_text": { "type": "keyword" },
"body": { "type": "keyword" },
"trace_id": { "type": "keyword" },
"span_id": { "type": "keyword" },
"service.name": { "type": "keyword" },
"service.version": { "type": "keyword" },
"http.route": { "type": "keyword" },
"http.response.status_code": { "type": "short" },
"error.type": { "type": "keyword" },
"error.message": { "type": "text", "index": true, "norms": false },
"duration_ms": { "type": "integer" },
"tenant.id": { "type": "keyword" },
"order.id": { "type": "keyword", "doc_values": true, "index": true },
"stack_trace": { "type": "text", "index": false },
"attributes": { "type": "flat_object" }
}
}
}
}
来看看这三道防线各自挡住了什么。
第一道,dynamic: strict。 一旦出现未定义的字段就拒绝这份文档。这很强硬,但很诚实。索引失败摆在明面上,总比日志悄悄消失要好。如果觉得直接拒绝代价太大,可以设成 false,只存不索引。
第二道,一个 flat_object 字段。 预留一个位置,专门接住那些没法预测的键值对。flat_object 把整个对象当成一个字段来处理,所以子键不会被注册进映射。你依然可以用点号语法查询,但因为没有针对子键的索引,搜索会比较慢。这个取舍正是我们想要的。
第三道,字段数上限。 就算出了事故,垮掉的也只是那一个索引,而不是整个集群。
| 防线 | 挡住什么 | 代价 |
|---|---|---|
| dynamic strict | 意外字段的注册 | 新增字段需要改模板 |
| flat_object | 任意键的映射注册 | 子键搜索变慢,聚合受限 |
| total_fields.limit | 事故的波及范围 | 超出上限后索引失败 |
| index: false | 大段文本的索引成本 | 该字段无法搜索,只能查看 |
| norms: false | 文本字段的评分元数据 | 相关度排序变得不那么精细 |
现状可以这样排查。
# 每个索引的字段数 —— 到了四位数就已经有问题了
curl -s 'https://opensearch:9200/logs-app-000042/_mapping?pretty' \
| jq '[paths(type=="object" and has("type")) | length] | length'
# 集群状态大小与映射更新队列
curl -s 'https://opensearch:9200/_cluster/stats?pretty' \
| jq '.indices.mappings'
curl -s 'https://opensearch:9200/_cluster/health?pretty' \
| jq '{status, number_of_pending_tasks, task_max_waiting_in_queue_millis}'
# 哪些索引在占用磁盘
curl -s 'https://opensearch:9200/_cat/indices/logs-*?v&s=store.size:desc&h=index,docs.count,store.size,pri,rep' \
| head -20
如果 number_of_pending_tasks 一直不为 0,就是映射更新在积压的信号。给这个值挂上告警,就能在爆炸走到第三步之前抓住它。
索引生命周期 — 滚动与分层迁移
日志索引不要只建一个,要按时间或大小切分。原因很简单:按索引删除是瞬时的,按文档删除却很贵。想清掉一天的日志而跑一次 delete-by-query,会触发段重写,把集群晃得东倒西歪。
PUT _plugins/_ism/policies/logs-app-lifecycle
{
"policy": {
"description": "应用日志保留 30 天",
"default_state": "hot",
"ism_template": [
{
"index_patterns": ["logs-app-*"],
"priority": 200
}
],
"states": [
{
"name": "hot",
"actions": [
{
"rollover": {
"min_primary_shard_size": "30gb",
"min_index_age": "1d"
}
}
],
"transitions": [
{ "state_name": "warm", "conditions": { "min_index_age": "3d" } }
]
},
{
"name": "warm",
"actions": [
{ "replica_count": { "number_of_replicas": 0 } },
{ "force_merge": { "max_num_segments": 1 } }
],
"transitions": [
{ "state_name": "cold", "conditions": { "min_index_age": "10d" } }
]
},
{
"name": "cold",
"actions": [
{ "read_only": {} }
],
"transitions": [
{ "state_name": "delete", "conditions": { "min_index_age": "30d" } }
]
},
{
"name": "delete",
"actions": [{ "delete": {} }]
}
]
}
}
按 min_primary_shard_size 来滚动,比按时间滚动更安全。流量突增的那天,如果一天的索引涨到 300GB,单个分片就会超过 100GB,搜索会急剧变慢。按大小做触发条件,那一天的索引就会自动拆成好几个。
在 warm 阶段把副本数降到 0,是拿成本换耐久性。要先确定旧日志因节点故障而丢失是否可以接受,再做这个决定。如果是需要审计的日志,就不要走这一步,而是把它们单独放进一个索引、配一套独立的保留策略。
也别忘了确认策略是不是真的在跑。ISM 会悄无声息地失败。
# 已挂载策略的索引及当前状态
curl -s 'https://opensearch:9200/_plugins/_ism/explain/logs-app-*?pretty' \
| jq 'to_entries[] | select(.value.index != null)
| {index: .value.index, state: .value."policy_id", step: .value.step.name, failed: .value.failed}'
# 只看失败的受管索引
curl -s 'https://opensearch:9200/_plugins/_ism/explain/logs-app-*?pretty' \
| jq '[to_entries[] | select(.value.failed == true) | .key]'
采样 —— 有原则地丢弃日志
如果全量保留和预算对不上,就必须丢一些,问题在于丢什么。原则只有一条:丢的是冗余度高的,而不是价值低的。
按效果从大到小排列如下。
- 健康检查和探针日志 —— 常常占全部日志的 20%~40%,信息量几乎为零
- 成功路径上的 DEBUG 和 INFO —— 没出错的请求的详细日志
- 反复出现的同一事件 —— 如果同一个 error.type 每秒出现几千次,就只留排名前 N 条加一个计数
- 静态资源请求 —— 图片、JS、CSS 的访问日志
在 collector 里做削减。因为这是一个不用改应用代码、只改策略就能生效的位置。
# otel-collector.yaml —— 日志流水线
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
# 1) 健康检查整个丢弃
filter/drop_health:
error_mode: ignore
logs:
log_record:
- 'attributes["http.route"] == "/healthz"'
- 'attributes["http.route"] == "/readyz"'
- 'attributes["http.route"] == "/metrics"'
# 2) 正常路径上的 DEBUG 只留 10%,错误不动
probabilistic_sampler/debug:
sampling_percentage: 10
attribute_source: record
from_attribute: trace_id
# 3) 截断过大的字段
transform/trim:
error_mode: ignore
log_statements:
- context: log
statements:
- truncate_all(attributes, 4096)
- delete_key(attributes, "http.request.body")
- set(attributes["error.message"], Substring(attributes["error.message"], 0, 500))
where attributes["error.message"] != nil
batch:
timeout: 5s
send_batch_size: 8192
exporters:
opensearch:
http:
endpoint: https://opensearch.observability.svc:9200
logs_index: logs-app
sending_queue:
enabled: true
queue_size: 5000
retry_on_failure:
enabled: true
max_elapsed_time: 300s
service:
pipelines:
# 错误和警告一律全量保留
logs/critical:
receivers: [otlp]
processors: [memory_limiter, transform/trim, batch]
exporters: [opensearch]
# 正常路径做采样
logs/routine:
receivers: [otlp]
processors: [memory_limiter, filter/drop_health, probabilistic_sampler/debug, transform/trim, batch]
exporters: [opensearch]
from_attribute: trace_id 很关键。同一条 trace 上的日志会一起保留或一起丢弃,剩下的日志才不会变得七零八落。如果按每条日志记录随机判定,一个请求的日志可能只剩三行,根本没法用来排查。
用路由把不同信号拆到不同流水线也是一种办法。按严重级别发到不同索引、配不同保留期,就能实现 ERROR 留 90 天、INFO 只留 7 天这样的策略。
| 层级 | 对象 | 保留期 | 索引 | 相对成本 |
|---|---|---|---|---|
| 热 | ERROR、WARN、审计事件 | 30~90 天 | 全部字段 | 基准 |
| 温 | INFO 中的业务事件 | 14~30 天 | 只保留核心字段 | 0.4 倍 |
| 冷 | 成功路径的访问日志 | 3~7 天 | 最小化 | 0.15 倍 |
| 归档 | 用于合规应对的原始副本 | 1~7 年 | 不索引,对象存储 | 0.02 倍 |
最下面这一行经常被忘记。如果因为审计要求缩短不了保留期,把这些日志原样放进对象存储、而不是搜索集群,需要时再取出来,架构会便宜得多。可搜索性和保留期是两回事。
用日志模拟指标,真实成本有多高
「日志都有了,从里面算错误率不就行了」是一个很自然的想法,规模小的时候确实能用。规模一大,成本结构就会反转。
用数字来看。
# 规模假设
rps = 5_000 # 每秒请求数
log_bytes = 800 # 一条日志索引后的大小 (字节)
seconds_of_day = 86_400
daily_gb = rps * log_bytes * seconds_of_day / 1024**3
print(f"每日索引量: {daily_gb:.1f} GB")
# 每日索引量: 321.8 GB
# 同样的信息用指标来表达
routes, status_classes, pods = 120, 5, 40
series = routes * status_classes * pods
samples_per_day = series * (seconds_of_day / 15) # 15 秒抓取一次
metric_bytes = samples_per_day * 2 # 压缩后每个样本约 2 字节
print(f"{series:,} 条时间序列,每日 {metric_bytes/1024**3:.3f} GB")
# 24,000 条时间序列,每日 0.257 GB
差距超过一千倍。而这还只是存储成本。真正的差距在查询环节会拉得更开。
| 项目 | 用日志算错误率 | 用指标算错误率 |
|---|---|---|
| 每日存储量 | 数百 GB | 数百 MB |
| 每 30 秒刷新一次的仪表盘 | 每次都全量扫描 | 查询预先算好的时间序列 |
| 30 天对比查询 | 翻查 30 天的文档,耗时数十秒 | 数百毫秒 |
| 保留期 | 受成本所限,7~14 天封顶 | 一年以上都现实可行 |
| 告警评估 | 每分钟一次很重的聚合查询 | 廉价的向量运算 |
| 流量突增时的表现 | 日志涨多少,查询就慢多少 | 时间序列数量不变 |
最后一行最危险。一出故障,日志就会暴涨,而恰恰就在这个时刻,基于日志的告警和仪表盘会变得最慢。这是一套在你最需要它的时候却不工作的观测系统。
正确的方向不是削减日志,而是把聚合挪到指标上,日志留给排查用。collector 可以替你完成这个转换。
# 从日志生成计数类指标,发往 Prometheus
connectors:
count:
logs:
log.error.count:
description: 按严重级别统计的日志发生次数
conditions:
- 'severity_number >= 17'
attributes:
- key: service.name
- key: error.type
- key: http.route
service:
pipelines:
logs/in:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [opensearch, count]
metrics/from_logs:
receivers: [count]
processors: [batch]
exporters: [prometheusremotewrite]
attributes 列表里放了什么就是一切。放进去的东西会原样变成指标标签,所以一旦放进 order.id 或 tenant.id,时间序列就会爆炸。一个在日志里毫无问题的高基数字段,一旦跨到指标那一侧就会立刻出问题。
故障模式与排查
| 症状 | 原因 | 排查方式 | 应对 |
|---|---|---|---|
| 日志只到了一部分 | 字段类型冲突导致索引被拒绝 | 索引失败的响应、死信 | 固定字段类型,在应用层统一类型 |
| 索引延迟持续上升 | 映射更新队列积压 | 集群待处理任务数 | 引入 dynamic strict、flat_object |
| 某个时间段搜索突然变得极慢 | 某个分片过大 | 各索引的分片大小 | 改为按大小滚动 |
| 旧索引一直删不掉 | ISM 策略失败后被放任不管 | ISM explain 里的 failed 字段 | 对失败索引重新应用策略,并加告警 |
| 磁盘突然被占满 | force_merge 期间的临时空间 | 合并任务状态 | 迁移到 warm 之前先留够余量 |
| 能搜索但聚合不了 | flat_object 的子键 | 检查映射 | 需要聚合的键提升为显式字段 |
| 故障期间日志丢失 | collector 队列饱和 | collector 的丢弃计数器 | 调整队列大小和背压策略 |
最后一行尤其要留心。如果日志流水线的背压一路传导到应用,就会出现观测系统反过来拖垮业务的局面。让 collector 在队列满时直接丢弃,并给丢弃计数器挂上告警。丢日志是坏事,但总比服务中断好。
exporters:
opensearch:
sending_queue:
enabled: true
queue_size: 5000
# 队列满了就丢弃新数据,不把背压传回应用
block_on_overflow: false
结语 —— 日志成本由字段设计决定,而不是数量
同样的流量下,两个组织的日志成本相差十倍,差别通常不在压缩算法。一个组织的字段名是固定的、聚合类问题已经挪到了指标上;另一个组织开着动态映射,仪表盘还在翻查原始日志。
现在就能做的检查有三项。第一,数一数你最大的日志索引有多少字段,到了四位数,这个季度的任务就已经定了。第二,拉出过去 30 天里真正被搜索过的索引列表,从没被搜索过的索引往往占了存储量的一半。第三,找出那些在扫描日志的仪表盘面板,看看能不能挪到指标上去。
值得继续深入的资料。
현재 단락 (1/323)
在整理季度基础设施账单时,发现日志存储和索引的花费比应用计算本身还贵,这种事并不少见。而那些日志里的大多数,根本没人搜索过。