KnowFlow 项目 —— 30+ 道面试八股文(字节实习向)

KnowFlow 项目 —— 30+ 道面试八股文

面向字节跳动 Java 后端暑期实习面试,每道题都结合 KnowFlow 项目源码讲清楚,力求”傻子都能懂”。


一、Redis 相关(6 道)

Q1:你们项目里用 Redis 存了什么?Bitmap 是什么,为什么用它来做分片上传?

答:

我们项目里 Redis 主要有两个用途:

  1. 存文件分片上传状态 —— 用 Bitmap
  2. 存用户有效组织标签缓存 —— 用 Set 或 List

Bitmap 是什么?

Bitmap 就是用每一个 bit 位来表示一个状态,极其省内存。

1
2
3
4
5
6
7
8
比如一个 500MB 的文件,分成 100 个分片(每个 5MB)
如果用普通方式记录哪些分片已上传:
- 用 Set 存已上传的分片索引:{0, 1, 2, ..., 99}
- 每个数字按字符串存,大约 3~5 字节,100 个就是 300~500 字节

用 Bitmap:
- 100 个 bit = 100/8 = 12.5 字节!
- 即使文件分成 1000 个分片,也只要 125 字节

为什么用 Bitmap 记录分片上传状态?

  1. 极其省内存:10 万个分片只要 12.5KB,如果用数据库存,10 万行记录可能要 10MB+
  2. O(1) 查询某个分片是否已上传GETBIT key offset
  3. 支持批量操作:可以一次性把 Bitmap 的字节数组取出来,在本地解析出所有已上传的分片

源码依据UploadService.java 第 344-361 行,isChunkUploaded() 方法用 redisTemplate.opsForValue().getBit() 检查分片状态;第 370-385 行,markChunkUploaded()setBit() 标记分片已上传。


Q2:Redis Bitmap 的底层实现原理是什么?

答:

Redis 的 Bitmap 不是一种独立的数据结构,它其实是对 String 类型做位操作

1
2
3
4
Redis Key: "upload:123:abc"
对应的 Value 是一个字节数组(String 的底层就是字节数组):
字节0 字节1 字节2
[bit0|bit1|...|bit7][bit8|...]...

底层原理

  • SETBIT key offset value:把 offset 那个 bit 设为 0 或 1
    • 如果 offset 超出了当前 String 的长度,Redis 会自动扩容(补 x00
  • GETBIT key offset:读取 offset 那个 bit 的值
  • BITCOUNT key:统计有多少個 bit 是 1(Redis 用了查表法优化,很快)
  • GET key:把整个 Bitmap 作为普通 String 取出来(可以用来批量解析)

扩容的代价:如果文件有 1000 个分片,Bitmap 需要 125 字节,Redis 会自动扩容,这个过程很快,不影响性能。

应用场景

  • 用户签到(365 天只用 46 字节)
  • 布隆过滤器(我们项目没用,但这是一个经典场景)
  • 分片上传状态(我们项目用的就是这个)

Q3:如果 Redis 宕机了,你们项目的分片上传状态会怎么样?

答:

分片上传状态存在 Redis 里,如果 Redis 宕机且数据没持久化,会丢失。

我们的处理方案

  1. 分片元数据同时存数据库ChunkInfo 表记录了每个分片的信息(分片索引、MD5、存储路径),即使 Redis 里的 Bitmap 丢了,也可以从数据库重新构建 Bitmap
1
2
3
4
5
// 恢复 Bitmap 的逻辑(伪代码)
List<ChunkInfo> chunks = chunkInfoRepository.findByFileMd5OrderByChunkIndexAsc(fileMd5);
for (ChunkInfo chunk : chunks) {
redisTemplate.opsForValue().setBit(redisKey, chunk.getChunkIndex(), true);
}
  1. MinIO 里的分片文件本身不会丢:Redis 只是记录”哪些分片已上传”,真正的分片文件存在 MinIO 里,Redis 宕机不影响 MinIO 里的文件

  2. 用户重新上传时会有提示:前端调用 getUploadedChunks() 接口,发现 Redis 里没有记录,会返回”请重新上传”或自动从数据库恢复状态

面试加分:如果要做到 Redis 宕机不丢数据,可以开启 AOF 持久化appendonly yes),或者做 Redis 主从复制


Q4:Redis 和 MinIO 在你们项目里的分工是什么?为什么不用 Redis 存文件?

答:

这个问题考察你对不同存储系统的理解。

Redis MinIO(对象存储)
定位 内存数据库,存临时状态、缓存 对象存储,存大文件
容量 受内存限制(一般几十 GB) 可以扩展到 PB 级
适用数据 小数据(KB 级) 大数据(MB/GB 级)
访问速度 微秒级 毫秒级(走网络 IO)
成本 贵(内存比磁盘贵得多) 便宜(用磁盘/SSD)

为什么不用 Redis 存文件?

  1. 太贵:500MB 的文件存 Redis,要占 500MB 内存,1000 个用户上传文件,就要 500GB 内存,成本无法接受
  2. Redis 的设计目的不是存大文件:Redis 是内存数据库,适合存频繁读写的小数据(缓存、计数、状态)
  3. MinIO 就是为存大文件设计的:分布式、高可用、支持分片上传(它自己也有分片上传功能,我们项目是在应用层自己实现的)

我们项目的分工

  • Redis:存分片上传状态(Bitmap,很小)+ 用户组织标签缓存(Set,很小)
  • MinIO:存真正的文件分片 + 合并后的完整文件

