帆软一面:进程线程、LSM 与 B 树、分布式锁与 Go 并发
- 轮次
- 一面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 进程和线程的区别。
- 有一个服务器集群,跑的都是 CPU 密集的任务,但现在并发请求数扛不住了,你有哪些办法?
- LSM 和 B 树有什么区别?
- B 树一般不适用于哪些场景?
- Redis 主从同步是怎么实现的?
- MySQL 要存储一个数值,需要支持精确到小数点后 10 位以及数学运算,你选择什么数据类型?
- 分布式锁怎么设计?
- 介绍一下 goroutine。
- 讲一下 channel 的底层实现。
- agent 中怎么做好成本管控?
- 平时怎么学习技术的?
- 你当时为什么会搞那个开源项目?
- 对于大型的代码仓库,怎么保证模型操作的准确性、怎么减少幻觉?
《参考解析》
进程与线程的区别
进程是资源分配的基本单位,线程是 CPU 调度的基本单位。进程有独立的虚拟地址空间、页表、文件描述符表和信号处理,线程共享进程的这些资源,只独享栈、寄存器上下文和线程局部存储。因此线程创建与切换更便宜(不用换页表,但仍要保存上下文),但一个线程崩溃可能拖垮整个进程。通信方式也不同:进程间要 IPC(管道、消息队列、共享内存、信号、socket),线程间直接读写共享内存但要配同步原语。在 Linux 内核里两者都是 task_struct,区别来自 clone 时传的 flags 决定了哪些资源被共享。
CPU 密集型集群并发扛不住怎么办
先定位再动手,不能一上来就说加机器。定位顺序:看是 CPU 打满、还是请求排队的队列堆积、还是上游依赖超时被算进并发。CPU 打满的情况下按顺序考虑——先是算法和数据结构层面的优化、减少重复计算、把热点数据缓存下来;再是编译与运行时优化(-O3、PGO、SIMD、减少锁竞争和伪共享、绑核与 NUMA 亲和);然后是水平扩容,前提是服务无状态、可以分片;同时做流量治理(限流、排队、优先级、把离线批处理错峰);如果瓶颈是纯计算且可并行,考虑下沉到 GPU 或专用加速。最后一定要有观测手段支撑:P99、load、上下文切换次数、perf 火焰图,优化前后用数据说话。
LSM 树与 B 树的区别
LSM 树把写变成顺序写:先写 WAL 保证持久化,再写内存里的 MemTable,满了之后刷成有序的 SSTable,后台 compaction 合并小文件。优点是写吞吐高、顺序 IO 友好;代价是写放大(compaction 反复重写数据)和读放大(一次读可能要查内存表加多层 SSTable,靠 Bloom filter 和分层策略缓解)。B+ 树是原地更新,读路径稳定在 O(log n) 且一次页读取就能到叶子,但随机写会引发页分裂与随机 IO。所以 LSM 适合写多读少、追加型负载(RocksDB、HBase、ClickHouse 的 MergeTree、消息队列存储),B+ 树适合读多写少、需要范围查询和事务的场景(MySQL InnoDB)。
B 树不适合哪些场景
写入量极大且以随机写为主的场景(写放大和随机 IO 会成为瓶颈);key 随机分布导致缓存命中率低的超大规模数据集;只需要追加和顺序扫描的日志型数据(LSM 或直接顺序文件更合适);单机内存放不下索引、又要求高频点查的场景。另外,如果业务的访问模式是「写入后很少读、只按时间范围扫」,用 B+ 树维护索引纯属浪费。
Redis 主从同步
第一次建立复制时做全量同步:从节点发 PSYNC,主节点 fork 生成 RDB(可以配置无盘复制直接走 socket),同时把期间产生的写命令累积到复制缓冲区,从节点载入 RDB 后再接收这部分增量。之后进入命令传播阶段,主节点把每条写命令发给从节点。主节点维护 repl_backlog 环形缓冲和 offset,从节点断线重连时如果 offset 还在 backlog 范围内就做部分重同步(PSYNC CONTINUE),否则再全量。要注意主从复制默认是异步的,主节点故障时可能丢最后一批写,所以强一致场景需要 WAIT 命令、或者用 Sentinel/Cluster 做故障转移并接受一定窗口,业务侧最好再配幂等和补偿。
精确到小数点后 10 位选什么类型
选 DECIMAL,例如 DECIMAL(20,10)——定点数用十进制存储,能精确保存并精确参与加减乘除,适合金额和需要严格精度的计量。不要用 FLOAT/DOUBLE,它们是二进制浮点,0.1 无法精确表示,做多次运算会累积误差,比较也会出错。DECIMAL 的存储是每 9 位十进制数用 4 字节,所以位数越多占用越大,够用就好。如果性能是绝对瓶颈且业务能接受误差(比如统计分析),才考虑 DOUBLE;另一种工程做法是用 BIGINT 存放大后的整数(乘以 10^10),由应用层负责换算。
分布式锁怎么设计
单机 Redis 版本:加锁用 SET key value NX PX ttl,一条命令保证原子性;value 用请求唯一标识(如 UUID 加线程 id);释放锁必须用 Lua 脚本先比较 value 再删除,否则会误删别人的锁;业务执行时间不确定就加看门狗续期。要注意 Redis 主从切换可能丢锁,所以强一致场景用 etcd 或 ZooKeeper——etcd 靠租约加 revision,ZooKeeper 靠临时顺序节点,都能做到锁与持有者生命周期绑定。无论用哪种,业务侧都要做到幂等,并在写操作上带 fencing token(单调递增的版本号),让存储层拒绝过期持有者的写请求,这是防止「锁过期后旧持有者继续写」的最后一道防线。还要考虑锁的粒度、超时时间怎么定、是否可重入,以及能不能用数据库唯一索引或乐观锁这类更简单的方案替代。
goroutine 与 channel 的实现
goroutine 是用户态协程,初始栈只有 2 KB,可按需增长,由 Go runtime 的 GMP 调度器多路复用到操作系统线程上,创建和切换成本远低于线程;从 1.14 起支持基于信号的异步抢占,避免死循环饿死其他 goroutine。常见坑是泄漏:channel 阻塞等待、没有退出条件、context 没有传递,排查手段是 runtime.NumGoroutine()、pprof 和 goroutine dump。
channel 的底层结构是 hchan:环形缓冲区(buf、sendx、recvx)、发送与接收的等待队列(sendq、recvq,元素是 sudog)、一把互斥锁、元素类型和关闭标志。发送时如果已有等待的接收者,直接把数据交接过去,不走缓冲;否则有空间就入缓冲,没空间就把当前 goroutine 包成 sudog 挂到 sendq 并 park。关闭 channel 会唤醒所有等待者,向已关闭的 channel 发送会 panic,接收则返回零值和 ok=false。channel 同时承担同步语义,满足 happens-before 关系。
Agent 的成本管控
先把账算清楚:成本等于输入 token 乘单价加输出 token 乘单价,再乘以调用次数,所以要分别压这三个因子。压调用次数:限制循环步数、避免重复调用(缓存工具结果、语义缓存命中直接返回)、把可合并的步骤合并。压输入:上下文裁剪(只带相关检索片段)、历史摘要、工具返回值裁剪、prompt 缓存。压输出:限制长度、结构化输出减少重试。再叠加模型分级路由(简单任务用小模型、复杂任务才上大模型)、异步批处理、以及按租户计量和限额告警。最后用离线评测确定性价比最高的配置,别凭感觉调。
大型代码仓库里怎么减少模型幻觉
三条:一是让模型少猜——用检索把相关文件与符号精确送进上下文,而不是整仓丢进去;二是给它可验证的反馈——检索用 AST 和符号索引而不是纯文本相似度,改完立刻跑类型检查和测试,把编译错误结构化回灌让它自己修;三是约束动作范围——分小步提交、每步只改少量文件、改动前后做 diff 校验,关键路径人工复核。