• Redis 生产环境 P99 飙升:RDB COW 触发内存淘汰与 Cluster Gossip 故障转移雪崩排查实战

    生产环境 Redis Cluster (6.2.7) 突发 P99 延迟飙升至 2000ms,并伴随频繁的主从切换。核心原因是 BGSAVE 触发 Copy-On-Write 导致内存触碰 maxmemory,引发主线程大规模 LRU 淘汰阻塞。主线程卡顿导致 Gossip 协议心跳超时,误判节点下线并触发级联故障转移。解决方式:预留 30% 内存给 COW,开启 lazyfree-lazy-eviction,并调大 cluster-node-timeout

    故障现场与指标断崖式下跌

    近期某集群告警,监控面板上呈现出典型的“雪崩”特征:

    1. QPS 骤降:单节点 QPS 从 80k 瞬间跌至 5k 以下。

    2. P99 剧烈抖动:平时稳定在 2ms 以内的 P99 突增至 2000ms+。

    3. 连接风暴:客户端因超时大量重连,引发短连接风暴。

    立刻登机拉取 Redis 日志,发现大量内存淘汰警告,紧接着是集群节点的 FAIL 状态广播:

    29302:M 10:23:14.123 * 10000 keys evicted, 153MB freed.
    29302:M 10:23:14.891 * 10000 keys evicted, 142MB freed.
    ...
    29302:M 10:23:19.456 * Marking node 3a8b... as failing (quorum reached).
    29302:M 10:23:19.458 # Cluster state changed: fail
    

    查看当时的内存指标和核心状态:

    $ redis-cli -p 6379 info memory | grep -E "used_memory_human|maxmemory_human|latest_fork_usec"
    used_memory_human:24.1G
    maxmemory_human:24.0G
    latest_fork_usec:89450
    

    现象很明确:由于触发了内存淘汰,主线程被长时间占用,导致集群内的 Gossip 节点心跳无响应,最终引发整个集群拓扑结构的重新计算和主从切换。

    为什么一次 BGSAVE 会引发集群雪崩?

    很多人在配置 Redis 时,习惯把 maxmemory 设为物理内存的 90% 甚至更大,认为这样“不浪费”。这在没有高频写入和持久化的场景下勉强能跑,但一旦触发 BGSAVE (RDB 持久化),就是灾难的开始。

    Redis 执行 BGSAVE 时会 fork() 一个子进程。现代操作系统利用 Copy-On-Write (COW) 机制,父子进程初始共享物理内存页。然而,如果此时集群正处于高频写入状态(特别是大 Key 的更新),父进程在修改数据时,操作系统必须为这些被修改的内存页分配新的物理空间。

    排查过程中的现场数据显示,该节点在 BGSAVE 期间产生了高达 4GB 的 COW 内存:

    # cat /proc/$(pidof redis-server)/smaps | grep -i private_dirty | awk '{sum+=$2} END {print sum/1024 " MB"}'
    3952 MB
    

    雪崩的传导链条如下:

    1. 内存触顶:COW 导致 Redis 实际占用内存 + 自身分配的内存超过了 maxmemory(24GB)。

    2. 同步淘汰阻塞:Redis 触发 maxmemory-policy(当时配的是 allkeys-lru)。在 Redis 6.2 中,如果未开启异步淘汰,主线程必须同步寻找并释放内存。大规模的 Key 淘汰(且包含 Hash/Set 大 Key)死死卡住了主线程。

    3. Gossip 协议“假死”:Redis 集群的节点保活依赖 Gossip 协议,而处理 Gossip 消息的 clusterCron() 是在主线程的事件循环中执行的。主线程被 Eviction 阻塞了 5 秒,导致无法回复其他节点的 PING

    4. 脑裂与故障转移:其他节点超过 cluster-node-timeout(当时配的是激进的 5000ms)未收到 PONG,将其标记为 PFAIL,进而升级为 FAIL,触发 Replica 强制上位。

    5. 全量同步加剧雪崩:旧 Master 恢复后变为 Slave,向新 Master 发起 SYNC,再次触发新 Master 的 BGSAVE。死循环形成。

    防御性配置与底层调优实战

    为了彻底根除这种由于持久化抖动引发的集群雪崩,必须从内存预留、异步淘汰和集群容忍度三个维度进行改造。

    1. 严格的内存水位控制 (COW 预留)

    永远不要把 maxmemory 贴着物理内存上限配置。标准做法是预留 30% – 40% 的内存给 COW、主从复制的 repl-backlog 以及客户端缓冲区。

    # 假设实例物理内存 32GB
    maxmemory 20gb
    # 淘汰策略根据业务改为 volatile-lru 或 volatile-ttl,避免全盘扫描
    maxmemory-policy volatile-lru
    

    2. 开启 Lazyfree 机制

    Redis 4.0 引入了 lazyfree,6.0+ 版本进一步完善。针对内存淘汰引发的阻塞,必须开启惰性删除,将释放内存的动作交给后台线程 (bio 线程池) 执行,保命主线程。

    # 开启惰性内存淘汰
    lazyfree-lazy-eviction yes
    # 开启惰性键过期
    lazyfree-lazy-expire yes
    # 隐式 DEL 转化为 UNLINK
    lazyfree-lazy-user-del yes
    

    3. 调校 Cluster Gossip 参数

    cluster-node-timeout 决定了集群对网络抖动和主线程阻塞的容忍度。千万别为了追求极端的“故障恢复速度”将其设为 3-5 秒。主线程偶然卡顿是常态,误判导致的 Failover 成本极高。

    # 推荐值为 15000 (15秒),足够覆盖绝大多数 RDB fork 和淘汰耗时
    cluster-node-timeout 15000
    

    配合调大复制积压缓冲区,防止主从切换或短连后触发全量重传:

    repl-backlog-size 512mb
    

    4. 彻底接管内核 THP (Transparent Huge Pages)

    在排查中发现,操作系统的 THP 是开启的。THP 会将默认的 4KB 内存页放大为 2MB。在 COW 发生时,即使 Redis 只修改了 10 字节的数据,内核也必须拷贝完整的 2MB 内存页。这直接导致了 BGSAVE 期间内存飙升速度放大了数百倍。

    必须在所有 Redis 宿主机上硬性关闭 THP:

    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    echo never > /sys/kernel/mm/transparent_hugepage/defrag
    # 固化到 rc.local 或 grub 中
    

    常见问题

    Q1:除了 BGSAVE,AOF 重写 (BGREWRITEAOF) 会引发完全一样的问题吗? 会。BGREWRITEAOF 同样依赖 fork() 子进程进行机制。只要有 Fork 操作,在海量写入时就会产生大量 COW 内存。防御策略完全一致。

    Q2:如何快速确认集群是否正因为主线程卡顿而处于 Gossip 瘫痪边缘? 观察 info stats 中的 latest_fork_usec 耗时,以及慢查询日志。如果 latest_fork_usec 超过 100ms,说明 fork 本身就极其耗时(通常由于系统页表太大引起)。同时可以监控 redis_cluster_messages_ping_sentpong_received 的差值斜率。

    Q3:开启了 lazyfree-lazy-eviction,内存就一定不会爆吗? 并非绝对。Lazyfree 只是把内存释放动作交给了后台线程。如果业务的写入速度远大于后台线程释放内存的速度,Redis 的总内存依然会持续上涨,最终触发操作系统的 OOM Killer 直接干掉 Redis 进程。因此,合理的 maxmemory 预留和限流依然是底线。

    Q4:Redis 7.0 的 Multi-part AOF 能解决这个问题吗? Redis 7.0 的 Multi-part AOF 优化了 AOF 重写期间的增量数据追加机制,大幅降低了重写带来的内存开销和 CPU 负担。但对于纯 RDB 的 BGSAVE COW 物理内存翻倍问题,底层机制并没有变,依然受限于内核的页表和 COW 行为。

  • Jenkins 生产环境雪崩排查实战:Groovy CPS 陷阱引发的 Metaspace 溢出与 K8S Agent 调度风暴

    结论先行:Jenkins Pipeline 复杂的 Groovy 闭包会导致 CPS(Continuation Passing Style)频繁进行 AST 转换,耗尽 Master Metaspace 触发 OOM。同时,K8S 插件在 Master 假死断连时产生的 Agent 创建风暴,会瞬间击穿 K8S API Server。本文通过重构 Shared Library 剥离 CPS 逻辑,并引入 JCasC 固化 K8S 动态 Agent 限流配置,彻底解决百级别并发构建下的系统雪崩问题。

    1. 故障现场:Master 假死与 K8S API Server 告警

    排查过程中接到告警,CI/CD 集群 P99 构建排队时间从平时的 5 秒飙升至 30 分钟以上。登录控制台发现 Jenkins UI 响应极其缓慢,部分页面直接 502。 联动监控大盘,发现了两个极度异常的指标:

    1. Jenkins Master JVMMetaspace 使用率在两小时内呈阶梯式上涨,直至 100% 触发 Full GC,单次 GC 停顿(STW)超过 12 秒。

    2. K8S 控制平面:API Server QPS 突增,尤其是针对 namespaces/jenkins/podsPOSTDELETE 请求,导致 API Server CPU 飙升,etcd 出现选主告警。

    进入 Jenkins Master 容器抓取现场:

    # 查看 JVM 内存状态
    jstat -gcutil $(pgrep java) 1000 5
      S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT    GCT   
      0.00 100.00  32.14  89.45  99.98  98.71  23412  145.312  425  312.411  457.723
    
    # 生成 Heap Dump 和 Thread Dump(保留案发现场)
    jcmd $(pgrep java) GC.heap_dump /tmp/jenkins_oom.hprof
    jcmd $(pgrep java) Thread.print > /tmp/jenkins_threads.txt
    

    日志中大量抛出 java.lang.OutOfMemoryError: Metaspace,同时伴随着 Kubernetes client: failed to create pod ... Read timeout。很明显,JVM 已经处于频繁 GC 的濒死状态。

    2. 为什么 Groovy CPS 机制会吃光 Master 的 Metaspace?

    把 Heap Dump 拖到 MAT(Memory Analyzer Tool)里分析,发现 ClassLoader 数量异常庞大,且绝大多数是由 com.cloudbees.groovy.cps.NonCPS 和 Pipeline 脚本动态生成的类。

    Jenkins Pipeline 的底层运行机制基于 CPS(Continuation Passing Style)。为了让 Pipeline 在 Jenkins Master 重启后还能从断点恢复继续执行,Jenkins 必须能够将当前执行的堆栈状态序列化到磁盘。 这就导致了一个致命陷阱:你在 Jenkinsfile 里写的每一行看似普通的 Groovy 代码,都会被 CPS 转换引擎解析重写为可以被序列化的 AST(抽象语法树)对象。

    在某次业务线提交的 Shared Library 中,发现了一段类似这样的代码:

    // 反面教材:在 CPS 方法中进行大量不可序列化对象的循环操作
    def processComplexJson(String jsonStr) {
        def jsonSlurper = new groovy.json.JsonSlurperClassic()
        def data = jsonSlurper.parseText(jsonStr)
        // 这里的 data 树结构非常复杂,且在循环中调用了 pipeline step
        data.items.each { item ->
            if (item.name.matches(".*-service-.*")) { // 正则 Matcher 不可序列化
                echo "Processing ${item.name}"       // 调用了 CPS 步骤
                // 复杂的处理逻辑...
            }
        }
    }
    

    原理解析

    1. JsonSlurper 解析出的复杂对象模型、java.util.regex.Matcher 等对象是不可序列化的。

    2. 当闭包 .each {} 内部混合调用了 Pipeline 原生 step(如 echo, sh)时,Jenkins 会尝试保存整个上下文。

    3. 每次执行构建,CPS 引擎为了处理这些无法直接解析的代码,会动态生成大量的匿名类加载到 Metaspace 中。由于这些类持有 Pipeline 的执行上下文(强引用),无法被 GC 快速回收。

    4. 并发一高,Metaspace 迅速被打爆。Master 发生长达十几秒的 STW。

    雪崩链条: Master STW -> JNLP Agent (运行在 K8S Pod 中) 的心跳超时 -> Jenkins 认为 Agent 已死,触发重连或重新分配 -> K8S Plugin 疯狂向 API Server 发起创建 Pod 请求 -> API Server 被打满 -> 旧 Agent 还在跑,新 Pod 不断创建 -> K8S 节点资源耗尽。

    3. 核心修复:Shared Library 与 K8S Agent 调优实践

    针对上述问题,我们从代码重构和配置加固两方面进行落地。当前环境为 Jenkins 2.414.3 LTS,Kubernetes Plugin 4136.v464303c7379d。

    3.1 剥离 CPS:使用 @NonCPS 与纯粹的 Java 类

    对于 Shared Library 中的数据处理逻辑,必须将纯粹的代码计算Pipeline 执行步骤隔离开。使用 @NonCPS 注解,让 Jenkins 跳过 AST 转换,按标准 JVM 字节码执行。

    import com.cloudbees.groovy.cps.NonCPS
    import groovy.json.JsonSlurperClassic
    
    // 1. 将耗时的、涉及不可序列化对象的纯计算逻辑标记为 @NonCPS
    @NonCPS
    List<String> getServicesToProcess(String jsonStr) {
        def services = []
        def jsonSlurper = new JsonSlurperClassic()
        def data = jsonSlurper.parseText(jsonStr)
    
        for (item in data.items) {
            if (item.name.matches(".*-service-.*")) {
                services.add(item.name)
            }
        }
        return services // 只返回可序列化的基本类型或标准集合
    }
    
    // 2. 在 Pipeline 步骤中通过标准 for 循环调用(不要用 .each 闭包混合 pipeline step)
    def call(String jsonStr) {
        List<String> targetServices = getServicesToProcess(jsonStr)
        for (int i = 0; i < targetServices.size(); i++) {
            def svc = targetServices[i]
            echo "Processing ${svc}"
            // 执行实际的 pipeline steps...
        }
    }
    

    3.2 阻断雪崩:JCasC 固化 K8S Agent 限流配置

    为了防止 Jenkins 在网络抖动或自身 GC 时向 K8S 发起 API DDOS 攻击,必须严格配置 K8S Plugin 的容量上限,并改用 WebSocket 代替 TCP JNLP 端口直连。 我们通过 JCasC (Jenkins Configuration as Code) 强制注入以下安全配置:

    jenkins:
      clouds:
        - kubernetes:
            name: "kubernetes"
            serverUrl: "https://kubernetes.default"
            namespace: "jenkins"
            jenkinsUrl: "http://jenkins-master.jenkins.svc.cluster.local:8080"
            # 【核心防御】开启 WebSocket,复用 HTTP 端口,避免 K8S LoadBalancer 断流导致心跳丢失
            webSocket: true 
            # 【核心防御】限制全局并发 Pod 数,保护 K8S API Server 和节点资源
            containerCapStr: "200"
            # 限制 API 请求超时时间
            readTimeout: 15
            connectTimeout: 5
            maxRequestsPerHostStr: "32"
            templates:
              - name: "base-maven"
                namespace: "jenkins"
                label: "maven-agent"
                # 限制单种模板的最大并发数
                instanceCapStr: "50" 
                containers:
                  - name: "jnlp"
                    image: "jenkins/inbound-agent:3148.v532a_7e715ee3-1"
                    workingDir: "/home/jenkins/agent"
                    resourceRequestCpu: "500m"
                    resourceLimitCpu: "2"
                    resourceRequestMemory: "1Gi"
                    resourceLimitMemory: "2Gi"
    

    同时,调整 Jenkins Master 启动参数,增大 Metaspace 并限制其无序扩张: JAVA_OPTS="-Xms8G -Xmx8G -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=1G -XX:+UseG1GC"

    应用上述修复后,P99 排队时间回落至 3 秒,Master 内存泄漏彻底消除,API Server 平稳运行。

    4. 常见问题 (FAQ)

    Q1: K8S 动态 Agent 频繁出现 JNLP connection timeout 或 offline,是什么原因? 通常有两个原因:一是中间的 Ingress/LoadBalancer 对长连接(默认 50000 TCP 端口)有 idle timeout 清理机制,导致静默断连;二是 Master 的 CPU 或内存被跑满,无法及时响应心跳。 建议解决: 启用 Kubernetes 插件的 webSocket: true 选项,让 Agent 通过 标准的 HTTP 8080 端口使用 WebSocket 与 Master 通讯,这样不仅穿透性好,还能复用 HTTP 的负载均衡和 KeepAlive 策略。

    Q2: 在动态 K8S Agent 中构建 Docker 镜像,推荐 DinD (Docker in Docker) 还是 Kaniko? 坚决抵制在 K8S 生产环境中大规模使用 DinD。DinD 需要开启 Pod 的 privileged: true 特权模式,这在任何有底线的运维体系中都是不被允许的,极易引发容器逃逸。 建议解决: 使用 Google 提供的 Kaniko。它完全在用户态执行,无需特权,直接通过解析 Dockerfile 在容器内层层构建镜像文件系统,最后 push 到 Harbor。

    Q3: 如何安全地在 JCasC YAML 中管理集群密码和 Secret? 禁止在 JCasC 的 yaml 文件里明文写 Token! 建议解决: 利用 Jenkins 的 Secret 机制结合 K8S 环境变量。在 JCasC 中使用 ${MY_SECRET} 占位符,然后在 Jenkins Master 的 Deployment 中通过 K8S Secret 挂载到环境变量。启动时 JCasC 会自动将其替换,实现配置与凭据解耦。

  • 深入 API 网关限流与熔断:从 Token Bucket 突发击穿看 Envoy 熔断器状态抖动排查实战

    结论先行:网关层单一使用 Token Bucket 限流,极易因 burst(突发)参数配置过大导致下游在流量毛刺下被击穿。某次排查发现,瞬间高并发耗尽令牌桶后直接透传,打挂了后端服务,进而引发 Envoy v1.27.0 熔断器(Outlier Detection)频繁弹射健康节点,触发 Panic 路由机制导致全局雪崩。核心解法:引入 Leaky Bucket 平滑流量,并精确调优 Envoy 驱逐窗口与熔断阈值。

    案发现场:P99 飙升与诡异的 503 UO 报错

    排查某核心交易链路问题时,监控大盘显示网关入口 QPS 平稳维持在 3000 左右,但 P99 延迟却在某些瞬间飙升至 4000ms 以上。紧接着,下游服务开始大面积报警,CPU 使用率出现锯齿状波动,Load Average 瞬间飙升至宿主机核心数的 3 倍。

    抓取入口网关与 Sidecar 的日志,发现海量的 503 报错。提取关键的 Envoy (v1.27.0) Access Log:

    {
      "response_code": "503",
      "response_flags": "UO",
      "upstream_cluster": "outbound|8080||order-svc",
      "duration": "2",
      "upstream_service_time": null
    }
    

    注意这里的 Response Flag UO (Upstream Overflow)。在 Envoy 的语义中,UO 意味着请求不仅没有到达后端应用代码,甚至连连接池都没建起来,直接被 Envoy 的 Circuit Breaker 拦截了。但进一步看,日志中还夹杂着大量 UC (Upstream Connection Termination) 和 503 URX (Upstream Retry Limit Exceeded)。

    这就很有意思了:流量大盘是平稳的,网关层配置了 5000 QPS 的全局限流,按理说后端集群(20个 Pod,单 Pod 容量 300 QPS)完全吃得消,为什么会被打出 UOUC

    为什么 Token Bucket 算法无法应对瞬间毛刺流量?

    排查网关层的分布式限流实现,发现业务研发基于 Redis + Lua 实现了一个标准的 Token Bucket(令牌桶)算法。核心 Lua 脚本片段如下:

    -- KEYS[1]: rate_limit_key
    -- ARGV[1]: capacity (桶容量)
    -- ARGV[2]: rate (每秒生成令牌数)
    -- ARGV[3]: current_timestamp (当前时间戳)
    local capacity = tonumber(ARGV[1])
    local rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
    
    local last_time = tonumber(redis.call('hget', KEYS[1], 'last_time') or '0')
    local current_tokens = tonumber(redis.call('hget', KEYS[1], 'tokens') or capacity)
    
    -- 计算这期间生成的令牌
    local delta_tokens = math.floor((now - last_time) * rate)
    local tokens = math.min(capacity, current_tokens + delta_tokens)
    
    if tokens > 0 then
        redis.call('hset', KEYS[1], 'tokens', tokens - 1)
        redis.call('hset', KEYS[1], 'last_time', now)
        return 1 -- 放行
    else
        return 0 -- 限流
    end
    

    当时的配置是:capacity = 2000rate = 1000。 这就是典型的防御盲区。Token Bucket 的核心特性是允许突发流量(Burst)。如果系统在过去 2 秒内极其空闲,桶里积攒了 2000 个令牌。此时一个瞬间的流量毛刺(Microburst)打过来,这 2000 个请求会在 10 毫秒 内全部被网关放行,直接砸向后端。

    对于后端来说,这不是 1000 QPS,这是瞬时 2000 / 0.01s = 200,000 QPS 的冲击。 微服务的连接池瞬间被打满,TCP Accept Queue 溢出,导致部分请求超时(产生 504/503)。

    如果是 Leaky Bucket(漏桶) 算法,由于其恒定速率流出的特性(类似 Nginx 的 limit_req 且不带 nodelay),这 2000 个请求会被强制在队列中排队,以绝对平滑的 1000 QPS 速率向后端转发,起到真正的削峰填谷作用。

    Envoy 熔断器(Outlier Detection)的雪崩效应

    流量毛刺击穿网关后,真正的灾难在 Envoy 代理层爆发。微服务由于瞬时过载,部分 Pod 开始返回 5xx 错误或连接超时。Envoy 的 Outlier Detection(异常点检测)机制被触发。

    当时配置的 DestinationRule 如下:

    apiVersion: networking.istio.io/v1alpha3
    kind: DestinationRule
    metadata:
      name: order-svc-dr
    spec:
      host: order-svc
      trafficPolicy:
        connectionPool:
          http:
            http1MaxPendingRequests: 1024
            maxRequestsPerConnection: 100
        outlierDetection:
          consecutive5xxErrors: 3
          interval: 10s
          baseEjectionTime: 30s
          maxEjectionPercent: 50
    

    当突发流量导致某几个 Pod 连续返回 3 个 5xx 时,Envoy 毫不犹豫地将它们拉黑(Eject)30 秒。 随着被拉黑的 Pod 越来越多(很快达到了 maxEjectionPercent: 50% 的上限),剩余 50% 的 Pod 必须承受全部流量,瞬间雪崩。

    更致命的是,当 Envoy 发现健康后端节点比例低于 Panic Threshold(默认 50%)时,会触发恐慌路由(Panic Routing)。Envoy 会认为:“既然健康检查机制可能出错了,那我就无视驱逐状态,把流量均匀分发给所有节点”。 于是,处于假死状态的 Pod 再次迎来海量流量,彻底 OOM,Envoy 连接池爆满,最终向上游网关抛出开篇看到的 503 UO503 UC

    体系化修复与架构加固

    为了彻底根治这种“毛刺流量 -> 网关击穿 -> 熔断驱逐 -> 恐慌路由 -> 全局雪崩”的连环雷,我们从网关层和 Mesh 层做了以下防御性调整:

    1. 网关层:平滑限流(Leaky Bucket 变体)替代纯令牌桶

    废弃了原有的自研 Lua 纯令牌桶,在 Nginx/OpenResty 入口层启用基于共享内存的严格限流。即使保留一定的并发度,也必须通过 delay 参数强制平滑:

    # 定义 1000r/s 的速率,桶容量为 500
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=1000r/s;
    
    # burst=500 允许一定突发,但 delay=200 表示超过 200 的突发流量将被严格按速率排队延迟,拒绝瞬间砸穿后端
    limit_req zone=api_limit burst=500 delay=200;
    

    2. Mesh 层:压制重试风暴与调优熔断参数

    调整 Envoy 的 Outlier Detection 与连接池控制,防止误杀:

    1. 放大连续错误阈值:将 consecutive5xxErrors 从 3 调整为 15。在高并发微服务中,3 个连续 5xx 极易被网络抖动误触发。

    2. 细化驱逐条件:启用 splitExternalLocalOriginErrors,明确区分应用自身抛出的 5xx(如 500 业务报错)和本地网络/Envoy 产生的 5xx(如 503 连接超时)。只对真正的网络连接异常进行物理节点驱逐。

    3. 调整恐慌阈值:在 Envoy Cluster 配置中,通过 EnvoyFilter 将 Panic Threshold 从 50% 降低至 20%(如果 80% 节点都挂了,再开启无差别盲发请求)。

    # EnvoyFilter 局部核心配置片段
    name: envoy.filters.network.http_connection_manager
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
      common_http_protocol_options:
        idle_timeout: 60s
      route_config:
        virtual_hosts:
        - name: order_route
          routes:
          - match: { prefix: "/" }
            route:
              cluster: outbound|8080||order-svc
              max_stream_duration:
                max_stream_duration: 3s # 强制设置全局绝对超时
    

    常见问题 (FAQ)

    Q1:Token Bucket 和 Leaky Bucket 在真实网关选型时究竟怎么选? 面对对外网关(防刷、防爬),首选 Leaky Bucket(或者带强排队机制的令牌桶变体),这能把刺猬一样的流量彻底削平。面对内部微服务间的限流(RPC 调用),由于内部流量更可控且对 RT 敏感,通常使用 Token Bucket 以容忍短时间的并发调用,但必须严格限制 burst 上限,burst 绝对不能超过目标服务连接池容量的 1/3。

    Q2:分布式限流用 Redis + Lua 有什么性能隐患? 最大的隐患是单点网络瓶颈和 Redis CPU 阻塞。Lua 脚本在 Redis 中是单线程原子执行的,如果网关单机并发极高,所有请求都在等待 Redis 响应,会导致网关 Worker 进程被严重阻塞。对于 10万+ QPS 的限流,千万别用纯 Redis 强一致限流,必须退化为本地内存限流为主,Redis 异步同步配额为辅的架构(类似 Sentinel 的集群限流机制)。

    Q3:Envoy 的 Circuit Breaker 和 Outlier Detection 有什么本质区别? 这是个极度容易混淆的概念。Envoy 的 Circuit Breaker 本质上是“连接池限制”,比如 max_requests: 1000,超出了直接本地决断拦截返回 503(抛出 UO)。它防御的是“我(Client)发出的并发太多了”。 而 Outlier Detection 才是传统意义上的熔断(类似 Netflix Hystrix),它通过统计后端节点返回 5xx 的频率,将坏节点剔除出负载均衡池。它防御的是“他(Server)坏了,我不要再把请求发给他”。排查时必须严格区分这两种动作产生的不同报错标识。

  • 异地多活网关雪崩实战:5 秒配置同步延迟引发的跨城路由死环与 Envoy 线程耗尽

    近期排查了一起极其典型的“异地多活”架构翻车事故。某业务在做全链路压测与流量切流演练时,双中心网关集群在 10 秒内接连雪崩,P99 延迟从 15ms 直接飙升至网关超时上限(10s),最终导致两个可用区同时瘫痪。

    结论先行:这不是什么深奥的底层 Bug,而是一个极其低级的架构设计缺陷。控制面在下发“单元化路由规则(UID -> AZ)”时存在跨城同步延迟。在这短短 5 秒的数据不一致窗口期内,AZ-A 认为请求该去 AZ-B,而 AZ-B 还在使用旧规则认为请求该回 AZ-A。网关层完全没有做防环处理(Loop Detection),导致请求在两地专线间无限次 Ping-Pong 转发,瞬间打爆了 Envoy 的连接池和跨城专线带宽。

    伪多活架构的遮羞布,就这样被区区 5 秒的延迟扯得粉碎。

    故障现场:从 P99 飙升到全局 502

    排查过程中,监控面板的异动非常诡异:

    1. 外部流量未突增:入口 QPS 正常,没有遭受 DDoS 攻击。

    2. 专线带宽被打满:两地机房之间的 10G 专线监控显示,出入带宽在几秒内呈直线上升至 100%。

    3. 网关层资源枯竭:Envoy 节点的 CPU Load Average 飙升至 80+,envoy_cluster_upstream_rq_pending_overflow 指标疯狂报错。

    4. 后端业务毫无波澜:底层的微服务和 DB 监控一片祥和,甚至 QPS 还下降了——因为流量全死在网关了。

    直接拉取 Envoy 的 Access Log,发现令人窒息的现象,同一个 x-request-id 在同一秒内出现了数百次日志打印:

    {"time": "...", "x-request-id": "a7b2c9-...", "upstream_cluster": "outbound|80||gateway-az-b", "response_code": "504"}
    {"time": "...", "x-request-id": "a7b2c9-...", "upstream_cluster": "outbound|80||gateway-az-b", "response_code": "504"}
    

    而在 AZ-B 的网关日志里,同样的 Request ID 正在被疯狂转发回 AZ-A。

    根因拆解:分布式的“阿喀琉斯之踵”

    该业务号称实现了“异地双活”,其实质是经典的单元化架构(Cell-based Architecture)。路由规则按用户 UID 取模或查表分配:UID_Range_1 在 AZ-A 闭环,UID_Range_2 在 AZ-B 闭环。如果用户访问错了机房,入口网关会负责将其 Proxy 到正确的机房。

    演练时,运维执行了 UID 搬迁操作:将某一批 UID 从 AZ-A 迁移至 AZ-B。 正常的迁移状态机应该是:禁止写入 -> 数据同步 -> 变更路由规则 -> 开放写入

    问题出在路由规则下发环节。全局控制面(Global Control Plane)将新的路由表通过 xDS 下发给两地的 Envoy 集群。 由于跨城网络抖动和底层配置中心的同步机制,AZ-A 的网关瞬间收到了新规则,而 AZ-B 的网关存在约 5 秒的同步延迟。

    这 5 秒内,逻辑变成了这样:

    1. 用户流量进入 AZ-A。

    2. AZ-A 网关查最新路由表:“该 UID 已迁至 AZ-B”,于是将请求通过专线转发给 AZ-B 的网关。

    3. 请求抵达 AZ-B。AZ-B 网关查旧路由表:“该 UID 属于 AZ-A”,于是将请求再转发回 AZ-A 的网关。

    4. AZ-A 再次收到请求,再次转发给 AZ-B……

    一次简单的 HTTP 请求,在没有 Max-Forwards 限制的情况下,变成了跨城专线上的死循环。几千个这样的请求,瞬间裂变成数百万次的内部 RPC 调用,直接击穿 Envoy 的 max_connectionsmax_pending_requests 限制,导致网关假死,进而引发全量业务 502。

    为什么犯错不可原谅?

    真正的多活,不仅是画在 PPT 上的两套对等集群,而是骨子里对分布式系统“弱一致性”的敬畏。 CAP 定理早就告诉我们,跨越 WAN 网络的节点,绝对不可能实现原子的状态变更。只要存在时间差,就一定会出现路由视角的不一致。

    在架构设计时,不假设“配置下发绝对同时生效”,而是假设“一定会出现路由环路并进行兜底拦截”,这叫防御性编程。花了几百万拉跨城专线,却连一个最基础的 Hop Limit 都不加,这种架构翻车纯属人祸。

    止血与防御性修复

    当时在现场的紧急止血操作非常粗暴:直接切断了 AZ-A 到 AZ-B 的专线路由转发(牺牲跨城纠错能力,强行阻断环路),网关雪崩立刻停止。随后紧急排查控制面同步组件并修复延迟。

    彻底的修复方案(防环机制落地):

    1. 网关层强制拦截:引入 Max-Forwards 机制 无论使用 Nginx 还是 Envoy,在进行跨机房流量 Proxy 时,必须注入并校验自定义 Header(如 X-Multi-Active-Hop)。在 Envoy 中,可以通过原生机制或极简的 Lua Filter 实现:

    # Envoy Lua Filter 防环片段
    name: envoy.filters.http.lua
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
      inline_code: |
        function envoy_on_request(request_handle)
          local hop_count = tonumber(request_handle:headers():get("x-multi-active-hop") or "0")
          if hop_count >= 2 then
            request_handle:respond({[":status"] = "508"}, "Loop Detected in Multi-Active Routing")
            return
          end
          request_handle:headers():replace("x-multi-active-hop", tostring(hop_count + 1))
        end
    

    2. 路由变更状态机:平滑过渡 不要做“一刀切”的路由变更。UID 迁移的路由切换必须存在中间态(Transit State)。 当 UID 正在迁移时,路由状态设为 MIGRATING,此时新旧机房的网关对该 UID 的请求应统一 Hold 住(挂起等待)或降级处理,直到两端均确认收到最新配置(ACK)后,再将状态切为 COMMITTED 放行。

    3. 隔离爆炸半径 为跨城 Proxy 流量配置独立的 Cluster 和 Connection Pool,绝对不能与处理本地域内流量的线程池混用。这样即使专线打满或跨城目标假死,本地域内的“正确流量”依然不受影响。

    同类问题速查(排查清单)

    1. 跨城/跨 AZ 路由环路检测:检查所有跨域转发是否携带并校验了 X-Forwarded-ForMax-Forwards 或自定义跳数 Header,超过阈值(通常为1或2)必须立即丢弃并返回 508 (Loop Detected)。

    2. 配置中心弱一致性容灾:检查下发控制面(etcd / Consul / 自研 xDS 服务)在脑裂或跨城延迟 > 10s 的情况下,Data Plane 是否能优雅降级,还是会触发雪崩逻辑。

    3. 隔离与限流(Bulkhead):检查网关对于“跨城纠错流量”是否配置了独立的连接池(Connection Pool)和并发数限制(Circuit Breaker),防止小比例的纠错流量耗尽全局 Worker 资源。

    4. UID 状态机原子性:在单元化架构中,检查 UID 归属地切换是否有明确的“过渡态”,严防因配置生效时间差导致的“两地都不认”或“两地互相抛”的脏读问题。

  • Kafka 集群 P99 飙升至 3000ms:强制开启 SSL 击穿零拷贝引发的 ISR 频繁收缩与雪崩

    近期处理了一起非常典型的 Kafka 核心集群生产事故:业务反馈生产耗时 P99 从平时的 10ms 突增至 3000ms 以上,消费端出现大面积堆积。排查发现,集群的 Under Replicated Partitions (URP) 指标狂飙,Controller 频繁进行 Leader 选举。

    最终结论直接抛出:这是一起因“安全合规”要求,盲目在 Broker 间以及所有 Client 强制开启 SSL/TLS 加密,导致 Linux底层的 sendfile(零拷贝)机制彻底失效,叠加某下游业务重放海量历史数据引发 PageCache 穿透,最终打满 CPU 且耗尽内存带宽的惨案。Follower 无法在 replica.lag.time.max.ms 内完成数据同步,导致 ISR 疯狂收缩与扩散,进而引发集群级雪崩。

    安全合规当然要做,但在没有经过基准压测的情况下,用一纸行政命令违背底层的 I/O 物理规律,除了给系统带来毁灭性打击外毫无意义。今天把底层的调用链剖开,看看零拷贝是怎么被干碎的。

    现场还原:被击穿的 I/O 与失控的 ISR

    接到告警时,监控面板上的数据已经可以用“惨烈”来形容:

    1. CPU 指标异常:平时 usr 利用率不到 15% 的 Broker 节点,usr 飙升至 85%,sys 飙到 15%,Context Switches(上下文切换)激增。

    2. URP 指标报警kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions 从 0 飙升到数百。

    3. Broker 日志刷屏:大量的 ISR 踢出与拉入日志,Zookeeper 会话出现超时。

    [202X-XX-XX 14:12:05,123] INFO [Partition topic-user-event-4 broker=1] Shrinking ISR from 1,2,3 to 1. (kafka.cluster.Partition)
    [202X-XX-XX 14:12:18,456] INFO [Partition topic-user-event-4 broker=1] Expanding ISR from 1 to 1,2. (kafka.cluster.Partition)
    

    更致命的是,为了保证数据不丢失,该集群配置了强一致性参数 acks=all 以及 min.insync.replicas=2。当 ISR 缩减到只剩 Leader(1 个副本)时,Producer 瞬间收到了海量的 NotEnoughReplicasException 报错,整个业务写入链路直接被掐断。

    底层原理解析:当 SSL 遇到 Kafka

    为什么开个 SSL 能把集群干趴下?这要从 Kafka 引以为傲的高吞吐内核设计——零拷贝(Zero-Copy) 说起。

    1. 正常状态下的数据流转(sendfile)

    在未开启 SSL(PLAINTEXT)时,Consumer(包括作为特殊 Consumer 的 Follower 副本)向 Broker 发起 Fetch 请求,Broker 直接利用 Linux 操作系统的 sendfile 系统调用。

    数据流转路径:磁盘 -> DMA Copy -> OS PageCache -> DMA Copy -> 网卡 Buffer。 整个过程没有任何数据被拷贝到用户态空间(User Space),CPU 的参与度极低,只负责下发指令和管理文件描述符。

    可以通过 strace 命令清晰地看到高频的 sendfile 调用:

    # 跟踪 kafka 进程的系统调用统计
    strace -f -p $(pgrep -f kafka) -e trace=sendfile,read,write -c
    
    % time     seconds  usecs/call     calls    errors syscall
    ------ ----------- ----------- --------- --------- ----------------
     98.15    4.521012          52     86942           sendfile
      1.02    0.047101          10      4710           write
      0.83    0.038102           9      4233           read
    

    2. 开启 SSL 后的数据流转(零拷贝失效)

    SSL/TLS 属于应用层的加密协议。要对数据进行加密,内核态的 PageCache 就无能为力了,数据必须被读取到用户态的 JVM 内存中进行加密运算。

    此时 sendfile 退化为 read + write 的组合。 数据流转路径变成了:磁盘 -> DMA Copy -> OS PageCache -> CPU Copy -> User Space (JVM 堆外/堆内) -> CPU 执行加密算法 -> CPU Copy -> Socket Buffer -> DMA Copy -> 网卡 Buffer

    上下文切换从 2 次增加到 4 次,数据拷贝次数增加,最要命的是 CPU 变成了密集型加密工人。结合当时的 strace 抓包,sendfile 的调用次数直接归零,取而代之的是暴涨的 readwrite

    3. 压死骆驼的最后一根稻草:历史数据回溯

    如果仅仅是开启 SSL,在常态流量下 CPU 也许还能扛住。但恰巧排查过程中发现,某大数据团队上线了一个新任务,从头消费(auto.offset.reset=earliest)一个高达数 TB 的核心 Topic。

    这种冷读行为导致了严重的 PageCache 污染与穿透。 Broker 不得不从物理磁盘读取冷数据,拉入 PageCache,再 Copy 到用户态进行 SSL 加密。磁盘 I/O 等待(iowait)、CPU 资源耗尽、内存带宽打满三管齐下。Broker 的网络线程池(num.network.threads)被这些沉重的处理逻辑长时间阻塞。

    故障传播链:ISR 机制引发的血案

    底层 I/O 阻塞后,Kafka 内核的分布式状态机开始崩溃,形成经典的雪崩链:

    1. Follower 同步延迟:Follower 向 Leader 发送的 FetchRequest 被积压在 Leader 的网络请求队列中。

    2. 触发 ISR 剔除:Leader 发现 Follower 超过 replica.lag.time.max.ms(默认 30000ms)没有成功拉取数据,认为 Follower 挂了,将其移出 ISR 列表。

    3. Zookeeper 冲击:Leader 将新的 ISR 状态写入 Zookeeper,频繁的元数据更新让 Zookeeper 的负载飙升。

    4. 业务断流:由于 min.insync.replicas=2,ISR 只剩 1 时,Leader 拒绝接收新的 Produce 请求。

    5. Leader 选举风暴:极端情况下,Broker 自身的 GC 停顿或 CPU 饥饿导致与 ZK 的 Session Timeout,Controller 认为 Broker 宕机,开始进行大规模的 Leader 切换,整个集群进入瘫痪状态。

    破局与防御性架构落地

    解决这个问题,不能头痛医头去改大 replica.lag.time.max.ms(这只会掩盖问题),必须从架构和配置层面进行隔离。

    1. 剥离内部 SSL,回归零拷贝 坚决摒弃“一刀切”的加密策略。将 Kafka 部署在安全的 VPC 内,配置多 Listener。Broker 间复制(内部流量)绝对禁止使用 SSL,保障 Follower 同步时的零拷贝效率。外部不可信网络接入时才走 SSL Listener。

    # 监听器隔离:内网明文,外网加密
    listeners=INTERNAL://0.0.0.0:9092,EXTERNAL_SSL://0.0.0.0:9093
    # 强制规定内部 Broker 通信必须走明文
    security.inter.broker.protocol=PLAINTEXT
    listener.security.protocol.map=INTERNAL:PLAINTEXT,EXTERNAL_SSL:SSL
    

    2. 物理资源隔离与 Quota 限流 针对重放历史数据的“野蛮”消费者,必须在 Kafka 层面实施配额(Quota)限制,防止单个 Consumer 耗尽 Broker 的网络带宽和 CPU。

    # 限制特定 Client-ID 的拉取速率 (例如限制为 50MB/s)
    bin/kafka-configs.sh --bootstrap-server localhost:9092 \
      --alter --add-config 'consumer_byte_rate=52428800' \
      --entity-type clients --entity-name bad_consumer_client
    

    3. 内核参数调优防御 为了缓解偶尔的网络抖动导致的 ISR 频繁变动,适当微调相关参数,但不要偏离合理区间:

    • replica.lag.time.max.ms:结合网络 P99 延迟,可适当从 30s 调至 45s-60s。

    • num.network.threadsnum.io.threads:在开启不可避免的 SSL 节点上,根据 CPU 核数增加处理线程,避免请求队列阻塞。

    排查清单:Kafka 性能骤降同类问题速查

    如果遇到类似 P99 飙升、URP 报警的问题,直接按以下清单排查:

    1. 查零拷贝状态:执行 strace -p -e trace=sendfile,read,write -c,如果 sendfile 占比极低且 read/write 极高,立刻检查是否有 SSL 开启或某些拦截器组件强制将数据读入了 User Space。

    2. 查 PageCache 命中率:通过 iostat -xdm 1 观察磁盘的 rMB/s。如果读磁盘的吞吐量极高,说明发生了冷读穿透,立刻通过 kafka-consumer-groups.sh 定位是哪个 Group 在重放历史数据。

    3. 查网络/IO线程阻塞:查看 JMX 指标 kafka.network:type=RequestMetrics,name=RequestQueueTimeMs。如果队列等待时间过长,说明 Broker 处理线程池已经打满。

    4. 查 ISR 震荡日志:在 server.loggrep "Shrinking ISR"。如果伴随频繁变动,且写入端出现 NotEnoughReplicasException,先检查集群级别的网络连通性及 Broker 负载,切勿盲目通过脚本强行 Reassign Partitions,那只会加重 IO 负担。

  • RocketMQ 生产环境 P99 抖动排查实战:PageCache 剧烈回收引发的 Broker Busy 与 Mmap 预热机制解析

    排查过程中,某高并发压测场景下的 RocketMQ 集群(v4.9.4)频繁爆出 [TIMEOUT_CLEAN_QUEUE]broker busy,发送延迟 P99 从 5ms 突增至 2000ms+。核心原因是 Linux PageCache 脏页回写与 mmap 缺页中断(Page Fault)阻塞了 Broker 写线程。结论先行:通过开启 RocketMQ 的 warmMapedFileEnable=truetransientStorePoolEnable=true,配合下调 OS 内核的 vm.dirty_background_ratio,可彻底斩断内核级阻塞,将 P99 稳定压制在 10ms 以内。

    故障现场与指标观测

    某次大促前夕的全链路压测中,单 Broker 节点 QPS 压到 4w 时,客户端开始出现大量的 MQBrokerException: broker busyRemotingTooMuchRequestException 报错。

    查看 Broker 端 store.logbroker.log,满屏如下报错:

    202X-XX-XX XX:XX:XX WARN [SendMessageThread_1] - [TIMEOUT_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: 205ms, size of queue: 853
    202X-XX-XX XX:XX:XX WARN [SendMessageThread_2] - OS page cache busy, osPageCacheBusyTimeOutMills=1000
    

    调出监控看板:

    1. CPU Load:平时 4-5 左右,故障发生瞬间 Load Average 飙升至 40+。

    2. 磁盘 IOiostat -xdm 1 显示 await 偶尔飙高,但 util% 只有 50% 左右,磁盘并未彻底被打满。

    3. 内存指标free -m 显示 buff/cache 占用接近 85%,物理空闲内存(free)极少。

    此时通过 strace -p -T -e trace=mmap,munmap,write,pwrite64 抓取底层系统调用,发现部分写操作耗时极其离谱,甚至超过 1 秒。这就引出了一个经典的架构错觉:我都全异步了,为什么还会卡?

    为什么异步刷盘(ASYNC_FLUSH)依然会阻塞写线程?

    很多开发人员认为,只要 RocketMQ 配置了 flushDiskType=ASYNC_FLUSH,消息只要写到内存(PageCache)就算成功,磁盘 IO 慢绝不会影响发送延迟。这是一个极其致命的认知盲区。

    RocketMQ 的 CommitLog 默认采取 1GB 固定大小,通过 mmap(Memory Mapped Files)将物理文件映射到用户态的虚拟内存中。Broker 处理写请求的核心路径是: SendMessageProcessor -> CommitLog.putMessage() -> MappedFile.appendMessagesInner() -> ByteBuffer.put(data)

    问题就出在这个 ByteBuffer.put() 上。这虽然是内存操作,但在 Linux 内核视角下,它随时可能被阻塞,原因有二:

    1. 缺页中断(Minor/Major Page Fault): 当 Broker 滚动创建新的 1GB CommitLog 并执行 mmap 时,Linux 采用的是“延迟分配”策略。仅仅是建立了虚拟内存地址映射,并未分配实际物理页。当写线程第一次往这个地址 put 数据时,会触发内核缺页中断,内核需要去寻找空闲物理页并建立页表。如果此时系统物理内存紧张,内核触发直接回收(Direct Reclaim),写线程就会被死死卡住。

    2. PageCache 脏页回写阻塞: 当脏页积累到内核阈值(vm.dirty_ratio,默认 20%)时,Linux 会挂起所有尝试生成新脏页的用户进程,强行同步刷盘。此时你的 ByteBuffer.put() 会直接退化为同步阻塞写。

    深度解析:CommitLog Mmap 与 读写分离预热机制

    为了规避上述内核级别的阻塞,RocketMQ 提供了几项极为核心的防御性存储机制。

    1. 强制预热与内存锁定(warmMapedFileEnable)

    配置 warmMapedFileEnable=true 后,Broker 在创建新的 1GB MappedFile 时,会提前在后台线程中将其填满 0,强行触发所有的缺页中断,真正分配物理内存。 不仅如此,RocketMQ 还会调用 JNA 执行 mlockmadvise

    // 核心源码示意 (MappedFile.java)
    LibC.INSTANCE.mlock(pointer, 1024 * 1024 * 1024);
    LibC.INSTANCE.madvise(pointer, 1024 * 1024 * 1024, LibC.MADV_WILLNEED);
    

    mlock 直接告诉内核:“这 1GB 内存你给我锁死在 RAM 里,绝对不允许 Swap 出去!”。这就彻底消除了写消息时发生 Page Fault 的可能性。

    2. 堆外内存写池(transientStorePoolEnable)

    这是应对 PageCache 毛刺的终极武器(仅限异步刷盘有效)。 开启后,RocketMQ 会预先向 OS 申请一块 DirectByteBuffer 内存池(不受 JVM GC 影响,也暂时不进 PageCache)。 写数据路径变为:写请求 -> DirectByteBuffer -> 立即返回客户端成功。 后台 CommitRealTimeService 线程定期将 DirectByteBuffer 的数据写入 FileChannel(进入 PageCache),再由 FlushRealTimeService 线程异步刷盘。 这是一种极致的读写分离策略,彻底将“接收消息的写线程”与“PageCache 分配/刷盘”解耦。

    极客实战:RocketMQ 存储与内核参数双向调优

    解决此类抖动问题,绝不能只改应用配置,必须深入 OS 层联动调优。以下是我在生产环境经过验证的黄金配置标准。

    RocketMQ 核心配置 (broker.conf)

    # 强制使用异步刷盘
    flushDiskType=ASYNC_FLUSH
    # 开启堆外内存池缓冲,彻底解耦写请求与PageCache抖动
    transientStorePoolEnable=true
    # 开启Mmap预热与内存锁定,消除运行时缺页中断
    warmMapedFileEnable=true
    # 优化PageCache锁超时机制(如果发生抖动,快速失败,依赖重试)
    osPageCacheBusyTimeOutMills=1000
    

    Linux 内核 IO 参数调优 (/etc/sysctl.conf)

    光配 Broker 不够,必须改造内核的脏页回写策略:

    # 脏页占总内存的 5% 时,pdflush 后台线程开始异步刷盘(原默认10%)
    # 目的:提早刷盘,细水长流,避免积压
    vm.dirty_background_ratio = 5
    
    # 脏页占总内存的 40% 时,强制阻塞所有用户态写进程(原默认20%)
    # 目的:拉开与 background_ratio 的差距,给突发流量留足 Buffer
    vm.dirty_ratio = 40
    
    # 坚决不使用 Swap(避免mmap的内存被换出)
    vm.swappiness = 1
    
    # 预留给 OS 应急的物理内存(例如 128G 内存机器配 2G)
    # 目的:避免缺页中断时因无空闲内存触发直接回收(Direct Reclaim)引发系统停顿
    vm.min_free_kbytes = 2097152
    

    执行 sysctl -p 生效。经过这一套连招组合拳,压测 P99 稳如泰山,再也没有出现过 broker busy

    常见问题 (FAQ)

    Q1:开启 transientStorePoolEnable=true 后,如果 Broker 进程直接 Crash(如 OOM Killer),数据会丢失吗? 会。这就是享受极致低延迟的代价。该模式下数据首先写入 DirectByteBuffer,这是用户态进程的堆外内存。如果进程被 kill -9 或者 Crash,这部分尚未 commit 到 OS PageCache 的数据将会丢失。如果你对数据一致性要求极度苛刻(如金融交易),只能忍受延迟,关闭此项并使用 SYNC_FLUSH

    Q2:为什么消费重试队列(%RETRY%)里的消息会导致明显的磁盘 IO 升高和 Broker 负载增加? RocketMQ 是基于 CommitLog 的混合存储。正常消费是顺序读写(刚写完的数据大概率还在 PageCache 中,命中率极高)。但重试队列消费的是过去某个时间点的冷数据。这就迫使 Broker 产生大量的随机 IO(读磁盘),导致 PageCache 污染,驱逐掉热数据,从而引发全局性能下降。应对策略通常是单独隔离重试服务,或使用 NVMe SSD 扛随机 IO。

    Q3:遇到 [TIMEOUT_CLEAN_QUEUE]broker busy,除了存储层问题,还有什么原因? 如果磁盘 IO 不高,PageCache 也没问题,你需要检查是不是 JVM 发生了长时间的 Stop-The-World (STW)。尤其是 G1 GC 配置不当,或是业务代码向 RocketMQ 发送超大消息(如几 MB 的报文),导致 Broker 在反序列化/网络传输时消耗大量 CPU 和内存资源,阻塞了 Netty 的 Worker 线程。

  • K8S API Server 被打挂的元凶:记一次 CRD Status 更新引发的 Reconcile 死循环惨案

    排查某个生产 K8S 集群异常时,发现 APIServer P99 延迟飙升至 4000ms 以上,etcd 磁盘 IOPS 直接打满。排查结论极度缺乏常识:业务团队新上线的一个 Operator 在 Reconcile 循环中毫无节制地更新 CRD 的 Status 字段(甚至注入了 time.Now()),且未配置任何 Event Filter。这导致了一个经典的死循环:更新 Status -> 触发 Update 事件 -> 进入 WorkQueue -> 再次 Reconcile -> 再次更新 Status。最终演变成针对 APIServer 的内网 DDoS,直接干碎了控制平面。

    这种低级失误在 Operator 开发中屡见不鲜。如果你连 K8S 声明式 API 的控制循环语义和 Informer 机制都没搞懂,就不要去碰 controller-runtime

    现场还原与指标雪崩

    近期监控系统疯狂报警,核心集群的 apiserver_request_duration_seconds_bucket 指标中,Mutating API 的 P99 延迟从平时的 15ms 暴涨到 4s。同时,etcd 节点的 etcd_disk_wal_fsync_duration_seconds 指标出现剧烈抖动,底层存储 IOPS 处于持续饱和状态。

    第一反应是控制平面被恶意击穿。拉取 APIServer 的审计日志和 QPS 监控(apiserver_request_total),发现某个特定资源 appconfigs.biz.example.comPUT / PATCH 请求 QPS 高达 8000+,且全集中在 /status 子资源上。

    随便抓一条 APIServer 的日志:

    I0814 10:23:45.123456       1 trace.go:205] Trace[12345678]: "Update /apis/biz.example.com/v1/namespaces/default/appconfigs/test-app/status" (started: 202x-xx-xx..., 3.5s)
    

    很明显,是新上的 Operator 出了严重 Bug。

    扒开烂代码:愚蠢的 Reconcile 逻辑

    把出问题 Operator 的代码拉下来,看一眼 Reconcile 函数和 Controller 的注册逻辑,简直是灾难现场。

    致命代码片段 1:无意义的动态 Status 更新

    func (r *AppConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        var instance bizv1.AppConfig
        if err := r.Get(ctx, req.NamespacedName, &instance); err != nil {
            return ctrl.Result{}, client.IgnoreNotFound(err)
        }
    
        // ... 执行一些实际的业务逻辑 ...
    
        // 灾难的根源:每次 Reconcile 都无脑更新时间戳
        instance.Status.LastReconciledTime = metav1.Now()
        instance.Status.Phase = "Running"
    
        if err := r.Status().Update(ctx, &instance); err != nil {
            return ctrl.Result{}, err
        }
    
        return ctrl.Result{}, nil
    }
    

    致命代码片段 2:毫无防备的 Watch 注册

    func (r *AppConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
        return ctrl.NewControllerManagedBy(mgr).
            For(&bizv1.AppConfig{}). // 没有任何 Predicate 过滤
            Complete(r)
    }
    

    底层原理解析:为什么会死循环?

    在 Kubernetes 的架构中,任何对 Object 的修改(无论是 Spec 还是 Status,甚至是 Annotations 的变动),都会导致该 Object 的 ResourceVersion 发生改变。

    当这段代码执行 r.Status().Update() 时,底层发生了什么?

    1. APIServer 接收到更新请求,持久化到 etcd,并生成一个新的 ResourceVersion

    2. Operator 内部的 Reflector 通过 List-Watch 机制感知到这个变更,将带有新 ResourceVersion 的对象推入 DeltaFIFO

    3. Informer 处理这个 Delta 事件,更新本地 Indexer 缓存,并触发 Update 事件回调。

    4. 由于 SetupWithManager 中没有配置任何过滤条件,这个 Update 事件被原封不动地转换成了一条针对该 NamespacedName 的 Reconcile Request,塞进 WorkQueue

    5. Worker 协程从队列中取出 Request,再次执行 Reconcile

    6. Reconcile 中又执行了 metav1.Now() 生成了全新的时间戳,再次发起 Update

    死循环正式确立。 Operator 的 CPU 飙升,APIServer 的连接池被耗尽,etcd 疯狂刷盘写 WAL,最终整个 K8S 控制平面的响应能力被拖垮。

    破局与防御性编程实践

    修复这个 Bug 只需要两步,但更重要的是建立防御性编程的思维。

    1. 引入 GenerationChangedPredicate 拦截无效事件SetupWithManager 中,必须明确告诉 Controller:我只关心 Spec 的变化,不关心 Status 的变化。Kubernetes 通过 metadata.generationmetadata.resourceVersion 来区分这一点。修改 Spec 会自增 generation,而仅修改 Status 只会改变 resourceVersion

    import "sigs.k8s.io/controller-runtime/pkg/predicate"
    
    func (r *AppConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
        return ctrl.NewControllerManagedBy(mgr).
            For(&bizv1.AppConfig{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
            Complete(r)
    }
    

    注:如果你的 Controller 需要响应 Annotation 或 Label 的变化,不能简单使用 GenerationChangedPredicate,需要自定义 Predicate 逻辑。

    2. 状态对比,拒绝盲目 Update 不要在 Reconcile 中无脑塞 metav1.Now()。状态是用来反映资源当前真实情况的,不是用来做心跳上报的。在调用 Update 之前,必须做 DeepEqual 或者状态哈希校验,只有真正发生变化时才发起网络请求。

    // 好的实践:对比新老状态
    oldStatus := instance.Status.DeepCopy()
    
    // ... 计算新的 status ...
    instance.Status.Phase = "Running"
    // 取消无意义的 LastReconciledTime 更新
    
    if !reflect.DeepEqual(oldStatus, &instance.Status) {
        if err := r.Status().Update(ctx, &instance); err != nil {
            return ctrl.Result{}, err
        }
    }
    

    3. 利用 Client-Side Rate Limiter 兜底 哪怕业务逻辑写出了死循环,也绝不能把底层的 APIServer 打挂。在实例化 Manager 时,应当配置合理的限速器(RateLimiter),控制入队重试的指数退避频率和最大 QPS。

    排查清单:Operator Reconcile 性能与死循环速查

    1. APIServer QPS 异常突增定位: 优先检查 Prometheus apiserver_request_total 指标,按 resourceverb 分组,找出请求量异常的 CRD 和操作类型(通常是 UPDATE / PATCH status)。

    2. Controller 队列深度监控: 观察 workqueue_depthworkqueue_adds_total 指标。如果某个 Controller 的 adds_total 呈陡峭直线飙升,必然存在 Reconcile 死循环。

    3. 检查 Event Predicate 配置: 确认 SetupWithManager 是否使用了 GenerationChangedPredicate,或者是否在自定义的 Update Func 中过滤掉了 oldObj.ResourceVersion == newObj.ResourceVersion 的无效事件。

    4. 排查 Informer Cache 穿透: 绝对禁止在 Reconcile 中使用 r.Client.Get 获取对象后,直接在原对象指针上修改并绕过 Client 调用。如果强行修改 informer 缓存的对象而不提交到 APIServer,会导致本地缓存污染和不可预期的异常。始终对拿到的对象做 DeepCopy

    5. CRD Subresource 配置核对: 检查 CRD 的 YAML 定义中是否启用了 subresources: status。如果没有启用,对 Status 的更新会被当作对主对象的更新处理,极易引发锁冲突和额外的业务级混乱。

  • 深入 PostgreSQL MVCC 机制:从 500GB 表膨胀看 Autovacuum 与 xmin 陷阱实战

    PG 核心表突发 500GB 膨胀,查询 P99 从 50ms 飙升至 3s。根因是长事务或废弃复制槽拖住了全局 xmin 视界,导致 Autovacuum 彻底失效,引发 Dead Tuples 堆积的“死亡螺旋”。本文拆解 PG 14 的 MVCC 与 VACUUM 底层机制,并给出防御性调优基线。

    现场与线索

    某次排查过程中,监控大盘开始疯狂告警:某 PG 14.5 生产集群磁盘使用率突破 85%,核心业务读写耗时严重劣化。登录主机查看系统负载,Load Average 飙升至 60+(32核机型),I/O Wait 居高不下。

    通过 pg_stat_user_tables 排查表状态,发现一张核心订单表 n_dead_tup(死元组数量)高达数千万,表物理体积从原本的 50GB 暴涨到了 550GB。

    显然,这是一次典型的表膨胀(Table Bloat)。立刻手动触发分析:

    VACUUM VERBOSE orders;
    

    终端很快返回了令人窒息的日志:

    INFO:  vacuuming "public.orders"
    INFO:  "orders": found 0 removable, 45120485 nonremovable row versions in 702581 pages
    DETAIL:  45120485 dead row versions cannot be removed yet, oldest xmin: 245189012
    

    关键信息在 0 removablecannot be removed yet。Autovacuum 其实在正常调度,只是它无权清理这些死元组。

    为什么正常执行的 Autovacuum 无法回收死元组(Dead Tuples)?

    要解释这个问题,必须扒开 PostgreSQL 基于多版本并发控制(MVCC)的底层实现。

    在 PG 中,执行 UPDATEDELETE 并不会原地修改或删除数据。UPDATE 实际上是 DELETE + INSERT。旧的数据行被称为 Dead Tuple。PG 在每行数据(Tuple)头部维护了两个核心隐藏字段:

    • t_xmin:插入/更新该元组的事务 ID。

    • t_xmax:删除/更新该元组的事务 ID(如果是活元组,此值为 0)。

    当 Autovacuum 扫描表时,它需要判断一个 Dead Tuple 是否可以被物理回收。判断的唯一法则基于 全局最小活跃事务 ID(Global Xmin Horizon)。 如果一个 Dead Tuple 的 t_xmax 大于或等于 当前系统中正在运行的最老活跃事务的 ID(也就是 oldest xmin),那么这个 Dead Tuple 绝对不能被清理。因为那个老事务如果执行查询,根据 MVCC 可见性规则,它依然需要读取这行“历史数据”。

    因此,一旦系统中出现“刺客”拖住了全局 xmin,哪怕你把 Autovacuum 调得再激进,也是徒劳。常见的“刺客”有三种:

    1. 长事务(Long Transactions):代码里忘了 COMMITidle in transaction 状态。

    2. 废弃的逻辑复制槽(Abandoned Replication Slots):下游消费端宕机,导致主库一直保留 WAL 和 xmin 视图。

    3. 两阶段提交的孤儿事务(Prepared Transactions)

    揪出元凶

    执行以下 SQL 抓取系统中最老的事务或复制槽:

    -- 查长事务
    SELECT pid, usename, state, backend_xmin, backend_xid, 
           age(backend_xmin) AS xmin_age, query, state_change
    FROM pg_stat_activity 
    WHERE backend_xmin IS NOT NULL 
    ORDER BY age(backend_xmin) DESC LIMIT 5;
    
    -- 查复制槽
    SELECT slot_name, plugin, slot_type, active, xmin, 
           catalog_xmin, age(xmin) AS xmin_age 
    FROM pg_replication_slots 
    ORDER BY age(xmin) DESC;
    

    排查发现,业务端有个定时任务触发了死锁异常被捕获,但在异常处理逻辑中漏掉了 Rollback,导致一个 idle in transaction 的会话挂了 3 天,彻底锁死了全局 xmin 推进。

    解决手段简单粗暴:直接 pg_terminate_backend(pid) 杀掉僵尸会话。随后 Autovacuum 迅速介入,大量死元组被标记为可复用空间(FSM, Free Space Map)。

    生产级 Autovacuum 防御性调优基线

    原生 PG 的默认配置极度保守,是为了能在树莓派或低配虚机上跑起来而设计的。把默认配置直接上到几十核的高并发生产环境,等于给系统埋雷。

    为了防止类似“死亡螺旋”的发生,我们在 postgresql.conf 中必须落地以下防御性配置策略(基于 PG 14):

    1. 斩断长事务的黑手

    绝对不要相信业务代码能完美处理所有异常分支。在数据库侧兜底是运维的基本素养。

    # 防御性配置:强制终结空闲时间过长的事务(极度重要)
    idle_in_transaction_session_timeout = '10min'
    
    # (可选)针对高并发OLTP,设置单条语句最大执行时间
    # statement_timeout = '30s'
    

    2. 限制复制槽的 WAL 与 xmin 保留

    PG 13 引入了关键配置,防止死掉的逻辑复制槽把主库磁盘撑爆。

    # 限制复制槽最大保留的WAL大小,超出此值将强制失效复制槽
    max_slot_wal_keep_size = '50GB'
    

    3. 释放 Autovacuum 的 I/O 枷锁

    默认的 autovacuum_vacuum_cost_limit 是 200,它限制了 VACUUM 进程的 I/O 速率,导致在大表中 VACUUM 速度远落后于 UPDATE 产生垃圾的速度。

    # 降低触发阈值,避免累积过多才开始清理
    # 默认是0.2(20%),对于1亿行的表,要等2000万行变更才触发,太晚了
    autovacuum_vacuum_scale_factor = 0.05
    autovacuum_analyze_scale_factor = 0.02
    
    # 提升 VACUUM 的 I/O 配额限制(默认 200 太低,SSD 环境可以直接上 2000~5000)
    autovacuum_vacuum_cost_limit = 2000
    autovacuum_vacuum_cost_delay = 2ms
    

    避坑指南:很多人喜欢调大 autovacuum_max_workers(默认 3)。注意,所有 Worker 是平分 autovacuum_vacuum_cost_limit 这个 I/O 额度的。如果只加 Worker 不加 Limit,每个 Worker 的执行速度反而会变得像蜗牛一样慢,加剧锁竞争。

    常见问题 (FAQ)

    Q1:表已经膨胀到 500GB 了,杀死长事务后,Autovacuum 跑完了,为什么磁盘空间没有释放? A:Autovacuum 只能将 Dead Tuple 占用的空间标记为 Free Space Map (FSM) 供后续 INSERT/UPDATE 复用,它不会将空间退还给操作系统(除非恰好死元组都在表文件的物理末尾)。如果急需释放磁盘空间,不能使用常规 VACUUM,需要使用 VACUUM FULL(会获取 8 级排他锁,阻塞读写),或在生产环境使用第三方工具 pg_repackpg_squeeze 进行在线无锁空间重组。

    Q2:日志里疯狂打印 “WARNING: oldest xmin is far in the past”,并伴随 Transaction ID Wraparound 警告,怎么救? A:XID 环绕是 PG 最严重的问题之一。PG 的事务 ID 是 32 位整数(约42亿),用尽后会绕回,导致未来的事务把旧数据看作不可见,造成“数据蒸发”错觉。当到达防环绕阈值时,PG 会强制进入只读模式。 一旦触发警告,需立即停止应用写入,找出拖住 xmin 的老事务/复制槽清理掉,并手工执行 VACUUM (FREEZE, VERBOSE) table_name 来冻结老数据的 XID。若已宕机,需进入单用户模式(Single-User Mode)手动执行 Freeze。

    Q3:频繁触发 VACUUM 会不会对 WAL 产生剧烈影响? A:会。当开启了 wal_log_hints = on 或使用了数据校验和(Checksums),且发生 Checkpoint 后的第一次页面修改时,PG 会触发全页写入(Full Page Writes, FPW)。VACUUM 过程中如果是第一次 touch 某个页,也会产生大量的 FPW,导致 WAL 体积激增。这是保证 Crash Safe 的必要代价。应对策略是合理拉长 Checkpoint 间隔(调大 max_wal_sizecheckpoint_timeout),降低 FPW 的发生频率。

  • TiDB 集群 P99 暴涨至 5000ms:一个 2 亿行大事务 DELETE 引发的 Percolator 惨案

    某次排查过程中,核心交易集群的 TiDB 节点发生大面积 OOM,集群 P99 延迟从日常的 10ms 直接飙升到 5000ms 以上,TiKV 节点接连抛出 Server is busy 拒绝服务。先说最终结论: 某位研发在后台归档任务中,执行了一条没有任何 LIMIT 和分批的 DELETE 语句,企图在一个事务内删掉 2 亿行历史数据。由于对底层 Percolator 分布式事务模型一无所知,这个超级大事务不仅瞬间抽干了 TiDB Server 的内存,残留在 TiKV 的海量锁和 MVCC 墓碑(Tombstone)更是直接引发了读写雪崩。

    案发现场:从 OOM 到全局雪崩

    监控面板上,故障的爆发几乎是垂直的:

    1. tidb_server_memory_usage 指标在 3 分钟内从 4GB 飙升到 64GB(容器 Limit),随后节点被内核 OOMKilled

    2. TiKV 的 Raft apply duration P99 飙到秒级,Coprocessor CPU 打满。

    3. 应用端出现大量 java.sql.SQLException: Lock wait timeout exceeded; try restarting transactionRegion is unavailable

    切到机器上抓一下 dmesg,典型得不能再典型的 OOM:

    [123456.789] Memory cgroup out of memory: Kill process 5678 (tidb-server) score 1980 or sacrifice child
    [123456.790] Killed process 5678 (tidb-server) total-vm:85934028kB, anon-vm:67108864kB, file-vm:0kB, shmem-vm:0kB
    

    翻看 INFORMATION_SCHEMA.SLOW_QUERY 和存活节点的 TiDB 日志,抓到了罪魁祸首:

    DELETE FROM trade_orders WHERE create_time < '2023-01-01 00:00:00';
    

    就是这么一句平平无奇的 SQL,命中了近 2 亿条数据。

    当我拿着这条 SQL 去找对应业务线的开发时,得到的答复是:“我们用的是分布式数据库啊,底层不是无限水平扩展的吗?删个历史数据怎么就挂了?”

    这种把分布式数据库当成魔法、完全无视底层物理定律的想法,是导致大多数生产灾难的根源。分布式 != 无底洞。

    刨根问底:为什么分布式数据库最怕“大事务”?

    在单机 MySQL (InnoDB) 中,大事务会撑爆 Undo Log,导致长事务阻塞和主从延迟。而在 TiDB 这类基于 Percolator 模型的分布式 HTAP 数据库中,大事务的杀伤力是指数级的。

    1. OOM 的元凶:两阶段提交(2PC)的内存缓冲

    TiDB 处理事务使用的是 Percolator 模型的变种。在事务提交(Commit)之前,客户端(即 TiDB Server)会把所有修改的数据缓存在自己的内存中。 当执行这句 2 亿行的 DELETE 时,TiDB Server 需要将这 2 亿个 Key 的修改操作(在底层,DELETE 也是一种写入,即写入包含 Tombstone 标记的 KV)装进内存。 算一笔最简单的账:单行数据的 Key + Value 加上事务元数据假设为 200 Byte。 200,000,000 * 200 Byte ≈ 40 GB。 更要命的是,Go 语言在处理如此庞大的对象分配时,GC 会产生巨大的开销,内存碎片加上堆栈扩展,轻轻松松就能把 64GB 的容器内存干爆。

    2. “掩耳盗铃”的配置修改

    其实 TiDB 为了防止这种惨案,出厂设置是有保护机制的:txn-total-size-limit 默认通常为 100MB。 理论上,这个事务早就该报 Transaction too large 失败了。但我查阅配置变更历史时发现,前段时间该业务线抱怨过几次批量更新报错,某位缺乏敬畏之心的运维,直接将全网的 txn-total-size-limit 改成了 10GB! 放开这种硬性防御阈值,等于拆掉了保险丝。TiDB 成功绕过了配置限制,然后死在了物理内存耗尽上。

    3. 锁残留与 Resolve Lock 风暴

    TiDB Server OOM 崩溃后,灾难并没有结束。 在 Percolator 2PC 的 Prewrite 阶段,TiDB 会在 TiKV 端写入大量的 Primary Lock 和 Secondary Lock。TiDB Server 进程猝死,导致这些锁变成了“孤儿锁”。 此时,正常的业务请求如果读取到了这些被锁住的 Key,就会发现事务处于 Pending 状态。为了保证 ACID,读请求必须触发锁清理机制(Resolve Lock)。 几十万个并发查询撞上几千万个残留锁,瞬间引发了海量的 RPC 交互:

    [WARN] [endpoint.go:612] ["error response"] [err="Key is locked (primary)"] 
    [WARN] [resolve.go:128] ["resolve lock timeout"] [txn=43981293847123984]
    

    TiKV 的 RPC 线程池直接打满,Raftstore 处理缓慢,最终导致大面积的 Region unavailable,连正常的小事务也无法提交。

    终极解法与避坑指南

    对于分布式数据库的批量数据清理,绝对不能用传统的“大事务一波流”。如果你需要删几亿条数据,请把“防御性编程”刻在脑子里。

    正规的落地姿势有三种:

    方案 A:非事务 DML(Non-transactional DML) 新版 TiDB 提供了原生的批处理语法,直接在内部完成分批提交,不保证事务的原子性(反正删历史数据也不需要原子性),彻底绕过大事务限制:

    BATCH ON id LIMIT 10000 
    DELETE FROM trade_orders WHERE create_time < '2023-01-01 00:00:00';
    

    方案 B:按时间分区的 Drop Partition 对于日志流、流水表,建表时就应该规划好时间分区(Partition By Range)。清理历史数据只需一条 ALTER TABLE trade_orders DROP PARTITION p2022;。这在底层仅仅是元数据的解绑,瞬间完成,没有 MVCC,没有锁冲突。

    方案 C:TiDB TTL (Time to Live) 机制 如果业务特性允许,直接在表结构上加上 TTL 属性:

    ALTER TABLE trade_orders TTL = `create_time` + INTERVAL 1 YEAR;
    

    交由 TiDB 后台按 Region 慢慢清理,对前台业务透明。

    排查清单:同类大事务问题速查 (Troubleshooting Checklist)

    1. 核对 OOM 与系统日志 立刻在 TiDB 节点执行 dmesg -T | grep -i oom,如果命中 tidb-server,说明发生过严重的内存挤兑,大概率是大事务或者无索引的巨型 JOIN。

    2. 定位元凶 SQL 检索 INFORMATION_SCHEMA.SLOW_QUERY,重点关注 Mem_maxTxn_start_tsQuery_time 极大的语句: SELECT query, mem_max, process_time FROM information_schema.slow_query ORDER BY mem_max DESC LIMIT 5;

    3. 检查全局限制配置 不要盲目调大保护参数。检查 tidb_mem_quota_query(单条 SQL 内存限制)和 txn-total-size-limit(总事务大小限制),恢复到合理阈值(推荐单事务不要超过 1GB)。

    4. 清理遗留的悲观锁/乐观锁 如果 OOM 后集群持续卡顿,观察 Grafana 中的 TiKV-Details -> Locks 面板。必要时可通过临时调低 resolve-lock 的 backoff 时间来加速孤儿锁清理,或联系官方辅助清理陈旧的 MVCC tombstone 触发手动 Compaction。

    分布式架构给了你海量存储的错觉,但底层的内存、网络 IO 和锁机制依然遵循着严密的物理约束。在生产环境敲下回车之前,想想底层要付出多大的代价。

  • 深入 K8S CSI 存储拓扑:从 Pod 跨可用区调度死锁看 WaitForFirstConsumer 机制

    在跨可用区(Multi-AZ)部署有状态服务时,默认的 Immediate 存储绑定模式极易导致 Pod 调度死锁(存储卷在 AZ-A 被提前创建,而该区无可用计算资源,导致 Pod 卡在 Pending)。本文直接给出核心解法:生产环境的多可用区集群中,StorageClass 必须强制启用 volumeBindingMode: WaitForFirstConsumer 与 CSI 拓扑感知,这也是解决存储与计算资源错配的唯一正解。

    排查某次线上 Elasticsearch 集群扩容故障时,发现新建的 Pod 持续处于 Pending 状态。 通过 kubectl describe pod 查看 Events,输出了非常典型的报错:

    Warning  FailedScheduling  3m22s  default-scheduler  0/15 nodes are available: 3 node(s) had volume node affinity conflict, 12 node(s) didn't match Pod's node affinity/selector.
    

    集群环境是 K8S v1.28.2,底层使用 AWS EBS CSI Driver (v1.30.0)。报错明确指出了 volume node affinity conflict(存储卷节点亲和性冲突)。

    顺着线索排查 PVC 和 PV:

    # kubectl get pvc data-es-cluster-3
    NAME                STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
    data-es-cluster-3   Bound    pvc-8f9a2c1b-4d3e-4f5a-9b1c-2a3b4c5d6e7f   500Gi      RWO            gp3-default    15m
    
    # kubectl get pv pvc-8f9a2c1b-4d3e-4f5a-9b1c-2a3b4c5d6e7f -o yaml | grep -A 5 nodeAffinity
        nodeAffinity:
          required:
            nodeSelectorTerms:
            - matchExpressions:
              - key: topology.ebs.csi.aws.com/zone
                operator: In
                values:
                - ap-southeast-1a
    

    问题已经水落石出:PVC 绑定到了一个位于 ap-southeast-1a 可用区的 PV,但此时 ap-southeast-1a 的所有 Node 资源(CPU/Memory)已经耗尽。kube-scheduler 试图把 Pod 调度到有充裕资源的 ap-southeast-1b1c,却被 PV 的拓扑亲和性(NodeAffinity)硬生生卡住,引发调度死锁。

    产生这种惨剧的根本原因,在于对应的 StorageClass 采用了 volumeBindingMode: Immediate

    危险的 Immediate 模式

    Immediate 模式下,PVC 的生命周期独立于 Pod。流程如下:

    1. StatefulSet 控制器创建 PVC。

    2. CSI 的 external-provisioner 监听到 PVC 创建,立刻调用后端存储接口(如 AWS API)创建底层卷(EBS)。由于此时没有 Pod 的调度上下文,CSI Driver 只能基于 StorageClass 配置或随机选择一个 AZ 创建卷。

    3. 卷创建完毕,K8S 生成 PV 并与 PVC 绑定。PV 被打上了所在 AZ 的亲和性标签。

    4. kube-scheduler 开始调度 Pod,发现 PVC 已经死死绑定在了 AZ-A,只能强行往 AZ-A 调度。如果 AZ-A 计算资源不足,调度彻底失败。

    为什么 WaitForFirstConsumer 能终结跨可用区调度死锁?

    要打破这个僵局,必须把“先建卷再调度”改为“先计算调度,再按节点建卷”。这正是 WaitForFirstConsumer 机制存在的原因。

    修改 StorageClass 配置:

    apiVersion: storage.k8s.io/v1
    kind: StorageClass
    metadata:
      name: gp3-topology-aware
    provisioner: ebs.csi.aws.com
    volumeBindingMode: WaitForFirstConsumer # 核心配置
    allowedTopologies:
    - matchLabelExpressions:
      - key: topology.ebs.csi.aws.com/zone
        values:
        - ap-southeast-1a
        - ap-southeast-1b
        - ap-southeast-1c
    parameters:
      type: gp3
      fsType: ext4
    

    当模式切换为 WaitForFirstConsumer 时,底层的控制流发生了彻底的翻转:

    1. PVC 处于 Pending:StatefulSet 创建 PVC 后,external-provisioner 会忽略这个 PVC,不发起任何创建卷的动作,PVC 保持 Pending。

    2. 调度器介入 (VolumeBinding Plugin):Pod 进入调度队列。kube-scheduler 的 VolumeBinding 插件不仅会计算 CPU/Mem,还会评估各个 Node 的存储拓扑(Storage Topology)。

    3. 模拟绑定与预检:调度器假设将 Pod 放到 Node-B(位于 AZ-B,资源充足),并检查该 Node 是否满足 StorageClass 的拓扑限制。

    4. 注入 Annotation:确认无误后,调度器会在 PVC 上打上一个关键的注解 volume.kubernetes.io/selected-node: Node-B

    5. 按图索骥 (Provisioning):此时 external-provisioner 监听到 PVC 上出现了 selected-node 注解,立即去查询 Node-B 对应的 CSINode 对象,提取其拓扑标签(topology.ebs.csi.aws.com/zone=ap-southeast-1b)。

    6. 精准建卷:CSI Driver 带着确切的 AZ 信息调用云厂商 API,在 AZ-B 精准创建出 EBS 卷。

    7. 大功告成:PV 创建,PVC 变为 Bound,Pod 被正式调度到 Node-B,挂载启动。

    整个过程,kube-scheduler 掌握了绝对的主动权,存储的创建被迫向计算资源的分布妥协,从根源上消灭了调度死锁。

    CSI 拓扑感知底座:CSINode 机制

    你可能会问:external-provisioner 怎么知道某个 Node 到底属于哪个存储拓扑域? 这就涉及 K8S CSI 架构中的 csi-node-driver-registrar Sidecar。在每个 Node 上运行的 CSI 节点组件,启动时会向 kubelet 注册自己,并上报节点级别的存储拓扑信息。K8S 会将这些信息持久化在 CSINode 资源中。

    我们可以直接抓取一个线上的 CSINode 对象来佐证:

    # kubectl get csinode ip-10-0-12-34.ap-southeast-1.compute.internal -o yaml
    apiVersion: storage.k8s.io/v1
    kind: CSINode
    metadata:
      name: ip-10-0-12-34.ap-southeast-1.compute.internal
    spec:
      drivers:
      - allocatable:
          count: 39
        name: ebs.csi.aws.com
        nodeID: i-0abcd1234efgh5678
        topologyKeys:
        - topology.ebs.csi.aws.com/zone
    

    这里的 topologyKeys 明确告知了上层组件,如果要往这台机器挂载 EBS 卷,必须参照 topology.ebs.csi.aws.com/zone 这个 Key 去匹配资源。

    常见问题 (FAQ)

    Q1:配置了 WaitForFirstConsumer,为什么集群扩容时 PVC 依然一直卡在 Pending? 这是生产环境常踩的坑。PVC Pending 说明调度器连第一步“找到合适的 Node”都没过去。这通常是因为:

    1. 集群内没有任何 Node 的 CPU/内存资源能满足 Pod 的 request。

    2. 调度器找到了有资源的 Node,但这台 Node 的拓扑标签与 StorageClass 中的 allowedTopologies 冲突。 检查手段:直接 kubectl describe pod ,看调度失败的 Event 是 Insufficient cpu 还是 volume node affinity

    Q2:StatefulSet 的 Pod 跨可用区调度时,如果原有的 Node 挂了,Pod 漂移到其他 AZ 会发生什么? 会持续处于 Pending。因为 PV 一旦创建,其 NodeAffinity 就已经被写死(例如限定在 AZ-A)。Pod 虽然可以因故障漂移,但 kube-scheduler 评估时发现 AZ-B 的计算资源无法满足这个 PV 的 AZ-A 亲和性,拒绝调度。 对于块存储(如 EBS、阿里云云盘)这是物理限制;如果是分布式文件系统(NFS/CephFS),可以通过多 AZ 共享解决。块存储场景下的容灾,必须依赖应用层的高可用(如 ES/Kafka 的多副本同步),而不是指望 K8S 底层的云盘跨区漂移。

    Q3:Pod 一直处于 Terminating,kubelet 日志狂刷 Unmounted failedMulti-Attach error,怎么处理? 这通常是 CSI 卸载流程卡死。K8S 的卸载顺序是:kubelet 发起 UnpublishVolume(解除宿主机挂载) -> Controller 发起 DetachVolume(解除云 API 绑定)。 遇到此类问题,绝对不要手贱强删 Pod(--force --grace-period=0),否则会导致云盘仍在 Node 上残留,最终耗尽机器的最大挂载数(allocatable count)。 正确排查步骤:

    1. 登录挂载 Node,执行 mount | grep 确认挂载点状态,必要时手动 umount -f 或清除挂死的僵尸进程(如 lsof | grep 找出的进程)。

    2. 查看对应的 csi-node DaemonSet Pod 的日志,确认是否调用底层 API 超时。

    Q4:本地存储(Local Persistent Volume)必须用 WaitForFirstConsumer 吗? 必须。本地盘与特定 Node 物理绑定,如果不延迟绑定,PVC 一旦被随机分配到某个节点的 Local PV 上,Pod 将永远被锁定在那台物理机上。一旦该机器无 CPU 资源,Pod 调度直接暴毙。所以在所有 Node-Local 存储(TopoLVM, OpenEBS LocalPV, 原生 Local Volume)的配置中,这是强制的铁律。