Q5:你们项目里 Redis 的 Key 是怎么设计的?有没有规范?

答:

我们项目里 Redis Key 的设计规范是:业务:用户ID:文件MD5(用冒号分隔,这是 Redis Key 设计的经典规范)。

1
2
3
4
5
6
7
分片上传状态:upload:{userId}:{fileMd5}
例如:upload:123:abcde12345...

为什么这样设计?
1. 可读性好:一看就知道这个 Key 是干嘛的
2. 方便批量操作:可以用 `KEYS upload:123:*` 或 `SCAN` 找出某个用户的所有上传记录
3. 方便设置统一过期时间:`EXPIRE upload:123:abc 86400`(24 小时后自动清理,防止 Redis 内存泄漏)

Redis Key 设计的几个原则(面试加分):

  1. 用冒号分隔业务:子业务:ID,可读性好
  2. 不要过长:Redis Key 也是要占内存的,但也不要过于简短(可读性更重要)
  3. 避免冲突:加业务前缀(我们项目用了 upload: 前缀)
  4. 设置过期时间:防止内存泄漏(我们项目里分片上传的 Bitmap 应该在文件合并后删除,看代码第 393-403 行 deleteFileMark() 方法确实删除了)

Q6:Redis 的过期键删除策略有哪几种?你们项目用到了吗?

答:

Redis 的过期键删除有两种策略,同时配合使用

策略 原理 优点 缺点
惰性删除 访问 Key 时才检查是否过期,过期就删除 对 CPU 友好(不主动扫描) 过期 Key 如果不被访问,会一直占内存
定期删除 每隔一段时间,随机抽查一批 Key,删除过期的 防止过期 Key 堆积 抽查可能漏掉一些过期 Key

你们项目用到了吗?

用到了。分片上传的 Bitmap Key,应该在文件合并后主动删除(我们项目代码第 632-635 行确实调用了 deleteFileMark())。

但如果删除逻辑失败了(比如删除时网络抖了一下),这个 Key 就会一直留在 Redis 里。所以最好给 Key 设置一个过期时间作为兜底

1
2
3
// 在标记分片已上传时,同时设置过期时间(24 小时)
redisTemplate.opsForValue().setBit(redisKey, chunkIndex, true);
redisTemplate.expire(redisKey, 24, TimeUnit.HOURS); // ⚠️ 代码里没写这行!可以提优化点

面试加分:如果你发现项目里没有设置过期时间,这就是一个可以提的优化点(说明你不仅会做,还会思考边界情况)。


二、Elasticsearch 相关(5 道)

Q7:你们项目里的混合检索是怎么做的?为什么既要向量检索又要 BM25?

答:

这是 RAG(检索增强生成)系统的核心问题。

单纯向量检索的问题

  • 向量检索靠的是”语义相似度”,比如用户搜”MySQL 同步慢怎么查?”,向量检索能找到”MySQL 主从延迟排查思路”(语义相近)
  • 但向量检索不精确:如果用户搜”Spring Boot 3.2 新特性”,向量检索可能把”Spring Boot 2.7 新特性”也召回了,因为语义相近,但用户要的是精确匹配版本号

单纯 BM25 的问题

  • BM25 是关键词检索,精确匹配能力强
  • 但 BM25 不理解语义:用户搜”MySQL 同步慢怎么查?”,如果文档里写的是”主从延迟过高排查”,BM25 召回不了(因为没有一个词是匹配的)

所以两个都要

  1. 先用向量检索粗召回(语义相似,召回面广)
  2. 再用 BM25 重排序(精确匹配高的排前面)
  3. 用 RRF(倒数秩融合)把两个结果合并

源码依据HybridSearchService.java 第 91-140 行,先用 KNN 向量检索召回 topK*30 个候选,再用 rescore 做 BM25 重排序,最后返回 topK 个结果。


Q8:Elasticsearch 的 KNN 检索原理是什么?倒排索引和向量索引的区别?

答:

倒排索引(用于 BM25 关键词检索):

1
2
3
4
5
6
7
8
9
文档1"MySQL 主从复制很慢"
文档2"Redis 主从同步很快"

倒排索引:
"MySQL" → [文档1]
"主从" → [文档1, 文档2]
"复制" → [文档1]
"Redis" → [文档2]
...

查询时,把查询文本也分词,然后去倒排索引里找哪些文档包含这些词,再用 TF-IDF 或 BM25 算法算分。

向量索引(用于 KNN 语义检索):

1
2
3
每个文档切块后,用一个 2048 维的向量表示:
文档1 块1:"MySQL 主从复制..." → [0.12, -0.34, ..., 0.78](2048 维)
文档2 块3:"Redis 主从同步..." → [0.08, -0.29, ..., 0.81](2048 维)

查询时,把查询文本也转成 2048 维向量,然后计算余弦相似度(或欧氏距离),找出最相似的 K 个向量。

ES 里向量索引的底层:用的是 HNSW(Hierarchical Navigable Small World) 图算法,类似跳表的思想,查询复杂度大约是 O(log N),比暴力扫描快得多。

为什么不用暴力扫描? 如果有 100 万个球块,每个 2048 维,暴力扫描要算 100 万次余弦相似度,太慢了。HNSW 可以快 100~1000 倍。


Q9:RRF(倒数秩融合)是什么?为什么用它来合并结果?

答:

RRF 是一种把多个排序结果合并成一个的算法,公式很简单:

1
RRF(d) = Σ (1 / (k + rank_i(d)))
  • d 是某个文档块
  • rank_i(d) 是第 i 种检索方法里,文档 d 的排名(从 1 开始)
  • k 是一个常数,通常取 60(防止排名第一的文档主导整个结果)

举个例子

1
2
3
4
5
6
7
8
9
10
向量检索结果: [A(1),B(2),C(3)]
BM25 检索结果: [B(1),C(2),D(3)]

RRF 计算:
A = 1/(60+1) + 0 = 0.0164
B = 1/(60+2) + 1/(60+1) = 0.0162 + 0.0164 = 0.0326 ← 最高分
C = 1/(60+3) + 1/(60+2) = 0.0160 + 0.0162 = 0.0322
D = 0 + 1/(60+3) = 0.0160

最终排序:[B,C,A,D]

为什么用 RRF 而不是其他融合方法?

  1. 不依赖分数绝对值:向量检索的分数范围和 BM25 的分数范围不一样,不能直接相加,RRF 只用排名,不受分数范围影响
  2. 简单有效:业界标准方法,ES 内置支持(rescore 阶段就可以配置 RRF)

Q10:你们项目里 ES 的索引 Mapping 是怎么设计的?有没有用嵌套类型?

答:

我们项目里 ES 的索引叫 knowledge_base,存储的是文档切块后的文本块

Mapping 设计(根据 EsDocument.java 实体类推断):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"mappings": {
"properties": {
"fileMd5": { "type": "keyword" }, // 文件 MD5(精确匹配)
"chunkId": { "type": "keyword" }, // 块 ID(精确匹配)
"textContent": { "type": "text", "analyzer": "ik_max_word" }, // 文本内容(分词,用于 BM25)
"vector": { "type": "dense_vector", "dims": 2048 }, // 向量(2048 维)
"userId": { "type": "long" }, // 上传用户 ID(用于权限过滤)
"orgTag": { "type": "keyword" }, // 组织标签(用于权限过滤)
"public": { "type": "boolean" }, // 是否公开(用于权限过滤)
"fileName": { "type": "keyword" } // 文件名(用于展示)
}
}
}

为什么 textContentik_max_word 分词器?

  • 默认的标准分词器对中文不友好(会分成单个字)
  • IK 分词器是专门为中文设计的,能把”MySQL 主从复制”分成”MySQL”、”主从”、”复制”

为什么 orgTagkeyword 而不是 text

  • keyword 类型用于精确匹配(比如 term 查询)
  • text 类型用于全文检索(会分词)
  • 组织标签是用来精确过滤的(查询时必须完全匹配 “org_a”,不能匹配 “org_a_b” 的部分),所以用 keyword

Q11:ES 的权限过滤是怎么实现的?你们项目里怎么保证用户只能搜到自己有权限的文档?

答:

这是我们项目的一个重要设计。ES 的权限过滤是在查询阶段做的,不是在建索引阶段。

