基于 TCP 的应用层协议设计

一直以来, 包括我在内的很多人都认为, 基于 TCP 的应用层协议很简单, 只需要加个包头就行了. 因为 TCP 是可靠的协议, 能保证数据有序无误地送达对端; 只是它面向流, 不保留消息边界, 因此我们只需要定义协议包头, 能区分各个数据报即可. 然而这个看法是错误的: 传输层协议所做的工作是有限的, 应用层协议的工作远不止封装一个包头. 我们来看个例子.

TCP 连接并不是那么可靠

我们用 C 写一个简单的客户端. 它用 TCP 连接上服务器, 然后 sleep 一段时间, 然后调用 send(2) 发送一段数据.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int main() {
int fd = socket(PF_INET, SOCK_STREAM, 0);

struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(8000);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);

if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
printf("connect error: %d\n", errno);
return 1;
}

printf("connected\n");

sleep(5); // pull the network cable

printf("try send\n");

const char *data = "Hello";
if (send(fd, data, strlen(data), 0) < 0) {
printf("send error: %d\n", errno);
return 1;
}
printf("sent\n");

return 0;
}

现在我们运行这个客户端. 在它连接上服务器后, 趁它在 sleep 时, 把网线拔了. 结果是, 它会正常发送, 然后正常退出. 服务器却什么都没收到.

1
2
3
4
$ ./cli
connected
try send
sent

为什么会出现这种情况? 我们知道, 在 TCP 中, 数据发送给对端后, 必须收到相应的 ACK 才能保证数据送达对端.

psh-ack

然而当 send(2) 返回时, 并不能保证发送的数据被确认, 甚至不能保证数据发出去了. 它所做的只是将数据放入内核缓冲区, 只要这个操作成功了, 它就会返回成功. send 完成后, 并没有正常关闭连接, 而是直接退出. 由于程序没有关闭连接, 在程序退出后, 操作系统仍然会维持这个连接状态. 在重传多次无果后, 连接会被放弃, 且程序感知不到异常.

这个问题相当严重, 尤其是在移动网络中. 移动设备的无线网络并不稳定, “拔网线” 这样的事情经常发生. 有可能发生在 send 调用前; 有可能发生在 send 调用后, 但数据尚未发送时; 有可能发生在数据已送达, 等待接收 ACK 时. 如何解决这个问题?

正常关闭连接

上面程序的一个问题是, 在程序结束前没有关闭连接. 回忆一下, 关闭 TCP 连接会向对端发送一个 FIN, 告诉对端: “小于 FIN 序列号的报文段都已发送完毕, 如果你已确认接收包括 FIN 在内的所有报文段, 请给我回复一个 ACK”. 因此如果连接能够正常关闭, 就能够保证所有数据送达对端.

close-connection

现在修改我们的代码, 在程序退出前调用 close(2), 看看效果怎样.

1
2
3
4
5
6
7
8
9
10
11
int main() {
...

if (close(fd) < 0) {
printf("close error: %d\n", errno);
return 1;
}
printf("closed\n");

return 0;
}

我们再次运行程序, 同样趁它 sleep 的时候拔掉网线. 然而结果却是数据成功发送, 连接成功关闭, 程序正常退出.

1
2
3
4
5
$ ./cli
connected
try send
sent
closed

原因是 close(2)send(2) 一样, 并不会等待 ACK, 它只是将 FIN 放入发送队列. 问题是, 就算我们能够确认连接正常关闭 (实际上确实有办法), 也是无济于事的, 因为对于长连接应用, 不可能通过关闭连接来确保数据送达.

获取发送队列大小

那么有没有办法确定数据对应的 ACK 已收到呢? 实际上是有的. Linux 支持 SIOCOUTQ 请求, 可以获取发送队列大小. 这也就是 TCP 尚未确认送达的数据大小. 我们在发送数据后 sleep 2 秒, 然后调用 ioctl(2) 传入 SIOCOUTQ 获取发送队列大小.

1
2
3
4
5
6
7
...

sleep(2);

int qsize;
ioctl(fd, SIOCOUTQ, &qsize);
printf("queue size: %d\n", qsize);

再次执行同样的操作:

1
2
3
4
5
6
$ ./cli
connected
try send
sent
queue size: 5
closed

可以看到, 即使等待了 2 秒, 仍然有 5 字节数据没有确认送达.

这种做法虽然能检测数据是否送达, 但我们不能采用这个做法. 它有以下几个问题:

  • 首先, 不是所有的系统都支持获取发送队列大小. SIOCOUTQ 只有 Linux 支持.
  • 其次, 未收到 ACK 并不代表数据未送达, 有可能数据已送达, 但在接收 ACK 时网络断开. 这些未被确认的数据本应由 TCP 重传, 对端在收到这些重传数据时, 会因为重复的序列号而忽略它们. 然而现在网络断开, TCP 无法重传, 我们能在之后建立的新连接中手动重传它们吗? 如果这些数据表示提交一笔订单会怎样? 这就导致请求重复发送了.
  • 最后, 这种做法违反了协议分层原则. TCP 的自动重传对于上层协议来说应该是透明的, 应用层应该将 TCP 看成一个面向流的双工通道, 而不应该关心 TCP 有没有收到 ACK. 况且应用层数据报与 TCP 的发送队列在概念上完全不同: 队列中未确认的数据有可能属于多个不同的数据报.

因此, 这个问题需要在应用层协议中解决.

HTTP 的解决方案

我们来看看成熟的应用层协议是怎么做的. HTTP 的做法非常简单, 它规定一个请求必然要对应一个响应, 且不允许服务器主动推送数据.

HTTP 的每个请求都期待一个响应, 如果一个请求迟迟收不到响应, 就会认为这次请求失败. 这样的话, 如果在发送过程中网络断开, 客户端就能够感知到异常, 从而重试. 然而这会带来另一个问题, 客户端不知道服务器是否收到请求, 因为网络有可能是在服务器应答时断开的. 如果请求涉及到状态, 就会出问题. 比如, 假设这个请求是提交订单, 重试就有可能导致提交多笔订单.

对此 HTTP 引入了方法这个概念. 对于幂等的请求, 也就是说, 不会改变服务器状态的请求, 使用 GET 方法. 重试这类请求没有任何问题. 对于会改变服务器状态的请求, 使用 POST 方法. 这类请求不能随意重试, 而应该先使用 GET 方法获取当前状态, 确认无误再重新发送 POST. 例如, 如果提交订单失败, 重试时浏览器会给出警告 “确认重新提交表单”. 这时应当刷新订单列表, 确认订单并未成功提交, 再作重试.

HTTP 不允许服务器主动推送数据. 因为客户端通常不会 (也不应当) 响应服务器的推送, 如果服务器随意推送数据, 那么在推送的过程中网络就有可能会断开, 造成数据丢失, 并且双方都感知不到. 即使客户端在稍后请求时感知到网络断开, 双方也不知道丢失了哪些数据.

WebSocket 的解决方案

WebSocket 的通信双方都可以自由地向对方发送数据, 也不要求请求一定有响应. 但是它有心跳机制. WebSocket 基于 TCP, 一旦网络断开, 必然导致下一次心跳超时, 从而检测出连接断开. 此外 WebSocket 会管理连接的关闭, 关闭一条 WebSocket 连接需向对端发送 Close 消息, 对方收到后会回复 Close 消息. 这样应用层就能清楚地知道连接是否正常关闭.

应用层协议设计

考虑到可能的网络断开, 即使使用 TCP 协议, 直接向对端发送一段数据并断言它能送达是错误的. 应用层协议要有保活机制. 我们可以像 HTTP 一样要求请求必有响应, 这样能最及时地检测出异常; 也可以使用心跳, 这可以用于一些要求较低的场景.

如果检测出网络故障, 我们可能需要断开当前连接并尝试重新建立连接, 然后重新发送请求. 然而并不是所有的请求都能这样做. 对于非幂等的请求, 即会改变服务器状态的请求, 这可能会因重复请求而导致意料之外的结果. 这类请求往往依赖双端状态, 一旦连接异常关闭, 双端状态就有可能不一致, 重连后应当重新同步状态.

举个例子, 假设客户端要请求服务器提交订单. 如果出现响应超时, 那么重连后客户端应当重新请求订单列表. 如果订单列表中已经有这个订单, 我们就不应该重新下单, 也就是说 “提交订单” 这个请求依赖 “订单列表” 这个状态. 更进一步, 我们可以为每个订单分配一个唯一的 ID, 这样客户端在重连成功后就能放心地重传订单, 服务器会忽略重复的订单.

再举个例子, 假设服务器要向全体客户端广播消息. 一般来说客户端不会对服务器推送的消息作回复, 且回复广播消息会造成集中请求, 给服务器造成负担. 这种情况下如何保证客户端都能收到这个消息呢? 我们可以采用以下做法:

  • 服务器广播消息的同时将它们缓存起来, 缓存时间取决于消息的有效期. 这便是广播消息的 “状态”.
  • 客户端有心跳机制. 如果在接收广播消息时出现网络断开, 稍后能够检测出来.
  • 客户端重连成功后, 会请求所有缓存的广播消息. 这样客户端就能获取所有的消息了.

这种做法是面向状态的通信, 或者说通过同步状态来通信. 它将消息或者消息造成的结果作为状态. 再比如在游戏服务器中, 要给玩家加 50 金币. 直接给客户端推送消息 “加 50 金币” 是不靠谱的, 正确的做法是服务器将其记录的玩家金币数增加 50, 然后向客户端推送玩家当前金币数. 如果出现网络断开, 重连后客户端会再次获取当前金币数. 这也是面向状态通信的例子, 这次是将消息造成的结果作为状态.

总结

网络协议并不仅仅定义交换数据的结构, 还要定义数据的交换方式, 传送步骤等一系列内容. 由于应用层不能 (也不应该) 获取传输层的详细状态, 应用层协议需要做一些工作保证数据的可靠性和完整性. 为了防止意外的网络断开, 应用层需要保活机制, 这可能依赖响应或者心跳. 当网络断开, 重连成功后, 不可轻易重传请求, 应当先同步状态. 对于没有应答的请求, 使用面向状态的通信是一个好办法.


参考资料:


基于 TCP 的应用层协议设计
https://luyuhuang.tech/2021/09/05/application-layer-protocol.html
Author
Luyu Huang
Posted on
September 5, 2021
Licensed under