楼上苏寒山把「路径 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 飙升。