凌晨三点,告警电话把我从躺椅上拎了起来。监控大屏上的限流指标一整排飘红——500 个节点的微服务集群刚完成滚动重启,核心接口的 QPS 在五分钟内从 1.2 万直接摸到了 8.9 万,超时率 37%。按理说第一道防线有 Sentinel 流控规则守着,该限的限、该断的断,可偏偏在最致命的窗口里,流控规则静悄悄地集体失守了。
排查到最后,问题落在了标题说的那个死局上:服务重启瞬间,规则还在从 Nacos 往回拉的漫长链路里,流量已经冲垮了后端。这个场景不是偶发,而是所有用 Sentinel + Nacos 挂了持久化的团队都可能踩中的坑。这篇文章把整个链路拆开讲清楚,也把这个"物理级"死局为什么会存在、以及怎么设计逃生通道说明白。
1. 事故复盘:规则为何在重启瞬间集体失守
1.1 一条半小时的告警时间线
发布系统在凌晨 2:47 触发滚动更新,策略是每批 50 个节点,等前一批全部就绪后再滚动下一批。前两批很安静,到第三批刚开始执行,2:52 左右监控跳出了第一批超时告警,2:55 核心接口的限流拒绝数降到 0,2: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 List<FlowRule> 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(List<FlowRule> 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() { // 第一路:本地兜底规则,同步加载,立即可用 List<FlowRule> localRules = LocalRuleStore.loadLocalRules(); if (!localRules.isEmpty()) { FlowRuleManager.loadRules(localRules); log.info("loaded local fallback flow rules, size={}", localRules.size()); } // 第二路:Nacos 数据源,异步拉取并订阅变更 ReadableDataSource<String, List<FlowRule>> ds = new NacosDataSource<>( nacosAddr, nacosGroup, nacosDataId, source -> JSON.parseObject(source, new TypeReference<List<FlowRule>>() {}) ); FlowRuleManager.register2Property(ds.getProperty()); // 第三路:规则更新后回写本地缓存 FlowRuleManager.getProperty().addListener(prop -> { List<FlowRule> 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 规则覆盖完成。这套流程走一次,比看十篇文档都管用。
还有一个小技巧:把规则的版本号或者更新时间写进本地缓存文件,出问题时第一眼就能看出当前生效的规则是不是最新的。这类事故里,可观测性比任何优化都重要,看得见,才能防得住。