字节跳动火山引擎 Agent 开发秋招一面
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 算法题:LRU 缓存(LeetCode Hot 100)。
- 场景题:设计一个简单的异步系统(不使用 MQ 等中间件),实现数据的存取,说明从发起请求到数据存储和获取的全过程如何设计。
- 项目拷打:说明实习的 Agent 项目中你主要参与的部分,包括方案选型、Skills 设计、接口设计、从请求到处理的全链路;你认为 Agent 开发和传统前后端开发有哪些区别?
- 进程和线程的区别是什么?
- 进程和线程创建、切换上下文的开销有哪些区别?
- 进程间的常用通信方式(IPC)有哪些?在终端输入
ls指令属于哪一种? - 操作系统的内核态和用户态有何区别?
- 说一下 TCP 的三次握手,并分析为什么不用两次或四次?
- 说一下 TCP 的拥塞控制机制。
- 说明 TCP 和 UDP 的区别;如果想用 UDP 实现可靠传输该怎么处理?
- HTTP 的常见状态码有哪些?了解 429 是什么吗?
- HTTP 接口的 POST、PUT、PATCH 有何区别?
- POST 接口本身不幂等,如果业务需要幂等处理,怎么设计接口?
- 了解 Go 语言和其中的协程吗?
- Redis 是单线程还是多线程的?为什么?
- I/O 多路复用中的 epoll 是什么?
- 了解过零拷贝吗?
- Redis 的数据持久化机制有哪些?
- 既然 Redis 有数据持久化机制,为什么还要用数据库,不直接用 Redis 存储数据?
- 除了它是 NoSQL 数据库外,为什么即使有些场景需要 NoSQL 数据库,也不选择 Redis?
- MQ 的消息积压如何处理?
- 在后端设计时,如何保证能监测到 MQ 积压问题?
《参考解析》
LRU 缓存的及格线是「哈希表 + 双向链表」,两个结构配合才能同时拿到 O(1) 的查询与更新。哈希表存 key 到链表节点的映射,双向链表按访问时间排序:头部是最近使用、尾部是最久未使用。get(key) 命中后把节点从原位摘下来插到头部;put(key, value) 若 key 存在则更新值并移到头部,若不存在则新建节点插头部,再判断容量是否超限,超了就删掉尾节点并同步删掉哈希表里的键。写代码时要注意几个细节:用哨兵头尾节点可以消掉一堆空指针判断;put 更新已有 key 时不要增加容量计数;Java 里可以直接 LinkedHashMap 重写 removeEldestEntry,但面试通常要求手写,主动提一句「工程上可以用 LinkedHashMap,手写是为了看链表操作」比较得体。常见追问是 LFU(需要额外的频次桶或最小频次指针)和「为什么不用数组/时间戳排序」(移动元素 O(n))。
不用 MQ 设计简单异步系统,思路是「本地持久化队列 + 工作线程池 + 状态机」。请求进来先把任务落库(状态 PENDING)并返回任务 id,这一步保证不丢;再用线程池或协程池从库里拉取待处理任务执行,成功后把状态改成 SUCCESS、失败改成 FAILED 并记录重试次数;查询接口按任务 id 返回状态,前端轮询或走 SSE/WebSocket 推送。要点有四个:① 拉取任务时用「SELECT ... FOR UPDATE SKIP LOCKED + 限流批量」避免多实例抢同一条;② 幂等靠任务 id 唯一约束或状态机的乐观锁(WHERE status='PENDING')保证重复消费无害;③ 失败要有退避重试和最大次数,超过就进死信表并告警;④ 可观测性要有积压量(待处理条数)、处理耗时、失败率三个指标。要主动说出这个方案的边界:单库轮询有 QPS 上限、延迟高于 MQ 推送、任务量大时会打库,所以适合「量不大但要简单可靠」的场景,量上来就该换 MQ——面试官问这题通常就是想听你承认取舍,而不是硬说本地队列能替代 MQ。
进程与线程的答法要落到「资源分配」与「调度执行」两个词的区分上:进程是资源分配的基本单位,有独立地址空间、文件描述符表、信号处理等;线程是 CPU 调度的基本单位,同一进程内的线程共享地址空间和文件描述符,只独占栈、寄存器、程序计数器。开销差别在于:创建进程要分配页表、拷贝父进程资源(fork 的写时复制只是延后了拷贝),创建线程只需分配栈和少量内核结构;切换进程要切换页表(进而刷新 TLB,代价最大),切换线程只切换寄存器和栈指针。通信方式上,进程间有管道/命名管道、消息队列、共享内存(最快,但要自己做同步)、信号量、信号、socket(可跨主机);线程间直接读写共享内存即可,靠互斥锁和条件变量同步。ls 这类命令执行时,shell 先 fork 出子进程再 exec 替换映像,父子之间用管道传输出——所以是同主机进程间通信里的管道。
TCP 三次握手的目的是双方都确认「自己的发送能力和对方的接收能力正常」,并同步初始序列号。两次不行是因为服务端发出 SYN-ACK 后无法确认客户端是否收到:如果客户端的连接请求在网络里滞留后重发,旧请求可能在新连接建立后才到达服务端,服务端会误以为客户端又要建连,白白分配资源——第三次握手让服务端能从客户端是否回 ACK 判断这条连接是否真的有效。四次也不必:服务端的 SYN 和 ACK 可以合并成一个报文,本来就是同一次交互里的两件事。拥塞控制四段式:慢启动(cwnd 从 1 个 MSS 指数增长到 ssthresh)、拥塞避免(超过阈值后改为每 RTT 加一,线性增长)、快重传(收到三个重复 ACK 立刻重传丢失报文,不等超时)、快恢复(ssthresh 减半、cwnd 设为新 ssthresh,进入拥塞避免)。现代实现通常是 CUBIC 或 BBR,可以把「基于丢包」和「基于带宽时延估计」的区别补一句。
UDP 上做可靠传输等于把 TCP 的机制在应用层重造一遍:给每个报文编号并在接收端按序重组、为每个报文设置超时重传、用滑动窗口做流量控制、用类似拥塞窗口的策略做拥塞控制、加校验和检测损坏。同时可以针对性优化 TCP 的痛点:不需要严格按序交付的场景(音视频)就允许后到的包先处理,避免 TCP 的队头阻塞;连接建立可以省去握手。现实中的参照是 QUIC(跑在 UDP 上,把 TLS、多路复用、可靠传输做进用户态),HTTP/3 就是它,答这题时能提一句 QUIC 会显得答案有落点。
HTTP 状态码按五类记:1xx 信息(101 协议切换)、2xx 成功(200、201 创建、204 无内容)、3xx 重定向(301 永久、302/307 临时、304 协商缓存命中)、4xx 客户端错误(400、401 未认证、403 无权限、404、409 冲突、429 请求过多)、5xx 服务端错误(500、502 网关错误、503 不可用、504 网关超时)。429 是限流响应,通常带 Retry-After 头告诉客户端多久后重试,客户端应做退避重试而不是硬打。POST/PUT/PATCH 的区别在语义:POST 创建资源(非幂等,重复调用可能产生多条)、PUT 全量替换指定资源(幂等,重复调用结果一致)、PATCH 局部更新(不要求幂等,取决于补丁语义)。接口幂等设计的主流做法是客户端生成唯一请求 id(幂等键)随请求带上,服务端用唯一索引或 Redis SETNX 做去重,命中则直接返回首次结果;此外还有状态机约束(如「订单只在待支付状态可支付」)、乐观锁版本号、以及数据库唯一约束兜底。要强调「幂等键要落库而不是只放 Redis」,否则 Redis 失效后会重复执行。
Redis 与线程模型:Redis 处理命令的主线程是单线程的,所以单条命令天然原子、没有锁竞争,这也是它能直接支撑 INCR、SETNX 这类原子操作的原因。但严格说不是「全单线程」:4.0 起有后台线程做 UNLINK、FLUSHALL ASYNC 这类惰性删除,6.0 起有 I/O 多线程只用于读写 socket 和协议解析(命令执行仍在主线程),还有 bgsave/bgrewriteaof 的 fork 子进程。epoll 是 Linux 上的 I/O 多路复用机制:单个线程用一个 epoll 实例同时监听大量 fd,内核用红黑树管理注册的 fd、用就绪链表返回已触发的事件,避免 select/poll 每次调用都要把整个 fd 集合拷进内核并线性扫描,这也是 Redis、Nginx 能单线程扛高并发连接的基础。零拷贝指减少数据在内核态与用户态之间的复制次数:传统 read + write 要四次拷贝两次系统调用,sendfile 让数据直接从页缓存进网卡(DMA),mmap 把文件映射进用户空间省掉一次拷贝,splice 可以走管道零拷贝。Redis 的持久化:RDB 是某一时刻的全量快照(fork 子进程写临时文件再原子替换,文件小、恢复快,但可能丢最后一次快照后的数据),AOF 是追加写命令日志(appendfsync 可选 always/everysec/no,everysec 是默认,丢了最多 1 秒),AOF 重写把冗余命令压缩;4.0 起支持混合持久化,AOF 文件里前半段放 RDB 快照、后半段放增量命令,兼顾恢复速度和数据完整度。
**「为什么不直接用 Redis 存数据」**要从持久化与一致性两个方向答。Redis 的持久化是异步的:RDB 有快照间隔、AOF everysec 也可能丢最后一秒,机器断电或进程被杀就可能丢数据;而数据库靠 WAL(redo log)和事务保证提交即持久。此外还有:内存成本远高于磁盘,全量数据放内存不经济;Redis 缺少完整的事务、复杂的多条件查询、二级索引、外键约束和强一致的读己之写;主从复制默认异步,故障切换可能丢数据。所以常规架构是「数据库为准、Redis 做缓存或高性能场景的加速层」,配合缓存一致性策略(先更新库再删缓存 + 延迟双删或订阅 binlog 失效)。至于「有些场景需要 NoSQL 也不选 Redis」:文档型数据(嵌套结构、按字段查询)更适合 MongoDB,海量宽列写入适合 HBase/Cassandra,检索与聚合分析适合 Elasticsearch,图关系适合 Neo4j;Redis 的定位是内存数据结构服务,持久化和查询能力都不是它的强项,硬扛会付出数据安全和运维上的代价。
MQ 消息积压的处理要按「先止损、再定位、后根治」讲。止损阶段:临时扩容消费者实例(前提是分区/队列数够,否则要先扩分区)、把消费逻辑里耗时的下游调用降级或异步化、必要时先批量转存到临时队列再慢慢补。定位阶段要看是生产端突增(活动流量、上游重试风暴)还是消费端变慢(下游 RT 升高、单条处理变慢、消费线程被阻塞、锁竞争),靠消费者的 lag 曲线和单条处理耗时分布区分。根治方向:提高并行度(分区数 ≥ 消费者数)、批量消费、把串行逻辑改并行、给下游加缓存或限流、把非核心动作(发通知、写日志)移出主消费链路。可观测性要常备三个指标:消费 lag(堆积量)、消费速率与生产速率之比、消息从生产到消费的端到端延迟;告警要按 lag 的增长斜率而不是绝对阈值,否则业务量正常增长时会一直误报。另外要提前设计死信队列和重试上限,避免一条毒消息把整个队列堵死。