C10M:那个凌晨四点的教训

四万美刀。

这是财务部那天凌晨四点发来的Slack消息。不是问我,是告诉我:上个月的AWS账单长得像个电话号码。

我打开监控一看,64个核心全部跑满,丢包率25%往上,而我当时还在想是不是该换个更大的实例。

我们做的是实时竞价平台。50字节的小包,每秒几百万个,延迟要控制在毫秒级。这种流量写在PPT里挺好看,真跑起来就是另一回事了。

有个事儿没人提前告诉你:到了每秒200万数据包,Linux网络栈直接躺平。不是那种优雅降级,是像溺水的人一样拼命挣扎。

这就是C10M问题。C10K是每秒1万连接,C10M直接把零加了一位——千万级。2012年就有人提出来了,但Linux内核到现在还是处理不来。

C10M网络瓶颈问题可视化 C10M问题:当每秒数据包突破200万时,Linux网络栈就躺平了

Linux网络栈的门房病

Linux内核的网络栈用了几十年了。大多数时候挺好用的,但你要是真把流量往上顶,它就开始掉链子。

90年代或者2000年初,每秒10万个数据包就算顶天了。那时候每个数据包都当VIP贵宾处理:有自己的中断,有自己的内存拷贝,还要在netfilter的检查站走一圈。

数据包到网卡,门铃响了。CPU放下手里所有活儿,切进内核模式,拷贝缓冲区,穿过iptables迷宫,往TCP/IP栈上推,再拷贝到用户空间。每个包都走这套流程。

每秒100万个包的时候,CPU光顾着应门,根本没时间看邮件是啥。每次上下文切换1500到3000个CPU周期。到了200万,你还没看数据是啥呢,CPU预算就花光了。这就是为什么C10M需要完全不同的思路。

我们当时iptables配了50条规则。连接跟踪、基本检查,低速时感觉不到,一上规模就变成铅块。每个包都要过这些关卡。每秒1亿次检查。

你这时候不是在处理数据,是在运行世界上最贵的电话交换机。

第一次流量高峰时我盯着htop看,就像汽车发动机挂空挡踩红线。所有核心跑满,应用线程只能拿到5%的CPU时间。剩下全是内核在吵吵,争论下一个该轮到谁碰这个包。

DPDK:把操作系统踢出门

DPDK这东西,内核绕过第一次听的时候我觉得挺扯淡。你告诉操作系统"谢了但不用了",然后把网卡的DMA内存直接映射到你的应用里。

内核网络栈 vs DPDK架构对比 左边:传统Linux网络栈的多层处理,右边:DPDK的零拷贝直接路径

就像快递直接送到家门口,而不是让每个包裹都经过邮局、分拣三次、盖章、登记。数据包落在内存里,你的代码直接读。没有中间商。

DPDK不是个库,是给操作系统的逐客令。Intel告诉你内核层从来都是可选的。

但代价立马就来了:你啥都没了。没有TCP栈,没有socket API,没有netstat,没有tcpdump,没有你熟悉的安全网。网卡从操作系统里彻底消失了。

搭建这玩意儿不像在写代码,更像在黑屋子里拆炸弹。你得像抢占战场一样提前划分Hugepages——在内核能碰之前就在启动时预留内存:

// 破釜沉舟 - 重启前内核别想拿回这块网卡
// 一次性核选项:./dpdk-devbind.py --bind=vfio-pci eth1

struct rte_eth_conf port_conf = {
    .rxmode = {
        .mq_mode = RTE_ETH_MQ_RX_RSS,  // 要么RSS要么死:把火分摊到各个核心上
    },
};
// 这儿就是你从操作系统手里抢硬件的地方
rte_eth_dev_configure(port_id, nb_rx_queues, nb_tx_queues, &port_conf);
// 预先煮好一池子数据包缓冲区(以前内核还得帮你盯着这个)
struct rte_mempool *mbuf_pool = rte_pktmbuf_pool_create(
    "MBUF_POOL",
    8192,          // 太少了负载一上来就得饿死
    250,           // 每核心缓存实现无锁性能
    0,             // 私有数据大小 - 通常就是零
    RTE_MBUF_DEFAULT_BUF_SIZE,  // 2KB一块
    rte_socket_id()  // 绑到本地NUMA或者准备好交延迟税
);

