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
| WHERE userId = 'user123' OR isPublic = true OR orgTag IN ('tag1','tag2')
WHERE file_md5 = 'abc123' AND user_id = 'user123'
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
|
CREATE INDEX idx_file_md5_user ON file_upload (file_md5, user_id);
CREATE INDEX idx_status_created ON file_upload (status, created_at);
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'
❌ 用不上索引的查询: 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
| 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 { @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
| @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 @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); }
@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); }
@Bean public Timer searchTimer(MeterRegistry registry) { return Timer.builder("knowflow_search_duration_seconds") .description("Search request duration") .publishPercentiles(0.5, 0.95, 0.99) .register(registry); }
@Bean public Counter kafkaProcessedCounter(MeterRegistry registry) { return Counter.builder("knowflow_kafka_messages_processed_total") .description("Total Kafka messages processed") .register(registry); }
@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; } }
|
5.3 在业务代码里上报指标
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public List<SearchResult> searchWithPermission(String query, String userId, int topK) { return searchTimer.recordCallable(() -> { return results; }); }
@KafkaListener(topics = "file-processing") public void processTask(FileProcessingTask task) { kafkaProcessedCounter.increment(); }
|
5.4 Prometheus 配置(让 Prometheus 来拉取指标)
1 2 3 4 5 6 7
| scrape_configs: - job_name: 'knowflow' scrape_interval: 15s metrics_path: '/actuator/prometheus' 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
| groups: - name: knowflow_alerts rules: - alert: SearchSlow expr: histogram_quantile(0.99, knowflow_search_duration_seconds) > 1 for: 2m annotations: summary: "搜索接口 P99 超过 1 秒!"
- alert: KafkaLagHigh expr: knowflow_kafka_consumer_lag > 1000 for: 5m annotations: summary: "Kafka 消息堆积严重,请检查消费者!"
- 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 + Thanos 或 Prometheus + VictoriaMetrics 做高可用,数据有多副本。
八、简历上怎么讲这句话(可直接用)
原句参考:”针对高并发检索场景设计复合索引,并引入 @Cacheable 配合全局异常统一接管,提升系统鲁棒性;同时构建完善的业务可观测性闭环,集成 Micrometer + Prometheus 自定义核心埋点,为线上排障提供数据支撑。”
面试时被问到,按这个顺序讲:
- 先说复合索引:ES 的 keyword 类型设计 + MySQL 最左前缀原则;
- 再说缓存:
@Cacheable + Redis 缓存用户组织标签,减少数据库查询;
- 再说全局异常:
@RestControllerAdvice 统一拦截,错误格式标准化,生产环境不泄露堆栈;
- 最后说可观测性:自定义 4 类指标(Counter/Timer/Gauge),Grafana 面板 + 告警规则,出问题能快速定位。
© 2026 KnowFlow 面试手册 · 转载请注明出处