你收不回来的那条 LSA
加入一个路由协议是一次握手。离开它则以一条你也许永远没机会发出的消息收场。
Michel Wijnberg
上一篇讲的是如何加入一个链路状态协议而不成为 穿过它的一条路径。这一篇讲的是另一端,它更难,而我当初低估了它。
到来是一次握手。双方协商、双方确认,丢了包就重试。OSPF 和 IS-IS 里的每一个状态机,都是为了让 “到来”变得可靠而存在的。
离开没有状态机。你把自己的告别宣告一次,宣告给一个全部目的就是记住路由器说过什么的协议,然后你 就不在了,什么也改不了。
网络会把你留着
不告诉任何人就停掉一个记录器,什么戏剧性的事都不会发生。问题正在于此。
你的进程消失了,你的 LSA 并不会跟着消失。它带着一个序列号和一个年龄,躺在每一台路由器的数据库 里,一直躺到年龄触顶为止;在 OSPF 里那是一小时。在这一小时里,拓扑中包含着一台已经不存在的路由器, 仍在广告着通往某个正默默纳闷你去哪了的邻居的邻接。
一个网络描述着不真实的事情,长达一小时,只因为一个监控工具退出了。
所以你必须好好道别,而两种协议都有办法做到这一点,归根结底是同一个想法:把你想移除的东西重新 起源一次,用一种意思是把这个删掉的形式。
- OSPF 把 LSA 的年龄设为 MaxAge(3600 秒)后泛洪出去,让每一台收到它的路由器把它从自己的 数据库里冲掉(RFC 2328 §13.4)。
- IS-IS 把 LSP 以剩余寿命为零泛洪出去。同样的想法,不同的字段。
两者都只是一条消息。没有带确认的拆除流程,没有”你确定吗”,也没有重试循环,因为等到这件事真正 重要的时候,进程正在退出。
告别里的陷阱
下面这部分很容易做错,而如果不是跑着带认证的 IS-IS,我根本不会发现它。
一条清除用的 LSP 什么也不带。没有可达性、没有主机名、没有能力信息。它的全部内容就是自己那个说着 “寿命为零”的头部。这让它看起来像一条里面没有任何东西需要保护的消息。
它仍然必须被认证。RFC 5304 要求使用加密认证的实现拒绝未认证的清除,并要求一条清除除认证 TLV 之外 不携带任何 TLV。只要你问一句”未认证的清除是什么”,这两半就都说得通了:那是一个删除另一台路由器 状态的、单独的未签名数据包,它的形状是一次攻击,而不是一次关机。
也就是说:你在关闭过程中发出的那唯一一条消息、那条你无法重试的消息、那条压根不含任何其他内容的 消息,恰恰是仍然必须被签名的那一条。做错了,失败会以最糟糕的方式保持沉默:从你这一侧看关机干净 利落,清除发了出去,做认证的路由器把它丢弃,而你的幽灵在数据库里待满整个寿命。
而代码所产出的形状正是如此:清除 LSP 在构造时不携带任何 TLV,当配置了签名时,序列化器再加上认证 TLV。在一个启用了认证的网络上,那条消息的全部内容就是它自己的签名。这不是优化。这是”离开”和 “看上去像离开”之间的区别。
你没机会发出的那次告别
在讲机制之前,先讲它底下的那个假设,因为这里很容易把设计做反。
上面的一切描述的都是优雅关机:进程知道自己要停了,并且还有时间再发一条消息。那是简单的情形, 而且不是值得据以设计的情形。
值得据以设计的情形,是压根没有任何消息的那一种。一个不等你的 kill 信号。一台掉电的主机。一个因 内存被收割的容器。在每一种情形里,清除都不会被发出,而网络被留下来抱着一台已经停止存在的路由器, 没有任何关于”出了什么事”的通知。
所以清除是尽力而为的,设计不能倚靠它。必须正确的,是那条假定它从未发生过的路径:重新起来, 发现自己的幽灵还装在那里,然后把它盖过去。如果那条路径管用,优雅的清除就是一次把故障窗口从一小时 缩短到一秒的优化。如果不管用,优雅的清除就是一种恰好在最需要它的时候失效的安慰。
清除改善收敛。它不能成为正确性机制。
所以本文余下的部分讲的是重启,而不是停止。
回来时发现自己已经在那儿了
问题的另一半是重启,而真正的缺陷正是由它产生的。
一个不干净地停掉、或者重启得比网络遗忘还快的记录器,回来时会发现上一次运行的 LSA 仍带着一个序列 号装在各处。要想被相信,新的起源必须压过它。
这听起来像一行比较。并不是,理由值得拼写出来。
OSPF 的序列号是有符号的 32 位。初始值 0x80000001 是可表示的最负的数,然后从那里往上数。
所以,用零当作”没有值”的哨兵来实现”我见过之前的序列号吗”这种自然做法,错得能一路活过测试:零
永远不是一个合法的 OSPF 序列号,但 int32(0) 大于每一个合法值。一条陈旧的 LSA 在与一个本该表示
“当时没有”的哨兵比较时永远会输。代码转而携带显式的布尔量,还有一条注释解释为什么那个显而易见的
版本是个陷阱。
接着是什么时候做,而两个显而易见的答案都是错的。
第一反应是一看到幽灵就立刻把它冲掉,在数据库交换过程中用一次 MaxAge 泛洪。我们观察到 Cisco 的 实现对此反应很糟:一次在邻接仍处于 Loading 时到达的 MaxAge 冲刷,会把泛洪推进一个大约每四十秒 一次的周期性重置循环。这不是我能指出的任何一方的规范违反。它就是那些路由器实际做的事。
吃过这个教训之后的第二反应,是等:把陈旧的序列号记下来,等邻接达到 Full 再重新起源。这会 死锁。邻居仍在广告那个幽灵,所以它留在我们的请求列表里,所以列表永远清不干净,所以永远达不到 Full,所以那次本可以解决问题的起源永远不会发生。于是恢复就得等刷新定时器,大约半小时之后。
答案两者都不是,而它取决于一个容易被忽略的区分:在 Loading 中途危险的是MaxAge 冲刷,而不是 起源。所以记录器会立即重新起源,就在 Loading 期间,作为一条普通 LSA,携带一个既高于自己那份副本 也高于那个幽灵的序列号,并顺手把陈旧条目从邻居的请求列表里去掉。普通 LSA 在那里是安全的。冲刷 不是。
这些都不在任何规范里。这是网络实际做出来的事,两次,用两种不同的方式。
本文所讲的这两列,恰恰就是让一条 LSA 难以收回的那两样:必须走到 3600 才会有人丢弃的存活时间, 以及必须被超过的序列号。在录制器持有邻接关系的地方,两者都是真值。在只有 SNMP 遍历重建出 LSA 的地方,浏览器打的是一道短横,而不是一个看起来很像样的零。
吞掉了一条邻接的那条五秒规则
然后是有意思的那种失败,也是我最喜欢的一类:两条都正确的规则,组合出一个错误的结果。
RFC 2328 §12.4 要求同一条 LSA 的两次起源之间至少间隔五秒。这是一个合理的限速,Osprey 也照做了。
另一件独立的事:在邻接达到 Full 之前发出的那条替代 LSA,刻意不带任何链路。广告一条你还没有 建立完成的邻接,等于对一段尚不存在的邻居关系做出断言。
现在把这两件事放进同一秒里。记录器因为还不是 Full,于是用不带链路的形式盖过了那个幽灵。紧接着 邻接达到了 Full,记录器试图起源它真正的那条 LSA,也就是带着链路的那条。这次起源落在了五秒窗口 里,被限速器丢掉了。
没有报错。没有重试。记录器现在正在广告着,而且是正确且合规地广告着,一台没有任何链路的路由器,而且它 会一直这样广告下去,直到周期性刷新到来。
那个刷新是三十分钟。
在 2026-08-05 的实验室部署中测到,两个事件落在了同一秒里:记录器自己的邻接显示为 down 长达半小时, 而这个产品的全部工作就是告诉你邻接是否正常。而诚实的部分在于,每一个组件的行为都完全符合设计。 限速器遵守了 RFC。Full 之前的那条 LSA 不去声称有链路是对的。缺陷落在它们之间的空隙里,而这一类 缺陷总是落在那儿。
修复是一次合并的一次性重试:被限速器拒绝的起源不会被丢弃,而是被重新装填,等窗口打开的那一刻发出。
为什么离开是更难的那一半
到来是一次带反馈的协商。如果你的 Hello 错了,邻接就建立不起来,而你会立刻、大声地、在一台不肯往前 走的状态机里知道这件事。
离开是一条无确认的单条消息,由一个即将停止存在的进程发出,必须在第一次尝试时就格式正确、签名正确, 而它的失败从你所站的位置看去,与成功一模一样。
正是这种不对称,使得这份代码里的关机路径比启动路径注释更多,也使得那条唯一没有内容的消息,成了 附带最严格要求的那一条。
要点
一个不会干净离开的监控系统并不是被动的。它在停止之前是被动的,而在那之后,它就是别人拓扑数据库里 一小时的虚构。
大部分难度不在规范里。它在规则与规则的接缝处:一条正确的限速、一条正确的 Full 前 LSA、一个看上去 显然没错却悄悄错了的哨兵值。它们都不是对协议的误解。它们全都是同一种形状,也就是两件正确的事相遇。
下一篇:那个压根没有数据库可加入的协议,以及即便你礼貌地询问、MIB 也不会告诉你的东西。 没有数据库的协议。