13 min read
一个端口究竟可以跑多少个服务?

在以往的学习和工作中,我的认知是“在一个端口只能跑一个服务”,但是从来没有去深入探究过这个问题,只是把它当成一个常识。

不过在以前的前端开发中,我遇到过这样一个问题:启动本地前端项目后,访问对应端口时,页面始终不对。后来发现,那个端口上其实已经有另一个 ipv6 的前端服务在运行,所以访问 localhost 时进到的是那个 ipv6 服务。

当时心存疑虑,却没有好好深入研究这个问题。这次就花点时间,把这个问题彻底弄明白:端口到底是不是服务的唯一标识?如果不是,一个端口究竟可以跑多少个服务?

场景复现

要复现这个问题,其实非常简单,可以直接使用 node 在同一个端口启动两个 http 服务。

实验

同一个端口启动 IPv4 和 IPv6 服务

验证同一个端口是否可以同时被 IPv4 服务和 IPv6 服务监听,以及访问 localhost 时会落到哪个服务。

实验代码
import { createServer } from "node:http";

const PORT = 8000;

createServer((_, res) => res.end("ipv4 server"))
  .listen(PORT, "0.0.0.0");

createServer((_, res) => res.end("ipv6 server"))
  .listen({ port: PORT, host: "::", ipv6Only: true });

::0:0:0:0:0:0:0:0 的简写,等同于 ipv4 中的 0.0.0.0,都是通配地址

实验结果

运行后,可以通过 lsof 看一下系统的端口占用情况:

$ lsof -nP -i :8000
COMMAND   PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
bun.exe 12587  mao    5u  IPv4 0xf8d9c4e4614d4d84      0t0  TCP *:8000 (LISTEN)
bun.exe 12587  mao    6u  IPv6 0xd4b10febdcfe6fcc      0t0  TCP *:8000 (LISTEN)

可以看到,8000 端口确实同时跑了两个服务,一个是 ipv4,另一个是 ipv6

这个时候访问 localhost:8000,结果会固定返回 ipv6 server。从 curl -v 的输出里可以看到,localhost 同时解析出了 ipv6ipv4,但优先尝试的是 ::1

$ curl -v http://localhost:8000
* Host localhost:8000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8000...
* Connected to localhost (::1) port 8000
> GET / HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Sun, 24 May 2026 08:29:17 GMT
< Content-Length: 11
<
* Connection #0 to host localhost left intact
ipv6 server%

从这个现象中,我们会有几个问题:

  • 为什么一个端口可以跑多个服务,有没有什么限制?
  • 可以在同一个端口跑两个 ipv4 或者 ipv6 的服务吗?如果不可以,为什么不可以?
  • 为什么同一个端口跑了 ipv4ipv6 的服务后,每次都是返回的 ipv6 呢,他们的顺序是谁规定的?
  • 同一个端口最多可以跑多少个服务?

接下来,我们一点点弄懂这些问题。

先自己设计

我们一开始先不去看现在的实现,很容易陷入一大堆的概念中,比如 OSI 七层模型端口协议 等等。我们从最原始的逻辑出发,先自己尝试从最朴素的需求开始设计一套网络通信模型,设计到哪里卡住了,再去看现实系统是怎么解决的,这样才能知道这些概念是为了解决什么问题诞生的。

我们的目的是为了解决这样一个问题:一台电脑上会同时运行很多程序/服务,比如浏览器、编辑器、本地开发服务器、数据库等等。如果网络数据包到了这台电脑,操作系统要怎么知道这份数据应该交给哪个服务?

最容易想到的方案是:给每个服务分配一个编号。

服务 A -> 8000
服务 B -> 3000
服务 C -> 5432

这样一来,数据包里只要带上目标编号,操作系统就可以根据编号把数据交给对应的服务,这个编号也就是我们平时说的端口。

