字节跳动离线数据架构二面:千亿向量索引与Flink State治理
- 轮次
- 二面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 做一下自我介绍。
- 简述一下几种向量索引方案?
- 现在 Milvus 里有一千亿个向量,你怎么设计索引?
- 如果分完桶之后,有一个桶里只有 10000 个向量,为什么会出现这样只有 10000 个向量的桶?这些向量是怎么组织的,怎么分层的?
- 设计一个由 10 台 Redis 机器构成的集群,内部存的是普通 K-V,K 是 UID,V 是一个二进制文件,要求考虑单点故障、负载均衡,实现高可用、低延迟。
- 写个算法:二叉树层序遍历。
- 手撕进阶:二叉树不可能永远存 int,如果我们需要二叉树里能存多种类型,要怎么做?
- 你们这边是离线数据架构,能介绍一下你对大数据组件的理解吗?
- 对这些组件有深入了解吗?Flink 有实操过吗?
- 随着计算任务推进,Flink 的 State 越来越多、变得难维护,你要怎么做?
- 反问环节。
《参考解析》
几种向量索引方案:分两大类。精确检索(ENN/Flat)就是暴力算全量相似度,召回率 100%、延迟随数据量线性增长,只适合百万级以内或作为评测基线。近似检索(ANN)才是生产主力,主流三类:IVF 系(倒排文件)先用聚类把向量空间分成若干桶(nlist 个质心),检索时只查距离最近的 nprobe 个桶,用聚类换速度,召回率由 nprobe 调节;HNSW 是分层可导航小世界图,上层稀疏用于快速跳转、下层稠密用于精确定位,查询复杂度接近对数级,是当前召回率与延迟平衡最好的一类,代价是内存占用高、构建慢、删除只能靠标记;PQ/量化系(乘积量化、标量量化)把向量切段后用量化码本压缩,大幅降低内存(可压到原大小的 1/4 到 1/32),常与 IVF 组合成 IVFPQ。工程上还有 DiskANN 这类磁盘驻留方案,用来处理单机内存装不下的规模。
一千亿向量的索引怎么设计:单机绝对装不下,必须先分片再谈索引。第一,按业务维度做一级分区(比如按用户或文档集合),检索时只在相关分区内做,这是最有效的一刀,比任何索引技巧都管用。第二,水平分片:把向量切到多个节点(一致性哈希或按分区键路由),每个节点内部再建索引,查询时广播到所有相关分片再归并 Top-K。第三,索引选型上,千亿规模内存必然紧张,走「IVF 粗量化 + PQ 压缩 + 磁盘驻留」的组合——先用 IVF 把候选集从千亿降到十万级,再用 PQ 压缩向量减少内存,必要时用 DiskANN 把大部分数据放磁盘、只把图的导航结构放内存;HNSW 只用于每个分片内的高频热数据。第四,工程细节:把 embedding 维度降下来(如 1536 降到 256~512,或做 PCA),索引构建要离线分批并支持增量更新,检索链路上加缓存(相同 query 直接命中)、加多级召回(粗排候选少则用 Flat 精算)。最后要能报数:Recall@k、P99 延迟、单 QPS 成本、索引内存占用这四个指标一起看,别只优化一个。
为什么会出现只有一万个向量的桶:这是 IVF 类索引的固有现象,根因是数据分布不均匀而不是 bug。聚类的质心是随机初始化或 k-means 迭代出来的,如果某片区域样本本来就稀疏(长尾内容、新上线类目、冷门语言),落在它附近的向量自然少;此外 k-means 的迭代次数不足、或数据是流式增量写入(后期写入的数据没有参与聚类),都会让某些桶长期偏小。桶内的组织取决于索引类型:如果是 IVF-Flat,桶内就是原始向量列表,检索时线性扫描;如果是 IVF-PQ,桶内是量化编码,先比对压缩码算近似距离再对候选重排;如果桶内还叠了 HNSW,就是「桶内再建小图」,图是分层随机的——上层的节点是随机挑选的,不按向量相似度选,所以同一层内的节点之间几何上并不邻近,靠的是「层数越高边越长」的跳转特性来加速收敛。也正因为分层是随机的,桶的大小对查询性能影响很大:太小的桶几乎不省时间还多了一次聚类查找,太大的桶则退化成线性扫描,所以生产上要么调大 nprobe 覆盖多个桶,要么对超大桶再建一层子索引。
十节点 Redis 集群的设计:先分清需求边界再谈方案。数据特征上,K 是 UID、V 是二进制文件,这有两个直接后果:一是大 key 风险,单个 value 太大(几 MB 以上)会阻塞单线程的 Redis、拖慢整个分片并放大网络开销;二是内存成本高。所以第一层判断是「二进制文件本身不该直接塞进 Redis」,更合理的是大文件放对象存储、Redis 里只存元数据与访问凭据(URL、版本、状态),只有在极端场景(要求毫秒级取整块文件的极端低延迟)才考虑 Redis 直存,且必须给 value 设上限并做分片切割。
集群层面:用 Redis Cluster 做原生分片,16384 个 slot 按 CRC16(UID) 分配,10 个主节点每个约 1600 个槽,每主配一个从节点保证单点故障时自动 failover——注意 10 台机器如果既是主又是从,故障域和容量都要算清楚。负载均衡靠 slot 分布加客户端做 MOVED/ASK 重定向处理;UID 哈希天然均匀,比按范围分片更稳。低延迟的抓手是:客户端用连接池与 pipeline 批量取、订阅拓扑变化避免每次重定向、热点 UID 在应用侧加本地缓存、以及把慢命令(KEYS、大 HGETALL)从路径上拿掉。高可用还要补:持久化上 AOF everysec 加 RDB 混合(重启恢复快)、定期做全量备份、配置 maxmemory 与淘汰策略避免 OOM 写失败、以及哨兵/集群的脑裂防护(min-replicas-to-write)。如果一致性要求高,还要考虑主从异步复制天然会丢最后一段写入这一事实——那就要在业务侧做「写后读主」或接受最终一致。
二叉树层序遍历:BFS,用一个队列。根入队,循环「记下当前队列长度 n,弹出 n 个节点处理并把各自的非空子节点入队」——这个「按当前层长度成批处理」的写法是关键,它天然把同一层聚在一起,返回二维数组或按层换行都靠它。复杂度 O(n)。变体要能随手写出来:之字形遍历(层号奇偶决定是否反转)、每层取最大值或平均值、右视图(每层最后一个节点)、以及求最大宽度。
二叉树要能存多种类型怎么做:三条路各有代价。一是用 Object 存,靠向上/向下转型——最直观,但每次取值都要显式强转,类型错误延迟到运行时才爆,而且基本类型会被装箱、有性能开销。二是用泛型 Node<T>——编译期就能约束类型,类型安全且无装箱开销(泛型特化后);局限是整棵树只能有一种 T,如果同一棵树里既要存 int 又要存 String 就用不了,此时可以退化成 Node<Object> 或用泛型加通配符 Node<? extends Comparable> 来约束可比性。三是用 Optional 之类包装器做空值保护,避免 null 混进类型判断里导致分支遗漏——它解决的是「空值」这一特殊「类型」,不能替代前两者。生产上的判断标准:类型集合固定且需要比较/排序,就用泛型加上界约束;类型完全动态,就用 Object 加运行时类型校验(instanceof 后立即转换,不要到处强转);要序列化到磁盘的话,还得额外存类型标签,否则反序列化时无法还原。
Flink State 越来越多怎么治理:按「能不能不存」到「怎么存得省」的顺序处理。第一层是源头治理:只注册真正需要的状态(把可以在算子里现算的中间结果从状态里拿掉),区分 Keyed State 与 Operator State,能用 MapState 的不要用多个 ValueState 拼。第二层是生命周期治理:给状态配 TTL(StateTtlConfig),按时间窗口做过期与淘汰——这正是「时间窗口」最直接的用法;同时要设好 StateTtlConfig 的清理策略(增量清理与 RocksDB compaction filter 各有权衡)。第三层是容量治理:把后端从内存换成 RocksDB(状态落在本地磁盘、内存只做缓存),并按 key 加盐拆分热点 key 避免单个 subtask 状态膨胀;再打开增量 checkpoint 与本地恢复,减小每次 checkpoint 的开销。第四层是架构治理:把状态持久化到外部 OLAP(ClickHouse/Doris)承载需要频繁查询的历史状态,Flink 只保留热窗口内的状态——但要注意这会引入 IO 开销与一致性窗口,不是所有场景都值得。第五层是可观测:监控 state size、checkpoint 时长与失败率、以及各 subtask 的状态分布,先看清楚是「总量大」还是「分布倾斜」,两者的解法完全不同。
大数据组件的整体理解:按数据流向组织比按组件罗列更有条理:采集(Flume、CDC、Kafka)负责把变更可靠地搬进来;存储分三层——对象存储/分布式文件系统放原始与明细(HDFS、S3)、列式存储放分析用的宽表(Parquet/ORC + Iceberg/Hudi 这类表格式解决 ACID 与增量)、OLAP 引擎放需要秒级响应的聚合结果(ClickHouse、Doris、StarRocks);计算分两态——批处理(Spark、Hive)跑 T+1 的全量与回溯,流处理(Flink)跑分钟级与秒级的增量;调度与治理(Airflow、DolphinScheduler 做编排,数据血缘与质量校验做保障)。组件选型的判断维度是延迟要求、数据规模、以及一致性要求,而不是「哪个新用哪个」——很多故障来自「用流处理做本该批处理做的回溯」或反过来。