面灵AI→

极兔后端一面 全项目拷打从Redis队列到事务失效

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

《面试题目》

  1. Redis Stream 做订单队列,如果消费失败了怎么兜底?
  2. 推模式的缺点是什么?有哪些改进方案?
  3. 滚动分页用时间戳加 offset 去重,为什么不直接用 limit?
  4. 点赞用 ZSet 存用户、数据库存计数,你怎么保证缓存和数据库的双写一致?
  5. 秒杀结束后,数据怎么同步到数据库?
  6. 项目中有用到过 AOP 吗?
  7. 事务注解在什么情况下会失效?
  8. 线程池核心线程数是 5、最大线程数是 10、等待队列也是 10,一下涌进来 30 个执行很慢的任务,这 30 个任务在池子里执行的流程是怎样的?
  9. 项目中哪里用到了 MQ?
  10. Redis Stream 当作消息队列,怎么保证消费幂等?
  11. 项目中有用到分布式锁吗?可重入锁是用 Redis 的哪几种数据结构实现的?
  12. JVM 的内存区域是怎么划分的?
  13. 一张表上 A、B 两个字段建了联合索引,写 SQL 时先写 B 等于、然后 and A 等于,这个索引还能生效吗?
  14. AI 物流助手里路径遍历的防护,防护的是什么?
  15. 如果忽然出现高并发、上千个用户同时来问 AI,你的系统哪里会先扛不住?
  16. 简单说一下 Netty 的网络模型。
  17. 怎么解决 NIO 的粘包和拆包问题?
  18. 项目中出现慢查询的时候,你是怎么定位的?

《参考解析》

Redis Stream 的失败兜底与消费幂等:Stream 的消费模型是至少一次:XREADGROUP 读到消息后进消费组的待处理列表,只有 XACK 才算出队,消费者崩了消息仍留在待处理列表里。所以兜底要分三层:一是处理失败不 ACK,让消息能被重投;二是用 XAUTOCLAIM 把空闲超过阈值的待处理消息转给其他消费者,避免某个挂掉的消费者把消息攥死;三是给消息记重试次数,超过上限转到死信 Stream 并告警,而不是无限重投把队列堵住。还要监控待处理堆积,以及注意 XADD 用 MAXLEN 近似裁剪虽然能防内存膨胀,但可能裁掉还没被确认的消息,需要权衡。幂等则靠业务侧的唯一约束兜底:以订单号或业务流水号去重(Redis SETNX 或数据库唯一索引),并把状态更新写成带条件的语句,比如 update ... where status = '待处理',让重复消费变成空操作。

缓存与数据库的双写一致性:点赞这类高频写,明细放 Redis(ZSet 记录谁点过)、计数落库或放缓存,二者不可能用一个事务保证强一致,只能做最终一致加对账。可行方案有几种:读未命中回填、写时先更新数据库再删缓存(Cache Aside),配延迟双删或消息队列二次删除来缩小并发窗口;订阅 binlog 异步刷新缓存,把业务与缓存解耦;计数类数据用「缓存累加 + 定时或定量回写 + 定期全量对账修复」。要避免的是先删缓存再写库的裸写法——并发下读请求会把旧值重新灌回缓存;也要避免给缓存计数加长事务或分布式锁来「保一致」,那会把点赞的吞吐打回数据库水平。秒杀的数据同步是同一类问题的极端形态:Redis 预扣减加消息队列削峰、异步落库,落库失败的补偿与对账必须提前设计,而不是等出问题再补。

滚动分页为什么不用 limit:limit 加 offset 在深分页时有两个问题:数据库要扫描并丢弃前 offset 行,越翻越慢;数据在翻页过程中新增或删除会带来漂移——插入一条会把后面所有内容后移一位,用户看到重复项,删除则直接漏项。用时间戳做游标可以避免扫描偏移,但时间戳精度有限,同一毫秒内的多条数据排序不稳定,所以更稳的做法是复合游标:把排序键和唯一键一起当游标(比如 create_time 加 id),查询写成 where (create_time, id) < (?, ?) order by create_time desc, id desc limit N,每一页的边界唯一确定。注意它仍然用了 limit,但不再依赖 offset;在这一层做去重其实是防御性的——游标做到位后,重复只可能出现在边界处理不当的情况下。

线程池里 30 个任务的执行路径:核心 5、最大 10、队列 10 的池子,前 5 个任务直接创建核心线程执行;第 6 到第 15 个任务进入等待队列(队列正好满 10 个);第 16 到第 20 个任务因为队列已满,触发扩容,创建线程一直到最大值 10 个来执行;第 21 到第 30 个任务此时核心与最大线程都已用满、队列也是满的,直接走拒绝策略(默认 AbortPolicy 抛 RejectedExecutionException)。所以 30 个任务里只有 20 个被接受,10 个被拒。最容易答错的点就是 JDK 线程池的顺序是「先入队、队列满了才扩容」,队列没满时最大线程数根本用不上;想让线程尽早扩起来,得用 SynchronousQueue,或者自定义队列让 offer 直接返回 false。还要补一句:任务都很慢时,队列里那 10 个要等核心线程空出来才轮到,实际排队时间可能很长,所以队列深度和排队时长都要进监控。

