最近把 MLAG 模块的代码从头到尾梳理了一遍,趁热记下这份心得笔记。MLAG 在交换机软件里是典型的“看概念很简单,真追代码到处都是细节”的模块:涉及两台设备的状态协商、跨设备的表项同步、硬件转发的去中心化设计,还有 peer-link 和 keepalive 这两条命根子。梳理完最大的感受是,MLAG 代码的复杂度不在于单段逻辑难懂,而在于所有关键路径都是跨设备的因果链——本地一个状态变化,对端要在一百毫秒内产生联动,中间任何一步断了,表现就是各种诡异的丢包和环路。这篇笔记既适合刚开始接触数据中心交换机软件、准备啃 MLAG 代码的开发者,也适合已经在维护相关模块、想系统整理一遍现场思路的同仁。
1. MLAG到底在解决什么问题
1.1 从普通链路聚合说起
先明确 MLAG 的定位。普通链路聚合(LAG)是把同一台交换机上的多个物理口绑成一个逻辑口,通过哈希把流量分摊到多条链路上。它解决的是链路层面的冗余和带宽扩展,但有一个天然边界:所有成员口都在同一台设备上,设备本身挂了,聚合组就整体失效。
MLAG(Multi-Chassis Link Aggregation Group)打破了这台设备的边界。两台交换机通过 peer-link(对等链路)互连,对外表现成一台逻辑设备,下游服务器或交换机用普通 LAG 双归接入这两台设备。对下游来说,它看到的仍然是单一聚合组,完全无感知;对上游来说,任何一台设备故障,流量可以无缝切换到另一台。
这个“对外像一台、对内是两台”的模型,就是 MLAG 所有代码复杂度的根源。梳理代码之前,一定要把这个问题在脑子里立住:我们写的每一行逻辑,要么是在维护“对外的一致性”,要么是在处理“对内的分工”。
1.2 MLAG 的三条命根子
MLAG 实现里一定有三个核心依赖,代码梳理时也可以围绕它们展开。
第一是 peer-link。它既是两台设备之间交换控制报文的通道,也是跨设备转发流量的数据通道。普通数据帧如果哈希到对端设备的成员口上,就要通过 peer-link 送过去,所以 peer-link 的带宽和时延直接影响跨设备转发性能。代码里对这个口的处理和其他口完全不同,很多实现都会给它单独的驱动队列和调度优先级。
第二是 keepalive。peer-link 断了不代表对端设备挂了,所以需要一条独立的心跳通道来探测对端是否存活。keepalive 的设计很讲究,后面我会专门说。它决定了系统进入双主模式还是直接接管。
第三是统一的系统标识。两台设备必须对外呈现相同的 LACP system-id,否则下游设备的聚合协商直接失败。这个统一标识通常是共享的系统 MAC 加系统优先级,所有涉及对外协议报文的代码都要从这里取值。
这三条命根子对应的代码路径,基本覆盖了整个 MLAG 模块的核心,后面的章节就按这个逻辑展开。
1.3 为什么代码梳理要有独立笔记
MLAG 的代码不像单机功能那样线性。一个功能点往往横跨配置管理、协议栈、内核表项、硬件驱动好几层,而且本地代码只表达了完整逻辑的一半,另一半在对端的进程里。单看一份代码很难建立全局观,必须通过日志、抓包甚至双机联调来反推设计意图。
所以我说,梳理 MLAG 代码值得专门做笔记。它不是看完能画出调用链就行,而是要记录大量“为什么这样设计”的决策。比如同步一个 MAC 表项,为什么要先判断这个 MAC 是从哪个成员口学到的?因为从 MLAG 成员口学到的 MAC,对端必须在本地也建一条出接口为 peer-link 的转发表项,否则对端收到发往这个 MAC 的流量就往 peer-link 上一丢,但表没建,就变成纯泛洪。这类细节,笔记里不记下来,三个月后再看代码又得从头推。
2. 从宏观到微观:MLAG代码模块怎么拆
2.1 分层思路:协议、平台、内核各管一段
我梳理模块的第一步,永远是先看代码目录结构,搞清楚边界。MLAG 在主流实现里通常分三层:协议层、平台适配层、内核与硬件层。
协议层负责 MLAG 的协商逻辑,包括 LACP 扩展报文的组装解析、状态机迁移、角色决策、表项同步消息的生成。这一层是纯逻辑,理论上不依赖具体芯片平台,可以在用户态独立测试。平台适配层负责把协议层的意图翻译成硬件行为,比如创建聚合口、绑定成员口、下发转发表项、配置端口阻断。内核与硬件层则是真正的数据面,包括驱动、转发芯片表项、报文收发。
这个分层不是随便分的,它对应一个重要的工程原则:协议逻辑不能和平台绑定。MLAG 的协商流程、状态机、双主检测策略,换了芯片平台也必须保持一致;而端口怎么阻塞、表项怎么下发,不同平台完全可以有不同的实现。梳理代码时如果发现某个“协议决策”被硬编码塞进了平台适配层,那基本可以断定这个实现后期会很难维护,这也是代码解耦问题的一个经典样本。
2.2 六大核心子模块的职责边界
按功能维度,MLAG 的代码大体可以拆成六个子模块。我习惯先画一张简单的职责表,再逐块看代码:
| 子模块 | 核心职责 | 典型代码入口线索 |
|---|---|---|
| 域管理 | MLAG 域创建删除、配置一致性校验、命名与优先级管理 | 命令行配置回调和配置数据库读写 |
| peer-link 管理 | peer-link 口创建、链路状态监控、控制通道建立 | 链路事件通知函数、聚合口创建回调 |
| keepalive 监控 | 心跳报文周期发送、超时判决、双主标记 | 定时器回调、超时处理函数 |
| 成员口管理 | MLAG 成员口的加入退出、LACP 协商状态同步 | 聚合组成员变更回调、LACP 状态机联动 |
| 表项同步引擎 | MAC/ARP/组播表项的批量同步、确认与老化 | 表项变更钩子、同步消息队列处理 |
| 状态机与角色决策 | 主备角色协商、故障转移决策、恢复流程 | 状态枚举、迁移函数、事件处理入口 |
这个拆法不是绝对标准,但很实用。你可以发现,这六个模块之间天然存在依赖关系:域管理是入口,peer-link 和 keepalive 是基础设施,成员口管理和表项同步是业务,状态机是大脑。梳理时按这个顺序走,能少走很多弯路。
2.3 状态机是整个模块的灵魂
MLAG 代码里最值得花时间啃的就是状态机。常见的状态集合大致是:disabled(未配置或未启用)、standalone(单机模式)、waiting(等待对端)、active(双活正常)、split-brain(双主分裂)。每种状态下,系统对成员口、peer-link、表项同步的动作都不同。
代码里状态机的实现风格主要有两种。老式的嵌入式代码喜欢用二维数组做状态迁移表,行为函数指针;新一些的代码多用 switch-case 加事件枚举。前者适合查全貌,后者适合看单个状态的逻辑。我的建议是梳理时先把状态枚举和事件枚举全部列出来,然后逐个状态问三个问题:进入这个状态前发生了什么?进入这个状态后执行哪些动作?哪些事件能让我离开这个状态?
这套问题问完,MLAG 的主干逻辑基本就通了。比如 active 状态里,成员口可能全部转发,peer-link 上的控制通道和数据通道都是通的;而 split-brain 状态里,低优先级设备必须把 MLAG 成员口全部阻塞,只保留 peer-link 口和管理口,避免双主同时转发造成广播风暴或重复帧。
3. 关键代码路径梳理实录
3.1 组建过程:从配置下发到 peer-link 起来
我实际追代码的顺序,是跟着日志走的。第一步是配置一个 MLAG 域并指定 peer-link 口,命令行的配置回调会创建对应的数据结构,然后触发底层聚合口创建。这个阶段可以看到两个关键点:一是配置的一致性校验,二是 peer-link 口的特殊标记。
一致性校验在组建早期就起作用。两台设备上的 MLAG 域 id、peer-link 口编号、聚合模式(通常是 active-active)必须一致,否则协议直接不进入协商。很多现场问题都是配了一边忘配另一边,代码里会打告警,但不会自动纠正。这个“校验不做自动修复”的设计是有意的,因为自动改配置可能引发更大的误操作,代码宁可通过告警让人去处理。
peer-link 起来之后,两台设备开始互发 MLAG 协商报文。报文里携带的信息包括域 id、系统 mac、角色优先级、成员口列表摘要等。这个阶段的代码重点在报文格式的解析和序列号的处理,尤其要关注版本兼容性字段。同一台设备上如果跑了不同版本的软件,协商报文里的版本号要能兼容,否则会出现起不来或反复震荡。
协商通过后,设备进入 active 状态,开始广播成员口信息并触发首次表项全量同步。首次全量同步是最容易出 bug 的路径,因为涉及大量消息的分批发送、接收方的乱序重组和确认机制。我看代码时特别留意了这批消息的队列深度和超时重传,任何一步处理不当,都会导致对端表项不全,而现场表现就是对端学不到 MAC。
3.2 成员口加入:LACP 协商背后的跨设备协作
MLAG 成员口的加入过程,是最能体现“跨设备协作”的一段代码。普通 LACP 协商是两台直连设备之间的事,但 MLAG 的场景里,下游设备只看到一台逻辑设备,所以它的 LACP 报文只会发给这个“逻辑设备”,而实际上报文可能到达两台中的任意一台。
这就带来一个核心设计:两台设备必须共享同一个 LACP system-id。代码里通常的做法是配置一个虚拟 MAC,或者从某个管理接口借用 MAC,然后两台设备在对外发送的 LACP PDU 中填相同的 system-id。而成员口的 port id 和 key 则按各自本地的实际值填写。下游设备收到报文后,会认为这是同一台设备的多个端口,正常完成聚合协商。
梳理这段代码时,我特别关注了 LACP 状态机与 MLAG 状态机的联动。本地成员口收到一个 LACP 报文,不能只更新本地状态,还要把关键信息同步给对端,让对端也知道这个聚合组里多了一个远端成员。如果对端不同步,那么对端收到的流量就无法正确转发到这个成员口对应的链路上,因为对端根本不知道这个成员口的存在。
成员口加入成功后,代码会触发一系列的后续动作:更新聚合组成员、刷新哈希表、同步 MAC 表、更新组播监听表。这一步是“从协议到数据面”的典型路径,也是梳理时最容易迷路的地方——因为成员口可能已经加入,但硬件表项还没下发完,中间被一个事件打断,状态就停留在中间态。
3.3 表项同步与硬件下发
MLAG 的表项同步是代码量最大的一部分,也是现场故障的重灾区。需要同步的表项至少包括:MAC 表、ARP 表、IPv6 ND 表、IGMP 组播监听表。路由表通常不需要全量同步,因为两台设备的路由计算是独立的,但依赖的接口状态要保持一致,否则路由可达性会出问题。
MAC 表的同步逻辑尤其有讲究。当一台设备从 MLAG 成员口学到一条 MAC,它会生成两条表项:本地的出接口是成员口,对端的出接口是 peer-link。这样才能保证哈希到对端的流量能通过 peer-link 转发回来。如果这个同步丢了,最常见的问题就是单播流量间歇性丢失——明明对端表里有 MAC,但出接口是空的,报文发不出去。
我梳理代码时画了一张简单的同步时序图:本地表项变更触发钩子函数,钩子函数把变更包装成消息,消息进入同步队列,队列由专门的发送任务处理,对端收到后先校验合法性,再下发硬件表项,最后回确认。每个环节都可以断,所以代码里必须有重传和超时机制。看代码时不要只盯着正常路径,要把超时、重传、队列溢出这些异常路径也看一遍,现场问题大多藏在这里。
硬件下发阶段的坑往往是顺序问题。比如新增一个成员口时,到底是先下发聚合组成员,还是先同步 MAC 表?实际做法通常是先让聚合组在硬件层面就绪,再开始表项同步。因为如果表项先到了,但成员口还没就绪,对端设备收到流量后往这个成员口转发,结果硬件层面还没有这个口,包就丢了。
3.4 故障切换:peer-link 断开时发生了什么
故障切换是 MLAG 代码里最惊险的路径,也是最容易出现“设计时想不到”的场景。peer-link 断了,两台设备之间无法直接通信,但两台设备本身都还活着。如果没有独立的 keepalive 通道,此时没有办法判断对端是死是活,必须等 LACP 超时,这是灾难性的。
所以 keepalive 的设计非常重要。代码里通常用独立的 IP 链路互发心跳报文,这个链路可以是带外管理口,也可以是独立物理口,绝不能和 peer-link 共用同一个物理链路。keepalive 超时后,设备进入双主分裂处理流程:通过角色优先级或系统 MAC 比较,决定谁是主设备,谁保留 MLAG 成员口,谁需要把成员口全部阻塞。
这段代码里有几个细节很值得看。一是角色的判断必须基于持久化信息,不能依赖协商报文——因为 peer-link 已经断了,协商报文过不来。二是阻塞成员口的动作要有延迟和重试机制,避免 keepalive 只是瞬断又恢复导致的反复震荡。三是主设备接管后,要主动广播一个“接管通知”,让下游设备尽快收敛,而不是傻等 LACP 超时。
恢复过程也一样重要。peer-link 恢复后,两台设备重新开始协商,此时不能直接把成员口全部放开,要先做配置一致性校验,再做表项增量同步,等确认两边表项一致后,低优先级设备才解除成员口阻塞。这个顺序如果反了,就会出现恢复期间的流量黑洞或广播风暴。
4. 我在梳理中踩过的坑和排查技巧
4.1 常见问题速查表
分享几个我在实际排障中反复遇到的问题,以及对应的代码排查思路:
| 现场症状 | 排查方向 | 常见根因 |
|---|---|---|
| 成员口加不进聚合组 | 查 LACP 报文中的 system-id 是否一致 | 共享系统 MAC 未生效或配置错误 |
| 对端学不到源 MAC | 查表项同步消息是否发出及对端是否确认 | 同步队列拥塞或编解码字段错位 |
| peer-link 断开后设备互殴 | 查 keepalive 是否走独立链路 | keepalive 与 peer-link 共用物理链路 |
| 恢复后流量仍丢包 | 查解除阻塞和表项同步的先后顺序 | 表项未同步完就放开了成员口 |
| 组播流不通 | 查 IGMP 监听表是否同步 | 只同步了 MAC 和 ARP,漏了组播表 |
| 双主误判后无法恢复 | 查角色优先级和系统 MAC 比较逻辑 | 两端配置了相同优先级且未规范 MAC 次序 |
这是我从实际排障经历中提取的高频场景。你可以把它们作为梳理代码时的“验收用例”——如果你的代码阅读能解释这些问题为什么发生、在哪里发生,那说明你真正读懂了 MLAG。
4.2 调试三板斧:日志、打点、抓包
我梳理代码时调试手段基本是三板斧。第一是日志分级与关键字过滤,MLAG 的关键路径必须能通过关键字把日志串起来,比如配置下发、协商报文收发、状态迁移、表项同步。如果你的代码里这些关键节点没有日志,那梳理时第一件事就是补日志,不补根本没法干活。
第二是在状态机跳转处打点。状态机的迁移是 MLAG 最核心的事件,每次迁移都要能打出一条包含旧状态、新状态、触发事件、原因码的日志。我在梳理时经常把状态机迁移日志接到一个脚本里,跑一遍故障场景,就能直接看出状态是怎么一步步走到 split-brain 的。这个习惯救过我很多次,强烈建议你也养成。
第三是抓包对比两台设备的报文。MLAG 跨设备协作的很多问题,单看一台设备很难发现,必须两侧对比。比如 member 口加入后,A 设备发了 LACP 报文,B 设备是否回了?报文里的字段是否符合预期?这些靠代码静态分析很难看出来,抓包一对比就清清楚楚。
4.3 时序与异步问题最隐蔽
MLAG 代码里最隐蔽的坑,是异步事件的处理顺序。两台设备的时钟本来就有偏差,报文在网络上有传输延迟,代码里各个任务又是并发跑的,这些因素叠加起来,就会出现“事件到达顺序和设计假设不一致”的情况。
一个典型的例子是:成员口从对端退出,同时本地又收到一条这个口的 MAC 表项。如果代码先处理了退出,再处理 MAC 表项,那么表项会被下发到 peer-link 口上,但成员口已经没了,这条表项就成了孤儿。现场表现是流量在 peer-link 上打转,直到表项老化。
这类问题的根治手段是给关键事件增加序列号或时间戳,并在处理时判断新旧。梳理代码时,我建议特别留意那些“修改某个数据结构但没有加锁”的地方,以及“先拔后填”的逻辑窗口。MLAG 是双机联动的模块,任何一个窗口被打开,都是潜在故障。
5. 梳理大型网络代码的心得方法
5.1 从点到面的阅读路径
如果你要梳理的不只是 MLAG,而是任何大型网络软件模块,我的建议是不要从头到尾顺序读代码。顺序读代码的缺点是容易在细枝末节里迷路,读到后面忘了前面。更有效的是“从点到面”:先找到你最容易理解的那个入口——接口函数、协议报文处理函数、或者某个关键数据结构——然后沿着它的调用关系向外扩展。
对 MLAG 来说,一个很好的切入点是 LACP 报文处理函数。从这个点出发,你能走到成员口管理、状态机、表项同步,几乎整个 MLAG 模块都能被串起来。每到一个新函数就问三个问题:谁调用我、我调用谁、我依赖什么全局数据。三个问题答完,调用链就画出来了。
实际梳理时,我习惯先用 grep 把所有相关的函数名和数据结构拉一个清单,然后分几轮阅读。第一轮只画主干调用链,不看具体实现;第二轮再看关键函数的细节,重点是对外行为(返回值、错误码、日志输出)和副作用(修改了哪些全局状态);第三轮才开始抠算法和边界条件。三轮下来,一个模块的脉络就清楚了。
5.2 让笔记成为长期资产
这次梳理我最大的收获之一,是形成了一个“边读代码边补注释”的习惯。不要小看注释的力量——很多历史代码里的注释已经过时了,梳理时顺手修正注释、补充设计意图,对后来人帮助巨大。我自己写注释的原则是:注释“为什么”,不注释“是什么”。比如“这里必须先同步表项再解除阻塞,否则恢复期间会出现转发黑洞”,这句话的价值远大于“解除阻塞并同步表项”。
另外一个实用建议是用 git 管理梳理过程中的实验性改动。如果你只是为了理解逻辑,临时打印日志、修改变量、加断点,请在独立分支上操作,不要直接改在主干上。这样你既能放心大胆地改,又能在梳理结束后通过 git diff 回看自己动了哪些地方。我在梳理中还习惯把每次复现问题的步骤记录到提交信息里,下次再遇到问题直接翻提交记录就能找到线索。
还有一点是关于代码解耦。你在梳理过程中一定会发现某些代码写得糟糕,协议逻辑和平台细节混在一起,改一处牵动全身。不要急着“优化”它,先记录下来。模块的边界和耦合点,本身就是一份宝贵的架构文档。等积累多了,你甚至能基于这些记录提出重构方案,那才是梳理代码带来的最大价值。
5.3 工具能辅助,思路才是核心
现代编辑器都支持代码跳转、引用查找、结构体预览,甚至还有 AI 代码补全,这些工具能大幅提升代码遍历的速度。但我要强调一点:工具不能替代思路。MLAG 这种跨设备模块,真正的难点在脑内建立“两台设备如何协作”的心智模型,这个只能靠多问为什么、多画图、多对照日志来建立。
我建议梳理时把两台设备的角色、状态、表项、事件始终放在对比视角里。看本地代码时,问一句“这时对端在干什么”;看对端代码时,问一句“本地配合这个动作的是什么”。把这些对应关系画在笔记里,你会发现整个模块的逻辑豁然开朗。这也是我觉得 MLAG 模块比其他模块更有意思的原因——它逼着你同时用两套代码、两个进程、两个时钟去思考问题。
我个人梳理完 MLAG 后最大的体会是:代码梳理这件事,永远不是读完就结束。每次带着问题去读,都能读到不一样的东西;每次顺手留下的注释和笔记,未来某个深夜排障时会成为最可靠的伙伴。如果你也正在啃这类跨设备的代码,希望这份心得能帮你少走点弯路,也欢迎你整理出自己的阅读路径,互相补全。