项目八股
针对简历上的项目,常见的面试总结。
一、分布式锁
Q:为什么用 Redis 分布式锁,不用数据库锁或 ZooKeeper?
Redis 性能最好,加解锁都是内存操作,QPS 能到十万级。数据库悲观锁 select for update 会占用数据库连接,高并发下连接池容易打满,而且锁的粒度控制在行级不够灵活。ZooKeeper 基于临时顺序节点实现,可靠性最高——节点断连自动删除,天然避免死锁,但性能只有 Redis 的十分之一,因为每次操作要走 ZAB 协议做多数派确认。我们的场景是高频的售后创建,对性能敏感,对极端场景下锁失效的容忍度较高——即使锁失效了还有业务字段幂等打标兜底,所以选 Redis。
Q:Redis 分布式锁怎么保证不死锁?
三个手段。第一,加锁必须带过期时间,用 SET key value NX EX seconds 一条命令原子完成,不能先 SETNX 再 EXPIRE——中间挂了就死锁。第二,value 必须是唯一标识比如 UUID,释放时用 Lua 脚本先比对 value 再删除,防止 A 的锁被 B 释放。第三,释放锁的代码必须在 finally 块里,保证业务异常时也能释放。
Q:业务执行时间超过锁的过期时间怎么办?
这是最经典的问题。锁过期自动释放,另一个线程拿到锁,两个线程并发执行,锁失效了。解决方案是锁续期,也叫看门狗机制。Redisson 的实现是加锁成功后启动一个后台定时任务,默认每 10 秒检查一次,如果业务还在执行就把锁的过期时间重置为 30 秒,直到业务结束或客户端宕机。我们的场景里更依赖业务字段幂等打标兜底——即使锁失效了,第二个线程读到 isApplyRefund 已被标记,也会直接返回不重复执行。
Q:为什么用 tryLock 不用 lock?
tryLock 拿不到锁立即返回 false,lock 会阻塞等待。选 tryLock 是因为申请即退是一个优化能力,不是必须成功的强依赖——拿不到锁说明有其他请求在处理同一订单,直接返回未命中,用户走正常售后流程,体验上没有损失。如果用 lock 阻塞等待,高并发下大量线程堆积等锁,会耗尽 Tomcat 线程池导致整个服务不可用。判断标准是:非关键路径用 tryLock 快速失败,关键路径比如支付回调、库存扣减用 lock 保证一定执行。
Q:锁的粒度为什么选订单维度不选售后单维度?
因为要保护的临界区是订单级别的数据。同一个订单下可能有多个子单售后,如果按售后单加锁,两个售后单会并发执行,都去读写订单的 ext_info,产生并发覆盖。按订单加锁让同一订单的所有售后操作串行化。粒度选择的通用原则是:找到被并发修改的最小共享资源单位,锁这一层。粒度太粗影响吞吐,太细保护不住数据。
Q:Redis 主从架构下锁会失效吗?
会。主节点写入锁后还没同步到从节点就宕机了,从节点被选为新主,锁信息丢失,另一个客户端能重新加锁成功。这就是 Redis 分布式锁在 CP 上的妥协——它保证 AP 不保证 CP。Redlock 算法是 Redis 官方给的解决方案,向 N 个独立的 Redis 实例加锁,超过半数成功才算加锁成功。但 Redlock 有争议,Martin Kleppmann 质疑它在时钟漂移和 GC 停顿下依然不安全。真正强一致的分布式锁应该用 ZooKeeper 或 etcd。工程上更实用的做法是:锁做性能层的并发控制,业务层加幂等兜底,接受锁的最终失效可能。
二、幂等设计
Q:接口幂等有哪些实现方案?
主要五种。唯一索引,靠数据库唯一约束拦截重复插入,最简单可靠,适合创建类接口。业务字段状态机,先查状态再判断能否执行,比如订单只有待支付才能支付,适合状态流转类。Token 机制,客户端先获取 token,提交时校验并消费 token,适合表单防重复提交。分布式锁,加锁后执行,适合短时间内的并发去重。乐观锁版本号,update ... where version = ?,适合更新类接口。我们的场景用的是业务字段状态机——isApplyRefund 字段不为 null 就说明已经处理过,直接返回之前的结果。
Q:为什么有了分布式锁还要幂等打标?
两者解决不同维度的问题。分布式锁解决并发问题——同一时刻两个请求,锁让它们串行。幂等打标解决时序问题——第一个请求执行完释放锁后,第二个重试请求进来,锁已经释放了,锁挡不住它,只有幂等字段能挡住。举例:gRPC 调用超时,客户端自动重试,但服务端其实已经处理成功了。这时锁早就释放了,靠的是幂等字段读到已处理标记直接返回。所以是并发和时序两个维度,缺一不可。
Q:幂等打标返回什么?返回成功可以吗?
不能写死返回成功。要返回第一次执行的真实结果。我们的 isApplyRefund 是 Boolean 三态:null 表示还没判断过,true 表示命中申请即退,false 表示判断过但没命中。重试请求进来读到 false 必须返回 false,如果写死返回 true 就破坏了幂等性的定义——幂等要求多次调用和一次调用的效果完全一致,包括返回值一致。
Q:幂等标记写入失败怎么办?
我们的处理是抛异常让整个流程失败。因为幂等标记写入是最后一步,前面的介入单创建和审批已经执行了,如果幂等标记没写成功,下次重试会重复执行审批操作。所以这里的 update 用了乐观锁——先深拷贝原对象,带着原版本号做 update,count != 1 说明版本号不匹配,被其他线程改过了,抛业务异常。整个流程在事务里,失败会回滚。
Q:为什么退款失败要回滚幂等标记?
这是一个降级设计。退款终态失败比如余额不足、账户冻结,重试也不会成功,但用户的售后诉求还在。如果不回滚幂等标记,售后单上一直标记着"已通过申请即退处理",就会阻塞用户走正常的人工售后审核流程。所以回滚标记让售后单回到初始状态,走商家审核或平台介入的常规路径。注意只在终态失败时回滚,网络超时这类可重试的失败不回滚,等重试。
三、责任链模式与规则引擎
Q:M1 → M2 双通道为什么用责任链,不用策略模式?
策略模式是并列的多个策略选一个执行,需要外部先决定用哪个。责任链是有优先级的顺序尝试,前一个处理不了才交给后一个。我们的场景是 M1 优先——运营人工配置的规则优先级高于系统自动规则,M1 命中就不再走 M2。这天然是责任链的语义。如果用策略模式,还需要额外的逻辑来判断该用 M1 还是 M2,逻辑上绕了一层。
Q:配置中心热加载是怎么实现的?和本地缓存有什么区别?
配置中心的核心是配置变更的推送和感知。主流有两种机制:长轮询,客户端发起一个 HTTP 请求,服务端 hold 住不返回,配置变更了立即返回,客户端拿到新配置后再次发起长轮询,Nacos 和 Apollo 都是这个思路。或者是长连接推送,服务端主动 push,比如 etcd 的 watch 机制。感知到变更后,客户端反序列化成 Java 对象放到内存里,业务代码读的是内存对象,所以是零延迟的。和本地缓存的区别是:本地缓存的失效是被动的靠 TTL 过期,配置中心是主动推送的实时更新,且能保证集群内所有实例配置一致。
Q:配置中心挂了业务还能跑吗?
能,靠本地快照。客户端第一次拉取配置成功后会把配置持久化到本地文件,配置中心不可用时降级读本地快照。启动时也是先读本地快照保证能启动,再异步拉取最新配置。这是配置中心的高可用设计,避免配置中心成为单点故障。
Q:为什么规则用 JSON 存配置中心,不建表存数据库?
三个考虑。第一,规则的变更频率低但需要立即生效,配置中心的热加载天然满足,数据库还要加缓存和缓存失效逻辑。第二,规则是全局共享的,配置中心保证所有实例读到同一份,数据库要考虑各实例缓存不一致。第三,配置中心有版本管理和灰度发布能力,改错了能一键回滚,数据库改数据没这个能力。数据库更适合存有关联查询需求、数据量大的业务数据,配置中心适合存规则、开关、阈值这类元数据。
四、灰度放量
Q:ABTest 灰度是怎么实现的?为什么用哈希取模不用随机数?
必须用哈希,因为要保证同一用户多次请求命中同一分组,这叫分流的稳定性。用随机数会导致同一用户这次命中下次不命中,体验割裂,数据也没法统计。哈希的做法是对用户 ID 做 hash,取模映射到 0-99 的桶里,配置放量 10% 就是命中桶号 0-9 的用户。我们用的是 CityHash,特点是分布均匀且计算快。
Q:本地哈希分流和 ABTest 平台有什么区别?
本地哈希分流是纯本地计算,没有网络调用,性能极高,但只能做简单的比例放量,无法做多组对照实验和实验指标统计。ABTest 平台是中心化的,要调远程接口获取用户的实验分组,支持多层实验、正交分流、实验指标自动上报,适合真正需要做科学对照实验的场景。选择标准是:只需要功能开关和比例放量用本地哈希,需要衡量业务指标做决策用 ABTest 平台。
Q:灰度放量的完整策略应该有哪几层?
我们这个需求实际上是三层开关。第一层全局总开关,Boolean 类型,出问题一键关停整个功能。第二层灰度比例开关,按用户维度哈希分流,控制放量比例从 1% 逐步到 100%。第三层规则级开关,每条规则有独立的 enabled 字段,可以只关掉某个场景不影响其他场景。这三层的意义是:故障时能快速止损,且止损的影响范围可控——不需要因为一个场景有问题就关停整个功能。
五、ES 与数据聚合
Q:为什么列表查询用 ES 不用 MySQL?
三个原因。多维度组合查询,Noah 页面有十几个筛选条件任意组合,MySQL 无法为所有组合建索引,会走全表扫描。深分页,MySQL 的 limit 10000, 20 要扫描一万条记录才能返回 20 条,ES 有 search_after 游标分页。数据量大,售后单是千万级数据,MySQL 复杂查询会拖慢主库影响交易链路。ES 是倒排索引 + 列式存储,天然适合多条件筛选和聚合分析。
Q:ES 和 MySQL 怎么保证数据一致性?
我们用的是 Binlog 同步。MySQL 写入后通过 Canal 或类似组件订阅 Binlog,异步写入 ES。这是最终一致性,通常延迟在秒级。ES 的定位是查询加速,不作为数据源头,涉及资金和状态判断的核心逻辑一律读 MySQL 主库。这就是 CQRS 的思路——写走 MySQL 保证强一致,读走 ES 保证高性能,接受读侧的秒级延迟。
Q:什么是跨服务按需富化?为什么不做宽表?
按需富化是先查主表拿到分页结果,再按需调其他服务补充字段。做宽表意味着把其他服务的数据同步冗余到自己这边,会引入三个问题:数据同步链路的一致性保障,源数据变更时的同步延迟,以及跨域数据的所有权混乱——订单数据的变更需要通知售后同步,耦合度高。按需富化的代价是多几次 RPC 调用,但保证了数据实时准确且服务边界清晰。选择标准是:查询 QPS 高、对延迟极敏感的做宽表,QPS 低、要求数据实时准确的做按需富化。运营后台的查询 QPS 很低,所以选按需富化。
Q:N+1 查询问题怎么解决?
N+1 是指查了一次列表拿到 N 条数据,然后为每条数据再查一次关联数据,总共 N+1 次查询。三种解法。批量查询,把 N 次单条查询合并成一次 in 查询,这是最优解。本地缓存,同一批数据里重复的 key 只查一次,我们的任务状态查询就是这么做的——多条售后单可能属于同一个任务,用 Map 缓存任务状态。或者数据预加载,一次 join 查出所有关联数据。选择上优先批量查询,如果关联数据的重复度高用本地缓存更简单。
六、状态机
Q:为什么用状态机而不是 if-else 判断状态流转?
状态机把"当前状态 + 触发事件 → 目标状态 + 执行动作"的映射关系集中声明,一眼能看出全部合法流转路径。if-else 散落在各个业务方法里,改一个流转要找遍全代码,且容易漏掉某个分支导致非法流转。售后系统有五十多种流转事件,用 if-else 根本无法维护。状态机的另一个价值是能自动生成状态转移图,产品和测试也能看懂。
Q:内部流转和外部流转有什么区别?
外部流转是状态发生变化,比如从已申请到已同意退款。内部流转是状态不变但执行了某些动作,比如在已申请状态内创建介入单——售后单还是已申请,但多了一个介入单。区分的意义在于两者的前置校验和数据操作不同:外部流转要校验目标状态的合法性,内部流转不涉及状态变更但要保证数据一致。我们的申请即退用了两次流转:先内部流转创建介入单,再外部流转到同意退款。
Q:自动退款为什么要复用人工客服的流转路径?
三个价值。审计可追溯,自动退款和人工退款在协商历史里都有完整记录,出问题能查到是哪条规则、哪个判责触发的。逻辑一致性,不需要为自动流程单独写一套状态校验和数据落库逻辑,避免两套逻辑行为不一致导致的数据不一致。可测试性,自动流程复用了已经被充分验证的人工流转链路,风险更低。这本质上是把"谁触发"和"怎么执行"解耦——触发方可以是客服、系统、AI,执行链路是同一套。
- 一、分布式锁
- Q:为什么用 Redis 分布式锁,不用数据库锁或 ZooKeeper?
- Q:Redis 分布式锁怎么保证不死锁?
- Q:业务执行时间超过锁的过期时间怎么办?
- Q:为什么用 tryLock 不用 lock?
- Q:锁的粒度为什么选订单维度不选售后单维度?
- Q:Redis 主从架构下锁会失效吗?
- 二、幂等设计
- Q:接口幂等有哪些实现方案?
- Q:为什么有了分布式锁还要幂等打标?
- Q:幂等打标返回什么?返回成功可以吗?
- Q:幂等标记写入失败怎么办?
- Q:为什么退款失败要回滚幂等标记?
- 三、责任链模式与规则引擎
- Q:M1 → M2 双通道为什么用责任链,不用策略模式?
- Q:配置中心热加载是怎么实现的?和本地缓存有什么区别?
- Q:配置中心挂了业务还能跑吗?
- Q:为什么规则用 JSON 存配置中心,不建表存数据库?
- 四、灰度放量
- Q:ABTest 灰度是怎么实现的?为什么用哈希取模不用随机数?
- Q:本地哈希分流和 ABTest 平台有什么区别?
- Q:灰度放量的完整策略应该有哪几层?
- 五、ES 与数据聚合
- Q:为什么列表查询用 ES 不用 MySQL?
- Q:ES 和 MySQL 怎么保证数据一致性?
- Q:什么是跨服务按需富化?为什么不做宽表?
- Q:N+1 查询问题怎么解决?
- 六、状态机
- Q:为什么用状态机而不是 if-else 判断状态流转?
- Q:内部流转和外部流转有什么区别?
- Q:自动退款为什么要复用人工客服的流转路径?

评论
评论区加载中,请稍候…