面灵AI→

BILBIL 广告二面凉经:线程池、事务失效与 CDC 幂等

轮次
二面
结果
已挂
时间
2026-09
来源
牛客网

《面试题目》

  1. ThreadPoolExecutor 的核心参数有哪些?分别有什么作用?
  2. Java 线程池提交一个任务后的完整执行流程是什么?什么时候入队,什么时候扩容到最大线程数?
  3. 线程池常见拒绝策略有哪些?ThreadFactory 在实际工程中有什么作用?
  4. Tomcat 线程池和 JDK ThreadPoolExecutor 的任务入队、线程扩容策略有什么区别?
  5. 什么情况下适合建立联合索引?联合索引的最左前缀原则是什么?
  6. 联合索引 (B, C, D),只查询 C、D 能否走索引?为什么?
  7. 联合索引中出现范围查询后,后面的索引列还能不能继续使用?>、>= 有什么区别?
  8. Spring @Transactional 常见的事务失效场景有哪些?
  9. 为什么 Spring 同类内部方法自调用可能导致事务失效?
  10. Spring 声明式事务为什么依赖 AOP 代理?
  11. Spring 如何保证一个事务中的 SQL 使用同一个数据库连接?ThreadLocal 起什么作用?
  12. Spring 事务传播行为有哪些?REQUIRES_NEW、NESTED、REQUIRED 有什么区别?
  13. 如果希望内部事务失败但不影响外层事务,应该如何设计事务传播方式?
  14. CDC + Kafka 的基本链路是什么?为什么收到 CDC 事件后还可能需要回查数据库?
  15. 什么是本地事务表 / Transactional Outbox?它解决什么问题?
  16. Kafka 消息重复投递时,消费者如何通过唯一 ID、状态机等方式保证幂等?
  17. 手撕:字符串实现大数加法,模拟竖式计算并处理进位

《参考解析》

线程池的七参数与执行顺序。 七个参数是 corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。执行顺序必须背准:核心线程未满 → 新建核心线程;核心线程已满 → 任务入队;队列满且未达最大线程数 → 新建非核心线程;都满 → 触发拒绝策略。这个顺序解释了很多反直觉现象,最典型的就是「无界队列让 maximumPoolSize 失效」。ThreadFactory 的作用是给线程起有业务含义的名字(默认 pool-N-thread-M,线上 dump 出来无法定位是哪个业务),同时可以设置为守护线程、统一异常处理器和线程组。拒绝策略四种:AbortPolicy(默认抛异常)、CallerRunsPolicy(调用线程自己执行,形成天然反压)、DiscardPolicy、DiscardOldestPolicy——生产上更常见的是自定义策略:降级 + 计数 + 打日志告警。

Tomcat 线程池和 JDK 线程池的策略差异。 关键在队列和扩容的配合:JDK 原生是「先入队、队列满了才扩容到 maximumPoolSize」;Tomcat 重写了这套逻辑,在队列未满时也会尝试把线程扩到最大(TaskQueue.offer() 里判断当前线程数是否小于 maximumPoolSize,小于就返回 false 让 execute 去创建新线程),目的是优先用线程数扛住突发流量,而不是让请求在队列里排队。理解这个差异,才能解释为什么同样参数下 Tomcat 的响应延迟表现不同,也才知道给 Web 应用调线程池时不能照搬 JDK 的经验。

联合索引与最左前缀的几个结论。 适合建联合索引的场景是:多个条件经常同时出现、且单个字段区分度都不足以支撑单独建索引。最左前缀原则的本质是联合索引按 (B, C, D) 顺序排列,只有从最左列开始连续匹配才能利用索引的有序性。所以 (B, C, D) 上只查 C、D 不能走索引(缺少最左列 B,无法定位起点);查 B、D 可以用到 B,但 D 无法用于定位。范围查询之后的列不能继续用于索引定位——因为范围条件让后续列在每个范围内有序、整体无序,所以 WHERE B = 1 AND C > 2 AND D = 3 里 D 只能做索引条件下推(Using index condition)过滤,不能减少扫描区间。> 和 >= 在这一点上本质相同(都是范围),差别只在边界是否包含,对「后续列失效」这件事没有影响——面试里常拿这两个问,就是在看你会不会被符号迷惑。

Spring 事务失效的完整清单。 一是同类内部自调用(this.method() 不走代理,最高频考点);二是方法不是 public(Spring 的代理对非 public 方法不生效);三是类没有被 Spring 管理(自己 new 出来的对象);四是异常被自己 try-catch 吞掉,事务管理器感知不到;五是在另一个线程里执行(事务上下文基于 ThreadLocal,不跨线程传播);六是数据库引擎不支持事务(如 MyISAM);七是方法被 final/static 修饰导致 CGLIB 无法代理。另外默认只对 RuntimeException 和 Error 回滚,受检异常必须显式写 rollbackFor。

