路由器中的关键算法有哪些? 路由表是如何生成的?

7 阅读19分钟

路由器的核心任务是确定数据包从源头到目的地的最佳路径,因此路由器背后的算法核心是路由选择算法(距离矢量、链路状态、路径矢量)最短路径算法(Dijkstra、Bellman-Ford) ,结合高效的查找机制(最长前缀匹配)和流量控制策略,保证数据包能够快速、准确地从源头到达目的地。

路由

我们知道,计算机网络的作用,就是通过把不同的节点连接在一起从而交换信息、共享资源,而各个节点之间也就通过网络形成了一张拓扑关系网。

比如在一个局域网下,节点 A 要给节点 B 发送一条消息,如果 A 和 B 并没有直接通过网络相连,可能就需要经过其他路由设备的几次转发,这时我们需要在整个网络拓扑图中找到一条可到达的路径,才能把消息发送到目的地。

每台路由器都是一台网络设备,也就是网络中的一个节点,在其中就保存有一张路由表,每次网卡收到包含目标地址的数据包(packet)时,就会根据路由表的内容决定如何转发数据。

你的电脑也是一个网络上的一个节点,我们在 Mac 上通过命令就可以看到自己节点的路由表:

netstat -nr

我本地获取到的路由表如下:


Routing tables

Internet:

Destination        Gateway            Flags           Netif Expire

default            192.168.170.122    UGScg             en0       

127                127.0.0.1          UCS               lo0       

127.0.0.1          127.0.0.1          UH                lo0       

169.254            link#12            UCS               en0      !

192.168.170        link#12            UCS               en0      !

192.168.170.122/32 link#12            UCS               en0      !

192.168.170.122    52:cf:f3:54:12:d6  UHLWIir           en0   1171

192.168.170.171/32 link#12            UCS               en0      !

224.0.0/4          link#12            UmCS              en0      !

224.0.0.251        1:0:5e:0:0:fb      UHmLWI            en0       

239.255.255.250    1:0:5e:7f:ff:fa    UHmLWI            en0       

255.255.255.255/32 link#12            UCS               en0      !
  

路由表的每一行都代表一条路由规则,至少会包括两个信息,也就是路由表的前 2 列:

  1. 目标网络地址(Destination):标示 IP 包要去往的目标网络
  2. 下一跳地址(Gateway):与当前路由器相邻的路由器,命中这条规则的数据包应该经由这个路由器转发去往最终目的地

这里的后 3 列顺带简单介绍一下,flag 是路由的一些信息,netif 指的是网络物理接口,expire 代表过期时间,感兴趣的话可以去查阅 Linux 手册详细了解。

因为每个数据包里包含了目标地址,所以路由器工作的基本原理就是,网卡基于路由表匹配数据包对应的规则,转发到下一跳的路由器直至抵达终点就可以了。

路由表

那这个路由表怎么来呢?主要有两种方式。

一种就是我们手动管理配置。Linux 提供了简单的配置命令,既可以根据路由 IP 配置路由表,也可以基于一定的策略配置,查阅 Linux 手册即可。这种方式也被称为静态路由表,最早期的网络就是这样由网络管理员手动配置的。但如果网络结构发生变化,手工修改的成本就非常高。

为了解决这种问题,第二种方式——动态路由表就应运而生,它可以根据协议在网络中通过节点间的通信自主地生成,网络结构变化时,也会自动调整。

而生成动态路由表的算法,就是选路算法。所以,选路算法所做的事情就是,构建一个动态路由表,帮每个数据包都选择一条去目标 IP 最快的路径。

那在路由选路问题中,什么是最快路径呢?

1.webp

我们知道信息在网络上传输肯定是需要经过物理传输的,各设备之间的距离以及不同设备本身的网络连接情况都是不同的,都会影响节点间传输的时间。如果我们把不同节点之间的通信时间当作距离,整个拓扑图上搜索最快路径的过程,其实就等价于求图上的最短路问题。

求解最短路的算法,有不少,比如基于 BFS 的 SPFA、基于贪心思想的 Dijkstra、基于动态规划思想的 Bellman-Ford 等算法。这些算法在选路问题中也有应用,最经典的两种就是基于 Dijkstra 实现的链路状态算法和基于 Bellman-Ford 实现的距离矢量算法。