如果只看到这里,“一个端口只能跑一个服务”这个说法似乎是合理的。因为在现在的设计中,端口就是服务的唯一编号,两个服务如果都占用 8000 端口,系统当然不知道应该把数据交给谁,就会出现冲突。

现在我们面临设计上的第一个问题:端口号应该有多少个?

首先我们想到的肯定是用一个无符号数字类型来表示端口,比如可以用 uint8uint16uint32uint64,那么我们分别分析一下这几个类型所支持的端口数量以及空间占用。

类型可表示的端口数量空间占用
uint82^8 = 2561 字节
uint162^16 = 655362 字节
uint322^32 = 42949672964 字节
uint642^64 = 184467440737095516168 字节

考虑到在早期的网络通信设计中,由于早期计算机的内存、带宽、CPU 都比今天紧张得多,网络链路速度也低得多,所以需要尽可能选择占用较少且数量足够的类型,那么 uint32uint64 的性价比就非常低了,主要是因为不太可能有这么多的服务同时在电脑上进行数据的收发。而且网络数据包需要在机器之间大量传输,每多一个字节都可能造成大量不必要的空间浪费,而 int8 只能支持到 256 个端口数,感觉又不是特别够用,所以 uint16 是最好的选择了,只占用 2字节,并且能够支持 65536 个端口数,比较均衡。

所以会发现这个数字和现在的端口号范围 0~65535 是可以对得上的,那说明我们的理解和实际没有太大偏差。

端口不能脱离协议

现在我们需要引入 协议 的概念,因为不同的服务可能使用到不同的传输协议,如 TCPUDP

假设我们现在有两个服务,它们都想使用 8000 端口:

TCP 服务 -> 8000
UDP 服务 -> 8000

按照我们现在的设计,因为 8000 已经被 TCP 服务占用了,后面启动 UDP 服务一定是无法再使用 8000 端口的。

回到我们最原始的需求,我们设计端口的目的,是为了让不同的服务可以在同一个系统上进行数据收发,并且能够准确地找到对应的服务。

而数据包在进入“端口分发”之前,其实已经先属于某一种协议了。一个 TCP 包和一个 UDP 包不是同一种东西,它们的协议头不同,处理方式也不同。既然协议都不同,那么他们就没有必要互斥了,系统仍然可以准确地把数据包发送给对应的服务。

当然,这里的协议只能是传输层的协议,因为端口本身就是传输层的一部分。

可能这里会有疑问,为什么端口得在传输层呢?我的理解是这样的,从物理层到网络层,目的是为了找到对应的设备,所以在这几层中,去关心 端口 显然不太合适,因为他们的目的不是为了区分服务,而是为了找到对应的设备,而传输层则开始涉及到数据传输的规则,也就是说它是关心数据发往什么地方的,以及从什么地方接受数据

所以,我们现在的模型变成了:

传输层协议 + 端口 = 服务的唯一标识

基于这个结论,我们至少可以确定,同一个端口可以同时跑 TCPUDP 两个服务。

实验

同一个端口启动 TCP 和 UDP 服务

验证端口号相同,但传输层协议不同时,系统是否会认为它们冲突。

实验代码
import { createSocket } from "node:dgram";
import { createServer } from "node:net";

const PORT = 8000;

createServer((socket) => socket.end("tcp ipv4 server"))
  .listen(PORT, "0.0.0.0");

const udpServer = createSocket("udp4")
  .on("message", (_, remote) => {
    udpServer.send("udp ipv4 server", remote.port, remote.address);
  })
  .bind(PORT, "0.0.0.0");
实验结果

运行后,继续使用 lsof 查看端口占用情况:

$ lsof -nP -i :8000
COMMAND   PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
bun.exe 20422  mao    5u  IPv4 0x51368c9b887f6c85      0t0  TCP *:8000 (LISTEN)
bun.exe 20422  mao    6u  IPv4 0x98b157675e32ee5e      0t0  UDP *:8000

UDP 只有 bind,没有 listen 状态,所以没有 LISTEN 符号

