跳到主要内容

11.5 SCTP:工程权衡下的混血儿

上一节我们最后看到 TCP 那个复杂且精细的世界,为了可靠性和有序性,它不惜一切代价。但在工程师的现实世界里,并不是所有场景都能忍受 TCP 的死板,也不能全盘接受 UDP 的冷漠。你需要的是一种混合体——既要 TCP 的可靠和拥塞控制,又要 UDP 的消息边界和多宿主能力。

这正是 SCTP(Stream Control Transmission Protocol)存在的理由。它在 2000 年被设计出来,最初是为了解决 PSTN(公共交换电话网)信令传输的问题,但很快人们发现它在通用的 IP 网络中,尤其是 LTE 这种对故障极其敏感的场景下,比 TCP 更好用。

为什么?因为 TCP 傻乎乎地认为连接断了才是断了,而 SCTP 能更快地发现链路挂了或者包丢了。

SCTP 的混血特性

你可以把它看作是 TCP 和 UDP 的联姻产物:

  • 它是可靠的(像 TCP):有拥塞控制、流量控制(接收窗口 a_rwnd)。
  • 它是面向消息的(像 UDP):TCP 是字节流,你切分好的消息在 TCP 那里只是一串数据;而 SCTP 保留了消息边界,你发一块,收的就是一块。
  • 安全升级:用四次握手代替 TCP 的三次握手,专门用来防 SYN 泛洪攻击。
  • 多宿主:端点可以有多个 IP 地址。一条网线断了,它自动切另一条。
  • 多流:在一个关联里并行跑多条独立的数据流。解决 TCP 致命的“队头阻塞”问题。

但在我们深入这些机制之前,得先看看它是怎么把自己塞进内核里的。

5.1 插队注册:协议初始化

