选型复盘:为何从自研ELK转向Datadog APM

去年底双十一大促,我们的订单服务突然告警,P99延迟飙到2秒。我盯着自研的ELK日志系统,翻了半小时的trace_id关联日志,才定位到是库存服务调用的一个慢SQL。那时候我就意识到,靠日志做全链路排查太被动了——日志是离散的,而请求是连续的。

我们之前的ELK方案(Elasticsearch 7.17 + Filebeat 8.4)其实已经做了不少定制:通过MDC注入trace_id,在Nginx层生成,透传到Java/Go服务。但问题很明显:

2024年5月Datadog更新了Universal Service Monitoring正式版后,我决定推动团队迁移。核心原因就两个:eBPF的无侵入性OpenTelemetry的原生支持

先说eBPF。我们之前用Datadog Agent做APM时,Java服务要挂-javaagent:dd-java-agent.jar,Go服务要引github.com/DataDog/dd-trace-go。但去年我们上线了一个用Rust写的风控服务,官方没有对应的Tracer,只能靠日志。现在Datadog的eBPF探针(基于2024年Q1发布的eBPF Collector v1.2)可以直接在内核层捕获网络包,自动生成服务调用拓扑,不需要改一行代码。我测试时,在AWS EC2上启动eBPF模式后,Rust服务的调用关系直接出现在了Service Map里,连/check接口的耗时都抓到了。

再看OpenTelemetry兼容性。我们之前担心迁移到Datadog会被厂商锁定,毕竟OTel已经是行业标准。但Datadog 2024年的更新里明确支持OTel Protocol(OTLP)原生接入,不需要通过Agent中转。我写了个简单的OTel Collector配置,把我们现有的OTel埋点数据直接发到Datadog:

# otel-collector-config.yaml receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 exporters: datadog: api: key: "${DATADOG_API_KEY}" site: datadoghq.com # 直接映射OTel的service.name到Datadog的service tags: - otel.service.name=service.name service: pipelines: traces: receivers: [otlp] exporters: [datadog]

这个配置跑在我们K8s集群的otel-collector Deployment里,原来的Java服务用的opentelemetry-javaagent.jar(1.32.0版本)不用改任何代码,只需要在启动参数里加-Dotel.exporter.otlp.endpoint=http://otel-collector:4317,数据就直接进Datadog了。对比之前ELK方案里要改logback.xmltrace_id字段,这个迁移成本几乎为零。

还有个细节:Datadog的持续性能分析(Profiler)是始终在线的,开销只有3%左右。我们之前用async-profiler做Java性能分析,得手动触发,而且只能抓几分钟。现在Datadog的Profiler自动关联trace,有次发现订单服务的OrderService.create方法CPU占用高,点进去直接看到是JSON序列化时用的FastJSON在循环里创建SerializeConfig,换成Jackson后CPU降了15%。

实战案例:百万日活电商订单系统的慢查询定位与优化实录

我们那个电商订单系统,日常QPS 8000,大促时峰值能到3万。去年10月,运营反馈用户下单后经常转圈超过5秒。我打开Datadog APM的Service Page,找到order-service,看到P99延迟从平时的200ms飙到了1200ms,错误率也从0.1%涨到了2.3%。

先点进分布式追踪的Trace列表,筛选duration > 1000ms的trace。随便点开一个,链路图显示:

gateway -> order-service -> inventory-service -> mysql

其中inventory-service/deduct接口耗时980ms,占整个请求的82%。再点开这个span的代码级可见性,直接关联到堆栈追踪,看到最后一行是com.inventory.dao.StockDao.selectBySkuId执行的SQL:

SELECT * FROM t_stock WHERE sku_id = ? AND warehouse_id = ?

我复制这个SQL到Datadog的数据库监控页面,看到它的平均执行时间是920ms,扫描行数10万+。点开执行计划,发现warehouse_id字段没加索引——我们之前做分库分表时,只给sku_id加了索引,忘了warehouse_id也是常用查询条件。

但问题没这么简单。我再看这个trace的服务映射,发现order-service调用inventory-service用的是同步HTTP调用,超时设置的是3秒。而inventory-service当时因为慢查询,连接池被打满(HikariCP最大连接数50,当时活跃48个),导致后续请求排队。

我先在测试环境复现:用sysbench模拟1000个并发调用/deduct接口,确认加索引后SQL执行时间降到12ms。但上线后,P99延迟只降到了400ms,还是比平时高。这时候我注意到Datadog的实时性能仪表盘里,inventory-service的JVM GC次数在慢查询期间飙升——原来慢查询导致大量数据加载到内存,触发了Full GC。

