数据的蒲公英——组播

image-20241215164454717

组播与单播

组播(Multicast,又称群播、多播) 与单播 (Unicast) 都是数据传输的一种方式,如果要映射现实世界中的信息传递方式,单播可以类比为打电话,有人呼出电话然后有人接听,然后他们互相畅谈最后互道再见;而组播则更加类似于广播,电台的主播会在收音机的那一头说话,所有打开收音机的人都可以收到,你也可以随时关闭收音机,只能单方面的听到来自对方的声音。

虽然今天到处都是打电话的人,而我们已经很少能在身边看到广播电台的身影,但是 “广播” 这种形式仍在某些领域大放异彩。

试想一下,这么一个场景,你有一份数据,要发送给十个 IP,利用常规的 TCP/IP 协议栈知识我可以简单的写出一段代码,依次连接每一个 IP,然后发送数据,这样就可以实现数据的传递,这种方式就叫单播。

但是如果我们把接收方的数量乘上 10 、100 甚至是 100000 呢? 那这个循环可真是太吓人了,我要是能发送一份数据能让所有的接收方都能收到,那该多好啊!

组播的诞生就源自这种朴素的想法,要是数据只用发一份就好了,组播是一种一对多的通信方式,它可以让一个发送方同时向多个接收方发送数据,而不需要发送方和接收方之间建立多个连接。

组播的传输模型具体实现的时候,也还是要回归到基于 OSI 七层计算机网络模型来的,理论上我们可以在任何一层实现组播, 只需要在这一层的协议头部中加上特殊的标记,让网络链路的中间设备能识别并复制该数据包,然后转发即可:

  • 在数据链路层,我们可以通过在以太网帧中加入特殊字段或者特殊的 MAC 地址来实现组播,这种方式叫做以太网组播;
  • 在网络层,我们可以通过特殊的 IP 地址标记来实现组播,这种方式叫做 IP 组播;
  • 在传输层,我们可以通过端口号来实现组播,这种方式叫做端口组播;
  • 在应用层,自由度就更大了,我们可以为所欲为的按照自己的喜好来实现一个应用层协议模拟组播,这种方式叫做应用层组播;

不过在实际应用中,IP 组播几乎就是组播的代名词,应用范围最为广泛,本文要也主要讨论 IP 组播相关内容。

组播的应用场景

目前互联网的使用场景非常广泛,从即时通信、长短视频、游戏娱乐、电商购物到在线办公、教育学习、金融支付、出行服务等, 然而这些场景跟组播都没什么关系。

即使是我们想当然的看起来很适合组播这种一对多模型的直播、视频会议、在线教育等场景,也都是通过 RTMPWebRTCSRTHTTP-HLSHTTP-FLVDASH 等基于单播的协议来实现的。

组播的传输模型相对复杂,需要节点中的每一台路由器深度参与和支持,哪怕只有 0.01% 的节点不支持导致用户无法正常观看直播,也足以在技术决策的时候被判死刑,另外组播的可靠性和安全性也相对较差,这也是很多应用不愿意选择组播的原因。

虽然互联网是混不下去了,组播仍然在局域网某些特定的场景下发挥着重要的作用,比如:

  • IPTV: 例如中国移动和中国电信等 ISP 的 IPTV 服务就是基于组播分发的,另外很多电视台也都有提供自己的组播源进行 IPTV 分发,理论上只要拿到组播地址就能直接播放,很多盗版的组播盒子类产品也是基于这个原理;
  • 金融行情:金融行情的分发是一个典型的一对多,且数据量通常极大,要求低延迟的场景,组播是一个非常好的选择; 芝商所(CME)、纳斯达克以及中国的股票和期货交易所等都在使用组播将进行高性能行情(例如买卖价格和订单簿信息)分发;
  • Multicast DNS: 用于局域网内的设备发现和服务发现,比如你在局域网内搜索打印机、共享文件夹等,这些都是基于组播的
  • 卫星通信:卫星通信带宽极其昂贵,不管有什么缺点只要能节省带宽就是好的,组播通信在卫星通信中有着广泛的应用

单播的传输

