Per connection classifier
MikroTik RouterOS 中的按连接分类器(PCC)使用源/目的地址和端口等 IP 头部字段将流量划分为多个流,从而将连接分布到不同链路上,并通过哈希实现负载均衡。
按连接分类器
PCC 匹配器允许你将流量划分为均等的流,并能够将具有特定选项集合的数据包保持在同一个流中(你可以从 src-address、src-port、dst-address、dst-port 指定这组选项)。
原理
PCC 从 IP 头部中选取指定字段,并借助哈希算法将所选字段转换为一个 32 位值。然后该值除以指定的 Denominator(分母),其余数再与指定的 Remainder(余数)进行比较,如果相等则捕获该数据包。你可以从头部中选择 src-address、dst-address、src-port、dst-port 用于此操作。
危险
在 Hotspot(强制门户)的情况下,PCC 不是一种有效的方法 —— 这是由于目前 Hotspot 使用 web-proxy,并且只使用默认路由表。
per-connection-classifier=
PerConnectionClassifier ::= [!]ValuesToHash:Denominator/Remainder
Remainder ::= 0..4294967295 (integer number)
Denominator ::= 1..4294967295 (integer number)
ValuesToHash ::= both-addresses|both-ports|dst-address-and-port|
src-address|src-port|both-addresses-and-ports|dst-address|dst-port|src-address-and-port
示例
此配置将根据源地址和端口将所有连接划分为 3 组
/ip/firewall/mangle/add chain=prerouting action=mark-connection \
new-connection-mark=1st_conn per-connection-classifier=src-address-and-port:3/0
/ip/firewall/mangle/add chain=prerouting action=mark-connection \
new-connection-mark=2nd_conn per-connection-classifier=src-address-and-port:3/1
/ip/firewall/mangle/add chain=prerouting action=mark-connection \
new-connection-mark=3rd_conn per-connection-classifier=src-address-and-port:3/2
PCC 的工作原理
本文旨在用简单的语言解释 PCC 的工作原理。官方手册 wiki 页面中的定义如下:"PCC 从 IP 头部中选取指定字段,并借助哈希算法将所选字段转换为一个 32 位值。然后该值除以指定的 Denominator,其余数再与指定的 Remainder 进行比较,如果相等则捕获该数据包。你可以从头部中选择 src-address、dst-address、src-port、dst-port 用于此操作。"完整的可用字段为:"both-addresses|both-ports|dst-address-and-port|src-address|src-port|both-addresses-and-ports|dst-address|dst-port|src-address-and-port"。如果你能理解该定义,那么本文对你来说就没有什么新内容了。
首先,这里是理解该定义所需的术语。
IP 数据包有一个包含多个字段的头部。其中两个字段是数据包的源 IP 地址和目的 IP 地址。TCP 和 UDP 数据包也有包含源端口和目的端口的头部。
分母和余数是取模运算的组成部分。取模运算求的是两个数相除后留下的整数余数,并且只接受结果的整数部分。它用 % 符号表示。以下是一些示例:3 % 3 = 0,因为 3 能被 3 整除。4 % 3 = 1,因为在 4 之前能被 3 整除的下一个最小数是 3,而 4 - 3 = 1。5 % 3 = 2,因为在 5 之前能被 3 整除的下一个最小数是 3,而 5 - 3 = 2。6 % 3 = 0,因为 6 能被 3 整除。
哈希是一种接收输入并产生输出的函数。哈希有许多有趣的特性,但就本文而言,唯一重要的特性是哈希函数是确定性的。这意味着当你向哈希函数输入 'hello' 并得到输出 '1' 时,你可以确信如果再次输入 'hello',它仍会产生输出 '1'。当你向哈希函数输入相同的输入时,它总是产生相同的输出。PCC 具体使用哪种哈希算法并不重要,因此在本讨论中,我们假设当你输入 IP 地址和端口时,它只是将 IP 地址的各个八位组以及端口按十进制数相加,然后取最后一位数字作为输出。以下是一个示例:
哈希函数被输入 1.1.1.1 作为源 IP 地址、10000 作为源 TCP 端口、2.2.2.2 作为目的 IP 地址、80 作为目的 TCP 端口。输出将是 1+1+1+1+10000+2+2+2+2+80 = 10092。其最后一位是 2,因此哈希输出为 2。每次输入这一组 IP 地址和端口时,它都会产生 2。
此时需要注意的是,尽管 PCC 最常用于在多个线路之间分摊负载,但 PCC 本身与路由、路由标记或负载分摊完全无关。PCC 只是一种匹配数据包的方式,并不直接关系到随后标记这些匹配数据包的动作,即使那正是它的主要用途。
以下是常用于 PCC 的三行配置及其说明:
/ip/firewall/mangle/add chain=prerouting action=mark-connection \
new-connection-mark=1st_conn per-connection-classifier=src-address-and-port:3/0
/ip/firewall/mangle/add chain=prerouting action=mark-connection \
new-connection-mark=2nd_conn per-connection-classifier=src-address-and-port:3/1
/ip/firewall/mangle/add chain=prerouting action=mark-connection \
new-connection-mark=3rd_conn per-connection-classifier=src-address-and-port:3/2
以下是在数据包匹配方面各字段选项的含义,这些字段将被输入到哈希算法中(并且就跨链路分摊负载而言,决定数据包将被放到哪条链路上)。请记住,哈希函数在输入相同时总是产生相同的输出:
- src-address:客户端的源地址始终相同,因此来自某个特定客户端的所有流量将始终匹配同一个 PCC 匹配器,并始终被放到同一条链路上。
- dst-address:特定服务器的目的地址始终相同,因此发往该服务器的所有流量(例如 Mikrotik Wiki)将始终匹配同一个 PCC 匹配器,并始终被放到同一条链路上。
- both-addresses:同一客户端与服务器之间的源和目的 IP 对始终相同,因此某个特定客户端与特定服务器之间的所有流量(例如你的笔记本电脑和 Mikrotik Wiki)将始终匹配同一个 PCC 匹配器,并始终被放到同一条链路上。
- src-port:客户端的源端口通常在创建连接时随机选择,因此在许多连接中,不同的源端口会被输入哈希函数,从而匹配不同的 PCC 匹配器,流量会走不同的链路。然而,某些客户端协议总是选择相同的源端口,而你路由器后面的服务器也很可能总是使用相同的服务端口向客户端发送返回流量。你路由器后面的 Web 服务器会从其 HTTP(80)和 HTTPS(443)端口发送大部分流量,而这些流量将始终匹配同一个 PCC 匹配器,并被放到同一条链路上。
- dst-port:客户端的目的端口通常是定义明确的服务端口,你的客户端与互联网上服务器之间的所有 HTTP(80)流量将始终匹配同一个 PCC 匹配器,并被放到同一条链路上。然而,同样的客户端进行 HTTPS(443)流量时可能匹配不同的 PCC 匹配器,并走不同的链路。
- both-ports:由于客户端端口(通常)是随机选择的,两个端口的组合(通常)是随机的,从而将负载分摊到不同链路上。
- src-address-and-port:与 src-port 有相同的注意事项。
- dst-address-and-port:与 dst-port 有相同的注意事项。
- both-addresses-and-ports:这是将流量分摊到链路的最随机方式,因为它包含的变量最多。
需要注意的是,尽管本文讨论的哈希函数被大大简化,并非实际使用的函数,但它很好地展示了哈希函数的另一个特性:两个完全不同的输入可以产生相同的输出。在我们的示例中,3 % 3 = 0,6 % 3 = 0;输入 3 和输入 6 都会得到 0。PCC 实际使用的函数也是如此,尽管我不知道它具体是什么,但从定义中我们确实知道它会输出一个 32 位值。IP 地址是 32 位,端口是 16 位,因此假设我们使用 both-addresses-and-ports,我们将输入 32+32+16+16 = 96 位的输入,却只收到 32 位输出,所以它必然会对不同输入产生相同输出。这意味着两个完全不相关的连接可能匹配同一个 PCC 匹配器,并被放到同一条链路上。经过 PCC 的连接越多,哈希函数就有越多机会产生不同输出,PCC 的效果也就越好。
配置示例
假设有如下配置:

