字节后端一面:布隆过滤器、幂等设计、Redis 持久化与 RAG 评估
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 介绍自己、项目和承担的角色,为什么选择这些技术组件?能不能只用一种数据库?为什么不只用 Redis,选择 MySQL 的依据是什么?
- 注册流程中的布隆过滤器、分布式锁和唯一索引,分别解决什么问题?
- 布隆过滤器判断用户名存在时后续怎么处理?误判导致用户无法注册怎么办?如果更新失败、重试一直失败,后续如何处理?
- 有人疯狂请求注册接口,如何限流?具体按什么维度识别和拒绝请求?
- 如何使用 requestId 实现幂等?既然 requestId 可以防重,为什么还需要加锁和二次校验?
- Redis 有哪些持久化方式?AOF 的具体流程是什么?AOF 与 RDB 应该如何选择?
- 如何保证数据库与缓存的一致性?并发读写时出现旧数据怎么办?
- MongoDB 字段级 Patch 和 CAS 版本控制分别解决什么问题?
- 如果 Redis 数据全部丢失,如何恢复面试进度?Redis Stream 的消费者处理到一半宕机会发生什么?如果 Redis 自身宕机,如何优化系统可用性?
- 会话级 FIFO 能否保证顺序?多个 Worker 同时执行时如何协调?
- 请解释 RAG 从文档上传、处理到检索和回答的完整流程。如何评估 RAG 或重排的效果?自己出的几个问题能否代表真实用户的各种提问?
- 多个知识库的检索结果如何融合?不同知识库的分数一定不能直接比较吗?为什么?
- HashMap 为什么线程不安全?线程安全的替代方案如何实现?
- MySQL 为什么需要两阶段提交?redo log 和 binlog 如何协调,故障后如何恢复?
- 事务有哪些特性?死锁有哪些必要条件?事务有哪些隔离级别?读未提交可能出现哪些读异常?脏读、不可重复读和幻读分别是什么情况?
- Redis 的 key 过期之后,内存会立即释放吗?
- AI 调用很慢,应该如何设计线程池?
- 同一个类中的普通方法调用另一个带事务注解的方法,事务一定会生效吗?
- 流式输出的断线补发如何实现?
- 每段生成内容都写数据库,请求量增大后如何处理?如果改用 Redis,又如何应对其宕机风险?
- 前端断线或者用户主动停止时,整个后台任务是否应该取消?已经调用的工具能否停止?
- Java GC 有哪些基本算法?它们分别如何工作?
《参考解析》
布隆过滤器、分布式锁与唯一索引的分工:三者不是替代关系而是成本与正确性的梯度。布隆过滤器把「大概率不存在」的查询挡在数据库外,省掉大部分 IO,但它有假阳性(说不存在一定不存在,说存在可能不存在),所以只能做前置过滤;唯一索引是最终的正确性保证,在数据库层保证不重;分布式锁是控制并发窗口,避免大量请求同时打到数据库。误判导致无法注册的处理:布隆说存在但库里没有时,最终以数据库为准放行(先查库,查不到就插入,唯一索引兜底);不能直接拒绝用户。布隆更新失败要有重建机制(全量扫描重建或双写队列补偿),重建时先用旧过滤器继续服务,避免空窗。
限流的维度:至少三层——IP/设备(挡批量脚本)、账号/手机号(挡同一账号刷)、全局出口(保护后端)。还要加行为特征(同 IP 短时间大量不同手机号、验证码请求频率、滑块验证)。拒绝策略要区分:超限返回明确的错误码与重试时间,而不是静默失败;对疑似机器人的流量走验证码而不是直接封禁,降低误伤。
requestId 幂等与二次校验:requestId 只解决「同一个请求重复提交」,做法是服务端以 requestId 为唯一键做去重(Redis setnx 或唯一索引)。它挡不住的是:不同 requestId 但业务上等价的请求(用户刷新页面重新生成 id)、以及并发窗口内的竞态(两个请求同时通过去重检查)。所以还需要分布式锁把并发串行化、以及数据库唯一索引做最后一道闸;二次校验(查一次库里是否已存在)是在锁内做的,用来处理「前一个请求刚提交成功但缓存还没更新」的窗口。
Redis 持久化:RDB 是某一时刻的全量快照(fork 子进程写,恢复快、可能丢最后一次快照后的数据);AOF 记录写命令(appendfsync 有 always/everysec/no 三档,everysec 最多丢 1 秒),文件会膨胀,靠 AOF 重写(BGREWRITEAOF,用当前数据生成最小命令集)压缩。选择上:能容忍丢几秒数据、要快恢复用 RDB;要求接近不丢用 AOF everysec;生产常态是两者都开(RDB 做备份与快速恢复,AOF 做低丢失),Redis 4.0 之后还有混合持久化(AOF 文件前半是 RDB 格式)。
缓存与数据库一致性:常规做法是「先更新数据库,再删除缓存」(Cache Aside),比先删缓存再写库的窗口更小。仍然存在的并发问题是「读请求把旧值回填」——解法是延迟双删、给缓存加版本号(写入时带版本,读到更旧版本就丢弃)、或用 binlog 订阅(Canal)异步失效。要讲清接受最终一致还是要求强一致:绝大多数业务是前者,强一致就别用缓存或在读路径上做校验。
Stream 消费与宕机:Redis Stream 用消费者组(consumer group)+ pending entries list(PEL)实现「至少一次」。消费者读完但未 ACK 就宕机,消息留在 PEL 里,可以通过 XPENDING/XAUTOCLAIM 把超时未确认的消息转给其他消费者重新处理——所以消费侧必须幂等。Redis 自身宕机则靠主从 + 哨兵/Cluster 做故障转移,配合 AOF 减少数据丢失;但故障转移期间会短暂不可用,业务侧要有降级路径(比如直接读数据库或返回稍后重试)。
RAG 全流程与评估:见前文(解析切块 → 向量化 → 混合召回 → 融合 → 重排 → 上下文拼装 → 生成 → 引用校验)。评估分检索(Recall@K、MRR、nDCG)和生成(忠实度、相关性、引用正确率)。「自己出的几个问题能否代表真实用户」——不能,自出题等于开卷,必须从真实日志采样并覆盖长尾表达。
多知识库分数融合:不同库的分数不能直接比较,因为向量相似度的分布依赖各自的语料与模型(同一个分数在不同库里含义不同)。正确做法是先做归一化或转为排名(RRF,倒数排名融合,只看名次不看绝对分数),或者每个库各自取 TopK 后再统一重排(用一个能同时看 query 与候选的 cross-encoder,它给出的分数是在同一空间里的)。另一种方式是学习一个融合权重,但那需要标注数据。
HashMap 线程不安全与替代:不安全体现在并发扩容时链表/红黑树结构被破坏(JDK8 里可能丢数据或死循环的历史问题在 8 之后缓解但仍有竞态)、size 计数不准、put 时的覆盖丢失。替代:ConcurrentHashMap(JDK8 用 CAS + synchronized 锁单个桶,粒度细)、Collections.synchronizedMap(整表锁,性能差)、或用分段/分片自己实现。
MySQL 两阶段提交:目的是让 redo log(存储引擎层,保证崩溃恢复)和 binlog(server 层,用于复制与备份)保持一致。流程是:写 redo log 并置为 prepare → 写 binlog → 提交 redo log 置为 commit。崩溃恢复时:如果 redo 是 prepare 但 binlog 已完整,就提交;如果 binlog 不完整,就回滚。判断 binlog 是否完整靠其中的 XID 与末尾的 commit 标记。
事务与隔离级别:ACID 四特性;死锁四个必要条件(互斥、占有且等待、不可剥夺、循环等待);隔离级别四级——读未提交(可能脏读)、读已提交(避免脏读,可能不可重复读)、可重复读(避免不可重复读,MySQL 默认,靠 MVCC + 间隙锁在很大程度上避免幻读)、串行化。脏读是读到别的事务未提交的数据;不可重复读是同一事务内两次读同一行结果不同;幻读是同一范围两次查询行数不同。
key 过期后内存是否立即释放:不会。Redis 用「惰性删除 + 定期删除」:访问到过期 key 时才删,后台定期抽样删除;所以在被删之前内存仍占用。这也是为什么大量 key 同时过期会造成内存尖峰和延迟抖动,过期时间要加随机抖动。
AI 调用慢的线程池设计:核心是隔离——给 AI 调用单独的线程池,不要和普通请求共用,避免慢调用把整个应用的线程耗尽。参数上:核心线程数不必大(瓶颈在下游),队列要有界(无界队列会把内存撑爆且延迟无限增长),拒绝策略要明确(快速失败并返回友好提示,或降级到备用模型)。还要配合超时(连接超时 + 读超时 + 整体超时)、并发上限(信号量限制同时在途的调用数)、以及熔断(连续失败就快速失败一段时间)。
同类内方法调用事务不生效:因为 Spring 的事务是基于代理的 AOP,this.method() 不经过代理,注解不生效。解法:把方法拆到另一个 bean、用 AopContext.currentProxy()(需开 exposeProxy)、或注入自身代理。更根本的建议是别依赖「同类调用带事务」这种隐式行为。
流式输出的断线补发:服务端把生成内容按序号分段落库(或写 Redis),客户端重连时带 lastSeq,服务端从该序号之后继续推;如果生成已经结束,就直接返回完整结果。关键是序号单调与幂等:客户端按序号去重,服务端不重复推同一段。要处理「重连时正在生成的段落」——把未完成的段落标记为未定稿,重连后重发该段。
前端断线或用户停止时是否取消后台任务:要分场景。纯查询类任务应该取消(省成本);有副作用的工具调用要谨慎——已经发出的请求无法撤回,能做的只是不再发起后续调用并记录已完成的部分。实现上用 context 的取消信号逐层传递,工具层检查信号决定是否继续。
GC 基本算法:标记-清除(标记存活对象后回收未标记的,简单但产生碎片)、复制(把存活对象复制到另一半空间,无碎片但牺牲一半空间,适合存活率低的新生代)、标记-整理(标记后把存活对象向一端移动,无碎片但移动成本高,适合老年代)、分代收集(按对象年龄分区,新生代用复制、老年代用标记-清除/整理)。现代收集器(G1、ZGC)在此基础上做并发标记与分区回收来压低停顿。