也可以通过 nc 工具进行测试:

$ nc 127.0.0.1 8000
tcp ipv4 server%
$ nc -u 127.0.0.1 8000
hello
udp ipv4 server

可以发现,端口 8000 同时跑了 TCPUDP 服务。

端口不能脱离地址

目前已经可以解释 TCPUDP 为什么可以使用同一个端口号,但它仍然默认“一台机器只有一个网络地址”。

实际并不是这样的,一台机器通常不只有一个地址。

比如本机回环地址 127.0.0.1, 局域网地址可能是 192.168.x.x,还有一个特殊地址 0.0.0.0

这里我们遇到一个新问题:如果某个数据包目标端口相同,协议相同,但目标地址不同,系统是否应该把它交给不同的服务处理?

比如两个 TCP 服务都使用 8000,但是一个只接收本机访问,另一个只接收局域网访问。

按照现有的设计,它们仍然会冲突,因为都是 TCP 8000。但是从数据包本身看,它们其实可以被区分:

TCP / 127.0.0.1 / 8000
TCP / 192.168.x.x / 8000

客户端访问 127.0.0.1:8000 和访问 192.168.x.x:8000,目标 IP 本来就不一样,既然如此,操作系统完全可以把它纳入分发规则。

所以我们的设计变成了这样:

本地地址 + 传输层协议 + 端口 = 服务

0.0.0.0 地址有点特殊,他是一个通配地址,当监听 0.0.0.0 时,等同于本机所有 IPv4 地址,所以如果一个服务已经绑定了 0.0.0.0:8000,另一个服务再去绑定 127.0.0.1:8000,通常就会冲突。因为 127.0.0.1 已经包含在“所有 IPv4 地址”里面了

基于现在的结论,我们可以知道,对于 TCPUDP 来说,都可以分别绑定不同的本地地址。

实验

同一个端口绑定不同 IPv4 地址

验证端口和协议相同,但本地地址不同时,系统是否可以把它们分发给不同服务。

实验代码
import { createSocket } from "node:dgram";
import { createServer } from "node:net";

const PORT = 8000;

createServer((socket) => socket.end("tcp 127.0.0.1 server"))
  .listen(PORT, "127.0.0.1");

createServer((socket) => socket.end("tcp 192.168.31.65 server"))
  .listen(PORT, "192.168.31.65");

const udpLocalhostServer = createSocket("udp4")
  .on("message", (_, remote) => {
    udpLocalhostServer.send("udp 127.0.0.1 server", remote.port, remote.address);
  })
  .bind(PORT, "127.0.0.1");

const udpLanServer = createSocket("udp4")
  .on("message", (_, remote) => {
    udpLanServer.send("udp 192.168.31.65 server", remote.port, remote.address);
  })
  .bind(PORT, "192.168.31.65");
实验结果

运行后,同样使用 lsof 命令查看一下端口占用情况:

$ lsof -nP -i :8000
COMMAND   PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
bun.exe 23135  mao    5u  IPv4 0x3d0a6cee52b9b04b      0t0  TCP 127.0.0.1:8000 (LISTEN)
bun.exe 23135  mao    7u  IPv4 0x66cdc4e4c0c7cf58      0t0  TCP 192.168.31.65:8000 (LISTEN)
bun.exe 23135  mao    8u  IPv4 0xead7765c2d118206      0t0  UDP 127.0.0.1:8000
bun.exe 23135  mao    9u  IPv4 0xa7bae361e43c149a      0t0  UDP 192.168.31.65:8000

nc 工具测试结果如下:

$ nc 127.0.0.1 8000
tcp 127.0.0.1 server%
$ nc 192.168.31.65 8000
tcp 192.168.31.65 server%
$ nc -u 127.0.0.1 8000
1
udp 127.0.0.1 server^C
$ nc -u 192.168.31.65 8000
1
udp 192.168.31.65 server^C

