Skip to content

필사 모드: 让日志可搜索,同时别把公司搜穷 — 结构化、映射爆炸、保留期与真实成本

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 日志成本超过计算成本的那一天

在整理季度基础设施账单时,发现日志存储和索引的花费比应用计算本身还贵,这种事并不少见。而那些日志里的大多数,根本没人搜索过。

原因通常不是压缩率或存储单价。往往是这样一条路径:一开始「先把所有东西都塞进去再说」,字段越来越多,索引越来越大,想缩短保留期又撞上审计要求,最后集群变得不稳定。

本文会沿着这条路径倒着走一遍,讲清每个节点本该做出什么决策。内容以 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

字段设计有三条原则。

  1. 名字固定,值可变。 绝不要把 ID 或日期塞进字段名本身。光这一条规则就能挡住九成的映射爆炸。
  2. 一个字段一种类型。 如果 duration 在某条日志里是数字,在另一条里是字符串 "1.4s",映射会固定成先被索引的那种类型,剩下的都会当作索引失败被丢弃。
  3. 大块内容是正文,不是字段。 堆栈跟踪、请求体、响应负载,不要把它们做成可搜索字段,而是放进不做索引的正文字段里。

映射爆炸 — 索引真正被拖死的路径

动态映射会自动注册没见过的字段。这很方便,但一旦字段名是从数据里长出来的,就危险了。

{
  "message": "cache stats",
  "cache": {
    "user_8871_hits": 12,
    "user_8871_misses": 3,
    "user_9902_hits": 7
  }
}

每个用户都会催生一个新字段。用户有 10 万,字段就有 20 万个。映射会进入集群状态,而集群状态由所有节点共享,每次变更都要广播出去。结果会按下面的顺序显现出来。

  1. 索引延迟开始上升。每个新字段都需要一次映射更新,而这要经过主节点
  2. 主节点的 CPU 和堆内存开始升高
  3. 集群状态广播变慢,节点开始掉线
  4. 分片重新分配开始,索引和搜索一起变慢
  5. 触及字段数上限,索引被拒绝

最糟糕的是,一旦进展到第 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]'

采样 —— 有原则地丢弃日志

如果全量保留和预算对不上,就必须丢一些,问题在于丢什么。原则只有一条:丢的是冗余度高的,而不是价值低的。

按效果从大到小排列如下。

  1. 健康检查和探针日志 —— 常常占全部日志的 20%~40%,信息量几乎为零
  2. 成功路径上的 DEBUG 和 INFO —— 没出错的请求的详细日志
  3. 反复出现的同一事件 —— 如果同一个 error.type 每秒出现几千次,就只留排名前 N 条加一个计数
  4. 静态资源请求 —— 图片、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.idtenant.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)

在整理季度基础设施账单时,发现日志存储和索引的花费比应用计算本身还贵,这种事并不少见。而那些日志里的大多数,根本没人搜索过。

작성 글자: 0원문 글자: 10,426작성 단락: 0/323