
凌晨三点告警电话把我从躺椅上拎了起来。监控大屏上的限流指标一整排飘红——500 个节点的微服务集群刚完成滚动重启核心接口的 QPS 在五分钟内从 1.2 万直接摸到了 8.9 万超时率 37%。按理说第一道防线有 Sentinel 流控规则守着该限的限、该断的断可偏偏在最致命的窗口里流控规则静悄悄地集体失守了。排查到最后问题落在了标题说的那个死局上服务重启瞬间规则还在从 Nacos 往回拉的漫长链路里流量已经冲垮了后端。这个场景不是偶发而是所有用 Sentinel Nacos 挂了持久化的团队都可能踩中的坑。这篇文章把整个链路拆开讲清楚也把这个物理级死局为什么会存在、以及怎么设计逃生通道说明白。1. 事故复盘规则为何在重启瞬间集体失守1.1 一条半小时的告警时间线发布系统在凌晨 2:47 触发滚动更新策略是每批 50 个节点等前一批全部就绪后再滚动下一批。前两批很安静到第三批刚开始执行2:52 左右监控跳出了第一批超时告警2:55 核心接口的限流拒绝数降到 02:57 数据库连接池被打满3:01 我把滚动发布暂停了。这条时间线里有个非常诡异的地方Sentinel 的流控规则是从 Nacos 配置中心持久化过的规则配置在控制台里明确查得到Nacos 那边的配置也没有被人改过。但重启后的节点上规则的生效状态栏空空如也。也就是说规则不是丢了而是还没来得及上身。后续验证也证实了这点我手动在一个刚重启完的节点上执行curl http://localhost:8719/flowRules返回结果是空集合等 2 分钟左右再执行规则全回来了。整个空窗期大约持续 30 到 60 秒而流量从节点注册完成到开始被调度进来最快只需要 3 到 5 秒。1.2 流量穿透 Sentinel 的完整路径要理解这件事得先理清从流量进来到规则拦住流量中间要经过几步。正常路径是这样的请求打到网关网关从注册中心拿到可用节点列表按负载策略转发到某个服务实例服务实例的 Sentinel 在收到请求时会先从内存里的规则表中查一下当前 QPS 数值超过阈值就快速失败没超过就放行。问题在于规则表是从哪儿填充的。Sentinel 的规则默认保存在 JVM 堆内由 FlowRuleManager 用静态容器持有。持久化到 Nacos 后启动过程会多一条分支服务启动初始化 Sentinel 核心此时规则容器是空的接着初始化 Nacos 数据源向配置中心发起一次配置拉取配置拉回来后反序列化成规则列表再注册进 FlowRuleManager。拉取配置这一步不是调用一个同步方法就能立刻返回的。中间涉及客户端建立连接、鉴权、长轮询注册、服务端返回配置、本地反序列化、规则写入内存。任何一个环节慢一点空窗期就拉长一点。更麻烦的是流量不会等你把规则加载完才进来——服务一注册到注册中心网关就开始往这个节点转发请求了。1.3 为什么前面的批次没出事这一批就出了事前两批节点重启时集群里健康节点多流量分摊得开。一个刚起来的节点即使裸奔被分到的流量也很有限在它把规则拉回来之前旧节点已经把这个缺口扛住了。但滚动到第三批的时候已经有 150 个节点处在重启或者刚重启完的状态剩余健康节点的冗余空间被明显压缩。第三批节点同时重启这个动作本身还触发了另一个效应Nacos 客户端的建立连接和长轮询注册会在同一时间集中发起。客户端不是瞬间就能拿到配置的几百个请求同时打到配置中心服务端的连接池和 IO 线程如果扛不住每个请求的响应时间都会拉长。一个节点本来 2 秒能拿到的规则在连接风暴下可能变成 5 秒、10 秒甚至超时后重试。所以这不是单个节点的孤立问题是一个概率放大器。单节点空窗几秒可能无所谓几百个节点的空窗叠加起来就是事故。2. 规则生命周期从内存态到持久化的谜题2.1 Sentinel 规则默认住进哪里重启之后还剩什么Sentinel 的规则管理模型和大多数限流组件一样走的是内存态优先的路子。你通过控制台或者 API 下发一条流控规则它最终会被写进当前进程内存里的一个 Map 结构key 是资源名value 是一组 FlowRule 对象。进程活着的时候这个内存结构管用进程一死什么都没了。这本身不算缺陷内存态是保证规则查询性能的基本前提。真正的坑在于很多团队以为做了 Nacos 持久化之后规则就一劳永逸了忽略了持久化和加载之间有一条时间缝隙。配置中心存的是数据本地进程没有把数据拉到内存之前规则就是不存在。我用一个生活化的比喻解释持久化相当于把规则写进了一本放在公司档案室的规章制度手册而每次服务启动相当于新招了一个员工。员工入职第一天不会自动知道手册里的内容他得先去档案室把手册借出来读一遍。从入职到读完之间这位员工没有任何行为约束。2.2 持久化方案的本质规则需要换一个地方住网上关于 Sentinel 持久化的方案不少最常被提到的是把规则写入 Nacos、Redis、数据库或者本地文件。不管选哪一种本质都是同一个动作把规则从内存态迁移到外部存储下再由某种机制在进程启动后把它搬回内存。这些方案之间最大的差别不在存储而在于更新链路。规则是静态写死的那用文件就行规则要动态变那就需要一个配置中心来广播变更。Nacos 之所以流行就是因为它既承担了注册中心的职责又能兼职配置广播让 Sentinel 客户端通过数据源订阅规则变化。和你并没有额外搭建一套配置系统。但要注意注册数据源只是建立了订阅关系不等于规则已经到手。订阅关系建立好之后数据源会异步去拉一次配置第一次拉取完成之前规则容器仍然是空的。这恰恰是空窗期的来源。2.3 空窗期有多长4 到 10 秒的裸奔窗口我拉了几次实际启动日志统计了从进程启动到规则全部加载完成的时间分布。在 Nacos 服务端正常、网络延迟正常的实验室环境里最快也要 4 到 5 秒线上环境通常 6 到 10 秒遇到连接风暴或者 Nacos 服务端 GC 抖动这个时间会冲到 15 秒开外。这 4 到 10 秒的空窗期就是标题里洪峰绞杀最核心的物理窗口。流量不会因为系统在初始化就停下来等你注册中心一广播节点可用流量就来了。规则没到位Sentinel 对每个请求的判定结果都是放行。QPS 没有上限线程数没有上限依赖下游的资源全部裸奔。而且这个窗口不是一次性的是每个节点每次重启都会经历一遍。日常低频发布可能撞不上流量高峰一旦赶上大促前扩容几百个节点或者故障后的集中重启就非常容易触发。3. 长轮询的真相为什么推送存在物理级死局3.1 Nacos 的推送到底是怎么发生的很多人说Nacos 会把配置变更推送过来这句话本身就有误导性。Nacos 配置中心不是传统意义上的 TCP 长连接推送而是基于 HTTP 长轮询Long Polling的机制。客户端每次发一个请求问配置有没有变服务端如果发现没变不会立刻返回空结果而是把这个请求挂住最多挂 30 秒期间一旦配置发生变化服务端立刻返回如果 30 秒都没变化就返回一个空响应客户端收到后马上发起下一轮请求。所以所谓推送其实是客户端不断来问服务端每次把答案晚一点给。这个机制的好处是能让配置变更的感知延迟降到非常低缺点是对连接数和服务器端线程占用比较敏感。每一条挂住的请求都占用着一份服务器端的资源。3.2 进程不存在推送不可能到达物理约束回到重启场景来看问题就清楚了。Sentinel 客户端注册到 Nacos 数据源本质是发起一个长轮询请求而长轮询请求的前提是进程里有一个活着的 TCP 连接或 HTTP 客户端在等着服务端返回。进程都还没启动或者刚启动还在初始化网络栈谁来发这个请求谁等在那里接收响应这是物理层面的时序约束不是配置错了也不是代码逻辑有 bug。服务端不可能把数据推给一个不存在的连接。任何推送都要求接收端先存在先建立一个通信通道。服务重启里那个空窗期本质上就是接收端还在创建推送却已经无从谈起。我把它叫物理级死局因为这个时间缝隙无法通过延长 Nacos 服务端的等待时间来解决只能从接收端这一侧去找出路。3.3 500 个节点同时连上来连接风暴的放大效应单个节点空窗几秒原本只是小概率风险。但 500 个节点同时重启把问题放大成了确定事故。你想象一下500 个进程几乎同时起来每个进程一到初始化阶段就向 Nacos 发起长轮询注册有的还叠加服务注册、心跳、配置拉取Nacos 服务端在同一时间窗口承受的连接数是平时的几十倍。这些长轮询连接会挂在服务端Nacos 服务端不是无限接纳的超过阈值就要排队。先到的先拿到配置后到的要等前面的连接释放。结果就是最先启动的那批节点可能 5 秒拿到规则最后启动的节点可能要等 15 秒甚至更久。而流量早在这中间就进来了。这里有一个经常被忽略的放大细节连接风暴还会拖慢已规则就绪节点的配置变更感知。如果这时候正好有运维同学在控制台调整阈值部分节点的长轮询请求还在队列里排队感知变更的时间会被拉长到几十秒。你看到的现象就是规则在控制台上改了但线上节点迟迟不生效。4. 逃生方案设计三路并进缩短空窗4.1 设计原则先保有规则再保新规则理解了死局成因再谈方案思路就清晰了。核心原则是一个从Nacos 规则到达之前进程没有任何规则变成进程在任何时候都至少有一份规则可用Nacos 规则只是用来把这份规则更新到最新。也就是把规则从命根子降级成增量更新。通俗点说以前进程一启动就在等 Nacos 发规则等不到就裸奔改造后进程一启动直接读本地缓存先用一份可能旧一点点、但基础可用的规则把防线立起来Nacos 的规则到达后再覆盖更新。先保有再保新优先级不能反过来。这个思路实现的成本不高但收益非常直接空窗期的系统行为从完全放行变成按历史规则限流。哪怕历史规则和当前业务不完全匹配最多就是限流阈值偏保守引发少量误限也不会出现下游被击穿这种灾难性场景。4.2 本地规则缓存兜底的实现本地缓存我选的是 JSON 文件放在固定的磁盘路径下。每次从 Nacos 拉取到规则并且成功注册到 FlowRuleManager 之后异步把规则列表序列化写入这个文件。注意是异步写入不能在规则更新链路上加同步 IO否则会把配置推送的耗时从毫秒级拉到几十毫秒影响面反而变大。public class LocalRuleStore { private static final String RULE_FILE /data/sentinel/flow_rule.json; public static ListFlowRule loadLocalRules() { try { String content FileUtils.readFileToString(new File(RULE_FILE), StandardCharsets.UTF_8); return JSON.parseArray(content, FlowRule.class); } catch (Exception e) { return Collections.emptyList(); } } public static void saveLocalRules(ListFlowRule rules) { CompletableFuture.runAsync(() - { try { String content JSON.toJSONString(rules); FileUtils.writeStringToFile(new File(RULE_FILE), content, StandardCharsets.UTF_8); } catch (IOException e) { log.error(save local rules failed, e); } }); } }有几个工程细节需要提一下第一文件写入加个临时文件 rename 的方式防止写入过程中进程崩溃留下半截文件第二不用每次规则更新都写设置一个最短写盘间隔比如 5 秒内只写一次避免频繁刷盘第三不做加密因为它是内部运维数据可读性更重要。4.3 启动预拉取与订阅刷新的代码有了本地缓存下一步要让进程在启动时先加载它再建立 Nacos 订阅。这里有一个顺序问题FlowRuleManager.loadRules 是同步覆盖NacosDataSource 注册后是异步加载。所以正确的初始化顺序是先同步加载本地缓存再注册 Nacos 数据源。Nacos 的规则到达后会自动覆盖本地缓存里的旧规则。PostConstruct public void initSentinelRules() { // 第一路本地兜底规则同步加载立即可用 ListFlowRule localRules LocalRuleStore.loadLocalRules(); if (!localRules.isEmpty()) { FlowRuleManager.loadRules(localRules); log.info(loaded local fallback flow rules, size{}, localRules.size()); } // 第二路Nacos 数据源异步拉取并订阅变更 ReadableDataSourceString, ListFlowRule ds new NacosDataSource( nacosAddr, nacosGroup, nacosDataId, source - JSON.parseObject(source, new TypeReferenceListFlowRule() {}) ); FlowRuleManager.register2Property(ds.getProperty()); // 第三路规则更新后回写本地缓存 FlowRuleManager.getProperty().addListener(prop - { ListFlowRule rules FlowRuleManager.getRules(); if (!rules.isEmpty()) { LocalRuleStore.saveLocalRules(rules); } }); }这个写法里有个隐含细节FlowRuleManager.getProperty() 这个全局属性的监听器会在规则被任何数据源更新时触发。在里面统一做本地缓存回写比在 Nacos 数据源里单独写回调要干净得多也避免后续接了其他数据源时漏掉回写。4.4 发布节奏、探针与流量恢复的配合本地缓存解决了进程无规则的问题但我还想再进一步把流量进入的时机往后挪一挪让规则至少有 2 到 3 秒的缓冲期。这一步靠发布节奏和健康检查探针来完成。Kubernetes 的滚动更新配置里把 maxUnavailable 调小比如 10% 到 15%避免同一时间有太多节点处于重启状态。同时把 readinessProbe 配置成调用服务自身的一个规则就绪检查接口规则数量大于 0 才返回 200否则返回 503。这样调度器不会把流量打进一个规则还没就绪的节点。readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5 failureThreshold: 12对应接口的实现逻辑很简单检查 FlowRuleManager.getFlowRuleMap() 不为空并且最近的 Nacos 规则拉取没有失败就返回就绪。这样从调度器的角度一个节点只有当服务能处理请求并且规则已经在内存里两个条件同时满足才会被分配流量。这套组合下来空窗期从不可控变成了可控本地缓存保证永远有规则探针保证流量不会抢跑Nacos 数据源负责把规则刷新到最新。即便 Nacos 在重启瞬间完全不可用本地缓存也能顶住不会裸奔。5. 常见问题与排查实录5.1 旧规则兜底会不会误伤流量有人担心本地缓存里的规则是旧的比如阈值还是一个月前的 1000 QPS但业务容量已经涨到了 5000 QPS兜底规则会把正常流量误限掉。这个担忧合理我的处理办法是本地缓存不直接当成最终规则只是最低保障。Nacos 订阅建立后最新规则会在几秒内覆盖兜底规则。同时在控制台把规则阈值当成动态配置管理容量变化时及时改 Nacos让本地缓存的保质期尽可能短。当然如果业务要求苛刻到任何一秒都不能误限那就得接受另一面在没有规则的情况下某几秒内所有流量都放行直到最新规则到位。这个取舍要看你的业务场景没有银弹。5.2 规则看起来加载了为什么还是没拦住排查这个问题时第一步不是看规则有没有加载而是确认规则被加载到了哪个维度。FlowRuleManager 只是流控规则的入口还有熔断规则DegradeRuleManager、系统保护规则SystemRuleManager、热点参数规则ParamFlowRuleManager每一类规则需要一个数据源去订阅。之前就遇到过只给流控规则配了 Nacos 数据源熔断规则完全没接的情况服务重启后熔断规则丢个精光。第二步是检查规则阈值单位。Sentinel 流控默认按 QPS 计算如果你把阈值配成 10000但线上单机 QPS 平均才 500那这条规则确实没机会触发。这不算 bug是阈值设计问题。用 8719 端口的/flowRules接口可以实时看到每个资源当前的规则和统计值排查时第一个看它。5.3 Nacos 自身也扛不住连接风暴节点规模一大Nacos 服务端本身也可能成为瓶颈。典型表现是Nacos 控制台打开慢配置发布后节点长时间感知不到变更。排查思路是先看 Nacos 服务端的连接数和线程池占用再查客户端日志里有没有连接超时、重试的 WARN。这类问题没有一步到位的解法我的建议是双管齐下一方面调大服务端连接数和长轮询线程池配置另一方面在客户端侧增加连接池大小并考虑把配置监听和注册心跳分流到两个不同的 Nacos 服务端节点上降低互相挤占的概率。更重要的是发布动作本身要错峰不要几百个节点在同一分钟里重启。5.4 控制台里规则数量翻倍了翻车现场还有一个常见问题数据源注册重复。代码里如果不小心把同一个 Nacos 数据源对象注册了两次FlowRuleManager 里会收到两份相同属性最终控制台看到的规则数量直接翻倍。更隐蔽的是两个数据源同时加载了相同 dataId 下不同版本的配置两者互相覆盖表现成规则偶尔失效偶尔生效。排查方法在控制台看规则列表里是否存在完全相同的两条然后检查注册入口代码保证只注册一次。建议把数据源初始化收敛到一个统一方法里用 Spring 容器的单例保证不会重复创建。多用日志记录注册时机和注册次数重现问题时直接看日志确认。6. 最后聊几点实在的体会这个坑踩过之后我的一个很深的感受是流控系统的可靠性不能只盯着规则配置本身更要注意规则从配置中心到进程内存之间的传输链路。Nacos 持久化解决的是规则不丢的问题但规则及时到位是另一个独立问题而且更容易被忽略。按我现在的习惯任何 Sentinel Nacos 的接入上线前都会做一个重启演练把一批节点重启用脚本持续探测 8719 端口记录空窗期持续时间确认本地兜底规则生效再确认 Nacos 规则覆盖完成。这套流程走一次比看十篇文档都管用。还有一个小技巧把规则的版本号或者更新时间写进本地缓存文件出问题时第一眼就能看出当前生效的规则是不是最新的。这类事故里可观测性比任何优化都重要看得见才能防得住。