凌晨两点半手机在枕头旁边疯狂震动。看了一眼屏幕值班群里的消息已经刷到了几十条线上接口超时率飙升用户反馈页面转圈订单提交失败。那一刻脑子里的第一个念头不是完了而是一种很复杂的熟悉感——又来了。把系统拉起来以后我一边翻监控一边想一个问题稳定性治理这个词在很多团队里至少喊了两三年为什么每一次故障来临的时候我们还是像救火队员一样狼狈我过去几年一直在做后端架构和线上稳定性相关的工作经历过大小故障几十次也主导过好几轮从零搭建稳定性体系的完整过程。这篇文章不打算讲那些教科书里都有的理论只想把我踩过的坑、排过的雷、复盘过的每一场故障以及我在稳定性治理过程中摸索出来的一套可落地的打法认真拆开揉碎讲一遍。如果你也是一个每天都在和线上问题打交道的后端开发、SRE或者技术负责人这篇文章应该能帮你少走很多弯路。1. 稳定性治理的整体设计先建骨架再填血肉1.1 稳定性治理到底在治什么很多团队对稳定性治理的理解就是买监控、配告警、上线了一套APM工具然后在某个周一发现线上挂了一群人冲上去救火。救完了写一份复盘报告然后等到下一次故障。这套路子最大的问题在于它把稳定性治理当成了一件事后工程。我自己的理解是稳定性治理本质上是在管理系统性风险。它不是某一个具体的动作而是一套贯穿设计、开发、测试、上线、运维全流程的风险控制体系。核心要回答的无非是三个问题系统在什么条件下会出问题出了问题能不能及时发现出问题之后能不能快速恢复。我们可以把系统想象成一座城市的交通网络。稳定性治理要做的事情不是等发生大堵车以后派交警去疏导而是在城市规划阶段就考虑好路网结构在关键路口设置红绿灯和监控探头在高峰期之前就准备好公共交通的调度预案还要定期搞一些模拟大客流压力的演习。这些工作单独拿出来看都不起眼但合在一起决定了整个城市在极端情况下还能不能正常运转。具体到技术层面我把稳定性治理拆解成了四个层次第一层是基础设施。包括网络、CPU、内存、磁盘、容器、宿主机这一层出了问题影响的是所有业务最底层也最致命。第二层是应用层。包括应用进程本身、JVM如果是Java技术栈的话、线程池、连接池、内存模型、框架代码这一层的问题通常隐藏得最深也最难排查。第三层是数据层。包括数据库、缓存、消息队列、搜索引擎等各类存储和中间件这一层是最容易出现容量瓶颈的地方。第四层是依赖层。包括内部服务之间的调用、第三方外部接口、云厂商的资源依赖这一层的特点是失控感最强你无法控制对方的代码、容量和发布节奏。每一层适用的治理手段完全不一样。基础设施层靠的是标准化和自动扩缩容应用层靠的是代码质量和运行时保护机制数据层靠的是容量规划和慢查询治理依赖层靠的是超时控制、熔断降级和服务隔离。很多团队稳定性做不好就是因为只盯着一层看忽略了其他层次的联动影响。1.2 四层防线构建稳定性治理的骨架我把稳定性治理的落地框架总结为四层防线。这四层防线的顺序是有讲究的它遵循的是事前发现、事中拦截、事后快速恢复、事后改进闭环的逻辑。第一层防线是监控与告警。这一层解决的核心问题是让系统自己告诉我们它要出问题了。很多老系统其实不是没有监控而是监控指标选得太乱、告警配置得太敏感、告警的接收人设得太大而全结果就是问题真正发生的时候大家反而被淹没在告警海洋里没有看到关键的那一条。第二层防线是容量规划与保护机制。这一层解决的是即便有突发流量系统也不能被打垮的问题。具体手段包括压测、容量预估、限流、降级、熔断、隔离。这一层做得好不好往往决定了突发故障是系统变慢还是系统雪崩的巨大差距。第三层防线是应急预案与故障演练。这一层解决的是当故障真的来了我们知道该怎么按按钮。我见过太多团队预案文档写了几十页真正出问题的时候根本没人看因为大家连文档在哪里都找不到。好的预案不是文档是可执行的脚本、流程和对应的责任人。第四层防线是复盘与改进闭环。这一层解决的是如何确保同一个坑不踩两次。复盘不是走形式它需要把根因分析到系统设计层面把改进项真正排期落地并且在下一轮迭代中验证改进效果。这个四层防线就是稳定性治理的骨架。每一层里面都有很多具体的坑和细节接下来我挑几个真正影响成败的环节仔细讲讲落地细节。2. 核心细节解析监控告警与容量规划的落地要点2.1 监控指标不是越多越好选对指标才是关键监控这块我见过最典型的反面教材Grafana面板上密密麻麻几十个仪表盘每个仪表盘上堆了几十个指标CPU使用率、内存使用率、磁盘IO等待时间、TCP重传率、JVM堆内存、老年代GC次数、Full GC时间、线程数、QPS、RT、错误率数一数上百个指标全都有。问题来了——故障发生的时候你根本不知道该看哪个。实际上业界早就总结出了非常成熟的监控指标体系。对应用服务来说我强烈推荐RED方法它只需要三个指标一是Rate请求速率。也就是每秒请求量它反映的是业务的真实流量。这个指标在监控图表上如果出现断崖式下跌或者非预期的暴增那一定是出了大问题。二是Error错误率。包括HTTP 5xx错误、业务返回码中的错误码、抛出未捕获异常的请求数。错误率通常要和Rate结合看比如请求量没变但错误率上升说明出现了局部故障请求量上升且错误率同步上升可能是正常的业务波动请求量下降但错误率上升那大概率是链路中某个环节出现了问题。三是Duration请求耗时。包括P50、P95、P99三个分位值。P99尤其重要因为它代表了最差的那1%用户的体验。很多时候平均值看着很漂亮但实际上P99已经涨了几十倍用户体验早就崩了。对底层资源来说我习惯用USE方法Utilization资源利用率、Saturation资源饱和度比如CPU运行队列长度、数据库连接池等待数、Errors资源错误比如磁盘坏块、网络丢包。这套方法专门用来排查资源层面的瓶颈。还有一个最容易被忽视的视角是SLO服务等级目标。你在监控里看到的每一个指标最终都应该落到一个用户可感知的目标上比如99.9%的请求在500ms内完成、每月可用性不低于99.95%等等。没有SLO做牵引监控指标就是一盘散沙因为你不知道什么情况算好什么情况算坏。2.2 告警规则制定的三个原则告警这个东西设定太敏感每天轰炸群里几百条消息最后大家全部静默设定太迟钝故障发生了半小时还没人收到提醒等于形同虚设。我过去在实践中反复调整最后总结出三个原则。第一个原则是告警分级。我之前带的团队把告警分成P0、P1、P2三级。P0表示核心链路已经不可用比如下单成功率低于90%、支付失败率超过5%需要立即响应通常是电话加短信加群通知一起上。P1表示功能可用但存在明显的服务质量下降比如P99延迟超过阈值、某个接口错误率超过1%需要工作时间及时处理。P2表示系统整体正常但出现波动比如磁盘使用率超过70%、某条非核心链路的慢请求变多可以由值班人员日常关注。第二个原则是告警必须可执行。每一条告警消息里必须告诉接收人你现在应该去哪里看而不是只有一行干巴巴的指标超限提示。我见过很多团队配告警的时候偷懒直接在告警规则里写了个指标名结果告警来了大家都不知道这个指标背后对应的是哪个服务、哪个接口、哪个资源。第三个原则是告警要有持续时间阈值和聚合规则。单次抖动就告警通常会产生大量噪音我习惯给大多数规则设置一个持续窗口比如错误率持续超过3%且持续时间超过5分钟才触发告警这样可以滤掉毛刺。聚合规则是把同一时间窗内多个实例的同类告警合并成一条否则10个实例同时告警会刷屏十几条消息关键信息反而被淹没了。下面是我常用的一个Prometheus告警规则示例可以给大家一个具体参考groups: - name: core_service rules: - alert: HighErrorRate expr: | sum(rate(http_requests_total{code~5..}[5m])) / sum(rate(http_requests_total[5m])) 0.03 for: 5m labels: severity: p1 annotations: summary: 核心服务错误率过高 description: 服务错误率已超过3%持续5分钟当前值: {{ $value }} dashboard: https://grafana.example.com/d/core-service这条规则的逻辑是计算最近5分钟内HTTP 5xx错误的比例如果这个比例连续5分钟都大于3%就触发一条P1级别的告警。这里把指标表达式和持续时间分开先算比率、再叠加时间窗口是为了尽量减少误报。2.3 容量规划从QPS倒推资源附带一个实战案例容量规划是最能体现架构师经验值的环节之一因为真正做起来不像写代码那样有标准答案需要结合压测数据、业务增长预期和冗余策略综合判断。我讲一个我最近做过的一个具体案例。背景是某个新业务要上线预估上线一个月后的核心接口峰值QPS大约是10000。我需要回答的问题是至少准备多少台应用实例才够第一步是明确单机容量。我们没有直接拍脑袋而是拿测试环境做了完整的压测脚本模拟真实业务逻辑逐步加压。压测结果出来在P99延迟低于200ms、CPU使用率不超过60%的前提下单台4核8G的实例大概能扛住1200 QPS。第二步是考虑冗余。生产环境不可能像压测环境那么理想总有一些意外的波动、突发流量、单机故障带来的负载重分配。所以我们一般会预留30%左右的冗余也就是单机按800 QPS的有效容量来规划。这样算下来10000 QPS需要大约12到13台实例。再加上另外两台的独特冗余比如接入层的缩容切换等等我直接申请了15台。第三步是反向验证连接数。实例数量确定以后还要看下游依赖够不够。比如单请求需要消耗一个数据库连接如果数据库的连接池上限是300单个实例的线程池默认配置可能是200那15个实例同时打过来的话连接数上限就是3000数据库肯定扛不住。这时候就需要评估是在数据库前面加一层连接复用组件还是把数据库连接池调大还是调整应用侧的并发模型。容量规划做完后必须配合一件事限流阈值的设定。我常用的做法是把单实例压测得出的稳定上限值乘一个0.7到0.8的系数作为单实例限流阈值。以上面这个案例来说单机稳定上限是1200 QPS那单机限流阈值设在900到950之间。这样即便某一台机器出现异常其他机器的保障能力也不会被拖垮。3. 疑难问题定位的实战过程三次让我印象深刻的故障排查3.1 线程池耗尽一次典型的假死故障有一次线上发生了一个特别诡异的现象某几个实例的CPU使用率并不高Load也不高但是接口的RT响应时间从几十毫秒暴涨到几秒并且超时率持续上升。更奇怪的是业务日志里开始间歇性出现RejectedExecutionException——这个异常意味着有任务被线程池拒绝了也就是线程池的队列已经满了线程数也达到了最大值。排查路径我梳理一下第一步先看监控曲线。QPS没有明显变化CPU正常内存正常但RT和超时率在同一个时间点开始同步抬升。这说明问题很可能出在应用内部而不是依赖资源。当时我们直接跳过了猜测环节马上执行线程dump。第二步连续抓了三次线程dump每三秒抓一次。分析下来的结论让人很意外线程池里的线程全部处于WAITING状态都在等待一个外部接口的响应。第三步才找到真正的根因。那个外部接口在某个时刻开始出现了严重的响应退化正常情况下50ms返回当时竟然等到了6秒才返回。而我们的代码里给这个外部调用设置的超时时间太长足足有10秒并且调用线程池没有做单独的隔离所有请求共用一个线程池。外部接口一变慢线程池里几乎所有的工作线程都被堵在了这个外部调用上新的业务请求根本拿不到线程资源。这个问题的修复方案非常有代表性一是线程池隔离。把核心业务链路和外部依赖调用拆成两个不同的线程池用独立的线程池处理慢调用场景这样即便外部的接口崩了也只会拖垮一块任务拖不垮主链路。二是配合超时控制。把外部接口的超时时间从10秒改成1秒同时引入快速失败机制在连续失败超过一定次数后直接熔断后面一段时间不再调用这个接口而是走兜底逻辑。三是把线程池的队列改成有界队列并配置合理的拒绝策略。这个案例让我深刻意识到线程池的参数绝对不是随便配个最大线程数就完事核心线程数、最大线程数、队列类型、队列长度、拒绝处理策略这几个参数要联动考虑任何一个单独拉出来都可能踩坑。3.2 内存泄漏一次被忽视的缓存另一个让我印象很深的问题是在某个Java服务上。故障表现是GC越来越频繁Full GC后内存占用很快又回升服务JVM内存持续增长最后直接OOM然后容器重启一重启内存又开始新一轮的快速增长形成了一个恶性循环。这类问题最典型的特征是缓慢恶化型故障监控指标上能看到一条缓慢爬升的曲线但整条链路还没有立即挂掉所以往往是在已经严重影响可用性之后才被告警通知到。排查步骤我一直沿用一个固定的流程第一步用jstat查看JVM的GC情况确认是不是有频繁的Full GC以及堆内存增长趋势。第二步用jmap dump出堆内存快照然后用MATMemory Analyzer Tool打开分析。重点看Dominator Tree支配树找到那些占据了大量堆内存的对象。第三步顺着大对象往回找引用链看谁把它持有住了。当时分析完堆快照发现一个ConcurrentHashMap里堆积了上百万个对象每个对象对应一个带时间戳的字符串。代码一查发现是某个同事在静态字段里用Map做的本地缓存只往里放没有做任何容量控制也从来没有清理过过期的数据。Key是每小时的时间戳字符串Value是当时从下游查出来的一批数据。时间一长这个Map就变成了一个无上限的垃圾桶。这个问题的修复其实很简单把那个静态Map换成了Caffeine一个本地缓存组件配上了过期策略和最大容量限制。但我后来复盘的时候意识到这类问题的本质不是用Map做缓存有罪而是团队缺少一个统一的本地缓存规范和组件沉淀导致人人都在重复造轮子而且造的轮子质量参差不齐。3.3 分布式链路里的幽灵报错有一次排查一个极其隐蔽的问题现象是某个核心接口的报错率每周都会莫名出现几次小尖峰但每次打开监控看单实例的详细日志时又找不到明确的错误堆栈请求日志显示一切正常。这个故障排查了整整一个星期最后定位到的问题让我对分布式排障的认知又上了一个台阶。事情是这样的这个接口是跨了上游、中游、下游三个系统的调用链。我们按标准做法接上了全链路追踪系统在日志里打印了TraceId和SpanId。奇怪的是下游系统确实成功处理了请求但返回结果在上游看来永远是超时。单看上游日志一切正常单看下游日志也一切正常两边对不上。后来在反复比对日志的时候发现一个细节部分跨系统调用的TraceId长度和我们系统内生成的TraceId长度不一致。继续查下去发现是某一次发版中一个前置网关在处理请求头时为了防止请求头超长把TraceId做了截断处理。截断之后链路追踪系统无法根据TraceId把日志串起来所以链路追踪面板上呈现的是一段断裂的链路。而这四分之一概率下TraceId被截断的请求在下游处理完返回时由于序列化器在某些字段上的兼容性问题出现了响应体被错误解析的情况最终表现为调用成功但没有拿到正确结果。这个问题的解决一方面是修正了网关对请求头的截断逻辑另一方面是替换了序列化器并增加了兼容性测试。但我更想分享的排查经验是当问题表现出偶发性、跨系统、两边日志都正常但结果不对这几个特征时一定要优先考虑链路中间的翻译环节——网关的请求头处理、代理层的数据包转发、序列化协议转换、消息队列的字段映射——这些环节最容易出现看起来正常但实际上偷偷改变了数据的问题。4. 故障复盘的正确姿势从追责会到改进闭环4.1 复盘的两个前提时间线还原与五问法复盘这件事最大的痛点是容易搞成追责会。一旦牵扯到谁的错所有人就都开始防御技术问题变成了人事问题最后复盘报告写的每一句话都在粉饰太平真正的问题永远浮不出水面。我后来在团队里定了一条铁律复盘只聊系统的设计缺陷不聊人的疏忽。人的疏忽背后一定有系统设计、流程设计或者工具支持的不足。比如一个值班人员漏看了一条告警那我们要问的不是为什么你漏看了而是为什么那么多条告警里只有这一条是关键的而系统没有把这条关键告警突出出来。问题的属性一旦从人的问题转为系统设计的问题讨论立即变得安全且高效。复盘的第一步是还原完整的时间线。故障不是凭空出现的一定有个慢慢积累或者突然触发的过程。时间线要从故障首次出现苗头之前就开始记录包括每一次发布、每一个配置变更、每一条告警触发、每一个人员的操作行为。我常用的办法是把所有相关系统的事件日志、告警记录和IM沟通记录汇总到一张时间表里然后按分钟级别对齐。第二步是连续追问为什么直到问出一个可以落实到设计层面的根因。这就是经典的五问法。举个例子磁盘突然满了导致服务无法写入日志进而引发服务崩溃。如果复盘只写到磁盘满了就停了这个复盘毫无价值。要接着问为什么磁盘满了日志量太大。为什么日志量这么大因为某个接口的错误日志重复记录。为什么这个接口的错误日志会大量重复因为这个接口在下游超时后代码里有误的循环重试逻辑。为什么会有这个循环重试逻辑因为当初做这个功能时没有对重试次数做上限校验。到了这一步设计层面的根因才浮现出来重试机制缺少统一的封装和参数约束。4.2 复盘报告的完整模板一份合格的复盘报告在我来看至少包含六个部分。我列一个我一直在用的模板框架一、故障概述。用几句话把故障的影响说清楚包括故障等级、影响范围哪些业务、哪些接口、影响时长、用户可见的损失和内部损耗。二、时间线还原。按照时间线列出从首次异常苗头到完全恢复的所有关键事件每条事件带时间戳和证据来源。三、根因分析。用五问法推导出来区分出技术层面的直接原因和管理体系层面的间接原因。四、故障止损过程。用了哪些手段恢复比如重启、降级、扩容、回滚每一步操作分别用了多长时间操作过程中遇到了什么问题。五、改进项清单。这个部分是最重要的必须用表格列出具体的整改措施、优先级、责任人、完成时间。表格大概是这样的序号改进项优先级负责人预计完成时间当前状态1修复重试无上限的问题P0唐工12月10日已完成2移除冗余告警规则P1李工12月20日进行中3增加线程池隔离组件P1张工1月5日待排期六、本次复盘的结论和后续动作。总结这次故障暴露出体系层面的哪些薄弱点后续如何在更大范围内推动改进。4.3 整改落地的三个坑复盘报告写得好不代表整改就能落地。我踩过的坑大概是这么三类每一个都疼过。坑一是复盘完就结束改进项没人跟。这个问题最普遍。我的解决方案是把改进项直接挂到项目管理工具里设置明确的负责人和截止时间并且每周例会上单独过一遍改进项的进展。没有这个强制机制改进项活不过两周就会被人忘掉。坑二是改进项太多没有优先级。一次大故障能牵出几十个改进项如果全部排到P0只会让团队疲于奔命。我的做法是优先级判断标准很简单这个改进不做会不会在未来三个月内再次引发同类故障。会就是P0可能但概率低就是P1影响不大就是P2。P2可以慢慢做P0必须在两周内做完。坑三是只改代码不做预案。很多团队复盘的改进项全是代码层面的修复但其实比代码修复更重要的是操作手册和应急预案的更新。代码修复是防止同类故障再次发生而应急预案更新是在故障万一还是发生了的时候能更快地恢复服务。这两种能力都需要维护缺一个都不行。5. 稳定性治理的工具选型与常用排查手段5.1 我常用的稳定性工具链工具选型没有绝对的好坏关键看是否匹配你自己的技术栈和团队规模。我这里分享一套我目前用得最顺手、也是性价比最高的组合大家可以作为参考。监控告警这一块Prometheus加Grafana属于标配采集指标用Prometheus可视化面板用Grafana配合Alertmanager做告警路由和收敛开源而且社区方案成熟。这套东西的难点不在部署在指标口径的统一定义和维护一定要有人专门负责指标规范的管理。日志层面简单场景直接就是ELKElasticsearch、Logstash、Kibana或者更加轻量的Loki配合Grafana关键是要有统一的TraceId串联能力。现在很多团队的实际情况是日志分散在几十台机器上排查问题的时候需要一台一台去翻效率极低。日志集中采集和清洗一定要在早期就做好。链路追踪这块SkyWalking和Zipkin都是很成熟的选择也可以直接用云厂商的APM产品。我的习惯是优先部署链路追踪再考虑要不要引入更重的APM因为链路追踪解决的是故障定位路径的问题APM解决的是自动发现异常的问题前者的优先级更高。故障演练和混沌工程ChaosBlade是一个不错的开源工具。很多团队一听混沌工程就觉得很高端实际上最简单的游戏场景你只需要在预发环境定期杀一台机器看能不能自动恢复慢慢扩展出断网演练、数据库主从切换演练和流量突增演练就已经很有价值了。5.2 高频故障排查手段速查表我把过去几年排查问题过程中总结出来的经验做成了下面这个速查表。这个表特别适合值班同学放在手边遇到问题先照表选取排查方向效率会高很多。现象特征初步判断方向推荐排查手段CPU使用率持续100%代码死循环、GC频繁、计算密集任务top确认进程jstack抓线程栈看是业务线程还是GC线程内存持续增长后OOM内存泄漏、缓存无上限、大对象堆积jmap dump堆、MAT分析、重点看静态集合类RT突然飙高但CPU正常锁竞争、线程池阻塞、网络等待、外部慢调用连续线程dump、查看BLOCKED/WAITING线程、检查外部依赖链路错误率上升但实例日志正常链路中间环节异常、协议解析问题查看网关日志、链路追踪面板、对比上下游TraceId偶发超时且无明确规律GC停顿、网络抖动、慢SQL、连接池不足查看GC日志是否出现STW长停顿、数据库慢查询日志、连接池监控请求在某一台机器上特别慢负载不均、单机硬件问题、缓存不一致对比多实例监控指标、检查宿主机负载、检查本地缓存命中率5.3 几条掏心窝的经验总结最后分享几条我做稳定性治理这几年最深切的体会。第一稳定性是设计出来的不是测试出来。如果一条链路在架构设计阶段就没有考虑超时、熔断、降级、隔离这些保护机制那后面任何测试和运维手段都是在打补丁。所以稳定性治理最好从项目立项第一天就介入越往后拖成本越高。第二故障不可怕可怕的是定位太慢。故障一定会发生这是分布式系统的基本宿命。真正拉开团队差距的是从出现问题到定位到根因的时间跨度。这个时间跨度的长短完全取决于监控指标是否清晰、日志是否完整、链路追踪是否覆盖、人员是否熟悉排查工具。这些都需要在日常就沉淀好。第三告警是给人看的不是给系统刷存在感的。每一条告警都应该经过严格的评审确认它值得打扰一个正在睡觉的人。告警噪音多了真正关键的通知也会被习惯性忽略。第四稳定性治理是一个持续迭代的过程不要追求一步到位。很多团队一开始就想搞一个宏大的稳定性平台最后往往死在半路上。我更推荐小步快跑的方式先解决监控告警再解决容量保护再推动故障演练再完善复盘闭环。每一步都能让系统变结实一点等四个层级都撑起来了你会发现即使故障还是发生了但你已经可以从容应对了。这几次实战过手的东西写出来也就几千字但每一个坑都是真金白银换来的教训。如果你也在做稳定性治理相关工作希望这篇文章能给你一些参考。如果有其他问题欢迎交流讨论我也还在持续踩坑和总结的路上。