跑完这个,你的网卡就不见了。ip link show都看不到它。没了。现在它归你的进程了,内核就站在那儿一脸懵逼,不知道自己硬件去哪了。

把操作系统踢开之后,实际循环简单得让你不敢信:

while (1) {
    struct rte_mbuf *bufs[32];  // 批处理就是一切 - 缓存局部性

    // 直接轮询硬件 - 不等待,不系统调用
    uint16_t nb_rx = rte_eth_rx_burst(port_id, queue_id, bufs, 32);

    // 这轮出现的任何东西现在都是你的了
    for (int i = 0; i < nb_rx; i++) {
        // 你的数据包逻辑 - 解析、路由、响应
        // 全用户空间内存,从网卡零拷贝
        process_packet(bufs[i]);
    }

    // 把缓冲区还回去,不然你就泄露了然后死掉
    rte_pktmbuf_free_bulk(bufs, nb_rx);
}

结果挺让人无语:8个核心在40%负载下打哈欠,干着以前64个核心满载还干不完的活儿。同样的流量模式,同样的数据包数量。我还以为我们引入了个bug在静默丢包呢。结果不是。就是……终于效率正常了一次。

XDP:精确打击

如果说DPDK是核选项,那XDP就是精确打击。你还在内核的地盘,但你在门廊干活,在数据包敲门之前通过猫眼查身份证。你的eBPF代码运行在驱动层,在数据包碰到网络栈之前就拦截了。

// 运行在内核空间但在驱动层 - 在所有东西之前
SEC("xdp")
int xdp_drop_garbage(struct xdp_md *ctx) {
    void *data = (void *)(long)ctx->data;        // 数据包开始
    void *data_end = (void *)(long)ctx->data_end; // 边界

    struct ethhdr *eth = data;
    // eBPF验证器很偏执 - 边界检查否则程序被拒绝
    if ((void *)(eth + 1) > data_end)
        return XDP_DROP;  // 格式错了,在门口干掉

    // 任何不是IPv4的东西都死在这儿,内核永远看不到
    if (eth->h_proto != htons(ETH_P_IP))
        return XDP_DROP;

    return XDP_PASS;  // 让正常流量通过到正常处理
}

我们现在用这个做DDoS防护。上个月我们被每秒500万个数据包攻击——某个有僵尸网络太多时间的脚本小子——XDP在车道里干掉了480万个垃圾数据包,它们连门都没敲着。内核根本没看到它们。CPU使用率几乎没动。剩下的20万正常数据包就像啥都没发生一样得到了正常处理。

但XDP的好日子在你需要追踪状态的时候就结束了。eBPF验证器开始对你尖叫。没有无界循环。没有"也许这个指针有效"这种鬼话。如果它觉得你可能会让内核崩溃,你的程序就被拒了。简单包过滤?完美。有状态处理或者复杂协议?不管你喜不喜欢,你都得回到DPDK。

代价:入场费

这是入场费:你基本上把整个运维剧本都删了。所有监控?没了。安全工具?没用了。你花好几年建立起来的调试肌肉记忆?完全错了的环境。

需要抓流量?tcpdump啥也看不到,所以你得从零开始写自定义工具。想要连接统计?最好把这玩意儿建到你的应用里。防火墙规则?现在这是你的问题了——每个安全逻辑都在你代码里,而且当它坏了的时候,没有内核可以怪。

调试过程简直是心理战。我们有个内存泄露花了三个星期才找到。三个星期盯着十六进制转储看,看到眼睛都流血了。标准工具看不到DPDK的内存池。gdb能attach但显示的是垃圾。valgrind完全瞎了。我们最后给代码加了自定义统计转储,每10秒一次,然后手动跟网卡计数器关联,就像在1985年调试汇编一样。

部署呢?我们有次生产推送失败了,因为有人——我们就叫他Dave吧,因为我还在生气——忘了在内核更新后把网卡重新绑到vfio-pio。Dave以为自动化脚本处理重新绑定。并没有。应用启动得挺好,发现零个DPDK控制的网卡,就坐在那儿一声不吭。没有错误。进程运行中。健康检查通过。监控仪表板看起来像心电监护仪上的直线。我们没有失败,我们只是……不存在了。Dave花了45分钟看应用日志,而网卡就在角落里,被全世界无视。