Dijkstra 算法

Dijkstra 算法是一个非常经典的求解单源最短路 (Single Source Shortest Path) 问题的算法,但它有一个巨大的限制:只能用于没有权重为负的边的图。

Dijkstra 算法是由荷兰计算机科学家 Edsger W. Dijkstra 在 1956 年提出的,用于求解带权有向图或无向图中单个源节点到所有其他节点的最短路径问题。该算法要求图中所有边的权值非负,这一条件保证了贪心策略的正确性。

算法核心思想

Dijkstra 算法采用贪心策略,逐步扩展已确定最短路径的节点集合。其核心步骤如下:

  1. 初始化:将源节点的距离设为 0,其他节点的距离设为无穷大。
  2. 选择节点:从未确定最短路径的节点中选择距离最小的节点。
  3. 松弛操作:对选中节点的所有邻接节点进行松弛操作,更新其距离值。
  4. 标记节点:将选中节点标记为已确定最短路径,不再处理。
  5. 重复步骤 2-4:直到所有节点都被标记或无法继续扩展。

算法关键点

  • 贪心策略:每次选择距离最小的节点,确保当前路径是最优的。
  • 优先队列优化:使用最小堆(优先队列)来高效选择最小距离节点,时间复杂度可优化至 O ((V+E) logV),其中 V 是节点数,E 是边数。
  • 非负权值要求:如果存在负权边,贪心策略将失效,需使用 Bellman-Ford 算法。

使用邻接表和优先队列的Dijkstra 算法实现:

import Foundation

// 图的节点类型
struct Node: Hashable {
    let id: String
}

// 边的结构:目标节点、边权
struct Edge {
    let to: Node
    let weight: Double
}

// Dijkstra 算法类
class Dijkstra {
    // 邻接表:节点 → 邻接边列表
    private var adjacencyList: [Node: [Edge]] = [:]
    
    // 初始化图(传入节点列表和边列表)
    init(nodes: [Node], edges: [(from: Node, to: Node, weight: Double)]) {
        for node in nodes {
            adjacencyList[node] = []
        }
        for edge in edges {
            adjacencyList[edge.from]?.append(Edge(to: edge.to, weight: edge.weight))
            // 若是无向图,需添加反向边(为有向图,如需无向图请取消注释)
            // adjacencyList[edge.to]?.append(Edge(to: edge.from, weight: edge.weight))
        }
    }
    
    // 计算单源最短路径
    func shortestPath(from start: Node) -> [Node: Double] {
        var dist = [Node: Double]()
        var visited = Set<Node>()
        let priorityQueue = PriorityQueue<Node>()
        
        // 初始化距离数组
        for node in adjacencyList.keys {
            dist[node] = Double.infinity
        }
        dist[start] = 0.0
        priorityQueue.enqueue((start, 0.0))
        
        while let (currentNode, currentDist) = priorityQueue.dequeue() {
            if visited.contains(currentNode) {
                continue // 已处理过,跳过
            }
            visited.insert(currentNode)

            // 遍历邻接边,更新距离
            for edge in adjacencyList[currentNode] ?? [] {
                let newDist = currentDist + edge.weight
                if newDist < dist[edge.to]! {
                    dist[edge.to] = newDist
                    priorityQueue.enqueue((edge.to, newDist))
                }
            }
        }
        return dist
    }
}

// 优先队列(最小堆)实现
class PriorityQueue<T> {
    private var elements: [(T, Double)] = []
    
    var isEmpty: Bool {
        return elements.isEmpty
    }
    
    func enqueue(_ element: (T, Double)) {
        elements.append(element)
        sortUp(from: elements.count - 1)
    }
    
    func dequeue() -> (T, Double)? {
        guard !isEmpty else { return nil }
        elements.swapAt(0, elements.count - 1)
        defer { sortDown(from: 0) }
        return elements.popLast()
    }
    
    private func sortUp(from index: Int) {
        var childIndex = index
        var parentIndex = (childIndex - 1) / 2
        while childIndex > 0 && elements[childIndex].1 < elements[parentIndex].1 {
            elements.swapAt(childIndex, parentIndex)
            childIndex = parentIndex
            parentIndex = (childIndex - 1) / 2
        }
    }
    