首先让我们回顾一下单播的传输链路,现在我们有一个发送方 A 和一个接收方 BA 有一份数据要发送给 B,那么这个过程是怎么样的呢?

首先 A 需要确认 B 的 IP 地址,可能是口耳相传的也有可能是 DNS 查询的,总而言之,数据有了方向,才能启航。

  1. A 自上而下(应用层、传输层、网络层)封装好网络包
  2. A 进行路由决策,看是否在同一子网,否则查询路由表送往下一跳(大概率是默认网关)
  3. ARP 查询下一跳 MAC 地址,然后发送
  4. 假设下一跳是路由器,路由器收到数据包后,进行路由表查询,然后转发
  5. 路由器们重复这个操作
  6. 网络包顺着光纤进入 B 的网卡,进行各种校验之后,就成功抵达

这是已有的单播的传输链路,那么组播的传输链路又是怎样的呢?

组播的传输

组播会复用单播的传输链路,但是具体又肯定有一些地方会不一样。

组播如何与单播区分

第一个问题是,组播的数据包如何与单播的数据包区分开来呢?

特殊的 IP 地址

上文已经提及了,本文讨论的是基于 IP 层的组播,因此区分的关键就在于 IP 地址本身。

IPv4 协议确立之初,IANA 将 2^32 四十亿个 IPv4 地址被分为了 A B C 三类地址,这三类地址我们应该都相当熟悉:

A 0xxxxxxx.xxxxxxxx.xxxxxxxx.xxxxxxxx 大型网络
B 10xxxxxx.xxxxxxxx.xxxxxxxx.xxxxxxxx 中型网络
C 110xxxxx.xxxxxxxx.xxxxxxxx.xxxxxxxx 小型网络

大中小全覆盖都有了,够了,那就先这样。

而组播是后面才诞生的,不过好在一开始地址划分的时候还留了一点,还有一个 D 类和 E 类地址是保留的,不然 IP 地址就像泼出去的水,一旦分发就再也收不回来。

要是没有预留,那可能后面诞生的组播就只能另辟蹊径了,比如在传输层做文章,或者不得不在应用层实现,这样的话效率和复杂度都会大大增加。

幸运的是,D 类地址正好尚未被占领,可以用来做组播:

D 1110xxxx.xxxxxxxx.xxxxxxxx.xxxxxxxx 组播地址
(224.0.0.0/4 => 224.0.0.0 到 239.255.255.255)

另外一直到今天,E 类地址也仍未正式启用,不过说实话,它可能永远也不会被启用了。

随着 IPv6 的普及,(2^128)海量的地址空间足以满足未来几十年甚至上百年的需求,根本不需要在 IPv4 上动什么心思了。

image-20241127233459148

D 类地址(组播地址)虽然看起来很大,但也不是随意使用的。

为了更好地管理和使用这些地址,IANA对其进行了仔细的划分(在 RFC 1112 “Host Extensions for IP Multicasting” 中定义):

224.0.0.0-224.0.0.255:局域网组播地址,专门用于网络协议(如OSPF、RIP),不会跨路由器转发
224.0.1.0-238.255.255.255:全球范围组播地址,用于互联网范围的应用(如流媒体、视频会议),但是这一部分应用并不广泛
239.0.0.0-239.255.255.255:管理权限范围组播地址,类似私有IP,供组织内部使用,这是最常见的地址了

数据链路层的配合

仅有 IP 层的标识还不够,为了让组播在整个网络中顺利传输,数据链路层也需要进行相应的调整。

在以太网中,组播 IP 地址会被映射到特殊的 MAC 地址,这个 MAC 地址的前 24 位固定为 01-00-5E,后 23 位则由组播 IP 地址的后 23 位映射得到。

这种映射关系让网络设备能在最底层就识别出组播流量,从而进行特殊处理,就像一个特殊的开门暗号。

传输层协议的选择

为了复用当前的网络架构,链路层和网络层我们没有其他选择,但是到了传输层这里,候选人就不再是一位了。

但是只需权衡一下两位候选人的特性,答案就呼之欲出了。

组播这种通信模式,注定只能选择 UDP 这样轻量级的协议,TCP 面向连接的特性与组播的一对多模式存在根本性冲突。

传输层协议的副产品