可以发现,确实每个都是独立的服务。至此,我们的想法和设计是和实际相符的。

IPv4 和 IPv6

在我们现有的设计中,都只考虑到了 ipv4 地址,但是现实中,还存在 ipv6 地址,不过他们并没有本质上的区别,只是地址的表示形式不同而已,都属于 ip 地址

所以在现在的设计中,应该也是可以直接兼容 ipv6 的,也就是说可以分别在 TCPUDP 中分别跑 ipv4ipv6 的服务。

实验

同一个端口绑定 IPv4 和 IPv6 地址

验证把地址维度从 IPv4 扩展到 IPv6 后,前面的模型是否仍然成立。

实验代码
import { createSocket } from "node:dgram";
import { createServer } from "node:net";

const PORT = 8000;
const IPV4_HOST = "127.0.0.1";
const IPV6_HOST = "fe80::2605:dc4d:cabd:284c%utun0";

createServer((socket) => socket.end("tcp ipv4 server"))
  .listen(PORT, IPV4_HOST);

createServer((socket) => socket.end("tcp ipv6 server"))
  .listen(PORT, IPV6_HOST);

const udpIpv4Server = createSocket("udp4")
  .on("message", (_, remote) => {
    udpIpv4Server.send("udp ipv4 server", remote.port, remote.address);
  })
  .bind(PORT, IPV4_HOST);

const udpIpv6Server = createSocket("udp6")
  .on("message", (_, remote) => {
    udpIpv6Server.send("udp ipv6 server", remote.port, remote.address);
  })
  .bind(PORT, IPV6_HOST);
实验结果

结果如下,和预期也是相符的:

$ lsof -nP -i :8000
COMMAND   PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
bun.exe 27122  mao    5u  IPv4 0x4f3595850ef5a91e      0t0  TCP 127.0.0.1:8000 (LISTEN)
bun.exe 27122  mao    6u  IPv6 0xc0ceccc770e3759      0t0  TCP [fe80:10::2605:dc4d:cabd:284c]:8000 (LISTEN)
bun.exe 27122  mao    7u  IPv4 0x5772188e4f31236      0t0  UDP 127.0.0.1:8000
bun.exe 27122  mao    8u  IPv6 0xead7765c2d118206      0t0  UDP [fe80:10::2605:dc4d:cabd:284c]:8000

能否多个服务绑定同一个地址和端口

在我们现在的设计中,本地地址 + 传输层协议 + 端口 等于是一个唯一标识,是完全不能够重复的。我们可以用代码测试一下,如果完全一致,会发生什么问题。

实验

重复绑定同一个地址、协议和端口

验证本地地址、传输层协议和端口完全一致时,系统是否还能区分两个服务。

实验代码
import { createServer } from "node:net";

const PORT = 8000;
const HOST = "127.0.0.1";

createServer((socket) => socket.end("first tcp server"))
  .listen(PORT, HOST);

createServer((socket) => socket.end("second tcp server"))
  .listen(PORT, HOST);
实验结果

运行时会发现如下的报错:

$ bun run src/step5.ts
first tcp server is listening on 127.0.0.1:8000
second tcp server failed to listen on 127.0.0.1:8000 error: Failed to listen at 127.0.0.1
  syscall: "listen",
  errno: 48,
  address: "127.0.0.1",
  port: 8000,
  code: "EADDRINUSE"
      at listen (unknown:1:1)
      at node:net:1363:30
      at listenInCluster (node:net:1424:24)
      at listen (node:net:1332:20)

很明显,从系统的角度来说,如果两个服务的地址、协议、端口完全一致,那么它是没办法区分数据包要交给哪个服务处理的。

不过当你问 AI 时,会发现可以通过 SO_REUSEPORT 来强制让多个服务跑在同一个 本地地址 + 传输层协议 + 端口 上。

实验

使用 SO_REUSEPORT 重复绑定同一个地址、协议和端口