我翻出当时的JVM参数:-Xmx2g -XX:+UseG1GC,G1的MaxGCPauseMillis设置的是200ms。但单次查询返回10万行数据(虽然实际只需要1行),导致Eden区瞬间占满,Mixed GC频繁。我改了两个地方:

-- 优化后的SQL,只查库存数量,加联合索引 SELECT stock_num FROM t_stock WHERE sku_id = ? AND warehouse_id = ? -- 对应的联合索引(DBA执行) CREATE INDEX idx_sku_warehouse ON t_stock(sku_id, warehouse_id);
// InventoryService.java 优化后的扣减逻辑 public boolean deductStock(Long skuId, Long warehouseId, Integer num) { // 1. 先查Redis缓存 String cacheKey = String.format("stock:%d:%d", skuId, warehouseId); Integer cachedStock = redisTemplate.opsForValue().get(cacheKey); if (cachedStock != null && cachedStock >= num) { // 缓存足够,直接走Redis扣减(用Lua脚本保证原子性) return redisTemplate.execute( REDUCE_STOCK_LUA, Collections.singletonList(cacheKey), num.toString() ) >= 0; } // 2. 缓存未命中或不足,查DB(此时SQL已经有索引,耗时12ms) Stock stock = stockDao.selectBySkuAndWarehouse(skuId, warehouseId); if (stock.getStockNum() >= num) { stockDao.deduct(skuId, warehouseId, num); // 3. 更新缓存 redisTemplate.opsForValue().set(cacheKey, stock.getStockNum() - num, 5, TimeUnit.SECONDS); return true; } return false; } // Redis Lua脚本,保证扣减原子性 private static final String REDUCE_STOCK_LUA = "local stock = redis.call('get', KEYS[1]) " + "if not stock then return -1 end " + "if tonumber(stock) >= tonumber(ARGV[1]) then " + " redis.call('decrby', KEYS[1], ARGV[1]) " + " return 1 " + "else " + " return 0 " + "end";

上线后,我再抓一个trace看:inventory-service/deduct耗时降到15ms,整个订单请求耗时120ms,P99回到正常水平。这次排查从开始到解决只用了40分钟,要是用之前的ELK方案,光关联日志就得花1小时,还得手动去数据库看执行计划。

独家数据:接入APM前后P99延迟对比及冷启动优化策略

我们接入Datadog APM是2024年3月开始的,先拿订单系统做试点,4月全量上线。我整理了接入前后3个月的核心数据,都是生产环境真实跑出来的:

| 指标 | 接入前(ELK方案) | 接入后(Datadog APM) | 变化幅度 |

|---------------------|-------------------|-----------------------|----------|

| 订单接口P99延迟 | 850ms | 120ms | -85.8% |

| 故障平均定位时间 | 47分钟 | 12分钟 | -74.5% |

| 慢查询发现周期 | 平均7天 | 实时告警(<5分钟) | -99% |

| 每月存储/计算成本 | 8200元(ES集群) | 3500元(Datadog按量) | -57.3% |

有个数据特别有意思:我们之前用ELK时,慢查询平均7天才被发现,因为得人工写定时任务扫描日志里的duration > 500ms的记录。现在Datadog的智能告警基于动态阈值,某个接口的延迟比过去7天的P99高20%就自动告警,我们上周有个新上线的优惠券接口,上线10分钟就因为N+1查询被告警,避免了大促时出问题。

再说说冷启动优化。我们去年上线了Serverless的秒杀服务,用AWS Lambda(Node.js 18 runtime),接入Datadog APM后,冷启动时间从1.2秒涨到了1.8秒——APM的Node.js Tracer(dd-trace v4.24.0)在初始化时要加载插件、建立连接,增加了600ms开销。

我查了Datadog 2024年的文档,发现他们针对Lambda做了冷启动优化模式:在serverless.yml里加个配置,让Tracer在Lambda的/tmp目录缓存初始化状态,下次冷启动时复用。我改了Lambda的配置:

# serverless.yml provider: name: aws runtime: nodejs18.x environment: DD_TRACE_ENABLED: true DD_COLD_START_TRACING_ENABLED: false # 关闭冷启动时的全量追踪,只记录关键span DD_LAMBDA_HANDLER: handler.lambdaHandler # Datadog包装原handler functions: seckill: handler: handler.lambdaHandler layers: - arn:aws:lambda:us-east-1:464622532012:layer:dd-trace-js:54 # 2024年5月发布的最新层,支持冷启动优化 package: patterns: - !src/dd-trace/** # 排除本地trace包,用层的

