
前面几篇把日志平台的检索侧讲得差不多了:查询 DSL、聚合大屏、查询延迟实测。这一篇回到写入侧------日志从解析引擎进入 Elasticsearch 的那一段路。这是最容易踩坑、也最容易被忽视的一段:检索慢还能加机器,写入链路的坑会让数据本身就是错的。
先列四个真实发生过的问题,本文就是围绕它们展开的:
- 时间缺失:部分第三方日志根本没有可靠的时间字段,落库后无法按时间检索;
- 时间错误:某国产数据库(GaussDB)的运行日志时间与真实时间恒差 8 小时,时区没有转换;
- 类型混乱:指标数据的 value 字段,有时是数字有时是文本,混写同一个索引直接 mapping 冲突;
- 吞吐不足:机械盘集群按默认配置写入,吞吐顶不上去,堆积越滚越大。
一、写入链路全景
先看一条日志从产生到可检索的完整路径,后面每一节对应其中一环:
| 阶段 | 做什么 | 本文对应章节 |
|---|---|---|
| 解析 | 重命名字段、正则/分隔符解析、关联维表补齐主机信息、裁剪字段 | 第三节(时间修正) |
| 路由 | 按规则决定目标索引名(值类型路由) | 第四节 |
| 写入 | 客户端 bulk 缓冲批量发送 | 第五节 |
| 预处理 | ingest pipeline 自动补齐时间戳 | 第二节 |
| 落盘 | 索引模板 mapping、translog、refresh | 第二、五、六节 |
整个链路的统一接入设计(Kafka 到 Elasticsearch 的分层)在《云原生日志采集与检索架构实战》里讲过,这里不重复,聚焦每一环内部的实现细节。
二、ingest pipeline:写入时自动补时间戳
2.1 问题:没有可靠时间的日志
第三方系统吐出来的日志千奇百怪,有一类特别头疼:全文没有任何可解析的时间,或者时间字段经常缺失。这种数据进了 ES,按时间范围检索就查不到------而日志检索 99% 的查询都带时间条件。
解法是把"写入时刻"作为一个兜底字段补进去。ES 的 ingest pipeline 天然适合做这件事,它是数据写入分片前的一段预处理,不需要动上游任何代码:
json
PUT _ingest/pipeline/add_save_time
{
"description": "自动添加当前时间到 save_time 字段",
"processors": [
{
"set": {
"field": "save_time",
"value": "{{_ingest.timestamp}}",
"override": true
}
},
{
"date": {
"field": "save_time",
"formats": ["ISO8601"],
"timezone": "Asia/Shanghai"
}
}
]
}
两个 processor 各司其职:
set把内置变量_ingest.timestamp(pipeline 执行时刻,ISO8601 格式的 UTC 字符串)写进save_time,override: true保证重复写入(重试、重放)时刷新为最新时刻;date再按 ISO8601 解析这个字符串,并声明Asia/Shanghai时区,保证落库时间是东八区语义。
2.2 挂到索引模板:default_pipeline
pipeline 定义好了还要让索引自动用它,在索引模板的 settings 里加 default_pipeline:
json
PUT _index_template/logs_app_template
{
"index_patterns": ["logs_app-*"],
"template": {
"settings": {
"index": {
"default_pipeline": "add_save_time",
"refresh_interval": "30s",
"number_of_shards": "6",
"number_of_replicas": "0"
}
},
"mappings": {
"properties": {
"log_id": { "type": "keyword" },
"raw_message": { "type": "text" },
"log_source": { "type": "keyword" },
"host_ip": { "type": "keyword" },
"host_id": { "type": "keyword" },
"log_time": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss.SSS||yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis" },
"save_time": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss.SSS||strict_date_optional_time||epoch_millis" }
}
}
}
}
(老集群还在用 PUT _template/<name>?include_type_name 的 6.x/7.x 语法,mappings 里带 _doc 类型层;8.x 已移除类型,新模板直接按上面这样写。)
之后所有匹配 logs_app-* 的新索引,写入时都会先过一遍 add_save_time。注意两点:
default_pipeline只对新创建的索引生效 ,模板更新不会回溯到存量索引------存量索引要单独PUT /<index>/_settings补;- date 字段的 format 列表是配合解析侧的"多格式兜底",为什么这么设计见《Elasticsearch 日志检索 DSL 实战》里对四个时间字段语义的分析:
log_time是业务时间,save_time是写入时刻,两者做差还能衡量采集链路的延迟。
2.3 踩坑:pipeline 异常会挡住写入
ingest pipeline 里任何一个 processor 抛错,默认会导致整条 bulk 请求失败------数据直接写不进去。对于"补时间戳"这种锦上添花的处理器,应该显式声明失败策略:
json
{
"set": {
"field": "save_time",
"value": "{{_ingest.timestamp}}",
"override": true,
"ignore_failure": true
}
}
或者用 on_failure 把失败文档改投到一个死信索引,方便事后排查。核心原则:增强类的 processor 永远不要有能力弄丢原始日志。
三、源端时间差 8 小时:GaussDB 日志时区偏移修复
3.1 现象与根因
接入 GaussDB 的运行日志后,页面上的日志时间比真实时间恒定早 8 小时。根因是这类日志在源端打时间戳时用了 UTC,写出时却没有做时区转换------东八区看就是差 8 小时。写入 ES 后,log_time 与 save_time 的差值稳定在 -28800 秒附近,一看就是时区问题而不是时钟漂移(漂移不会恒定)。
3.2 解析侧五步修复
修复放在解析链路里做,把 log_time 的生成过程改成五步:
- 原解析出的
log_time改名为source_time(保留原始值,永远给自己留后路); - 把
source_time按其真实格式(yyyy-MM-dd HH:mm:ss)转换成秒级时间戳; - 数值计算
source_time + 28800,结果存为log_time; - 把
log_time从时间戳转回yyyy-MM-dd HH:mm:ss格式; - 下游的告警、检索继续用
log_time,完全无感。
如果源数据精确到毫秒,则用毫秒时间戳加 28800000,其余步骤相同。8 小时 = 28800 秒 = 28800000 毫秒,这个魔数建议直接写成 8 * 3600 参与计算,别硬编码一个光秃秃的 28800 让后人猜。
3.3 为什么不在查询侧修
一个自然的想法是查询时加 8 小时(range 上移时间窗)。很快就会发现不可行:
- 每个查询、每个大屏、每条告警规则都要各自加偏移,漏一处就是错误数据;
- 聚合的时间桶(
date_histogram)也要带偏移,DSL 复杂度爆炸; - 时间差是恒定的,完全可以在写入前一次性修正,没有理由把成本摊到每一次读取上。
修一次,还是修 N 次 ------数据治理类问题永远选前者。事后该数据库的日志组件在新版本里修正了打时间戳的时区,解析侧的偏移逻辑随之退役,但因为当初保留了 source_time,回溯和核对都有依据。
3.4 验证:时间差分位数聚合
修没修对,用一个聚合就能验证------统计 save_time - log_time 的分位数:
json
GET logs_app-*/_search
{
"size": 0,
"query": { "range": { "log_time": { "gte": "now-1h" } } },
"aggs": {
"lag_stats": {
"percentiles": {
"script": { "source": "(doc['save_time'].value.millis - doc['log_time'].value.millis) / 1000" },
"percents": [50, 95, 99]
}
}
}
}
修复前 p50 在 -28800 附近;修复后整体落到正数区间(秒级采集延迟),且分位数集中,说明偏移干净利落地消掉了,顺便还白捡一个采集延迟的监控指标。percentiles + script 的用法在《Elasticsearch 聚合 DSL 实战》里有同族展开。
四、指标按值类型路由索引
4.1 问题:一个 value 字段,又是数字又是文本
性能指标数据的结构很简单:指标名 item_name + 指标值 value。麻烦在于 value 的实际内容不可控------CPU 利用率是 12.5,状态类指标是 running,还有一些描述性文本。混写进同一个索引,ES 的动态映射第一次见到 value 时按数字建了 long/double,后面再来文本直接报 mapper_parsing_exception,数据丢失。
反过来统一当 text 存也不行:数值聚合(求平均、算同比)全废,监控场景不可接受。
4.2 设计:按值类型分四类索引
解法是在解析链路的路由环节,按 value 的实际类型把数据路由到不同索引:
| 索引模式 | 判定条件 | 典型内容 |
|---|---|---|
metric-double-yyyyMMdd |
含一个小数点的数字 | 12.5、0.87 |
metric-long-yyyyMMdd |
不含小数点、长度 ≤17 的整数 | 1024、86400 |
metric-text-yyyyMMdd |
非数字内容 | running、connection refused |
metric-unknown-yyyyMMdd |
指标名解析不出归属 | 兜底,防止丢数 |
日期后缀按天滚动,四类索引在同一个索引模板体系下管理生命周期(容量与分片规划见《Elasticsearch 日增 30TB 日志架构实战》)。这样每类索引的 mapping 是确定的:double/long 索引里 value 是数值类型,可以放心做聚合;text 索引里是 keyword/text。类型确定,是数值聚合可用的前提。
4.3 值类型判定规则
判定用一段可热插拔的表达式实现(我们存在一张规则表里,改规则不用重启任务),逻辑本身很朴素:
javascript
function routeValueType(value, itemName) {
if (value === null || value === "") return "text";
// 指标名归属:取 itemName 首个下划线前的大写前缀,转小写作为业务域
// 解析不出 → unknown,宁可进兜底索引也不能丢
if (itemName === null) return "unknown";
if (!/^[.\d]+$/.test(value)) return "text"; // 含非数字字符
const parts = value.split(".");
if (parts.length === 2) return "double"; // 恰好一个小数点
if (parts.length > 2) return "text"; // 1.2.3 是脏数据
// 纯整数:超过 17 位归 double,防溢出防丢精度
if (value.length > 17) return "double";
return "long";
}
有三个细节值得展开:
1.2.3判 text 而不是 double:多个小数点是典型的脏数据,强行按数字解析会在解析器里抛异常,整条日志跟着失败;- 17 位阈值是有来历的 :JS 的安全整数上限是 2^53-1(16 位),超过它数值精度先丢;ES 的
long上限是 19 位(9223372036854775807),再往上直接溢出。17 位以上的"整数"(时间戳纳秒、雪花 ID 之类)解析链路里已经保证不了精度,归入 double 最多损失精度,不会溢出报错; - unknown 兜底索引是保底设计:路由规则没覆盖到的新指标先进 unknown,数据一条不丢,事后再补规则。
4.4 收尾工作:从 unknown 里捞数据
运行一段时间后 unknown 索引里会积累一批未归类指标,定期 terms 聚合按 item_name 分组统计数量,把高频指标补进路由规则,下次滚动到新索引就自动归位。未知不可怕,不可见才可怕。
五、写入吞吐三件套:refresh、translog、bulk
5.1 refresh_interval:检索可见性让路
写入的文档先进 index buffer,refresh 才生成 segment 供检索。默认 1s 刷新一次,对批量写入场景太频繁------每次 refresh 都是一次 Lucene segment 提交,小 segment 一多,合并压力和查询开销都上来。日志场景把可见性延迟放宽到 30 秒毫无压力:
json
"refresh_interval": "30s"
5.2 translog:durability 换吞吐
translog 是写入的可靠性日志,默认每条请求都 fsync(request 模式),机械盘上这是吞吐杀手。改成异步刷盘:
json
PUT /_all/_settings
{
"index.translog.durability": "async",
"index.translog.sync_interval": "120s"
}
含义是 translog 每 120 秒才落一次盘。代价必须说清楚:节点宕机最多丢 120 秒内已应答的写入。 日志场景丢两分钟尾部数据可以接受,换来的吞吐提升立竿见影(第六节有实测);但如果集群里混着交易类索引,/_all/ 这种全集群设置就要改成按索引精确下发,别一刀切。
5.3 bulk 客户端缓冲参数
写入客户端(这里是 Flink 的 ES sink)的 bulk 缓冲有三个触发刷写的阈值,谁先到谁触发:
| 参数 | 机械盘集群 | 高配集群 | 说明 |
|---|---|---|---|
| bulkFlushMaxActions | 15000 | 10000 | 攒够多少条发一批 |
| bulkFlushMaxSizeMb | 20 | 60 | 批次大小上限(MB) |
| bulkFlushIntervalMs | 1000 | 1000 | 兜底定时刷写 |
| parallelism | 12 | 12 | 并发 bulk 的算子并行度 |
两个方向的经验:
- 批次往大了攒:一次 bulk 摊薄每条文档的固定开销。机械盘集群批次上限 20MB------再大单次 fsync 的停顿会传导回上游,反而造成周期性毛刺;
- 并行度对齐分片数:并行度 12 对应 12 个主分片,每个 bulk 请求能均匀散到各分片;并行度远超分片数时多余的请求只是在排队。
六、存储介质判断与机械盘实测
6.1 先确认盘是什么盘
调优前先搞清楚数据盘是固态还是机械,这决定了优化空间的上限:
bash
lsblk -o NAME,ROTA,SIZE,TYPE
ROTA=1 表示旋转盘(机械盘),ROTA=0 是固态。一般情况 sd a 是系统盘,数据盘看挂载点对应的那块。
6.2 机械盘四轮实测
一次固定数据量的批量导入(12 分片、0 副本、机械盘数据盘),四轮调优的耗时变化:
| 轮次 | 改动 | 耗时 |
|---|---|---|
| 1 | 初始配置 | 59s |
| 2 | 解析链路去掉一个冗余算子 | 69s |
| 3 | 无改动复跑 | 66s |
| 4 | translog 改 async / 120s | 62.989s |
结论有点反直觉但很真实:
- 第 2 轮"优化"反而更慢------去掉算子的收益被机械盘的随机写抖动整个淹没,同配置的波动(59→66s)比一次代码优化的收益还大;
- 机械盘上做写入调优,先看盘再看代码。translog async 消掉的是每请求 fsync,理论上收益最大,但实测也只是回到了第一轮的水平附近------因为瓶颈本质上是机械盘的 IOPS;
- 顺带一提,这套数字的意义在于建立基线:同一环境同一数据量,任何改动跑两轮以上再下结论,单轮数据在机械盘上没有可信度。
同样的参数换到固态盘集群(bulk 批次放宽到 60MB),吞吐直接翻了几倍------存储介质的代差不是参数能填的。
七、踩坑清单
最后汇总本篇链路上的坑,方便对照自查:
default_pipeline不回溯存量索引:模板改了只管新索引,存量索引要单独下发 settings;- 增强类 processor 必须声明
ignore_failure或on_failure:补时间戳这种功能绝不能有资格弄丢日志本体; - 时区偏移在写入前修,不在查询侧修 :恒定偏移修一次就够了,保留
source_time原始字段以便回溯; - 多小数点的值判 text :
1.2.3按数字硬解析会炸整条链路; - 超长整数归 double:17 位以上精度已不可信,宁可损精度不要溢出报错;
- 路由规则必须有 unknown 兜底索引:新指标先落袋,再定期归类;
translog async有 120 秒丢失窗口:日志可接受,交易不可接受,混合集群按索引精确下发;- 机械盘上调优先跑基线:盘抖动可能大于优化收益,单轮数据不作数。
结语
写入侧的活儿琐碎,但每一条都直接影响数据的正确性:时间错了,检索和告警全错;类型错了,聚合直接不可用;吞吐错了,堆积会放大前面所有问题。把 pipeline、路由、translog 这几件小事做扎实,日志平台的地基才算稳。
你的日志链路里踩过哪些时间字段的坑?欢迎在评论区交流。
系列前篇: