不要随意使用 Node.js 中 socket.write 方法的回调参数

该文章根据 CC-BY-4.0 协议发表,转载请遵循该协议。
本文地址:https://fenying.net/post/2025/03/16/why-not-use-socket-write-callback-in-nodejs/

本文记录一个在 Node.js 中使用 socket.write 方法的回调参数的坑。

1declare class Socket extends EventEmitter {
2
3    write(buffer: Uint8Array | string, cb?: (err?: Error) => void): boolean;
4}

在 Node.js 中,socket.write 方法用于向 Socket 里写入数据,其特征如下:

  • 如果内核缓冲区有足够的空间,可以直接写入,那么 socket.write 方法会返回 true,否则返回 false,表示数据被写到了用户空间的缓冲区中。

    当数据被写入到用户空间的缓冲区中后,Node.js 会等待内核缓冲区有足够的空间后再将数据写入内核缓冲区。 而当内核缓冲区有空闲空间时,Node.js 会触发 drain 事件,表示可以继续写入数据。

  • socket.write 方法的最后一个参数是一个回调函数,它会在本次写入的数据被对端接收并确认后被调用。

看起来很清晰,那么我们遇到了什么问题呢?先来看看故事背景。

问题出现在我们开发的一个应用层协议客户端库里,这个协议类似于 HTTP,是一来一回的通信模式,即客户端发送一个请求,服务端返回一个响应。由于每个请求没有唯一的标识符,因此只能通过请求的发送顺序来匹配请求和响应。而故障的现象很简单:客户端发送了一个请求 r1 后,服务端没有及时响应,而当客户端发送了第二个请求 r2 后,客户端中 r1 的回调函数被调用了,得到的却是 r2 的响应。这并不是必现的,仅在某些情况下会出现。

经过我们的排查,发现问题出在了 socket.write 方法的回调函数使用方法上,大概的代码如下:

 1class Client {
 2
 3    public constructor(private readonly _socket: Socket) {}
 4
 5    private _requests: Request[] = [];
 6
 7    private _queueRequest(request: Request): void {
 8
 9        this._requests.push(request);
10    }
11
12    public send(request: Request): void {
13
14        this._socket.write(request.toBuffer(), (err) => {
15
16            if (err) {
17
18                return;
19            }
20
21            this._requests.push(request);
22        });
23    }
24}

问题出在 send 方法中,当客户端发送一个请求时,会将数据写入 Socket,然后等待到 socket.write 的回调函数被调用时,再将请求放入 _requests 数组中。我已经不记得这么写的原因,大概可能是如下原因之一:

  • 看文档时误看了 socket.write 的回调函数的说明,以为是在数据被写入内核缓冲区后被调用的。
  • 希望在对端接收到数据后再将请求放入 _requests 数组中,以避免未成功发送的请求被放入等待响应的队列中。

我倾向于第二个理由,那么即便是第二个理由,似乎看起来并不影响程序的正确性,而事实上确实出了问题。

所以,真相是什么?

这就得从 TCP 协议的特性说起了——TCP 是一个面向连接的全双工协议,它保证了数据的可靠传输,即数据不会缺失、不会乱序、不会重复,而且数据是按照发送的顺序到达的。在 TCP 协议中,数据包是通过一个叫做序列号的字段来标识的,每个数据包都有一个序列号,接收端通过序列号来确定数据的顺序。TCP 接收端在收到数据包后,会向发送端发送一个确认包,告诉发送端已经收到了某一个数据包。

这是一个很合理的机制,但问题也就出现在这里。TCP 接收端并不会每收到一个数据包就立即发送一个确认包,而是会等待一段时间,将收到的数据包放入缓冲区中,然后再发送确认包,也就是 TCP 的延迟确认机制。比如接收端一连收到了 1,2,3,4,5 这 5 个数据包,那么它并不会逐个发送确认包,而是缓冲一会儿后,直接发送一个确认包,表示 1-5 的数据包都已经收到了。需要注意的是,虽然 TCP 的确认是延迟的,但是接收端将收到的数据包交给应用层却是立即的。也就意味着,应用层可以在发送确认包之前就把请求给处理了,然后发送响应数据。

真相到这里已经很明朗了——由于客户端只有等到确认包到达后才能确定请求已经成功发送,然后将请求放入 _requests 队列中。而在这之前,对端可能已经将响应包发送过来了,导致请求和响应的匹配出现问题。

同时我们检查客户端代码时还发现,当匹配不到请求时,客户端会直接将响应包丢弃,而不是抛出异常并关闭连接。这也就导致了客户端可能把 r1 的响应丢掉后,又把 r1 的请求放入等待队列,最终导致了请求和响应的匹配错误。

解决方案很简单,只需要确保写入数据和将请求放入队列两个操作在同一个 Tick 中执行即可。这样就可以保证请求和响应的匹配正确。

🤷‍♂️所以,没事不要乱用 socket.write 的回调参数,使用 Socket 的 error 事件处理异常即可,除非还有别的需求需要判断对端是否已经收到了某个数据包。

comments powered by Disqus

翻译: