第1881篇:生产事故复盘——记一次AI服务雪崩的排查过程和教训
第1881篇:生产事故复盘——记一次AI服务雪崩的排查过程和教训
去年十月的一个周五下午,我正准备提前下班,结果收到了告警短信。那一刻手机震动的感觉,到现在还记得很清楚。
监控大盘上,AI推荐服务的错误率从0.3%直接蹿到了97%,响应时间从平均200ms扩展到了30秒超时。用户端的表现更直接——App首页推荐模块白屏,客服群里炸了。
这篇文章是对那次事故的完整复盘。不是为了甩锅,也不是为了炫技,而是因为我觉得这种"真实踩坑"比任何教程都值钱。
事故背景:看起来毫不相关的两件事
我们的AI推荐服务架构大致是这样的:用户请求进来,先查Redis缓存,缓存没有的话调用Embedding模型生成向量,然后去Milvus做向量召回,最后用一个排序模型做精排,结果写回缓存。
事故当天,有两件看起来毫不相关的事情同时发生:
- 运维同学在下午3点对Milvus集群做了一次扩容操作,从3节点扩到5节点。
- 另一个团队的同事上线了一个新功能,这个新功能在某些场景下会绕过缓存,强制重新生成推荐结果。
单独看任何一件事,都没什么问题。Milvus扩容是计划内操作,新功能也经过了测试。但两件事凑在一起,就成了灾难。
排查过程:从表象到根因
第一阶段:排除明显嫌疑
告警出现后,我第一反应是看日志。错误信息是这样的:
com.zaxxer.hikari.pool.HikariPool$PoolInitializationException: Failed to initialize pool
...
Caused by: io.milvus.exception.MilvusException: RPC failed, status=UNAVAILABLE: io exceptionMilvus连接失败。我当时第一个猜测是扩容操作搞出了问题,就让运维去看Milvus的状态。运维那边反馈说节点都是健康的,集群状态正常。
这就有点奇怪了。
然后我看了一下连接池的监控,发现连接池耗尽了。我们给Milvus配置的连接池上限是50,但此时活跃连接数显示是50/50,而等待队列里积压了几百个请求。
// 我们当时的连接池配置,后来发现这里有一个严重问题
MilvusClientConfig config = MilvusClientConfig.newBuilder()
.withHost("milvus-cluster")
.withPort(19530)
.withConnectTimeout(5000)
.withKeepAliveTimeout(55000)
.withPoolSize(50) // 这个数字是当初随手填的
.build();连接池耗尽了,为什么?
第二阶段:追踪连接泄漏
我让同事去捞了一下JVM的线程dump,发现有大量线程阻塞在Milvus的IO等待上,等待时间都超过了10秒。正常情况下,一个Milvus查询应该在100-300ms内返回。
这说明Milvus虽然"健康",但响应变慢了。慢到什么程度?从日志时间戳来看,平均响应时间超过了8秒。
这时候运维那边来消息了:扩容之后,Milvus做了一次数据重平衡(rebalance),把一部分索引从老节点迁移到新节点。迁移期间,这些Collection的查询性能会大幅下降。
找到了!Milvus响应变慢 → 连接池线程长时间占用 → 连接池耗尽 → 后续请求全部报错。
但这只解释了"为什么Milvus慢",还没解释"为什么错误率会这么高,高达97%"。
第三阶段:发现真正的雪崩点
如果只是Milvus变慢,按理来说我们有缓存兜底,大部分请求不应该打到Milvus上。
我查了一下缓存命中率——正常情况下是85%左右,事故期间降到了不到10%。
这就指向了那个"绕过缓存"的新功能。
我翻了一下那个功能的代码,发现逻辑是这样的:用户在特定页面上操作后,会给该用户打一个"需要刷新推荐"的标记,下次请求时强制绕过缓存。问题是,上线那天有一个活动推送,几乎所有活跃用户都点击了那个特定页面,导致几十万用户同时被打上了"需要刷新"的标记。
三点那个时间节点,大量用户涌入,缓存几乎全部失效,请求全部打到Milvus,然后撞上了正在rebalance的Milvus集群。
雪崩就这样成型了。
应急处置:怎么止血
找到根因之后,应急处置反而简单了。
第一步:暂时回滚"绕过缓存"功能。这个最快,5分钟内完成。缓存命中率立刻回升,Milvus的压力下降了70%。
第二步:临时扩大Milvus连接池上限。
// 紧急调整后的配置
MilvusClientConfig config = MilvusClientConfig.newBuilder()
.withHost("milvus-cluster")
.withPort(19530)
.withConnectTimeout(2000) // 缩短连接超时,快速失败
.withKeepAliveTimeout(30000)
.withPoolSize(200) // 临时扩大到200
.build();第三步:给Milvus查询加短路断路器。
我们的服务里本来有Resilience4j,但没有配置在Milvus客户端上,这次补上了。
@Bean
public CircuitBreaker milvusCircuitBreaker() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率超过50%触发断路
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(10)
.permittedNumberOfCallsInHalfOpenState(3)
.build();
return CircuitBreakerRegistry.of(config).circuitBreaker("milvus");
}
// 在调用处包裹
public List<Long> searchSimilar(float[] vector, int topK) {
return circuitBreaker.executeSupplier(() -> {
// 实际的Milvus查询逻辑
return milvusClient.search(buildSearchParam(vector, topK));
});
}断路器打开的时候,请求会快速失败,而不是排队等待。这样连接池就不会耗尽,整体服务至少可以正常响应(虽然推荐结果降级了)。
第四步:推荐结果降级方案。
断路器触发后,我们需要一个降级逻辑,不能直接返回空。我们临时接入了一个"热门推荐"的静态列表作为兜底。
public List<RecommendItem> getRecommendations(Long userId) {
try {
return circuitBreaker.executeSupplier(() -> {
float[] vector = embeddingService.getUserVector(userId);
List<Long> itemIds = milvusService.searchSimilar(vector, 20);
return itemService.batchGet(itemIds);
});
} catch (CallNotPermittedException e) {
// 断路器打开,返回热门降级列表
log.warn("Milvus circuit breaker open, fallback to hot items for user {}", userId);
return hotItemService.getHotItems(20);
}
}应急处置完成后,大概过了40分钟,错误率降回了正常水平。整个事故的持续时间是1小时17分钟。
深度复盘:三个层面的问题
技术层面:防护措施缺失
事故暴露出我们在架构设计上的几个严重缺陷:
缺陷一:没有对外部依赖做超时和断路保护。
连接池的keepAlive超时设了55秒,这意味着一个"慢"的连接可以占用连接池资源长达55秒。正确的做法是设置查询级别的超时,而不只是连接超时。
// 正确的做法:查询级别的超时控制
SearchParam searchParam = SearchParam.newBuilder()
.withCollectionName(COLLECTION_NAME)
.withVectors(Collections.singletonList(vector))
.withTopK(topK)
.withMetricType(MetricType.IP)
.withParams("{\"nprobe\":10}")
.withGuaranteeTimestamp(Consistency.Bounded)
.build();
// 用带超时的Future来控制
CompletableFuture<List<Long>> future = CompletableFuture.supplyAsync(
() -> doSearch(searchParam), executor
);
try {
return future.get(2, TimeUnit.SECONDS); // 最多等2秒
} catch (TimeoutException e) {
future.cancel(true);
throw new ServiceException("Milvus query timeout");
}缺陷二:缓存失效策略太粗暴。
一个动作导致几十万用户的缓存同时失效,这是典型的"缓存雪崩"触发条件。正确的做法是加随机过期时间,或者采用异步更新的方式,而不是直接清空缓存。
// 有问题的做法:直接删除缓存
public void markUserNeedRefresh(Long userId) {
redisTemplate.delete("recommend:user:" + userId); // 危险!
}
// 改进后的做法:异步更新,而不是删除
public void markUserNeedRefresh(Long userId) {
// 异步触发缓存更新任务,不立即删除
refreshQueue.offer(userId);
// 同时给缓存打一个"软过期"标记,而不是直接删除
redisTemplate.opsForValue().set(
"recommend:stale:" + userId,
"1",
Duration.ofMinutes(1)
);
}
// 查询时的处理
public List<RecommendItem> getFromCache(Long userId) {
String cacheKey = "recommend:user:" + userId;
String staleKey = "recommend:stale:" + userId;
List<RecommendItem> cached = getCache(cacheKey);
boolean isStale = redisTemplate.hasKey(staleKey);
if (cached != null && !isStale) {
return cached; // 正常缓存命中
}
if (cached != null && isStale) {
// 缓存存在但已过期:返回旧数据,异步刷新
asyncRefresh(userId);
return cached; // 先返回旧的,不影响响应时间
}
return null; // 真正的缓存缺失
}缺陷三:变更窗口没有锁定。
Milvus扩容和新功能上线在同一天发生,这本来应该被变更管理流程拦住的。我们有变更窗口机制,但执行得太松散。
流程层面:变更管理形同虚设
复盘会上,有人问了一个很刺耳的问题:为什么两个变更会在同一时间发生?
答案是:我们有变更管理系统,但很多人觉得"走流程"太麻烦,于是各自为政。运维觉得扩容是"例行操作",不需要在系统里登记。开发同学觉得"只是一个小功能",也没有严格走变更流程。
结果就是,没有人知道那天同时有两个变更在进行。
这不是技术问题,是人的问题。但技术手段可以辅助解决这个问题——比如自动检测同一时间窗口内的多个变更,强制要求负责人确认。
意识层面:对AI服务特殊性的低估
普通Web服务的外部依赖通常是数据库和缓存,这些系统非常成熟,出问题的概率很低。但AI服务多了一层——向量数据库、模型推理服务——这些系统相对年轻,稳定性和可观测性都弱于传统数据库。
我们用对待MySQL的经验来对待Milvus,这是根本性的认知错误。Milvus在做rebalance的时候,查询性能会显著下降,这是它的特性,不是bug。MySQL做主从切换的时候也会短暂影响性能,但我们早就针对这个场景做了保护,却忘了给Milvus也做。
事后改进:我们做了什么
改进一:完整的熔断降级体系。
针对每个外部AI服务(Milvus、Embedding服务、LLM API),都配置了独立的熔断器和降级策略。
@Configuration
public class AiServiceResilienceConfig {
@Bean
public CircuitBreaker milvusCircuitBreaker(CircuitBreakerRegistry registry) {
return registry.circuitBreaker("milvus", CircuitBreakerConfig.custom()
.failureRateThreshold(40)
.waitDurationInOpenState(Duration.ofSeconds(20))
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.TIME_BASED)
.slidingWindowSize(30)
.build());
}
@Bean
public CircuitBreaker embeddingCircuitBreaker(CircuitBreakerRegistry registry) {
return registry.circuitBreaker("embedding", CircuitBreakerConfig.custom()
.failureRateThreshold(30)
.waitDurationInOpenState(Duration.ofSeconds(60))
.slidingWindowSize(20)
.build());
}
@Bean
public TimeLimiter milvusTimeLimiter(TimeLimiterRegistry registry) {
return registry.timeLimiter("milvus", TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(2))
.build());
}
}改进二:缓存预热和平滑失效机制。
不再允许批量删除缓存,改为异步更新队列加限流。
@Service
public class CacheRefreshService {
// 使用令牌桶限制刷新速率:每秒最多刷新1000个用户
private final RateLimiter refreshRateLimiter = RateLimiter.create(1000);
private final BlockingQueue<Long> refreshQueue = new LinkedBlockingQueue<>(100000);
@PostConstruct
public void startRefreshWorkers() {
// 启动10个工作线程消费刷新队列
for (int i = 0; i < 10; i++) {
Thread worker = new Thread(this::processRefreshQueue, "cache-refresh-" + i);
worker.setDaemon(true);
worker.start();
}
}
private void processRefreshQueue() {
while (true) {
try {
Long userId = refreshQueue.take();
refreshRateLimiter.acquire(); // 限流
doRefreshUserCache(userId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}改进三:变更日历强制化。
在CI/CD流水线中加了一个检查步骤:如果当前时间窗口(±2小时)内已有其他变更登记,必须人工确认才能继续部署。
改进四:AI服务专项监控大盘。
原来AI服务的监控和普通Web服务混在一起。事故后,我们单独搭了一个AI服务监控大盘,重点关注:
- 向量数据库的查询延迟分位数(P50/P95/P99)
- 连接池使用率和等待队列长度
- 各AI服务的熔断器状态
- 缓存命中率趋势(突然下降是危险信号)
我最后想说的
这次事故最让我难受的不是持续了1个多小时的故障,而是复盘时意识到:这些问题早就应该被发现,只是没有人去认真想过。
连接池没有配超时保护,是因为"Milvus一直很稳"。缓存失效没有做限流,是因为"这个场景很少见"。变更管理没有严格执行,是因为"走流程太麻烦"。
每一个"因为"后面,都跟着一个侥幸心理。
做AI服务跟做普通Web服务有一个很大的不同:AI服务的依赖链更长,每一个环节都可能成为瓶颈。模型推理、向量检索、Embedding生成,这些服务都比传统数据库"脆",需要更精心的保护设计。
如果你现在也在做AI服务,建议认真问自己几个问题:
- 你的向量数据库查询有没有超时控制?
- 你有没有针对Embedding服务做熔断?
- 你的缓存失效是否可能引发雪崩?
- 你的变更管理流程是否真的被执行?
别等到故障发生再去问这些问题。