对于当前网络架构的复用,让组播系统不必从零开始构建完整的协议栈。然而,这种复用也带来了一些本可以避免的设计。 端口号就是这样一个”副产品” —— IP 地址本身就足以标识一个组播组,D 类地址库已经足够庞大,端口号并非组播通信所必需,却因为选择了 UDP 协议而自然成为了组播系统的一部分。

好吧,既然在这里了那就物尽其用吧。

组播应用程序像 TCP 单播应用程序一样,也可以通过同样的地址,不同的端口号来提供不同服务,虽然我还是实在觉得这个端口号有些多余。

组播路由:不止是转发

很好,我们现在生成要发送的数据之后,拼上 UDP 头置入目标地址和端口(随便想一个),再拼上 IP 头,定制好 MAC 头部,找张网卡扔出就好了。 现在问题来到路由器这边了,这包你给我,我该给谁呢? 2 处理传统的单播路由问题时,只需要通过 BGP、OSPF 发现出整个网络的路由表,有单播包到来时根据目标地址查询路由表发往对应的端口即可。

但是在组播场景下,目标可能是非常多个对这个数据包感兴趣的人。

另外整个网络中肯定不是所有人都会对我们的数据感兴趣,如果将数据包传遍网络的每一个节点,那就不叫组播,而叫广播了,粗暴的挨家挨户敲门问无疑会在网络中掀起一场非常可怕的风暴。

这就需要一种全新的协议机制。

IGMP

工作流程

首先介绍的是 IGMP (Internet Group Management Protocol) 协议,它是组播通信中用于管理组成员关系的关键协议。 当我们说”管理组成员关系”时,指的是让路由器知道自己连接的网段上有哪些主机对哪些组播组感兴趣。

它的工作流程非常简单:

  • 主机可以告诉路由器我要加入某个组
  • 主机也可以告诉路由器我要离开某个组
  • 路由器还会定时询问网段上的主机,你们还对某个组感兴趣吗?

这样路由器在收到组播数据包时,就可知道数据包都要转发一份给谁了。

协议报文

IGMP 是一个网络层协议,协议具体在实现上有三代版本:

V1 版本:最早的版本,只支持主机加入组播组,只能通过路由器定时轮询来检测主机离开
V2 版本:增加了主动离开机制(Leave),成为当前最常用的版本
V3 版本:增加了源地址过滤功能,可以指定接收或拒绝特定源的组播,但报文更复杂

这里我们主要目前应用最为广泛的 IGMPv2:

                       IGMP 报文格式 (IGMPv2)
+-----------+-----------+----------------------+---------------------------------+
|    类型    | 最长响应时间|         校验和        |           组播地址               |
|    (8)    |     (8)   |         (16)         |            (32)                 |
+-----------+-----------+----------------------+----------------------------------+
  • 类型:报文类型,有 5 种类型,包括查询、报告、离开等
    • 0x11:成员关系查询(Membership Query)
    • 0x12:IGMPv1 成员关系报告
    • 0x16:IGMPv2 成员关系报告
    • 0x17:离开组(Leave Group)
  • 最长响应时间:查询报文中的最长响应时间字段,单位为 110
  • 校验和:校验和字段,用于校验报文的完整性
  • 组播地址:组播地址字段,用于指定组播组的地址,也可以是 0.0.0.0 表示通用组播地址

拥塞控制

IGMP 协议需要处理大量主机同时加入或响应查询的场景。

为了避免产生报文风暴,协议设计了两种主要的拥塞控制机制:

  1. 随机延迟响应,主机收到查询后不会立即回复,在 0 到”最长响应时间”间随机选择延迟, 错开响应时间,避免所有主机同时发送报告, 这也是报文第二个字段的作用 Maximum Response Time 的设计目的。

  2. 报告抑制 ,如果等待期间听到其他主机的相同组播组报告,则取消自己的报告发送 减少重复报文

IGMP Snooping

IGMP 可以让路由器感知到哪些主机对哪些组播组感兴趣,路由器是知道了,但是交换机呢?

