[{"data":1,"prerenderedAt":923},["ShallowReactive",2],{"blog-observability-three-pillars":3},{"id":4,"title":5,"body":6,"category":910,"date":911,"description":12,"extension":912,"meta":913,"navigation":914,"path":915,"seo":916,"series":917,"seriesOrder":917,"stem":918,"tags":919,"__hash__":922},"blog\u002Fblog\u002Fobservability-three-pillars.md","可观测性三支柱：日志、指标与链路追踪的分工",{"type":7,"value":8,"toc":904},"minimark",[9,13,16,19,133,136,141,148,159,162,252,255,278,311,329,347,350,453,468,475,481,484,503,510,519,523,526,546,549,564,571,574,583,593,596,600,603,614,725,728,739,746,774,789,792,795,798,804,811,814,820,827,839,859,876,879,885,888,894,900],[10,11,12],"p",{},"笔者在稳定性治理上花时间最多的地方，其实是让问题能被看见。系统没出事的时候，监控大屏只是背景；真出事的那半小时，能不能在十分钟内说出「哪一层慢了、慢在哪个调用」，决定了后面是修故障还是猜故障。",[10,14,15],{},"这几篇东西本来是分三次记的。一份讲日志怎么打，一份是 Prometheus 的随手记，还有一份是 tracing 的笔记，夹在别的草稿里。整理的时候才发现，三份笔记其实都在回同一个问题：出问题的时候，笔者手上有什么证据。日志是最细的证据，指标是最早的证据，链路追踪是唯一能把两者对上号的证据。既然绕不开，不如合起来写一次，顺便把几处当时没想明白的地方重新想一遍。",[10,17,18],{},"先放一张对照表，后面几节都在展开它：",[20,21,22,40],"table",{},[23,24,25],"thead",{},[26,27,28,31,34,37],"tr",{},[29,30],"th",{},[29,32,33],{},"指标",[29,35,36],{},"链路追踪",[29,38,39],{},"日志",[41,42,43,58,72,86,100,114],"tbody",{},[26,44,45,49,52,55],{},[46,47,48],"td",{},"记录的粒度",[46,50,51],{},"固定间隔的聚合值",[46,53,54],{},"一次请求的调用树",[46,56,57],{},"一条条离散事件",[26,59,60,63,66,69],{},[46,61,62],{},"典型的可见延迟",[46,64,65],{},"秒级（受抓取间隔限制）",[46,67,68],{},"分钟级（要看采样和批处理）",[46,70,71],{},"秒级到分钟级（看采集链路）",[26,73,74,77,80,83],{},[46,75,76],{},"单位数据的成本",[46,78,79],{},"最低",[46,81,82],{},"中到高（取决于采样率）",[46,84,85],{},"最高",[26,87,88,91,94,97],{},[46,89,90],{},"最擅长发现",[46,92,93],{},"趋势、毛刺、容量拐点",[46,95,96],{},"跨服务的耗时归属",[46,98,99],{},"单次调用的具体因果",[26,101,102,105,108,111],{},[46,103,104],{},"天生看不见",[46,106,107],{},"个体（哪一个请求）",[46,109,110],{},"未被采样的那批请求",[46,112,113],{},"全局分布（要现算）",[26,115,116,119,122,130],{},[46,117,118],{},"查询方式",[46,120,121],{},"PromQL 聚合",[46,123,124,125,129],{},"按 ",[126,127,128],"code",{},"TraceId"," 查整条链路",[46,131,132],{},"全文检索加字段过滤",[10,134,135],{},"这张表不能替你决定先上哪一层，但能解释一件事：为什么三样东西都得有——每一层的盲区，恰好是另一层的强项。",[137,138,140],"h2",{"id":139},"指标先知道出事了再问谁出事了","指标：先知道「出事了」，再问「谁出事了」",[10,142,143,144,147],{},"指标的强项是便宜和快。一个数字加上时间戳，采样间隔固定，存储成本低到可以全量留着；查询是聚合，几百万个数据点加起来也就几十毫秒。Prometheus（开源监控系统，2016 年加入 CNCF）的数据模型很朴素：指标名加一组标签构成一条时间序列，形如 ",[126,145,146],{},"http_requests_total{service=\"im-gateway\",method=\"POST\",code=\"500\"}","，值永远是浮点数，后面跟一个毫秒时间戳。",[10,149,150,151,154,155,158],{},"它的采集方式是拉取（pull）：服务暴露一个 ",[126,152,153],{},"\u002Fmetrics"," 端点，Prometheus 周期性去抓，而不是服务主动上报。这个选择有个容易被忽略的后果——目标列表本身就是一份存活清单，抓取失败（",[126,156,157],{},"up == 0","）是第一个能拿到的事实；代价是短命任务来不及被抓到，得靠 Pushgateway 之类的中转来补。目标列表从哪来，取决于部署方式：Kubernetes 里用服务发现加 relabel 规则自动生成，物理机或者虚机集群上则常用文件发现，配一份机器清单让 Prometheus 定期重读，加机器时只需要改这个文件，不用重启进程。",[10,160,161],{},"指标值有四种类型，这个分类决定了你能对它算什么：",[20,163,164,180],{},[23,165,166],{},[26,167,168,171,174,177],{},[29,169,170],{},"类型",[29,172,173],{},"语义",[29,175,176],{},"典型用途",[29,178,179],{},"能做的运算",[41,181,182,202,216,233],{},[26,183,184,187,190,193],{},[46,185,186],{},"Counter",[46,188,189],{},"只增不减",[46,191,192],{},"请求总数、错误数",[46,194,195,198,199],{},[126,196,197],{},"rate()"," \u002F ",[126,200,201],{},"increase()",[26,203,204,207,210,213],{},[46,205,206],{},"Gauge",[46,208,209],{},"可增可减",[46,211,212],{},"CPU、内存、队列长度、在线连接数",[46,214,215],{},"直接取值、比较、求平均",[26,217,218,221,224,227],{},[46,219,220],{},"Histogram",[46,222,223],{},"按桶累计计数",[46,225,226],{},"请求耗时、响应体大小",[46,228,229,232],{},[126,230,231],{},"histogram_quantile()"," 估分位数",[26,234,235,238,241,244],{},[46,236,237],{},"Summary",[46,239,240],{},"客户端算好的分位数",[46,242,243],{},"需要精确分位数、且不跨实例聚合",[46,245,246,247,251],{},"直接读，",[248,249,250],"strong",{},"不能","跨实例相加",[10,253,254],{},"笔记里当时把 Counter 拿去统计请求数、把 Gauge 拿去统计 CPU 和内存，这两个用法都是对的，Counter 的语义就是「累计发生过多少次」。真正容易踩的是后两种的取舍：Summary 的分位数是每个实例在本地算完再暴露的，几个实例的 P99 直接求平均没有意义；Histogram 用桶把分布记下来，服务端可以跨实例合并，代价是分位数只是估算，精度取决于桶边界。",[10,256,257,259,260,263,264,266,267,269,270,273,274,277],{},[126,258,197],{}," 和 ",[126,261,262],{},"irate()"," 的差别，笔者也是混过好几次才理清。",[126,265,197],{}," 取区间内首尾两个样本算平均增长率，曲线平滑，适合看趋势和做告警；",[126,268,262],{}," 只取区间内最后两个样本，反应快，适合看瞬时尖刺，但它对采集抖动同样敏感，拿它做告警容易被打点噪声牵着走。还有个老问题：抓取间隔 15 秒，PromQL 区间却写 ",[126,271,272],{},"rate(x[1m])","，一个区间里只有四个点，出现一次抓取失败就会让曲线掉一块——",[248,275,276],{},"区间至少要是抓取间隔的四倍","，这是 Prometheus 文档里反复提醒的事。",[10,279,280,281,284,285,284,288,291,292,284,295,298,299,302,303,306,307,310],{},"这里还藏着指标最容易翻车的一处：标签基数（cardinality）。",[126,282,283],{},"status","、",[126,286,287],{},"method",[126,289,290],{},"service"," 这类取值有限的标签可以放心用；一旦有人顺手把 ",[126,293,294],{},"user_id",[126,296,297],{},"order_id"," 或者完整的请求路径塞进标签，时间序列的数量就是用户数乘以接口数，Prometheus 的内存和查询耗时会一起起飞。**判断标准很简单：这个标签的取值集合是有限的枚举，还是随业务无限增长？**前者能进标签，后者只能进日志或者链路的属性。指标命名也一样，笔记里说的「用下划线分隔」是约定的一半，另一半是名字要自带单位与语义——",[126,300,301],{},"http_request_duration_seconds"," 比 ",[126,304,305],{},"api_latency"," 有用得多，",[126,308,309],{},"_total"," 结尾则明示它是一个 Counter。这里有个最近的变化值得记一笔：Prometheus 3.0（2024 年底发布）放开了指标名和标签名的字符限制，UTF-8 名字要用引号写法，OpenMetrics 那套命名约束不再是硬性门槛；同时它也能直接接收 OTLP 数据，也就是 OpenTelemetry 的 SDK 采集的指标可以不走 Prometheus 客户端库。这两件事的方向是一样的——Prometheus 在向 OpenTelemetry 的生态靠，两边原来各自一套的命名和采集方式迟早要合并。笔者手上的笔记写在 3.0 之前，所以还按老规矩记的，真要动手配的时候得先确认版本。",[10,312,313,314,317,318,321,322,284,325,328],{},"采集这些指标的活儿大多不写在自己的代码里。",[126,315,316],{},"node_exporter"," 负责机器层面的 CPU、内存、磁盘、网卡；",[126,319,320],{},"blackbox_exporter"," 做拨测，从外部看端口能不能连上、HTTP 能不能通、证书还有几天过期——它测的是「用户视角能不能到达」，这和进程内部的指标是两回事；剩下的按中间件各配一个，",[126,323,324],{},"redis_exporter",[126,326,327],{},"mysqld_exporter"," 之类。业务指标才需要自己埋，笔记里那句提醒是对的：不要在业务代码里直接调 Prometheus 的 client，包一层，否则哪天换后端或者要加统一标签，就得全仓改。",[10,330,331,332,335,336,339,340,343,344,346],{},"告警是 ",[126,333,334],{},"Alertmanager"," 在处理，规则用 YAML 描述，",[126,337,338],{},"expr"," 是一段 PromQL，",[126,341,342],{},"for"," 表示条件持续多久才真正触发。",[126,345,342],{}," 这个字段看着不起眼，实际它决定了半夜有多少条消息会响：没有它，一次抖动、一次发布重启、一次抓取超时都会变成告警，连着响几周之后，真的那条就没人看了。抑制（inhibition）和静默（silence）是另外两个救命的功能——前者让「机器下线」压住「这台机器上的所有服务异常」，后者给变更窗口留出安静时间。",[10,348,349],{},"一份最小的告警规则大概长这样，字段不多，但每个都得填对：",[351,352,357],"pre",{"className":353,"code":354,"language":355,"meta":356,"style":356},"language-yaml shiki shiki-themes github-dark","- alert: APIErrorRateHigh\n  expr: |\n    sum(rate(http_requests_total{code=~\"5..\"}[5m])) by (service)\n      \u002F sum(rate(http_requests_total[5m])) by (service) > 0.01\n  for: 10m\n  labels:\n    severity: warning\n  annotations:\n    summary: \"{{ $labels.service }} 5xx 占比超过 1%\"\n","yaml","",[126,358,359,379,391,397,403,414,423,434,442],{"__ignoreMap":356},[360,361,364,368,372,375],"span",{"class":362,"line":363},"line",1,[360,365,367],{"class":366},"s95oV","- ",[360,369,371],{"class":370},"s4JwU","alert",[360,373,374],{"class":366},": ",[360,376,378],{"class":377},"sU2Wk","APIErrorRateHigh\n",[360,380,382,385,387],{"class":362,"line":381},2,[360,383,384],{"class":370},"  expr",[360,386,374],{"class":366},[360,388,390],{"class":389},"snl16","|\n",[360,392,394],{"class":362,"line":393},3,[360,395,396],{"class":377},"    sum(rate(http_requests_total{code=~\"5..\"}[5m])) by (service)\n",[360,398,400],{"class":362,"line":399},4,[360,401,402],{"class":377},"      \u002F sum(rate(http_requests_total[5m])) by (service) > 0.01\n",[360,404,406,409,411],{"class":362,"line":405},5,[360,407,408],{"class":370},"  for",[360,410,374],{"class":366},[360,412,413],{"class":377},"10m\n",[360,415,417,420],{"class":362,"line":416},6,[360,418,419],{"class":370},"  labels",[360,421,422],{"class":366},":\n",[360,424,426,429,431],{"class":362,"line":425},7,[360,427,428],{"class":370},"    severity",[360,430,374],{"class":366},[360,432,433],{"class":377},"warning\n",[360,435,437,440],{"class":362,"line":436},8,[360,438,439],{"class":370},"  annotations",[360,441,422],{"class":366},[360,443,445,448,450],{"class":362,"line":444},9,[360,446,447],{"class":370},"    summary",[360,449,374],{"class":366},[360,451,452],{"class":377},"\"{{ $labels.service }} 5xx 占比超过 1%\"\n",[10,454,455,456,459,460,463,464,467],{},"这里 ",[126,457,458],{},"for: 10m"," 是关键的一行。删掉它，一次发布重启带来的瞬时 5xx 就会把值班的人叫起来；留得太久，真正的故障又要等十分钟才浮出水面。",[126,461,462],{},"labels"," 里那个 ",[126,465,466],{},"severity"," 决定了这条告警走哪条通知渠道——这也是抑制规则能生效的前提，没有标签就没法说「P0 压制 P2」。",[10,469,470,471,474],{},"关于告警规则，笔记里那句话笔者原样同意：不要让告警太频繁，重要告警才配，不然就变成狼来了。它还值得再往下推一层。一条告警要成立，至少得同时满足三件事：",[248,472,473],{},"有明确的触发阈值、有人能对它采取动作、以及它指向的是用户能感受到的东西","。少任何一条，它都会慢慢退化成噪音。拿常见的「CPU 使用率超过 80%」来说，如果这台机器的容量本来就跑在 80% 附近，这条规则每天都会响，而收到的人除了关掉它做不了别的；换成「5xx 占比超过 1% 且持续十分钟」，可动作性就完全不同了——要么回滚，要么扩容，要么去查依赖。",[10,476,477,478,480],{},"判断标准可以更狠一点：如果一条告警响了，值班的人点开看一眼然后关掉，这条规则就该删掉或者改阈值。告警的价值不在于覆盖面，在于每一条响起来都有人愿意当真。至于 ",[126,479,342],{}," 到底该填 5 分钟还是 10 分钟，笔者自己的做法是先设宽一点，等真实故障跑几轮之后再往下压——这比一开始就调得很灵敏、然后被噪音逼着全部静音要好。",[10,482,483],{},"存储这一段笔记里记得也不算错。Prometheus 用自己的时序数据库（TSDB，Time Series Database），最新两个小时的数据先在内存里，之后落到磁盘块上；默认保留期是 15 天，想留得更久就配远程写入（remote write），把这批数据转发到 Thanos、VictoriaMetrics 或者 Grafana 的 Mimir，长期存储和查询交给它们。15 天这件事的实际后果是：一个季度才复发一次的慢查询，等你想起去查的时候，数据已经没了。",[10,485,486,487,490,491,494,495,498,499,502],{},"笔记里说的那个坑，笔者认为比它看起来更值得展开：Histogram 的桶配错了，几乎所有请求都落进 ",[126,488,489],{},"+Inf"," 这个桶，分位数就完全看不出分布。原因是桶边界默认是 ",[126,492,493],{},"0.005"," 到 ",[126,496,497],{},"10"," 秒那一串，如果业务接口的正常耗时本来就在 200 毫秒以上，绝大部分样本会集中在少数几个桶里；反过来，如果接口是微秒级的内网调用，样本又会全挤在最小的那个桶。改法不复杂，把 ",[126,500,501],{},"le"," 的边界按自己的耗时量级重设一遍就行，但前提是你得先知道自己的耗时大概在哪个量级——很多人是画不出分位数曲线之后才回头看的。",[10,504,505,506,509],{},"指标这一层的盲区也就在这里：它是一个聚合结果，天生不带个体。",[126,507,508],{},"http_requests_total{code=\"500\"}"," 涨了，你知道过去五分钟有 37 次失败，但不知道这 37 次分别属于哪个用户、打的是哪个群、失败信息是什么。真去查现场，仍然得回到日志或者链路——这也是为什么三类信号最后一定要能对上号，而不是各自建一套。",[10,511,512,513,518],{},"缓存命中率就是被这层盲区盖住的一个典型：整体数字一直好看，回源却集中在少数几个 key 上，等到曲线上能看出问题时数据库已经被打过了。",[514,515,517],"a",{"href":516},"\u002Fblog\u002Fha-cache-penetration-breakdown","缓存穿透与击穿：回源的两种形态","整篇讲的就是这种「告警是真的，指标也是真的，两边就是对不上」的局面。",[137,520,522],{"id":521},"链路追踪把一次请求拆成看得见的时间","链路追踪：把一次请求拆成看得见的时间",[10,524,525],{},"跨服务调用多了之后，日志和指标都会露出同一个缺口。指标告诉你「下单接口慢了」，日志告诉你「这一次的写库语句报错了」，但「慢在哪一跳」这个信息，两边都没有。微服务里一个请求可能经过十几个服务，中间隔着 RPC、消息队列、缓存和定时任务，没有一个横跨它们的标识，因果关系就断了。",[10,527,528,529,531,532,535,536,539,540,284,542,545],{},"分布式追踪的做法是给一次请求分配一个 ",[126,530,128],{},"，请求每经过一个服务或一段操作，就生成一个 ",[126,533,534],{},"Span"," 记录起止时间、父节点、状态和若干属性。",[126,537,538],{},"SpanContext"," 负责把这些标识随请求传下去，包含 ",[126,541,128],{},[126,543,544],{},"SpanId"," 和采样标记。有了父子关系之后，同一时刻哪一层在等谁，就能画成一条有向的瀑布图，而不是一堆散落的时间戳。",[10,547,548],{},"OpenTelemetry（缩写 OTel，CNCF 项目）现在是这件事的事实标准。它把可观测性拆成三个信号——链路追踪、指标、日志——用一套 API 和 SDK 全部覆盖，这也是前面提的「三份笔记是同一件事」的源头：以前 OpenTracing 管链路、OpenCensus 管指标和链路、各家厂商再各自加一套 Agent，2019 年这两个项目合并成 OpenTelemetry（首版 1.0 在 2021 年发布）。它的架构分四层：API 定义接口，SDK 提供具体实现，Collector 负责接收、批处理、过滤、再导出，Exporter 把数据发给后端。Collector 这一层的价值经常被低估：应用的 SDK 只需要指向本地的 Collector，后端换成 Jaeger、Tempo 还是别的，改的是 Collector 的配置，不用重新发版。",[10,550,551,552,555,556,284,558,560,561,563],{},"标识怎么跨进程传，有独立的标准管着：W3C 的 Trace Context 规定了 HTTP 头 ",[126,553,554],{},"traceparent"," 的格式，把版本、",[126,557,128],{},[126,559,544],{}," 和采样标志编码成一个字符串。这一层是跨厂商、跨语言能对上的基础——Go 服务写进去的 ",[126,562,554],{},"，Java 服务按同一个规范读出来，链路才连得上。如果中间夹了一个自己实现透传的网关，头名少写一个字符，链路就从那里断开了。",[10,565,566,567,570],{},"埋点分成两种，两者不冲突。自动埋点（auto-instrumentation）挂到 HTTP 客户端、gRPC 框架、数据库驱动这些标准库上，代码零侵入就能拿到跨度；手动埋点留给业务语义，比如「创建群组」这一步内部做了什么、耗时多久、失败在哪个校验上——这些标准库不可能替你命名。全自动埋点的问题不是覆盖不够，是所有的 span 都叫 ",[126,568,569],{},"POST \u002Fapi\u002F...","，点开一看全一样，看不出业务上的因果关系。",[10,572,573],{},"采样是绕不过去的：全量记录每个请求的链路，数据量和存储成本会先崩。头部采样（head sampling）在请求开始时按固定比例决定记不记，比如 10%，实现简单、开销低，但决定下得太早，一个后面会报错的请求可能一开始就被丢掉；尾部采样（tail sampling）等请求结束、看到完整状态之后再决定，可以做到「正常的少留、出错的都留」，代价是 Collector 必须先把这条链路上的所有 span 缓存住——延迟高了、内存也高了。",[10,575,576,577,579,580,582],{},"尾部采样还有个实现上的前置条件：同一 ",[126,578,128],{}," 的所有 span 必须落到同一个 Collector 实例上，否则每个实例只看到链路的一小段，谁也没法做完整判断。常见做法是按 ",[126,581,128],{}," 做一致性哈希，再给决策结果加一层缓存。这两处哪一处配错，表现都不是报错，而是「链路缺了几段」——比报错更难发现。",[10,584,585,586,592],{},"这里要回应笔记里标为「错误观点」的那句。当时记的是「OpenTelemetry 只支持 HTTP」，后面自己划掉了。可以确定的是这个说法不对：OTLP 作为 OTel 的传输协议，标准形态是 gRPC 和 HTTP，HTTP 上再分 protobuf 与 JSON 两种编码。笔记里补的「还支持 UDP」也不能套到 OTLP 上：",[514,587,591],{"href":588,"rel":589},"https:\u002F\u002Fopentelemetry.io\u002Fdocs\u002Fspecs\u002Fotlp\u002F",[590],"nofollow","OTLP 规范","定义了 gRPC 和 HTTP 两种传输方式，没有定义 UDP 传输。",[10,594,595],{},"后端选型笔记里列了四个，判断基本可用：Jaeger 是老牌，功能全，官方已在推 OpenTelemetry SDK，老的 Jaeger 客户端处于维护收尾状态；Zipkin 更早，新项目基本不再选它；Grafana Tempo 把数据直接放对象存储，不需要单独维护一套索引集群，成本和 Grafana 的集成是它的卖点；拿 ELK 硬做 tracing 也不是不行，但查询模型并不贴合瀑布图，用起来别扭。笔者现在的倾向是 Tempo 或 Jaeger 二选一，取决于团队已有的存储和可视化栈，而不是取决于哪个 benchmark 快。",[137,597,599],{"id":598},"日志最后还是要看具体那一笔","日志：最后还是要看具体那一笔",[10,601,602],{},"日志的证据粒度最细，也最贵。指标是一次聚合，链路是一条时间轴，日志是这两者底下那些原始事件：谁在什么时候对哪个对象做了什么，返回值是什么。",[10,604,605,606,609,610,613],{},"笔记里那句「线上几百万条日志，查问题像大海捞针」，问题其实不在数量，在于这批日志当时是按「给人看」的格式写的：",[126,607,608],{},"2026-09-14 10:23:41 ERROR 创建群组失败: group already exists","。人眼读得懂，机器读不了——时间戳格式一变、字段顺序一换，正则就失效。结构化的做法是让每条日志本身就是一条 JSON，字段固定：时间、级别、服务名、",[126,611,612],{},"trace_id","、消息、以及这次操作相关的业务键。",[351,615,619],{"className":616,"code":617,"language":618,"meta":356,"style":356},"language-json shiki shiki-themes github-dark","{\"level\":\"error\",\"ts\":\"2026-09-14T10:23:41.512+08:00\",\"service\":\"im-group\",\n \"trace_id\":\"4f1c9a7d2b8e5306\",\"span_id\":\"a91c07f4\",\"user_id\":\"10231\",\n \"msg\":\"create group failed\",\"reason\":\"group already exists\",\"group_id\":\"8891\"}\n","json",[126,620,621,660,692],{"__ignoreMap":356},[360,622,623,626,630,633,636,639,642,644,647,649,652,654,657],{"class":362,"line":363},[360,624,625],{"class":366},"{",[360,627,629],{"class":628},"sDLfK","\"level\"",[360,631,632],{"class":366},":",[360,634,635],{"class":377},"\"error\"",[360,637,638],{"class":366},",",[360,640,641],{"class":628},"\"ts\"",[360,643,632],{"class":366},[360,645,646],{"class":377},"\"2026-09-14T10:23:41.512+08:00\"",[360,648,638],{"class":366},[360,650,651],{"class":628},"\"service\"",[360,653,632],{"class":366},[360,655,656],{"class":377},"\"im-group\"",[360,658,659],{"class":366},",\n",[360,661,662,665,667,670,672,675,677,680,682,685,687,690],{"class":362,"line":381},[360,663,664],{"class":628}," \"trace_id\"",[360,666,632],{"class":366},[360,668,669],{"class":377},"\"4f1c9a7d2b8e5306\"",[360,671,638],{"class":366},[360,673,674],{"class":628},"\"span_id\"",[360,676,632],{"class":366},[360,678,679],{"class":377},"\"a91c07f4\"",[360,681,638],{"class":366},[360,683,684],{"class":628},"\"user_id\"",[360,686,632],{"class":366},[360,688,689],{"class":377},"\"10231\"",[360,691,659],{"class":366},[360,693,694,697,699,702,704,707,709,712,714,717,719,722],{"class":362,"line":393},[360,695,696],{"class":628}," \"msg\"",[360,698,632],{"class":366},[360,700,701],{"class":377},"\"create group failed\"",[360,703,638],{"class":366},[360,705,706],{"class":628},"\"reason\"",[360,708,632],{"class":366},[360,710,711],{"class":377},"\"group already exists\"",[360,713,638],{"class":366},[360,715,716],{"class":628},"\"group_id\"",[360,718,632],{"class":366},[360,720,721],{"class":377},"\"8891\"",[360,723,724],{"class":366},"}\n",[10,726,727],{},"代价也很实在，笔记里那句「开发的时候看 JSON 日志真的很累眼睛」是实话。折中办法是在本地开发时让输出走一个可读的 console encoder，线上再切回 JSON，用 zap（Uber 出的 Go 日志库）或者 zerolog 都支持这种双编码器切换。",[10,729,730,731,734,735,738],{},"笔记里还记了「zap 比标准库快 4～5 倍」，这个数字当时就自己标了「记得可能不太准」——性能倍数取决于打点场景、字段数量和是否结构化，官方 benchmark 的口径和线上真实负载差得远。**可以确定的是 zap 和 zerolog 走的是零分配的设计路径：字段值尽量不强转成 ",[126,732,733],{},"interface{}","，减少逃逸和 GC 压力，比 ",[126,736,737],{},"log"," 标准库快是共识；那个具体倍数没能核实，不当结论用。**真要在生产里选，比倍数更值得看的是结构化字段怎么传、能不能挂全局字段、日志采样怎么配。",[10,740,741,742,745],{},"日志规范里最硬的几条，笔记总结得很干净，值得原样留在这里：日志里不能出现密码、令牌、身份证号这类敏感信息；异常日志必须带堆栈，只写一句「调用失败」等于没写；日志级别不能乱用，把正常业务信息打成 ",[126,743,744],{},"error"," 的后果是半夜的告警响个不停；不要在循环体里打日志，一秒几万条不是夸张，是很容易发生的事。",[10,747,748,749,751,752,755,756,759,760,763,764,767,768,770,771,773],{},"笔记里没有正面回答的是开头那个「日志到底要怎么打」。笔者现在的分法比笔记里细一点，方向是一样的：",[126,750,744],{}," 只留给",[248,753,754],{},"需要人介入","的事，它应该和告警一一对应；",[126,757,758],{},"warn"," 留给「这次没成功但系统自己能兜住」的情况，比如重试一次后成功、降级到缓存返回了旧值；",[126,761,762],{},"info"," 记录状态迁移和关键业务动作，用来复盘「这个群是什么时候建的、什么时候解散的」；",[126,765,766],{},"debug"," 在线上默认关掉，需要时按服务或按请求动态打开——这也是为什么日志框架要支持动态调级别，而不是重新发一次版。判断 ",[126,769,744],{}," 用得对不对有个简单办法：看值班的人会不会为它起床。不会的话，它大概只该是 ",[126,772,758],{},"。",[10,775,776,777,779,780,782,783,785,786,788],{},"这套分法里隐含着一件事：",[126,778,762],{}," 那一层的价值是事后才显现的。",[126,781,744],{}," 日志当场就有用，",[126,784,762],{}," 日志平时看着像废话，直到某次需要回答「这个失败是个例还是所有请求都这样」——只看 ",[126,787,744],{},"，你只知道失败发生了，不知道失败之前这个用户做过什么、走到了哪一步。所以「线上几百万条日志」这件事里，有相当一部分不是浪费，是暂时没用上的证据；要砍的是重复打印和循环里的日志，不是这一类。",[10,790,791],{},"至于「不能打敏感信息」这条，落到实现上有两个低成本的做法：在日志库里注册一个统一的结构化字段做脱敏，或者给日志的 encoder 加一层钩子，在落盘前按 key 把手机号、令牌、身份证号替换成掩码。靠写日志的人自觉很容易漏，靠一层拦截会稳得多。",[10,793,794],{},"采集链路上，笔记记的是 Filebeat 采集、Logstash 处理、Elasticsearch 存储、Kibana 展示这套经典组合。真要挑毛病，问题出在中间那一环：Logstash 是 JVM 写的，一条日志要过一堆 filter 插件，资源占用和启动时间都不轻。常见做法是拿掉它——轻量的场景用 Filebeat 直接写 ES，需要解析和路由的用 Fluentd 或 Vector 顶上，Vector 是 Rust 写的，同样吞吐下 CPU 和内存都更省。保留策略用 ES 的索引生命周期管理（ILM，Index Lifecycle Management）来做：索引按天滚动，热数据 7 天留在 SSD 上，温数据留到 30 天，再往前的归档到对象存储。索引按天滚动这件事不只是省钱——单个索引涨到几百 GB 之后，一次查询要扫的分片数量会让 Kibana 直接等到超时。",[10,796,797],{},"现在来处理笔记里那个「一个疑惑」：到底要不要把日志打到 Elasticsearch 里？笔记里写的是「有人说 ES 不是为日志设计的，ClickHouse 更适合，但 ClickHouse 的生态没有 ES 完善，这个得再调研一下」。",[10,799,800,803],{},[248,801,802],{},"笔者目前的理解是这样，未必对。"," 这句话里的分歧其实来自两个不同的负载。ES 建在 Lucene 上，为倒排索引和全文检索优化，擅长「在几百万条里找出包含某个关键词的那几条」，配上 Kibana 的开箱即用和成熟的告警、权限体系，是日志检索最省事的选择；代价是写入放大的压缩开销和集群运维成本。ClickHouse 是列存，为聚合扫描优化，擅长「按服务和错误码分组，统计每小时的失败率」这类分析查询，压缩比通常也更好，但全文检索和「关键词模糊匹配」不是它的强项，周边生态（采集、可视化、权限）也确实不如 ES 那一套齐。",[10,805,806,807,810],{},"所以这个问题的答案取决于你主要在问哪种问题。日志检索和日志分析是两件事：前者是运维现场翻日志，后者是做容量规划和错误率趋势。",[248,808,809],{},"没能验证的部分","：笔记里提到的 ES 与 ClickHouse 在日志场景下的具体对比基准、以及各自在真实写入量下的压缩比与成本，笔者没有查到可靠的一手数据，这里只能给出负载层面的判断，不给数字。",[137,812,813],{"id":813},"三者怎么串起来",[10,815,816,817,819],{},"前面三节按信号分开了，实战里它们是串行的，而且方向是反的：指标告诉你「有异常」，链路追踪告诉你「哪一段」，日志告诉你「具体哪一笔」。反过来，如果你从一条用户反馈的日志出发，也能顺着 ",[126,818,612],{}," 往上找到那条链路，再对着时间窗口去查对应的指标曲线。",[10,821,822],{},[823,824],"img",{"alt":825,"src":826},"同一场故障在三个视角下的表现：指标发现异常、链路定位服务、日志确认具体调用，以及各自的盲区","\u002Fimages\u002Fblog\u002Fdiagrams\u002Fobservability-three-pillars-01-funnel.svg",[10,828,829,830,832,833,835,836,838],{},"三者能串起来，靠的是一条共享的 ",[126,831,612],{},"。这是整篇里最便宜、收益最大的一件事：请求进网关时生成 ",[126,834,128],{},"，中间件把它放进日志上下文，SDK 把它放进 ",[126,837,538],{},"，日志和链路就在这一列字段上对齐了。对齐之后，从某个错误率的毛刺跳到某一次失败调用，中间不再需要靠时间戳猜。",[10,840,841,842,845,846,848,849,852,853,855,856,858],{},"串不上的情况也很典型，而且原因通常不在工具上。异步任务、消息消费、线程池里换了线程、",[126,843,844],{},"context"," 没往下传，链路就断在那里；",[126,847,612],{}," 只写进了接入层的 access log，业务代码的日志里没有它，对齐就无从谈起。一个很常见的具体形态是：入口处手动 ",[126,850,851],{},"go func()"," 起了个协程去写审计，忘了把带 ",[126,854,538],{}," 的 context 传进去，于是审计日志里的 ",[126,857,612],{}," 是空的——日志本身一条不少，出问题的时候就是接不上。",[10,860,861,862,865,866,869,870,872,873,875],{},"按这个顺序，一次典型的排查会走成这样：告警是 ",[126,863,864],{},"sum(rate(http_requests_total{code=~\"5..\"}[5m])) by (service) \u002F sum(rate(http_requests_total[5m])) by (service) > 0.01"," 持续十分钟触发的，指标先告诉你出问题的是接入层；打开这个服务在该时间窗内的链路列表，按耗时排序，看到瀑布图上一段 ",[126,867,868],{},"group.create"," 的 span 占掉了绝大部分时间，而它下面的数据库调用其实很快——问题不在数据库，在这段业务逻辑自己；拿到那条 span 的 ",[126,871,612],{}," 回日志里过滤，才看到同一时刻的 ",[126,874,744],{}," 日志写着「锁等待超时」。三步各出一半证据，缺任何一步都还得靠猜——只有指标，你只知道接入层的失败率在涨；只有日志，你得先在几百万条里捞到对的那几条。",[10,877,878],{},"顺带说一个容易忽略的方向：链路数据本身也能反过来喂指标。有些团队直接从 span 里按服务、按接口、按状态码聚合出请求量、错误率、耗时分布这组「黄金指标」，好处是口径和应用里埋的那套完全一致，不用维护两份打点；代价是这笔聚合依赖采样率——只有 10% 的请求被记录，聚合出来的绝对值和真实请求量就差了一个量级，只能看趋势，不能当容量数字用。指标和链路的重叠部分怎么分工，是笔者觉得值得单独想清楚的一件事。",[10,880,881,882,884],{},"如果只能按顺序补，笔者会这么排：先把 ",[126,883,612],{}," 的生成和透传打通，再把指标口径和服务分级对齐，然后才做链路的采样策略和后端选型，日志的采集与保留放在最后。理由是前两步的收益立刻能兑现——透传打通当天，日志就能按请求串起来；而采样策略和存储选型一旦定错，返工要动的是整条数据管道。",[10,886,887],{},"写到这里，三份笔记算是合成一份了。至于尾部采样在生产环境到底该怎么配、Collector 需要多大的内存才扛得住，笔者现在只有概念上的理解，没有跑过量——这里再挖一个坑，等真按这个方案搭起来之后再回来补。",[10,889,890,891,893],{},"真到出事的时候，这个顺序还会被迫加速。值班的人手上通常只有几分钟：先看大盘确认影响面，再挑一条出错或超时的链路点开，最后才去翻那一条日志的上下文。这三步里任何一步的数据缺口，都会让排查退化成猜——指标口径不对，就是照着错的曲线在判断；链路断了，就得靠时间戳去凑；日志里没有 ",[126,892,612],{},"，就得在几百万条里靠关键词碰运气。笔者在稳定性治理上花的时间，很大一部分就是花在补这三个缺口上，而不是花在加新的监控面板上。",[10,895,896],{},[514,897,899],{"href":898},"\u002Fblog\u002F","返回博客列表",[901,902,903],"style",{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}",{"title":356,"searchDepth":381,"depth":381,"links":905},[906,907,908,909],{"id":139,"depth":381,"text":140},{"id":521,"depth":381,"text":522},{"id":598,"depth":381,"text":599},{"id":813,"depth":381,"text":813},"可观测性","2026-08-14","md",{},true,"\u002Fblog\u002Fobservability-three-pillars",{"title":5,"description":12},null,"blog\u002Fobservability-three-pillars",[910,39,920,921,36],"Prometheus","OpenTelemetry","YbRGPylK08PSkpvzwJhmdLFPbNVA0tWqvChTcgzn0BM",1789387761500]