然后在代码里,我只对核心的seckill函数做手动instrumentation,非关键逻辑不创建span:

// handler.js const { tracer } = require('dd-trace'); // 手动包装秒杀核心逻辑,只追踪这个span async function seckillCore(event) { const span = tracer.startSpan('seckill.process'); try { // 1. 校验库存(调用inventory-service) const stockSpan = tracer.startSpan('inventory.check', { childOf: span }); const stockRes = await fetch('https://inventory.internal/check', { method: 'POST', body: JSON.stringify({ skuId: event.skuId }) }); stockSpan.finish(); if (stockRes.ok) { // 2. 创建订单 const orderSpan = tracer.startSpan('order.create', { childOf: span }); await createOrder(event); orderSpan.finish(); return { success: true }; } return { success: false }; } catch (e) { span.setTag('error', true); span.log({ error: e.message }); throw e; } finally { span.finish(); } } // Lambda原handler,被Datadog包装 exports.lambdaHandler = async (event) => { // 非核心逻辑(比如参数校验)不创建span,减少开销 if (!event.skuId) return { error: 'skuId missing' }; return seckillCore(event); };

优化后,Lambda冷启动时间降到了1.3秒,只比接入前多了100ms,而慢请求追踪的覆盖率还是100%。我们测了100次冷启动,平均耗时1.28秒,满足秒杀场景的要求(用户感知阈值一般是2秒)。

还有个细节:Datadog的RUM关联帮我们解决了前端抱怨的"后端慢但不知道慢在哪"的问题。现在前端报错时,RUM会自动关联后端的trace,上个月有个用户反馈下单失败,前端同学直接点RUM里的trace链接,看到是payment-service的第三方支付接口超时,我们立刻联系支付渠道排查,比之前前端提工单、后端查日志的流程快了2小时。

4. 避坑指南:生产环境高并发下的Span丢失与Lambda开销治理

去年双十一大促,我们那个订单核心链路突然告警,QPS 飙到 1.2 万的时候,Datadog 后台看到的 Trace 覆盖率直接从 95% 掉到了 40% 不到。我当时盯着 Service Map 里断掉的调用线,第一反应是 Agent 是不是挂了,结果上去一看,CPU 才用了 30%,内存也正常。后来翻了 Datadog 2024 年 5 月更新的 Agent 文档才搞明白,高并发下默认的 apm_config.max_events_per_second 只有 500,超过这个阈值的 Span 直接就被丢弃了,根本不会上报。

我之前一直以为 Agent 会无脑把所有数据都发上去,其实不是。Datadog Agent 为了不把业务机器的资源吃光,内置了一个限流机制。如果你不手动调,默认每秒只处理 500 个事件(包括 Span 和 Metric)。我们那次大促,一个下单请求平均产生 8 个 Span,1.2 万 QPS 就是 9.6 万个 Span/秒,远超 500 的限制,不丢才怪。

解决起来其实不难,改 Agent 配置就行。我们在 datadog.yaml 里加了这段:

apm_config: enabled: true # 根据机器配置调整,我们用的 8C16G 云主机,设到 5000 比较稳 max_events_per_second: 5000 # 关闭默认的 HTTP 采样,改用全量,因为我们要看具体的慢请求 sampling: rate: 1.0 # 2024年更新后支持 eBPF 辅助采集,降低用户态开销 use_dogstatsd: true

改完重启 Agent,覆盖率立马回到 98%。不过这里有个坑,别一上来就设成 10000。max_events_per_second 设太高,Agent 的 goroutine 会疯狂抢占 CPU,我们当时有一台机器设成了 8000,结果业务服务的 P99 延迟反而涨了 15ms,因为 Agent 在做 Span 序列化和压缩时把 CPU 时间片抢走了。后来我们压测发现,8C 的机器这个值设在 5000-6000 之间最平衡,既能保证数据不丢,又不会让业务服务受损。

另一个让我头疼的是 Lambda 环境。我们那个图片处理服务跑在 AWS Lambda 上,平时冷启动就要 1.2 秒,接了 Datadog APM 之后,冷启动直接飙到 2.8 秒,超时率涨了 30%。我查了社区讨论,发现很多人都在吐槽 Lambda 下的 APM 开销。后来我试了 Datadog 2024 年新出的 Lambda Extension 优化方案,把原来的 Layer 方式换成了 Extension,并且关掉了不必要的 Instrumentation。

具体做法是在 serverless.yml 里调整配置:

functions: image-processor: handler: index.handler # 使用最新的 Datadog Lambda Extension layers: - arn:aws:lambda:us-east-1:464622532012:layer:Datadog-Extension:56 environment: # 只采集超过 500ms 的请求,减少冷启动时的开销 DD_APM_SAMPLE_RATE: "0.1" DD_TRACE_SAMPLE_RATE: "0.1" # 关闭自动 Instrumentation 里的 AWS SDK 追踪,我们只关心业务逻辑 DD_TRACE_DISABLED_PLUGINS: "aws-sdk" # 减少上报频率,合并发送 DD_APM_FLUSH_INTERVAL: "5"

改完之后,冷启动时间降到了 1.5 秒左右,虽然还是比没接 APM 慢一点,但已经能接受了。这里有个细节,DD_APM_FLUSH_INTERVAL 设成 5 秒,意味着 Span 会在内存里攒 5 秒再批量上报,这样能减少 Lambda 的调用次数,但代价是你在 Datadog 后台看到的数据会有 5 秒延迟。对于非实时告警的场景,这个延迟完全没问题,总比因为 APM 开销导致业务超时强。

还有一次,我们发现某个服务的 Span 总是断断续续,排查了半天,最后发现是服务里用了自定义的 HTTP Client,没有手动注入 Trace Context。Datadog 的自动埋点覆盖不了所有场景,特别是你自己封装的网络库。后来我手动加了这段代码,把上下文传下去:

import requests from ddtrace import tracer def call_payment_service(order_id): # 手动获取当前 Span 的上下文 current_span = tracer.current_span() headers = {} if current_span: # 注入 Trace 头,确保下游服务能接上 tracer.inject(current_span.context, headers) # 发起请求时带上头 resp = requests.get( f"https://payment.internal/status/{order_id}", headers=headers ) return resp.json()

如果不加这段,下游支付服务的 Trace 就会和上游断开,你在 Service Map 里看到的就是两个孤立的服务,根本没法排查跨服务调用的问题。

5. 进阶技巧:打通RUM与LLM监控,构建AI驱动的全链路可观测性

上个月我们上线了一个基于 LLM 的智能客服功能,结果用户反馈说有时候回答很慢,有时候又很快。我一开始只看了后端的 APM,发现 LLM 调用的延迟波动很大,从 300ms 到 8 秒都有。但问题是,我不知道用户是在什么场景下触发的,是点了按钮还是发了语音,前端有没有卡顿。后来我把 RUM 和 APM 打通了,才看清了全貌。

Datadog 的 RUM 和 APM 打通其实挺简单的,核心就是那个 trace_id 的传递。我们在前端 SDK 初始化的时候,开启了 trackInteractionstraceSampleRate