当主机是通过交换机连接到路由器的时候,路由器转发过来的包到达交换机时, 交换机虽然可以根据 MAC 地址规则识别这是一个特殊的组播包,但是它并不知道哪些机器订阅了这个组播组,只能盲目的广播。 这样就会导致交换机上的广播风暴,所有的机器都会收到这个组播包,这显然是不合理的。

IGMP Snooping 就是为了解决这个问题而生的,它是一种交换机的功能, 可以监听 IGMP 报文,从而知道哪些端口上的主机对哪些组播组感兴趣,

IGMP Snooping 技术通过”窥探”网络中的 IGMP 报文来解决这个问题。

当启用了 IGMP Snooping 的交换机看到主机发送的 IGMP Report(加入组播组)或 Leave(离开组播组)消息时,会记录下组播组与端口的对应关系。这样,当组播数据到达时,交换机就能只将数据转发给那些真正需要的端口,而不是盲目地向所有端口转发。

这种优化极大地提升了网络效率,减少了不必要的带宽占用。而且它只需要在交换机上启用,不需要终端设备做任何改动,是一个对网络十分友好的优化手段。

PIM

很好我们现在已经可以构建一个完整的小型组播网络了, 但是我们仅仅只解决了第一公里和最后一公里的问题,如果我们的数据包需要跨越多个路由器,中间的路由器怎么办呢,数据该怎么走呢?

我们需要一种额外的路由协议来帮助我们的数据包合理规划路径,有许多协议尝试解决这个问题,例如 DVMRP、MOSPF、CBT 等, 而其中最为著名的就是 PIM 协议。

报文

PIM (Protocol Independent Multicast) 是基于 IP 协议之上的一种网络层路由协议,用于在不同的组播路由器之间传递组播数据包,* *注意,这是一个路由器之间的协议**,和单播路由协议 BGP 一样,协议的发送者和接收者都是路由器。

协议报文格式如下:

                            PIM 报文格式
+--------+--------+----------+----------------------------------------+
| 版本(4) | 类型(4)| 保留字段(8)|                校验和(16)              |
+--------+--------+----------+----------------------------------------+
|                                                                     |
|                        类型特定的负载部分                              |
|                                                                     |
+---------------------------------------------------------------------

类型如下:

  • 0x0 = Hello - 邻居发现与维护
  • 0x3 = Join/Prune - 加入/剪枝请求
  • 0x4 = Bootstrap - RP 选举信息
  • 0x5 = Assert - 断言消息
  • 0x6 = Register - 源注册消息
  • 0x7 = Register-Stop - 停止注册

每种不同的类型会有各自不同的负载,这个格式并不固定。

这些不同类型的报文共同配合,帮助我们解决组播路由中的各种问题。

比如 Hello 报文用于发现和维护邻居关系,Join/Prune 报文用于构建和维护组播分发树,而 Register 相关报文则用于源注册过程。

PIM 面临的本质上是一个“双盲”问题,发送者不知道接收者在哪里,接收者也不知道发送者在哪里。

为了解决这个问题 PIM 协议具体有两种思路,遍历查找与中间人协商:

DM: 遍历查找

第一种思路是遍历整个网络,把所有人都问一遍, 这样我们肯定能知道谁订阅了,然后再慢慢修剪没有订阅的节点避免不必要的传输,这种方式称为 Dense Mode(DM)。

具体实现逻辑是这样的:

  1. 首先这种模式会假设网络中每个节点都对数据包感兴趣,然后通过洪泛的方式将数据包传递到整个网络中
  2. 路由器如果下游没有任何组成员,就向上游发送剪枝 (0x3 Prune) 报文,让上游不再往自己的端口转发,分为两种组成员:
    1. 直接成员:查询维护的 IGMP 记录即可得知
    2. 间接成员:依赖下游路由器是否发送了剪枝报文来判断
  3. 这样就可以逐渐剪枝,最终只有订阅的主机才会收到数据包
  4. 如果中途有主机加入组,可以再发送嫁接报文加入
  5. 如果中途有主机加入组,可以再发送嫁接 (0x3 Join) 报文加入
  6. 剪枝状态是有生存期的,超时后会重新泛洪,以便及时发现新加入的成员

这种方式适合小型网络或者组播组成员比较密集的场景,如果组成员比较少的话,会浪费很多带宽在泛洪和剪枝上。

