面灵AI→

顺丰 Java 一面:Lua 原子性、Kafka 可靠性、缓存穿透手撕

轮次
一面
时间
2026-10
来源
牛客网

《面试题目》

  1. 自我介绍
  2. 询问 base 地、更倾向传统后端开发还是 AI 应用开发?
  3. 结合安全背景,开发过程中会注意哪些安全问题,怎么处理?
  4. Lua 脚本为什么具备原子性?
  5. 为什么用 Lua 而不是用 WATCH + MULTI?
  6. Lua 脚本执行后业务侧超时,怎么处理这种数据不一致的问题?
  7. Kafka 如何保证消息不丢失?
  8. Kafka 的分区机制是怎样的?
  9. 介绍一下你在实习中的项目,怎么做聚合分析和规则引擎的?
  10. 手撕:怎么解决缓存穿透(伪代码)

《参考解析》

Lua 的原子性来自 Redis 的单线程执行模型,而不是 Lua 本身有什么事务能力。 Redis 处理命令是单线程串行的,EVAL 会把整段脚本作为一个「不可打断」的任务执行完,期间不会插入其他客户端的命令,所以脚本里的「读—判断—写」在服务端看是一个整体。要注意三个边界:① 脚本中途报错不会回滚已执行的写命令(Redis 没有事务回滚);② 脚本执行时间过长会阻塞所有其他请求(lua-time-limit 只允许其他客户端发 SCRIPT KILL,若已产生写则杀不掉),所以脚本要短、别做循环大计算;③ 集群模式下脚本涉及的所有 key 必须在同一个 slot(用 hash tag {} 保证)。

为什么用 Lua 而不是 WATCH + MULTI,答案是「失败代价与重试复杂度」。 WATCH 是乐观锁:监视 key,如果在 EXEC 前被其他客户端改过,整个事务失败返回 null,需要客户端自己循环重试;高并发下冲突频繁,重试风暴会拖垮性能,而且 watch 的对象多时更难控制。Lua 是服务端一次性执行,把判断逻辑下沉到 Redis,一次往返完成,天然没有「检查与执行之间被插队」的窗口,也不需要客户端重试。代价是逻辑写在服务端、调试不便、脚本要随版本管理(用 SCRIPT LOAD + EVALSHA 减少传输),以及前面说的单线程阻塞风险。

「Lua 执行成功但业务侧超时」是最经典的分布式不一致场景,处理原则是「不确定就回查 + 幂等收口」。 具体分三层:① 结果可查:为每次操作生成业务唯一键(如订单号/请求号),并把处理结果写进 Redis 或数据库;超时后客户端不要盲目重发,先按这个键回查真实状态,查到结果就采用,查不到才重试。② 幂等:无论重试多少次,最终效果只有一次——用 Redis 的幂等标记或数据库唯一索引兜底;Lua 脚本里直接带 SETNX 式判断可以让重复请求快速返回。③ 对账与补偿:给这类操作建立对账任务(定时比对各方的状态),发现不一致时以权威方(数据库)为准做补偿;同时记录超时率并告警。要主动补一句:这类问题的根源是「网络超时无法区分『没执行』和『执行了但响应丢了』」,所以任何解决方案都必须包含「回查」这一步。

Kafka 的消息不丢失,要在三个环节分别保证。 生产者:acks=all(等所有 ISR 副本确认)+ retries 足够大 + 开启幂等生产者(enable.idempotence=true,用 PID + 序列号去重)避免重试导致重复;Broker:replication.factor >= 3、min.insync.replicas >= 2 且 unclean.leader.election.enable=false(宁可不可用也不选举落后副本当 leader),并注意刷盘策略(默认靠 OS page cache,掉电可能丢,要权衡性能);消费者:关闭自动提交(enable.auto.commit=false),先处理业务再提交 offset,这样崩溃后最多重复消费一次(at-least-once),因此消费端必须幂等。要能说清至少一次的语义代价,以及为什么「精确一次」通常要靠幂等或事务实现。

分区机制是 Kafka 并行度与顺序性的来源。 一个 topic 分成多个 partition,partition 是有序且不可变的追加日志,也是并行消费的最小单位(同一分区同一时刻只能被同一消费组内的一个消费者消费),所以分区数决定最大并行度。消息的路由:指定了 key 就按 hash(key) % partition_num 落到固定分区(同 key 有序),没指定 key 则按 sticky/轮询分配(批次内粘滞以提升吞吐)。生产实践:分区数要按目标吞吐与消费者数量规划(在线扩容会增加元数据与重平衡成本,且扩容后 key 的映射会变,破坏原有顺序假设);消费端如果要求全局有序只能用一个分区(牺牲并行度),通常做法是把需要有序的维度放 key 里保证局部有序。另外要提重平衡:消费者增减或心跳超时会触发 rebalance,期间消费暂停,所以要用合适的 max.poll.interval.ms、静态成员(group.instance.id)与协作式再均衡减少抖动。

缓存穿透的手撕思路有三条,答题时优先给「布隆过滤器 + 空值缓存」的组合。 ① 空值缓存:查库为空时也写一个短 TTL 的空值标记,后续请求直接命中缓存返回;要注意与真实数据更新的一致性和大量随机 key 造成的缓存污染。② 布隆过滤器:写入数据时把 key 放进布隆过滤器,查询前先判断「一定不存在」就直接返回,判断「可能存在」再走缓存/数据库;特点是空间小、无假阴性但有假阳性(可用计数布隆过滤器支持删除,或配合定期重建)。③ 参数校验与限流:先做合法性校验(id 格式、范围),对明显遍历式请求(大量不同 key、同一 IP 高频)做频控与黑名单。伪代码可以是:if (!bloom.mightContain(key)) return NOT_FOUND; v = cache.get(key); if (v != null) return v; v = db.query(key); if (v == null) { cache.set(key, EMPTY, 60s); return NOT_FOUND; } cache.set(key, v, ttl); return v;,并补一句「空值缓存的 TTL 要明显短于真实数据,避免数据新增后仍被判空」。顺带区分穿透(查不存在的数据)、击穿(热点 key 失效瞬间的并发回源,用互斥锁或逻辑过期解决)与雪崩(大量 key 同时失效或缓存整体不可用,用随机 TTL、多级缓存与熔断降级解决)。