可观测性三件套实战:Python日志、指标与追踪落地指南
线上排查这事做久了真能碰到一些让人崩溃的瞬间服务CPU飙到99%但是所有日志都是正常的用户反馈下单失败后台却查不到任何报错一个功能时好时坏重启就好过两天又犯。这些问题的共同点不是难修而是看不见——看不见流量在哪一步丢失看不见某一个环节到底慢在哪看不见错误为什么只影响一小撮用户。我在生产环境踩过太多次这种坑之后才真正理解了可观测性的价值。所谓可观测性本质就三件套日志Logging、指标Metrics、追踪Tracing。这篇文章就是写给Python开发者的实战指南不讲空泛概念直接讲这三样东西分别解决什么问题、怎么在你的服务里落地、以及它们配合起来能达到什么效果。无论你是刚接触微服务的小白还是已经被分布式问题折磨过的老手这篇都能给你一套可以直接抄走的作业。1. 先聊明白可观测性三件套到底是什么互相之间啥关系1.1 日志、指标、追踪各自回答一个“灵魂拷问”日志、指标、追踪这三者经常被放在一起说但它们的定位其实完全不同。我习惯用一个简单的方式来理解它们日志回答“发生了什么”指标回答“现在异常吗”追踪回答“如果是为什么会这样到底卡在哪一环”。日志就是程序运行过程中输出的记录比如一条请求进来了、某个变量值是多少、数据库查询报错了。它最大的优点是有上下文、有细节能看到具体的报错信息和堆栈。缺点也很明显量太大线上环境一天几个GB的日志很正常想从里面快速找到某一条有意义的信息如果没有结构化处理那基本等于大海捞针。指标是为了回答“系统是不是出问题了”而存在的。它是一个数值比如QPS、错误率、响应时间P99、CPU使用率。指标适合做聚合和对比能够告诉你过去一小时和现在相比有什么变化也能够配置告警规则在数值超过阈值时自动通知你。但指标本身不包含细节它只告诉你“不健康”但不会告诉你哪里不健康、为什么。追踪解决的则是分布式环境里最头疼的问题。一个用户请求可能经过网关、认证服务、订单服务、支付服务、数据库好几个环节如果每个服务只记自己的日志出了问题你很难拼出完整的故事。追踪通过一个全局唯一ID把经过各个服务的过程串联起来让你看到这次请求在哪个环节耗时最多在哪里报错。1.2 只靠其中一两个会漏掉哪些看不见的问题我见过很多团队对可观测性的理解是“有日志就行”尤其是早期项目出了问题ssh到服务器上grep日志好像也能排查。但等你把服务一拆流量一大这一套立刻失灵。举个非常实际的例子某次线上告警说订单接口P99耗时从300ms涨到了3秒我打开日志看了半天没有一条错误日志全部都返回200因为业务上它是成功的只是整体变慢了。这时候你只能靠指标去发现“到底是从哪个时间点开始变慢的”再靠追踪去定位“慢在调用链的哪一个环节”是数据库慢查询还是下游服务超时。如果只有指标没有日志你知道了问题发生的时间窗口但不知道具体报错是什么还得去翻日志或加临时日志重现一次。如果只有追踪没有指标你只会对单个请求进行排查但缺少全局视角不知道这个慢请求是偶发还是普遍是某台机器的问题还是整个服务的瓶颈。所以说日志、指标、追踪是互补关系不是一个替代另一个。一个完整的可观测性体系应该让这三种数据同时存在并且能够互相引用。后面我详细讲每种怎么落地以及最后怎么把它们串起来形成一套完整打法。2. 日志实战先把“行为记录”做成能查、能筛、能报警的资产2.1 别再用print了用logging稳一点先给所有Python新手提个醒print只适合在本地调试时临时用不适合线上日志。print调用的是标准输出它不会写入文件进程退出日志就丢了也不带时间戳和级别根本没法按严重程度过滤更没有轮转能力日志文件会无限膨胀。真正的线上日志至少要满足几个要求有时间、有级别、有位置信息、能写文件、能按大小或时间做轮转。这些事情Python标准库logging都能做到。logging最基础的使用方式是这样的import logging logging.basicConfig( levellogging.INFO, format%(asctime)s | %(levelname)s | %(name)s | %(message)s ) logger logging.getLogger(__name__) logger.info(订单创建成功 order_id%s, A12345) logger.error(数据库查询失败 db%s table%s, orders, order_item)asctime是时间levelname是级别name是logger名字message是正文。这里有一个细节我特别强调一下logger的方法不要把变量直接拼到字符串里比如logger.info(订单创建成功 order_id order_id)这种写法会先完成字符串拼接即使这个日志级别被过滤掉拼接操作也执行了白白浪费CPU。正确做法是把变量作为参数传给logger方法由它按参数格式化在日志级别被过滤时根本不会做字符串拼接。不过basicConfig的方式只适合小项目。稍微大一点的应用建议用一个统一的logging配置函数把Formatter、Handler都配置好在所有模块里引用同一个logger。这样不会出现不同模块日志格式不统一、一个模块打文件另一个模块只打到控制台的情况。2.2 结构化日志机器能读懂的日志才有价值很多人写日志是按“给人看”的思路写的比如“订单A12345创建成功”这种格式人看着挺清楚但到了日志平台里就很难办你没法用字段去筛选只能在全文里搜关键词更别说按订单号、按用户ID、按trace_id去精确过滤了。而且一旦日志进了日志检索系统比如Loki或ELK非结构化文本的索引效率低、查询速度慢、存储成本高。我在生产环境里的做法是所有日志默认用JSON格式输出专门给日志一个logger的Formatterimport json import logging import datetime class JsonFormatter(logging.Formatter): def format(self, record): data { time: datetime.datetime.fromtimestamp(record.created).astimezone().isoformat(), level: record.levelname, logger: record.name, message: record.getMessage(), } # 将extra参数里的自定义字段合并进来 for key, value in record.__dict__.items(): if key not in (time, level, logger, message): data[key] value return json.dumps(data, ensure_asciiFalse) handler logging.StreamHandler() handler.setFormatter(JsonFormatter()) logger logging.getLogger(app) logger.addHandler(handler) logger.setLevel(logging.INFO) logger.info(订单创建成功, extra{order_id: A12345, user_id: 888})这样输出到日志平台后order_id和user_id就是独立的字段可以直接做筛选也可以在告警的时候把日志和某个具体订单关联起来。这个习惯尽早养成后面日志量大了再改造会麻烦很多。所谓的“日志设施”Logging Infrastructure在Python生态里其实没有特别复杂的东西核心就是输出格式统一、落盘或上报渠道稳定、保留策略明确。结构化日志是整个体系里最值得先砸时间的一环。2.3 日志级别怎么定不要一刀切“全打成INFO”日志级别这个事坑也很多。很多团队线上日志基本只有INFO和ERROR两种INFO恨不得把每个if分支都打一遍结果日志量大得惊人真正有用的信息反而被淹没。我的建议是至少用好四级DEBUG调试细节默认关闭、INFO关键业务事件比如创建订单、支付回调、WARNING可能有问题但不影响主流程比如重试次数超过阈值、ERROR明确失败需要关注并修复。线上环境日志级别一般设置为INFO或WARNING。INFO一方面可以保留核心业务节点另一方面量级还可以接受。DEBUG在生产环境不要开除非你已经确定需要在某台机器上临时开一会儿并且知道日志量会非常大。这里补一个真实运维中常见的坑日志轮转。如果没配置轮转线上一个Java进程或Python进程跑一个月日志文件动辄十几GB磁盘直接被打满进程写着写着就报No space left on device。用Python的RotatingFileHandler设置按大小切分并保留最近几个文件from logging.handlers import RotatingFileHandler file_handler RotatingFileHandler( app.log, maxBytes100 * 1024 * 1024, # 每个文件100MB backupCount10, # 保留最近10个文件 encodingutf-8 )文件满了之后会自动切分老文件不会无限堆积。如果要按时间切TimedRotatingFileHandler也能实现。我个人建议优先按大小切分因为时间切分在业务突发量大时单文件可能短短几小时就变得巨大不便于上传和分析。2.4 日志采样与异步写入别让日志拖垮业务本身高并发服务里每次打印日志都同步写磁盘会带来可感知的性能损耗尤其是IO密集型的Gunicorn worker或多线程环境。Python中logging默认的FileHandler是同步写入写日志时业务线程会被阻塞在文件IO上。这在请求量上来之后会成一个不小的瓶颈。一个常用的方案是引入queueQueueHandler把日志写入操作丢到一个内存队列由后台线程负责消费并真正写入文件业务线程只负责把日志放进队列性能损耗可以降到非常低。更彻底的方案是直接使用structlog、loguru这类库它们内部对性能和可读性做了大量优化很多公司生产环境直接采用loguru因为配置简单、输出格式漂亮、支持异步与按级别分流。另外一个容易被忽略的点是采样。打日志打得太狠不仅伤性能还会让存储成本上升。比如一个接口每秒被刷了上万次每次请求都打INFO日志量直接爆表。我之前处理过一个服务日志量每天上百GB后来把核心接口的INFO日志改为以1%的概率采样日志量降到了5GB左右关键业务节点的排查能力并没有明显下降。3. 指标实战把“模糊感知”变成“精准预警”3.1 指标是什么其实是一个“可以报警的仪表盘”说实话最早接触可观测性的时候我也觉得指标有点抽象不就是一个数字嘛什么QPS、错误率和日志比感觉没什么信息量。但用过一阵子才明白指标的核心价值不是“看得见单个数字”而是能聚合并对比——你可以在时间维度上对比今天和昨天、这一小时和前一小时更关键的是设定告警阈值让系统主动告诉你“这里不对劲了”。在Python生态里指标领域的“事实标准”是和Prometheus配套的prometheus_client库。Prometheus是一个时序数据库专门存储和查询以时间为轴的数值序列。它在CNCF里地位相当稳固而且生态非常好Grafana面板、告警规则都默认和它集成。核心切分角度是不要“凭感觉看日志找问题”而是依赖“指标异常 → 触发告警 → 再进去排查”这个流程。告警必须在发生的第一时间主动找到你靠人肉盯着Dashboard不现实。3.2 四个基础指标类型到底怎么选prometheus_client库提供四种基础指标类型新手经常分不清觉得不都是计数吗这里我必须把它们的区别掰开揉碎讲清楚。Counter计数器只增不减适合累计值请求总数、错误总数、进入某个分支的次数。需要注意进程重启后Counter会从0开始但这没关系因为Prometheus计算增长速率时用的是差值。Gauge仪表盘可增可减适合瞬时值当前在线人数、内存使用率、队列长度、温度。它反映的是某个瞬间的状态。Histogram直方图用于记录分布的指标比如接口响应耗时。它会统计落在各个桶bucket里的样本数量比如“耗时小于10ms的有多少”、“小于50ms的有多少”。通过桶的分布可以近似算P50、P99等分位数也能看出来响应时间是集中在某个范围还是大面积长尾。Summary摘要也是用于统计分布。区别是Histogram的桶是在客户端固定的服务端可以基于桶数据做聚合计算Summary直接把分位数计算结果存在客户端但多个实例的数据无法在服务端聚合。也就是说如果你需要跨多台机器聚合P99用Histogram更合适。当前主流建议就是能用Histogram就不用Summary因为Summary的聚合能力弱在分布式场景下容易失真。3.3 实战用prometheus_client给FastAPI接口加监控给Python服务加指标监控其实没有那么复杂。下面用FastAPI加prometheus_client做一个最简单的示例from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST from fastapi import FastAPI, Request from fastapi.responses import Response import time app FastAPI() REQUESTS Counter( http_requests_total, Total HTTP requests, [method, path, status] ) LATENCY Histogram( http_request_duration_seconds, HTTP request latency in seconds, [method, path], buckets(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10) ) app.middleware(http) async def metrics_middleware(request: Request, call_next): start time.perf_counter() response await call_next(request) duration time.perf_counter() - start path request.url.path method request.method status response.status_code REQUESTS.labels(methodmethod, pathpath, statusstatus).inc() LATENCY.labels(methodmethod, pathpath).observe(duration) return response app.get(/metrics) def metrics(): return Response(generate_latest(), media_typeCONTENT_TYPE_LATEST) app.get(/hello) def hello(): return {message: hello}这个例子有几个关键点值得展开Counter的labels里带了method、path、status三个维度这样你就能清楚地看到“哪个接口、哪个HTTP方法、哪个状态码”收到的请求量排查时比一个全局总数有用得多。Histogram的buckets是我手动指定的这个选择很有讲究如果桶范围太粗比如(0.1, 1, 10)你就没法区分一个接口的耗时是0.1秒还是0.9秒太细则存储开销大而且没有太大实际意义。一般来说桶的分布要尽量覆盖你的核心SLA范围比如接口要求P99小于500ms那桶在0.1到1秒之间就应该密一点。很多人在这个环节犯的错误是把labels做成高基数字段比如把user_id、request_id之类的放进去。这会让Prometheus的内存和存储直接爆炸因为每出现一个不同的label值都会产生一条新的时间序列。一个服务如果同时在线几十万用户每来一个请求就产生一条新序列Prometheus扛不住。正确做法是标签只保留低基数的、可枚举的维度比如method、path、status、service、instance。这个原则一定要刻在脑子里。3.4 指标埋点从哪“下刀”RED与USE方法论对于互联网后端服务基本上所有指标都围绕两类问题用户感不感受得到问题以及资源够不够用。业界有一套比较成熟的方法论一个是RED一个是USE我觉得比自己去悟要高效得多。RED针对的是“用户请求型服务”Rate每秒请求数、Errors每秒失败请求数、Duration请求耗时分布。这三条基本覆盖了你判断“服务是否健康”的全部维度。Rate反映流量大小Errors反映质量Duration反映性能。每新增一个服务先保证这三个指标存在其余再按业务需求加。USE针对的是“基础设施和资源”Utilization资源利用率比如CPU用了多少、Saturation饱和程度比如线程池排队数量、Errors错误数。它适合检查数据库、Redis、消息队列等资源是否成为瓶颈。在实际埋点时我建议先用RED把服务对外接口的“健康度”覆盖到再逐步往内部组件延伸。比如订单服务先有订单接口的QPS、错误率、耗时然后再看它调用下游支付服务的耗时分布、依赖的数据库连接池的饱和度。这样一层一层铺开排查问题的速度会快非常多。3.5 慢查询日志和业务指标两者不要混为一谈提到指标很多人也会联想到数据库的慢查询日志。注意慢查询日志本质上是日志不是指标。但它其实是一个很好的“指标补充”数据源你可以通过采集慢查询数量把它暴露成一个指标——比如mysql_slow_queries_total。这个做法的价值在于你可以为“慢查询数量”直接设置告警规则比如5分钟内超过20次就触发通知而不是每次都人工去翻慢查询日志。Redis的慢日志同理它的价值和数据库慢查询日志类似都是定位性能瓶颈的关键素材。业务指标是另一个方向比如“今日新增注册用户”、“支付成功金额”、“订单取消率”。这类数据通常需要从业务代码里埋点用Counter或Gauge暴露出来。不要觉得这是运营应该做的事对排查问题同样重要——有时候接口看起来全绿灯错误率也很低但业务指标突然下跌说明逻辑层面出了问题。比如支付成功率从98%跌到80%如果没有业务指标单靠接口层面的RED指标完全看不出来。4. 追踪实战还原一次请求的完整“案发现场”4.1 Trace与Span追得清依赖链路的两个核心概念追踪这个概念在单机时代其实意义不大——一个函数调另一个函数栈一打就知道了。但微服务化之后请求从客户端进入网关网关再调A服务A服务调B和CB又调D链路一长就麻烦了。这时我们需要追踪体系。追踪里最核心的两个概念是Span和Trace。把一次完整的请求看作一条链路Trace链路由很多个Span组成。每个Span代表调用链路里的一个“节点”比如“调用订单服务”是一个Span“订单服务查询MySQL”又是一个Span。每个Span都记录了自己的名称、开始时间、结束时间、父Span的ID。所有Span通过全局唯一的trace_id串成一条Trace。假如说一条Trace是一个人从北京到广州的一次旅程那么每一段交通工具高铁、地铁、出租车就是一个Span。出发点、到达点、花的时间各不相同但通过订单号trace_id都能串起来。我们排查一个请求很慢的问题实际上就是打开这条Trace看每一段各花了多少时间找出花费最大的那一段。4.2 Python生态里把追踪真正用起来追踪在Python生态里的事实标准是OpenTelemetry。它的定位是OpenTracing和OpenCensus的继任者现在已经是CNCF的顶级项目主流语言都有SDK。它的思路是你引入SDK并配置好Exporter通过自动或手动埋点生成Span然后把这些Span数据发送到一个后端比如Jaeger、Tempo、Zipkin进行存储和查询。用OpenTelemetry配合FastAPI代码量其实并不大。先安装依赖pip install opentelemetry-api opentelemetry-sdk opentelemetry-instrumentation-fastapi opentelemetry-exporter-otlp-proto-http然后在启动入口初始化from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from fastapi import FastAPI trace.set_tracer_provider(TracerProvider()) tracer_provider trace.get_tracer_provider() # 通过OTLP发送到Jaeger/Tempo等后端 exporter OTLPSpanExporter(endpointhttp://localhost:4318/v1/traces) span_processor BatchSpanProcessor(exporter) tracer_provider.add_span_processor(span_processor) app FastAPI() FastAPIInstrumentor.instrument_app(app)做完这些每个请求进来都会自动生成一个根Span中间调用的数据库、HTTP请求等如果SDK支持自动埋点也会自动生成子Span。你可以打开Jaeger界面看到一条瀑布图直观地看到每个环节的耗时。这就是追踪比日志直观太多的地方。4.3 上下文传播跨服务串联的关键一步一个容易忽略但极其重要的点是上下文传播。追踪请求经过服务B时怎么知道它属于哪条Trace答案是把trace_id和parent_id放到请求头里传过去目标服务再从中取出并恢复上下文。OpenTelemetry使用W3C的traceparent头格式来传播上下文。好消息是如果你的服务A调用服务B使用的是HTTP库比如requests、httpx且已经开启了对应的自动instrumentation那上下文传播通常是自动完成的不需要手写。但有些场景没法自动完成比如你手动通过Redis发消息给另一个服务、或者调用很老的自研RPC协议。这时你需要手动获取当前上下文并把它编码到消息里from opentelemetry import trace from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator carrier {} TraceContextTextMapPropagator().inject(carrier) # 把carrier放到消息中比如Redis消息的header字段里下游服务再通过extract把上下文恢复出来。这一环节如果不处理好就会出现“每个服务都有自己的trace_id”的局面日志查起来依旧没法串联追踪的意义就大打折扣了。4.4 采样策略怎么在追踪和存储之间找平衡采样这个词在追踪领域里经常被误解。很多人一听采样就觉得“完了那是不是很多请求查不到了”。确实是但同时也要理解如果每个请求都生成大量Span数据存储成本和服务性能都会承压。尤其在高并发场景全量采集的成本非常高而绝大多数请求是正常的出问题的往往只是少数。业界常用的策略有两种一种是基于头部采样Head-based Sampling在请求进入服务时根据条件决定是否采样比如固定概率1%、10%或者针对特定错误状态码全量采样。实现方式也简单在初始化TracerProvider时设置一个Sampler即可from opentelemetry.sdk.trace.sampling import ParentBased from opentelemetry.sdk.trace.sampling import TraceIdRatioBased sampler ParentBased(TraceIdRatioBased(0.1)) # 10%采样 trace.set_tracer_provider(TracerProvider(samplersampler))另一种是尾部采样Tail-based Sampling它是在Span数据已经收集到后端后根据完整链路的特征决定是否存储这样能真正做到“有问题的Trace全部保留正常Trace按比例保留”但实现复杂度也高一些。对于一个中小型团队我建议先从10%概率采样开始关键业务接口如果没有独立花钱搞定存储全量采集很可能会直接把入口打爆。后面如果真的遇到“偶发问题正好没采到”的恼火时刻再逐步提高比例同时对正常请求加大过滤即可。5. 把三个串起来一次线上事故的完整排障闭环5.1 一次真实故障的复盘“为什么我的接口突然慢了”说了这么多用一个具体场景把三件套怎么配合发挥作用的完整链路串起来更直观。假设你负责一个电商系统某天下午接到用户反馈“下单很慢”你立刻打开Grafana面板看指标。首先注意到checkout接口的RED指标QPS没有明显变化但错误率从0.1%飙升到5%P99耗时从300ms涨到2.8s。指标层面可以确认接口确实出了状况。接着向下游拆解。你用追踪系统查这个时段内checkout接口的调用链路发现大量请求的耗时集中在“调用支付服务”这个子Span上其余环节加起来不到100ms。这时候把“支付服务慢了”这个结论锁定。然后去翻支付服务的日志发现同一时间窗口里有大量WARN级别的日志提示redis连接池获取连接超时pool_size20。结合支付服务的Gauge指标Redis连接池的使用率接近100%队列等待长度也持续在高位。到这里根因基本清楚Redis连接池被打满导致支付服务无法正常获取Redis连接请求排队超时反馈给上层就是下单慢。这个排查逻辑走下来整个过程不到十分钟。如果只用日志你会淹没在大量INFO里面压根不知道慢在哪个环节如果只有指标你只知道慢但定位不到支付服务这一层如果只有追踪你定位到了支付服务但还是不知道是Redis连接池的问题。三者配合从“现象”到“定位”再到“根因”就是一步一个脚印的过程。5.2 trace_id是打通三者的“超链接”上一步里“从追踪定位到支付服务再从日志里看到Redis连接池超时”这件事靠的是什么呢就是trace_id。在写日志的时候我把链路上下文里的trace_id和span_id提取出来放进日志的结构化字段里。这样在Jaeger或Tempo里看到一个慢Span直接拿它的trace_id去日志平台一搜这个请求在这一段产生的所有日志全出来了不用在几千万条日志里大海捞针。具体到Python里logging和OpenTelemetry的联动可以这样封装在日志格式化的时候从当前Span里读取trace_idfrom opentelemetry import trace span trace.get_current_span() if span is not None: span_context span.get_span_context() trace_id format(span_context.trace_id, 032x) span_id format(span_context.span_id, 016x) else: trace_id span_id 把这个逻辑放到Formatter里每一条日志自然都带上了trace_id和span_id。这就是可观测性的“关联设计”没有这个超链接三件套还是三个孤岛。还需要注意告警信息里也带上关键标识。告警触发时消息里至少包含服务名、异常的指标名和具体值、时间范围、可能关联的trace_id甚至直接给一个Grafana面板的跳转链接。这样收到告警的人才不会一脸懵。5.3 有了“三件套”之后团队该怎么分工协作工具落地只是第一步更重要的是团队里每个人都愿意用、会用。我在推动可观测性改造时有一条很深的体会工具是砖头流程才是水泥。比如每次线上问题排查完必须更新一份排障手册记录“当XX指标异常时应该看哪些面板、查哪些日志关键字、追踪重点看哪个Span”。这些经验如果不沉淀新人永远只能靠老员工口口相传。在Grafana里把日常用的面板分类整理成“服务总览”“接口质量”“依赖性能”几类并放在统一目录下团队所有人默认打开就知道当前服务健不健康这个投资非常值。告警规则的设定也要讲策略不要“逢错必报”告警疲劳比没有告警更危险。很多团队一开始配置了十几条警报结果每天都响半夜被叫醒一看是小问题后面就没人认真看告警了。我的建议是一开始只配最核心的业务健康指标比如“错误率连续5分钟超过1%”“P99连续5分钟超过SLA”等稳定后再逐步加告警并且每一条告警都要能直接定位问题不能只报“服务异常”这种没说清楚的信息。5.4 从零开始的可观测性建设路线按什么顺序推进如果你刚接手一个没有可观测性体系的服务先从哪里入手我的建议是按“日志 → 指标 → 追踪”的顺序来。先规范化日志把结构化输出和trace_id埋进去这是投入最低、见效最快的哪怕只有一个服务也立刻有收益。然后把核心接口的RED指标和机器层面的USE指标做出来用Grafana做一个简单的仪表盘能一眼看到服务活得好不好。最后再上追踪因为你已经积累了日志和指标追踪的接入就有了明确的目标——为了定位跨服务问题。每一步做完都应该对团队的工作方式有一个实质改变日志结构化之后你可以在日志平台里按字段快速过滤指标有图表之后你可以在出现问题时先看趋势再翻日志追踪上线后你可以直接看调用链而不用一条条猜。这个过程不是配置完就结束了它更像是给团队“装一层感知能力”需要在使用过程中不断打磨和调整面板与告警。6. 写给Python开发者的避坑指南这一章全是在线上被真实毒打过之后总结出来的经验。很多坑我都踩过多花了不少冤枉时间和精力写出来给大家省点弯路。6.1 时间统一用UTC别让日志时间“穿越”日志的asctime默认是本地时间。如果服务器分布在多个时区或者开发时在本地、部署在云上日志时间全是乱的排查时很难对齐事件顺序。我的建议是无论部署在哪应用日志统一用UTC时间前端展示时再转回本地时区。这个规则要写进团队规范里否则总有人会忘记。设置方式很简单在Formatter里用timezonePython 3.7以上保持UTCimport time class UtcFormatter(logging.Formatter): converter time.gmtime # 使用UTC时间指标的时间戳是由Prometheus服务器统一打的所以一般不会有太严重的时区问题。日志则一定要格外小心。6.2 日志处理器的线程安全与阻塞隐患Python的logging.handlers大部分不是线程安全的锁管理器但在多线程环境下同时写日志可能出现日志行被截断或交错。更麻烦的是Gunicorn等多进程模式下如果多个进程写同一个日志文件内容会相互穿插。我的方案是日志处理和业务进程解耦。先进内存队列queue.Queue后台单独开一个线程负责从队列取日志并落盘相当于一个简单的异步日志器。这样每个业务线程只做一次无锁入队操作真正写文件由单一线程串行执行既能避免并发写文件的问题也能显著降低日志IO对请求线程的影响。如果你不想自己造轮子loguru内置的enqueueTrue就是干这个事的。还有一点容易被忽略TimedRotatingFileHandler在某些Python版本下文件轮转时对多个进程的处理并不安全会重复打开文件名导致日志丢失。如果要用多进程且不想太早引入外部组件干脆做“每个进程一个日志文件”用进程ID或进程名做后缀最后在采集端按通配符合并。6.3 标签基数的坑别把Prometheus用成InfluxDB指标标签的高基数问题我在3.3里提过一次但这确实是新手的重灾区我再多重复一段。假设计划统计每个用户的请求数你给Counter加了user_id这个label请求量一上来Prometheus需要记录的时序数量等于用户数乘以接口数乘以状态码数这个数会膨胀得非常快。一旦线上用户量达到百万级别Prometheus直接OOM也不奇怪。正确的思路是标签里只放有限的、可枚举的、对排查问题有真正区分度的维度。如果一定要按用户维度统计建议在应用层聚合好之后以另一种方式输出比如批量计算“Top用户请求数”并写入Gauge而不是把原始级用户维度暴露给Prometheus。还要注意标签名和标签值不要动态拼。你在循环里动态生成标签名也会造成同样的高基数混乱而且比固定标签更隐蔽。6.4 日志、慢查询、访问日志别全混在一个文件里很多新手在本地调试时图方便把所有日志写到一个控制台一个文件里上线后也是一样。结果就是访问日志、应用日志、慢查询日志、系统错误全在一个文件里排查时要把文件下载下来再用grep效率极低。我的建议是每类日志分文件输出至少把访问日志和应用日志分开。访问日志量大、噪声多适合做流量分析和基础质量监控应用日志里的WARNING和ERROR才是排查重点。Python里用多个Handler很容易实现info_handler logging.FileHandler(app_info.log) error_handler logging.FileHandler(app_error.log) info_handler.setLevel(logging.INFO) error_handler.setLevel(logging.WARNING) logger.addHandler(info_handler) logger.addHandler(error_handler) logger.setLevel(logging.INFO)这样INFO及以上的日志进app_info.logWARNING及以上的进app_error.log排错的时候只需要重点看error文件。后面接Loki之类平台时也建议按文件或者标签分不同stream。6.5 高频问题速查表整理一个常见问题速查表基本线上线下问题排查时都能对应上现象可能原因排查方法日志打印了但文件里没有内容Handler级别或Logger级别配置高于输出级别检查logger和handler两级的level设置日志时间与系统时间差8小时没有统一使用UTC在Formatter里设置convertertime.gmtime日志重复打印logger上添加了重复Handler检查是否在循环或多次初始化时重复addHandler指标出现NaN或负值Counter进程重启导致归零用rate()函数计算速率而不是直接展示Counter值Prometheus内存暴涨标签基数过高审查标签维度去掉高基数label同一条Trace跨服务串不起来上下文传播没有配置检查HTTP client是否加了OpenTelemetry instrumentation追踪数据大量丢失采样率过低或Exporter队列满了升高采样率或调整BatchSpanProcessor的队列大小告警信息不明确告警规则只是简单阈值在告警消息里带上跳转链接和当前指标值这些排查思想其实一通百通先确认数据采集到没有再确认链路完整不完整最后才是解释为什么。很多问题不是你代码写错了而是数据“没接进来”或者“接进来但格式不对”。最后再分享一个个人心得在做可观测性建设的时候千万不要陷入“为了上工具而上工具”的怪圈。工具永远是为了让问题更快浮现、更快定位、更快解决。刚开始只需要很小的投入把日志规范、关键接口指标、追踪链路铺起来就已经比大多数项目强太多了。后续随着服务的演进再不断补充面板、优化告警、沉淀排障手册这一套体系自然会长成适合你团队的样子。这就是我理解的“看得见才能修得快”的真正含义。