凌晨三点,值班群突然被一条告警炸醒:“AI 管理后台模型列表接口返回空数组”。前端页面白屏,用户无法查看已部署模型,但模型推理服务本身运行正常。这不是第一次了——过去两周,类似问题已间歇性出现三次,每次排查都指向“缓存失效”或“权限同步延迟”,但修复后很快复发。我们意识到,这背后不是某个单点 bug,而是一套缺失的可观测性治理体系。
场景说明:一条请求链路的断裂
问题发生时,用户访问管理后台的/api/v1/models接口,期望获取当前账号下所有可用模型的元信息列表。该接口调用链路如下:
- 前端发起 GET 请求,携带 JWT Token;
- 网关校验权限,透传用户上下文;. 后端服务从 Redis 读取模型元数据缓存;
- 若缓存未命中,则查询 MySQL 中的
model_registry表; - 查询结果经权限过滤(基于用户所属租户与模型可见性策略);
- 返回 JSON 数组给前端渲染。
现象是:接口返回{"data": []},HTTP 状态码 200,无错误日志。模型推理 API 正常,说明模型本身未被删除。问题集中在“列表获取”这一管理平面功能。
常见误区:盲目重启与局部修复
初期团队反应是典型的“救火模式”:
- 运维同学重启了后端服务,问题暂时消失;
- 开发同学检查了 MySQL 数据,确认模型记录存在;
- DBA 确认权限表同步无异常;
- 缓存团队表示 Redis 内存充足,无淘汰策略触发。
这种处理方式掩盖了真正的问题:我们无法判断故障点是“数据未加载”还是“加载后被过滤”。更严重的是,由于缺乏细粒度指标,我们甚至不知道请求是否到达了数据库层。
另一个误区是过度依赖“缓存一致性”假设。团队一度认为只要保证 MySQL 与 Redis 双写,问题就不会重现。但现实中,缓存重建过程本身可能因权限上下文丢失而导致空结果被缓存——这正是后续复现的关键诱因。
正确做法:构建可观测性驱动的决策链
我们重新设计了排查路径,核心原则是:先定位“是否执行了查询”,再判断“为何返回空”。为此,我们在关键节点插入了四类可观测性信号:
1. 请求链路追踪(Tracing)
在 OpenTelemetry 中标记关键 span:
cache.hit/cache.missdb.query.start/db.query.endpermission.filter.before/permission.filter.after
通过 trace 可视化,我们发现:在故障时段,大量请求在cache.miss后未触发db.query.start,而是直接返回空数组。这说明问题不在数据库,而在缓存重建逻辑。
2. 缓存重建上下文审计(Audit Log)
我们新增了一个审计日志表model_cache_rebuild_log,记录每次缓存重建的上下文:
- 重建触发时间
- 执行重建的用户 ID(或系统任务 ID)
- 使用的权限上下文(租户 ID、角色、可见性策略版本)
- 重建前后的缓存键值快照
日志显示:某次夜间权限策略批量更新任务,以系统账号(无租户上下文)触发了缓存重建。由于权限过滤依赖租户信息,系统账号无法通过过滤条件,导致重建出的缓存值为空数组,并被后续所有用户请求命中。
3. 指标驱动的状态机监控(Metrics)
我们在 Prometheus 中定义了三个关键指标:
# 缓存命中率(按用户租户分组) ai_backend_model_cache_hit_ratio{tenant="*"} # 数据库查询次数(区分缓存命中/未命中) ai_backend_model_db_query_total{source="cache_miss"} # 权限过滤后结果为空的比例 ai_backend_model_empty_after_filter_ratio当empty_after_filter_ratio突增且伴随db_query_total下降时,即可判定为“空缓存污染”而非“数据丢失”。
4. 管理后台可视化决策面板
我们在 Grafana 中构建了“模型列表健康度”看板,包含:
- 缓存命中率趋势图
- 空结果请求占比(按租户)
- 缓存重建任务审计日志摘要
- 权限策略变更事件时间线
运维人员可通过该面板在 5 分钟内判断:是全局缓存问题?租户隔离问题?还是权限策略变更副作用?
工程细节:治理策略的落地实现
基于以上分析,我们实施了四项治理改进:
(1)缓存重建强制上下文绑定
修改缓存重建逻辑,禁止无租户上下文的系统任务直接写入用户缓存。系统任务仅可写入“全局只读缓存”,用户请求仍走租户隔离路径。
// 伪代码:缓存写入前校验上下文 if (cacheKey.startsWith("user:") && currentUser.getTenantId() == null) { throw new IllegalStateException("Cannot write user-scoped cache without tenant context"); }(2)空结果缓存自动熔断
当数据库查询返回非空,但权限过滤后为空时,不缓存该结果,并触发告警。避免“合法数据因临时权限配置错误被缓存为空”。
(3)权限变更联动缓存失效
在权限策略更新时,主动失效相关租户的模型缓存,而非依赖 TTL。通过监听权限变更事件,调用缓存清除接口:
POST /internal/cache/invalidate {"tenant_id": "t_123", "resource_type": "model_list"}(4)管理后台增加“缓存状态”调试页
为运维人员提供一键查看当前缓存内容、重建历史、权限上下文的能力,支持手动触发重建或强制刷新。
风险与边界
- 性能代价:审计日志和细粒度指标会增加约 5% 的写入开销,但通过异步写入和采样策略可控。
- 权限模型耦合:当前方案强依赖租户隔离模型,若未来支持跨租户共享模型,需扩展上下文传递机制。
- 缓存一致性窗口:权限变更到缓存失效存在秒级延迟,需在产品层面提示用户“变更可能延迟生效”。
总结:从救火到治理的范式转移
这次故障的本质,不是某个代码 bug,而是管理平面缺乏可观测性治理。我们过去习惯于“服务可用就行”,却忽视了管理后台作为运维决策中枢的重要性。当用户看不到模型列表时,他们无法判断是“模型没了”还是“我看不到”,这种不确定性会迅速演变为信任危机。
真正的解决方案,不是修复某个缓存逻辑,而是建立一套面向决策的可观测性体系:
- 用 tracing 定位“有没有查”;
- 用 audit log 回答“为什么查出来是空”;
- 用 metrics 实现“早发现、早预警”;
- 用可视化面板降低运维认知负荷。
在 AI 工程落地中,模型推理只是冰山一角,水面下的管理、监控、治理才是决定系统能否长期稳定运行的关键。这一次,我们从一条空列表的请求链路,走到了可观测性驱动的治理决策框架。
技术补丁包
缓存重建上下文强制校验 原理:在写入用户隔离缓存前,校验当前执行上下文是否包含有效租户信息,防止系统任务污染用户缓存。 设计动机:避免无权限上下文的后台任务生成空缓存,导致用户请求误判为“无模型”。 边界条件:仅适用于租户隔离场景;全局共享缓存需单独处理。 落地建议:在缓存写入层增加前置拦截器,结合 Spring Security 或自定义上下文 holder 实现。
空结果缓存自动熔断机制 原理:当数据库查询返回非空结果,但经权限过滤后为空时,禁止缓存该空结果,并触发告警。 设计动机:防止因临时权限配置错误或上下文丢失导致“合法数据被缓存为空”。 边界条件:需区分“真无数据”与“假无数据”(即过滤后为空但源数据存在)。 落地建议:在数据访问层添加 post-filter 校验逻辑,结合 Prometheus Counter 记录熔断事件。
权限变更事件驱动缓存失效 原理:监听权限策略变更事件,主动失效受影响租户的模型列表缓存,而非依赖 TTL 被动过期。 设计动机:缩短权限变更到缓存生效的时间窗口,提升管理操作实时性。 边界条件:需确保事件发布可靠性,避免因消息丢失导致缓存未失效。 落地建议:使用可靠消息队列(如 Kafka)传递权限变更事件,消费端实现幂等缓存清除。
管理后台缓存状态调试面板 原理:提供可视化界面展示当前缓存内容、重建历史、权限上下文及手动操作入口。 设计动机:降低运维人员排查成本,避免频繁查日志或连数据库。 边界条件:需严格限制访问权限,防止敏感信息泄露。 落地建议:基于 Grafana 或自研管理端实现,集成缓存查询、日志检索、手动刷新功能。