• 突破 OOM 死亡循环:Prometheus 高基数指标引发的 TSDB 内存雪崩与底层结构解析实战

    结论先行:Prometheus 频繁 OOM 且 WAL 截断失败,99% 的根因是高基数(High Cardinality)标签打穿了 Head Block 的倒排索引。底层 Gorilla 压缩算法只能极大地优化时序“值”的存储(16字节压缩至约1.37字节),但救不了无限膨胀的 Label 组合。解决方案:通过 promtool tsdb analyze 定位基数元凶,用 metric_relabel_configs 在抓取阶段实行防御性清洗,并合理配置 TSDB 的 Block 压缩与落盘周期。

    某次排查过程中,我们线上一套监控几十个 K8S 集群的核心 Prometheus(v2.45.0)节点陷入了 CrashLoopBackOff 的死亡循环。告警静默,监控大屏一片空白。

    查看系统内核日志,死因极其明确——被 OOM Killer 制裁:

    $ dmesg -T | grep -i oom
    [xxx] prometheus invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
    [xxx] Memory cgroup out of memory: Killed process 12345 (prometheus) total-vm:85493200kB, anon-rss:67108864kB
    

    物理机分配了 64GB 内存给这个容器,居然在几分钟内被吃干抹净。这绝不是正常的指标写入量增长,而是典型的“高基数雪崩”。

    寻找雪崩元凶:拨开 WAL 与 Head Block 的迷雾

    Prometheus 的 TSDB 设计基于内存(Head Block)与磁盘(Persistent Block)的组合。最新采集的 2-3 小时数据全部驻留在内存中,并依靠 WAL(Write-Ahead Log)保证不丢数据。每次 Prometheus OOM 重启后,第一件事就是 Replaying WAL。如果导致 OOM 的高基数数据还在 WAL 里,重启过程必将再次吃满内存,形成死循环。

    为了强行中断这个循环,我们先将该节点的内存限制临时放大到 128GB 让其启动,随后立即使用官方神器 promtool 对本地数据目录进行离线解剖:

    $ promtool tsdb analyze /prometheus/data
    ...
    # Top 10 label names with high memory usage:
    1: trace_id
    2: client_ip
    3: pod_ip
    
    # Top 10 series count by metric names:
    1: 4501230  http_request_duration_seconds_bucket
    2: 2100450  http_requests_total
    ...
    

    破案了。某业务研发在 http_requests_total 和耗时直方图里,顺手加上了 trace_idclient_ip 作为 Label。

    为什么仅仅是多加了一个 Label,就会耗尽上百 GB 的内存?

    很多开发对时序数据库有误解,认为“Prometheus 压缩率很高,多加个字段无所谓”。这就必须深入 TSDB 的底层数据结构来解释。

    在 Prometheus 中,一条时间线(Series)由 Metric Name 和一组 Label 键值对唯一确定http_requests_total{method="GET", status="200", client_ip="192.168.1.10", trace_id="abc123xxx"}

    TSDB 处理数据分为两大核心路径:数据块(Chunks)倒排索引(Inverted Index)

    1. Chunks 的极致压缩(Gorilla 算法) 对于属于同一条时间线的连续样本数据 (Timestamp, Value),Prometheus 采用了类似 Facebook Gorilla 论文中的 XOR 增量压缩算法。因为时间戳通常是规律递增的(如 15s 一次),Value 往往变化极小。通过计算差值的差值(Delta-of-Delta),一对原本需要 16 Bytes(8 Byte int64 时间戳 + 8 Byte float64 值)的样本,能被压缩到平均 1.37 Bytes。这就是大家常说的“高压缩率”。

    2. 倒排索引的内存黑洞 Gorilla 压缩对 Label 完全无效。为了能让 PromQL 飞速查询,Prometheus 必须为每一个 Label 的 Name 和 Value 建立倒排索引映射: Label (client_ip="192.168.1.10") -> [Series ID 1, Series ID 205...] 当引入 trace_id 这种几乎每次请求都不同的 Label 时,Series 的数量等于所有 Label 基数的笛卡尔积。 百万级别的 trace_id 瞬间生成了数百万条独立的 Series。每条全新 Series 的诞生,都会在 Head Block 中分配新的字符串内存(Symbols table)、新的倒排索引指针(Postings list),以及独立的数据 Chunk。内存消耗呈现指数级爆炸,且完全无法被压缩。

    当这些海量的内存结构积压在 Head Block(默认驻留时间最多达 3 小时),内存自然会瞬间被打穿。

    落地实战:防御性清洗与架构调优

    对于这种毒瘤级的指标,我们绝不妥协,必须在网关侧/采集侧直接干掉,实施“防御性运维”。

    1. 采集端防御配置(metric_relabel_configs) 在 Prometheus 的 scrape_configs 中,利用 metric_relabel_configs 在指标进入 TSDB 引擎前将其截杀。注意,不要用 relabel_configs(作用于 target 发现阶段),必须用 metric_relabel_configs

    scrape_configs:
      - job_name: 'business-api'
        ...
        metric_relabel_configs:
          # 方案一:直接丢弃整个包含了违规 Label 的指标序列(下手最狠)
          - source_labels: [trace_id]
            regex: '.+'
            action: drop
    
          # 方案二:保留指标,但抹除高基数 Label(推荐,保证监控不丢失总并发量)
          - regex: '(trace_id|client_ip)'
            action: labeldrop
    

    加载配置 (curl -X POST http://localhost:9090/-/reload) 后,新的数据洪流被清洗干净。

    2. TSDB 块生命周期(Compaction)调优 为了让 Prometheus 尽快清理掉历史遗留的庞大 Head Block,我们需要理解 TSDB 的落盘(Compaction)机制。 Head Block 中的数据达到特定条件会切分成持久化的 Block 目录(包含 meta.json, index, chunks/, tombstones)。

    如果服务器内存吃紧,我们可以适当干预落盘周期(启动参数配置):

    # 默认 min-block-duration 为 2h,决定了 Head 块多久切片一次落盘。
    # 强制保持一致,避免块过大难以合并
    --storage.tsdb.min-block-duration=2h
    --storage.tsdb.max-block-duration=24h
    

    持久化到磁盘后的 Block 会被通过 mmap 的方式映射到虚拟内存空间(VIRT),此时只要不进行全量范围的 PromQL 查询,这部分数据对物理内存(RES)的占用将大幅度降低,由 Linux 内核的 PageCache 全权接管。

    3. 清理已存在的毒瘤数据(Tombstones 机制) 对于历史的脏数据,可以使用 Admin API 软删除:

    curl -X POST -g 'http://localhost:9090/api/v1/admin/tsdb/delete_series?match[]={trace_id=~".+"}'
    

    执行后,数据不会立刻从磁盘消失,而是写入到 Block 目录下的 tombstones 文件中。后续的 Compaction 过程会读取该文件并真正剔除无用数据。如果需要强制立即清理磁盘,可调用:

    curl -X POST http://localhost:9090/api/v1/admin/tsdb/clean_tombstones
    

    常见问题 (FAQ)

    Q1:为什么通过 metric_relabel_configs 删除了高基数 Label,Prometheus 的物理内存(RES)并没有立刻下降? A:这是符合预期的。由于 Prometheus TSDB Head Block 的机制,数据通常要在内存中攒满 2 小时(加上最长允许的 1 小时 overlap)才会执行落盘并释放内存。即使新抓取的数据不再含有高基数标签,旧的庞大倒排索引依然存活在内存里。你需要耐心等待下一次 Head 切片,或者干脆重启进程,配合之前放宽的内存上限让 WAL 重放完成后,内存自然回落。

    Q2:Prometheus 发生 OOM 重启后,启动特别慢,日志一直卡在 Replaying WAL 是什么情况? A:Prometheus 只有正常退出时,才会将 Head Block 里的内容做 Checkpoint 或者全量 Flush 落盘。OOM 属于非正常崩溃,内存数据丢失。重启后,它必须逐行读取 data/wal/ 目录下的日志以在内存中重建倒排索引和 Chunks。如果 WAL 高达几十个 GB,这个过程将极其漫长(且高度依赖磁盘 IOPS)。建议将 TSDB 部署在企业级 NVMe SSD 上,这是监控系统的底线。

    Q3:我可以通过降低抓取频率(将 scrape_interval 从 15s 调整为 60s)来缓解高基数导致的 OOM 吗? A:不能,这是经典误区。scrape_interval 影响的是同一条 Series 每分钟追加的样本点数量。这部分数据被 Gorilla XOR 算法高效压缩,占用极小。导致 OOM 的是 Series 的总数(基数规模)膨胀,进而撑爆了不可压缩的倒排索引表。无论是 15s 抓取一次还是 1 分钟抓取一次,只要这几百万个带唯一 trace_id 的 Label 依然存在,生成的索引内存消耗是一模一样的。

  • 深度剖析:跨机房 Federation 链路高延迟引发的 RabbitMQ 内存雪崩与路由风暴

    结论先行:跨机房部署 RabbitMQ Federation 时,高延迟 WAN 链路配合过大的 prefetch-count 会触发 Erlang VM 内存雪崩。解决方案:将 Upstream 的 prefetch-count 下调至 100-500,调优底层 TCP 发送窗口,并强制配置 max-hops=1 彻底阻断 AMQP 路由环路。以下是故障现场复盘。

    凌晨两点半,告警群被 P99 投递延迟报警刷屏。生产环境一组基于 RabbitMQ 3.11.15 (Erlang 25.3) 构建的双活集群由于跨机房专线拥塞,引发了连锁反应:上游集群触发 vm_memory_high_watermark 导致全量生产者被 Connection.Blocked 阻塞,核心交易链路短时瘫痪。

    为什么高延迟WAN链路会击穿 Federation 的内存防线?

    排障的第一步永远是看现场指标。通过 rabbitmq-diagnostics memory_breakdown,我发现上游集群的内存消耗并非由于 Queue 中积压了大量 Ready 消息,而是 connection_readersconnection_writers 占用了接近 6GB 内存。

    本质上,RabbitMQ Federation 插件是一个运行在下游(Downstream)集群内部的 AMQP 客户端。它会在上游(Upstream)声明一个内部队列(通常命名为 federation: exchange_name -> target),然后通过 AMQP 协议的 basic.consume 不断拉取消息。

    当 WAN 链路出现 50ms 以上的延迟波动时,灾难的种子就埋下了:

    1. 默认无限制的信道窗口:如果不显式指定,Federation 链路会使用默认较大的 prefetch_count(或者受限于网络吞吐)。

    2. Erlang 的异步发送机制:上游的 Channel 进程在收到 ACK 之前,会将 In-flight(飞行中)的消息保存在 Erlang 进程字典和底层 TCP Socket 缓冲区中。

    3. 内存急剧膨胀:延迟飙升导致下游 ACK 返回极慢。上游积压了大量 Unacked 消息,Erlang VM 为了维持吞吐,不断分配 Binary Heap。当总内存触及 vm_memory_high_watermark.relative = 0.4 的警戒线时,RabbitMQ 启动自保,触发全局内存告警,挂起所有发送消息的 TCP 连接。

    抓取底层网络包也能印证这一点:

    # 查看堆积在 TCP Send Buffer 里的数据量
    ss -tnpi | grep -A 1 5672
    

    你会看到 wmem_alloccwnd 极大,数据卡在内核态发不出去,上层 Erlang 进程不断重试分配内存。

    隐藏在 Binding 下的无限反射:路由风暴溯源

    在控制住了内存水位(临时调大 watermark 阈值放行流量)后,我发现上游的 TPS 曲线呈现出不自然的周期性锯齿。查阅日志,发现了大量重复的 x-received-from Headers。

    这就是跨机房双活的第二个大坑:AMQP 路由风暴

    在双向同步(Active-Active)架构中,A 机房的 Exchange 同步给 B 机房,B 机房的 Exchange 又配置了 Federation 同步给 A 机房。如果路由控制不当,一条消息会在 A 和 B 之间像乒乓球一样无限反射。

    Federation 防止环路的核心机制是附加 AMQP Header:

    • 消息离开 A 机房时,被打上 x-received-from: A-node-name

    • 消息到达 B 机房,B 尝试转发回 A 时,检查 Header 发现 A 已经存在,则丢弃。

    但坑在于:如果你使用的是 HAProxy 等四层负载均衡连接 Upstream,或者节点重启导致 Node Name 发生变化,Header 的防环检测就会失效。此时 max-hops 参数就成了最后一道防线。如果没配,消息默认会跳跃多次,导致内部网络带宽被无效的 AMQP Framing 完全榨干。

    核心调优与防御性配置落地

    废话不多说,直接上修复方案和最终配置。我们要从应用层协议栈到底层内核参数进行全面限制。

    1. 收紧 Federation 链路的 QoS

    重置 Upstream 参数,严格控制 prefetch-countmax-hops

    # RabbitMQ 控制台执行,动态更新 Federation Upstream
    rabbitmqctl set_parameter federation-upstream my-cross-dc-upstream \
    '{"uri":"amqp://sync_user:password@remote-haproxy:5672", 
      "prefetch-count": 200, 
      "max-hops": 1,
      "reconnect-delay": 5,
      "ack-mode": "on-confirm"}'
    

    注:prefetch-count: 200 是经过网络带宽延迟乘积(BDP)计算的折中值,既保证了基本吞吐,又避免了延迟突发时的内存爆仓。ack-mode: on-confirm 确保消息在落盘后再回执,防止脑裂丢数据。

    2. 底层 TCP 缓冲区调优

    rabbitmq.conf 中调整与 WAN 链路适配的 TCP 缓存参数,防止底层协议栈吃光内存后反压至 Erlang 层。

    # /etc/rabbitmq/rabbitmq.conf
    ## 针对高延迟网络调优 TCP Write/Read Buffer
    tcp_listen_options.sndbuf  = 131072
    tcp_listen_options.recbuf  = 131072
    tcp_listen_options.backlog = 1024
    tcp_listen_options.nodelay = true
    
    ## 开启信用流控告警
    vm_memory_high_watermark_paging_ratio = 0.75
    

    3. 清理残留的无效 Binding

    路由风暴往往伴随着错误的内部绑定。使用以下命令排查并清理:

    # 过滤查看内部的 federation 绑定关系
    rabbitmqctl list_bindings -p / | grep 'federation:'
    

    如果发现某些已废弃机房的临时 Queue 还在,坚决通过 rabbitmqadmin delete queue name='...' 干掉,防止死信不断积压。

    常见问题

    Q1:跨机房同步,Shovel 和 Federation 到底该怎么选? Federation 是基于 Exchange 拓扑的声明式同步,适合大面积的“状态复制”(如配置广播、多活全量同步),但其隐藏了内部队列,出故障时排查成本高。Shovel 是明确的点对点队列搬运工,属于典型的“硬连接”,结构简单且极度可控。如果是核心交易数据的跨机房灾备,我强烈建议使用 Shovel;如果是常规业务的多活路由,再考虑 Federation。

    Q2:Federation 链路状态显示 running,但消息就是不同步怎么排查? 大概率是网络半连接(Half-Open)或者 AMQP 协议层的死锁。直接看下游节点的内部 Queue 堆积情况。使用 rabbitmqctl list_queues name messages_unacknowledged 过滤 federation: 开头的队列。如果 unacknowledged 居高不下,说明网络回包被丢弃。结合 tcpkill 或重启 Federation link 插件即可快速恢复。

    Q3:如何精准监控 Federation 的积压情况? 不要只盯上游业务队列。必须监控下游针对上游自动生成的内部队列积压。建议在 Prometheus Exporter 中增加正则匹配: rabbitmq_queue_messages_ready{queue=~"federation:.*"}。只要这个指标突破 1000,立刻触发 P2 级告警检查专线质量,否则等待你的就是全线上游节点的熔断。

  • 深入解析 GitLab CI 制品分层缓存:基于 BuildKit 与外部后端的镜像构建优化实战

    镜像构建耗时往往是 CI 流水线的核心瓶颈。通过在 GitLab Runner (v16.3) 中引入 Docker BuildKit 的 LLB 状态树,结合 --mount=type=cache 挂载与 Registry/S3 分布式缓存后端,我将核心业务的 Go/Node.js 镜像构建耗时从 15 分钟压缩至 90 秒,彻底解决了高并发构建时的 CPU 与网络 IO 争用问题。

    上午巡检时看了一眼 CI 大盘,近期研发提交的高峰期,流水线的排队等待率显著下降。回想起几个月前,每天一到发版窗口,K8S 集群里的 CI 节点负载就被打满,本质原因其实是没有建立起工程化的制品缓存体系。今天正好梳理一下这套缓存落地的底层逻辑。

    为什么传统的 Docker 分层缓存在分布式 CI 环境中总是失效?

    很多研发在本地跑 docker build 觉得很快,但一上 CI 就慢得令人发指。这是因为本地环境是有状态的,Docker Daemon 的 /var/lib/docker/overlay2 目录完整保留了之前构建的每一层哈希值和物理文件。

    而在现代的分布式 CI 架构中(例如使用 GitLab Kubernetes Executor),为了保证构建的纯洁性和资源的弹性伸缩,Runner Pod 通常是无状态阅后即焚的(Ephemeral)。 当你在流水线里执行标准的 docker build 时,面临两个致命缺陷:

    1. 宿主机缓存漂移:上一次构建在 Node A,下一次调度到了 Node B。由于 Daemon 环境隔离,历史 layer 完全丢失,必须从 FROM 语句开始重新拉取基础镜像、重新下载全量依赖。

    2. CI Cache 机制不匹配:即便你使用了 GitLab 官方的 cache: 关键字,它也只是在流水线步骤之间通过 S3 或 MinIO 打包、解包工作目录下的文件(ZIP 压缩/解压)。这种机制对应用层的代码有效,但根本无法介入 Docker Daemon 的底层构建图(Build Graph)中。

    结果就是,每次构建都在重复执行 go mod downloadnpm install,白白浪费海量的网络带宽与磁盘 IO。

    核心解法:引入 BuildKit LLB 状态树与分布式缓存后端

    从 Docker 18.09 开始引入的 BuildKit(默认在较新版本中启用)彻底重构了构建引擎。它不再是逐行执行 Dockerfile,而是将其编译为低级构建器(LLB,Low-Level Builder)的无环有向图(DAG)。

    基于 DAG,BuildKit 不仅能并发执行互不依赖的构建阶段(Multi-stage),更重要的是它支持了外部缓存后端(Cache Backends)。这意味着我们可以将构建树的元数据和文件层推送到远端 Registry 或 S3 中,下一次无论 Runner 被调度到哪台物理机,都能通过拉取元数据直接恢复缓存图。

    下面是我们目前正在使用的生产级 Dockerfile 改造方案,以 Go 1.21 项目为例:

    # 必须显式声明开启 BuildKit 的高级语法支持
    # syntax=docker/dockerfile:1.4
    FROM golang:1.21-alpine AS builder
    
    WORKDIR /app
    
    # 优化点 1:只复制依赖描述文件,防止业务代码变动导致依赖层的 cache 被 invalid
    COPY go.mod go.sum ./
    
    # 优化点 2:挂载外部构建缓存到容器内的标准包路径
    # 即使容器被销毁,/go/pkg/mod 下的缓存依然能在 BuildKit Daemon 中持久化或通过后端恢复
    RUN --mount=type=cache,target=/go/pkg/mod \
        go mod download
    
    COPY . .
    
    # 优化点 3:不仅缓存依赖库,同时缓存 Go 的编译中间文件(GOCACHE)
    RUN --mount=type=cache,target=/go/pkg/mod \
        --mount=type=cache,target=/root/.cache/go-build \
        go build -ldflags="-w -s" -o /app/server ./cmd/main.go
    
    FROM alpine:3.18
    WORKDIR /app
    COPY --from=builder /app/server /app/server
    CMD ["/app/server"]
    

    生产级流水线配置与指令重构

    在 GitLab CI 中,仅仅改写 Dockerfile 是不够的。我们需要通过 docker buildx 工具链对接远端 Registry 作为缓存载体。以下是具体的 .gitlab-ci.yml 核心片段(基于 GitLab Runner v16.3 + Docker 24.0.5):

    variables:
      DOCKER_DRIVER: overlay2
      DOCKER_BUILDKIT: 1
      # 定义缓存镜像库的地址
      CACHE_IMAGE: $CI_REGISTRY_IMAGE/buildcache
    
    build-image:
      stage: build
      image: docker:24.0.5-cli
      services:
        - docker:24.0.5-dind
      script:
        - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
        # 初始化一个支持多种特性的 buildx 实例
        - docker buildx create --use --name multi-arch-builder --driver docker-container
        - docker buildx build 
            --push 
            --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA 
            --cache-from type=registry,ref=$CACHE_IMAGE:latest 
            --cache-to type=registry,ref=$CACHE_IMAGE:latest,mode=max 
            .
    

    原理解析:

    1. --cache-from type=registry,ref=...:在构建开始时,BuildKit 不会马上拉取完整的镜像 Blob,而是先拉取 Cache 的 JSON 元数据。在 DAG 计算时,只有命中的层级才会被按需下载(Lazy-pulling)。

    2. --cache-to ... mode=max:这里有一个极易踩坑的点。默认的 mode=min 只会缓存最终输出镜像所涉及的层。但我们在多阶段构建(Multi-stage)中,往往 builder 阶段的依赖耗时最长。必须设置为 mode=max,告诉 BuildKit 将所有中间阶段的层也一并推送到远端缓存库。

    常见问题 (FAQ)

    我在推进这套流水线重构时,遇到过几个非常典型的现场问题,这里一并总结:

    Q1:多条流水线并发执行时,--mount=type=cache 会发生写冲突或文件锁死吗? A:会。默认情况下,BuildKit 对于同一挂载点的并发访问策略是 sharing=shared,这意味着多个构建容器可以同时读写该目录。对于 go mod 这种具备并发安全设计的工具没问题,但如果你在使用 npm install 且涉及到旧版 SQLite 绑定等操作,极易引发锁损坏。 解决办法是在挂载指令中显式指定锁机制:--mount=type=cache,target=/root/.npm,sharing=locked。这样当并发流水线调度到同一个 BuildKit 实例时,后续的构建会等待前一个构建释放目录锁。

    Q2:使用 Registry 缓存后,为什么有时候网络拉取缓存的耗时比重新编译还要长? A:这是一个非常经典的 I/O 与 CPU 的博弈问题。go build 在高配 CPU 节点上可能只需要 10 秒,但对应的 Cache 层打包压缩、上传到远端、再通过网络下载解压可能需要 30 秒。 遇到这种情况,建议检查 CI 节点与 Registry 之间的网络是否属于同 AZ 的内网。如果带宽受限,可以在 cache-to 中添加参数关闭压缩:--cache-to type=registry,ref=...,compression=uncompressed。虽然存储空间占用更大,但在千兆/万兆内网下,未经压缩的元数据传输耗时几乎为零。

    Q3:我的基础镜像(Base Image)更新了,如何确保缓存失效而不是继续使用存在安全漏洞的旧依赖? A:BuildKit 的缓存键(Cache Key)是通过指令内容和上下文文件的哈希共同决定的。如果你的 FROM alpine:3.18 发生了底层补丁更新(镜像 SHA256 改变),但 Dockerfile 文本没变,BuildKit 默认可能会复用基于旧镜像生成的 LLB 节点。 为了确保绝对的安全更新,可以在 CI 触发配置中设置定期(如每周一次)传入一个无缓存的构建指令 docker buildx build --no-cache ...,强制刷新一版带有最新底层系统的 $CACHE_IMAGE,为后续的构建提供新的健康基线。

  • 突破数万 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)在特定的极度边界下产生的连锁反应。搞懂了底层的流转逻辑,解决问题不过是顺水推舟。