import { datadogRum } from '@datadog/browser-rum'; datadogRum.init({ applicationId: 'your-app-id', clientToken: 'your-client-token', site: 'datadoghq.com', service: 'ai-customer-service-web', env: 'production', version: '1.2.0', sessionSampleRate: 100, sessionReplaySampleRate: 20, trackUserInteractions: true, trackResources: true, trackLongTasks: true, // 关键:开启 RUM 到 APM 的追踪 traceSampleRate: 100, allowedTracingOrigins: ["https://api.ourcompany.com"] });

前端发请求的时候,SDK 会自动在 HTTP 头里加上 x-datadog-trace-idx-datadog-parent-id。后端只要用了 Datadog 的自动埋点,就会接上这个 Trace。我在后端查 Trace 的时候,点一下就能跳转到对应的 RUM Session,能看到用户当时点了哪个按钮,页面加载了多久,甚至能看到用户当时的屏幕录像(如果开了 Session Replay)。

有一次排查一个慢请求,我通过 RUM 发现用户是在弱网环境下(网络请求耗时 2 秒)触发的,然后后端 LLM 调用又花了 5 秒,整个链路加起来 7 秒,用户肯定觉得卡。如果不打通 RUM,我只看到后端慢,可能会盲目去优化 LLM 的 Prompt,其实问题出在前端没做加载态优化。

说到 LLM 监控,Datadog 在 2024 年 5 月正式推出了 LLM 监控特性,这个对我们太有用了。以前我们监控 LLM 调用,只能看耗时和错误率,现在能看到 Token 消耗、Prompt 内容、甚至模型返回的质量评分。我们在 Python 后端用了 ddtrace 的 LLM 集成:

import openai from ddtrace import patch, tracer from ddtrace.llmobs import LLMObs # 开启 OpenAI 的自动埋点 patch(openai=True) # 初始化 LLM Observability LLMObs.enable( ml_app="ai-customer-service", agentless=True ) @tracer.wrap(service="llm-service", resource="gpt-4o-chat") def get_ai_response(user_query): # 记录 Prompt 内容,方便后续分析 LLMObs.annotate(input_data={"prompt": user_query}) response = openai.ChatCompletion.create( model="gpt-4o", messages=[{"role": "user", "content": user_query}], temperature=0.7 ) # 记录返回内容和 Token 消耗 LLMObs.annotate(output_data={"completion": response.choices[0].message.content}) # 记录自定义指标:Token 成本 total_tokens = response.usage.total_tokens # gpt-4o 输入 $5/M, 输出 $15/M cost = (response.usage.prompt_tokens / 1000000 * 5) + (response.usage.completion_tokens / 1000000 * 15) tracer.current_span().set_tag("llm.cost_usd", cost) return response.choices[0].message.content

通过这段代码,我在 Datadog 后台不仅能看到每次 LLM 调用的耗时,还能看到这次调用花了多少钱,用了多少 Token。我们后来发现,有些用户发的 Prompt 特别长,动不动就 2000 个 Token,导致成本飙升。于是我们在前端加了个限制,超过 1000 字的输入就截断,一个月省了差不多 2000 多美元的 API 费用。

更有意思的是,Datadog 现在集成了 AI 驱动的根因分析。有一次我们的订单服务突然报错率升高,我还没来得及去查日志,Datadog 的 AI 助手直接弹了个建议,说是因为数据库连接池满了,并且给出了具体的堆栈信息。我点进去一看,确实是我们那个新来的同事改代码的时候,把连接池的最大连接数从 50 改成了 10,导致高并发下连接不够用。这种 AI 辅助排查,比我们以前一个个翻 Span 快多了。

不过我也发现,LLM 监控现在还不是万能的。比如我们用的那个开源模型,Datadog 的自动埋点覆盖不了,我只能手动去打 Span。还有,Token 消耗的统计有时候会不准,特别是用了流式返回(Streaming)的时候,因为响应是分块回来的,总 Token 数要等流结束才能算出来。我现在的做法是,在流结束的时候,手动去更新那个 Span 的 Tag:

def stream_llm_response(query): span = tracer.trace("llm.stream", service="llm-service") span.set_tag("llm.model", "llama-3-70b") full_response = "" token_count = 0 # 模拟流式返回 for chunk in fake_stream_generator(query): full_response += chunk token_count += 1 yield chunk # 流结束后更新 Tag span.set_tag("llm.total_tokens", token_count) span.set_tag("llm.response", full_response[:100] + "...") # 只存前100字,避免 Tag 太大 span.finish()

如果不这么做,你在 Datadog 后台看到的 Token 消耗就是 0,没法做成本分析。这也是为什么 Datadog 一直在推 OpenTelemetry 原生支持,因为 OTel 的标准更灵活,能覆盖更多这种非标准的场景。我们最近也在尝试把部分服务从 Datadog Agent 迁移到 OTel Collector,主要是为了以后换监控平台的时候不用改业务代码,毕竟谁也不想被一个厂商绑死。

站长实战手记

那次让我印象最深的订单接口抖动

去年双11前,我负责的一个电商订单服务突然开始报警,P99延迟从200ms飙到1.2s。那会儿我们刚上线Datadog APM没多久,我第一反应就是打开Service Map,一眼就看到订单服务下游的优惠券核销接口全是红色。

我点进去看Trace,发现一个很怪的现象:同一个接口,有的Span只要30ms,有的却要800ms。我本来以为是数据库慢查询,结果翻了半天,发现是冷启动问题。当时我们用的是Lambda处理优惠券逻辑,流量一上来,初始化时间直接把延迟拉爆。

后来我做了两件事:

  • 把Lambda的预留并发直接拉满,避免反复冷启
  • 在APM里加了自定义Span,专门记录初始化耗时

改完上线,P99直接掉回220ms,那晚我终于没再被电话叫醒。

我的一些真实看法

用了几年Datadog,我自己的体感是:

  • 适合上的场景:业务链路复杂、微服务多、你真的需要全链路追踪和RUM联动的时候,它确实省心。
  • 没必要上的场景:小团队、单体应用、QPS不高的内部系统,你接上去就是纯纯的成本负担,ELK + 简单Metrics完全够用。
  • 我踩过的选型坑:一开始我以为全量接入最香,后来发现高并发下Span丢失严重,最后改成按优先级采样,核心接口全采,边缘接口随机采,成本和完整性才算平衡。

给正在折腾APM的你

别一上来就追求“全链路全覆盖”,先想清楚你最怕出问题的那几个接口是谁。把那几条线盯住了,比什么都强。