ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

高并发系统稳定性治理:从十次重大告警看架构决策

高并发系统稳定性治理:从十次重大告警看架构决策 凌晨三点十七分手机在床头柜上震了第三遍。屏幕上是一条来自告警平台的推送下单核心链路错误率连续五分钟超过阈值P99延迟已经逼近四秒。我爬起来打开笔记本第一件事不是看代码而是确认这一轮流量是从哪个区域进来的——欧洲晚高峰、北美东岸还是某个刚刚开始大促的独立站站点。这种场景在四年里重复了不知道多少回。四个年度周期里我所在的技术团队处理了十次真正意义上的大型告警每一次都对应着一轮技术决策。有些决策当场见效有些要等几个月之后才能验证对错还有些到现在回想起来依然是“当初不该那么选”。这篇文章不是按时间线写编年史而是把十次告警背后的治理逻辑拆开聊一聊真正影响结果的那十几个决策是怎么做出来的。如果你正在维护一个流量不低的在线交易系统或者刚接手一个偶尔出现诡异故障的服务这篇文章里的内容值得你拿杯咖啡慢慢看。高并发治理不是堆机器、调参数它是流量模型、容量预估、限流降级、缓存策略、数据一致性、告警设计这些东西共同组成的一套生存法则。1. 四年十次告警先看全貌再谈治理1.1 十次告警的完整清单为了不被具体细节淹没我先把四年里十次比较关键的告警列成一张表。这些事件都经过脱敏处理时间线上的顺序和真实情况一致但具体的服务名、量级、耗时做了归整。时间线触发场景根因分类持续时长主要影响21年3月会员积分服务GC长停顿代码/内存8分钟订单确认页卡顿21年11月黑五预热脚本误判容量预估45分钟部分商品库存展示异常22年2月支付回调重复消费数据一致性2小时订单状态错乱22年6月数据库连接池被打满SQL/DB25分钟下单超时激增22年12月圣诞大促流量预估偏低容量预估36分钟搜索服务降级23年4月缓存穿透引发DB慢查询缓存设计50分钟商品详情页超时23年9月告警风暴掩盖真实故障告警治理70分钟定位延迟24年2月配置中心变更误操作变更管理12分钟限流策略失效24年6月新机房网络链路劣化基础设施28分钟写路径超时25年1月多区域库存一致性窗口数据一致性35分钟预扣库存回补延迟从这张表能看出两件事。第一真正的“瞬时流量爆炸”只占一半另外一半是代码、配置、基础设施层面的问题。第二影响时长和根因严重程度并不完全相关有几次看起来根因很简单但正因为定位过程绕了弯路反而拖了很久。1.2 十次告警里藏着的高频根因把十次告警的根因做一个简单归纳会发现几条反复出现的规律。容量预估相关的三次问题都出在“流量模型假设错误”上。比如21年11月黑五预热我们的预估模型是按照上一年的总量乘一个增长系数结果忽略了当季广告投放带来的新客比例大幅上升导致预热脚本按旧模型分批跑热卖商品提前被预扣。22年12月那次更典型预估时只算了核心下单链路忽略了搜索和推荐服务在大促流量下也会同步升高。数据一致性相关的两次本质都是“异步链路缺少兜底机制”。支付回调重复消费那次是消费端没有做好幂等同一条回调被不同worker各处理了一次订单状态就被覆盖成错误值。25年1月库存一致性窗口的问题也类似——预扣和回补两个动作分布在不同的服务里中间没有任何对账任务窗口期一拉长数据就对不上。DB相关的告警占了两席一次是慢SQL导致连接池耗尽一次是缓存穿透打垮数据库。这两次其实互为镜像一个是缓存没挡住一个是索引没走对。它们都能用一句话概括数据库这一层永远是最容易出事的那个单点任何到达DB的流量都必须被严格怀疑。剩下的告警风暴和配置变更失误属于“人”的问题但也恰恰说明治理体系不能只盯机器。后面我会单独用一节讲告警治理那是整个体系里最容易低估的一部分。2. 第一次P0级故障库存服务崩溃从一条慢SQL说起2.1 故障现场先看到的是连接池告警22年6月那次告警平台在凌晨连续推送了三条消息。最早的一条是数据库连接池活跃连接数超过85%紧接着是下单服务的P95耗时从120ms跳到2.8s然后是错误率曲线开始抬头。我当时的反应顺序是这样的先重新打开Grafana看订单相关接口的响应时间分布再去查数据库的慢查询日志最后才看应用层有没有发布变更。为什么要这个顺序因为连接池被打满最常见原因就是慢SQL撑住了连接不释放。如果这时候先去查代码最近改了什么反而会漏掉第一手证据。慢查询日志确实很快就给出了答案。订单列表接口里有一条SQL没走索引执行计划显示全表扫描。这条SQL本身写得不复杂就是按用户ID加状态筛选订单再按创建时间倒序分页看起来完全正常。但生产环境订单表的数据量已经过亿优化器根据统计信息估算出来的行数偏少选错了索引结果一个等值查询变成了全表扫描。2.2 为什么这条慢SQL能在生产环境存活这么久这条SQL在预发环境测过也压过执行时间都在100ms以内。问题出在数据规模预发库只有生产的1/80优化器在数据量小的时候怎么选索引都无所谓一旦数据量跨过某个量级执行计划就会完全不同。这是所有做高并发系统迟早会撞上的一类坑测试环境的数据特征和真实流量脱节导致SQL性能问题被推迟到上线后才爆发。从我们后来的实践看解决方式不是让每个开发都在本地搭一套和生产等量的数据那样成本太高而是要在SQL上线前强制走一条分析流程查看执行计划、确认预估行数和实际行数的偏差、对超过一定量级的数据表建立索引更新策略。那一次我们紧急给该查询加了组合索引配合应用侧小版本发布25分钟内恢复了。但复盘的时候大家都很清楚加索引只是把当前这颗雷拆掉了真正的问题不在SQL本身而在架构里“订单列表查询”这种读多写少、数据量大的场景不应该直接打到主库。2.3 应急止损的三个动作顺序比动作本身更重要复盘会上有同学问当时为什么不第一时间就加索引反而先让连接池快速失败。这个问题问得很好它背后是一个很容易被忽略的决策原则故障现场的第一优先级是止损不是修复。我当时做的三个动作按顺序是把数据库连接池等待超时从3秒改成300毫秒让无法及时获取连接的请求快速失败在网关层打开下单接口的降级开关摘掉非核心的积分和优惠券计算只保留最基础的库存预扣最后才去执行索引变更。为什么先改超时时间因为连接池已经被慢SQL占满了如果新请求继续排队等待应用线程池也会被耗尽故障会从DB层蔓延到整个应用层。让请求快速失败看起来会让错误率短暂上升但可以保住应用实例不挂后续恢复反而更快。这个顺序后来被写进了我们的故障处置手册先快速失败再降级保核心最后才做根因修复。很多人遇到故障第一反应是去查代码我不能说这个思路完全错但如果没有先止损查代码的时间窗口可能已经被拖垮的系统彻底关闭了。2.4 从慢SQL到架构决策读写分离开启这次故障的最终产物不是一条索引而是一个为期两个月的重构项目订单查询从直连主库改成读写分离读流量走只读从库同时把近三个月的订单放一份到Elasticsearch里列表页的复杂查询走ES订单详情页走Redis缓存只有库存预扣和状态变更继续打主库。很多人听到“读写分离”会觉得这是很基础的架构。但在一个高速迭代的业务里能不能下决心抽出两周专门干这件事其实取决于团队对“数据库连接池是所有请求共享的木板”这个认知有多深。我见过很多团队加索引、加连接池大小、优化SQL折腾了一圈还是周期性出问题就是因为没有从根上把“昂贵且共享”的DB访问路径隔离出去。那次之后我们也给SQL上线流程加了硬性要求任何一条要上生产的查询语句都必须附上explain执行计划截图由值班负责人把关。这个动作表面上是流程实际是在逼每个开发养成“考虑数据量和索引选择性”的习惯。3. 海外电商的高并发为什么不能照搬国内那套打法3.1 流量模型完全不同多时区、多节日的叠加标题里写了“海外电商平台”这四个字里的水很深。同一套系统要服务多个国家和地区这意味着流量不是一条平滑的曲线而是多个地区高峰叠加在一起的锯齿波。国内做电商大促流量峰值通常集中在几波秒杀节点时间模型很好预测。海外则是另一番光景黑五和网络星期一压着北美和欧洲的购物季南半球的大促在年中东南亚有9.9和10.10拉美有Hot Sale欧洲又有各自国家的圣诞采购周期。这些节日不完全重叠但也谈不上错峰因为欧洲晚上流量起来的时候美国西海岸的白天下单量也在跑。这个差异直接影响了容量预估的方式。我们不能用一个“全年峰值”来做全局容量规划因为每个区域的峰值在不同时刻到来某个时刻欧洲是高峰、亚洲是低谷服务需要同时处理不同区域的混合流量。所以我们的容量模型最终改成了“区域分时段预测全局资源池共享”每个区域按自己的大促时间表提前做全链路压测但底层的数据库和消息队列资源是打通的增加了一层调度避免某个区域大促时把共享资源池全部占光。3.2 数据一致性窗口比国内长得多海外业务带来的另一个复杂点是链路长。国内电商的支付回调通常几秒钟回来跨境支付则可能涉及收单行、卡组织、汇款通道延迟从几秒到几十分钟都有可能。支付结果晚回来订单状态机的推进就慢用户已经付了钱但订单还停留在待支付状态客服就要开始接单。支付回调的延迟拉长之后重复通知的概率也变高了。22年2月那次订单状态错乱根因就是我们在消费回调消息时没有做幂等——同一条支付结果被推送了三次三个消费端实例各处理一次状态被覆盖成了旧值。那次之后我们给所有异步消费统一加了基于业务唯一键的去重表同时把订单状态机从“允许任意状态跳转”改成了“只允许沿着定义好的边转移”。后者的价值不只是在回调场景而是从根本上杜绝了未来其他状态覆盖类故障。3.3 数据存储约束不是所有数据都能放到一个大库里海外业务还面临一个很实际的约束个人数据存储地要求。不同国家和地区的法规要求用户数据存储在本地/特定区域这意味着我们不能像很多国内系统那样把全量用户数据放在一个大库里然后无限加从库。订单、用户、库存这些核心数据天然要按照区域分片。这给“全局视角”类查询带来了很大麻烦比如运营想看多个区域的销量汇总或者客服搜一个用户的跨区订单拆库之后都变得很费劲。我们当时的决策是做“区域分片全局汇总层”核心交易数据按区域分片保证写路径的本地性另建一套异步的汇总数据管道只同步非敏感的聚合数据到分析侧供运营和客服查询使用。这个方案的代价是“全局查询存在分钟级延迟”但对交易系统来说这个代价完全可以接受——反正你不大可能在主交易链路上做跨区域实时聚合查询。这三个点叠加在一起就决定了海外电商的高并发治理不能照搬国内教程。你可以照搬缓存、限流、熔断这些通用手段但流量建模、一致性设计、数据分片这些决策必须结合自己业务的地域结构单独做。4. 几个影响全局的技术决策逐个说清当时的取舍4.1 限流选型自研还是基于开源组件二次开发限流是每个高并发系统都绕不开的组件。当时摆在我们面前的有三条路完全自研、直接用开源的Sentinel、以及基于Sentinel做二次开发。先评估了完全自研。限流本身的核心算法不难滑动窗口、令牌桶都可实现难的是后面这些东西规则动态下发、多维度的统计、与网关和微服务框架的集成、控制台的治理体验。如果从零做一套完整的流量治理平台工期至少两个季度这成本对当时的团队来说不现实。如果直接用社区版Sentinel核心功能基本都能覆盖包括QPS限流、并发线程数限流、熔断降级还有Dashboard可以看实时的调用链数据。但我们的场景有些额外的要求一是希望把限流阈值和容量预估系统联动起来压测数据要能自动生成建议阈值二是告警触发时要能自动在群里带上流量曲线而不是让值班同学去点好几个页面。所以我们最终选择了以开源Sentinel为基础保留它的核心能力自己写了两层东西一层负责从配置中心拉取动态规则并批量推送另一层把Sentinel的metric数据接到PrometheusGrafana让限流指标和业务指标在同一个看板上表达。这个选型的好处是核心算法有人持续维护团队只需要focus在自己业务的那部分坏处是你需要对Sentinel的内部机制有足够理解出问题才敢改。4.2 限流阈值不是拍脑袋从全链路压测里反推限流阈值设多少是我们团队讨论最多的话题之一。拍脑袋一定会出事设太大流量上来时保护不了后端设太小平时业务稍微波动就开始误伤。我们的做法是从全链路压测里反推。每次大促前我们会按预估峰值的1.2倍到1.5倍做全链路压测压测过程中观察每个核心服务的CPU、内存、RT、线程池活动线程数找到某个服务的“濒危临界值”——超过这个值RT会快速恶化错误率抬头。然后把这个临界值打一个八折作为线上限流阈值留出20%的buffer给流量抖动。Sentinel里我们主要用快速失败模式控制突发流量给部分对延迟敏感的接口配了排队等待模式。举一个库存预扣接口的规则配置示例rules: - resource: inventory:preoccupy grade: 1 # 按QPS限流 count: 500 # 压测临界值约620取八折后500 controlBehavior: 2 # 排队等待 maxQueueingTimeMs: 50这里的count500不是随便写的它来自压测曲线。给50ms排队时间是为了让请求不是直接失败而是可以做非常短时间的等待应对小毛刺。4.3 缓存分层本地缓存挡住热点Key的第一波冲击23年4月那次缓存穿透让我们重新审视了整个缓存架构。当时商品详情页的Redis缓存里有一个Key因为某种原因被设置了很短的过期时间大量的请求在某个瞬间穿透到DB。这个DB是一组从库但慢查询还是把它打到了接近极限。事后我们做了一个很常见的缓存分层方案应用本地缓存Caffeine再加Redis这一层最底层才是DB。热点商品的详情不会频繁变化缓存有效期可以设置得很长我们在本地缓存里放一份在Redis里再放一份多级备份。读取顺序是本地缓存先查命不中再查Redis再miss才走DB并回填。这个方案的本质是让“热点Key”尽量在离应用最近的本地缓存里被消化掉。热点商品在秒杀场景下同一个SKU的访问量可能占整个详情流量的30%以上如果这30%全部打到RedisRedis的单分片也会成为瓶颈。至于本地缓存一致性我们牺牲了一小段不一致窗口换来了极大的性能提升——对商品详情这种非强一致场景完全可行。4.4 异步化下单流程里哪些步骤值得牺牲一致性换吞吐异步化是“数十个技术决策”里被讨论最久的一个。下单主流程涉及库存预扣、订单创建、支付请求、优惠券核销、积分累计、通知推送等多个步骤。如果全部同步执行一个下单请求的RT会非常长而且每个依赖的可用性都会直接拖垮主链路。我们的取舍标准是用户能不能感知到这个步骤的结果感知不到的就异步化。库存预扣必须同步因为用户提交订单时就要锁定库存否则超卖不可控订单创建必须同步因为页面要立即展示订单号支付请求可以放到下游但用户反馈要能被快速感知所以它是半异步——先返回“处理中”再由支付回调推进状态。优惠券核销、积分累计、通知推送全部异步走消息队列。消息中间件选了RocketMQ而不是Kafka原因是它对事务消息的支持以及延迟消息、顺序消息这些能力都是开箱即用的。Kafka在吞吐上确实更强但我们在交易场景更看重可靠性、消息不丢不重RocketMQ更合适。消费端的幂等设计也配套做了唯一的业务键去重表加上状态机校验确保一条消息即使被重复投递也不会产生副作用。4.5 幂等设计支付回调重复消费的终极解法22年2月那次支付回调重复消费让我们把幂等从口头要求升级成了强制约束。所有消费MQ消息的入口都必须先做“业务唯一键查重-占位-执行-标记完成”这个流程。Retryable(maxAttempts 3, backoff Backoff(delay 1000)) public void handlePaymentCallback(PaymentCallback callback) { String bizId callback.getOrderId() : callback.getEventId(); if (dedupService.isProcessed(bizId)) { return; } paymentRepo.createIfAbsent(bizId, callback); orderStateMachine.transfer(callback.getTargetStatus()); dedupService.markProcessed(bizId); }createIfAbsent这一步是核心。不使用分布式锁而是依赖数据库唯一索引天然具备原子性。不管同一个回调被多少个消费者拉取最终只有一个实例能插入成功其他全部在查重这一步被挡回去。配合订单状态机的约束即使插入成功了但后续处理失败重试时状态机也会拒绝非法跳转不会出现订单被回退到更早状态这种诡异问题。幂等这件事听着像常识但真正落地到每个消费端还是会有遗漏。我们的做法是code review阶段设置检查点凡是新增MQ消费者必须附上幂等设计说明否则不允许合并。4.6 容量预估从拍脑袋到可计算的模型前两次容量预估失误让我意识到预估这件事不能靠某个人感觉“今年应该比去年多30%”。我们把预估模型改成了可计算的公式预估峰值 去年同时段峰值 × 业务增长系数 × 活动刺激系数 × 渠道增量系数这个模型需要每个系数都有数据支撑业务增长系数来自近半年的DAU和订单量趋势活动刺激系数来自运营给的折扣力度和投放预算渠道增量系数来自新开站点和广告渠道的转化预估。算出来的值还要跟近一周的流量数据做对比校验如果偏差超过20%就要回到各个系数的假设里去检查。这套模型也不是一开始就准第一年误差有40%左右后面随着数据积累逐步收敛到15%以内。高并发治理这件事本质上就是“把所有拍脑袋变成有依据的估算”哪怕一开始估得不准至少复盘的时候有可修正的抓手。4.7 多区域部署单元化和最终一致性最后一个大决策是多区域部署方案。我们在三个不同区域都有可用区核心交易数据按区域分片。刚开始是简单的“搬一套独立环境”每个区域一套完整服务数据各自独立。这方案部署简单但没法做跨区域的库存通卖而且某个区域一个节点挂掉另一区域的资源又帮不上忙。后来演进成单元化部署按用户维度分片一个用户的所有核心请求都进入他所属的那个单元单元内包含完整的服务调用链可以独立完成交易闭环跨单元的通信尽量走异步消息实时性要求高的场景才走同步调用。这个方案的代价是基础设施复杂度上了一个台阶但换来了可用性的明显提升。这里要说明一下单元化不是银弹。如果业务没有区域数据合规约束或者流量没有明显的地域属性强行做单元化只会增加复杂度。我们之所以值得做是因为“数据存储地要求”已经决定了必须区域分片单元化只是在分片之上把架构往前推进了一步。5. “狼来了”告警十次告警里有四次是告警体系自己出了问题5.1 告警风暴是怎么把真实故障掩盖掉的23年9月那天的经历我觉得每个做稳定性的人都应该听一遍。凌晨2点左右值班群开始涌入告警最开始是磁盘使用率超过85%然后是多台主机的Load Average偏高再然后是Pod重启次数增加。一瞬间几十条告警刷屏群里所有人都盯着屏幕但没人能看清到底有没有P0级故障发生。大概过了10分钟才有人发现支付服务的成功订单量在持续下降等到我们反应过来已经过了近半小时。复盘时看了时间线真正的问题——支付服务一个实例内存泄漏导致频繁重启——在告警风暴开始后3分钟就已经触发了但那条告警被淹没在几十条P3级主机告警里了。监控系统按主机维度配置告警几十台机器同时报磁盘和负载监控对象没有聚合规则没有分级于是整个值班群就成了一个比谁嗓门大的菜市场。5.2 告警分级和路由让每条告警都能回答“我该干嘛”那次事件之后我们对告警规则做了全面改造。第一件事就是分级。级别定义通知渠道响应时限P0资损、核心链路完全不可用电话短信群机器人15分钟P1核心链路严重降级非核心功能不可用群机器人短信30分钟P2单实例异常、局部性能劣化群机器人2小时P3资源指标触达阈值但业务未受影响不推送给值班只入看板次日关注规则很简单告警必须能回答“我该干嘛”。P0和P1是必须打断人睡觉的P2推送值班群P3只记录不打扰。为了实现这个目标几乎所有告警规则都从“单指标绝对值”改成了“多条件组合判断”。举个例子。以前是“CPU 80%告警”现在是“CPU 80%持续5分钟且同时间段某核心接口的P99 300ms或错误率 1%”。单看CPU高没有意义很可能只是某个批处理任务在跑只有当CPU高伴随业务指标劣化时才说明系统真的需要人工干预。5.3 告警是给机器看还是给人看引入SLO倒推阈值告警阈值有一个非常普遍的错误把监控指标当成告警依据而不是把用户体验当成告警依据。你以为你在监控CPU、内存、磁盘实际上这些指标是手段不是目的。我们用SLO倒推告警阈值之后整个规则体系清晰了很多。先定义核心接口的SLO目标比如99.9%的请求错误率低于0.1%、99%的请求P95响应时间低于300ms然后反推哪些底层指标的异常会直接影响这两个SLO只有和SLO有因果关系的指标才值得配置告警。这也解释了为什么很多团队告警疲劳越来越严重——他们给几十个指标都配了告警但其中大部分只是偶尔波动一下跟用户体感毫无关系。真正有效率的告警体系应该和你的SLO目标强相关告警触发的本质是“我们可能无法达成SLO了”而不是“某个数字超过了某个阈值”。5.4 告警疲劳比没有告警更可怕那次告警风暴之后还有一件小事让我印象很深。有一个接口的错误率阈值设得偏低几乎每两天就会触发一次P3告警但因为业务影响面很小值班同学每次都确认“没事”。三个月之后这个接口真正出问题、错误率飙到5%的时候第一位接警的同学竟然在群里问了一句“这个告警是不是又是那个误报”这就是告警疲劳的典型表现。人对于重复出现但毫无行动价值的信号会产生忽视心理一旦形成“这个告警基本不用管”的印象再想纠正就很困难。所以我们的策略是宁可少配告警也不为了“看起来监控完善”配一堆无效规则。每一条告警的触发都应该能对应一个具体的处置动作如果这个动作是“看一眼然后忽略”这条告警规则就应该被直接删除。6. 如果让我回到四年前我会先做好这几件事6.1 把容量预估工具链前置如果再走一次这四年我会把容量预估工具链放在所有工作的最前面。当时我们的压测环境和工具是后来补的前几次告警里有一半都和容量预估不准有关。第一年每次大促都像盲人摸象靠运营给增长预期靠开发拍脑袋给容量系数。后来建立了完整的全链路压测流程有了流量模型有了从压测反推限流阈值的机制告警次数才明显下降。这件事最理想的落地时间点是业务规模还没有爆发前。因为流量模型需要历史数据但基础的工具链——压测环境、影子库、流量回放、监控大盘——这些不依赖历史数据可以提前搭好。等到业务真的遇到流量峰值你已经有工具去测量而不是临时搭台。6.2 维护一份可持续更新的故障手册第二件让我后悔没有更早做的事是故障手册。第一次P0之后我们写了复盘文档但那个文档躺在知识库里吃灰直到第三次P0之后才发现很多处置方式早就被记录过只是没人想起来去看。后来我们改成了一种更轻量、更面向下次故障的“故障操作手册”每条记录只包含现象关键词、影响判定方法、止损动作、负责人。不是长篇大论的复盘而是可以直接照着做的Checklist。每次告警发生时值班同学先访问这个手册用现象关键词找到对应的行动项。多年下来这本手册帮了大忙尤其对于新入职的同学他们不一定理解系统全貌但可以按图索骥完成止损动作。这就够了故障处置的第一目标是止血对系统深层次的理解可以在复盘里慢慢沉淀。6.3 培养团队对“未知”的容忍度最后一件事不太技术但可能是最深的一个体会高并发治理里最难的不是写代码、配规则、搭监控而是接受“我们永远无法预测所有故障”。十次告警里有几次的根因哪怕是事后看也觉得很难避免——比如配置中心变更误操作导致限流策略失效再比如新机房网络链路劣化。这类问题靠更多监控和告警并不能完全解决因为它们是链条上某一个环节的人为失误或环境异常。所以我们的做法变成了“前置防御”对变更流程做灰度验证对网络链路做周期性的故障演练对配置改动做审批和变更回滚预案。这些动作不会让告警数降为零但会让同样的问题不再重复出现。接受未知但不接受同样的未知发生两次这可能是四年治理经验里最值得带走的一句话。四年过去告警声还是会响。只是我们不再像第一年那样半夜三更爬起来手忙脚乱地看一堆监控面板。每个人都清楚告警来了先干什么后干什么谁是那个需要被电话叫起来的人。这种感觉比“再也没有告警”更让人安心。
返回列表