很久很久以前,我为了解决以前项目中bus通信膨胀的问题设计了 libatbus, 当时主要解决的核心问题是解决大量服务器节点互通时服务器间两两建立通道时的大量内存浪费问题。也同时使用跨进程的无锁队列避免了多写单读场景下的加锁以提高性能。
当时也顺带实现了高性能的跨平台支持。
但是当时云原生还不流行,出海业务也较少。所以没有考虑出海跨大区网络抖动和质量很不稳定的问题。也没有过多地考虑动态代理层支持。 当时的想法是受网络路由的启发,实现了一个按固定网关和子网去管理的流程。这个流程好处是简单、快,算法层面就能算出路由去向,不需要外部服务发现的依赖。 虽然当时就支持多级代理,但是整体是一个树形结构。又因为是只实现了异步接口(为了保证高性能,不要因为谁偷懒写出卡住服务器的代码)。 所以整个框架冠以AT前缀(即Tree like - Async)。
但是现在云原生流行起来以后,如果想支持完全动态可以自动扩缩容的网关和代理层,且网关和代理层也可以无损HPA和迁移,那么就需要我们的代理支持网状结构。 也不得不引入外部的路由规则。
同时,出海场景的另一个问题是跨大区网络很不稳定。有些场景可以用专线缓解,但是专线死贵死贵。所以我们有些场景会走公网流量。 这就需要一方面隔离部分流量,防止部分网络不稳定影响整体服务健康,可以降级服务但不能不可用。另一方面走公网流量得部分也需要加强安全性,要走更严格的密钥协商和加密算法。
有一些其他解决方案,为了兼容老接口和流程,强行把云下的流程搬到云上(比如腾讯的tbuspp,给每个pod sidecar注入一个agent进程),其实一方面有额外不必要的负担,另一方面也反而丢失了原来设计上解决的一些通道管理上的问题。 我这次打算彻底整体重构 libatbus, 不考虑协议层向前兼容(大部分API还是得兼容)。可以在协议设计层面来解决掉这些问题。
本文主要关注于路由算法层面的改进。
libatbus 采用类似 IP 路由的树形结构设计,通过 Bus ID 和 子网掩码 来管理节点间的路由关系。
Loading excalidraw...
每个节点可以配置一个或多个子网范围,通过 id_prefix 和 mask_bits 定义:
struct endpoint_subnet_conf {
ATBUS_MACRO_BUSID_TYPE id_prefix; // 子网前缀
uint32_t mask_bits; // 后缀位数
};例如,配置 conf.subnets.push_back(atbus::endpoint_subnet_conf(0, 16)) 表示该节点可以管理后16位变化的所有子节点 ID。
sequenceDiagram
participant P as Parent
participant C as Child
Note over P,C: 父节点 → 子节点 (直接发送)
P->>C: send_data(child_id, data)
Note over P,C: 子节点 → 父节点 (直接发送)
C->>P: send_data(parent_id, data)sequenceDiagram
participant C1 as Child1
participant P as Parent
participant C2 as Child2
Note over C1,C2: 首次通信: 通过父节点转发
C1->>P: 1. send_data(child2_id)
P->>C2: 2. forward(data)
P-->>C1: 3. 通知直连Child2地址
Note over C1,C2: 建立直连后: 直接通信
C1->>C2: 4. 直连发送
C2->>C1: 5. 直连发送sequenceDiagram
participant GC1 as GrandChild1
participant C1 as Child1
participant P as Parent
participant C2 as Child2
Note over GC1,C2: GrandChild1 → Child2 (跨层级转发)
GC1->>C1: 1. send_data(child2_id)
C1->>P: 2. forward to parent
P->>C2: 3. forward to child2sequenceDiagram
participant C1 as Child1
participant P1 as Parent1
participant P2 as Parent2
participant C3 as Child3
Note over C1,C3: 跨父节点通信 (需Root互连)
C1->>P1: 1. send_data(child3_id)
P1->>P2: 2. forward (Root节点需主动直连)
P2->>C3: 3. forward to child3
Note over C1,C3: ❌ 不会建立直连
始终通过父节点转发graph LR
subgraph "Root节点互连"
R1["Root1"]
R2["Root2"]
R3["Root3"]
R1 <--> |"主动connect()"| R2
R2 <--> |"主动connect()"| R3
R1 <--> |"主动connect()"| R3
endRoot节点之间没有父子关系,需要通过 node->connect(address) 主动建立连接。
根据通信双方的位置关系,自动选择最优通道:
flowchart TD
Start[选择数据通道] --> SameProc{同进程?}
SameProc -->|是| MemChan[内存通道 mem://]
SameProc -->|否| SameHost{同物理机?}
SameHost -->|是| CheckShm{支持共享内存?}
CheckShm -->|是| ShmChan[共享内存 shm://]
CheckShm -->|否| UnixChan[Unix Socket unix://]
SameHost -->|否| TcpChan[TCP通道 ipv4/ipv6://]
MemChan --> Priority1["优先级: 1 (最高)
零拷贝, 无锁队列"]
ShmChan --> Priority2["优先级: 2
跨进程共享内存"]
UnixChan --> Priority3["优先级: 3
本地Socket, 无需网络栈"]
TcpChan --> Priority4["优先级: 4 (最低)
网络IO"]
style MemChan fill:#90EE90
style ShmChan fill:#87CEEB
style UnixChan fill:#DDA0DD
style TcpChan fill:#FFB6C1通道类型 | 协议前缀 | 适用场景 | 特点 |
|---|---|---|---|
内存通道 | mem:// | 同进程内 | 零拷贝、无锁队列、最高性能 |
共享内存 | shm:// | 同物理机跨进程 | 多写单读、无需网络IO |
Unix Socket | unix:// pipe:// | 同物理机跨进程 | 无需网络栈、比TCP快、Windows上用Named Pipe |
TCP通道 | ipv4:// ipv6:// | 跨物理机 | 支持双工、跨网络 |
// 按性能优先级排序: mem > shm > unix > tcp
connection *endpoint::get_data_connection(const endpoint *ep) {
// 检查是否同进程 (share_pid)
if (ep->get_hostname() == get_hostname() &&
ep->get_pid() == get_pid()) {
// 优先使用 mem:// 通道 (ACCESS_SHARE_ADDR)
}
// 检查是否同物理机 (share_host)
if (ep->get_hostname() == get_hostname()) {
// 优先使用 shm:// 或 unix:// 通道 (ACCESS_SHARE_HOST)
}
// 使用 tcp:// 通道
return tcp_connection;
}// 控制通道: 用于节点注册、心跳等控制消息
connection::ptr_t ctrl_conn_;
// 数据通道: 用于业务数据传输,按性能优先级排序
std::list data_conn_; // mem > shm > tcp特性 | 说明 |
|---|---|
树形拓扑 | 节点间呈严格的父子层级关系 |
静态路由 | 路由路径在算法层面可直接计算,无需外部服务发现 |
自动直连 | 同父的兄弟节点首次通信后会自动建立直连 |
非直接兄弟不直连 | 不同父的节点始终通过父节点链转发 |
多通道支持 | 同时支持 TCP、共享内存、内存通道,自动选择最优通道 |
子网冲突检测 | 注册时检测子网范围冲突,防止路由歧义 |
我们现在要用额外的路由注册表去托管节点间关系的管理,并且支持上述的各类关系。就需要考虑下注册表中应该包含什么内容。
先列举一下节点间关系(尽量避免 Parent/Child 这类“族谱词”,更贴近转发/路由语境;其中 Immediate 表示一跳,Transitive 表示多跳闭包关系)。
约定:以下关系均以“判定 X 与 Y 的关系”为视角。
补充说明:这里的 Upstream/Downstream 用于描述在“转发拓扑/汇聚层级”上的相对位置:Upstream 表示承担转发/汇聚职责的节点,Downstream 表示挂载在某个 Upstream 之下的节点。实际连接与消息收发是双向的,只是我们在判定关系时会选定参考点(X)来描述相对位置。
关系(中文) | 新算法英文名 | 老英文名 | 定义 |
|---|---|---|---|
自己 | Self | self | $X = Y$ |
代理父节点 | ImmediateUpstream | parent | 若 $X \to * $ 的消息需要经由 $Y$ 转发($Y$ 是 $X$ 的一跳 Upstream) |
间接父节点 | TransitiveUpstream | parent | 若存在路径 $X \to \cdots \to Y$ 且路径上的节点均承担转发/汇聚($Y$ 是 $X$ 的多跳 Upstream) |
直接子节点 | ImmediateDownstream | child | 若 $X$ 挂载在 $Y$ 之下且 $Y$ 是 $X$ 的一跳 Upstream(从 $Y$ 的视角,$X$ 是其直接 Downstream) |
间接子节点 | TransitiveDownstream | child | 若 $X$ 可视作挂载在 $Y$ 之下的多跳 Downstream(跨越若干层 Upstream) |
邻居节点 | SameUpstreamPeer | 通过 contains 接口判定 | 若 $X$ 与 $Y$ 共享同一个 ImmediateUpstream(即它们的直属 Upstream 相同) |
远方节点 | OtherUpstreamPeer | 通过 contains 接口判定 | 设 $H_X$ 为 $X$ 的 ImmediateUpstream,$H_Y$ 为 $Y$ 的 ImmediateUpstream;若 $H_X$ 与 $H_Y$ 互为 SameUpstreamPeer(Upstream 层相邻),则 $X$ 与 $Y$ 互为远端对等体 |
原来的树形路由逻辑里是通过 subnet 判定,属于纯算法可计算的关系。目标ID落在自己的子网范围内,则目标是自己的下游节点;目标的ID是自己的父节点地址,(其子网范围包含自己),则目标是自己的上游节点;目标的ID也在自己的父节点子网范围内,则目标是自己的邻居节点或远方节点;否则是自己的远方节点。 两个节点间的关系是 邻居节点 还是 远方节点 是由 代理父节点 根据是否双方都是 直接子节点 决定的。
而在新的路由关系中,我们理论上只需要上报: A节点 -> |代理父节点| -> B节点 。其他的都能根据算法得出。
之前的路由流程中,最上层的proxy层总是互相直连的。然后由父节点判定两个直接子节点是否可以直连。但是在跨大区通信时,比如下面的结构:
Loading excalidraw...
区域1-Proxy1 和 区域2-Proxy1 永不互通。即便他们是 SameUpstreamPeer 关系。而 区域1-Proxy1 和 区域1-Proxy2 是互通的,并且他们不需要经过 跨大区Proxy节点 下发直连消息就应该直接互通。
也就是说,是否能够直连应该是带策略的。我们可以用类似策略路由的方式来,在 libatapp 层会有创建连接的流程。可以在这个流程里判定是走代理还是走直连,这个操作不会频繁,所以通过对比属性集的开销还可以接受。 但是在上游代理节点中,原本有判定两个节点是否通知直连的操作,如果每次转发消息包都去走策略判定属性集开销会过高了,所以我们可以直接提供个全局关闭的参数直接不再通过父节点通知直连了。 实际上后面都可以通过 libatapp 来主动判定直连策略,后期也可以考虑干脆把这个协议删了算了。
这里涉及到 libatapp 层需要能获知到是直连还是需要代理,而这个又是属于 libatbus 的算法层面的东西。所以还要设计一个接口去获知到链路下一跳的目标endpoint和connection地址。
这个问题是上一个问题延伸出来的。为了 libatapp 能够接入不同的网络协议层,而不只支持 libatbus 作为协议通信层,我之前在某个版本里给 libatapp 也加了一层endpoint管理。它会处理连接过程中的pending message排队和支持接入其他协议层,比如loopback。然后把 libatbus 作为其中一种协议。
在 libatapp 层实现pending message排队的好处是不需要网络层协议支持在连接过程中的缓冲区,这样接入其他协议更简单也更灵活一些。 而在 libatbus 里只有connection层的排队,是没有 message 层面的排队逻辑的。
我们也不希望重复实现message层面的排队重试逻辑,尽可能复用。这就导致在 libatapp 中,针对 libatbus 的接入层需要一定程度关心 libatbus 的流程。 比如发起连接的时候 libatapp 要根据 libatbus 的算法知道实际发送的链路走直连,还是发给下游某个子节点,还是发给父节点。 如果是发给父节点则可以直接发送数据,如果是发给直连节点或者下游子节点,则要判定是否已经创建连接,如果没有创建连接要等连接创建完再尝试发送。
libatapp 里已经接入了etcd做服务发现和策略路由。一个选择是把 libatbus 的路由关系也写到这个服务发现的数据里。 但是如果在父节点扩缩容或者抖动的时候就会导致整个业务策略路由抖动。另一个选择是单独放一份,这样 libatbus 层的路由关系抖动不影响业务层负载分布。 但是隔离开有个问题是如何保证一致性?启动的时候我们可以先写入 libatbus 层的路由关系,再写服务发现。 并且它们绑到同一个lease上,这样唯一的不一致点就在于上下线的时候,如果接收etcd的两个key的事件被分割到两次收包处理,可能有短暂的不一致。 虽然这种情况可以通过pending message的retry去解决,但是会增加整体实现的复杂性。为了以后两个数据分离不互相影响,这应该是值得的。
最终的方案是 路由拓扑和服务发现分离,但共享同一个 etcd lease。写入顺序是先拓扑再服务发现,这样其他节点看到某节点的服务发现数据时,该节点的拓扑信息已经就绪。唯一的短暂不一致窗口(收到两个 key 的 watcher 事件之间的间隔)通过 libatapp 层的 pending message 和 retry 机制自然消化。
确定了上面四个问题的方向后,下面具体看拓扑注册表的实现。
新的拓扑管理由 topology_registry 实现,它是一个进程内的轻量级图结构:
topology_peer,通过 bus_id_t(64 位)唯一标识topology_data(PID、hostname、labels),用于支持策略路由判定graph TD
R1["Root-1 (no upstream)"] --> A["Node-A"]
R1 --> B["Node-B"]
A --> A1["Node-A1"]
A --> A2["Node-A2"]
R2["Root-2 (no upstream)"] --> C["Node-C"]
C --> C1["Node-C1"]
style R1 fill:#f9e79f
style R2 fill:#f9e79ftopology_registry::get_relation(from, to) 的判定逻辑:
实际场景中代理层级很少超过 3-4 层,所以 O(d) 的遍历开销可以忽略。
为了解决"邻居节点不一定能直连"的问题,引入了 topology_policy_rule:
struct topology_policy_rule {
bool require_same_process; // 要求同进程
bool require_same_hostname; // 要求同主机
// Key: label 名, Value: 允许的 label 值集合
std::unordered_map> require_label_values;
};每个 peer 注册时携带 labels(如 region=us-west、tier=proxy),连接决策时通过 check_policy(from, to, rule) 判定两个节点是否满足直连条件。这样就可以灵活表达"同区域内的 proxy 可以直连,跨区域的必须走代理"这类规则。
topology_registry::update_peer() 自带环检测。更新某个 peer 的 upstream 前,会沿新 upstream 的链路向上遍历,如果在链路中发现了当前 peer 的 ID,则拒绝更新并返回错误码。这避免了拓扑形成环导致消息死循环转发的风险。
老版本 libatbus 的协议基于自定义的二进制编码,迭代和扩展不方便。新版本全部切换到 Protobuf v3,带来几个直接的收益:
go_package 指令,已在计划中提供 Go SDKgraph LR
subgraph "Wire Format"
VL["Varint Length"] --> MH["message_head"]
MH --> MB["message_body (oneof)"]
end
subgraph "message_body 类型"
MB --> DT["data_transform_req/rsp
业务数据转发"]
MB --> NR["node_register_req/rsp
节点注册握手"]
MB --> PP["node_ping_req / node_pong_rsp
心跳 + ECDH"]
MB --> HC["handshake_confirm
切换加密上下文"]
MB --> CC["custom_command_req/rsp
自定义命令"]
endmessage_head 包含:版本号、消息类型、序列号、源节点 bus_id,以及可选的加密元数据(算法类型 + IV + AAD)和压缩元数据(算法 + 原始大小)。
老版本 libatbus 的加密协商采用类似 TLS 握手的方式——客户端和服务端发送给对方的数据是不同的(非对称角色)。这个设计有两个现实问题:
新版本改成了对称式握手:双方在 ping/pong 中各自发送公钥,各自独立推导出相同的 shared secret。这种方式天然是对称的——谁先发 ping 谁就是"Client",但双方执行的操作完全一致。
代价是密钥交换算法必须两端配置成一样的(不像 TLS 可以在握手中协商非对称算法)。考虑到内部服务集群的部署通常可以统一配置,这个限制可以接受。
新版本在心跳报文中嵌入 ECDH 密钥协商流程,同一条连接不需要额外的握手阶段:
sequenceDiagram
participant C as Client
participant S as Server
C->>S: ping_req { crypto_handshake: {
type: X25519,
algorithms: [AES-256-GCM, ChaCha20-Poly1305, ...],
public_key: [client_pubkey]
} }
S->>S: ECDH(server_privkey, client_pubkey) → shared_secret
HKDF-SHA256(shared_secret) → [KEY ‖ IV]
选择双方最优算法
S->>C: pong_rsp { crypto_handshake: {
selected: AES-256-GCM,
public_key: [server_pubkey]
} }
C->>C: ECDH(client_privkey, server_pubkey) → shared_secret
HKDF-SHA256(shared_secret) → [KEY ‖ IV]
C->>S: handshake_confirm { sequence }
Note over C,S: 双方切换到对称加密通道
仅 data_transform 加密,控制消息明文关键设计决策:
新版本增加了传输层压缩支持。一个关键的设计决策是 先压缩再加密:加密前的数据通常更有规律(protobuf 序列化的结构化数据、重复的字段前缀等),压缩率显著高于加密后再压缩。处理顺序是:
发送: 原始 body → 压缩 → 加密 → [head | encrypted(compressed(body))]
接收: [head | encrypted(compressed(body))] → 解密 → 解压 → 原始 body这个设计带来一个实现上的约束:除了控制消息(register、ping/pong、handshake_confirm)使用验证算法验证 key 以外,其他流量(data_transform)的 body 都是加密的。这意味着不能像以前那样把业务 payload 直接放在 protobuf message 的 oneof body 字段里让 protobuf 自动解析——因为 body 在到达 protobuf 反序列化之前需要先经过解密和解压。所以新版本的报文打解包必须手动处理:先序列化 message_head,再独立处理 body 的压缩/加密,最后拼接成完整的 wire format。接收端也是先拆出 head 和 body,对 body 做解密/解压后再交给上层。
协议同时引入了语义化的压缩等级定义,将不同算法的参数抽象为统一的等级:
语义等级 | 典型映射 (zstd / lz4 / zlib) | 适用场景 |
|---|---|---|
STORAGE | level 1 / fast(1) / level 1 | 落库前轻量压缩 |
FAST | level -1 / fast(1) / level 1 | 延迟敏感的实时通信 |
BALANCED | level 3 / fast(默认) / level 6 | 日常通信默认值 |
HIGH_RATIO | level 9 / HC / level 7 | 带宽受限的跨区传输 |
支持的压缩算法包括 Zstd、LZ4、Snappy、Zlib,上层可以按消息类型或链路特征选择不同等级。
新版本的连接状态机更精细,加入了独立的握手阶段和密钥刷新阶段:
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connecting: connect() / listen()
Connecting --> Handshaking: TCP 建连完成
Handshaking --> Connected: register + ping/pong握手完成
Connected --> Connected: 周期性换密钥 (3h)
Connected --> Disconnecting: close()
Disconnecting --> Disconnected: IO 清理完成
Handshaking --> Disconnected: 握手超时/失败每个远端节点(endpoint)包含两类连接:
ctrl_conn_):承载注册、心跳、ECDH 握手等控制消息data_conn_):承载业务数据,可以有多条,按优先级排序多通道选择机制基本延续了老设计,按实际部署拓扑自动选择最优通道:
优先级 | 通道类型 | 协议前缀 | 条件 |
|---|---|---|---|
最高 | 内存通道 | mem:// | 同进程(同 PID + 同 hostname) |
高 | 共享内存 | shm:// | 同物理机(同 hostname) |
中 | Unix Socket / Named Pipe | unix:// pipe:// | 同物理机 |
低 | TCP | ipv4:// ipv6:// atcp:// | 跨物理机 |
新增的 atcp:// 协议名不预设 IPv4/IPv6,而是根据地址实际内容判定协议族,在双栈环境下更友好。
新版本的 node_register_req 会携带节点的 公开信息(listen 地址列表、支持的通道类型、支持的加密/压缩算法集),服务端验证通过后返还 node_register_rsp。验证机制使用 HMAC-SHA256 签名 access_key,防止未授权节点接入。
综合上面的拓扑注册表和连接模型,新的路由流程如下:
flowchart TD
Start["send_data(target_id, data)"] --> CheckLocal{"本地路由表
有 target 的 endpoint?"}
CheckLocal -->|"有,直连"| DirectSend["通过 endpoint 的
data_conn 直接发送"]
CheckLocal -->|"无"| CheckUpstream{"target 是
自己的 upstream?"}
CheckUpstream -->|"是"| SendUpstream["通过 upstream
ctrl/data_conn 发送"]
CheckUpstream -->|"否"| QueryTopo["查询 topology_registry
get_relation(self, target)"]
QueryTopo --> RelDown{"ImmediateDownstream
或 TransitiveDownstream?"}
RelDown -->|"是"| FindNextHop["沿拓扑树向下找
下一跳 child endpoint"]
FindNextHop --> HopExists{"下一跳
endpoint 存在?"}
HopExists -->|"是"| ForwardChild["经下一跳子节点转发"]
HopExists -->|"否"| FallbackUp["回退: 经 upstream 转发"]
RelDown -->|"否"| RelOther{"OtherUpstreamPeer?"}
RelOther -->|"是"| PolicyCheck{"check_policy:
可以直连?"}
PolicyCheck -->|"允许直连"| TryDirect["尝试建立直连"]
PolicyCheck -->|"不允许"| FallbackUp
RelOther -->|"否 (SameUpstreamPeer)"| PolicyCheck2{"check_policy:
可以直连?"}
PolicyCheck2 -->|"允许直连"| TryDirect
PolicyCheck2 -->|"不允许"| FallbackUp
style DirectSend fill:#90EE90
style FallbackUp fill:#FFB6C1
style TryDirect fill:#87CEEB与老路由的关键差异:
维度 | 老路由(subnet + 静态树) | 新路由(topology_registry + 策略) |
|---|---|---|
关系判定 | 子网掩码位计算 | 拓扑注册表链式查询 |
直连决策 | 父节点自动通知直连 | 策略规则判定,上层应用可控 |
拓扑变更 | 不支持动态变更 | update_peer / remove_peer,实时响应 |
代理层扩缩容 | 需要预分配 ID 段 | 动态注册和注销,支持 HPA |
跨区隔离 | 不支持 | labels 策略精确控制 |
这次 libatbus 的重构不是简单的代码翻新,核心是把路由模型从"静态子网树"切换到"动态拓扑注册表 + 策略驱动"。四个关键设计决策及其 trade-off:
这些变更为后续 libatapp 中的连接管理、重连机制、代理级联等上层特性提供了基座。关于 libatapp 如何在这个基座上实现 pending message 排队、拓扑变更响应、指数退避重连和代理级联传播,将在下一篇文章中展开。