
凌晨两点被值班电话叫醒说线上订单成功率骤降。我登上跳板机打开终端面对的是三台应用服务器上的几十个日志文件。一行一行地grep关键字一会儿怀疑是数据库连接池爆了一会儿又觉得像是下游接口超时来来回回折腾了一个多小时最后才发现是某台实例里一条被截断的堆栈日志指向了内存溢出。那一刻我下定决心不能再靠肉眼翻文件排查问题了必须把日志统一收起来在一个地方就能搜、能看、能分析。这就是我搭建ELK收集Spring Boot日志的背景。ELK指的是Elasticsearch、Logstash、Kibana这套组合专门用来做日志的采集、存储、检索和可视化。如果你也在维护Spring Boot应用而且手头有超过两台服务器日志排查已经让你感到吃力那这篇文章写的整套方案和踩坑记录应该能帮你少走不少弯路。下面不讲什么架构图和高大上的原理只讲我从零开始搭、上线跑了半年之后沉淀下来的实操内容和教训。1. 为什么非要用ELK这套组合来收集Spring Boot日志1.1 没有日志平台之前排查问题到底是什么状态在动手搭建之前我先梳理了一下原来的日志处理方式。Spring Boot默认的日志输出就是往本地文件里写一台服务器一个文件甚至一个应用多个实例就是多个文件。我们当时的架构是Nginx后面挂了三个应用实例日志分别落在各自的服务器上排查问题只能按下面这套流程走先看告警猜一个大概的时间点。依次登录三台服务器用tail、grep配合时间戳去翻日志。如果问题跨多个服务还要一边看应用日志一边看下游服务日志人工对时间线。遇到堆栈异常被截断还得回服务器重新找原始文件。这招在小流量的单机场景下勉强能忍但只要请求量上来日志一滚动旧文件被压缩成.gz包排查效率会进一步下降。更头疼的是没有全局聚合能力——你根本无法快速回答“过去半小时内所有ERROR级别的日志有多少条、分布在哪些实例”这类问题。而这类问题恰恰是定位线上故障最常用的切入点。1.2 ELK三个组件在链路里的分工ELK这个名字看着像是一个整体但每个组件的职责非常清楚我后来跟同事解释的时候经常打一个比方Logstash是搬运工Elasticsearch是仓库Kibana是仓库的展示窗口。Logstash负责从日志文件或者其他数据源采集数据做过滤、解析、转换然后把干净的数据送给Elasticsearch。它插件生态丰富支持input、filter、output各种插件自由组合。Elasticsearch负责存储和检索。它基于倒排索引能对日志内容做全文检索也能对字段做聚合统计比如按日志级别、按应用实例、按接口路径等维度做count。Kibana负责可视化。它把Elasticsearch里的数据变成可查询的界面可以写KQL语法做搜索也可以拖拽生成柱状图、折线图、饼图甚至做Dashboard。对Spring Boot项目而言这套组合最大的优势在于应用只需要做很小的改动——把日志格式从纯文本改成JSON——剩下的采集、清洗、存储、查询全链路都是现成的解决方案。不需要从零开发也不需要维护复杂的自研组件社区里的踩坑资料也足够丰富遇到问题搜一下一般都有答案。我当时也考虑过是否直接用云厂商的日志服务但最终选择自建ELK一是因为我们有现成的服务器资源二是因为日志数据的控制权在自己手里索引策略、保留周期、字段映射都能灵活调。1.3 什么规模的上应用才适合上ELK这里我必须说点实话。ELK不是银弹如果你的Spring Boot应用只是单机部署每天的日志量只有几百MB那完全没必要搞这套东西直接在服务器上用grep和tail就够用Kibana带来的查询便利远不如运维成本大。适合上ELK的场景我总结为三条应用实例数大于等于3日志已经分散在多台机器上。排查线上问题需要对多实例日志做时间线合并或全局检索。团队有基本的Linux操作能力和意愿去维护Elasticsearch集群。如果你符合上面两三条再动手。否则不要为了技术而技术。2. Spring Boot日志端改造先把日志变成机器能读懂的结构2.1 默认文本日志为什么不适合被采集网上很多ELK教程上来就直接教你怎么配Logstash但我个人强烈建议先改应用日志输出格式。Spring Boot默认的文本日志长这样2025-01-05 10:00:00.123 INFO 12345 --- [http-nio-8080-exec-1] com.example.service.OrderService : 订单创建成功orderId10086这种日志人眼读起来没问题但对Logstash来说不够友好。想在Logstash里把时间、级别、线程名、类名、消息拆成独立字段你得写一堆grok正则而且Java异常堆栈是多行的处理起来更是麻烦——一条堆栈信息会被拆成好几个事件后续检索时根本没法把异常堆栈当成一个整体来看。我一开始也想用纯文本加grok的方案后来发现随着日志里字段越来越多正则只会越来越复杂维护成本实在太高。最后换成了JSON结构化输出效果立竿见影Logstash里一句json插件就能解析所有字段自动拆分多行堆栈在JSON里就是一个字符串不用再处理multiline问题。2.2 用logstash-logback-encoder输出JSON日志Spring Boot 2.x默认使用Logback作为日志框架我们只需要引入一个编码器依赖并调整logback-spring.xml里的输出格式即可。这个依赖在Maven里的坐标是dependency groupIdnet.logstash.logback/groupId artifactIdlogstash-logback-encoder/artifactId version7.3/version /dependency然后新建logback-spring.xml放在src/main/resources下。下面是我实际在用的配置删减了一些内部业务字段保留了核心结构?xml version1.0 encodingUTF-8? configuration springProperty scopecontext nameappName sourcespring.application.name/ springProperty scopecontext nameprofile sourcespring.profiles.active/ appender nameJSON_FILE classch.qos.logback.core.rolling.RollingFileAppender file/data/logs/${appName}/${appName}.json/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern/data/logs/${appName}/${appName}.%d{yyyy-MM-dd}.%i.json/fileNamePattern timeBasedFileNamingAndTriggeringPolicy classch.qos.logback.core.rolling.SizeAndTimeBasedFNATP maxFileSize500MB/maxFileSize /timeBasedFileNamingAndTriggeringPolicy maxHistory7/maxHistory /rollingPolicy encoder classnet.logstash.logback.encoder.LogstashEncoder customFields{app_name:${appName},profile:${profile}}/customFields /encoder /appender root levelINFO appender-ref refJSON_FILE/ /root /configuration这个配置做了几件重要的事日志统一写到/data/logs/{appName}/{appName}.json按天滚动单个文件超过500MB继续滚动保留7天。每条日志以JSON格式输出LogstashEncoder会自动带上timestamp、version、logger_name、thread_name、level、message等标准字段。通过customFields给每条日志额外加上app_name和profile两个字段。这个太重要了后续在Kibana里按应用筛选就靠它。有一点提一下LogstashEncoder默认会输出level_value等字段生产环境建议看下实际生成的结果按需精简避免每一行日志携带过多无关字段。2.3 MDC里塞traceId链路日志才能串起来光有JSON还不够。Spring Boot应用在微服务调用链里一个请求会经过多个方法甚至多个服务如果每条日志之间没有关联标识在ELK里你只能按时间猜没法快速拉出整条调用链路。我用的是在日志过滤器里往MDC塞traceId的方案。具体做法是在logback-spring.xml里增加一个MDCInsertingServletFilter或自定义Filter在请求进入时生成UUID写入MDC然后在JSON输出里带上MDC字段。LogstashEncoder默认会把MDC里的键值也输出成JSON字段所以无需额外配置。有了trace_id字段之后在Kibana里搜一个请求直接按trace_id筛选整条链路的所有日志就都出来了排查效率完全不在一个量级。2.4 同步打印还是异步打印日志输出本身有IO开销在业务QPS较高时每条日志都同步写磁盘会影响接口响应时间。线上数据说话我们某个核心服务在日志量高峰期同步输出JSON时接口RT上升了约20%。后来我把日志输出改成了异步问题立刻缓解。Logback的异步配置也简单在JSON_FILE外面包一层AsyncAppenderappender nameASYNC_JSON classch.qos.logback.core.AsyncAppender queueSize8192/queueSize discardingThreshold0/discardingThreshold neverBlocktrue/neverBlock appender-ref refJSON_FILE/ /appender通过上面配置日志写入成为异步操作业务线程不会被磁盘IO卡住。如果队列满了discardingThreshold0意味着丢弃部分日志而不是阻塞业务线程这在日志不敏感业务里是可以接受的如果日志需要百分百采集可以调大queueSize但也要接受内存占用上升的现实具体取舍看业务。3. Logstash采集链路从日志文件到Elasticsearch的这段路3.1 用Filebeat采集还是直接让Logstash读文件第一个要做的决定是采集端选型。Logstash可以直接用file插件读文件但它的JVM进程比较重如果每台应用服务器都部署一个Logstash内存占用会非常难看。我们应用服务器是4C8G的配置再塞一个默认分配1-2G堆内存的Logstash业务压力大的时候会很危险。所以我最终选了Filebeat Logstash的经典组合Filebeat部署在每台Spring Boot应用服务器上用Go写的轻量、资源占用小负责读日志文件并转发给中心Logstash。Logstash单独部署在日志服务器上集中处理所有应用的日志流做解析、清洗、写ES。这种架构的好处是采集端足够轻解析逻辑只在中心Logstash维护一份后续修改解析规则不用去每台应用服务器上动配置管理成本直接下降。3.2 Filebeat配置要采集哪些文件Filebeat的配置在filebeat.yml里核心部分如下filebeat.inputs: - type: log enabled: true paths: - /data/logs/*/*.json json.keys_under_root: true json.add_error_key: true fields: log_source: app fields_under_root: true output.logstash: hosts: [10.0.0.x:5044]这里我用的是/data/logs/*/*.json这样的通配路径因为每台服务器上可能部署多个应用各自在/data/logs下有独立子目录Filebeat会自动递归匹配。有个细节要说明json.keys_under_root: true表示JSON日志里的字段直接放到日志事件的根层级这样后续在Logstash里解析起来省事很多。fields里加的log_source: app是为了标识日志来源未来如果还想采集Nginx、系统日志等其他数据源就能做区分了。3.3 Logstash Pipeline配置一个能直接用的示例中心Logstash的Pipeline配置文件我放在/etc/logstash/conf.d/springboot.conf内容如下input { beats { port 5044 } } filter { if [log_source] app { json { source message target json_body skip_on_invalid_json true } if [json_body] { mutate { replace { message %{[json_body][message]} } add_field { logger_name %{[json_body][logger_name]} thread_name %{[json_body][thread_name]} level %{[json_body][level]} app_name %{[json_body][app_name]} } remove_field [ json_body ] } date { match [ [timestamp], ISO8601 ] target timestamp } mutate { remove_field [ host, log ] } } } } output { elasticsearch { hosts [http://10.0.0.x:9200] index springboot-logs-%{yyyy.MM.dd} } }这套Pipeline做了这么几件事接收来自5044端口的Filebeat数据。用json插件把message字段里的JSON字符串解析出来放到json_body里。用mutate把json_body里我们关心的字段提到顶层然后把原始json_body删掉。这一步是为了让最终写入ES的文档干净避免嵌套层太深导致Kibana里查询麻烦。用date插件把日志原始时间覆盖为timestamp保证后续Kibana里展示的时间是日志真实产生时间而不是Logstash接收时间。索引名按天滚动形如springboot-logs-2025.01.05。这样做的好处后面会细说。3.4 关于杜绝堆栈日志被拆散的一个认知很多人问我Java异常堆栈是多行的Filebeat采集的时候会不会拆成多个事件这个问题在纯文本日志场景下确实存在解决办法是配置Filebeat的multiline规则把堆栈行合并到前一条日志里。但如果你按我第2节的做法把日志输出改成了JSON格式那么这个坑在源头就消失了。LogstashEncoder输出的JSON是单行的异常堆栈作为字符串的一部分在JSON里自动转义存储Logstash解析后message字段里天然就是完整的堆栈文本。所以“多行堆栈问题”在我看来本质上是个上游格式化问题而不是采集端问题。把日志端改成结构化输出比在采集端做各种multiline配置更省心、更可靠这也是我实际用了半年之后的体会。3.5 Logstash性能参数与持久化队列Logstash默认Pipeline配置在日志量比较大的时候可能成为瓶颈我调过几个关键参数放在logstash.yml里pipeline.workers: 4 pipeline.batch.size: 1250 pipeline.batch.delay: 5 queue.type: persisted queue.max_bytes: 1gbpipeline.workers控制filter和output阶段的并发线程数一般跟CPU核数挂钩pipeline.batch.size和batch.delay控制批量攒批的大小适当提升能减少IO次数提高吞吐。重点说下queue.type: persisted。Logstash默认用内存队列如果写ES时ES恰好重启或者网络抖动内存里的日志可能直接丢。用持久化队列后日志先落盘保存等ES恢复后再消费发送可靠性好很多。代价是需要额外的磁盘空间我给了1GB实际运行中完全够用毕竟消费速度很快。4. Elasticsearch索引设计与Kibana查询配置4.1 按天滚动索引写ES的索引名设计成springboot-logs-2025.01.05这种按天切分的格式这个决定在我后来维护时帮了很大忙。按天索引的好处是删除旧数据简单直接删整个索引就行。索引体积可控单个索引的数据量不至于膨胀成巨无霸。排查问题时可以只查指定几天甚至一天的数据响应速度更快。配合索引模板可以让每天新建的索引自动套用统一的字段映射和分片设置不用每天手动建索引。模板配置大致如下{ index_patterns: [springboot-logs-*], settings: { number_of_shards: 3, number_of_replicas: 1, refresh_interval: 5s }, mappings: { properties: { timestamp: { type: date }, level: { type: keyword }, logger_name: { type: keyword }, thread_name: { type: keyword }, app_name: { type: keyword }, message: { type: text }, trace_id: { type: keyword }, profile: { type: keyword } } } }分片数3不是拍脑袋定的我们每天的日志量大概在2GB左右算上副本单个索引6GB。按单个分片20-30GB的常规规划标准3个分片既能支撑当前的日增量也有余量应对未来的日志增长。如果你日志量更大分片数可以相应提高但别贪多——分片过多会导致ES打开文件数暴涨集群状态反而容易黄。4.2 索引生命周期管理防止磁盘被日志打爆按天滚动索引如果不做清理日志会无限堆积一年下来磁盘迟早被写满。ES自带ILMIndex Lifecycle Management机制我用它把索引分成hot、warm、delete三个阶段hot阶段保存最近1天的数据使用SSD盘上的节点refresh_interval设置小一点保证查询实时性。warm阶段超过1天后进入索引只读refresh_interval调大减少资源占用。delete阶段保留30天后删除索引释放磁盘空间。策略配置好后在Kibana的Stack Management里创建ILM Policy再把策略绑定到索引模板新索引会自动套用。这个动作我建议在接入日志的第一天就做好否则后期磁盘告警再补中间那几天可能已经积累了没法挽回的数据。4.3 Kibana里怎么高效查日志日志进了ES之后Kibana里先创建Index Pattern匹配springboot-logs-*时间字段选timestamp就能在Discover里检索了。我实际用得最多的查询语法分这么几种按级别筛错误level: ERROR按应用筛app_name: order-service按关键字全文检索message: NullPointerException按链路ID查全链路trace_id: abc123...组合查询level: ERROR AND app_name: order-service AND message: timeoutKQL写法很直观布尔操作符用AND、OR、NOT。注意一点message字段类型是text全文检索会自动分词像trace_id、app_name这类字段是keyword查询时必须用精确匹配不能写模糊搜索。如果写对了字段名但搜不到数据先怀疑字段类型是不是不对。除了Discover检索我还给运维同事做了几个常用面板按小时统计ERROR数量变化的折线图、错误日志Top10接口的排名、应用各实例日志量对比。Kibana的Dashboard拖拽完成不需要额外写代码但前提是日志里有维度字段比如我加过app_name、trace_id、profile这些字段在排障时确实救了命。5. 实测中遇到的坑与完整排查过程5.1 时区偏了8小时Kibana时间永远对不上这是上线第一天就碰到的问题。日志在应用服务器上显示的时间是正确的北京时间但在Kibana里查询时总比实际时间慢8个小时看日志时感觉像是日志延迟了。当时我检查Kibana配置显示默认时区是浏览器当地时区看起来没有问题。接着我又去ES里查原始文档用curl直接请求ES接口看写入的数据发现文档里的timestamp字段存的确实比实际时间少了8小时。问题根因出在应用日志生成时间上。Spring Boot的JSON日志中timestamp携带UTC时区Logstash的date插件按ISO8601解析后写入ES没有做时区转换ES内部统一按UTC存储。而Kibana在展示时会按浏览器时区换算这本身没错错在我Filebeat和Logstash之间的链路没有把时区对齐到北京时间。排查链路是这样的先确认timestamp原始值是否为UTC时间。用ES里某个文档的原始JSON确认。再对比Logstash日志里采集时的日志时间确认Logstash解析没问题。最后确认Kibana的数据源和时区设置没问题。最后的解决办法很简单在Kibana里把时间显示时区改为Asia/Shanghai同时在Logstash的date插件里不依赖时区转换逻辑而是改用timezone参数指定Asia/Shanghai确保timestamp正确处理北京时间。这里也提醒一句排查时区问题不要一上来就怀疑ES先在数据源头把时间戳的时区含义搞清楚再逐步往后端链路排查你会少走很多弯路。5.2 Filebeat断点续传失效重启后日志丢失有一次给应用服务器发布新版本重启了Spring Boot进程然后发现重启前最后几条日志没有进ES。一开始以为是应用没打印出来后来去服务器上看了原始日志文件内容明明在Filebeat却没有采到。排查过程是这样的确认Filebeat进程是否正常运行systemctl status filebeat显示正常。查看Filebeat日志发现没有报错。检查Filebeat的registry文件发现里面记录的文件offset可能因为日志文件轮转错乱了。问题出在应用重启后日志文件路径发生了变化。因为Logback滚动策略是SizeAndTimeBasedFNATP当进程重启且当天已有多个滚动文件时文件名后缀可能变化。Filebeat靠文件名和文件inode来记住读取位置如果轮转把旧文件删了或者改了名它的registry里记录的offset就丢了。我做的改进有两点在Filebeat配置中加入clean_inactive和ignore_older参数确保不读取太旧的文件同时及时清理registry里的过期状态。更重要的是优化日志滚动策略把滚动周期和进程重启对不上号的问题从源头规避。我的实际配置是让当前日志始终写入固定的app.json滚动文件才带日期后缀。这样Filebeat始终针对同一路径追读很少再出现断点续传问题。5.3 Elasticsearch堆内存配置不对集群频繁GC告警日志上线一个月后收到ES集群GC时间过长的告警。登录服务器一查JVM堆内存被用满频繁Full GC查询时Kibana卡得几乎没法用。排查后发现根因是我在启动ES时没有显式设置ES_JAVA_OPTS默认堆内存是1GB而日志量增长后索引和查询的压力早就超过这个水位。确认问题很像补救方案是调大堆内存但调大前必须看服务器物理内存剩余情况避免堆设置太大导致系统本身内存不足。ES官方建议堆内存不超过物理内存的50%但不能超过32GB。我们日志服务器是8C16G给ES分配了8G堆内存。调整后GC告警消失查询响应时间从秒级降到几百毫秒。这个坑的教训是ES的堆内存一定要在部署时就根据物理资源规划好不要用默认值。默认堆1G只能说能启动根本跑不了生产。类似的还有bootstrap.memory_lock建议开启锁住物理内存防止堆内存被系统Swap到磁盘导致性能断崖式下跌。5.4 同一字段在不同日志中出现类型冲突导致索引mapping报错还有一个容易踩的坑是字段类型冲突。某天发现有个应用的日志索引创建失败查看ES日志提示某个字段在已有mapping中是keyword类型新日志想写入long类型冲突了。这个问题的场景很典型应用A日志里有个字段叫request_time输出的是字符串应用B日志里同名字段是数字ES按天索引本来各建各的没问题但因为我们用了同一个索引模板mapping固化后就无法再写入不同类型的数据。排查链路其实比较简单ES日志里已经明确指出冲突字段名和类型但关键是怎么规避方案一是不同应用用不同索引前缀从根上隔离比如order-logs-*、pay-logs-*但这样Kibana里检索要切换多个索引模式不够方便。方案二是在索引模板里对常用字段显式声明类型消除字段映射歧义把字段类型控制权掌握在自己手里。我最终用的是方案二在索引模板里把所有可能产生冲突的字段类型显式定义好新数据只接受匹配的类型。现在这个坑基本不会再出现。6. 如果让我再搭一次ELK这几个建议我会放在最前面从决定搭建ELK到整套链路跑顺前后花了大概两周时间中间踩过的坑不少。如果让我重新来一次我会把下面这几条作为起步阶段就贯彻的原则第一日志端的结构化改造优先级高于一切采集配置。先把Spring Boot的日志变成JSON后面所有解析、检索、面板都会省力不要跳过这一步直接去配Logstash和Kibana。第二从第一天就规划好索引模板和ILM策略不要等日志积累了几天、几十G之后再补那时插补策略的成本和风险都会大很多。第三给每条日志都带上trace_id和应用标识。ES的全文检索再强大没有关联标识你也很难在奔涌的日志洪流里把一次请求的完整生命周期捞出来。链路日志的价值在使用ELK半年之后我才算真正体会到。第四不要追求把所有日志都收进来。有些Debug日志、健康检查的探活日志、批量任务的空转日志不仅占存储还在检索时制造大量噪音。我在Logstash的filter里用drop插件把明显无用的日志在源头丢弃效果很不错索引体积直接降了30%以上Kibana里的检索结果也干净了很多。日志中台这件事做起来并不神秘难的是把链路里的每个环节都按生产标准配置好并且坚持治理。希望这篇基于实际经验写下的东西能帮正在搭建或者准备搭建Spring Boot日志收集体系的你避掉一些坑。如果你在搭建过程中也有自己的踩坑记录欢迎一起交流。