基准测试:当理论在现实中活下来

我们把两个一模一样的盒子并排放在一起——都装了双至强6138,都是Intel X710网卡——然后朝它们扔同样的UDP洪流,看谁先挂。

我们往内核扔了所有"最佳实践"博客文章——绑定IRQ绑到脸都蓝了,把环形缓冲区调到月球上去,配置RPS和RFS就像命都靠它们一样。没用。32个核心为了活命才��强到每秒180万数据包,还丢了18%的流量。风扇听起来像停机坪上的喷气引擎。风扇越响,我们丢包越多。这简直就是我们失败架构的警报器。

DPDK vs 内核网络性能基准测试 基准测试结果:内核网络在180万PPS时已经力不从心,DPDK轻松达到920万PPS

DPDK实现,同样的负载:每秒920万数据包持续。12个核心60%负载。丢包率低于0.001%,而且大部分还是我们用户空间逻辑里的bug,不是框架的问题。

内核网络低速巡航的时候挺好的。但一旦你跨过每秒100万数据包的门槛,事情就开始晃悠。到了200万,轮子彻底掉了,你就是在花钱给亚马逊,看你CPU在上下文切换上把自己折腾死。

警告:隔离失效

你知道比每秒200万数据包内核网络窒息更糟糕的是啥吗?是你的DPDK应用在每秒1000万数据包时有缓冲区泄露。那个数据包速率,你能在三秒内耗尽网卡缓冲区。我们有个缓冲区回收的差一错误,在流量高峰时导致完全崩溃。那个月的SLA图显示参差不齐的下降,看起来像有人拿锯子锯过一样。恢复意味着完全重启,意味着五秒钟什么都丢,同时CFO的Slack私信里的脏话越来越有创意。

祝你能找到真正能维护这玩意儿的人。我问了个分布式系统大师——这哥们有NSDI论文,能聊好几小时Paxos——怎么处理无锁环形缓冲区的缓存缺失,他看着我的眼神就像我在说火星语。他都不知道TLB缺失是啥。我们面试了能在睡梦中背诵RFC的网络工程师,但没有GUI就不会调试内存泄露。韦恩图的重叠部分微乎其微。

而且会叠加。每次内核升级都意味着重新测试hugepage配置。每次网卡固件更新都是潜在的地雷。我们维护一个单独的分级环境专门用于包处理,因为失败模式太怪了,正常QA根本抓不到。

但说实话?如果你生产环境真的推过了每秒500万数据包,你没得选。Linux内核会失败。不是因为它设计得烂——它对99.9%的用例都很棒——而是因为CPU比网络快的假设已经死了。那个假设大概在2015年左右就崩了,从那以后我们一直生活在后果里。

别因为这玩意儿酷就去做。做是因为你没别的选择了,你已经尝试了其他所有东西。内核绕过管用,它很糟糕,我这辈子因为这玩意儿失眠的时间比任何其他架构决策都多。

最后说几句

Linux内核网络就像个过度热情的门房。低速时挺好,但到了每秒百万级数据包,它光顾着应门了,根本没时间处理邮件。

给你几个参考:

  • 每秒100万数据包以下?内核网络够用,别折腾
  • 每秒100-200万?优化中断、缓冲区、RSS,还能再顶一顶
  • 每秒200万以上?考虑DPDK或XDP,但准备好把运维工具链全扔了
  • DPDK是核选项,XDP是精确打击
  • 最重要的:这玩意儿很难维护,招人比找对象还难

你现在处理网络流量最头疼的是哪一步?是还没遇到瓶颈,还是已经在每秒百万级边缘挣扎了?或者你已经踩过DPDK的坑了?

下回我们可以聊聊怎么观测这些高性能系统的状态——毕竟你都绕过内核了,传统的监控工具基本都瞎了。或者你想知道怎么在应用层实现TCP栈?这又是一个大坑,但有人踩过了,我们就能避开那些坑。


觉得这篇文章有用吗?

  1. 点个赞让更多人看到
  2. 分享给可能需要的朋友或同事
  3. 关注梦兽编程,不错过更多实用技术文章
  4. 有问题或想法?欢迎在评论区交流

你的支持是我持续创作的最大动力!