IS-IS(Intermediate System to Intermediate System)是运营商网络中广泛使用的链路状态路由协议,它依靠LSP(Link State Packet)在区域内泛洪传播链路状态信息,最终让每个节点都拥有一份完全一致的链路状态数据库(LSDB)。协议规范文档读起来往往晦涩难懂,但如果我们亲手用Ruby写一个简化版的模拟器,把泛洪、确认、同步这几个核心环节跑起来,整个协议的运作逻辑会立刻变得清晰。本文将一步步实现这个模拟过程。

一、IS-IS LSP泛洪的核心机制解析
在动手写代码之前,需要先理解LSP泛洪到底在做什么。每个IS-IS节点会周期性或在外部链路发生变化时,生成一份描述自身链路状态的LSP报文,报文中包含三个关键字段:System ID(节点标识)、序列号(Sequence Number)和剩余生存时间(Remain Lifetime)。序列号越大,代表这份LSP越新;生存时间则类似TTL,每秒递减,归零后LSP会被老化清除。
泛洪的基本规则很简单:当一个节点收到邻居发来的LSP时,它会先与本地LSDB中同ID的条目比较序列号。如果收到的LSP更新(序列号更大),就将其存入数据库,并转发给除接收接口之外的所有邻居,这就是所谓的 Reliable Flooding。如果收到的LSP更旧,节点会用本地的新版本回发给对方,纠正过时信息。如果序列号相同,则静默丢弃。正是这套简单规则,保证了链路状态信息能够在有限时间内扩散到全网。
与OSPF不同,IS-IS的可靠性保障依赖SNP报文:CSNP(Complete Sequence Number Packet)携带数据库的完整摘要,用于周期性全量同步;PSNP(Partial Sequence Number Packet)则用于对单条LSP做确认或请求。在我们的模拟器中,会把这两类报文一并实现,观察它们如何配合完成数据库收敛。
二、用Ruby构建节点通信与LSP报文结构
模拟器采用TCP Socket实现节点间的邻接通信,每个节点监听一个独立端口,节点之间的链路抽象为一对互联的TCP连接。为了简化代码,我们用JSON序列化报文,真实协议中是二进制TLV编码,但教学场景下JSON更易读。
先定义LSP的数据结构和节点的骨架代码:
require 'socket'
require 'json'
# LSP报文结构
class LSP
attr_accessor :sys_id, :seq, :lifetime, :checksum, :neighbors
def initialize(sys_id, seq, neighbors = [])
@sys_id = sys_id # 节点唯一标识,如 "R1"
@seq = seq # 序列号,越大越新
@lifetime = 1200 # 剩余生存时间,模拟1200秒
@neighbors = neighbors # 邻居列表
@checksum = compute_checksum
end
def compute_checksum
# 简化的校验和:对字段拼接字符串做散列
require 'digest'
Digest::MD5.hexdigest("#{@sys_id}:#{@seq}:#{@neighbors.sort.join(',')}")[0, 8]
end
def newer_than?(other)
@seq > other.seq
end
def to_h
{ type: 'LSP', sys_id: @sys_id, seq: @seq,
lifetime: @lifetime, neighbors: @neighbors, checksum: @checksum }
end
def self.from_h(h)
lsp = LSP.new(h['sys_id'], h['seq'], h['neighbors'])
lsp.lifetime = h['lifetime']
lsp
end
end
上面的代码中,newer_than?方法封装了序列号比较逻辑,这是泛洪决策的核心判断。校验和使用MD5散列模拟,真实IS-IS通过对LSP内容逐字节计算得到,用于检测传输损坏。接下来实现节点主体,包括LSDB存储、Socket监听和泛洪处理:
class IsisNode
attr_reader :sys_id, :lsdb, :links
def initialize(sys_id, port)
@sys_id = sys_id
@port = port
@lsdb = {} # sys_id => LSP
@links = {} # 邻居sys_id => TCPSocket
@seq = 0
start_server
origin_own_lsp
end
def start_server
Thread.new do
server = TCPServer.new('127.0.0.1', @port)
loop do
client = server.accept
Thread.new(client) { |c| handle_message(c) }
end
end
end
def add_link(peer_sys_id, peer_port)
sock = TCPSocket.new('127.0.0.1', peer_port)
@links[peer_sys_id] = sock
@links.each { |_, s| s.puts({ type: 'HELLO', from: @sys_id }.to_json) }
refresh_own_lsp
end
def origin_own_lsp
refresh_own_lsp
Thread.new do
loop { sleep 15; refresh_own_lsp } # 周期性刷新自身LSP
end
end
def refresh_own_lsp
@seq += 1
lsp = LSP.new(@sys_id, @seq, @links.keys)
@lsdb[@sys_id] = lsp
flood(lsp, nil)
end
def flood(lsp, except_peer)
@links.each do |peer, sock|
next if peer == except_peer
sock.puts(lsp.to_h.to_json)
end
end
end
这段代码里有两个关键点值得展开。第一,flood方法的except_peer参数实现了泛洪时的水平分割:从某个邻居收到的LSP不再回传给该邻居,避免无意义的报文回弹。第二,节点每次链路状态变化都会将自身序列号加一后重新生成LSP,这正是真实IS-IS在链路震荡时的行为。序列号在真实协议中是一个32位无符号整数,从1开始,达到最大值时会触发LSP老化并重新从1编号,模拟器中省略了这一边界处理。
三、实现泛洪决策与链路状态数据库同步
收到报文后的处理逻辑是整个模拟器的灵魂。节点需要根据报文类型分发处理:HELLO用于建立邻接关系,LSP走泛洪决策,CSNP用于全量比对,PSNP用于单条请求或确认。
class IsisNode
def handle_message(client)
loop do
line = client.gets
break if line.nil?
msg = JSON.parse(line)
case msg['type']
when 'HELLO' then next
when 'LSP' then handle_lsp(LSP.from_h(msg), peer_of(client))
when 'CSNP' then handle_csnp(msg['entries'], peer_of(client))
when 'PSNP' then handle_psnp(msg['requested'], peer_of(client))
end
end
end
def handle_lsp(incoming, from_peer)
local = @lsdb[incoming.sys_id]
if local.nil? || incoming.newer_than?(local)
# 本地没有或收到的更新:存库并向其他邻居扩散
@lsdb[incoming.sys_id] = incoming
flood(incoming, from_peer)
send_psnp_ack(incoming.sys_id, from_peer)
elsif local.newer_than?(incoming)
# 收到的更旧:把本地新版回发给对方纠正
send_to(local, from_peer)
end
# 序列号相同:静默丢弃
end
def handle_csnp(entries, peer)
entries.each do |sys_id, seq|
local = @lsdb[sys_id]
if local.nil? || local.seq < seq
# 本地缺失或过时,向对方发PSNP请求
request_lsp(sys_id, peer)
elsif local.seq > seq
# 本地更新,主动推送给对方
send_to(local, peer)
end
end
end
def send_csnp
entries = @lsdb.map { |id, l| [id, l.seq] }.to_h
@links.each do |_, sock|
sock.puts({ type: 'CSNP', entries: entries }.to_json)
end
end
def send_to(lsp, peer)
sock = @links[peer]
sock.puts(lsp.to_h.to_json) if sock
end
def request_lsp(sys_id, peer)
sock = @links[peer]
sock&.puts({ type: 'PSNP', requested: sys_id }.to_json)
end
def send_psnp_ack(sys_id, peer)
# 简化处理:确认即回送一份相同LSP的摘要
sock = @links[peer]
sock&.puts({ type: 'PSNP', requested: sys_id, ack: true }.to_json)
end
end
handle_lsp中的三分支判断正是前文所述泛洪规则的代码化体现。需要注意handle_csnp的处理方式:CSNP本质上是数据库目录清单,收到后逐条比对序列号,比对方旧的条目通过PSNP请求补齐,比对方新的条目直接推送过去。这个双向纠偏过程,就是IS-IS在邻接建立初期快速同步数据库的机制,也是新设备接入网络后能够迅速获得全网拓扑的原因。
最后写一个启动脚本,搭建R1、R2、R3三节点拓扑(R1与R2直连,R2与R3直连),验证LSP能否经过R2中继泛洪到R3:
r1 = IsisNode.new('R1', 9001)
sleep 0.5
r2 = IsisNode.new('R2', 9002)
sleep 0.5
r3 = IsisNode.new('R3', 9003)
sleep 0.5
r1.add_link('R2', 9002)
sleep 0.5
r2.add_link('R3', 9003)
sleep 1
# 模拟链路变化:R3接入后,R1刷新自身LSP
r1.refresh_own_lsp
sleep 2
r3.lsdb.keys.each { |id| puts "R3数据库包含: #{id}" }
# 输出应包含 R1、R2、R3 三条LSP,证明泛洪跨越了中间节点
运行后可以看到R3的LSDB中最终包含了全部三个节点的LSP,说明R1的链路状态信息成功经过R2中继到达了R3。如果在refresh_own_lsp前后分别打印各节点数据库,还能观察到序列号递增以及数据库逐步收敛的过程。
四、简化实现与真实协议的差距及改进方向
这个模拟器为了突出主干逻辑做了不少取舍:报文用JSON而非TLV二进制编码,缺少分片支持(真实LSP超过MTU会分片,LSP ID由System ID、伪节点ID和分片号三部分组成),没有实现LSP老化计时器(真实环境中生存时间每秒减一,归零后需要老化清除再重新泛洪),CSNP的周期发送也只是留了接口没有调度。
如果想进一步逼近真实协议,可以从三个方向改进。一是引入老化线程,每隔一秒将所有LSP的lifetime减一,归零的条目以序列号加一、lifetime为零的方式重新泛洪一次再删除;二是实现CSNP的定时调度,例如每10秒调用一次send_csnp,观察数据库在模拟丢包场景下的自愈能力;三是把TCP换成UDP并在应用层实现重传,这样更贴近真实IS-IS在数据链路层之上的运行方式,也能观察到PSNP确认超时重传的行为。
通过这个Ruby实现的练习,你会发现IS-IS的泛洪机制本质上是一套基于序列号比较的分布式数据同步算法,与Gossip协议、CRDT的版本向量思想都有相通之处。理解了这一点,再去阅读RFC 1195或ISO 10589的原文,那些看似复杂的报文格式和状态机描述就会变得容易消化得多。动手写一遍协议,永远是学习网络协议最有效的路径。