KnowFlow ③:复合索引 + @Cacheable + 全局异常处理 + Prometheus 可观测性(面试深挖)

KnowFlow ③:复合索引 + @Cacheable + 全局异常处理 + Prometheus 可观测性

项目:KnowFlow 企业级 RAG 知识库系统 | 整理时间:2026-06-07 | 适用于后端实习/校招面试


一、为什么要做”可观测性”?(面试必问)

1.1 没有可观测性的世界

1
2
3
4
5
6
7
用户投诉:"搜索怎么这么慢?"

你:哪次慢?哪个接口慢?慢在哪里?

翻日志 → 日志太多找不到 → 猜是 ES 慢 → 重启试试

用户又投诉:"又不行了!"

这就是没有可观测性的下场 —— 系统是个黑盒,出了问题全靠猜。

1.2 可观测性的三大支柱

支柱 解决什么问题 KnowFlow 用的工具
Metrics(指标) 系统健康状况、QPS、耗时分布 Micrometer + Prometheus
Logging(日志) 出了什么错、上下文是什么 SLF4J + Logback
Tracing(链路追踪) 一个请求经过了哪些服务、各花了多久 (KnowFlow 暂无,可扩展)

二、复合索引设计(高并发检索场景)

2.1 哪些查询需要索引?

先看 KnowFlow 里最常见的查询场景:

1
2
3
4
5
6
7
8
-- 场景 1:权限过滤(每个搜索请求都会执行!)
WHERE userId = 'user123' OR isPublic = true OR orgTag IN ('tag1','tag2')

-- 场景 2:按 MD5 查文件上传记录(分片上传时频繁调用)
WHERE file_md5 = 'abc123' AND user_id = 'user123'

-- 场景 3:按文件名查公开文件(下载/预览时用)
WHERE file_name = 'manual.pdf' AND is_public = true

2.2 ES 索引的字段设计

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"mappings": {
"properties": {
"textContent": { "type": "text", "analyzer": "ik_max_word" },
"vector": { "type": "dense_vector", "dims": 2048 },
"fileMd5": { "type": "keyword" },
"chunkId": { "type": "keyword" },
"userId": { "type": "keyword" }, // ← 要建索引!
"orgTag": { "type": "keyword" }, // ← 要建索引!
"public": { "type": "boolean" } // ← 要建索引!
}
}
}

⚠️ 面试坑text 类型不能用于精确过滤!userId 如果是 text 类型,term 查询会失效(因为被分词了)。必须要用 keyword 类型。

2.3 MySQL 复合索引设计

1
2
3
4
5
6
7
8
9
10
-- file_upload 表(记录文件上传状态)
-- 高频查询:findByFileMd5AndUserId
CREATE INDEX idx_file_md5_user ON file_upload (file_md5, user_id);

-- 高频查询:按状态查未完成的上传(定时清理任务)
CREATE INDEX idx_status_created ON file_upload (status, created_at);

-- chunk_info 表(记录分片信息)
-- 高频查询:findByFileMd5OrderByChunkIndexAsc
CREATE INDEX idx_file_md5_chunk ON chunk_info (file_md5, chunk_index);

复合索引的最左前缀原则(面试必问):

1
2
3
4
5
6
7
8
9
索引:(file_md5, user_id)

✅ 能用上索引的查询:
WHERE file_md5 = 'abc123' AND user_id = 'user123'
WHERE file_md5 = 'abc123' -- 只用到了 file_md5 部分

❌ 用不上索引的查询:
WHERE user_id = 'user123' -- 没用到最左前缀!
WHERE file_md5 LIKE '%abc%' -- 模糊匹配在开头,索引失效

三、@Cacheable 缓存设计(提升响应速度)

3.1 哪些数据适合缓存?

1
2
3
4
5
6
7
8
✅ 适合缓存:
- 用户信息(不经常变,频繁查询)
- 组织标签关系(几乎不变,每次搜索都要查)
- 文件元数据(上传完后基本不变)

❌ 不适合缓存:
- 搜索结果(每个人搜的不一样,缓存命中率低)
- 实时统计数据(要求强一致,不能缓存)

3.2 源码解析:OrgTagCacheService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 缓存用户组织标签(24 小时过期)
public void cacheUserOrgTags(String username, List<String> orgTags) {
String key = "user:org_tags:" + username;
redisTemplate.opsForList().rightPushAll(key, orgTags.toArray());
redisTemplate.expire(key, 24, TimeUnit.HOURS);
}

// 获取时先查缓存,没有再查数据库
public List<String> getUserOrgTags(String username) {
String key = "user:org_tags:" + username;
List<Object> cached = redisTemplate.opsForList().range(key, 0, -1);
if (cached != null && !cached.isEmpty()) {
return cached.stream().map(Object::toString).toList();
}
// 缓存未命中 → 查数据库 → 写入缓存
...
}

3.3 Spring @Cacheable 的使用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class UserService {

// Spring 会自动把返回值缓存到 Redis
// cacheNames = "users", key = 方法参数(这里是 userId)
@Cacheable(cacheNames = "users", key = "#userId")
public User getUserById(Long userId) {
return userRepository.findById(userId).orElse(null);
}

// 更新用户后,自动失效缓存
@CacheEvict(cacheNames = "users", key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user);
}
}

Redis Key 设计规范(面试会问):

1
2
3
4
5
6
7
8
9
❌ 糟糕的 Key 设计:
user:123 ← 不知道是什么业务
data_abc ← 单词缩写,不清楚含义
* ← 用通配符当 key(会撞车!)

✅ 好的 Key 设计(KnowFlow 项目用的规范):
user:org_tags:zhangshan ← 业务:子业务:具体ID
user:primary_org:lishi ← 一目了然
knowflow:token:abc123def ← 加项目前缀,防止 key 冲突

3.4 缓存穿透、击穿、雪崩(面试八股文必问!)

问题 原因 KnowFlow 的解决方案
缓存穿透 查询一个不存在的数据,每次都打到数据库 布隆过滤器(规划中);或者缓存空值(TTL 设短一点)
缓存击穿 一个热 Key 过期瞬间,大量请求打到数据库 synchronized 本地锁 + 双重检查;或者用 Redis 分布式锁
缓存雪崩 大量 Key 同时过期,数据库瞬间被打爆 过期时间加随机值(比如 24h + random(0~3600)s)

四、全局异常处理(系统的安全网)

4.1 没有全局异常处理的灾难

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ❌ 糟糕的写法:每个接口都写 try-catch
@RestController
public class DocumentController {

@GetMapping("/documents/{id}")
public ResponseEntity<?> getDocument(@PathVariable String id) {
try {
Document doc = documentService.get(id);
return ResponseEntity.ok(doc);
} catch (DocumentNotFoundException e) {
return ResponseEntity.status(404).body("文档不存在");
} catch (AccessDeniedException e) {
return ResponseEntity.status(403).body("无权限");
} catch (Exception e) {
return ResponseEntity.status(500).body("服务器错误");
}
}
// 每个接口都这样写 → 代码重复到死 😡
}

4.2 @RestControllerAdvice 一键接管所有异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// ✅ 项目中的写法:一个类搞定所有异常
@RestControllerAdvice // ← 这个注解会让它影响所有 @RestController
@Slf4j
public class GlobalExceptionHandler {

// 自定义业务异常
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(
CustomException ex, HttpServletRequest request) {
log.error("CustomException: {}", ex.getMessage(), ex);
ErrorResponse error = ErrorResponse.builder()
.code(ex.getStatus().value())
.message(ex.getMessage())
.timestamp(LocalDateTime.now())
.path(request.getRequestURI())
.build();
return ResponseEntity.status(ex.getStatus()).body(error);
}

// 参数校验失败(@Valid 注解触发的)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(...) { ... }

// 权限不足
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(...) { ... }

// 兜底:所有没被上面捕获的异常
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(...) {
// 生产环境不返回堆栈信息(安全考虑)
// 开发环境可以返回堆栈(方便调试)
}
}

4.3 统一错误响应格式(前后端协作的规范)

1
2
3
4
5
6
7
8
9
10
11
12
// ✅ 所有错误都返回这个格式,前端处理起来很方便
{
"code": 403,
"message": "没有权限删除此文档",
"timestamp": "2026-06-07T21:30:00",
"path": "/api/v1/documents/abc123",
"details": {
"fieldErrors": {
"orgTag": "组织标签不存在"
}
}
}

💡 面试回答话术:”我们用 @RestControllerAdvice 做全局异常处理,所有 Controller 的异常都会在这里统一拦截。好处是:第一,不用每个接口写 try-catch,代码简洁;第二,错误响应格式统一,前端处理方便;第三,生产环境自动隐藏堆栈信息,避免敏感信息泄露。”


五、Prometheus + Micrometer 自定义埋点(面试亮点)

5.1 为什么需要自定义业务指标?

Spring Boot Actuator 自带了很多系统指标(JVM 内存、GC、线程数等),但业务指标要靠自己埋点

1
2
3
4
5
6
7
8
9
10
系统指标(自带的):
- jvm_memory_used_bytes
- system_cpu_usage
- htp_server_requests_seconds_count

业务指标(我们要加的):
- knowflow_file_upload_total ← 总共上传了多少文件?
- knowflow_search_duration_seconds ← 搜索接口 P99 耗时多少?
- knowflow_kafka_messages_processed_total ← Kafka 消费了多少条消息?
- knowflow_websocket_active_connections ← 当前有多少人在线?

5.2 源码解析:MetricsConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Configuration
public class MetricsConfig {

// ★ 计数器:记录文件上传总数(只增不减)
@Bean
public Counter fileUploadCounter(MeterRegistry registry) {
return Counter.builder("knowflow_file_upload_total")
.description("Total number of file uploads")
.tag("type", "upload")
.register(registry); // 注册到 Prometheus
}

// ★ 计时器:记录搜索耗时分布(P50/P95/P99)
@Bean
public Timer searchTimer(MeterRegistry registry) {
return Timer.builder("knowflow_search_duration_seconds")
.description("Search request duration")
.publishPercentiles(0.5, 0.95, 0.99) // ← P50/P95/P99 分位数
.register(registry);
}

// ★ 计数器:Kafka 消息处理总数
@Bean
public Counter kafkaProcessedCounter(MeterRegistry registry) {
return Counter.builder("knowflow_kafka_messages_processed_total")
.description("Total Kafka messages processed")
.register(registry);
}

// ★ 仪表盘:当前 WebSocket 活跃连接数(可实时查看)
@Bean
public AtomicInteger activeWebSocketConnections(MeterRegistry registry) {
AtomicInteger connections = new AtomicInteger(0);
Gauge.builder("knowflow_websocket_active_connections",
connections, AtomicInteger::get)
.description("Number of active WebSocket connections")
.register(registry);
return connections; // ← 把这个对象暴露出去,WebSocket 连接/断开时修改它
}
}

5.3 在业务代码里上报指标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 搜索接口:用 Timer 记录耗时
public List<SearchResult> searchWithPermission(String query, String userId, int topK) {
return searchTimer.recordCallable(() -> {
// 搜索逻辑...
return results;
});
}

// Kafka 消费:处理成功后 +1
@KafkaListener(topics = "file-processing")
public void processTask(FileProcessingTask task) {
// 处理逻辑...
kafkaProcessedCounter.increment(); // ← 埋点!
}

5.4 Prometheus 配置(让 Prometheus 来拉取指标)

1
2
3
4
5
6
7
# prometheus.yml
scrape_configs:
- job_name: 'knowflow'
scrape_interval: 15s
metrics_path: '/actuator/prometheus' # ← Spring Boot 暴露指标的端点
static_configs:
- targets: ['localhost:8080']

💡 面试追问:”Counter 和 Gauge 有什么区别?”
答:Counter 只增不减(比如请求总数、错误总数);Gauge 可增可减(比如当前在线人数、当前队列长度)。Timer 是特殊的 Counter,专门记录耗时分布。


六、Grafana 可视化(让数据说话)

6.1 核心监控面板设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌───────────────────────────────────────────────┐
│ KnowFlow 系统监控面板 │
├───────────────────────────────────────────────┤
│ QPS(每秒请求数) │
│ ▁▂▃▄▅▆▇▆▅▄▃▂▁ (折线图) │
├───────────────────────────────────────────────┤
│ 搜索接口 P99 耗时 │
│ ▁▂▃▄▅ 平均 120ms, P99 380ms (热力图) │
├───────────────────────────────────────────────┤
│ 文件上传成功率 │
│ ████████████████░░ 98.5% (条形图) │
├───────────────────────────────────────────────┤
│ Kafka 消费 Lag(消息堆积量) │
│ ▁▂▃ 当前 Lag = 12 条 (折线图) │
├───────────────────────────────────────────────┤
│ 当前在线用户数(WebSocket 连接数) │
│ ▁▂▃▄▅▆ 23 人 (实时数字) │
└───────────────────────────────────────────────┘

