1. 四元组是什么
网络里常说的"连接四元组",指一个连接/流的关键标识:
(源 IP, 源端口, 目的 IP, 目的端口)
- 源(client)IP:客户端地址
- 源端口:客户端临时端口(通常随机分配)
- 目的(server)IP:服务端地址
- 目的端口:服务端监听端口(例如 8080/443)
对 TCP 来说,一个已建立连接通常可以用四元组唯一确定;更严格的场景会用"五元组":
(源 IP, 源端口, 目的 IP, 目的端口, 协议)
因为 TCP/UDP 可能共享同一对端口,协议不同就不是同一条流。
例子
你从 10.0.0.10 访问 10.0.0.5:8080:
- 源 IP:10.0.0.10
- 源端口:52341(临时端口)
- 目的 IP:10.0.0.5
- 目的端口:8080
四元组就是:(10.0.0.10, 52341, 10.0.0.5, 8080)
2. 为什么四元组很重要
四元组/五元组是很多系统的基础索引或 hash 输入:
- NAT / conntrack 用它维护映射表
- 负载均衡(L4)经常用它做 hash 来决定转发到哪个后端
- Linux 内核在一些场景下会基于它做分发(例如
SO_REUSEPORT的连接分配) - 日志追踪、风控、限流、连接治理也常用它作为最小粒度
一句话:你想"稳定地识别一条连接/一条流",绕不开它。
3. 端口复用到底指什么
"端口复用"这个词很容易被混用,先把两个最常见的区别说明白:
3.1 SO_REUSEADDR:快速重启不被 TIME_WAIT 卡住
它主要解决"程序退出后重启 bind 失败"的问题(端口看起来还被占用)。
但它不是"多个进程共享同一端口一起 listen"。
3.2 SO_REUSEPORT:多个进程共享同一端口(真正意义的复用)
多个进程同时 listen :8080,由内核把新连接分配给其中一个监听 socket。
这是我们做"同机多实例""多 worker"时最想要的能力。
4. 四元组和 SO_REUSEPORT 的关系:内核怎么决定把连接交给谁
当你开启 SO_REUSEPORT,系统里会出现多个监听者都绑定在同一个 :8080 上。此时"新连接来了",内核必须决定:
这条连接应该交给哪个进程 accept?
常见实现会基于连接的某些字段做 hash(不同内核版本细节可能不同),但你可以把它理解成:
- 分配与连接标识(通常与四元组/五元组相关)有关
- 同一个连接一旦分配给某个进程,就固定在那个进程上(直到连接断开)
- 多次新建连接由于源端口不同,四元组会变化,所以可能落到不同进程
直观后果
- 你压测时每次都新建短连接:分配更"平均",但不是绝对均匀
- 你是 IM 长连接:连接建立一次后就固定在某个 worker 上,后续消息都走同一个进程
这也是为什么很多 IM 系统特别在意"连接归属"和"路由稳定性"。
5. Go 中如何拿到四元组(服务端视角)
Go 标准库 net.Conn 已经提供了你需要的信息:
go
ra := conn.RemoteAddr().String() // 源IP:源端口
la := conn.LocalAddr().String() // 目的IP:目的端口
想拆成 host/port:
go
host, port, err := net.SplitHostPort(conn.RemoteAddr().String())
if err != nil { /* handle */ }
_ = host
_ = port
你可以把四元组格式化成一个结构体,写日志、打指标、做路由:
go
type FourTuple struct {
SrcIP string
SrcPort string
DstIP string
DstPort string
}
func GetFourTuple(c net.Conn) (FourTuple, error) {
srcHost, srcPort, err := net.SplitHostPort(c.RemoteAddr().String())
if err != nil {
return FourTuple{}, err
}
dstHost, dstPort, err := net.SplitHostPort(c.LocalAddr().String())
if err != nil {
return FourTuple{}, err
}
return FourTuple{
SrcIP: srcHost, SrcPort: srcPort,
DstIP: dstHost, DstPort: dstPort,
}, nil
}
6. Go 实战:用 SO_REUSEPORT 启动多进程共享同端口
Go 没有一行开关,但可以用 net.ListenConfig.Control 在创建 socket 时设置 SO_REUSEPORT。
下面给你一个"足够生产化"的实现(Linux 上常用),同时打开:
SO_REUSEADDR:方便快速重启SO_REUSEPORT:多进程共享同一端口
go
package reuseport
import (
"context"
"net"
"runtime"
"time"
"golang.org/x/sys/unix"
)
type Options struct {
ReuseAddr bool
ReusePort bool
KeepAlive time.Duration
}
func ListenTCP(addr string, opt Options) (net.Listener, error) {
lc := net.ListenConfig{
Control: func(network, address string, c syscallRawConn) error {
// 这里按 Linux 语义实现;其他系统复用策略差异较大
if runtime.GOOS != "linux" {
return nil
}
var ctrlErr error
if err := c.Control(func(fd uintptr) {
if opt.ReuseAddr {
ctrlErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1)
if ctrlErr != nil {
return
}
}
if opt.ReusePort {
ctrlErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
if ctrlErr != nil {
return
}
}
}); err != nil {
return err
}
return ctrlErr
},
KeepAlive: opt.KeepAlive,
}
return lc.Listen(context.Background(), "tcp", addr)
}
type syscallRawConn interface {
Control(func(fd uintptr)) error
Read(func(fd uintptr) (done bool)) error
Write(func(fd uintptr) (done bool)) error
}
用这个 listener 起两个进程(或同一进程 fork 多个实例)同时监听 :8080,然后每个连接打印自己的四元组,就能直观看到连接如何被分配。
示例 server:
go
package main
import (
"fmt"
"log"
"net"
"os"
"strconv"
"time"
"yourmod/reuseport"
)
func main() {
id := 1
if len(os.Args) > 1 {
id, _ = strconv.Atoi(os.Args[1])
}
ln, err := reuseport.ListenTCP(":8080", reuseport.Options{
ReuseAddr: true,
ReusePort: true,
KeepAlive: 30 * time.Second,
})
if err != nil {
log.Fatal(err)
}
log.Printf("worker=%d listen :8080", id)
for {
c, err := ln.Accept()
if err != nil {
log.Printf("accept err: %v", err)
continue
}
go func(conn net.Conn) {
defer conn.Close()
log.Printf("worker=%d conn remote=%s local=%s", id, conn.RemoteAddr(), conn.LocalAddr())
fmt.Fprintf(conn, "hello from worker %d\n", id)
}(c)
}
}
运行:
bash
go run main.go 1
go run main.go 2
测试:
bash
for i in {1..10}; do curl -s localhost:8080; done
你会看到请求被两个 worker 分担,同时每条连接的四元组(尤其源端口)在变化。
7. 生产经验:四元组视角下的常见坑
7.1 "为什么我觉得分配不均匀?"
短连接压测时,源端口会不断变化,四元组变化大,整体看会更均匀;但它不是数学意义的绝对均匀。
长连接业务里,一条连接建立后就固定,天然会出现"连接数量不均"的现象,特别是滚动发布期间。
建议做两类指标:
- 每个进程当前连接数
- 每分钟新建连接数(accept rate)
7.2 "同一个用户为什么会落到不同 worker?"
如果你按"用户 ID"来理解路由,那四元组并不包含用户信息。
用户不断重连,源端口、甚至源 IP 都可能变化(移动网络、NAT、代理),四元组自然不同,落到不同 worker 正常。
要做"用户级一致性",需要:
- 入口层(L7/L4)按用户标识做一致性哈希
- 或应用层维护会话映射(成本更高)
7.3 "灰度发布时连接大量抖动"
老实例退出,新连接会被分配到新实例;如果你退出时不做 drain,会触发客户端集中重连,形成风暴。
正确做法:优雅下线
- 先停止接新连接(readiness/lb 摘除、或关闭 listener)
- 等待存量连接自然结束或超时
- 再退出
8. 总结
- 四元组是连接最基础的标识:
srcIP:srcPort -> dstIP:dstPort SO_REUSEPORT解决"同机多进程共享同端口",内核会基于连接标识分配新连接- 在 Go 里你可以用
net.ListenConfig.Control打开SO_REUSEPORT,并在net.Conn上直接获取四元组做观测与治理 - 真正要做"用户级稳定路由",不能只依赖四元组,需要入口层或更高层的路由策略