跳至正文

字节跳动财经支付–日常实习一面回忆

前言

 

投递了字节财经支付团队的日常实习,本以为会有一场“八股”大战,结果没想到面试官几乎全程在深挖项目,八股(基础知识)问得很少。这种面试风格非常考验对项目细节的理解和技术方案的思考深度。虽然过程很“痛苦”(哭),但复盘下来收获真的很大。


 

📝 面试感受

 

整体感觉:项目为主,算法为辅,场景为王。

面试官非常关注你“为什么”这么做,以及你做的方案有什么“隐患”或“B计划”。如果只是简单地“用”一个技术,而没有思考背后的原理和取舍(Trade-off),就很容易被问倒。


 

💻 手撕算法:无重复字符的最长子串

 

  • 题目: LeetCode Hot 100 – “无重复字符的最长子串”(LC.3)
  • 过程:
    • 这是一个典型的滑动窗口问题,使用一个哈希表(HashMap)来存储字符及其最新的索引。
    • 思路是:用右指针 j 不断向右移动,把字符 char 和它的索引 j 放入 map
    • 如果 map已包含 char,说明遇到了重复字符。此时,需要移动左指针 i
    • 关键点: 左指针 i 应该跳到 map.get(char) + 1i 当前位置中较大的那一个,以防止窗口回缩(例如 “abba”)。
    • ans = Math.max(ans, j - i + 1)
  • 复盘:
    • 我的整体思路是正确的,但是在更新 map 中重复字符的索引时忘记了(map.put(char, j) 这一步)。
    • 由于面试平台不能本地调试,这个小 bug 一直没找出来,导致最后没能 AC。
    • 反思: 核心代码的熟练度还是不够,边界条件和更新逻辑要刻在脑子里。

 

🚀 印象深刻的几个问题(深度复盘)

 

这几个问题问得非常有水平,直指项目中的痛点和技术选型的核心。

 

1. 项目中使用布隆过滤器的真正作用?

 

  • 问题: 面试官问我项目里用布隆过滤器(Bloom Filter)是做什么的。
  • 我的回答: 我一开始想当然地回答是为了“防止缓存穿透”。
  • 追问与反思:
    • 面试官追问:“你的业务代码里,如果布隆过滤器误判了(说‘有’,但实际数据库‘没有’),你是怎么处理的?”
    • 我回去看代码发现,业务逻辑是:先查布隆,如果布隆说“有”,就再去查数据库。
    • 正确结论: 在我这个特定的业务场景下,它并没有起到防止缓存穿透的作用(因为缓存穿透的定义是:查询一个数据库必定不存在的数据)。
    • 它的真正作用是:快速过滤(Quick Fail)。它充当了一个“白名单”,对于那些必定不存在的请求(布隆说“没有”),就直接返回错误,连数据库都不用查。这极大地降低了数据库的压力,是一种“快速检验”功能。

 

2. 如何终止线程池中一个无限循环的任务?

 

  • 问题: 如果 threadPool.submit() 提交了一个 while(true) 的无限循环任务,如何从外部优雅地终止它?
  • 我的思考:
    1. (错误答案)使用 Thread.stop()。这个方法已经被废弃,因为它太暴力,会立即释放所有锁,导致数据不一致,非常不安全。
    2. (正确方向)提交任务时就设计成“可响应中断”的。
  • ⭐ 详细的标准答案: 核心是利用 Java 的中断(Interrupt)机制第一步:任务代码必须“可响应中断” 你提交的 RunnableCallable 任务,它的 run() 方法必须正确处理中断信号。

    如何优雅地终止 Java 线程池中的任务

    使用 Future.cancel(true) 和中断信号

    在 Java 中,我们经常使用线程池来管理异步任务。但一个常见的问题是:如果一个任务是无限循环的(例如,一个后台监控服务),我们该如何从外部安全地停止它呢?

    答案是使用 Java 的中断机制 (Interrupt)。这是一种协作式的方式,允许一个线程“请求”另一个线程停止。Future.cancel(true) 正是利用了这一机制。

    第一步:让你的任务响应中断

    首先,你的 RunnableCallable 任务必须被编写为可以响应中断信号。这通常意味着在循环中检查中断状态。

    Runnable infiniteTask = () -> {
        // 循环条件检查当前线程的中断标志位
        while (!Thread.currentThread().isInterrupted()) {
            try {
                // 1. 你的业务逻辑
                System.out.println("任务正在运行...");
    
                // 2. 如果任务中有可中断的阻塞方法(如 sleep, wait, take)
                //    当它被中断时,会抛出 InterruptedException
                Thread.sleep(1000);
    
            } catch (InterruptedException e) {
                // 3. 捕获到 InterruptedException
                //    这表示有人想让我们停止。
                //    我们应该立即清理资源,并跳出循环。
                System.out.println("收到中断信号,正在停止...");
    
                // (重要) 捕获 InterruptedException 后,中断标志位会被清除。
                // 如果想让循环外的代码也能感知到中断,需要重新设置中断标志。
                Thread.currentThread().interrupt();
    
                // 跳出 while 循环
                break;
            } catch (Exception e) {
                // 处理其他业务异常
            }
        }
        System.out.println("任务已终止。");
    };
    
    • 关键点: 循环的判断条件是 !Thread.currentThread().isInterrupted()
    • 当调用 Thread.sleep() 这样的阻塞方法时,如果线程被中断,它会抛出 InterruptedException
    • 捕获 InterruptedException 后,必须重新设置中断标志 Thread.currentThread().interrupt(),并 break 循环。

    第二步:从外部发起中断

    1. 当你使用 threadPool.submit() 提交任务时,你会得到一个 Future<?> 对象。
    2. 调用 future.cancel(true)
    // 假设我们有一个 ExecutorService
    // ExecutorService threadPool = Executors.newSingleThreadExecutor();
    
    // 1. 提交任务,并保留 Future 对象
    // Future<?> future = threadPool.submit(infiniteTask);
    
    // ... 让任务运行一会儿
    // Thread.sleep(5000);
    
    // 2. 想要停止它时,调用 cancel(true)
    System.out.println("正在从外部请求终止任务...");
    // 参数 true 至关重要,它表示 "mayInterruptIfRunning"
    // 这会向正在执行该任务的线程发送一个 interrupt() 信号
    // future.cancel(true);
    
    // threadPool.shutdown();
    

    为什么是 cancel(true)

    Future.cancel() 方法接受一个布尔参数 mayInterruptIfRunning
    如果为 false,它只会尝试取消尚未开始的任务。
    如果为 true,它会向正在运行该任务的线程发送一个 interrupt() 信号,这正是触发我们 catch (InterruptedException e) 块的关键。

    总结: 优雅地终止任务是一个“双向奔赴”。

    1. 外部调用者: 必须调用 future.cancel(true) 来“打招呼”(发送中断信号)。
    2. 任务执行者: 必须在循环中检查 isInterrupted() 状态,或者在 catch (InterruptedException) 中正确地处理这个“招呼”,然后主动退出。

    如果任务代码写得很烂(例如 while(true) 里面只有计算,没有 sleep 也不检查中断标志),那 cancel(true) 也拿它没办法。

 