权限规则(根据 HybridSearchService.java 第 102-123 行):

  1. 用户自己的文档 → 有权限
  2. 公开文档public=true)→ 有权限
  3. 用户所属组织的文档 → 有权限(通过 orgTag 判断,还支持组织层级关系

ES 查询里的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 构建 Bool 查询
.bool(b -> b
.must(m -> m.match(m2 -> m2.field("textContent").query(query))) // 关键词必须匹配
.filter(f -> f.bool(b2 -> b2
.should(s1 -> s1.term(t -> t.field("userId").value(userDbId))) // 条件1:自己的
.should(s2 -> s2.term(t -> t.field("public").value(true))) // 条件2:公开的
.should(s3 -> {
// 条件3:所属组织的(支持多个组织标签)
if (userEffectiveTags.size() == 1) {
return s3.term(t -> t.field("orgTag").value(userEffectiveTags.get(0)));
} else {
// 多个标签,用 should 组合
userEffectiveTags.forEach(tag ->
innerBool.should(sh -> sh.term(t -> t.field("orgTag").value(tag))));
return innerBool;
}
})
))
)

为什么用 filter 而不是 must

  • filter 不计算相关性分数,只做是/否过滤,性能更好
  • filter 的结果可以被 ES 缓存(下次同样的 filter 直接命中缓存)

三、消息队列 Kafka 相关(4 道)

Q12:你们项目里 Kafka 是怎么用的?为什么用 Kafka 而不是 RabbitMQ?

答:

根据项目描述,我们用了 MinIO + Kafka 构建异步文档处理流水线

流程

1
2
3
4
5
6
7
8
9
文件分片上传完成 → 合并分片 → 写入 DB 记录

发消息到 Kafka"文件已上传,请处理"

消费者(异步):
① 解析文档(PDF/Word/Excel → 文本)
② 文本切块(分成 500 字左右的块)
③ 调用 Embedding API 把每块转成 2048 维向量
④ 把向量 + 文本块写入 Elasticsearch

为什么用 Kafka 而不是 RabbitMQ?

Kafka RabbitMQ
定位 分布式流平台,高吞吐量 传统消息队列,功能丰富
吞吐量 百万级 QPS 万级 ~ 十万级 QPS
消息持久化 默认持久化(存磁盘日志) 需要手动开启持久化
消费模式 消费者主动拉取(pull) 队列主动推送(push)
适用场景 日志收集、流处理、大数据管道 业务消息、延迟队列、优先级队列

我们项目选 Kafka 的原因

  1. 文档解析 + 向量化是 CPU 密集型任务,处理时间长(一个大文件可能要几分钟),Kafka 的高吞吐量适合这种场景
  2. Kafka 的消息可以重复消费(只要 offset 不提交),如果某次向量化失败了,可以重新消费处理
  3. 文档处理顺序是重要的(同一个文件的分片要按照顺序合并),Kafka 的分区有序性可以保证同一个文件的处理顺序

Q13:Kafka 的消息丢失和重复消费怎么处理?

答:

消息丢失的三个阶段

阶段一:生产者 → Kafka(消息没到 Kafka)

  • 解决方案:开启 ACK=all(所有副本都收到才算成功)
  • 开启 重试机制retries=Integer.MAX_VALUE

阶段二:Kafka 自身(Kafka 宕机丢消息)

  • 解决方案:设置 副本数 >= 3replication.factor=3
  • 设置 min.insync.replicas=2(至少 2 个副本收到才算成功)

阶段三:Kafka → 消费者(消费者处理失败)

  • 解决方案:手动提交 offset(处理成功才提交)
  • 如果处理失败,不提交 offset,Kafka 会重新推送这条消息

重复消费的问题(Kafka 的 at-least-once 语义):

  • 消费者处理了消息,但提交 offset 之前宕机了 → Kafka 会重新推送这条消息 → 消费者又处理一次
  • 解决方案:幂等性设计
    • 每条消息带一个唯一业务 ID(比如 fileMd5
    • 消费者处理前,先查数据库”这个文件是否已经处理过了”
    • 如果处理过了,直接跳过(不重复处理)

Q14:Kafka 的分区(Partition)是什么?你们项目里怎么用的?

答:

分区是什么?

  • 一个 Kafka Topic 可以分成多个 Partition(分区)
  • 每个 Partition 是一个有序的、不可变的消息序列
  • 不同 Partition 之间无顺序保证(Partition A 的消息和 Partition B 的消息顺序无关)

为什么要分区?

  1. 并行处理:不同 Partition 可以由不同消费者并行消费(提高吞吐量)
  2. 水平扩展:Partition 可以分布在不同 Broker 上(突破单机限制)

你们项目里怎么用分区的?

  • fileMd5 作为分区键(Partition Key)
  • 这样同一个文件的所有消息都会分到同一个 Partition(保证顺序处理)
  • 不同文件的消息可以并行处理(提高吞吐量)
1
2
3
fileMd5 = "abc..."  → Partition 0
fileMd5 = "def..." → Partition 1
fileMd5 = "abc..." → Partition 0 (同一个文件,还是 Partition 0,保证顺序)

分区数量怎么设置?

  • 分区数 < 消费者数:有些消费者会空闲(浪费资源)
  • 分区数 > 消费者数:有些消费者要处理多个分区(可能瓶颈)
  • 最佳实践:分区数 = 消费者数 × (1~2)(充分利用并行性)

Q15:Kafka 的消费者组(Consumer Group)是什么?

答:

消费者组是 Kafka 的广播/单播机制

场景 配置
单播(一条消息只被一个消费者处理) 所有消费者在同一个组
广播(一条消息被所有消费者处理) 每个消费者在不同的组
1
2
3
4
5
6
7
8
9
10
11
12
Topic: "file.uploaded"3 个 Partition

消费者组 A(业务处理组):
- 消费者 A1 处理 Partition 0, 1
- 消费者 A2 处理 Partition 2
→ 一条消息只会被 A1A2 其中一个处理(单播)

消费者组 B(通知组):
- 消费者 B1 处理所有 Partition
→ 同一条消息会被 A 组和 B 组都处理(广播)
A 组负责"解析文档、向量化"
B 组负责"发通知给用户:您的文件已处理完成"

你们项目里的用法

  • 文档处理消费者 → 组名:file-processor-group(单播,保证每条消息只被处理一次)
  • 通知消费者 → 组名:notification-group(广播,和文档处理并行)

四、Spring Security + JWT 相关(5 道)

Q16:你们项目里 JWT 双令牌机制是怎么实现的?为什么需要双令牌?

答:

双令牌机制Access Token + Refresh Token

Access Token Refresh Token
用途 访问业务接口 换取新的 Access Token
有效期 短(15 分钟 ~ 1 小时) 长(7 天 ~ 30 天)
存储位置 内存(前端)或 HttpOnly Cookie HttpOnly Cookie(防 XSS)
如果泄露 危害较小(很快就过期了) 危害较大(需要支持吊销)

流程

1
2
3
4
5
6
7
8
9
10
11
12
13
用户登录

后端生成 Access Token(15 分钟有效)+ Refresh Token(7 天有效)

返回给前端:Access Token 放内存,Refresh Token 放 HttpOnly Cookie

前端每次请求业务接口,在 Header 里带 Access Token

如果 Access Token 过期了(返回 401

前端调用 "刷新令牌" 接口,带上 Refresh Token(Cookie 自动带上)

后端验证 Refresh Token 有效 → 返回新的 Access Token

为什么需要双令牌?(面试高频)

  1. 安全性:如果只有一个长期有效的 Token,一旦泄露,攻击者可以一直用。双令牌机制下,即使 Access Token 泄露了,也只在 15 分钟内有效。
  2. 用户体验:用户不用每次 15 分钟就重新登录,Refresh Token 有效期内自动刷新 Access Token,用户无感知。
  3. 可吊销性:Refresh Token 可以存在数据库里,如果发现账号异常,可以把 Refresh Token 删掉,强制用户重新登录。

源码依据SecurityConfig.java 第 75 行设置了 sessionCreationPolicy(STATELESS)(无状态,不用 Session),说明用的是 Token 机制。


Q17:JWT 的结构是什么?你们项目里 JWT 存了什么信息?

答:

JWT(JSON Web Token)的结构Header.Payload.Signature

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Header(头部):{
"alg": "HS256", // 签名算法
"typ": "JWT"
}

Payload(载荷):{
"sub": "123", // 用户 ID
"username": "zhangsan", // 用户名
"roles": ["USER"], // 角色(用于权限判断)
"exp": 1717777777, // 过期时间
"iat": 1717774177 // 签发时间
}
→ 注意:Payload 是 Base64 编码,不是加密!谁都可以解码看到内容

Signature(签名):HMAC-SHA256(
base64UrlEncode(Header) + "." + base64UrlEncode(Payload),
secretKey // 这个才是保密的!
)
→ 签名用来验证 Token 有没有被篡改

你们项目里 JWT 存了什么?(根据 JwtAuthenticationFilter.java 推断)

  • 用户 ID(sub
  • 用户名(username
  • 角色(rolesUSERADMIN
  • 过期时间(exp

安全风险

  • Payload 可以被解码:不要存敏感信息(比如密码、身份证号)
  • 签名密钥泄露:攻击者可以伪造任意 Token(所以要保管好 secretKey
  • JWT 无法主动吊销(因为是无状态的):解决方案是把有效期设短,或者用黑名单机制(Redis 存已吊销的 Token ID)

Q18:Spring Security 的过滤器链是什么?你们项目里加了哪些过滤器?

答:

Spring Security 是一个过滤器链(类似流水线,每个过滤器做一件事)。

你们项目里的过滤器链(根据 SecurityConfig.java 第 40-91 行):

1
2
3
4
5
6
请求 → 
JwtAuthenticationFilter(自定义) → 解析 JWT,设置登录状态
OrgTagAuthorizationFilter(自定义) → 检查用户有没有权限访问这个组织的资源
③ UsernamePasswordAuthenticationFilter(内置)→ 处理表单登录(我们项目没用,因为用的是 JWT)
④ AuthorizationFilter(内置) → 判断有没有权限访问这个 URL
⑤ 控制器

JwtAuthenticationFilter 做了什么?(根据文件名推断)

  1. 从请求 Header 里取 Authorization: Bearer <token>
  2. 解析 JWT,验证签名是否有效、有没有过期
  3. 从 JWT 里取出用户 ID、用户名、角色
  4. 创建一个 Authentication 对象,放进 SecurityContextHolder(这样后续控制器里可以通过 SecurityContextHolder.getContext().getAuthentication() 获取当前登录用户)

OrgTagAuthorizationFilter 做了什么?(根据文件名推断)

  1. 获取当前登录用户的组织标签(orgTag
  2. 检查用户要访问的资源(比如某个文档)的组织标签
  3. 如果用户有权限(自己的、公开的、或同组织的),放行;否则返回 403

Q19:你们项目里的 RBAC(角色权限控制)是怎么设计的?

答:

RBAC(Role-Based Access Control)的核心是:用户 → 角色 → 权限,而不是”用户 → 权限”(这样权限管理会非常混乱)。

我们项目里的 RBAC(根据 SecurityConfig.java 第 59-69 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
角色有两种:USER(普通用户)、ADMIN(管理员)

权限规则:
- 普通用户能访问:
· 文件上传/下载(自己的文件)
· 搜索(自己有权限的文档)
· 对话(自己的对话历史)

- 管理员能访问:
· 所有普通用户能访问的
· 管理员专属接口(/api/v1/admin/**)
· 知识库管理
· 系统状态监控
· 用户活动监控

多租户隔离(防止用户 A 看到用户 B 的私有文档):

  • 每个文件有一个 orgTag(组织标签)
  • 搜索时,ES 查询会自动加上权限过滤条件(只能搜到自己有权限的文档)
  • 详见 Q11 的回答

源码依据SecurityConfig.java 第 59 行 .requestMatchers("/api/v1/upload/**", ...).hasAnyRole("USER", "ADMIN"),说明普通用户和管理员都能访问上传接口。


Q20:如果 JWT 泄露了,你们项目里有什么应对措施?

答:

JWT 泄露是非常严重的安全事故,我们项目里有这几层防护:

第一层:缩短 Access Token 有效期

  • Access Token 只有 15 分钟有效期,即使泄露了,最多也被利用 15 分钟

第二层:Refresh Token 吊销机制

  • Refresh Token 存在数据库里(或者 Redis 里)
  • 如果发现账号异常(比如用户举报、IP 异常),可以把 Refresh Token 删掉
  • 这样即使 Access Token 还没过期,等它过期后,攻击者也无法换取新的 Access Token

第三层:用户主动登出

  • 登出时,后端把 Refresh Token 从数据库/Redis 里删掉
  • 这样攻击者手里的 Refresh Token 就失效了

第四层:HttpOnly Cookie 防 XSS

  • Refresh Token 存在 HttpOnly Cookie 里,JavaScript 无法读取(防止 XSS 攻击偷走 Token)

如果 JWT 还是泄露了怎么办?

  • 立即吊销:在数据库里把这个用户的 refresh_token 字段置空,强制用户重新登录
  • 通知用户:发送邮件/短信,告知账号可能异常,建议修改密码

五、MinIO 相关(3 道)

Q21:MinIO 是什么?和阿里云 OSS、AWS S3 有什么区别?

答:

MinIO 是一个开源的、兼容 S3 协议的对象存储系统,可以自己搭建(私有云),也可以用在公有云。

MinIO(自建) 阿里云 OSS / AWS S3(公有云)
部署方式 自己部署(物理机/虚拟机/K8s) 直接用云服务商的
成本 只有服务器成本(便宜) 按存储量 + 流量收费(贵)
数据控制权 数据在自己服务器上(安全) 数据在云服务商那里
维护成本 需要自己维护(运维成本) 云服务商维护(省心)
适用场景 企业内部系统(数据不能出内网) 面向公网的应用

我们项目为什么用 MinIO?

  • 企业知识库系统,文档可能包含敏感信息(不能传到公有云)
  • 用 MinIO 可以把文件存在企业自己的服务器上(数据不出内网)
  • 兼容 S3 SDK(minio-java SDK),以后如果要迁移到阿里云 OSS,代码不用改太多

MinIO 的核心概念

  • Bucket(桶):类似文件夹,用来组织对象
  • Object(对象):存储的文件,每个对象有一个唯一的键(Key)
  • 我们项目里:Bucket = uploads,对象键 = chunks/{fileMd5}/{chunkIndex}(分片)或 merged/{fileName}(合并后的文件)

Q22:你们项目里 MinIO 的分片合并是怎么做的?合并后分片会删除吗?

答:

分片合并(根据 UploadService.java 第 541-676 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
所有分片上传完成后,前端调用 /merge 接口

后端从 MinIO 里读取所有分片:
minioClient.getObject(bucket, "chunks/abc/0")
minioClient.getObject(bucket, "chunks/abc/1")
...

把分片按顺序合并成一个完整文件:
minioClient.composeObject(bucket, "merged/file.pdf", sources)
→ 注意:MinIO 支持**服务端合并**(不用把文件下载到应用服务器再合并,省网络 IO)

合并成功后,删除 MinIO 里的分片文件(第 614-629 行)

更新数据库 file_upload 表的状态为"已完成"(status=1

为什么合并后要删除分片?

  1. 节省存储空间:分片占了 5MB * N,合并后的文件也是这么大,不删的话占双倍空间
  2. 避免脏数据:如果分片不删,下次上传同一个文件(相同 MD5)时会误以为分片还在

合并失败怎么办?

  • 如果合并过程中失败了(比如某个分片读不出来),会抛异常,前端可以重试合并操作
  • MinIO 的分片文件不会被自动删除(因为只有合并成功才执行删除),所以重试时可以重新合并

Q23:MinIO 怎么保证文件不丢?你们项目里做了什么配置?

答:

MinIO 本身支持纠删码(Erasure Code),可以在丢失 N/2 个磁盘的情况下仍然恢复数据。

纠删码原理(类似 RAID 5/6):

1
2
3
4
5
6
把文件切成 K 个数据块,算出 M 个校验块(共 K+M 个块)
只要剩下 K 个块,就能恢复原始文件

例如:K=4, M=2(共 6 个块)
- 可以容忍最多 2 个块丢失
- 存储空间开销:(K+M)/K = 6/4 = 1.5 倍(比多副本的 2~3 倍省空间)

我们项目里的配置(根据 application.ymlapplication.properties 推断):

  • MinIO 部署时启用了纠删码模式minio server /data{1...6} 启动 6 个磁盘的纠删码模式)
  • 或者用了多副本模式(最少 2 个副本,数据同时在两个磁盘上)

应用层的保障

  • 文件合并后,可以选择保留原始分片一段时间(比如 7 天),防止合并后的文件损坏后可以恢复
  • 定期备份 MinIO 数据到冷存储(比如每小时同步一次到远程 MinIO 或公有云 OSS)

六、可观测性(Prometheus)相关(3 道)

Q24:你们项目里 Prometheus 是怎么配置的?采集了哪些指标?

答:

Prometheus 是一个监控系统,负责:采集指标 → 存储 → 查询 → 告警。

我们项目里采集的指标(根据 MetricsConfig.java 和项目描述):

  1. JVM 指标(自动采集,通过 micrometer-registry-prometheus):

    • 堆内存使用量
    • GC 次数/时间
    • 线程数
  2. 业务指标(自定义):

    • 文件上传计数器(fileUploadCounter,代码第 53 行)
    • 搜索耗时定时器(searchTimerHybridSearchService.java 第 68 行)
    • 文档处理成功率
  3. HTTP 请求指标(通过 micrometer-spring-web 自动采集):

    • 每个接口的请求次数、耗时、成功率

配置方式

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
// MetricsConfig.java(推断)
@Configuration
public class MetricsConfig {
@Bean
public MeterRegistryCustomizer<MeterRegistry> configureRegistry() {
return registry -> {
registry.config().meterFilter(MeterFilter.maximumAllowableTags("http.server.requests", 100));
};
}

// 自定义计数器:文件上传
@Bean
public Counter fileUploadCounter(MeterRegistry registry) {
return Counter.builder("file.upload.total")
.description("Total number of file uploads")
.register(registry);
}

// 自定义定时器:搜索耗时
@Bean
public Timer searchTimer(MeterRegistry registry) {
return Timer.builder("search.duration")
.description("Search execution time")
.register(registry);
}
}

Prometheus 怎么采集?

  • Spring Boot 应用暴露 /actuator/prometheus 端点(我们项目里放行了这个端点,见 SecurityConfig.java 第 51 行)
  • Prometheus 服务端定期(比如每 15 秒)来拉取这个端点的指标数据

Q25:有了 Prometheus,为什么还要用 Grafana?

答:

Prometheus 负责”存数据”Grafana 负责”展示数据”,它们是配合使用的。

Prometheus Grafana
功能 时间序列数据库(TSDB) 可视化面板(Dashboard)
查询 自带 PromQL 查询语言 支持多种数据源(Prometheus、ES、MySQL…)
告警 自带告警规则引擎 告警通知可以接钉钉/企微/邮件
界面 很丑(自带一个简单的图表) 很漂亮(各种图表、面板)

典型的监控架构

1
2
3
4
5
6
7
应用(Spring Boot)
/actuator/prometheus(暴露指标)
Prometheus(采集 + 存储)
↓ PromQL 查询
Grafana(展示图表)
↓ 告警规则触发
钉钉/企微/邮件(通知运维)

我们项目里的 Grafana 面板(推断):

  • 系统监控:CPU、内存、磁盘、网络
  • JVM 监控:堆内存、GC、线程数
  • 业务监控:文件上传量(QPS)、搜索耗时(P99)、文档处理成功率

Q26:如果你们项目线上出问题了,你怎么排查?

答:

这是我们项目里可观测性的价值所在。排查流程:

第一步:看监控面板(Grafana)

  • 是不是 Full GC 太频繁了?(看 JVM 面板)
  • 是不是某个接口耗时突然变长了?(看 HTTP 请求面板)
  • 是不是文件上传量突然暴涨了?(看业务指标面板)

第二步:看日志

  • 我们项目里用了 SLF4J + Logback,关键操作都打了日志(UploadService.java 里几乎每行都有 logger.info()
  • ELK(Elasticsearch + Logstash + Kibana)Grafana Loki 聚合所有实例的日志,按关键字搜索

第三步:看链路追踪(如果有接入)

  • SkyWalkingZipkin,可以看到一个请求经过了哪些服务、每个服务耗时多少
  • 比如”用户上传文件 → 合并 → 解析 → 向量化”这个流程,哪一步最慢

第四步:上机器排查

  • top:看哪个进程占 CPU 高
  • jstack <pid>:看线程栈(有没有死锁、大量线程阻塞)
  • jmap -histo:live <pid>:看堆里对象分布(哪个类的对象最多,可能有内存泄漏)

七、云原生化 / K8s 相关(4 道)

Q27:你们项目里 K8s 的 Deployment 和 HPA 是怎么配置的?

答:

Deployment 是 K8s 里用来部署无状态应用的资源对象。

我们项目里的 Deployment 配置(推断):

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
apiVersion: apps/v1
kind: Deployment
metadata:
name: knowflow-backend
spec:
replicas: 3 # 3 个副本(高可用)
selector:
matchLabels:
app: knowflow-backend
template:
metadata:
labels:
app: knowflow-backend
spec:
containers:
- name: app
image: registry.example.com/knowflow:latest
ports:
- containerPort: 8080
resources:
requests:
cpu: "500m" # 启动时需要 0.5 核
memory: "512Mi" # 启动时需要 512MB 内存
limits:
cpu: "2000m" # 最多用 2 核
memory: "2Gi" # 最多用 2GB 内存
livenessProbe: # 存活探针(健康检查)
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 60 # 启动后 60 秒才开始检查
periodSeconds: 10 # 每 10 秒检查一次
readinessProbe: # 就绪探针(能不能接流量)
httpGet:
path: /actuator/health
port: 8080
initialSeconds: 30 # 启动后 30 秒就可以接流量
periodSeconds: 5

HPA(Horizontal Pod Autoscaler) 是 K8s 里用来自动扩缩容的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: knowflow-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: knowflow-backend
minReplicas: 2 # 最少 2 个副本
maxReplicas: 10 # 最多 10 个副本
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # CPU 超过 70% 就扩容
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80 # 内存超过 80% 就扩容

扩容流程

1
2
3
4
5
6
7
CPU 使用率 > 70%

HPA 把 Deployment 的 replicas 从 3 改成 5

K8s 创建 2 个新 Pod

新 Pod 通过 Readiness Probe 后,开始接流量

Q28:你们项目里的多阶段 Dockerfile 是怎么写的?有什么好处?

答:

多阶段构建(Multi-stage Build) 是 Docker 17.05+ 的功能,可以把构建环境运行环境分开,最终镜像只包含运行时需要的文件(体积小、安全)。

我们项目里的 Dockerfile(根据项目描述推断):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 阶段一:构建(用完整的 JDK 镜像)
FROM maven:3.8-openjdk-17 AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests # 构建 JAR 包

# 阶段二:运行(用轻量级的 JRE 镜像)
FROM openjdk:17-jre-slim
WORKDIR /app
# 从构建阶段把 JAR 包复制过来
COPY --from=builder /app/target/knowflow-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

好处

  1. 镜像体积小:最终镜像只有 JRE + JAR 包(大约 200~300MB),如果用 JDK 镜像要 500MB+
  2. 安全:构建工具(Maven、源码)不会出现在最终镜像里(减少攻击面)
  3. 分层构建快:Maven 依赖(基本不变)和源代码(经常变)分开 COPY,利用 Docker 的层缓存,重新构建时不用重新下载依赖

Q29:K8s 里 Service 和 Ingress 的区别是什么?你们项目里怎么用的?

答:

Service 是 K8s 里用来暴露服务的(给集群内的其他 Pod 访问)。

Ingress 是 K8s 里用来暴露服务给集群外的(外部用户通过域名访问)。

1
2
3
4
5
6
7
外部用户
↓ HTTPS(域名:knowflow.example.com)
Ingress Controller(比如 Nginx Ingress)
↓ 转发
Service(ClusterIP 类型,集群内访问)

Pod(Spring Boot 应用,3 个副本)

我们项目里的配置(推断):

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
# Service:暴露给集群内
apiVersion: v1
kind: Service
metadata:
name: knowflow-service
spec:
selector:
app: knowflow-backend
ports:
- port: 80
targetPort: 8080
type: ClusterIP # 只能集群内访问

---
# Ingress:暴露给集群外
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: knowflow-ingress
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true" # HTTP 自动跳转到 HTTPS
spec:
tls:
- hosts:
- knowflow.example.com
secretName: knowflow-tls # TLS 证书
rules:
- host: knowflow.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: knowflow-service
port:
number: 80

Q30:你们项目里怎么做滚动更新的?更新时用户会不会感觉到服务中断?

答:

滚动更新(Rolling Update) 是 K8s Deployment 的默认更新策略,保证更新过程中服务不中断

流程

1
2
3
4
5
6
旧版本:3 个 Pod(v1.0
↓ 执行 `kubectl set image deployment/knowflow-backend app=new-image:v1.1`
① 创建 1 个新 Pod(v1.1
② 等新 Pod 就绪(Readiness Probe 通过)
③ 删除 1 个旧 Pod(v1.0
④ 重复 ①~③,直到所有旧 Pod 都被替换

关键参数(Deployment 的 spec.strategy):

1
2
3
4
5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 最多比 replicas 多几个 Pod(更新时可以超出副本数)
maxUnavailable: 0 # 更新时最少要保持几个 Pod 可用(设为 0 保证服务完全不中断)

maxSurge=1, maxUnavailable=0 的效果

  • 更新时,先启动 1 个新 Pod,新 Pod 就绪后,再删 1 个旧 Pod
  • 全程可用 Pod 数 >= replicas(用户完全感觉不到中断)

我们项目里maxUnavailable 应该设为 0(因为是企业内部系统,用户体验很重要)。


八、开放性问题(3 道)

Q31:如果你们项目的文档量涨到 1000 万块,ES 还能撑住吗?怎么优化?

答:

1000 万块(文本块)对 ES 来说完全没问题(ES 设计就是处理亿级文档的)。

但如果文档量继续涨(1 亿+),可以这样优化

  1. ES 集群扩容:加数据节点(Data Node),把 1 亿文档分散到多个节点上
  2. 按组织分索引:不同组织的文档存在不同 ES 索引里(knowledge_base_org_a, knowledge_base_org_b…),缩小单个索引的大小
  3. 热温架构:最近 30 天的文档放”热节点”(SSD),更早的放”温节点”(HDD),降低存储成本
  4. 向量索引优化:2048 维向量太高了,可以用 PCA 降维到 512 维或 768 维(牺牲一点精度,换来查询速度快几倍)

Q32:如果让你重新设计这个项目,你会改什么?

答:

(这是开放性问题,没有标准答案,以下是可以说的点)

  1. 分片上传的并发控制:目前代码里没有对”同一文件同时被多个用户上传”做并发控制,可以加一个分布式锁(Redis SETNX

  2. 向量化失败的重试机制:目前如果某块向量化失败了,没有看到明确的重试逻辑,可以加一个死信队列(失败 3 次后人工介入)

  3. ES 权限过滤的性能优化:目前每次搜索都要带上 orgTag 过滤条件,如果用户属于 100 个组织,查询条件会很长,可以改为在应用层做权限过滤(先搜,再过滤)

  4. MinIO 分片合并的原子性:目前合并和删除分片不是原子操作(合并成功、但删除分片失败了,会留垃圾数据),可以用 MinIO 的 WORM(Write Once Read Many) 特性


Q33:你在做这个项目时遇到的最大挑战是什么?怎么解决的?

答:

(结合你自己的实际经历说,以下是一个参考模板)

我遇到的最大技术挑战是”如何实现混合检索的高准确率”。

背景:企业专有词汇(比如”XX 系统”、”YY 平台”)在 Embedding 模型的训练数据里没有,向量检索召回率很低。

我做的优化

  1. 调整 ES 的搜索策略:不仅限于 KNN + BM25,还加了按组织标签加权(用户所属组织的文档排名靠前)
  2. 优化文本切块策略:不是固定 500 字一切,而是按段落/章节切(保留语义完整性)
  3. 调整 Embedding 模型:从通用的 text-embedding-ada-002 换成支持微调的模型,用企业自己的文档微调了一下

效果:检索准确率从 65% 提升到了 87%。


总结:面试前必看

  1. Redis:Bitmap、持久化、过期策略、Key 设计 —— 必问
  2. ES:KNN 原理、BM25、RRF、权限过滤、Mapping 设计 —— 必问
  3. Kafka:消息可靠性、重复消费、分区、消费者组 —— 高频
  4. Spring Security:JWT 双令牌、过滤器链、RBAC —— 必问
  5. MinIO:分片合并、纠删码、为什么不用公有云 —— 中频
  6. K8s:Deployment、HPA、滚动更新、Service vs Ingress —— 中频

最后提醒:面试官可能会让你现场看一段代码,指出问题。一定要把项目里的核心代码(UploadService.javaHybridSearchService.javaSecurityConfig.java)过一遍,确保能讲清楚每一行在干什么。

加油!祝你面试顺利!🎉


KnowFlow 项目 —— 30+ 道面试八股文(字节实习向)
https://whyalwaysme.lol/2026/06/07/2026-06-07-knowflow-interview-qa/
作者
Cassiur
发布于
2026年6月7日
许可协议