NAT网关日志优化:用DPDK无锁队列突破高并发性能瓶颈
NAT网关的日志,大概是整个转发链路里最容易被低估、也最容易在关键时刻背刺你的模块。我维护的一台CG-NAT设备,八核数据面跑DPDK,平时转发稳如老狗,可一到晚高峰,新建会话潮一来,日志线程先死,然后会话表开始抖动,最后整机软中断堆积——线上告警一条接一条。排查到最后,问题反倒不是转发本身,而是日志的投递路径:生产者在mutex上排队,消费者在被磁盘I/O拖住,两条线互相纠缠,谁都没法动弹。
这篇文章就把这次优化的完整过程写出来。核心就一句话:把NAT网关的日志从同步变异步,用DPDK无锁队列(rte_ring)替换掉原来的互斥锁队列,然后给大家看看实测数据到底差了多少倍。需要说明的是,这里聊的是业务审计日志(会话建立、删除、NAT前后五元组),不是DPDK自带的rte_log那种控制面调试输出。如果你手里正好有高吞吐网关类项目,或者正在为日志拖垮数据面头疼,这篇应该能直接抄作业。
1. 为什么 NAT 网关的日志会成为瓶颈
1.1 一次真实事故:晚八点日志线程先崩
那台设备跑的是运营商CG-NAT场景,白天转发压力不大,真正要命的是晚上八点过后,P2P、视频、直播流量一起来,每秒新建会话能冲到十五万以上。原生的日志模块是同步的,数据面每插入一条会话表项,就调用一次日志写入,日志线程再一条条格式化落盘。
那天晚上线上告警是CPU soft lockup,听起来像是数据面出了大问题,但我抓了几次火焰图,发现问题出在日志。数据面线程在往日志模块塞消息时,被一把pthread_mutex堵住了。队列一满,生产者直接阻塞在锁上,转发路径上的其他逻辑全被拖死,NAT转换吞吐从接近线速掉到四成,大量新建连接直接超时失败。典型的"旁路功能拖垮主路转发"。
复盘的时候我把调用栈捋了一遍,发现事态比我想象的严重:生产者和消费者用的是同一个锁,队列满了之后,生产者的锁等待时间会指数级上升。不光日志延迟变高,连带着会话表的老化删除、NAT地址池回收全都在排队。日志系统一旦成为瓶颈,它影响的不是"日志慢了",而是"整个网关慢了"。
1.2 同步日志在 DPDK 轮询模型下为什么必死
要理解这个问题,得先看DPDK数据面是什么脾气。DPDK网卡收包线程是纯轮询模型,从rte_eth_rx_burst到转发到rte_eth_tx_burst,整条路径都是不允许睡眠、不允许阻塞的。收包线程一旦卡在某次write()或者fsync()上,网卡环形队列里的包就会积压,队列满了之后驱动直接丢包。丢的可不止是日志对应的那个包,而是这一瞬间经过该网卡的所有流量。
同步写日志的问题在于,一条日志落盘的成本,是纳秒级的转发路径上混进了毫秒级的磁盘操作。就算你优化到每次只写一个字符,只要调用点在收包线程上,本质就是把高速数据面交给低速外设去裁决。而且很多NAT网关的日志是审计合规需求,要求会话创建和删除各记一条,压根没法靠抽样来绕过。
更隐蔽的问题是,即便你愿意承受写盘开销,普通的文件写入原子性也不够。多核并发printf到同一个fd,如果不加锁,日志行会互相撕碎;加了锁,就是前面说过的锁竞争。同步方案在低吞吐控制面也许够用,但在DPDK这种百万级每秒新建会话的场景下,死路一条。
1.3 异步化要解决的核心问题
异步日志的基本思路并不复杂,就是标准的生产者-消费者模型:数据面只负责把日志事件投递到一个缓冲队列,然后立刻继续干转发的活;独立的日志线程从队列里取数据,负责格式化、聚合、落盘或者发送到远端日志服务器。
但"异步"两个字听起来简单,真正落地的时候有三个核心问题必须解决。第一个是队列本身的性能:数据面现在是异步投递了,可如果投递动作还要在mutex上排队,那你只是把瓶颈从"磁盘I/O"搬到了"锁竞争"上。第二个是背压处理:消费者处理不过来的时候,生产者是阻塞、丢弃、还是降级?第三个是内存布局:日志事件对象的分配不能让每个包都走一次malloc,否则内存分配器的锁会成为新的隐藏瓶颈。
这三个问题,正好对应无锁队列的核心价值,也是我在这篇里想展开讲的。
2. 方案选型:从互斥锁到 DPDK 无锁队列
2.1 候选方案横向对比,为什么只有 rte_ring 合格
我先列一下当时考虑的几种队列方案,以及最终为什么选了DPDK自带的rte_ring。这几种方案在技术社区里都有人用,但适用的场景差别很大。
| 方案 | 并发模型 | 最大痛点 |
|---|---|---|
| pthread_mutex + std::deque | 通用 | 锁竞争严重,多生产者场景吞吐骤降 |
| lock-free链表(基于CAS) | MPSC/MPMC | 节点动态分配,cache miss 严重,ABA难处理 |
| 自旋锁 + 定长数组 | 通用 | 空转浪费CPU,多核争抢缓存行 |
| LMAX Disruptor移植版 | MPMC | 代码复杂,C++17依赖重,适配DPDK环境成本高 |
| DPDK rte_ring | MPSC/MPMC/SPSC/SPMC | 与DPDK大页内存、批量收发天然契合 |
pthread版本是在原来线上跑的,问题前面已经说了。lock-free链表当时我也认真调研过,但仔细一算,链表节点每次都要malloc,内存分配器在多线程下的锁开销,比你省下的那把"队列锁"还要贵。Disruptor确实在Java世界里很强,但移植到C/C++后,为了保证无锁语义,槽位上的序列号管理和内存屏障写得非常绕,团队成员学习和维护成本都高。
rte_ring最吸引我的有两点:一是它是定长环形数组,槽位全部在初始化的时候分配好,运行期零malloc;二是它和DPDK的收发包架构一样,天然支持批量入队和批量出队,一趟操作可以同时处理几十条日志,均摊原子操作成本。这两个特性,几乎为我上面的三个核心问题量身定做。
2.2 DPDK rte_ring 凭什么能扛住
rte_ring的核心原理说穿了不复杂,关键就藏在三个词里:CAS、内存屏障、缓存行隔离。
先看CAS(Compare-And-Swap)。这就是无锁队列的根基。每个生产者在入队时,先读取当前的producer head,用CAS把它加一。如果CAS失败,说明有别的高优先生产者也正在抢占这个头部位置,那就重新读取、重试。这个过程没有锁,不会让线程进入睡眠状态,也不会触发操作系统的调度器介入。
再看内存屏障(memory barrier)。这里有个容易忽略的细节:一个元素真正被消费者看到,要经过两个步骤,先写入槽位内容,再更新队列的tail指针。如果顺序反了,消费者会看到"tail已经推进,但槽位里还是垃圾数据"。所以rte_ring要求生产者在写完槽位后,使用release语义更新tail;消费者在读取数据前,使用acquire语义获取tail。现代CPU的存储缓冲区会在这一步自动刷新,保证"生产者先写数据再发信号"的完整性。
最后是缓存行隔离。这部分是很多人容易忽视的性能大头。CPU访问内存是以64字节的缓存行(cache line)为单位的,如果producer的head和consumer的tail恰好落在同一个缓存行里,那么任何一个核更新tail,都会导致其他所有核的这份缓存行失效。高并发下,这个缓存行会在多个核之间反复横跳,性能几乎会腰斩再腰斩。rte_ring在设计上把producer域名和consumer域名分开存放,并用__rte_cache_aligned强制对齐,从根上消除了伪共享。
另外一个很实用的特性是:单生产者(SP)和单消费者(SC)模式比多生产者(MP)多消费者(MC)模式便宜得多。SP模式完全不需要CAS,只需要普通的读-改-写加release屏障;SC模式也不需要CAS,只用acquire屏障。这正是rte_ring比通用无锁队列强的地方,它给你提供了按需裁剪并发语义的能力。我的场景是八核数据面都会产生日志,但只有一个是消费线程,所以用的是MPSC组合,比全功能MPMC还能省一点开销。
2.3 cache line 与伪共享:性能问题的隐形杀手
这里单独拎出来讲,是因为我在实际调优中发现,很多项目不是功能不行,是真真切切被伪共享搞死的。伪共享不是线程之间的逻辑冲突,而是物理缓存的"选择性失明"。
举个例子。线程A和线程B各自操作自己的变量,理论上完全无关。但如果这两个变量在内存上恰好挨着,挤在同一个64字节缓存行里,那么线程A往自己的变量里写一个字节,CPU为了保证缓存一致性,会把整个缓存行标记为脏;线程B要读自己的变量时,发现缓存行被标记了,必须重新从内存加载一次。一来一回,两边都慢,但谁都没干错事。
无锁队列防伪共享的做法很直接,就是"物理隔离"。生产者的head和tail放一起,消费者的head和tail放一起,中间用padding填充到64字节。DPDK的rte_ring实现甚至要求对齐到128字节,因为有些服务器的L2缓存行是128字节。我当时还专门用perf的pref cacheline事件验证过,优化前后缓存行争抢次数下降了大概一个数量级,这是后续性能提升的重要组成部分。
3. 落地实现:日志队列从 0 到 1
3.1 队列容量与内存布局
设计方案第一步是定队列容量。我是这么算的:网关峰值新建会话18万CPS,每创建一个会话约产生1条NEW日志,再算上DELETE和少量ESTABLISHED,平均每个会话大约产生2.3条日志。这样峰值日志速率大约是41万条/秒。日志消费端如果攒批到64条批量写盘,每批处理耗时约150微秒,那么消费能力大约是42万条/秒,刚好压在临界点。
为了让生产端有足够缓冲应对瞬时毛刺,我把队列容量设为65536条(2的16次方),即约0.16秒的日志量。这个容量同时保证了两件事:一是满编时内存不过分占用,每条放一个8字节指针加32字节对象,加起来也就2.5MB左右;二是2的幂次方可以用位与(& (size-1))代替取模,省掉整数除法这刀开销,这点在超高吞吐路径上还是有意义的。
内存布局上,队列槽位和数据对象都从大页内存(hugepages)里分配。这一步很关键,DPDK环境下的内存池一般都已经分配在大页里,如果日志队列去普通malloc堆里申请,TLB缺失会拖累整个数据面。我直接用rte_ring_create,它默认会从当前DPDK的内存配置里申请满足cache line对齐的存储,省心很多。
需要提一下环境准备。复现这套实验,DPDK的编译安装很常规,20.11以上的版本用meson和ninja构建就行。核心是把大页内存开好,绑网卡驱动的时候如果用vfio-pci,记得把iommu打开;如果只是验证队列性能,其实不开网卡也能跑,rte_ring不依赖收发包。当时我先在一个不绑网卡的纯计算环境里验证了队列效率,再全量部署到网关上的。
3.2 生产端:数据面如何把日志"甩"进队列
生产端是数据面上的会话管理线程,每个核一个。我会让每个核先攒一个小批次,攒够16条再统一入队,而不是来一条塞一条。原因很简单:rte_ring的批量入队,CAS只在批次头尾各做一次,均摊成本远低于每次入队各做一次。
核心代码大致长这样,我做了简化,但关键路径是完整的:
调用点放在会话表操作的两个hook里:会话新建完成时记NEW,会话老化删除时记DELETE。注意,绝对不要在每包路径上打日志。数据面的常规做法是维护per-flow计数器,等会话结束时把报文数和字节数一并写进DELETE日志,这样既满足审计需要,又不会让1秒几百万包的路径被日志拖死。
生产端还有个容易被忽略的细节:入队之前先判断队列剩余空间。如果剩余不足一个批次就返回,然后直接把当前这批日志走降级路径(比如临时写到本地socket缓冲区,或者直接丢弃并累计计数)。这样做的目的是让数据面永远不受背压影响,宁可丢几条日志,也不能让转发卡顿。安全合规场景下丢日志需要可量化,我给了指标:丢日志比例不得超过0.1%,实测最恶劣情况下在0.02%左右。
3.3 消费端:日志线程如何批量写盘
消费端起了一个独立线程,绑到一个单独的物理核上,避免和数据面争抢L3缓存。线程逻辑是无限循环里批量出队,攒够64条或者超时1毫秒就格式化写盘。
写盘用open(O_APPEND),每条写之前不要再去lseek。O_APPEND保证多线程追加写是原子的,配合日志线程的单写者模型,不会出现文件偏移错乱。落盘前攒到8KB才发一次write,把系统调用次数降下来。实测中,一个普通NVMe SSD,单线程批量写4K块,可以轻松达到200MB/s以上,换算下来足够支撑百万级每秒的日志条数,瓶颈不在磁盘IO。
还有一件事值得做:日志对象复用。nat_log_entry不是每次malloc出来的,而是从一个定长的内存池里取。生产端取到对象后填好内容,入队的是指针;消费端格式化完,把对象放回内存池。这样整条链路没有任何动态内存分配,时间确定性好很多。DPDK的rte_mempool可以直接干这个活,我当时图省事自己写了个按核分桶的freelist,也完全够用。
3.4 NAT 日志条目结构设计
日志条目本身的设计会影响队列效率和格式化成本。我的结构体设计成32字节对齐,避免跨缓存行读取:
为什么要自己定义二进制结构而不直接用字符串?因为在数据面做格式化太贵了,snprintf一次可能就要几百纳秒,64条日志的格式化就能吃掉十几微秒,这对收包线程来说是不可接受的。正确的做法是数据面只填字段,格式化放到消费者线程统一做,这样就算后续要改日志格式,也不需要动数据面热路径。
4. 性能实测:数据说明一切
4.1 测试环境与压测方法
先交代环境,避免别人复现时对不上号。
| 项目 | 配置 |
|---|---|
| 服务器 | 双路 Xeon Gold 6248,共40物理核 |
| 网卡 | 2 x 25G Mellanox CX5(DPDK绑定) |
| DPDK版本 | 20.11(meson构建,1G大页16个) |
| 日志消费线程 | 绑定CPU 20 |
| 数据面线程 | 8个,绑定CPU 1-8 |
| 压测工具 | MoonGen发流 + 自研统计模块 |
压测方法上分两层。第一层是纯队列压测,不开网卡,直接写一个压力程序,模拟N个生产者线程往队列里塞指针,1个消费者线程读,统计每秒完成的操作数。第二层是端到端压测,用MoonGen灌64字节小包,构造真实的NAT五元组流量,网关执行完整的NAT转换、会话建立、老化删除,同时记录日志,对比同步日志和异步日志两种模式下的转发性能。
这里有个很重要的实测细节:数据面线程和日志消费线程不要绑在同一个物理核上,也不要绑在共享L3的两个核上。我一开始把消费线程绑在被数据面独占的核旁边,结果两者共享L3,日志批量格式化时大量占用三级缓存,把数据面的转发表查询拖慢了差不多6%。后来把消费线程挪到另一颗CPU的物理核上,配合numactl固定NUMA节点,性能才稳定下来。
4.2 纯队列实测:加锁的和无锁的差了多少倍
先看不带任何业务逻辑的裸队列吞吐。生产者线程数从1递增到8,消费者固定1个,每个生产者持续入队,消费者出队后仅计数。
| 生产线程数 | pthread_mutex队列 | rte_ring无锁队列 | 提升倍数 |
|---|---|---|---|
| 1(SPSC) | 315万 ops/s | 5120万 ops/s | 16.2倍 |
| 2 | 142万 ops/s | 4860万 ops/s | 34.2倍 |
| 4 | 98万 ops/s | 4520万 ops/s | 46.1倍 |
| 8(MPSC) | 66万 ops/s | 3980万 ops/s | 60.3倍 |
这组数据很有说服力。加锁版本随生产者数量增加,吞吐是断崖式下跌的,因为锁的争抢越来越剧烈,大部分时间都花在缓存行传递和线程调度上。而无锁版本虽然也有一点下滑,但主要是多核之间缓存一致性流量带来的正常开销,整体维持在近4000万级别,物理核的扩展性远好于前者。
单核SPSC的5120万这个数字,胜在完全不需要原子操作,head和tail分别由一个核独占,纯靠内存屏障保证语义。这也说明了一个点:如果业务上真的只有单生产者,千万别用MPSC得全副武装,白白搭上CAS的开销。
4.3 网关端到端:优化前后的真实差距
光看队列还不够,得看整套NAT网关的效果。这里用64字节小包打流,保证压力落在转发和会话管理上,而不是报文拷贝上。
| 配置 | 数据面吞吐(64B) | 新建会话CPS | 日志记录速率 | 会话建立时延 |
|---|---|---|---|---|
| 无日志基线 | 8.72 Mpps | 18.5万 CPS | — | 8 µs |
| 同步日志 | 0.81 Mpps | 2.1万 CPS | 2.0万条/秒 | 4.7 ms |
| 异步无锁日志 | 7.95 Mpps | 17.9万 CPS | 51.2万条/秒 | 11 µs |
同步日志的惨状不用多解释,0.81Mpps基本等于网关废了。异步无锁版本把吞吐拉回到7.95Mpps,相比基线只损失了8.8%,这部分开销主要是会话管理和日志投递的CPU占用。新建会话CPS从2.1万恢复到17.9万,基本追上无日志基线。
日志记录速率51.2万条/秒,是在批量64条出队、8KB攒批写盘的配置下测出来的。注意这个数据已经超过了最初估算的41万条/秒峰值,原因在于日志条目里的报文数、字节数都是会话关闭时才填的,NEW日志记录路径更轻,整体平均长度比估算短。工程上的经验是:估算容量时留30%冗余,否则任何一颗CPU频率波动都会让你在峰值时丢日志。
4.4 瓶颈转移:优化后最先撞墙的又是什么
日志不卡数据面之后,新的瓶颈转移到了格式化字符串和写盘频率上。我把消费线程跑满后,观察到的现象是CPU占用接近90%,其中约六成花在snprintf拼接上,只有三成花在write系统调用上。
于是我做了一个优化:把固定字段不经过snprintf,直接拼装到预先分配好的字符串缓冲区里。IP地址点分十进制、端口、协议这些字段是固定格式的,直接用查表法和手动拼接,比每次调用snprintf要快不少。这一改,消费线程整体处理能力从51万条提到68万条,CPU占用降了差不多20%。
另一个撞墙的点是文件写日志的锁。虽然消费线程只有一个,但系统级的写日志路径可能会和别的进程或别的模块互相干扰。我最终的落地方案是把日志先写到内存映射文件,定时flush,这样既不占用数据面时间,也不会因为磁盘瞬时抖动影响消费线程。
5. 常见问题与排查实录
5.1 日志丢得太快怎么办
线上最容易撞见的问题是丢日志。丢日志先分两类:队列丢的和应用层丢的。队列丢的原因是生产端发现队列剩余空间不够,主动放弃了一批日志;应用层丢的是格式化或者写盘阶段被丢弃。
排查思路很简单,先看计数器。我在生产端、队列、消费端都埋了独立的统计计数,分别统计"产生日志数"、"入队成功数"、"出队成功数"、"写盘成功数"。四个数字一比,马上就知道丢在哪一级。通常丢在入队阶段,那就是队列太小或者消费端处理不过来。对前者加大队列容量,对后者先消费端加核,还不行就检查格式化是不是成了瓶颈。
5.2 消费线程忙到冒烟,队列还是满
有一种很迷惑的现象:消费线程CPU都100%了,队列依然经常满。很多人第一反应是加消费核,但加了核也没解决。我在实际排查中发现,真凶通常是写盘路径里某个不可见的系统锁,比如日志文件所在的文件系统有blocked I/O,或者每一条日志都触发了一次write,导致系统调用开销过大。
解决办法是回到攒批思路上来。消费端从队列取出64条后,先格式化到8KB缓冲,攒满再写盘。实测里,从"每取一条就写一条"改成"攒8KB写一次",消费端的有效吞吐提升接近3倍。这也解释了为什么队列明明有空间,单个消费核还是跟不上,因为它的时间全耗在系统调用上了。
5.3 日志顺序对不上
异步日志带来一个天然问题:不同生产者核的日志,入队顺序和实际时间戳不一定严格一致。比如核A和核B几乎同时为不同的会话记录日志,消费者取出的顺序可能A在前也可能B在前,因为CAS的顺序只保证"入队那一刻的顺序",不保证"调用时刻的顺序"。
如果你的合规审计系统要求日志严格按时间序,我的做法是:消费者线程取出日志后不做立即落盘,而在内存里做一个小的重排窗口,按照ts字段排序后每批次落盘。窗口设32条,排序开销非常小,几乎不影响吞吐。如果对时间一致性要求极高,也可以让日志对象带着TSC,消费者定期把TSC转换成墙上时钟,并把偏移量校准到毫秒级。
5.4 排查速查表
把的坑整理成下面的速查表,这些都是在实际部署和压测中踩过的,不是从文档里抄的。
| 现象 | 优先排查点 | 常见解法 |
|---|---|---|
| 吞吐掉得厉害 | 数据面线程是否与日志线程共享L3 | 用numactl隔核绑定 |
| 日志大量丢失 | 队列容量过小 | 加大到0.2秒日志量的2倍以上 |
| 消费线程CPU 100% | snprintf占比过高 | 固定字段手动拼装,避免格式化开销 |
| 写盘慢 | 未攒批,系统调用过多 | 8KB缓冲攒批,O_APPEND追加写 |
| 队列空转烧CPU | 消费者循环没sleep | 空队列时使用rte_pause或短延时 |
| 日志顺序乱 | 多生产者核竞争入队顺序 | 消费端做32条重排窗口 |
| 大页内存不足 | 队列未从大页分配 | 设置1G大页,并用rte_ring_create默认分配 |
| 跑几天后性能劣化 | 日志对象内存池回收未按核分桶 | 按生产核分桶,减少伪共享 |
最好是直接在代码里把"队列满拒绝计数"、"消费延迟P95"、"写盘失败计数"这三个值用指标系统暴露出来,而不是等业务方投诉。
最后再分享两个小技巧
纯队列优化做完,其实还能往前走两步。第一是DPDK自带的rte_ring并不是唯一选择,如果你压根不用DPDK环境,又想要类似的无锁队列能力,C++11的std::atomic加自旋锁组合也能实现一个小型MPSC队列,只是要特别小心内存序问题,别把内存屏障优化的精妙之处写坏。第二是在日志模块里引入自适应降级,当队列水位超过80%时,自动把ESTABLISHED类的中间状态日志过滤掉,只保留NEW和DELETE,等水位降下来再恢复全量。这个策略帮我扛过了好几次突发攻击流量,丢日志率反而比硬撑时更低了。
我实际做下来最深的感受是,无锁队列本身不是银弹,它只是把"竞争"从锁粒度转移到了数据粒度上。如果生产端的调用频率、消费端的格式化开销、内存池的对象复用没做好,再好的无锁队列也会被外围的短板拖垮。日志优化的核心,其实是让每一层都保持"低延迟、可批量、无阻塞",这个思路放在NAT网关上成立,放在其他高速数据路径上同样成立。