3. 手写策略模式

 

  • 问题: 现场手写策略模式。
  • 复盘:
    • 虽然磕磕绊绊写出来了,但很不规范。
    • 标准结构:
      1. Strategy 接口: 定义一个抽象方法(例如 doOperation(int a, int b))。
      2. ConcreteStrategy 类: 多个实现类(例如 AddStrategy, SubtractStrategy),分别实现该接口。
      3. Context 类: 一个上下文类,它持有一个 Strategy 接口的引用。它提供一个 setStrategy() 方法来切换策略,并提供一个 executeStrategy() 方法来调用策略的抽象方法。
    • 反思: 以为不会考这种手写设计模式的。基础设计模式的标准写法还是要背熟,尤其是策略模式、单例模式、工厂模式。

 

4. Lua 脚本与数据一致性

 

  • 问题 1: 在 Redis 的 Lua 脚本里写过多逻辑会导致什么问题?
  • 回答:
    1. 阻塞 Redis: Redis 是单线程的。Lua 脚本的执行是原子性的,在脚本执行期间,Redis 无法处理任何其他命令。如果脚本逻辑过重、执行时间过长,会严重阻塞 Redis,导致吞吐量急剧下降。
    2. 难以调试和维护: 复杂的逻辑写在 Lua 脚本(本质是字符串)里,非常不利于调试、测试和版本控制。
    3. 事务回滚困难: Lua 脚本是原子的,但它没有“回滚”机制。一旦执行到一半出错了,已经执行过的命令无法撤销(除非在脚本里手动写反向操作,但这更复杂了)。
  • 问题 2: 扣减库存场景:如果 Redis(Lua 脚本)扣减成功,但后续数据库扣减失败了怎么办?(数据一致性问题)
  • 我的回答:
    1. 分布式事务(TCC): 使用 TCC(Try-Confirm-Cancel)模式。
      • Try:预扣减 Redis 和 数据库(例如冻结库存)。
      • Confirm:异步 Kafka 创建订单成功后,执行 Confirm,真正扣减库存。
      • Cancel:创建订单失败,执行 Cancel,回滚(解冻)Redis 和数据库的库存。
    2. 定时任务兜底: 启动一个定时任务,定期扫描“Redis 已扣减但数据库未扣减”的异常订单,进行补偿或回滚。
  • 补充方案(面试后思考):
    • 可靠消息最终一致性(MQ): 这是业界更常用的方案。
      1. (上游)扣减 Redis 成功后,向 RocketMQ 发送一个“事务消息”(或“半消息”)。
      2. (上游)发送“半消息”成功后,立即执行数据库扣减(本地事务)。
      3. (上游)如果数据库执行成功,则 Commit 这个半消息,MQ 将其投递给下游(例如订单系统)。
      4. (上游)如果数据库执行失败,则 Rollback 这个半消息,MQ 将其丢弃。
      5. (下游)订单系统消费到消息,创建订单。
    • 这种方案保证了“Redis 扣减”和“数据库扣减”这两个操作的最终一致性

 

总结

 

字节的面试非常注重实战,会逼着你去思考方案的B面(缺点和风险)。这次面试虽然挂了,但也暴露了我在项目深度、算法熟练度和设计模式上的短板。继续刷题,继续深入项目,下次再战!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注