验证开启 reusePort 后,多个服务是否可以跑在同一个本地地址、传输层协议和端口上。

实验代码
import { createServer } from "node:net";

const PORT = 8000;
const HOST = "127.0.0.1";

createServer((socket) => socket.end("first tcp server"))
  .listen({ port: PORT, host: HOST, reusePort: true });

createServer((socket) => socket.end("second tcp server"))
  .listen({ port: PORT, host: HOST, reusePort: true });
实验结果

运行后会发现没有报错了,并且 lsof 结果如下:

$ lsof -nP -i :8000
COMMAND   PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
bun.exe 29151  mao    5u  IPv4 0x5f7ef352c7bde55      0t0  TCP 127.0.0.1:8000 (LISTEN)
bun.exe 29151  mao    6u  IPv4 0x193be1888925d7c5     0t0  TCP 127.0.0.1:8000 (LISTEN)

而且当你访问时会发现始终都是连接到第二个 tcp 服务的:

$ nc 127.0.0.1 8000
second tcp server%
$ nc 127.0.0.1 8000
second tcp server%
$ nc 127.0.0.1 8000
second tcp server%
$ nc 127.0.0.1 8000
second tcp server%
$ nc 127.0.0.1 8000
second tcp server%
$ nc 127.0.0.1 8000
second tcp server%

这个问题就不在这里探究了,因为 SO_REUSEPORT 后面的分发策略和系统实现有关,不能简单地认为后创建的服务一定会覆盖前面的服务。或许你可以自己尝试去弄清楚:

  1. 同一个 本地地址 + 传输层协议 + 端口 最多能跑多少个服务呢?
  2. 是否永远是后创建的服务覆盖之前的服务呢?系统会有什么策略吗?

所以结论为:正常情况下,是不可以把多个服务运行在 本地地址 + 传输层协议 + 端口 上的,除非使用 SO_REUSEPORT

为什么访问 localhost 总是到了 IPv6 服务

首先,ipv4ivp6 是可以运行独立的服务的,所以问题是出在 localhost 解析到 本地地址 的链路上。

从之前的 curl 结果中可以明显地看到,localhost 同时解析出来了 ipv6ipv4 两个地址,但是优先尝试访问的是 ipv6 地址。

通过查资料发现 rfc 6724 中有相关的定义:

默认策略表会让 IPv6 地址比 IPv4 地址有更高 precedence;当两者同样合适时,IPv6 会排在前面

所以在同时得到多个候选地址后,大部分现代软件/工具会优先尝试 ipv6 地址。当然,这也会受到系统配置和应用自身策略的影响。

总结

现在,我们可以回答最开始提出的那几个问题了。

为什么一个端口可以跑多个服务,有没有什么限制?

因为系统是以 本地地址 + 传输层协议 + 端口 为唯一标识的,所以只要 本地地址 + 传输层协议 不同,就可以跑在同一个端口上。

可以在同一个端口跑两个 ipv4 或者 ipv6 的服务吗?如果不可以,为什么不可以?

在同一个端口中,如果都是跑 TCP 协议,那么可以同时跑多个 ipv4 地址。也可以跑同一个地址,但是分别是 TCPUDP 协议。

为什么同一个端口跑了 ipv4ipv6 的服务后,每次都是返回的 ipv6 呢,他们的顺序是谁规定的?

根据 rfc 6724 ,只能说现代的软件/工具往往会优先尝试 ipv6,其次再是 ipv4

同一个端口最多可以跑多少个服务?

在不考虑 SO_REUSEPORT,并且只讨论 TCPUDP 的情况下,同一个端口最多可以跑的服务数量大致取决于可以独立绑定的 ipv4ipv6 地址数,再乘以 2 (两种协议)。这里还需要注意,通配地址会和具体地址产生包含关系,所以不能简单把所有地址直接相加。

代码

上述使用到的测试代码都放在了 repo 中,有兴趣的可以 clone 下来跑一跑。