    private func sortDown(from index: Int) {
        var parentIndex = index
        while true {
            let leftChildIndex = 2 * parentIndex + 1
            let rightChildIndex = 2 * parentIndex + 2
            var smallestIndex = parentIndex
            
            if leftChildIndex < elements.count && elements[leftChildIndex].1 < elements[smallestIndex].1 {
                smallestIndex = leftChildIndex
            }
            if rightChildIndex < elements.count && elements[rightChildIndex].1 < elements[smallestIndex].1 {
                smallestIndex = rightChildIndex
            }
            if smallestIndex == parentIndex {
                break
            }
            elements.swapAt(parentIndex, smallestIndex)
            parentIndex = smallestIndex
        }
    }
}

// 使用示例
func main() {
    // 定义节点
    let A = Node(id: "A")
    let B = Node(id: "B")
    let C = Node(id: "C")
    let D = Node(id: "D")
    let E = Node(id: "E")
    
    // 定义边(有向图示例,边权非负)
    let edges = [
        (A, B, 2.0),
        (A, C, 3.0),
        (B, D, 3.0),
        (C, E, 4.0),
        (D, E, 1.0)
    ]
    
    // 初始化图
    let graph = Dijkstra(nodes: [A, B, C, D, E], edges: edges)
    
    // 计算从A出发的最短路径
    let dist = graph.shortestPath(from: A)
    
    // 输出结果
    for (node, distance) in dist {
        print("从 A 到 \(node.id) 的最短距离:\(distance)")
    }
}

输出:

A 到 D 的最短距离:5.0A 到 C 的最短距离:3.0AB 的最短距离:2.0AA 的最短距离:0.0A 到 E 的最短距离:6.0

Dijkstra 算法广泛应用于:

  • 网络路由协议(如 OSPF)中的最短路径计算。
  • 地图导航中的最优路线规划。
  • 推荐系统中的权重路径计算。

网络路由算法,核心就是在动态变化的网络中,基于探测和寻找最快传输路径的想法,帮助路由器建立路由表,让每个数据包都可以快速且正确地传播到正确目的地。首先我们需要想办法解决最短路的问题,Dijkstra 就是这样一种在没有负边的图中求解单源最短路的算法,基于贪心的思想,我们构造一颗最短路径树就可以求出从源点到网络中所有节点的最短路径了。核心的就是松弛操作,每次加入一个最短节点之后,我们还需要基于它去探索一遍和它相临的节点是否距离更短,比如从不可达变成可达,或者从一条更长的路变成一条更短的路。

选路算法

计算机网络很复杂,但核心就是把不同的节点连接在一起,交换信息、共享资源,每个节点自己会维护一张路由表,选路算法所做的事情就是:构建出一张路由表,选择出到目标节点成本最低通常也是最快的路径。

网络路由问题

路由器最大的作用就是转发决策,动态路由算法的作用就是,帮助路由节点在动态变化的网络环境下建立动态变化的路由表,而每个路由表记录,本质就是当前节点到目标节点的最短路径。

链路状态算法的思路就是:先在每个节点上都通过通信构建出网络全局信息,再利用 Dijkstra 算法,计算出在当前网络中从当前节点到每个其他节点的最短路,从而把下一跳记录在路由表中。

对于最短路问题,我们可以把网络抽象成一个有向图,也就是网络拓扑图。图中每个节点就是一台台路由设备,而节点之间的边的权重(边权)就代表着某种通信成本,我们一般叫链路成本,它有很多种定义方式,比如:

  • 网络通信时间,最常用的成本衡量标准,选出了最短路也就意味着选出了网络当前时刻下,从源节点到目标节点延时最低的数据传输路线。
  • 带宽或者链路负载,有时候也会作为成本的度量,带宽大负载低的路径成本就低,反之成本更高;在这种构建方式下,选出的是带宽比较充裕的路线,用户往往可以享受到更快的带宽速度。

我们会以网络通信时间也就是链路延时作为链路成本的策略来讨论,其他指标核心的问题解决思想是类似的。

