分类: 未分类

  • 突破数万 NVPS 的监控积压:Zabbix Proxy 架构解耦与底层数据库 IO 重构

    刚把监控大盘上的 Zabbix Queue 积压量从 50 万硬生生压回 0,顺手把跑满的数据库主库切断了重连。拿起手边的茶杯,茶水已经冷透了,窗外是凌晨三点的夜色。

    在过去的四个小时里,整个机房的告警系统处于半瘫痪状态。大量宿主机的 CPU、内存告警出现长达数小时的延迟,甚至发生了“主机已宕机,告警还在报 CPU 负载高”的时空错乱感。

    表象很容易看清:Zabbix Server 的 History Syncer 进程长时间 100% busy,导致 History Cache 被打满。紧接着,各地分布的 Zabbix Proxy 无法将采集数据上报给 Server,Proxy 本地的数据库开始急速膨胀,最终导致全部监控链路阻塞。

    但这只是表象。高并发监控系统崩溃的尽头,往往都是存储的底层挣扎。

    1. 拆解 IO 风暴:Housekeeper 的无差别屠杀

    当监控项规模达到几十万,NVPS(每秒处理的新值数量)突破两三万时,Zabbix 原生的架构设计会暴露出一个致命缺陷:Housekeeper 清理机制

    Zabbix 默认依赖内部的 Housekeeper 进程去定期删除过期历史数据。其本质是执行类似这样的 SQL:

    DELETE FROM history_uint WHERE clock < 1698765432;
    

    在 MySQL (InnoDB) 引擎下,对一张高达 TB 级别的超级大表执行海量 DELETE 操作,简直是一场灾难。 首先,它会导致严重的写放大。InnoDB 需要为每一行被删除的数据记录 Undo Log 以支持 MVCC 回滚;其次,这些操作会把 Buffer Pool 中大量热点业务数据挤出,导致缓存命中率暴跌;最后,删除后的空间并不会立刻释放,而是留下大量数据空洞(Fragment),引发不可预测的页分裂和合并,让底层的随机 IOPS 直接拉满。

    我的处理动作很直接:停掉 Housekeeper,用 MySQL 的表分区(Table Partitioning)降维打击。

    zabbix_server.conf 中直接斩断历史数据的清理动作:

    # 禁用历史数据和趋势数据的原生清理
    HousekeepingFrequency=0
    MaxHousekeeperDelete=0
    

    随后在数据库端,对 historyhistory_uinttrends 等核心大表实施按天/按月的分区策略。改写后的表结构如下(以 history_uint 为例):

    ALTER TABLE history_uint PARTITION BY RANGE (clock) (
        PARTITION p20231024 VALUES LESS THAN (UNIX_TIMESTAMP('2023-10-25 00:00:00')),
        PARTITION p20231025 VALUES LESS THAN (UNIX_TIMESTAMP('2023-10-26 00:00:00')),
        PARTITION p20231026 VALUES LESS THAN (UNIX_TIMESTAMP('2023-10-27 00:00:00')),
        ...
        PARTITION p_max VALUES LESS THAN MAXVALUE
    );
    

    用定时脚本或者存储过程,每天凌晨执行 ALTER TABLE history_uint DROP PARTITION p20231024;。在文件系统层面,这等同于直接 unlink 删除了一个 .ibd 物理文件,这是一个 $O(1)$ 的顺序 IO 操作。原本需要锁表死磕几个小时的清理动作,现在几十毫秒就能完成,数据库 IO 瞬间回归平稳。

    2. 重塑 Proxy 架构缓冲:打破内存与连接的死锁

    数据库的 IO 瓶颈解除后,Zabbix Server 的写入速度恢复,但 Proxy 端的积压并没有立刻消化。

    看了一眼 Zabbix Server 的内部状态:

    zabbix_server -R diaginfo
    

    输出显示 HistoryCacheSize 的可用空间在剧烈震荡。Zabbix Proxy 的运行逻辑是:如果 Server 的接收缓冲满了,Trapper 进程会拒绝 Proxy 的批量推送。Proxy 被拒后,只能把数据继续积压在自己本地的 SQLite/MySQL 中。随着本地数据越攒越多,Proxy 的 Poller 进程会被拖慢,引发更大范围的采集延迟。

    为了加速存量几百万积压数据的消化,我调整了 Server 端负责衔接 Proxy 的关键内存参数,并大幅增加了同步器并发:

    # 扩大历史数据缓存,防止 Proxy 突发大流量将 Cache 击穿
    HistoryCacheSize=2G
    HistoryIndexCacheSize=256M
    
    # 扩大底层同步进程数(对应写入 MySQL 的并发数)
    StartHistoryPollers=30
    
    # 增加 Trapper 进程以接收大批量的 Proxy 连接
    StartTrappers=50
    

    注意,StartHistoryPollers 并不是越大越好。如果这个数值超过了 MySQL 能承载的最大并发写入线程数,反而会导致 InnoDB Row Lock Contention(行锁争用)。在做了分区表的基础上,我将并发控制在 30 左右,既能保证写入吞吐,又不会引发严重的锁竞争。

    3. 从源头止损:自定义模板与预处理(Preprocessing)的重构

    当数据洪峰终于退去,系统负载降下来后,我开始查根源:为什么今天的 NVPS 会突然飙升到平时的三倍?

    排查 Zabbix 的 items 表发现,近期业务组导入了一套自定义的“全栈监控模板”。这套模板存在两个极其外行的设计:

    第一,滥用被动模式(Passive Check)。 模板里包含了几百个针对端口存活、TCP 状态的监控项,且全部配置为 Zabbix agent(被动模式),采集周期设为 10 秒。 在被动模式下,Zabbix Proxy 或 Server 的 Poller 进程需要主动发起 TCP 连接去拉取数据。面对上千台机器,成千上万的短连接频繁建立和销毁,直接耗尽了 Proxy 本地的临时端口号(TIME_WAIT 飙升),Poller 进程全被网络 IO 阻塞。 我立刻用 SQL 批量将这部分监控项全部修改为 Zabbix agent (active)。主动模式下,Agent 会自己在本地汇总数据,然后在一个长连接中批量推给 Proxy,彻底释放了 Proxy 的并发调度压力。

    第二,大量采集无意义的静态冗余数据。 比如“系统内核版本”、“网卡 MAC 地址”、“挂载点配置”,这些数据几个月都不会变一次,模板却丧心病狂地设置了每分钟采集一次,并且全部原样存入数据库。 这是对存储资源的极大浪费。我直接在自定义模板的监控项中,加入了 Zabbix 原生的 Preprocessing(预处理) 逻辑: 使用了 Discard unchanged with heartbeat(丢弃未更改的心跳数据),心跳周期设置为 1d(一天)。

    // 在监控项预处理步骤中添加
    {
      "type": "DISCARD_UNCHANGED_HEARTBEAT",
      "params": "1d"
    }
    

    这行简单的配置在底层起到了奇效:当 Agent 将数据推送到 Server 时,Server 的 Preprocessing Manager 进程会在内存里比对上一次的值。如果内核版本还是 3.10.0-1160,直接在内存中丢弃这条数据,不进入 History Cache,更不发起任何数据库 INSERT 操作。仅此一项改动,全局 NVPS 瞬间断崖式下降了 40%,系统终于迎来了真正的平静。

    监控系统的本质,是处理海量时间序列数据的流式计算与存储架构。很多人习惯把 Zabbix 当成一个无脑的黑盒工具,堆机器、加内存。但当架构演进到真正的深水区,决定系统生死存亡的,往往是对一条 SQL 锁范围的精确评估,是对 TCP 队列状态的底层感知,是对每一字节数据生命周期的严苛控制。

    问题解决了。收拾完手头的脚本,该去补个觉了。

  • 跨越 Veth Pair 的性能鸿沟:高并发场景下 IPVLAN 与 SR-IOV 的底层抉择

    上午的流量早高峰刚过,监控大屏上的多条告警逐渐恢复平静。趁着喝口水的功夫,我把刚结束的复盘会内容整理一下。

    事情起因是业务线新上线了一个高频交易网关,部署在 K8S 集群中。QPS 刚切过来 30%,节点上几个 CPU 核心的 si(软中断)使用率就直接飙到了 100%,随之而来的是 P99 延迟剧烈抖动,部分请求出现几十毫秒的网络排队延迟。业务研发跑过来问是不是宿主机网卡跑满了,我瞥了一眼监控面板,千兆网卡的带宽连一半都没用到,但这台机器的网络 PPS(每秒包数)已经突破了 40 万。

    很明显,这不是带宽瓶颈,而是经典的 Linux 网络协议栈软中断瓶颈。更准确地说,是容器网络默认的 veth pair 在高并发下的底层机制拖垮了 CPU。

    Veth Pair 的隐性代价:上下文切换与软中断风暴

    目前绝大多数 K8S 的 CNI 插件(如 Flannel、Calico)默认都采用 veth pair + Bridge/路由 的模式。veth pair 本质上是一对虚拟网卡,连接着容器的 Network Namespace 和宿主机的 Root Namespace。

    为了定位当时的 CPU 开销,我在物理机上抓了一把 perf top -C <对应核>

      12.45%  [kernel]       [k] veth_xmit
      10.21%  [kernel]       [k] __netif_receive_skb_core
       8.32%  [kernel]       [k] net_rx_action
       7.14%  [kernel]       [k] br_handle_frame
       6.55%  [kernel]       [k] ipt_do_table
       4.10%  [kernel]       [k] ip_forward
    

    注意看 veth_xmit 这个函数。当网关 Pod 处理完请求向外发包时,数据包从容器内的 eth0(veth一端)发出,经过内核协议栈,最终调用 dev_queue_xmit() 发送到虚拟网卡,触发 veth_xmit

    veth pair 的底层逻辑是:在发包端调用 veth_xmit 时,实际上是在向对端(宿主机上的 veth 接口)发包。它会调用 netif_rx()(或者更高版本内核中的 netif_rx_ni / netif_rx_internal),把 sk_buff 挂到目标 CPU 的 softnet_data 队列上,然后触发一个 NET_RX_SOFTIRQ 软中断。

    这意味着什么?一个数据包从容器到物理网卡,要在内核态经历至少两次完整的网络协议栈处理(容器内一次,宿主机一次),并触发额外的软中断上下文切换。 还要经过宿主机上的 Bridge (br_handle_frame) 和 Netfilter/iptables (ipt_do_table)。在 40万 PPS 的冲击下,这种“纯软件模拟”的转发路径不仅带来了巨大的 CPU 消耗,更是延迟抖动的罪魁祸首。

    绕过宿主机协议栈:Macvlan 的局限与 IPVLAN 的突围

    既然宿主机协议栈太重,那能不能让容器直接和物理网卡对话?

    方案无非是 Macvlan 和 IPVLAN。

    Macvlan 的原理是基于物理网卡虚拟出多个带有独立 MAC 地址的子网卡。容器直接使用这些子网卡,数据包在物理网卡的 rx_handler 阶段就被直接截获并分发到对应的容器 Namespace,完全绕过宿主机的 Bridge 和 iptables。

    但在企业级网络架构中,Macvlan 有一个致命缺陷:交换机 MAC 表爆炸。 如果一个集群有 100 台宿主机,每台跑 50 个 Pod,物理交换机上就需要学习 5000 个 MAC 地址。多数接入层交换机的 CAM 表容量是有限的(通常 4K-8K),一旦溢出,交换机会降级为广播行为,引发未知的单播泛洪(Unknown Unicast Flooding),这在生产环境是不可接受的灾难。另外,部分公有云环境出于安全考虑,甚至会在底层 vSwitch 直接丢弃非宿主机 MAC 的包。

    因此,我们当时毫不犹豫地将网关节点的网络模型切换到了 IPVLAN (L2 Mode)

    IPVLAN 最大的特点是:所有虚拟接口共享物理网卡的 MAC 地址,但在 IP 层(L3)进行流量多路复用。

    下面是当时我们在测试环境用原生命令验证 IPVLAN 拓扑的配置片段:

    # 1. 在物理网卡 eth0 上创建 ipvlan 子接口,模式为 L2
    ip link add link eth0 name ipv1 type ipvlan mode l2
    ip link add link eth0 name ipv2 type ipvlan mode l2
    
    # 2. 将子接口移入独立的 Network Namespace 模拟容器
    ip netns add ns1
    ip link set dev ipv1 netns ns1
    ip -n ns1 link set ipv1 up
    ip -n ns1 addr add 192.168.1.100/24 dev ipv1
    ip -n ns1 route add default dev ipv1
    
    # 宿主机上无需配置任何桥接或路由策略即可完成容器对外通信
    

    在内核源码中,当使用 IPVLAN 时,物理网卡 eth0 注册了 ipvlan_handle_frame 作为接收钩子(rx_handler)。当带有宿主机 MAC 的数据包到达时:

    1. 网卡驱动收包。

    2. 触发 ipvlan_handle_frame

    3. IPVLAN 模块解析以太网帧头后面的 IP 头。

    4. 基于目标 IP 地址,通过内部的 Hash 表直接查找到对应的子接口(即某个容器的网卡),并将 sk_buff 直接交过去。

    效果立竿见影: 网关集群切换到支持 IPVLAN 的 CNI(配合 multus-cni 使用)后,PPS 承载能力提升了接近一倍,宿主机 CPU 的软中断开销暴降了 60% 以上,网络延迟彻底压平。

    极致性能的终局:SR-IOV 硬件直通

    如果仅仅是网关,IPVLAN 已经能应付绝大多数场景。但早上的复盘会还讨论了另一个极端的场景:如果未来把核心数据库(比如高频写入的 Redis 集群或者 TiKV)做容器化,百万级 PPS 下,IPVLAN 还能扛住吗?

    答案是:勉强,但不够优雅。因为 IPVLAN 依然依赖宿主机 CPU 来处理中断和执行拆包/分发逻辑。要想彻底解放 CPU,只能依靠硬件,这就必须引入 SR-IOV (Single Root I/O Virtualization)

    SR-IOV 的本质是让支持该特性的物理网卡(如 Mellanox ConnectX 系列或 Intel X710)在 PCIe 硬件层面“裂变”出多个虚拟功能(VF, Virtual Function)。

    相比于 IPVLAN 的纯软件多路复用,SR-IOV 的架构是降维打击:

    1. 每个 VF 拥有独立的 PCI 寄存器、独立的硬件收发队列(TX/RX Queues)、独立的 MAC 和 VLAN 过滤表。

    2. 将 VF 直接通过 VFIO/IOMMU 映射给容器使用。

    3. 数据包到达网卡后,物理网卡的内置交换芯片(eSwitch)直接基于硬件规则将包放入对应 VF 的队列,并通过 DMA 机制直接拷贝到容器分配的内存页中。

    我们在验证 SR-IOV 时,常用的排查和分配手段如下:

    # 查看物理网卡是否支持及当前配置的 VF 数量
    cat /sys/class/net/eth0/device/sriov_numvfs
    
    # 开启 4 个 VF
    echo 4 > /sys/class/net/eth0/device/sriov_numvfs
    
    # 使用 lspci 可以看到新生成的 Virtual Function 硬件设备
    lspci | grep Ethernet
    # 04:00.0 Ethernet controller: Intel Corporation Ethernet Controller X710 for 10GbE SFP+ (PF)
    # 04:02.0 Ethernet controller: Intel Corporation Ethernet Virtual Function 700 Series (VF 0)
    # 04:02.1 Ethernet controller: Intel Corporation Ethernet Virtual Function 700 Series (VF 1)
    

    当容器配置了 SR-IOV CNI 后,在容器内部看到的就是一块真真切切的硬件网卡。整个发包路径直接从容器内的 Socket 通过驱动写到 PCIe 设备的硬件队列,宿主机的内核网络栈在这个过程中完全是被架空的,没有任何 CPU 会因为这个容器的网络 I/O 被软中断打断

    结语

    技术架构里从来没有银弹。 对于 90% 的普通微服务,veth pair 配合 eBPF(比如 Cilium 的 sockops 绕过)或者单纯的 iptables/IPVS 已经足够;对于高吞吐的 API 网关或者视频流媒体,切换到 IPVLAN 可以用极小的架构变动换取巨大的性能红利,并且避开交换机 MAC 限制;而对于追求极致微秒级延迟和极高 PPS 的核心存储与交易撮合引擎,SR-IOV 才是最终归宿。

    认清瓶颈在内核还是在硬件,在上下文切换还是在队列排队,远比盲目修改内核参数要有效得多。剩下的时间,我得去查查为什么今天那台边缘节点的 kubelet PLEG 会出现轻微卡顿了。

  • 当跨机房同步遇上存储分离:一次 Pulsar BookKeeper WriteCache 背压与雪崩的底层剖析

    凌晨一点半,办公室只剩敲击键盘的白噪音。监控大屏突然闪红,Pulsar 集群的 Producer P99 延迟监控曲线像被猛抽了一鞭子,从平稳的 5ms 直接飙升到了 3000ms+,部分高频写入业务开始报 ProducerSendError

    切到终端,快速拉取 Broker 的指标,发现并不是 Broker 层的 GC 或网络拥塞,而是底层存储层(Bookie)的 pulsar_storage_write_latency_le 出现了严重的长尾。

    在 Pulsar 的计算存储分离架构中,Broker 是无状态的路由与分发层,真正的脏活累活都在 BookKeeper。当 Bookie 的写入延迟飙升,通常意味着磁盘 IO 或者内存管线被彻底堵死了。

    现场排查:冰火两重天的 IO 状态

    我挑了一台延迟最高的 Bookie 节点 SSH 上去,习惯性地打出一套组合拳:

    # 观察磁盘 IO 状态
    iostat -x 1
    

    输出的结果让我察觉到了一丝诡异:

    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme0n1 (Journal) 0.00     0.00    0.00   85.00     0.00  1205.00    28.35     0.01    0.15    0.00    0.15   0.12   1.02%
    sdb     (Ledger)  0.00     0.00 8540.00   12.00 546560.00 4500.00   128.00    32.50   18.50   18.60   12.40   0.11 100.00%
    

    在标准的 BookKeeper 部署最佳实践中,我们将 Journal(类似 MySQL 的 Redo Log)独立部署在高吞吐低延迟的 NVMe 盘上(nvme0n1),而把 Ledger(实际的数据文件和 RocksDB 索引)放在普通 SSD 上(sdb)。

    目前的现象是:Journal 盘闲得发慌(%util 1%),但 Ledger 盘的读 IO 已经被彻底打满(r/s 飙到 8500+,%util 100%)。

    Journal 盘负责处理实时的写入请求(fdatasync),理论上只要 Journal 盘没满,写入就不该阻塞。那为什么 Broker 端感受到了巨大的写入延迟?

    顺藤摸瓜:是谁在疯狂读取?

    Ledger 盘被海量的读请求击穿,只有两种可能:一是某个消费者在大量回溯历史消息(Catch-up Read);二是集群在做数据均衡或者副本修复。

    通过 pulsar-admin 查看集群整体状态,我锁定了罪魁祸首:

    pulsar-admin topics stats-internal tenant-a/namespace-1/topic-x
    

    在一堆 JSON 输出中,我看到了 replication 节点的异常:

    "replication" : {
      "cluster-b" : {
        "msgThroughputOut" : 52428800.0,
        "msgRateOut" : 12500.0,
        "replicationBacklog" : 15800450,
        "connected" : true,
        "replicationDelayInSeconds" : 3600
      }
    }
    

    真相浮出水面:租户 A 配置了 Geo-Replication,将数据跨地域异步复制到 cluster-b。一小时前,跨机房的专线出现了短暂的物理网络抖动,导致复制链路断开。网络恢复后,Broker 侧的 Replication Cursor 开始疯狂地向后追平这 1500 万条积压数据。

    这股突发的冷数据读取洪流,直接打穿了 Bookie 的 ReadAheadCache,穿透到了底层的 Ledger 磁盘。

    深度解析:Ledger 读风暴如何引发 Journal 写阻塞?

    这似乎是个多租户隔离失效的经典案例:一个租户的跨机房冷读,影响了全局的实时热写。但在计算分离架构下,Journal 和 Ledger 盘是物理隔离的,读写究竟在哪一层发生了交叉碰撞?

    这就必须深入到 BookKeeper 的 DbLedgerStorage 底层管线。

    在 Bookie 中,一条 Message 的写入路径(AddEntry)如下:

    1. 请求进入 Bookie 的 Netty 线程,分发给 SyncThread

    2. 写 WAL:追加到 Journal 内存队列,由单独的 Journal 线程 fdatasync 到 NVMe 盘。

    3. 写缓存:同时将数据插入到内存中的 WriteCache

    4. Journal 落盘且 WriteCache 插入成功后,向 Broker 返回 ACK。

    这里的关键在第 3 步和后续的异步刷盘机制。

    内存中的 WriteCache 是有容量上限的(由 dbStorage_writeCacheMaxSizeMb 控制,默认通常是系统内存的 1/4)。当 WriteCache 写满时,会触发 Flush 动作:

    • 将数据序列化并追加到 Ledger 磁盘的 EntryLog 文件中。

    • 将 Entry 的位置索引信息(LedgerId, EntryId -> EntryLogId, Offset)写入 RocksDB。

    如果此时 Ledger 磁盘正在被 Geo-Replication 的海量随机读(追冷数据)严重占用,IOPS 饱和,导致 Flush 操作极其缓慢。

    那么连锁反应来了:

    1. Flush 变慢,WriteCache 内存无法及时释放。

    2. 前端高频的热点写入继续涌入,瞬间填满剩余的 WriteCache

    3. WriteCache 一旦满了,新的 AddEntry 请求在尝试插入 WriteCache 时,就会被同步阻塞(Backpressure)。

    4. Netty 工作线程被挂起,无法处理新的网络请求,最终导致请求在队列中超时,抛出 Transaction timeout 异常,Broker 端观察到的 P99 延迟直接原地起飞。

    这就是为什么 Journal 盘空闲,但写入依然被卡死的根本原因。资源在物理磁盘上是隔离的,但在内存管线(WriteCache)和存储引擎(DbLedgerStorage)上却存在强耦合。

    现场止血与架构调优

    既然找到了症结在于 Geo-Replication 产生的读取洪流没有被限流,破坏了底层存储的 IO 节奏,止血方案就非常明确了。

    1. 动态下发 Dispatch 限流策略(止血)

    Pulsar 提供了灵活的多租户资源隔离能力,我立即通过 pulsar-admin 对租户 A 的特定 Namespace 下发了流量 Dispatch 限制,掐断它的读取速率,给磁盘留出喘息的空间。

    # 限制该 namespace 的 Dispatch 速率为 50MB/s,或者 10000 msg/s
    pulsar-admin namespaces set-dispatch-rate tenant-a/namespace-1 \
      --byte-dispatch-rate 52428800 \
      --dispatch-rate 10000
    
    # 限制跨机房 Replication 的读取速率(关键配置)
    pulsar-admin namespaces set-replicator-dispatch-rate tenant-a/namespace-1 \
      --byte-dispatch-rate 20971520 \
      --dispatch-rate 5000
    

    指令下发后大约 10 秒,iostatsdb%util 开始回落到 60% 左右。Bookie 的 WriteCache 终于能够顺畅地 Flush 到 Ledger 盘,积压的 AddEntry 队列迅速清空,集群 P99 延迟恢复到 5ms。

    2. 底层 BookKeeper 参数调优(治本)

    为了防止未来其他租户再次触发这种边缘场景引发雪崩,需要对 BookKeeper 的底层配置进行更严谨的调校。

    调整 WriteCache 与 ReadAheadCache 的配比 默认情况下,读写缓存的分配可能并不适合重度冷读积压的场景。在 bookkeeper.conf 中显式隔离并调整内存屏障:

    # 强制开启 DbLedgerStorage
    ledgerStorageClass=org.apache.bookkeeper.bookie.storage.ldb.DbLedgerStorage
    
    # 增加 WriteCache 的比例,提供更大的缓冲池来吸收底层的抖动
    dbStorage_writeCacheMaxSizeMb=4096
    
    # 限制 ReadAhead 的内存使用,防止冷数据污染导致 OOM 或频繁的 GC
    dbStorage_readAheadCacheMaxSizeMb=2048
    
    # 分离 RocksDB 的读写 BlockCache
    dbStorage_rocksDB_blockCacheSize=1073741824
    

    操作系统层面的 IO 提示优化 Geo-Replication 回溯历史数据时,本质上是对 Ledger 文件的顺序读。我们可以通过调整内核的 Read-Ahead 大小,减少底层的 IO 次数,提升吞吐:

    # 将 Ledger 盘 sdb 的预读设置为 4096 个扇区 (2MB)
    blockdev --setra 4096 /dev/sdb
    

    3. 隔离 Read/Write 线程池

    BookKeeper 中可以通过配置将读和写的处理线程池完全拆开,避免冷读耗尽处理线程资源导致心跳或写入响应不及时:

    # 开启独立的读线程池
    numReadWorkerThreads=16
    numAddWorkerThreads=16
    numHighPriorityWorkerThreads=8
    

    总结

    计算存储分离的架构(如 Pulsar + BookKeeper)在理论上提供了极好的扩展性,但在实际的运维战场上,资源的边界往往比想象中更加模糊。

    在这个场景中,跨地域高延迟网络抖动触发了 Broker 端的异步补偿(Geo-Replication Catch-up),补偿机制转化为海量的吞吐导致底层的存储读引擎击穿,进而在 WriteCache 内存模型处形成了反向的写背压(Backpressure),最终导致了全局写入的雪崩。

    多租户与存储分离,不是把服务拆开部署就万事大吉了。真正的能力体现在从计算侧的配额下发、网络层的隔离、到存储引擎侧 IO 管线的严格切分。只有把 QOS(服务质量限制)像烙印一样打在每一个数据流转的节点上,才能在复杂的生产环境中睡个安稳觉。

  • 深度剖析:Checkpoint Age 激增引发的雪崩——当 Redo Log 阻塞遇上间隙锁

    凌晨两点半,机房的 VPN 刚断开。屏幕上的 Threads_running 指标终于从刺眼的 800 多回落到了个位数。

    这原本是一个再平淡不过的深夜,直到告警短信把我叫醒:核心交易库 TPS 突然掉底,连接池被打满。初看现象,这是一起典型的数据库死锁或锁等待超时(Lock Wait Timeout),但顺着线索往下挖,底层却是一场由 Buffer Pool 刷脏机制和 Redo Log 容量引发,最终通过间隙锁(Gap Lock)放大导致的全盘雪崩。

    这个问题很有代表性,它把 InnoDB 的内存管理、日志机制和并发控制完美地串联在了一起。趁着现在毫无睡意,把排查过程和底层逻辑梳理一下。

    1. 现场:诡异的锁等待

    登录数据库,习惯性地先看当前运行的事务和锁状态:

    SELECT
      r.trx_id waiting_trx_id,
      r.trx_mysql_thread_id waiting_thread,
      r.trx_query waiting_query,
      b.trx_id blocking_trx_id,
      b.trx_mysql_thread_id blocking_thread,
      b.trx_query blocking_query
    FROM performance_schema.data_lock_waits w
    INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_engine_transaction_id
    INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_engine_transaction_id;
    

    结果显示,大量的单条 INSERT 语句被阻塞。顺藤摸瓜找到源头(blocking_query),是一个定时清理历史数据的批处理 SQL:

    DELETE FROM trade_orders 
    WHERE status = 'CLOSED' AND updated_at < '2023-09-01 00:00:00';
    

    updated_at 字段上有二级索引。在默认的 REPEATABLE READ 隔离级别下,InnoDB 为了防止幻读,会在扫描二级索引时加上 Next-Key Lock(Record Lock + Gap Lock)。由于这是一个范围删除,它不可避免地锁住了大段的索引间隙,导致落入这些间隙的新订单 INSERT 被阻塞。

    到这一步,看似问题已经找到了:大批量 DELETE 导致的间隙锁阻塞。

    但逻辑上说不通。这条清理语句每次只限制删除 1000 条数据,平时执行耗时通常在 50ms 以内。为什么今晚这个事务执行了十几秒还没提交?事务不提交,锁就不会释放。

    是什么拖慢了这 1000 条数据的删除?

    2. 下沉:被无视的 Checkpoint Age

    既然不是死锁,也没有其他事务阻塞这个 DELETE,那瓶颈必然在系统资源或 InnoDB 引擎内部。扫了一眼系统监控,CPU 负载不高,但磁盘 I/O 的 %util 接近 100%,大量的写操作排队。

    立刻切到引擎层,查看 InnoDB 状态:

    mysql> SHOW ENGINE INNODB STATUS\G
    ...
    ---
    LOG
    ---
    Log sequence number          14589320145
    Log flushed up to            14589319800
    Pages flushed up to          14080120000
    Last checkpoint at           14080119800
    ...
    

    这段输出里的四个数字,是解开谜团的钥匙。我们来算一笔账:

    Checkpoint Age = Log sequence number (当前 LSN) – Last checkpoint at (上一次检查点 LSN) = 14589320145 – 14080119800 = 509,200,345 Bytes (约 485 MB)

    再看一下线上 Redo Log 的配置: innodb_log_file_size = 256M innodb_log_files_in_group = 2

    总 Redo Log 容量是 512MB。 由于 Redo Log 是循环写入的,为了防止覆写还未刷入磁盘的脏页日志,InnoDB 定义了两个水位线:

    • Async Watermark (异步刷脏水位):通常是总容量的 75%(约 384MB)。

    • Sync Watermark (同步刷脏水位):通常是总容量的 90%(约 460MB)。

    当前的 Checkpoint Age(485MB)已经无情地突破了 Sync Watermark!

    3. 底层机制:单线程刷脏的绝望

    当 Checkpoint Age 突破 90% 时,InnoDB 会发生什么?

    在正常情况下,Buffer Pool 中的脏页是由后台线程(Page Cleaner Thread)异步刷入磁盘的。不管前台有多少高并发的增删改,只要后台刷得够快,Redo Log 就有足够的空间推进,前台线程只管写内存和顺序写 Redo Log 即可,速度极快。

    但这批夜间跑批任务包含了大量密集的 UPDATEDELETE,短时间内生成了海量的 Redo Log。256M * 2 的 Redo Log 空间被迅速填满。后台异步刷脏的速度(受限于 innodb_io_capacity 参数)远远赶不上 Redo Log 产生的速度。

    当 LSN 推进到 Sync Watermark 时,InnoDB 的保护机制被触发:所有产生 Redo Log 的用户线程(User Threads)被强制挂起,必须参与同步刷脏(Sync Flush)。

    这就解释了那个诡异的现象:

    1. DELETE 事务在执行过程中,遇到了 Redo Log 空间不足。

    2. 该事务的执行线程被 InnoDB 引擎强行拽去干苦力——等待甚至参与将 Buffer Pool 里的脏页刷回磁盘(通过推进 Checkpoint 来释放 Redo 空间)。

    3. 这个过程是随机 I/O,且极其耗时。导致原本 50ms 就能完成的 DELETE,被拖长到了十几秒。

    4. 雪崩的闭环

    现在,整个雪崩的逻辑链条完全闭合了:

    1. 导火索: 跑批任务触发密集写操作,产生大量 Redo Log。

    2. 容量瓶颈: innodb_log_file_size 过小,Checkpoint Age 迅速突破 Sync Watermark。

    3. I/O 阻塞: 引擎进入同步刷脏模式,用户线程被阻塞,等待脏页落盘。

    4. 锁放大: 正在执行 DELETE 的线程被挂起,但它持有的间隙锁(Gap Lock)并不会释放

    5. 雪崩: 大量正常业务的 INSERT 请求命中被锁定的索引间隙,进入 Lock Wait 状态。连接池迅速被堆积的挂起线程耗尽,引发全盘宕机。

    5. 破局与参数调优

    知道了症结,解决起来就不复杂。这种问题,单靠优化 SQL 治标不治本,核心是要调整 InnoDB 的内存与日志 I/O 策略,让存储层能扛住瞬间的吞吐。

    第一步:扩容 Redo Log

    256M 的单文件大小放在现代的高并发业务中犹如儿戏。直接将其扩容:

    # my.cnf
    innodb_log_file_size = 2G
    innodb_log_files_in_group = 3
    

    注:在 MySQL 8.0.30 之后,这两个参数被废弃,统一使用 innodb_redo_log_capacity。这里由于线上还是 5.7 版本,依然采用老参数。这样总容量达到 6G,给予后台线程充足的缓冲时间来刷脏。

    第二步:释放底层 I/O 潜力

    既然底层是纯 SSD 阵列,没必要让 InnoDB 表现得像个老旧的机械硬盘。调整后台刷脏的 I/O 能力:

    # 告诉 InnoDB 底层存储每秒能处理的 IOPS
    innodb_io_capacity = 3000
    # 遇到脏页堆积或 Checkpoint 追尾时,最高可以飙到的 IOPS
    innodb_io_capacity_max = 6000
    
    # 针对 SSD 关闭相邻脏页合并刷盘特性(该特性只对机械硬盘有意义,SSD 上反而增加开销)
    innodb_flush_neighbors = 0
    

    第三步:规避大范围间隙锁

    从业务侧,把这种依赖二级索引范围扫描的 DELETE 改造掉。先通过主键查出需要删除的 ID,然后做主键删除,将 Next-Key Lock 降级为精准的 Record Lock,彻底解除对其他正常 INSERT 业务的间隙阻塞:

    -- 改造前
    DELETE FROM trade_orders WHERE status = 'CLOSED' AND updated_at < '...';
    
    -- 改造后,分批执行
    SELECT id FROM trade_orders WHERE status = 'CLOSED' AND updated_at < '...' LIMIT 1000;
    DELETE FROM trade_orders WHERE id IN (...);
    

    6. 尾声

    很多人在排查数据库阻塞时,一看到锁等待,就死磕业务逻辑和事务隔离级别。但实际上,数据库是一个极其精密的机械体。内存、日志、I/O 以及并发控制锁,是互相咬合的齿轮。一个看起来微不足道的 Redo Log 尺寸配置,在特定的业务波峰下,就能通过间隙锁将阻塞效应放大千百倍,最终酿成灾难。

    运维架构的深度,往往就藏在这些基础组件的边界摩擦里。合上电脑,该补个觉了。

  • 当 io_uring 遭遇 XFS 元数据锁:高并发 Direct IO 阻塞的底层机制解析

    凌晨两点半,VPN 还在挂着。刚刚把压测环境的一组存储节点退下来,顺手把排查过程理一理。

    事情的起因是存储研发团队在重构底层写引擎,从传统的 AIO 迁移到了 io_uring。理论上,配合 NVMe SSD 和 XFS的 Direct IO,吞吐量应该能实现数量级的跃升。但在进行大并发压测时,现象却让人大跌眼镜:当并发写入请求急剧增加时,磁盘的 IO util 连 30% 都没跑到,IOPS 却出现了断崖式下跌,同时机器的 sys CPU 飙升到了 85% 以上。

    引入新技术时,把一切理所当然当成常态,往往就是要交学费的时候。

    现场还原与初步定位

    登录压测机,第一感觉是系统响应变慢了,但 iostat 显示磁盘毫无压力。 用 top 看了一眼,几个压测进程的 CPU 占用并不高,反而是内核的 kworker 线程和名为 io_wq_manager 的线程把 sys 态 CPU 给吃干抹净了。

    遇到内核态 CPU 飙升,最直接的手段就是抓热点。直接跑一把 perf

    perf top -U -F 99 -g
    

    抓了几十秒,展开调用栈,看到了一个非常刺眼的调用链路:

    - 81.24% io_wqe_worker
       - 79.12% io_issue_sqe
          - 78.05% io_write
             - 77.81% xfs_file_write_iter
                - 75.32% xfs_ilock
                   - 74.90% down_write
                      - rwsem_down_write_slowpath
                         - osq_lock
    

    这说明绝大部分 CPU 周期耗在了自旋锁上(osq_lock 是 qspinlock 的一部分),而锁的源头是 XFS 的 inode lock(xfs_ilock),触发点居然是 io_wqe_worker

    按照 io_uring 的设计理念,它应该是一个极度轻量级的环形队列交互,为什么会突然涌出大量的内核 worker 线程,而且还在疯狂抢夺 XFS 的文件系统锁?

    剖析 io_uring 的 NOWAIT 语义退化

    要理清这个问题,得深入到 Linux IO 栈的提交流程里。

    当我们通过 io_uring 提交一个异步写请求时,如果不做特殊设置,内核底层在解析这个 SQE(Submission Queue Entry)时,会默认给这个 IO 加上 IOCB_NOWAIT 标志。这个标志的含义是告诉底层的文件系统和块设备:这个 IO 必须是非阻塞的,如果你发现当前操作需要睡眠等待(比如等锁、等内存分配),请立刻返回 -EAGAIN,不要阻塞我的提交线程。

    我们再来看看 XFS 这一层。压测工具模拟的业务场景是多线程并发对同一个文件进行 Append Write(追加写)

    在 XFS 的实现中,进入 xfs_file_write_iter 时,如果是普通的覆盖写(Overwrite),且不需要分配新的数据块,XFS 只需要获取 inode 的共享锁(XFS_IOLOCK_SHARED),这种情况下并发写入毫无压力。

    但是,追加写(Append)需要修改文件的 EOF(End of File),这涉及到了文件大小元数据的变更,甚至可能需要向 Allocation Group(AG)申请分配新的 Block。在这个过程中,XFS 必须获取该文件 inode 的独占锁(排他锁):

    // 截取自 fs/xfs/xfs_file.c
    STATIC ssize_t
    xfs_file_dio_write_aligned(...)
    {
        ...
        if (iocb->ki_flags & IOCB_APPEND) {
            // 需要独占锁
            iolock = XFS_IOLOCK_EXCL;
        } else {
            iolock = XFS_IOLOCK_SHARED;
        }
    
        // 如果带了 NOWAIT 标志,尝试非阻塞获取锁
        if ((iocb->ki_flags & IOCB_NOWAIT) && !xfs_ilock_nowait(ip, iolock))
            return -EAGAIN;
        ...
    }
    

    看上面这段逻辑就很清晰了。高并发下,第一个线程拿到了 inode 的独占锁开始处理追加写,后续的并发请求到达时,XFS 发现带有 IOCB_NOWAIT 并且无法立刻拿到锁,果断返回 -EAGAIN

    重点来了,io_uring 收到这个 -EAGAIN 后会怎么做?

    它当然不能把错误直接抛给应用层(那就破坏了异步 IO 的语意)。io_uring 的内部机制是:既然你在当前的提交上下文无法非阻塞完成,那我就把你扔到后端的 io-wq(内核异步工作队列)里慢慢跑。

    于是,原本应该在高速环形队列里完成的极速 IO 提交,变成了一场灾难:

    1. 提交线程不断遇到 -EAGAIN

    2. 任务被海量丢入 io-wq

    3. io-wq 线程池迅速扩容(生成大量 io_wqe_worker 线程)。

    4. 这些 worker 线程剥离了 NOWAIT 标志,再次向 XFS 发起写请求,开始硬扛着去抢那个文件的独占锁(阻塞等待)。

    5. 几千个内核线程抢一把读写锁的写锁,触发严重的 osq_lock 竞争,内核态上下文切换风暴爆发,sys CPU 直接打满,吞吐量断崖式下降。

    解决方案与最佳实践

    弄懂了底层逻辑,修复方案就不能单纯在 io_uring 层面调参了,必须从文件系统和 IO 模型的结合点入手。

    1. 空间预分配(fallocate),化 Append 为 Overwrite

    既然罪魁祸首是 Append Write 导致的排他锁和元数据更新,那么最有效的手段就是打破这个条件。通过 posix_fallocate 或者 Linux 特有的 fallocate 系统调用,提前为文件分配好足够的物理空间。

    在应用层的逻辑中:

    • 先预分配一段空间(比如 1GB)。

    • 各个线程不要再用 O_APPEND 标志,而是自己维护一个全局递增的 offset(可以用原子操作 atomic_fetch_add)。

    • 每次构建 sqe 时,明确指定写入的 offset

    这样,XFS 在处理这些 io_uring 请求时,发现空间已经分配,不需要修改 EOF,只需要 XFS_IOLOCK_SHARED 即可。非阻塞拿共享锁几乎不会失败,NOWAIT 语义得以保持,io-wq 的退化灾难直接消失。

    // 伪代码示例
    int fd = open("test.dat", O_RDWR | O_DIRECT | O_CREAT, 0644);
    // 预分配 1G 空间,注意 XFS 建议使用 fallocate 以保持物理连续性
    fallocate(fd, 0, 0, 1024 * 1024 * 1024);
    
    // ... 在提交 sqe 时
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_write(sqe, fd, buffer, size, current_offset);
    // 更新 offset
    atomic_fetch_add(&global_offset, size);
    

    2. XFS Extent Size Hint 的调优

    即使做了预分配,如果是针对多个不同文件的大并发写入,依然可能在 XFS 的 AG(Allocation Group)锁上发生竞争。可以通过给目录或文件设置 extsize,让 XFS 在分配数据块时按更大的粒度进行(比如 1MB 到 4MB),减少底层 B+ 树分裂和分配元数据修改的频率。

    # 查看当前的 extent size
    xfs_io -c "extsize" /data
    # 设置当前目录的默认 extent size 为 2MB
    xfs_io -c "extsize 2m" /data
    

    3. 谨慎使用 io_uring 的高级特性

    很多人觉得配置了 IORING_SETUP_SQPOLL(让内核线程去轮询提交队列)就能解决一切阻塞问题。实际上,SQPOLL 仅仅是把用户态的 io_uring_enter 甚至 syscall 的开销省了,它依然绕不开底层 XFS 的锁机制。如果底层退化成同步抢锁,SQPOLL 的轮询线程一样会被卡死,甚至会导致单核 CPU 100% 的死锁假象。

    总结

    Linux IO 栈从来不是几个新鲜名词的简单拼凑。io_uring 确实提供了当前 Linux 下最高效的异步原语,但它的性能上限依然受限于底层的块设备调度与文件系统的具体实现。

    当你在高频低延迟或者极高并发的场景下使用 Direct IO 时,必须对文件系统(无论是 XFS 还是 ext4)在特定操作(追加、稀疏文件填充、跨 AG 分配)下的锁粒度有绝对的把握。仅仅关注上层调用,只会被深埋在内核态的上下文切换和自旋锁里反复摩擦。

  • 剥离CFS的伪公平:高频低延迟场景下的RT调度切换与NUMA亲和性深度优化

    凌晨三点,机房的报警面板终于恢复了一片幽绿色。我合上终端,冷掉的半杯咖啡还在手边。

    过去的一周,一个核心交易网关的P99延迟一直在折磨整个基础架构组。平时稳定在 2ms 的响应,在早晚高峰时段会毫无规律地出现 50ms 甚至上百毫秒的毛刺。研发翻遍了所有业务日志,将怀疑的矛头指向了网络抖动和 GC,但通过在宿主机不同层级抓包打点,最终的证据链却指向了一个容易被忽略的盲区:内核调度器。

    这不是什么玄学,而是当业务的 IO 密集度与延迟敏感度达到一定水位时,Linux 默认的 CFS(完全公平调度器)策略及其在 NUMA 架构下的行为,已经成了系统的最大瓶颈。

    CFS 唤醒延迟的底层软肋

    最初排查时,宿主机的 CPU 负载(Load)和整体使用率都非常健康,单核使用率最高不超过 60%。但系统看起来闲,并不代表任务跑得顺。

    我直接挂上 perf 抓了一段调度延迟:

    perf sched record -p <gateway_pid> -- sleep 10
    perf sched latency
    

    解析后的结果让我皱了眉头:网关的几个核心 Epoll Reactor 线程,最大调度延迟(Maximum delay)竟然飙到了 43ms。这意味着,一个网络包到达网卡,硬中断转软中断,协议栈处理完唤醒 Epoll 线程后,这个线程在 Runqueue 里干等了 43ms 才真正拿到 CPU。

    问题出在 CFS 的“公平”二字上。

    CFS 维护了一个红黑树,按照每个任务的虚拟运行时间(vruntime)来排序。当一个处于休眠状态的 IO 线程被网卡中断唤醒时,它需要抢占当前 CPU 上正在运行的任务。但内核并非无条件允许抢占,我们看内核 kernel/sched/fair.c 中的 check_preempt_wakeup 函数逻辑:

    只有当被唤醒任务的 vruntime 与当前任务的 vruntime 的差值,大于一个特定的阈值时,才会触发抢占(resched_curr(rq))。这个阈值由内核参数 sched_wakeup_granularity_ns 决定,默认通常是数毫秒级别。如果抢占失败,这个极其重要的 IO 线程只能被乖乖塞进红黑树,等待当前任务消耗完它的最小时间片(sched_min_granularity_ns)。

    对于注重吞吐量的 Web 服务,这种设计完美避免了频繁上下文切换带来的开销;但对于低延迟交易网关,毫秒级的等待就是灾难。

    粗放的 CPU 亲和性与缓存失效

    起初,我尝试调小 sched_wakeup_granularity_ns 来提升唤醒抢占的敏感度,毛刺确实有所缓解,但随之而来的是系统上下文切换(cs)飙升,整体吞吐下降。

    更严重的问题隐藏在研发之前做的一个“优化”里。为了避免跨 NUMA 节点的内存访问,研发通过 taskset 将整个网关进程粗暴地绑在了 NUMA 0 节点的所有核上(0-19核)。

    在 CFS 机制下,当一个线程被唤醒时,调度器会进入 select_task_rq_fair 来为它挑选一个合适的 CPU。内核不仅会看之前运行的 CPU,还会评估整个 NUMA 节点内各个核心的负载情况。由于绑核粒度太粗,这几十个核心网关线程在 NUMA 0 的 20 个物理核之间疯狂弹跳。

    每一次跨核心的线程迁移,意味着该线程之前在 L1/L2 Cache 中建立的热点数据全部失效(Cache Miss),随之而来的 TLB Miss 更是让内存访问延迟雪上加霜。在 perf stat -d 的数据里,L1-dcache-load-misses 的比例高得吓人。

    破局:SCHED_FIFO 与精准硬隔离

    面对这种场景,修修补补已经没有意义。核心网关线程不需要 CFS 给的“公平”,它们需要的是“绝对特权”。

    我决定将网关网络层的 Reactor 线程从传统的 SCHED_OTHER(CFS调度)剥离,切换到 SCHED_FIFO(实时调度 RT)。

    SCHED_FIFO 的逻辑极为霸道:只要 RT 线程处于 Runnable 状态,它会无视任何 CFS 任务直接抢占 CPU;除非它主动让出(阻塞/休眠)或被更高优先级的 RT 任务抢占,否则它会一直霸占 CPU。

    1. 业务层切调度策略

    我们在网关初始化的代码中加入了这段逻辑,仅针对核心线程提权,普通的工作线程依然走 CFS:

    #include <sched.h>
    
    void set_thread_rt_priority() {
        struct sched_param param;
        param.sched_priority = 50; // 设置为较高的 RT 优先级 (1-99)
    
        // 获取当前线程 PID,将调度策略改为 SCHED_FIFO
        if (sched_setscheduler(0, SCHED_FIFO, &param) == -1) {
            perror("sched_setscheduler failed");
        }
    }
    

    2. 内核层兜底防护

    将用户态进程设为 RT 是极其危险的操作。如果这个网络线程出现死循环,该 CPU 核心将彻底锁死,连 SSH 的 sshd 进程都得不到运行机会。

    为了防止“一车面包人全被带进沟里”,必须配置 RT 组调度参数作为底层保险:

    # 调度周期 1秒
    sysctl -w kernel.sched_rt_period_us=1000000
    # RT任务在一个周期内最多只能运行 0.95 秒,剩下 0.05 秒强制留给 CFS 任务
    sysctl -w kernel.sched_rt_runtime_us=950000
    

    3. 精细化绑核与中断隔离

    解决了抢占延迟,下一步是消灭 Cache Miss。废弃之前大锅饭式的 NUMA 绑定,在 Grub 内核启动参数中,我直接划出 4 个物理核作为“禁区”:

    # grub 启动参数追加
    isolcpus=16,17,18,19 nohz_full=16,17,18,19 rcu_nocbs=16,17,18,19
    

    isolcpus 让 CFS 调度器完全忽略这几个核心,nohz_fullrcu_nocbs 进一步剥离了这几个核心上的时钟滴答(Tick)和 RCU 回调。这就是纯粹的 Linux 零干扰环境(Zero-Interference)。

    随后,通过网卡队列的 smp_affinity,将特定网卡队列的硬中断绑定在这 4 个核上;最后,在程序启动后,精确地将 4 个设为 SCHED_FIFO 的 Reactor 线程,通过 pthread_setaffinity_np 1对1地死死按在这 4 个物理核上。

    不迁移,不排队,不被普通任务打断。

    尾声

    一套组合拳打完,重新上线切流。

    再看监控面板,不仅那几十毫秒的毛刺彻底绝迹,连平均响应延迟都硬生生压低了 15%。内核调度器就像是一条八车道的高速公路,CFS 负责让所有车都能平稳地跑起来,但如果你开的是救护车,就别在车流里按喇叭了,直接去走应急车道。

    今晚的活儿干完了,该下线了。

  • 深夜的IO风暴:由一个长事务引发的PG MVCC与WAL写放大效应解析

    凌晨三点,机房的制冷机组应该正发出单调的轰鸣,而我面对的只有屏幕上刺眼的告警红框。某个核心 PostgreSQL 集群的 IO Util 瞬间被打到了 100%,TPS 从平时的 5000 直接跳水到不到 200。 登到机器上,敲下 iostat -xdm 1,看到 awaitw/s 指标飙得极高。这不是突发的并发查询导致的读瓶颈,而是极端的写盘风暴。 顺手查了一下 pg_stat_activity,活跃连接数并没有激增。排查这种毫无征兆的写瓶颈,必须从 PG 的内核机制去倒推:在什么情况下,正常的业务写入会导致成倍的底层物理 IO?

    幽灵长事务与 MVCC 的死穴

    我首先怀疑的是 Autovacuum 失效导致的表极度膨胀。查了一下当前的死元组(dead tuples)情况:

    SELECT relname, n_dead_tup, n_live_tup, 
           round(n_dead_tup * 100.0 / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio
    FROM pg_stat_user_tables 
    ORDER BY n_dead_tup DESC LIMIT 5;
    

    结果令人吃惊,几张核心大表的 dead_ratio 居然达到了惊人的 60% 以上。系统后台的 autovacuum worker 确实在跑,但清理效率极其低下。 直接看系统里有没有卡住的事务:

    SELECT pid, usename, state, backend_xid, backend_xmin, 
           EXTRACT(EPOCH FROM (now() - xact_start)) AS duration_sec, query
    FROM pg_stat_activity
    WHERE state IN ('idle in transaction', 'active')
    ORDER BY duration_sec DESC LIMIT 5;
    

    列表第一行赫然出现了一个处于 idle in transaction 状态的会话,duration_sec 已经达到了惊人的 18000 秒(5个小时)。这是一个下游数据抽取的脚本,开启了事务,跑完 SELECT 后由于网络或者应用逻辑问题,一直没有发 COMMITROLLBACK。 在 PostgreSQL 的 MVCC 实现里,这个被遗忘的会话就是最致命的毒药。 PostgreSQL 的 MVCC 与 MySQL/InnoDB 有着本质的区别。InnoDB 通过 Undo Log 存放旧版本数据,而当前数据页永远是最新的。PG 则是 Append-only 模式。更新一行数据(UPDATE),本质上是把旧行的系统字段 xmax 标记为当前事务ID,然后插入一行全新的数据,其 xmin 为当前事务ID。新旧数据通常共存在同一个文件、甚至同一个数据页中。 当旧数据不再对任何活跃事务可见时,它就成了 dead tuple,需要 Autovacuum 进程来回收空间。但判断一条 dead tuple 能否被清理的边界,是全局最老的活跃事务ID(即系统的 OldestXmin)。 那个挂了 5 个小时的 idle in transaction 犹如一把铁锁,死死卡住了全局的 OldestXmin 向前推进。这 5 个小时内,整个实例所有表产生的所有 UPDATE 和 DELETE 操作遗留的死元组,全部无法被物理回收。

    从表膨胀到 WAL 全页写(FPW)风暴

    如果仅仅是表膨胀,通常表现是查询变慢(由于要扫描更多的数据页),但这解释不了 IO 被打满的写风暴。 进一步观察系统的写行为,发现极高比例的 IO 来自于 WAL(Write-Ahead Logging)目录。 这引出了 PG 内核的另一个关键机制:Full Page Writes (FPW)。 PostgreSQL 的默认页大小是 8KB,而绝大多数 Linux 文件系统的块大小是 4KB。在极端情况下(例如系统断电或内核崩溃),一个 8KB 的 PG 数据页可能只有一半(4KB)被成功写入磁盘,这就是所谓的“撕裂页(Torn Page)”。 为了保证数据的一致性,PG 引入了 FPW 机制。当开启 full_page_writes = on(默认且强烈建议开启)时,在每次 Checkpoint 之后的第一次修改某个数据页,PG 不仅仅把行级的数据变更(逻辑日志)写入 WAL,而是把整个 8KB 的数据页镜像完整地写入 WAL。 把长事务、MVCC 膨胀和 FPW 串联起来,整个故障链路就完全清晰了:

    1. 长时间未提交的事务卡住了 OldestXmin,导致大量死元组无法被回收。

    2. 表急剧膨胀,原本 1 个数据页能存放的记录,现在散落在了 5 个甚至 10 个数据页中。

    3. 业务在进行高频 UPDATE 时,由于数据离散,修改操作跨越了比平时多得多的数据页。

    4. 由于写入量大,脏页迅速累积,导致 Checkpoint 被频繁触发。

    5. 致命一击:每次 Checkpoint 后,这些被极度分散的、数量庞大的数据页只要遭遇第一次修改,就会触发 Full Page Writes。

    6. WAL 日志量呈指数级暴增,直接击穿了存储的 IOPS 瓶颈。

    现场阻断与内核调优

    定位到根因后,处理现场的手段必须果断。 第一步,干掉那个毒瘤事务,释放 OldestXmin

    SELECT pg_terminate_backend(pid_of_the_idle_transaction);
    

    杀掉事务后,Autovacuum 终于能够正常工作了。但由于堆积的 dead tuples 太多,默认的 Autovacuum 参数显得杯水车薪,反而因为长时占用磁盘 IO 影响业务。此时需要动态干预,提升清理效率的同时限制瞬时 IO 消耗。 我调整了集群的几个关键配置:

    -- 提高允许并发清理的工作进程数
    ALTER SYSTEM SET autovacuum_max_workers = 6;
    -- 降低每次清理触发休眠的阈值,让清理操作更平滑而不是突刺
    ALTER SYSTEM SET autovacuum_vacuum_cost_delay = '2ms';
    -- 提高每个休眠周期的资源消耗上限,加快整体进度
    ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 2000;
    -- 针对核心膨胀表,临时降低 NAPTIME,让其被更高频地关注
    ALTER SYSTEM SET autovacuum_naptime = '30s';
    SELECT pg_reload_conf();
    

    同时,为了缓解 WAL 频繁 Checkpoint 带来的 FPW 放大效应,需要拉大 Checkpoint 的跨度,让尽可能多的页更新合并在一个 Checkpoint 周期内,从而只产生一次 FPW。

    -- 拉长检查点超时时间
    ALTER SYSTEM SET checkpoint_timeout = '15min';
    -- 增大 WAL 文件的最大配额,防止因 WAL 数量超限强制触发 Checkpoint
    ALTER SYSTEM SET max_wal_size = '20GB';
    -- 调整检查点完成的平滑度,防止 Checkpoint 刷脏页的突刺
    ALTER SYSTEM SET checkpoint_completion_target = '0.9';
    SELECT pg_reload_conf();
    

    调整完成后,观察 iostat 上的 w/s 开始稳步回落。此时可以通过 pg_stat_bgwriter 视图确认 Checkpoint 的状态:

    SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint, buffers_clean, maxwritten_clean 
    FROM pg_stat_bgwriter;
    

    重点关注 checkpoints_req(因 WAL 满而强制触发的检查点)与 checkpoints_timed(因时间到达触发的检查点)的比例。调整参数后,checkpoints_req 的增长明显停滞,说明系统已经回到了由时间驱动的平稳刷脏状态,FPW 风暴被有效阻断。

    规避机制

    把连接闲置等价于事务挂起,是很多开发者使用 ORM 框架时的常见误区。在 PG 这种强依赖 MVCC xmin 推进的数据库中,长事务是万恶之源。 在生产环境中,不能单纯依赖开发者的代码质量来保证数据库的稳定。必须在服务端设置兜底防线。 在 postgresql.conf 中,我会强制设置以下参数,这是防御此类故障的底线:

    # 终止超过 30 分钟处于 idle in transaction 状态的会话
    idle_in_transaction_session_timeout = '30min'
    # 终止执行时间超过 1 小时的超长查询(根据业务容忍度设定)
    statement_timeout = '3600s'
    

    屏幕上的 IO 图表终于拉平了。其实底层系统的很多所谓“诡异”问题,拆解到最后,都是由最基础的机制(Append-only, WAL, Page Cache)在特定的极度边界下产生的连锁反应。搞懂了底层的流转逻辑,解决问题不过是顺水推舟。

  • 导致集群雪崩的200MB大Key:当同步淘汰遇上Gossip心跳超时

    早上 10:30,正是一天中业务流量开始拉升的阶段。监控大屏上突然弹出密集的告警,某核心业务的 Redis Cluster 开始出现剧烈的节点状态震荡:Master 和 Replica 不断发生角色切换,客户端大面积报 READONLY 错误和 Command timed out。 介入排查时,第一眼看系统层指标:宿主机 CPU 并没有被打满(由于 Redis 单线程模型,单核跑到 100%,但整体 CPU 负载在一个合理水位),网络带宽也没有出现明显的瓶颈。 但集群就是不稳定。随意连上一个节点执行 cluster nodes,发现 configEpoch 正在疯狂自增,说明 Failover 在频繁发生。

    诡异的现场:消失的 Slowlog 与沉默的慢查询

    遇到节点响应慢甚至超时,排查的第一直觉通常是查慢查询。我在几个发生过切换的节点上执行 slowlog get 10,结果出乎意料:几乎是空的,或者只有几个毫秒级别的正常操作。 这是一个非常典型的误区:很多开发甚至运维认为,只要客户端超时,就一定能在慢查询日志里找到“元凶”。 实际上,Redis 的慢查询日志统计的仅仅是命令本身的执行时间。在源码中,慢查询的耗时计算严格包裹在 call(c, CMD_CALL_FULL) 函数内部。而一个命令在真正进入 call() 执行之前,还需要经历很多阶段,其中最致命的一个环节就是:内存淘汰(Eviction)

    抽丝剥茧:同步淘汰引发的 Reactor 线程阻塞

    既然不是命令本身慢,那必然是主线程在其他地方被卡住了。 我立刻查看了节点的实时状态:

    redis-cli -p 6379 info stats | grep evicted_keys
    

    发现 evicted_keys 的数值在极其剧烈地飙升。同时,info memory 里的 used_memory 紧紧贴着 maxmemory 的边缘疯狂试探。 结合业务方的发布记录,真相逐渐浮出水面:开发团队在早晨上线了一个所谓的“性能优化”逻辑,为了减少网络 RTT,他们将全量用户的关系图谱作为 Hash 结构缓存进了 Redis。通过 redis-cli --bigkeys 扫描,发现部分 Hash 结构的体积达到了恐怖的 150MB 到 200MB,包含上百万个 Field。 当 Redis 内存达到 maxmemory,且配置了 allkeys-lru 策略时,处理任何写入命令前,都会进入 freeMemoryIfNeeded() 函数尝试释放内存。 灾难的爆发点就在这里:这批集群运行的版本默认并未开启 lazyfree-lazy-eviction。这意味着,当 LRU 算法不幸选中了一个 200MB 的超级大 Key 时,Redis 必须同步地去销毁这个拥有百万元素的 Hash 字典。 在 C 语言层面,这是一个极其沉重的 dictRelease 操作,需要逐一遍历并释放内部的 dictEntry。释放一个 200MB 的 Hash,主线程会被死死卡住 2 到 3 秒。在这 3 秒内,整个 Reactor 事件循环完全停滞。

    Gossip 协议的暗礁:极端的 cluster-node-timeout

    如果仅仅是主线程阻塞 3 秒,顶多是一次短暂的客户端 P99 延时毛刺,为什么会引发整个集群的连环 Failover? 这就牵扯到了开发人员犯的第二个致命错误。为了追求所谓的“极致高可用”,有人在配置文件中将 cluster-node-timeout 从默认的 15000 毫秒(15秒)暴降到了 3000 毫秒(3秒)。 逻辑看似很丰满:“超时时间越短,故障发现越快,主从切换就越敏捷。” 但这完全违背了 Redis Cluster 的底层通信机制。Redis 的 Gossip 协议虽然使用的是独立端口(通常是业务端口 + 10000,例如 16379),但它的网络 IO 事件(clusterReadHandler / clusterWriteHandler)同样是注册在主线程的 aeMain 事件循环中的。 当主线程在同步释放 200MB 的大 Key 时,事件循环被阻塞。此时,该节点既无法处理客户端的新请求,也无法响应其他集群节点发来的 Gossip PING 包。 时间线是这样推进的:

    1. 0.0s: 内存触顶,触发 LRU 淘汰,选中 200MB 大 Key。

    2. 0.1s – 2.5s: 主线程陷入 dictRelease,疯狂释放内存,对外界毫无响应。

    3. 1.5s: 其他节点发送 PING 给该节点。数据包到达 OS 内核 TCP 缓冲区,但 Redis 主线程没空去 read()

    4. 3.0s: 致命时刻。由于 cluster-node-timeout 被设为 3000ms,其他节点发现超过 3 秒没有收到 PONG,立即将其标记为 PFAIL(疑似下线)。

    5. 3.1s: 通过 Gossip 交换状态,集群半数以上 Master 认为该节点 PFAIL,状态升级为 FAIL

    6. 3.2s: 触发从节点选举,Replica 晋升为新 Master。

    7. 3.5s: 原 Master 终于删完了大 Key,主线程苏醒。它处理完积压在缓冲区的 Gossip 包,愕然发现自己已经被弹劾。它只能乖乖降级为 Replica,并向新 Master 发起全量同步(SYNC)。

    8. 后续: 全量同步触发 BGSAVE,Copy-On-Write 机制在内存高水位下瞬间导致 OOM Killer 介入,节点彻底崩溃。 这是一个教科书级别的雪崩链路。

    技术结论与干预

    处理这种现场,没有任何优雅退让的余地。我直接采取了三步阻断措施: 第一步:热更配置,开启异步淘汰,把释放内存的脏活丢给后台 BIO 线程。

    config set lazyfree-lazy-eviction yes
    config set lazyfree-lazy-server-del yes
    

    第二步:回调 Gossip 超时时间到合理水位。3 秒的超时在分布式网络环境中无异于走钢丝。

    config set cluster-node-timeout 15000
    

    第三步:拦截写入大 Key 的源头,通知开发紧急回滚。大对象必须拆分(例如按用户 ID Hash 取模落到不同的子 Key 中),没有商量的余地。 不要把单机思维强行套用在分布式架构上。在 Redis 这种极度依赖单线程事件循环的模型中,将超时时间压榨到极致,并不能换来高可用,只会让系统变得像玻璃一样脆弱。任何一个同步阻塞的微小扰动,都会顺着 Gossip 协议的连锁反应,被放大成一场席卷全网的风暴。而懂得在“快速发现故障”和“容忍正常网络抖动与执行毛刺”之间找到平衡点,才是系统架构的成熟体现。

  • 把单机思维带进分布式:一次千万级 DELETE 语句引发的 Percolator 锁雪崩

    上午十点,刚把架构改造方案的拓扑图画完,监控大屏突然红了一片。应用端开始疯狂报 Lock wait timeout exceeded,伴随着偶尔的 Transaction is too large 异常。 打开 Grafana 扫了一眼 TiDB 集群的状态:几个 TiDB Server 节点的内存呈直角飙升,接着发生了 OOM 重启;底层的 TiKV 节点 CPU 几乎跑满,Scheduler command duration 面板里,prewritecommit 的 P99 耗时已经飙到了数十秒。 很显然,整个集群被什么东西卡死,发生了严重的锁阻塞。 连上存活的 TiDB 节点,敲下 SHOW PROCESSLIST,我很快抓到了罪魁祸首。那是一条极度简单、却在这个场景下极度致命的 SQL:

    DELETE FROM order_history WHERE create_time < '2023-01-01';
    

    没有 LIMIT,没有按主键分批,什么保护机制都没有。问了下业务侧,这张表里一年前的历史数据大概有四千多万行。开发人员的逻辑很简单:“既然换了分布式数据库,容量和算力应该随便造,一条 DELETE 删几千万条数据怎么了?MySQL 可能会锁表,你们分布式不应该更牛吗?” 这种把分布式数据库完全当成单机 MySQL 甚至当成黑盒来用的思维,是导致这场灾难的根本原因。 分布式数据库确实能横向扩展,但它绝不是无脑承接烂 SQL 的垃圾桶。在 TiDB 这类基于 Percolator 模型的分布式事务引擎中,大事务是天然的毒药。今天必须把底层逻辑拆透,看看一条千万级的 DELETE 是怎么把一个健康的集群拖进深渊的。 在单机 MySQL (InnoDB) 中,大批量删除主要消耗的是 Undo Log、Redo Log 以及 Buffer Pool,顶多把这条数据所在的数据页锁住,影响局部的并发。 但在 TiDB 中,事务采用的是基于 Google Percolator 模型的两阶段提交(2PC)。当我们执行这行 DELETE 时,底层到底在发生什么? 第一步是读取与内存构建。TiDB Server 并非一边查一边删,它需要先将满足 create_time < '2023-01-01' 的所有记录的主键全部扫出来。在这个过程中,这四千万行数据的 Mutation(变更操作)会被缓存到 TiDB Server 的内存里。即使 TiDB 后来对大事务做过一定优化,但单事务的限制依旧存在(默认事务大小限制为 100MB,最多几百万条 KV)。即使通过改参数强行突破了限制,TiDB Server 节点的内存也会被瞬间抽干,引发 OOM 崩溃。 第二步是致命的 Prewrite 阶段。假设内存勉强扛住了,TiDB 开始向 PD(Placement Driver)获取 TSO 作为事务的 start_ts,然后进入预写阶段。Percolator 模型会在修改的这数千万个 Key 中,选出一个作为 Primary Lock(主锁),剩下的全部作为 Secondary Lock(从锁),这些从锁都会包含一个指向主锁的指针。 接着,TiDB 会并发地向 TiKV 节点发送这些 Prewrite 请求。此时,TiKV 层开始疯狂地写 Lock CF(列族)和 Default CF。几千万个锁如同雨点般砸向整个集群的各个 Region。 第三步,雪崩的形成(锁冲突与 Resolve Lock)。Percolator 模型保证了强一致性的快照隔离(Snapshot Isolation)。在这条巨型 DELETE 语句尚未进入 Commit 阶段(即 Primary Lock 未提交)时,其他正常的业务查询如果读到了这几千万个 Key 中的任何一个,会发生什么? 读请求会看到这里有一个未提交的 Lock。为了保证一致性,它不能直接跳过,必须等。它会等待这个锁被释放,或者等待锁的 TTL 过期。 一旦 TTL 过期,读请求还要主动触发 Resolve Lock(清理锁)操作——顺着 Secondary Lock 找到 Primary Lock,检查那个大事务到底有没有提交,如果没有,就尝试回滚它。 当整个 TiKV 充满了这种未决的 Lock 时,系统的 Resolve Lock 开销会呈指数级上升。正常的读写请求全被卷入这场找锁、等锁、清锁的漩涡中,最终导致整个 TP 业务瘫痪。 在故障现场,我没有别的选择,立即 KILL 掉了这个执行中的大事务连接。 但噩梦并没有马上结束。在分布式环境中,大事务的回滚代价同样极其高昂。TiKV 需要清理掉刚才写入的上千万个 Lock 记录。在接下来的十几分钟里,监控上的垃圾回收(GC)指标依然居高不下,直到异步清理动作彻底完成,集群的延迟才慢慢降回毫秒级。 分布式不仅仅是几台机器连在一起那么简单。存储底座从 B+ 树变成了 LSM-Tree,事务模型从单机的本地日志变为了基于时间戳的分布式 2PC,这些底层原理的剧变,要求开发者必须改变对数据库的交互方式。 如果在单机时代,我们提倡“大事务拆小事务”是为了减少锁表时间和 Undo 空间;那么在 Percolator 分布式事务模型下,“大事务拆小事务”就是关乎集群生死存亡的铁律。 对于这种历史数据的清理,最干净的做法是在建表时就规划好时间分区(Partition),清理时直接一条 ALTER TABLE order_history DROP PARTITION p2022,这在底层只是元数据的变更,瞬间完成。 如果非要用 DELETE,就老老实实写一个脚本,按主键范围每次处理 2000 到 5000 行,循环往复。 把单机时代的粗暴操作原封不动地搬到架构复杂的分布式系统上,是对技术的无知。任何时候,不敬畏系统底层的运行机制,系统必定会以最直接的方式给你一个惨痛的教训。

  • 抛弃线性遍历:高并发网关从 iptables 迁移至 nftables 的核心逻辑与底层重构

    上午刚做完边缘网关集群的灰度替换,看着监控面板上节点 CPU 的 si(软中断)使用率从常态的 40% 直接掉到了 5% 以下,整个系统的吞吐量算是彻底释放出来了。 长久以来,我们在处理海量 IP 黑名单封禁、复杂的 SNAT/DNAT 转发时,习惯性地依赖 iptables。但随着单机连接数突破百万,原本那些跑得好好的 iptables 规则逐渐成了吃掉 CPU 性能的隐形黑洞。今天借着上午这次重构落地的契机,聊聊从 iptables 迁移到 nftables 背后的底层逻辑,以及如果只用 iptables-translate 为什么根本达不到优化效果。

    兼容层的虚假繁荣:iptables-nft 的陷阱

    现在很多较新的 Linux 发行版(如 Debian 11/12, RHEL 8+),你在终端敲下 iptables 时,底层其实已经是指向 iptables-nft 的软链接了。 很多人有一个误区,认为系统既然底层用了 nftables 的引擎,性能自然就上去了。这是一个错觉。iptables-nft 仅仅是一个兼容层,它的作用是把 iptables 的命令语法,在内核层面翻译成 nftables 的字节码(Bytecode),但它完全保留了 iptables 的线性遍历逻辑。 在传统的 iptables 架构中,规则在内核里是作为一个连续的内存块(Blob)存储的。这意味着:

    1. 匹配极慢:如果有一万条 IP 封禁规则,数据包进入 PREROUTINGINPUT 链后,内核必须从第一条规则开始逐条进行匹配(O(N) 复杂度)。

    2. 更新代价高昂:每次新增或删除一条规则,内核都需要加锁,并将整个规则集的 Blob 重新拷贝一遍。这在频繁更新规则的 K8s 节点或动态防火墙上,会导致严重的自旋锁竞争。 虽然 iptables-nft 解决了全局锁更新的问题(nftables 支持增量更新),但只要你还是按照以前一条条添加规则的思路去写逻辑,O(N) 的线性匹配性能瓶颈就依然存在。

    核心重构:从线性匹配走向集合(Sets)与映射(Maps)

    nftables 的真正杀手锏在于原生支持了高级数据结构:Sets(集合)、Dictionaries(字典/映射)和 Concatenations(级联匹配)。这也是我们在重构时改变最大的地方。

    1. 使用 Sets 终结海量 IP 匹配

    以前我们要封禁 1000 个恶意 IP,iptables 要写 1000 条规则:

    iptables -A INPUT -s 1.1.1.1 -j DROP
    iptables -A INPUT -s 2.2.2.2 -j DROP
    ...
    

    在原生的 nftables 中,我们通过定义一个底层的 Hash 表或红黑树(取决于具体的配置和元素数量)来实现 O(1) 或 O(log N) 的查找:

    # 定义一个名为 blackhole 的表
    table inet blackhole {
        # 定义一个集合,底层用 hash 实现,支持动态更新
        set bad_ips {
            type ipv4_addr
            flags dynamic, timeout
            timeout 24h
        }
        chain input {
            type filter hook input priority 0; policy accept;
            # 一条规则搞定所有匹配
            ip saddr @bad_ips counter drop
        }
    }
    

    运行时如果需要动态加黑名单,只需执行:nft add element inet blackhole bad_ips { 3.3.3.3 }。内核层面只需在现有的 Hash 表里插入一个节点,数据包匹配时直接进行键值查找,CPU 开销微乎其微。

    2. 使用 Maps 重构复杂的 NAT 转发

    在之前的架构中,边缘网关承担了大量不同端口到不同内网机器的 DNAT 转发。几十条 NAT 规则在 PREROUTING 链中依次匹配。 重构为 nftables 后,我们利用 Map 结构,将端口号映射为后端 IP 和端口的组合,实现了只需一条规则就能处理所有转发逻辑:

    table ip nat {
        map port_to_backend {
            type inet_service : ipv4_addr . inet_service
            elements = { 
                8080 : 10.0.1.10 . 80,
                8443 : 10.0.1.11 . 443,
                9090 : 10.0.1.12 . 9090 
            }
        }
        chain prerouting {
            type nat hook prerouting priority dstnat; policy accept;
            # dnat to 后面接 map 查找,如果匹配直接完成 NAT 重写
            # tcp dport 取出目标端口,去 port_to_backend 字典里查
            dnat to tcp dport map @port_to_backend
        }
        chain postrouting {
            type nat hook postrouting priority srcnat; policy accept;
            masquerade
        }
    }
    

    这里的 ipv4_addr . inet_service 是 nftables 的 Concatenations(级联)特性。它允许我们将多个字段拼接成一个元组(Tuple)作为 value。这不仅让配置极其清晰,更从底层消除了 Netfilter 链表的遍历损耗。

    终极性能释放:Conntrack 与 Flowtable 硬件/软件卸载

    只优化规则匹配时间是不够的。任何熟悉 Linux 网络栈的人都知道,Netfilter 的性能大头其实耗在了 conntrack(连接跟踪)上。只要使用了 NAT 或者状态防火墙(如 ct state established,related accept),每一个数据包都要过一遍状态机。 虽然我们无法完全绕过 conntrack,但 nftables 引入了 flowtable 机制(Fastpath),这是 iptables 时代难以实现的降维打击。 它的底层逻辑是:对于一条已经建立(ESTABLISHED)的连接,既然它的双向路由和 NAT 状态已经确定,为什么还要让后续的海量数据包去走完整的 Netfilter Hooks(PREROUTING -> FORWARD -> POSTROUTING)? 上午我们在部分流量极大的节点上开启了软件层面的 flowtable 卸载:

    table inet filter {
        # 定义 flowtable,挂载在 ingress 钩子上
        flowtable f_fastpath {
            hook ingress priority 0
            devices = { eth0, eth1 }
        }
        chain forward {
            type filter hook forward priority 0; policy accept;
            # 对于新建连接,走正常的规则过滤,如果允许,则加入 flowtable
            ip protocol { tcp, udp } flow add @f_fastpath
            # 兜底的传统 established 处理
            ct state established,related accept
        }
    }
    

    这段配置在内核态发生了什么? 当握手包(SYN, SYN-ACK, ACK)走完传统的 forward 链后,flow add @f_fastpath 会将这条五元组连接插入到 flowtable 中。 当这条连接后续的数据包到达网卡的 ingress hook(比 PREROUTING 更早)时,直接命中 flowtable。内核会在这里直接执行目的 MAC 替换、TTL 减 1 以及 NAT 重写,然后直接塞给网卡发包出去。整个过程完全短路了标准的 IP 路由栈和 Netfilter 钩子链。 这就是为什么上午灰度一开,CPU 软中断直接从 40% 砸到 5% 的根本原因。

    迁移过程中的避坑指南

    真正执行这种底层迁移时,不能光看着爽,几个坑必须注意:

    1. 禁用内核遗留模块:既然切了 nftables,就彻底一点。通过 rmmod 或者内核黑名单机制,把 ip_tables, iptable_filter, iptable_nat 统统屏蔽。否则内核里同时跑着两套 Hook 注册,性能反而会倒退,还会出现玄学的报文丢失问题。

    2. Conntrack INVALID 状态丢包:迁移后注意监控系统指标 netstat -s | grep "invalid" 或者 conntrack -S。有些非标准客户端发出的乱序包,或者非对称路由产生的单向流,会被 nftables 的严格状态机判定为 INVALID 并 drop。必要时需在 raw 表(nftables 中对应 priority 为 -300 的 hook)里做 notrack(在 nft 里叫 ct state untracked)放行。

    3. 不要依赖 iptables-translate:很多人偷懒,直接用这个工具把以前的 iptables 规则批量翻译成 nft 脚本导入。前面说了,这样只是把垃圾代码换了一种语言重写了一遍。一定要根据业务逻辑,重新抽象成 Maps 和 Sets。 技术演进不是简单的命令替换,弄懂 Netfilter 数据包流转在内核源码层的路径变化,才是做这种底层架构调整的底气所在。今天白天这波重构算是稳住了,后续再观察晚高峰的并发表现。