IP 地址
/ip/address
add address=10.10.4.100/24 interface=ether_ISP1 network=10.10.4.0
add address=10.10.5.100/24 interface=ether_ISP2 network=10.10.5.0
add address=192.168.100.1/24 interface=ether_LAN network=192.168.100.0
路由器有两个上游(ISP)接口,地址分别为 10.10.4.100/24 和 10.10.5.100/24。LAN 接口的 IP 地址为 192.168.100.1/24。
我们添加两个新的路由表,稍后将使用它们:
/routing/table
add disabled=no fib name=ISP1_table
add disabled=no fib name=ISP2_table
策略路由
/ip/firewall/mangle
add action=accept chain=prerouting dst-address=10.10.4.0/24 in-interface=ether_LAN
add action=accept chain=prerouting dst-address=10.10.5.0/24 in-interface=ether_LAN
通过策略路由,可以将所有流量强制发往特定网关,即使流量是发往已连接网络中的主机(而非该网关)。这样会产生路由环路,导致无法与这些主机通信。为避免这种情况,我们需要允许发往已连接网络的流量使用默认路由表。
add action=mark-connection chain=input connection-state=new in-interface=ether_ISP1 new-connection-mark=ISP1
add action=mark-connection chain=input connection-state=new in-interface=ether_ISP2 new-connection-mark=ISP2
add action=mark-connection chain=output connection-mark=no-mark connection-state=new new-connection-mark=ISP1 per-connection-classifier=both-addresses:2/0
add action=mark-connection chain=output connection-mark=no-mark connection-state=new new-connection-mark=ISP2 per-connection-classifier=both-addresses:2/1
首先必须处理从外部发起的连接 —— 回复必须从请求进入的同一接口(同一公网 IP)发出。我们将标记所有新的传入连接,以记住当时是哪个接口。
add action=mark-connection chain=prerouting connection-mark=no-mark connection-state=new dst-address-type=!local in-interface=ether_LAN new-connection-mark=ISP1 per-connection-classifier=both-addresses:2/0
add action=mark-connection chain=prerouting connection-mark=no-mark connection-state=new dst-address-type=!local in-interface=ether_LAN new-connection-mark=ISP2 per-connection-classifier=both-addresses:2/1
mark-routing 动作只能在 mangle 链的 output 和 prerouting 中使用,但 mangle 链 prerouting 会捕获所有发往路由器本身的流量。为避免这一点,我们将使用 dst-address-type=!local。借助新的 PCC,我们将根据源地址和目的地址把流量划分为两组。
add action=mark-routing chain=output connection-mark=ISP1 new-routing-mark=ISP1_table
add action=mark-routing chain=prerouting connection-mark=ISP1 in-interface=ether_LAN new-routing-mark=ISP1_table
add action=mark-routing chain=output connection-mark=ISP2 new-routing-mark=ISP2_table
add action=mark-routing chain=prerouting connection-mark=ISP2 in-interface=ether_LAN new-routing-mark=ISP2_table
然后我们需要为来自这些连接的所有数据包打上相应的标记。由于只有发往互联网的流量才需要策略路由,请不要忘记指定 in-interface 选项。
/ip/route
add check-gateway=ping disabled=no dst-address=0.0.0.0/0 gateway=10.10.4.1 routing-table=ISP1_table suppress-hw-offload=no
add check-gateway=ping disabled=no dst-address=0.0.0.0/0 gateway=10.10.5.1 routing-table=ISP2_table suppress-hw-offload=no
为每个 routing-mark 创建一条路由
add distance=1 dst-address=0.0.0.0/0 gateway=10.10.4.1
add distance=2 dst-address=0.0.0.0/0 gateway=10.10.5.1
要启用故障转移,必须有这样的路由:当网关故障导致其他路由变为非活动状态时,它们能立即顶替上来。(而这只有在 check-gateway 选项处于活动状态时才会发生。)
NAT
/ip/firewall/nat
add action=masquerade chain=srcnat out-interface=ether_ISP1
add action=masquerade chain=srcnat out-interface=ether_ISP2
由于路由决策已经做出,我们只需要为所有出站数据包修正 src-address 的规则。如果该数据包经由 ether_ISP1 离开,它将被 NAT 为 10.10.4.100;如果经由 ether_ISP2 离开,则被 NAT 为 10.10.5.100