AOP 与事务失效:@Transactional 靠代理生效,所以绕过代理就失效——同一个类里 A 方法直接调用 B 方法,B 上的事务注解不会生效,因为这次调用没有经过代理对象;解法是自注入、取当前代理,或者把方法拆到另一个 bean。其它常见失效点:方法不是 public(JDK 动态代理只织入 public);方法或类被 final、方法被 static 修饰;异常被自己 catch 掉没抛出,或者抛的是受检异常而没配 rollbackFor(默认只回滚运行时异常和 Error);在异步线程里调用(事务上下文不跨线程传递);对象不是容器管理的 bean;数据库引擎本身不支持事务;以及传播行为选错——REQUIRES_NEW 会挂起外层事务,内层回滚不影响外层,而嵌套在同一个事务里再 catch 往往救不回来。AOP 本身在项目里的常见用途是统一日志、鉴权、幂等、缓存、分布式锁和埋点;实现上 JDK 动态代理基于接口、CGLIB 通过继承生成子类,这也解释了为什么私有方法和 final 类没法被织入。

联合索引列序与慢查询定位:只要 where 里同时有 A、B 的等值条件,写成 where B = ? and A = ? 依然能用上 (A, B) 联合索引——优化器会按索引列对齐等值条件并自行重排,与书写顺序无关,最终的命中列数和扫描行数都一样。真正让索引失效的是另外几类:跳过最左列(只查 B 不带 A)、最左列做范围查询后再用后面的列(范围列之后只能过滤不能定位)、对列做函数运算或隐式类型转换、前导通配的 like、用 or 连接非索引列,以及 !=、not in 这类低选择性条件。定位慢查询的路子是:先看慢日志拿到具体 SQL 和耗时分布,再用 explain 看访问类型、命中索引、估算行数与实际行数的偏差(偏差大通常意味着统计信息过期或索引选错),结合回表次数、是否 filesort、是否用临时表、有没有锁等待来判断;改写的方向是补索引或调索引列序、拆开复杂查询、减少回表、避免大范围排序,最后用真实数据量回归验证。

路径遍历防护与突发并发下的瓶颈:AI 助手类功能里出现路径遍历,多半是用户输入被拼进了文件路径或 URL——比如按单号拼出 ./docs/<单号>.pdf,攻击者传 ../../etc/passwd 就能读到预期目录之外的文件,所以防的是任意文件读取,进而可能拿到配置和凭据。有效手段是白名单加规范化校验:先做 URL 解码、再拼路径、然后用 realpath 取绝对路径并断言它以允许的根目录为前缀;文件名尽量由系统生成、不接受用户传入;运行账户按最小权限挂载;把 ..、绝对路径、空字节当作硬拒绝而不是过滤替换。至于上千个并发来问哪里先扛不住,要顺着链路推:通常第一个瓶颈是对外部大模型服务的调用——并发配额、长连接数和响应时间都卡在那里;其次是应用侧的连接池与线程池被慢请求占满,一个慢下游就能拖垮整个池;再往后才是数据库或缓存。对应措施是入口限流与排队、给每次调用设超时并降级、把同步长请求异步化或改成流式返回、对高频问题做结果缓存与复用、给不同下游配独立线程池做隔离,并用压测确认瓶颈位置而不是拍脑袋。

Netty 网络模型与粘包拆包:Netty 用的是主从 Reactor 多线程模型:bossGroup 只负责 accept 新连接,把 Channel 注册到 workerGroup 的某个 EventLoop 上,之后的读写事件都由这个 EventLoop 串行处理——一个 Channel 在生命周期内只绑定一个 EventLoop,所以业务代码里不用给 Channel 加锁,但绝不能在这个线程里做阻塞操作,否则会把该 EventLoop 上所有 Channel 一起卡住。数据流经 ChannelPipeline 上的 handler 责任链,ByteBuf 用引用计数加内存池管理,配合 CompositeByteBuf、slice、直接内存实现零拷贝。粘包拆包是 TCP 字节流的固有属性——它不保留应用层消息边界,一次 read 可能读到半个消息或多个消息。解决方式有三类:固定长度(每包等长,适合定长协议)、分隔符(按换行或自定义分隔符切分)、长度字段(报文头部声明长度,最常用,要配好偏移量、长度域字节数、是否包含头部)。自定义协议一般还会配心跳与编解码器,处理半包时框架会缓存未读完的字节,等下一批数据到齐再交付。