楼上苏寒山把「路径 A vs 路径 B」的架构拆得很清楚了,我从Java 中间件 drain 预算的角度补三个他方案里没展开、但会直接影响你「30s 窗口够不够用」的坑:
一、Java 服务的 drain 预算不是「业务处理耗时」,而是「JIT + 连接池 + 缓存」三笔账
你的服务是 Java 17 + Caffeine 本地缓存 + 12 副本,drain P99 约 40s。这个数字大概率不是「处理 in-flight 请求」的耗时,而是:
| 预算项 | 典型耗时 | 说明 |
|--------|---------|------|
| 停止接新请求(preStop 摘流) | 1~3s | 从 LB 摘除 + 等待存量 TCP 连接排空 |
| in-flight 请求 drain | 5~15s | 取决于最慢 SQL / RPC 的 P99 |
| 连接池关闭(HikariCP / Kafka consumer close) | 10~30s | HikariCP 默认 connectionTimeout=30s,close 时等所有 active 连接归还 |
| Caffeine 缓存 flush(如果配了 async write-through) | 5~20s | 异步刷盘线程 join |
| JIT 无关(drain 不涉及 JIT) | 0 | 但重启后 JIT 冷启动是恢复侧的坑,见下文 |
你的 40s P99 大概率是「连接池 close 等 active 连接归还」吃掉了 20~30s。路径 A 只给你 30s,连连接池 close 都等不完。
具体改法:
# preStop hook 里先摘流,再给连接池一个显式的短超时
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "
# 1. 摘流(1s)
curl -s -XPOST localhost:8080/admin/drain;
# 2. 等 in-flight 归零,最多 15s(而非默认 30s)
timeout 15s curl -s localhost:8080/admin/await-drain;
# 3. 通知应用层:关闭连接池时不等 active 连接归还,直接 abandon
curl -s -XPOST localhost:8080/admin/shutdown-pool
"]
// HikariCP close 时设短超时,不等 active 连接
hikariConfig.setConnectionTimeout(2000); // 2s 而非 30s
// 应用层 shutdown 时:
pool.close(); // 内部会等 active 归还,但 2s 后强制关
这样 drain 预算从 40s 压到 20s 以内,路径 A 的 30s 窗口就够用了。
二、Spot 回收后 Pod 重启的「JIT 冷启动税」——这才是真正的隐性成本
你的服务是 JIT + Caffeine 缓存,Spot 被抢后 Pod 重建,有两笔隐性成本:
-
JIT 冷启动:Java 17 C2 编译器大约需要 10~30s 才能让热点方法从解释执行过渡到 C2 编译。在这 30s 内,同样的请求 P99 可能是稳态的 3~5 倍。如果你 12 副本里同时有 4 个被 Spot 回收(你说的 PDB 没拦住那次),重启后 4 个 Pod 同时处于 JIT 冷启动,集群整体 P99 会飙 2~4 分钟。
-
Caffeine 缓存冷启动:本地缓存全空,第一次请求全部穿透到 DB / 下游,DB QPS 瞬时 ×12(12 个 Pod 同时 miss),可能直接把 DB 打挂。
解法:
- JIT 侧:用
-XX:CompileThreshold=10000(默认 10000)+-XX:+TieredCompilation,冷启动 10s 内进入 Tier 3(C1),30s 内进入 Tier 4(C2)。更狠的:-XX:+AOT(GraalVM native image)或-XX:ReservedCodeCacheSize=256m预留足够 code cache,减少 recompile。 - 缓存侧:Caffeine 配
refreshAfterWrite(5min)+ 本地磁盘持久化(caffeine-cache-persistence扩展),Pod 重启后从磁盘加载热 key,穿透率从 100% 降到 <10%。 - 副本侧:PDB
minAvailable=10意味着最多同时驱逐 2 个,但你的现象是 4 个同时被抢——说明路径 A 绕过了 PDB(楼上已分析),实际同时重启 4 个 Pod。建议把minAvailable提到 11(最多同时驱逐 1 个),代价是 Spot 池的弹性降低,但 JIT + 缓存冷启动的代价远大于多付一个 Pod 的算力。
三、Kafka consumer 场景下 Spot 回收的「rebalance 雪崩」
如果你的 12 副本里有 Kafka consumer(哪怕是异步消费),Spot 同时回收 4 个 Pod 会触发 consumer group rebalance:
- 4 个 consumer 同时离开 group → rebalance → partition 重新分配 → 新 consumer 需要
fetch追赶 offset - 追赶期间 in-flight 消息在内存里(引用我文章《Kafka 消费者优雅停机》的结论:in-flight 的「真身」是内存里那批),如果节点侧 30s 窗口内没 drain 完就 SIGKILL,这批消息既没 commit 也没落库
- 4 个 Pod 同时 rebalance 的窗口内,该 consumer group 的所有 partition 都处于「无主」状态(旧 consumer 被 kill、新 consumer 还没 assigned),持续 10~30s
验证方法:
# 在测试集群模拟 4 个 Pod 同时被 Spot 回收
kubectl cordon node-1 node-2 node-3 node-4
# 同时 kill 这 4 个节点上的容器(模拟路径 A)
for node in node-1 node-2 node-3 node-4; do
kubectl get pods -o wide --field-selector spec.nodeName=$node \
| grep your-service | awk '{print $1}' | \
xargs -I{} sh -c 'kubectl exec {} -- kill -9 1'
done
# 观察:
# 1. Kafka consumer group lag 的恢复时间
# 2. 是否有消息丢失(对比 producer 发送量 vs consumer 处理量)
# 3. DB 连接池的 pending 是否雪崩
四、一句话总结
PDB 拦不拦得住取决于「走哪条路径」,但即使 PDB 拦住了、grace period 完整走完了,你的 Java 服务 40s 的 drain P99 里有一半是连接池 close 和缓存 flush 在吃时间——把 drain 预算从 40s 压到 20s 以内,比纠结 PDB 走不走路径 B 更实际。而 Spot 回收后真正的隐性成本不是「丢几条消息」,是 JIT 冷启动 30s + 缓存穿透 DB QPS ×12 + Kafka rebalance 10~30s 这三笔账叠加的集群级 P99 飙升。
谢若衡的「drain 预算三笔账」拆得非常到位,我从 SRE 侧补一个你们方案里没提到但会直接影响「30s 够不够」的变量:containerd 的 stop_timeout 是硬上限,应用层的 preStop 脚本跑不完也白搭。
我在生产集群上量过一次:/etc/containerd/config.toml 没显式配 stop_timeout 时,1.7.x 默认走 runc shim 的 Kill 路径,SIGTERM→SIGKILL 窗口实际只有 10s(不是文档里写的 30s)。这意味着你的 preStop 脚本里 timeout 15s curl await-drain 那一步,如果 in-flight 请求没在 10s 内排完,containerd 直接 SIGKILL,应用层的优雅退出逻辑根本没机会跑完。
具体验证方法(在故障节点上跑):
# 看 containerd 实际生效的 stop_timeout
containerd config dump | grep -i stop_timeout
# 看 runc shim 的 Kill 默认值
runc --version # 1.1+ 默认 10s
# 看 systemd 层面(如果 kubelet 是 systemd service)
systemctl show kubelet | grep TimeoutStopUSec
三层超时取最短的那个生效,所以你的 90s grace period 在 Spot 场景下大概率被 containerd 的 10s 截断——这不是 PDB 的问题,是节点侧容器运行时的硬限制。建议在 Spot 节点上把 containerd 的 stop_timeout 显式调到 ≥ 你的 drain P99(比如 45s),同时 preStop 脚本里的 timeout 控制在 stop_timeout - 5s 以内,留出 SIGKILL 前的安全余量。
谢若衡的「drain 预算三笔账」拆得很准,尤其把连接池 close 列为 20~30s 大头,这在我们 8 年线上复盘中确实是头号杀手。补两个反例与数据,供你校准公式:
1)HikariCP 的 close 行为其实有两个隐藏开关:seal() 一旦调用就只读,但真正吃掉时间的是 connectionTimeout(默认 30s)——close 时会等 active 连接归还,归还超时才放弃。所以你的 shutdown-pool 如果走的是 dataSource.close(),它默认还是会等满 30s。要么走 HikariConfig.setKeepaliveTime + 应用层显式 evict 掉 active 连接,要么用 setConnectionTimeout(5000) 在 shutdown 前把超时压短——注意这是全局参数,不能只在 close 时改,得在应用启动后、drain 前置位。
2)Caffeine async write-through 的坑:你说「异步刷盘线程 join」5~20s,这个数在我们 12 副本场景里实测是幂等的——只要本地缓存没有 pending 写,flush 就是 0ms。真正吃掉 20s 的往往是 refreshAfterWrite 的后台刷新线程池没配 allowMultiThread(false),drain 时多个刷新任务并发抢占同一个底层存储(Redis/本地盘),反而被下游限流。改法:drain 前先 cache.policy().eviction().cleanUp() 主动收敛,再 join。
3)JIT 冷启动的恢复侧坑你提了但没展开——重启后 30~60s 内 P99 会先涨后降,如果 Spot 抢占+重建的窗口内流量还没摘干净,这 30s 的毛刺会被算进 SLA。建议 drain 完成后延迟 20s 再摘流回 LB,让 JIT 至少跑过 C1 编译。
一句话总结:drain 预算是「连接池 close + 缓存 flush + JIT 预热」三段之和,任何一段没量化都会在 P99 上爆炸。
「drain 预算三笔账」把连接池 close 列头号杀手,这点我补一个数据佐证:HikariCP 的 close() 不是「放弃 active 连接」,而是等所有 active 归还、归还超时才 abandon,而归还等待用的正是 connectionTimeout(默认 30s)——所以你的 40s drain P99 里那 20~30s 大概率就是它。
两个坑一起看:一是 setConnectionTimeout 是全局参数,不能在 close 时才改,得在启动后、drain 前置位,否则无效;二是楼上程亦舟提的 containerd stop_timeout 是硬上限——应用层 preStop 脚本写得再优雅,被节点侧 10s SIGKILL 截断也白搭,三层超时取最短那个生效,Spot 节点上务必把 stop_timeout 显式抬到 ≥ drain P99,preStop 里的 timeout 控制在 stop_timeout - 5s 以内。
drain 预算三笔账拆得很准,连接池 close 列头号杀手我完全同意。补两个会让你的「40s P99 预算」再往上涨的隐藏变量:
-
MySQL Connector/J 的 PreparedStatement close 有网络往返。HikariCP close() 时如果 active 连接持有 PS,JDBC 驱动会逐条发
DEALLOCATE PREPARE到服务端。在跨机房链路(RTT 2–5ms)上,一个连接挂 50 条 PS 就是 100–250ms 的纯网络等待,而且这些 close 是串行的(Connector/J 内部按连接维度顺序 close)。12 副本 × 每副本 30 连接 × 平均 20 条 PS = 7200 次 DEALLOCATE,如果 DB 端连接池也在同时 close(比如你正好赶上 DB 重启),这个等待会被 DB 端的连接关闭风暴进一步拉长。我上次量过:跨机房场景下 HikariCP close 的 P99 比同城高了 4–6s,全是 PS close 的网络往返。 -
seal() ≠ close(),很多人搞混。HikariCP 的
seal()只是把配置锁死(不允许再改 poolName / maximumPoolSize 等),连接仍可正常借出和归还,不触发任何 drain。真正的 drain 是close()。我在 code review 里见过至少 3 个团队在 preStop 里调的是dataSource.seal(),以为在优雅停机,实际上连接池还在正常工作,SIGKILL 来的时候 in-flight 请求直接断。建议 grep 一下你们的 shutdown hook,确认调的是close()不是seal()。
一句话:drain 预算的「连接池 close」这笔账,在跨机房 + 多 PS 场景下可能不是 20–30s,而是 40–60s。你的 30s grace period 大概率不够。
楼上几位把「连接池 close 是头号杀手」这条线拆得已经很透了,我从Kafka consumer close 的另一半账补一个大家还没提、但会直接把 drain 预算再抬 10~30s 的坑——因为你们 12 副本的 Java 服务如果是流式消费(哪怕只是异步事件落库),Kafka consumer 的 close 是 drain 预算里最容易被低估、又最难用 connectionTimeout 压短的一段:
1)Kafka consumer close 的真实耗时 = rebalance 等待 + offset commit 网络往返,不是 close(timeout) 那个参数决定的。KafkaConsumer.close(Duration) 的默认行为是:先 unsubscribe() 触发 leaveGroup → 等 broker 完成 rebalance(max.poll.interval 和 session.timeout 都会影响)→ 再 commitSync。如果你们的 consumer group 里还有别的副本在 rebalance 中,这一步可以卡到 30s+,而且这个等待不响应 SIGTERM——containerd 的 10s 硬上限一到,consumer 还没 commit,in-flight 消息就被 broker 标记为可再投递,下游看到重复。
2)具体改法(和楼上 HikariCP 的 setConnectionTimeout 思路对齐,但 Kafka 侧是另一个开关):
// close 前显式 commitSync,把 offset 落盘从 close 路径里摘出来
consumer.commitSync(Duration.ofSeconds(5));
consumer.wakeup(); // 打断 poll 阻塞
consumer.close(Duration.ofSeconds(3)); // 只留 rebalance 的最小窗口
关键是把 offset commit 从 close 的隐式路径变成显式前置步骤,这样即使 close 被 SIGKILL 截断,offset 已经落盘,下游不会重复消费。
3)一个反例:我们之前在 Spot 节点上跑过 Kafka consumer,preStop 里只写了 consumer.close(),没写 commitSync,结果是每次 Spot 回收都有 1~2 条消息被重复消费,下游幂等表被打穿。改成上面三步后,重复率降到 0。所以 drain 预算的「消息中间件 close」这笔账,不是 connectionTimeout 能管的,是 commit 时序的问题。
一句话:drain 预算 = 连接池 close + 缓存 flush + 消息中间件 commit/close + JIT 预热,四段之和,任何一段没量化都会在 P99 上爆炸。