6.2 告警规则(出问题自动通知你)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# alert_rules.yml
groups:
- name: knowflow_alerts
rules:
# 规则 1:搜索 P99 超过 1 秒,告警!
- alert: SearchSlow
expr: histogram_quantile(0.99, knowflow_search_duration_seconds) > 1
for: 2m
annotations:
summary: "搜索接口 P99 超过 1 秒!"

# 规则 2:Kafka 消息堆积超过 1000 条,告警!
- alert: KafkaLagHigh
expr: knowflow_kafka_consumer_lag > 1000
for: 5m
annotations:
summary: "Kafka 消息堆积严重,请检查消费者!"

# 规则 3:文件上传失败率超过 5%,告警!
- alert: UploadFailureRateHigh
expr: rate(knowflow_file_upload_errors_total[5m])
/ rate(knowflow_file_upload_total[5m]) > 0.05
for: 3m
annotations:
summary: "文件上传失败率超过 5%!"

七、面试八股文高频题

Q1:Spring 的 @Cacheable 原理是什么?

:Spring 用 AOP 实现。方法被调用时,AOP 拦截器先查缓存(Key 是方法签名 + 参数),缓存中有就直接返回,不执行方法体;没有就执行方法,把返回值写入缓存。底层用的是 CacheManager,可以接入 Redis、Caffeine 等实现。

Q2:Prometheus 的 Pull 模型和推模型(Push)比,有什么优劣?

:Prometheus 是 Pull 模型(服务端主动来拉),优势是:① 服务挂了,Prometheus 能感知到(拉不到就是挂了);② 配置集中管理,不用在每个服务里配置推送地址。劣势是:短生命周期的任务(比如批处理脚本)来不及被 Pull,需要用 Pushgateway 辅助。

Q3:Micrometer 的 Timer 是怎么记录 P99 的?

:底层用的是 HDR Histogram(高动态范围直方图),用压缩算法把耗时分布存储在一个固定大小的数据结构里,可以高效计算 P50/P95/P99 等分位数,内存占用很稳定(不会因为请求多而暴涨)。

Q4:全局异常处理能不能捕获 Filter 里的异常?

@RestControllerAdvice 只能捕获 Controller 层 抛出的异常。Filter 里的异常(比如 JwtAuthenticationFilter 里解析 Token 失败)不会经过 Controller,所以不会被全局异常处理器捕获。解决方法:在 Filter 里自己 try-catch,或者把异常包装成 AuthenticationException 抛给 Spring Security 处理。

Q5:Prometheus 的指标数据存在哪里?会不会丢数据?

:Prometheus 是时序数据库(TSDB),数据存在本地磁盘(默认 /prometheus 目录)。如果磁盘坏了会丢数据。生产环境一般用 Prometheus + ThanosPrometheus + VictoriaMetrics 做高可用,数据有多副本。


八、简历上怎么讲这句话(可直接用)

原句参考:”针对高并发检索场景设计复合索引,并引入 @Cacheable 配合全局异常统一接管,提升系统鲁棒性;同时构建完善的业务可观测性闭环,集成 Micrometer + Prometheus 自定义核心埋点,为线上排障提供数据支撑。”

面试时被问到,按这个顺序讲:

  1. 先说复合索引:ES 的 keyword 类型设计 + MySQL 最左前缀原则;
  2. 再说缓存@Cacheable + Redis 缓存用户组织标签,减少数据库查询;
  3. 再说全局异常@RestControllerAdvice 统一拦截,错误格式标准化,生产环境不泄露堆栈;
  4. 最后说可观测性:自定义 4 类指标(Counter/Timer/Gauge),Grafana 面板 + 告警规则,出问题能快速定位。

© 2026 KnowFlow 面试手册 · 转载请注明出处


KnowFlow ③:复合索引 + @Cacheable + 全局异常处理 + Prometheus 可观测性(面试深挖)
https://whyalwaysme.lol/2026/06/07/2026-06-07-knowflow-observability/
作者
Cassiur
发布于
2026年6月7日
许可协议