为什么依赖代理、为什么连接能保持一致。 声明式事务靠 AOP:容器在启动时为被 @Transactional 标注的 Bean 生成代理,方法进入前由 TransactionInterceptor 开启事务、正常返回后提交、抛异常则回滚——所以未经代理的调用(内部自调用)自然不会有事务,这就是第 9 题的根因。同一个事务里的多条 SQL 能共用一个连接,靠的是 DataSourceTransactionManager 把连接绑定到 ThreadLocal(TransactionSynchronizationManager 的 resources),同一线程里后续获取连接时先查 ThreadLocal,拿到就复用;这也是「事务不跨线程」的原因——新线程的 ThreadLocal 是空的,会去拿新连接、开新事务。

传播行为的取舍。 REQUIRED(默认)是「有则加入、无则新建」,内外层共用一个事务,内层回滚会连带外层回滚;REQUIRES_NEW 是挂起外层、开启一个全新事务,内层提交或回滚不影响外层(但要注意它会占用另一个数据库连接,嵌套多了可能耗尽连接池);NESTED 用**保存点(savepoint)**实现,内层失败可只回滚到保存点而不影响外层已做的修改——但它要求底层 JDBC 支持保存点,且仍是同一个物理事务。所以第 13 题(希望内部失败不影响外层)的正确选择是 REQUIRES_NEW(要完全独立)或 NESTED(要部分回滚但保留外层),具体看「外层已做的修改是否应该保留」以及是否受得住额外连接的开销。还要注意 REQUIRES_NEW 内部失败抛异常如果没被捕获,依然会传播到外层导致外层回滚。

CDC + Kafka 链路,以及为什么要回查数据库。 基本链路是:数据库的变更日志(MySQL binlog)→ CDC 工具(Debezium/Canal)解析成事件 → 投递到 Kafka topic → 下游消费者处理。需要回查的常见原因有三个:一是 CDC 事件只包含变更的行数据,业务往往还需要关联其他表的信息(比如订单变了要拿用户和商品信息),而事件本身没有;二是事件顺序与提交顺序不保证,同一行连续两次变更可能乱序到达,需要回查当前最新状态来兜底;三是 MySQL 的 binlog 在事务提交时才写出,事件的 before/after 可能不包含中间态,某些一致性判断必须读当前库。当然回查会带来额外压力和不一致窗口(读到比事件更新或更旧的数据),所以更稳的做法是把下游需要的字段冗余进事件,把回查限制在少数必要的场景。

Outbox 模式解决什么问题。 它解决的是**「业务写库」和「发消息」这两个动作无法原子提交的问题:如果先写库再发消息,发消息失败就丢事件;先发消息再写库,写库失败就发了不存在的业务。Transactional Outbox 的做法是在同一个本地事务里既写业务表、又往一张 outbox 表插一条记录,然后由独立的投递器(轮询或 CDC 订阅 outbox 表)把记录发到 MQ 并标记已发送。这样本地事务保证了「业务数据和待发消息」的一致性,投递失败可以重试,代价是引入了至少一次**投递语义——所以消费端必须幂等。

消费者的幂等怎么保证。 核心是「唯一 ID + 状态机 + 约束」。可实现的手段:① 用业务唯一键(订单号、事件 ID)建数据库唯一索引,重复插入直接冲突失败——这是最可靠的兜底,注意要用「先插后判」而不是「先查后写」,后者在并发下挡不住;② 维护状态机,只允许合法状态迁移(如 pending → paid → shipped),重复事件到达时因状态不匹配而被忽略,这比单纯去重更能处理乱序;③ 记录已处理的事件 ID 表并设置过期清理,或把「处理结果」缓存起来直接返回;④ 消费端处理逻辑设计成可重复执行(用 upsert 而不是 insert、用绝对值赋值而不是增量累加)。要牢记 Kafka 的投递语义通常是至少一次(消费成功但提交 offset 前崩溃会重放),所以幂等不是可选项。

大数加法手撕的要点。 用两个指针从字符串末尾往前遍历,逐位相加并维护进位 carry;循环条件要写成「i >= 0 || j >= 0 || carry > 0」,千万别漏掉最后的进位("99" + "1" 必须输出 "100");每一位的结果要取模 10,进位整除 10。边界处理:空串按 0 处理、前导零("001" + "1")按题意决定是否保留、结果拼接后要反转。如果要支持负数和小数,就要先解析符号、再按位对齐小数点——面试时先说清支持范围再做,比闷头写更稳。