为什么网络是一个有向图呢?道理很简单,我们以延时作为边权的场景为例,两个节点双向通信的速度很可能是不一样的,所以自然是一个单向图。

有了拓扑图的定义,我们如何在每个节点中都构建出这样一张带有整个网络信息的图呢?

在这个动态路由问题里,所有的节点其实都只是网络中的一部分,不同于静态路由的管理员直接有全局的上帝视角,动态路由下的每个节点能真正触达的信息,也只有和自己直接相邻的节点传来的 0 和 1。所以,要构建网络,自然也只有通过通信的方式了。

2.webp

在各种网络选路协议中,OSPF 协议采用的就是链路状态算法,它把链路状态信息的获取分成了 4 个主要步骤:发现节点、测量链路成本、封装链路状态包、发送链路状态包。

发现节点

节点想要获取全局的链路信息,显然只能通过和邻居间交换自己知道的信息,才有可能构建出全局的网络图。那第一步当然是要发现和自己相邻的所有节点,并在本地维护这个邻居信息。

发现节点具体怎么做呢?

其实也很简单,就是直接向网络广播一条hello消息,我们称为hello包。所有能直接收到这条消息的一定都是一跳的邻居。协议规定,收到这条消息的节点必须回应一条 Response 消息,告知自己是谁。

image.png

所以每个节点只要统计自己发出 hello 后收到的回应数量,就可以知道自己和哪几个节点相邻,也知道了它们的地址之类的信息,保存在本地就可以了。

测量链路成本

现在每个路由器都有了自己的邻居信息,接下来要做的就是衡量边权也就是链路成本。

每个节点想要衡量自己和邻居之间的传输成本,没什么别的办法,试一下就行了。

协议规定,每个节点向自己的邻居发送一个特殊的 echo 包,邻居收到之后,必须原封不动地把 echo 再返回给发出 echo 的节点,这样,每个节点只需要统计一下自己从发出 echo 到收到 echo 的时间差,就可以用它来估计和邻居之间的网络传输时延了,从而也就可以计算出链路状态算法所需要的链路成本了。

image.png

当然由于网络传输是不稳定的,我们会多次测量,取出均值,这样的时间我们有时也叫 RTT,round-trip-time。

如果你经常打游戏,可能会在测速工具或者游戏界面中看到过这个词,RTT 是最常见的用于衡量网络时延情况的指标,在许多系统里都会用到。

封装链路状态包

现在,每个路由器都知道自己到所有邻居节点的链路成本了。要让每个节点都能构建出整个网络图,显然需要让自己知道的信息尽快扩散出去,也尽快收集别人的信息来拼接出整个路由的拓扑图。

这就要求我们把每个节点已知的信息封装成一个数据包,然后在网络中广而告之。这个数据包我们就叫做链路状态包,

链路状态包中至少要包含几个字段呢?

首先是本机 ID,指出链路状态包的发送方,说明当前节点是谁;其次,我们前两步获得的已有链路信息当然也要写上,也就是找的到邻居列表和当前节点到每个邻居的链路成本(前面测出来的通信时延)。

另外我们知道网络是在时刻动态变化的,考虑到包的有效性问题,每个包不可能是永久有效的,过了一段时间之后就应该让这个包自动失效。所以还需要一项生存期,标记这个包中的成本记录有效的时间窗口。

除此之外,OSPF 协议还引入了一个关键字段:序号,标示当前状态包是发送方发出的第几个包。因为在网络中传输内容时,出于各种原因可能会产生错序的情况,这个序号就能帮助接收方衡量这个包是老的包还是新的包。其实,序号这种思想贯穿了计算机网络各个层次协议的设计,在许多应用场景下也会通过序号,帮助我们进行消息传递的排序或者去重。

在 OSPF 协议中 4 项内容是这样组织的,本机 ID、序号、生存期、邻居|成本,你可以看这张图:

image.png

发送链路状态包

有了链路状态包,那最后一个步骤自然是发送这些包。为了确保所有的包都能被可靠地传输到每个节点,避免出现各个节点路由构建不一致等问题,我们采用泛洪的方式进行传输。