`里,真正把包交给网络层的最后一跃是这一行:

. . .
err = icsk->icsk_af_ops->queue_xmit(skb, &inet->cork.fl);
. . .
}

(net/ipv4/tcp_output.c)

TCP 的世界深不见底。我们剥开了连接建立、定时器、收发包这几层表皮,但这仅仅是冰山一角。好消息是,有了 TCP 和 UDP 的底子,理解 SCTP 会容易得多——它本质上是在这两个极端之间做着工程权衡。

但内核不认识它,你就没法用。

SCTP 的初始化入口是 sctp_init()。它的任务很繁琐:分配内存、初始化 sysctl 变量,最重要的是向 IP 层(IPv4 和 IPv6)注册自己。

int sctp_init(void)
{
int status = -EINVAL;
. . .
/* 先在 IPv4 协议层注册 */
status = sctp_v4_add_protocol();
if (status)
goto err_add_protocol;

/* 再在 IPv6 协议层注册 */
status = sctp_v6_add_protocol();
if (status)
goto err_v6_add_protocol;
. . .
}

(net/sctp/protocol.c)

这一步跟 UDP 之类的协议没什么两样,就是填表。SCTP 定义了一个 net_protocol 结构体实例,填好处理回调和错误回调,然后把自己挂到内核的协议链表上去。

static const struct net_protocol sctp_protocol = {
.handler = sctp_rcv, /* 收包入口 */
.err_handler = sctp_v4_err, /* ICMP 错误处理 */
.no_policy = 1,
};

(net/sctp/protocol.c)

注册动作在 sctp_v4_add_protocol() 里完成:

static int sctp_v4_add_protocol(void)
{
/* 监听 IP 地址的变化,增删都要通知 SCTP */
register_inetaddr_notifier(&sctp_inetaddr_notifier);

/* 正式把 SCTP 挂载到 IP 层,协议号是 IPPROTO_SCTP */
if (inet_add_protocol(&sctp_protocol, IPPROTO_SCTP) < 0)
return -EAGAIN;

return 0;
}

(net/sctp/protocol.c)

这里有个细节值得注意:register_inetaddr_notifier()

SCTP 非常关心网卡 IP 地址的变化。因为它是“多宿主”的,如果本地的某个 IP 突然没了,或者新加了一个 IP,SCTP 必须立刻知道,以便更新它的全局地址列表(sctp_local_addr_list)并通知对端。这个 notifier 就是内核传话的管道。

5.2 积木与盒子:包结构与块

SCTP 的包结构比 TCP 要“乐高化”得多。TCP 头部后面全是数据,界限模糊;而 SCTP 头部后面跟的是一堆“块”。

每个 SCTP 包由一个公共头部(Common Header)和若干个(Chunk)组成。

公共头部

这是每个 SCTP 包的身份证:

typedef struct sctphdr {
__be16 source;
__be16 dest;
__be32 vtag; /* Verification Tag,验证标签 */
__le32 checksum; /* 校验和,带 Adler-32 或 CRC32c */
} __attribute__((packed)) sctp_sctphdr_t;

(include/linux/sctp.h)

  • source / dest:端口,跟 TCP 一样。
  • vtag:这是 SCTP 的防伪标志。每个关联都有一个随机的 32 位 Tag。如果一个包带着错误的 vtag 进来,内核会直接把它扔掉,不费二话。
  • checksum:校验和。

块头部

紧跟在公共头部后面的,就是块。块也有自己的头:

typedef struct sctp_chunkhdr {
__u8 type; /* 块类型 */
__u8 flags; /* 标志位 */
__be16 length; /* 块长度 */
} __packed sctp_chunkhdr_t;

(include/linux/sctp.h)

  • type:这是什么块?是数据(SCTP_CID_DATA),是建连的 INIT,还是报错的 ABORT?所有的块都遵循 TLV(Type-Length-Value) 格式,这保证了协议的扩展性。
  • flags:通常全 0,但在某些特殊块(如 ABORT)里有特定含义。
  • length:包含头部在内的总长度。

内核为了处理这些块,定义了一个庞大的对象叫 struct sctp_chunk。它是内核里处理 SCTP 逻辑的基本单元。你可以把它想象成一个包裹,里面装着具体的数据块,贴着发送地和目的地的标签,甚至还知道它属于哪个“关联”。

struct sctp_chunk {
. . .
atomic_t refcnt;

/* 根据类型不同,subh 指向不同的子头部 */
union {
__u8 *v;
struct sctp_datahdr *data_hdr;
struct sctp_inithdr *init_hdr;
struct sctp_sackhdr *sack_hdr;
struct sctp_heartbeathdr *hb_hdr;
/* ... 更多类型 ... */
} subh;

struct sctp_chunkhdr *chunk_hdr;
struct sctphdr *sctp_hdr;

struct sctp_association *asoc; /* 这个块属于哪个关联 */

/* 接收端点信息 */
struct sctp_ep_common *rcvr;

/* 来源地址和目的地址 */
union sctp_addr source;
union sctp_addr dest;

/* 传输路径:如果是入包,它告诉我们要从哪回;如果是出包,它告诉我们要去哪 */
struct sctp_transport *transport;

};

(include/net/sctp/structs.h)

5.3 关联

在 TCP 里我们说“连接”,但在 SCTP 里我们要说“关联”。

为什么换个词?因为 TCP 的连接是“一对一”的 IP 地址。而 SCTP 的两个端点之间,可能同时存在多条 IP 路径。所以,“关联”是一个比“连接”更宏大的概念,它描述的是两个端点(Endpoint)之间的关系,不管这中间隔着几条网线。

内核用 struct sctp_association 来表示它:

struct sctp_association {
...
sctp_assoc_t assoc_id; /* 关联 ID */

/* 状态 Cookie,用于四次握手验证 */
struct sctp_cookie c;

/* 对端的信息 */
struct {
struct list_head transport_addr_list; /* 对端的地址列表 */
__u16 transport_count; /* 有几个地址 */
__u16 port;

/* primary_path: 最开始建连用的那个地址(老家) */
struct sctp_transport *primary_path;

/* active_path: 当前正在用来发数据的地址(可能切了) */
struct sctp_transport *active_path;

} peer;

sctp_state_t state; /* 关联状态机 */
. . .
};

(include/net/sctp/structs.h)

  • assoc_id:每个关联的唯一身份证号。
  • peer:这是对端的画像。注意那个 transport_addr_list,这是个链表。因为 SCTP 支持多宿主,对端可能告诉你:“我有 IP A, IP B, IP C,你看着办。”
  • primary_path vs active_path:这是 SCTP 的精髓。primary_path 是“首选路径”,一般是第一个打通的地址;active_path 是“当前活跃路径”。如果 primary_path 挂了,SCTP 会自动切到备用路径上,active_path 就变了。

如何往这个关联里添加对端地址?靠 sctp_connectx() 系统调用。想绑定本地多个地址?用 sctp_bindx()

5.4 建立信任:四次握手

TCP 用三次握手,SCTP 用四次。为什么要多这一步?

还记得我们说 TCP 的 SYN Flood 攻击吗?攻击者发一堆 SYN 包,塞满服务器的半连接队列,服务器苦等着建连,资源耗尽。SCTP 设计者决定:在对方证明自己真的想说话之前,我绝不分配宝贵的 TCB(Transmission Control Block,即连接控制块)资源。

这就是四次握手的核心逻辑。

第一跳:INIT

客户端 A 想跟服务端 Z 说话。A 发一个 INIT 块。

  • A 生成一个随机 Tag,放在 INIT 块里。
  • SCTP 公共头部的 vtag 填 0。
  • A 的状态变为 SCTP_STATE_COOKIE_WAIT

第二跳:INIT-ACK

Z 收到了 INIT。Z 并不建立 TCB,也不分配昂贵资源。它做了一件很聪明的事:生成一个 State Cookie。这个 Cookie 里包含了所有 Z 需要记住的信息(比如 A 的 IP、Tag、Z 自己的 Tag 等),并且加密签名,防止伪造。

Z 把这个 Cookie 放在 INIT-ACK 里发回给 A。

  • Z 生成了自己的 Tag。
  • Z 把 A 发来的 Tag 填在 SCTP 头部的 vtag 里(为了证明我收到了)。
  • 附带那个 State Cookie。

第三跳:COOKIE-ECHO

A 收到了 INIT-ACK 和 Cookie。A 乖乖地把这个 Cookie 原封不动地装进一个 COOKIE-ECHO 块里发回去。

  • 从现在起,A 发的所有包,vtag 都填上 Z 给它的那个 Tag。
  • A 的状态变为 SCTP_STATE_COOKIE_ECHOED

第四跳:COOKIE-ACK

Z 收到了 COOKIE-ECHO。Z 拿出 Cookie,解密,发现这是刚才我自己发的,且没过期,里面的信息也对得上。好的,你是合法的。

此时,Z 才真正分配 TCB,建立 struct sctp_association,状态变为 SCTP_STATE_ESTABLISHED,并回复 COOKIE-ACK

A 收到 COOKIE-ACK,关联建立。

重点:整个过程中,服务器 Z 直到收到 Cookie-echo 之前,都没有保留任何关于这个连接的状态。这就是对抗 SYN Flood 的终极武器——不带状态的拒绝。

5.5 收包与OOTB

包到了内核,入口是 sctp_rcv()

它先做常规检查:包够不够长?校验和对不对?

然后,它会遇到一个非常 SCTP 特有的概念:OOTB(Out of the Blue)

什么叫 OOTB?就是这个包格式完全正确,校验和也对,但内核翻遍了所有的关联,愣是找不到它是属于谁的。

  • 可能是个迟到的包,关联早就断了。
  • 可能是个乱发的探测包。

遇到 OOTB 怎么办?sctp_rcv_ootb() 函数会接管。根据 RFC 4960 的规定,它不是直接忽略,而是根据包里的块类型做反应。比如,如果是 ABORT 块,就丢弃;如果是 INIT 块,可能触发一个新的关联建立尝试。

如果找到了对应的关联,包就会被推入关联的接收队列,由 sctp_assoc_bh_rcv() 进一步处理状态机。

5.6 发包流程

用户态调用 sendmsg() 发数据,内核走到 sctp_sendmsg()

这跟 TCP 发包有点像,也是找到关联,打包数据块。但中间多了一步状态机的跳转:sctp_primitive_SEND() -> sctp_do_sm()

sctp_do_sm() 是个庞大的函数,它驱动着整个 SCTP 的状态机引擎。经过一系列复杂的判定和副作用处理(side effects),最终数据块会被交给 sctp_packet_transmit(),也就是打包成 IP 包,发出去。

5.7 心跳:生命体征监测

SCTP 既然支持多宿主,它怎么知道当前用的那根网线是不是断了?

靠心跳。

每隔一段时间(默认 30 秒,可调 /proc/sys/net/sctp/hb_interval),SCTP 会向对端的一个地址发送 HEARTBEAT 块。对端收到后,必须回一个 HEARTBEAT-ACK

如果连续丢了多少个心跳,SCTP 就会判定:这条路不通了,active_path 切换!这就是为什么 LTE 这种移动网络喜欢 SCTP——基站切换 IP 地址时,TCP 往往会卡死半天,而 SCTP 能毫秒级切到备用路径。

发送心跳由 sctp_sf_sendbeat_8_3() 完成(函数名里的 8_3 指的是 RFC 4960 的 8.3 节)。

5.8 多流:解决队头阻塞

这是 SCTP 最迷人的特性之一。

在 HTTP/1.1 的管线化时代,我们有队头阻塞问题:浏览器发了 A、B、C 三个请求,如果 A 的包丢了,TCP 要等 A 重传成功后,才能把 B 和 C 交给应用层。B 和 C 明明早就到了,但因为 TCP 是字节流,必须按顺序交付。

SCTP 解决了这个问题。

一个 SCTP 关联里可以有多个“流”。每个流有独立的序列号(SSN)。

  • 流 1 的包丢了,只阻塞流 1。
  • 流 2、流 3 的数据照样交付给应用。

这就是 sinit_num_ostreamssinit_max_instreams 的意义。你在建连时商量好:“我有 10 条发送流,你能收 10 条吗?”

5.9 多宿主:不仅是看起来美

最后,关于多宿主,有一个巨大的误区。

很多人以为只要我在服务器上 bind() 了两个 IP 地址,SCTP 就自动具备容灾能力了。

错了。

SCTP 实现的是**“目的端多宿主”**。也就是说,必须是对端也告诉你它有多个 IP 地址,并且你把这些地址都加到了 peer.transport_addr_list 里,容灾才生效。

如果你的服务器知道 5 个 IP,但客户端只给了你 1 个 IP,那么客户端断网,你毫无办法。真正的容灾,是两边都暴露多条腿,左腿断了走右腿。


至此,SCTP 这个混血儿的骨架算是搭起来了。它没有 TCP 那么普及,但在特定的工业级场景(电信、军工、高频交易)里,它是无可替代的王者。

下一节,我们去看本章最后一个传输层协议:DCCP。它试图在 UDP 的实时性和 TCP 的拥塞控制之间,找一条更细的中间路线。