SM: 中间人协商

第二种思路是指定一个中间人,大家都告诉它自己的需求,它来负责协调,这种方式称为 Sparse Mode(SM)。

这个中间人在 PIM 中被称为汇聚点(Rendezvous Point,简称 RP),它的主要职责是:

  • 接收源的注册 (0x6 Register)
  • 接收接收者的加入请求 (0x3 Join)
  • 建立并维护从源到接收者的转发树

这种方式下:

  1. 源端先向 RP 注册,告知自己要发送数据
  2. 接收者通过显式加入的方式向 RP 表明自己的订阅意愿
  3. RP 负责在源和接收者之间建立转发路径

这种方式避免了泛洪带来的开销,更适合组播组成员分布稀疏的大型网络。 实际应用场景中,PIM-SM 是主流的模式选择。 虽然需要维护 RP 这个特殊节点增加了一些复杂性,但它能更好地控制网络资源的使用,

SM 还有两种同样思路的变种模式:

  • SSM: Source Specific Multicast,源特定组播,源端直接扮演中间人的角色,需要接收端事先知道源端的地址
  • BIDIR-PIM: Bidirectional PIM,双向 PIM,适用于多对多的组播场景,源端和接收端都可以同时发送和接收数据

BSR: 自动中间人

另外 在 PIM-SM 中,由于采用了中间人协商的方式,所有路由器都需要知道 RP 的位置信息。 为了避免手工配置 RP 信息带来的维护困难,PIM 引入了 BSR (Bootstrap Router) 机制来自动分发 RP 信息。

工作过程如下:

网络中可以配置多个候选 BSR (C-BSR),它们之间通过选举(0x4 Bootstrap)确定一个 BSR
候选 RP (C-RP) 向 BSR 发送广告,宣告自己可以作为 RP
BSR 收集所有 C-RP 的信息,周期性地向整个网络发送 Bootstrap 报文
所有路由器接收 Bootstrap 报文后,就能知道有哪些 RP 可用

BSR 机制使得 PIM-SM 的部署和维护更加方便,支持自动发现和切换 RP,提高了网络的可靠性和可扩展性。

PIM Snooping

与 IGMP Snooping 类似,PIM Snooping 也是二层交换机的一种优化机制,区别在于它是通过监听 PIM 协议报文(而不是 IGMP 报文)来确定哪些端口需要组播数据,从而实现更精确的组播转发。

组播安全

组播系统本身并不是一个安全的系统,因为它的设计初衷是为了提供一种高效的数据传输方式,而并没有考虑安全性。

但是在实际应用中,这是一个不容忽视的问题,理论上一个网络中任何一台主机都可以接收甚至是发送组播数据包,它只需要在 UDP 包中填入对应的地址即可,这就给网络安全带来了很大的风险。

我们通常可以通过以下几种方式来提高组播的安全性:

  • IPSec: 在 IP 层之上再套一层 IPSec 传输协议来对数据进行加密防护
  • VLAN or VPN:将组播流量隔离到一个独立、可控网络中
  • ACL:在路由器上配置访问控制列表(ACL)限制哪些主机可以发送组播数据包
  • 应用层校验、加密、或者判断组播数据包源地址

组播的发送与接收

使用组播网络其实是一件非常简单的事情,IGMPPIM 等这些都是组播网络的提供者需要去考虑的问题,而作为一名使用者,在使用方式上而言, 接发组播和接发 UDP 单播几乎没什么区别

这里我们用通过代码来演示组播的发送与接收过程,我们将会使用 C 语言来实现,C 的接口和系统调用相对较为底层,更有利于我们理解网络协议的细节。

发送

发送者会定时发送 UDP 组播包, sender.c:

#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define MCAST_GRP "224.1.1.1"// 组播地址( D类 IP 224.0.0.0 - 239.255.255.255 是组播地址范围)
#define MCAST_PORT 5000      // 组播使用的 UDP 端口号