泛洪,也是在计算机网络中常用的一种传播消息的机制,类似广播,每个节点都会把自己封装好的包和收到的包,发送或转发给所有除了该包发送方的节点。

这样,经过一小段时间的传播,每个节点就可以收到整个网络内所有其他节点的邻居信息,从而也就相当于有了一个拓扑图中邻接表的全部信息,自然就可以在内存中构建出一张完整的带有边权的有向图了。

image.png

计算路由

现在每个节点都有了这样一张有向图,每个节点自然就可以利用之前我们讲解的 Dijkstra 算法,在有向图中计算出自己到网络中任何其他所有节点的最短路径。

A到其他节点的最短路径如下:

A->B
A->B->D
A->B->D->E
A->B->D->E->F
A->C

以拓扑图中 A 节点到其他节点的最短路计算为例,我们可以很容易得到每个节点的路由表:

A到其他节点的最短路径可以得到A的路由表:

Destination Gateway  
    B          B
    C          C
    D          B
    E          B
    F          B

比如从 A 到 E 的最短路径是 A、B、D、E,那么在路由表中,只需要记录到 E 的下一跳是 B 就可以了。每个节点都进行类似的过程,数据包就可以在这些节点各自构建的路由表的基础上正确地传输了。

总的来说,OSPF 协议中的链路状态算法通过 以上4 步,先在每个节点上都通过通信构建出网络全局信息,再利用 Dijkstra 算法,计算出当前网络中从当前节点到每个其他节点的最短路,把下一跳记录在路由表中。

但到目前为止,我们还没有看到链路状态算法路由动态性的体现。

链路状态的动态性

链路状态算法之所以是动态路由算法,还有很重要一个点就是链路状态是可以根据网络的变化自动调整的。这就要涉及最后的一个知识点了:链路状态包是什么时候发送的?

链路状态发送主要有两个时机:

  • 一是我们会指定一个周期,让每个路由器都定时向外泛洪地发送链路状态包,比如 30s 一次。有点像心跳机制,如果长时间没有收到某个节点的链路状态包,这个节点随着之前的包中的生存期到期,就会被认为是失效节点,不会再被路由算法选作传输路线了。
  • 另一个就是当每次发生重大变化,比如节点上下线、网络情况变动等等,相关节点有可能的话也会主动向外快速扩散这些消息,让网络尽快得到动态的修正。

这样简单的策略是非常强大的,以链路延时为成本的链路状态算法甚至可以非常智能地避免网络的阻塞。

我们看个例子。构建网络拓扑图之后,t0 时刻,发现 A 和许多其他节点去往 H 的路由都是通过 G 转发最快,那这个时候,大量的信息都会发送到 G 路由节点中待转发。

image.png

但计算机网络中有个“拥塞”的情况,每个节点在单位时间里能处理的信息是有限的,剩余的信息转发就需要排队,总传输时间也就变得更长了。所以,当 G 节点处理的消息越来越多时,G 节点就很容易进入拥塞的状态,经过 G 转发的链路成本也都会飙升。

但是没有关系,我们的动态路由算法很快就会发现这件事情,G 自己就会更新到 H 的链路成本,比如从 4 变成 7,那再稍后的 t1 时刻,路由 A 到 H 的路由选择就从 AGH 变成了 ABEFH,不再经过 G 转发了。

image.png

总结

动态路由算法中基于 Dijkstra 算法的链路状态算法,核心思路就是通过节点间的通信,获得每个节点到邻居的链路成本信息,进而在每个节点里都各自独立地绘制出全局路由图,之后就可以基于 Dijkstra 算法构建出路由表了。

每个节点虽然有了全局的信息,但在路由表中我们依然只需要管好自己就行,只要每个节点都履行好自己的转发义务,数据包就可以正确有效地在动态变化的网络中传输了。

链路状态中为了解决不同的问题引入了许多手段。

比如,状态包通过周期性的和发生变化时的发送,可以让整个路由表动态地被更新、给包加序列号进行消息传递的排序或者去重,避免过期的信息因为延迟导致误更新、通过定期发送 echo 包统计来回时间,来测量网络时延监控网络情况等等。

这些思想在许多其他场景下也多有应用