int main() {
    int sock;
    struct sockaddr_in multicast_addr;
    char *message = "Hello, Multicast!";

    // 创建 UDP 套接字,组播是基于 UDP 协议传输的
    if ((sock = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
        perror("Socket creation failed");
        exit(1);
    }

    // 设置 TTL 值,确保组播包不会超出本地网络
    int ttl = 2;
    if (setsockopt(sock, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl)) < 0) {
        perror("Setting TTL option failed");
        close(sock);
        exit(1);
    }

    // 配置组播地址
    memset(&multicast_addr, 0, sizeof(multicast_addr));
    multicast_addr.sin_family = AF_INET;
    multicast_addr.sin_addr.s_addr = inet_addr(MCAST_GRP);
    multicast_addr.sin_port = htons(MCAST_PORT);

    printf("Sending multicast messages to %s:%d...\n", MCAST_GRP, MCAST_PORT);

    while (1) {
        // 每隔 2 秒发送一次,发送消息到组播地址,就是调用的很普通的 socket 函数
        // 和发送普通 UDP 包就只有地址的区别
        if (sendto(sock, message, strlen(message), 0, (struct sockaddr *) &multicast_addr, sizeof(multicast_addr)) < 0) {
            perror("Failed to send message");
        } else {
            printf("Sent: %s\n", message);
        }
        sleep(2);
    }

    close(sock);
    return 0;
}

接收

接收者会监听组播地址,接收到数据包后打印出来, receiver.c:

#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define MCAST_GRP "224.1.1.1"// 组播地址
#define MCAST_PORT 5000      // 组播端口
#define BUFFER_SIZE 1024     // 缓冲区大小

int main() {
    int sock;
    struct sockaddr_in local_addr;
    struct ip_mreq mreq;
    char buffer[BUFFER_SIZE];

    // 创建 UDP 套接字
    if ((sock = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
        perror("Socket creation failed");
        exit(1);
    }

    // 绑定本地地址和端口
    memset(&local_addr, 0, sizeof(local_addr));
    local_addr.sin_family = AF_INET;
    local_addr.sin_addr.s_addr = htonl(INADDR_ANY);// 接收所有本地接口的数据
    local_addr.sin_port = htons(MCAST_PORT);

    if (bind(sock, (struct sockaddr *) &local_addr, sizeof(local_addr)) < 0) {
        perror("Binding failed");
        close(sock);
        exit(1);
    }

    // 加入组播组
    mreq.imr_multiaddr.s_addr = inet_addr(MCAST_GRP);// 组播地址
    mreq.imr_interface.s_addr = htonl(INADDR_ANY);   // 本地接口
    if (setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) {
        perror("Joining multicast group failed");
        close(sock);
        exit(1);
    }

    printf("Listening for multicast messages on %s:%d...\n", MCAST_GRP, MCAST_PORT);

    while (1) {
        // 接收组播消息
        int nbytes = recvfrom(sock, buffer, BUFFER_SIZE, 0, NULL, NULL);
        if (nbytes < 0) {
            perror("Receive failed");
            break;
        } else {
            buffer[nbytes] = '\0';// 确保字符串以 '\0' 结尾
            printf("Received: %s\n", buffer);
        }
    }

    // 离开组播组
    if (setsockopt(sock, IPPROTO_IP, IP_DROP_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) {
        perror("Dropping multicast group failed");
    }

    close(sock);
    return 0;
}

Run

首先将上面两个程序编译:

gcc sender.c -o sender
gcc receiver.c -o receiver

然后我们创建三个容器,模拟三个网络节点,分别运行 sender 和两个 receiver:

docker network create --driver bridge multicast-net
docker run -it --rm --network host --name=sender -v .:/root  ubuntu bash
docker run -it --rm --network host --name=receiver1 -v .:/root  ubuntu bash
docker run -it --rm --network host --name=receiver2 -v .:/root  ubuntu bash

image-20241215001942029

这样一个简单的组播网络就搭建好了,sender 会定时发送组播消息,receiver1 和 receiver2 会接收到这个消息。

总结

组播允许数据包同时发送给特定的一组接收者。

它使用特殊的D类IP地址(224.0.0.0到239.255.255.255)来标识组播组,通过 UDP 协议传输数据。

为了实现组播通信,网络中采用了 IGMP 协议来管理主机与路由器间的组成员关系,同时使用 PIM 协议来处理组播数据的路由转发。

未命名文件(10)