Skip to main content

字节跳动 - AI搜索推荐后端开发

面试基本信息
  • 日期: 2025-12-22
  • 岗位描述
    • 1.以 AI 技术为驱动,主导或参与信息流推荐、多模态搜索、智能内容中台建设;
    • 2.参与亿级内容分发系统架构设计与优化,结合 AI 技术提升搜推一体化效果;
    • 3.构建 AI 能力中台,解决内容理解、生成、合规等内容生态的共性技术需求;
    • 4.参与 SaaS 应用的生产调优,解决大规模场景下系统的性能和稳定性问题。
  • 面试时长: 45分钟
面试复盘

这个岗位从职责描述或者是任职要求上,应该是更偏向 AI 业务应用的搜推方向,面试整体过程中双方的 预期没有对齐,即没有想好要招什么人,也没想匹配对应的描述。更像是一个面向 校招的常规后端没有任何 AI 方向的交流、也没有任何搜推方向的交流,也没有考虑到 社招 3-4 年背景 所需要考察的面试能力。还是希望面试的三方都做好对齐,找什么样的人,考察什么样的能力,提高筛选效率。

01 最近项目复盘

02 消息平台的消息分发如何确保 仅投递一次

核心思路

"仅投递一次"(Exactly-Once) 是消息分发的核心诉求,需从 生产端、中间件、消费端 三层设计保障,结合电商营销消息场景落地。

1. 生产端:幂等 + 事务保障消息不重复发送

  • 本地事务表 + 消息队列双写:发送营销消息前,先在 MySQL 写入 "消息发送日志"(含 msg_id、状态=待发送、业务唯一标识),并与业务操作(如营销任务触发)放在同一事务
  • 幂等发送:生产端为每条消息生成全局唯一 msg_id,发送前校验本地日志,避免重复提交;若发送超时,基于 msg_id 重试,保证消息至少投递到 MQ

2. 中间件:精准投递 + 状态追踪

  • 选用支持 "消息轨迹 + 死信队列" 的 MQ(如 RocketMQ/Kafka
  • 开启 MQ 的 "消息确认机制"(Producer Ack),确保消息成功写入 MQ 集群
  • 对未确认的消息,基于 msg_id 做有限重试(如 3 次),失败则进入死信队列人工介入

3. 消费端:幂等处理 + 事务确认

这是 "仅投递一次" 的核心环节,营销消息推送场景落地如下:

  • 消费幂等:消费端以 msg_id 或 "业务唯一标识(如用户 ID + 营销活动 ID)" 为键,在 Redis/MySQL 中记录 "已消费" 状态,消费前先校验,避免重复处理
  • 消费事务:消费端处理消息(如推送至邮件网关)后,先更新本地消费状态(已消费),再向 MQ 发送 "消费确认"(ACK);若处理失败,不发送 ACK,MQ 会重试投递
  • 重试兜底:设置消费重试次数(如 5 次),超过次数则进入死信队列,避免无限重试导致重复投递

关键补充

营销消息推送允许 "最终一致",若极端场景下出现重复消息,可通过业务层去重兜底:比如用户收到重复推送时,前端展示层基于 "消息唯一标识" 去重,不影响用户体验。

03 MySQL 的事务隔离级别

MySQL 默认事务隔离级别为 REPEATABLE READ(可重复读),基于 InnoDB 存储引擎的 MVCC(多版本并发控制) 实现,4 个隔离级别从低到高如下

隔离级别核心定义解决的问题存在的问题底层实现
READ UNCOMMITTED
(读未提交)
能读取其他事务未提交的数据-脏读、不可重复读、幻读不加锁,直接读取最新数据
READ COMMITTED
(读已提交)
只能读取其他事务已提交的数据脏读不可重复读、幻读MVCC,每次查询生成新的 Read View
REPEATABLE READ
(可重复读)
同一事务内多次读取同一数据,结果一致脏读、不可重复读幻读(InnoDB 已通过 Next-Key Lock 解决)MVCC,事务启动时生成唯一 Read View,全程复用
SERIALIZABLE
(串行化)
最高级别,事务串行执行所有并发问题性能极差,完全丧失并发性对所有读取的行加锁,读写互斥

核心考点补充

  • 脏读(Dirty Read):读取到其他事务未提交的脏数据(如 A 事务修改数据但未提交,B 事务读取该数据,A 事务回滚后 B 读到无效数据)
  • 不可重复读(Non-Repeatable Read):同一事务内多次读取同一数据,结果不同(如 A 事务读取数据后,B 事务修改并提交,A 再次读取数据变化)
  • 幻读(Phantom Read):同一事务内多次执行同一查询,返回的行数不同(如 A 事务查询 "用户 ID>100 的记录",B 事务插入一条 ID=101 的记录,A 再次查询多了一条)
  • InnoDB 优势REPEATABLE READ 通过 Next-Key Lock(行锁 + 间隙锁) 解决了幻读,是 MySQL 的核心优势

04 TCP 的 4 次挥手

TCP 是面向连接的可靠传输协议,"4 次挥手" 是关闭 TCP 连接的标准流程,核心解决 "双方数据都传输完成后,安全关闭连接" 的问题

为什么需要 4 次?

TCP全双工通信(双方可同时收发数据),关闭连接需分别确认 "发送端无数据" 和 "接收端无数据"

  • 第一次挥手:客户端告知服务端 "我不发数据了"(FIN=1
  • 第二次挥手:服务端确认 "知道你不发了,但我可能还在发数据"(ACK=1
  • 第三次挥手:服务端告知客户端 "我也不发数据了"(FIN=1
  • 第四次挥手:客户端确认 "知道你也不发了,连接可关闭"(ACK=1

核心状态变化

  • 客户端FIN_WAIT_1FIN_WAIT_2TIME_WAIT(等待 2MSL,确保服务端收到最后一个 ACK)→ CLOSED
  • 服务端CLOSE_WAITLAST_ACKCLOSED

TIME_WAIT 的作用

  • 客户端发送最后一个 ACK 后,会等待 2 倍 MSLMSL = 报文最大生存时间,默认 1 分钟)
  • 避免最后一个 ACK 丢失,服务端重发 FIN 时客户端能再次响应
  • 确保本次连接的所有报文都从网络中消失,避免新连接收到旧连接的残留报文

05 40 亿个整数去重 (范围 -2^32~+2^32)

针对 40 亿个覆盖 -2³¹ ~ 2³¹-1(-2147483648 ~ 2147483647)的 32 位有符号整数去重,BitMap 是最优方案,以下从核心原理、实现、优化及面试追问展开解析

一、经典 BitMap 方案 - 内存充足时

1. 核心原理

BitMap 本质是 "用位代字节" 的空间优化策略:

  • 32 位有符号整数全集共 2³² = 4,294,967,296 个唯一值
  • 每个值对应 1 个比特位0=不存在、1=存在)
  • 仅需 512MB 内存(4,294,967,296 bit ÷ 8 ÷ 1024 ÷ 1024)即可存储全集状态,远优于 哈希表 的 32GB+
  • 负数 无法直接作为数组下标,需 偏移映射
    • 偏移量 = 整数 + 2,147,483,648(2³¹)
    • 将最小值 -2,147,483,648 映射为 0、最大值 2,147,483,647 映射为 4,294,967,295
    • 反向映射用 "整数 = 偏移量 - 2,147,483,648" 还原

2. 实现步骤与伪代码

数据选型:选用无符号字节数组(1 字节 = 8 bit,存储 8 个整数状态,最小可寻址单元,避免浪费)

简洁伪代码

常量定义:
OFFSET = 2^31 (2147483648)
BITMAP_SIZE = 2^32 / 8 = 512MB

步骤一: 初始化
创建字节数组 bitmap[BITMAP_SIZE],全部置 0

步骤二: 标记存在的整数
for 每个整数 num:
偏移量 = num + OFFSET
字节索引 = 偏移量 / 8
位索引 = 偏移量 % 8
bitmap[字节索引] 的第 位索引 位 置为 1

步骤三: 输出去重结果
for i = 0 to BITMAP_SIZE-1:
if bitmap[i] == 0: 跳过
for bit = 0 to 7:
if bitmap[i] 的第 bit 位为 1:
原整数 = (i * 8 + bit) - OFFSET
输出 原整数

3. 边界处理与性能

边界处理

  • 计算偏移量必须用 64 位整数,避免 32 位溢出
  • 内存分配失败则降级为 分块方案
  • bitIndex 0-7 对应字节最低位到最高位,保证映射一致

性能分析

  • 时间复杂度O(n+M)(n=40 亿输入,M=5.36e8 遍历 BitMap),线性高效
  • 单线程处理 40 亿整数约 10-20 秒,取决于 IO 效率

二、分块 BitMap 方案 - 内存受限

1. 核心思路与设计

当可用内存不足 512MB 时,按 "分而治之" 拆分 BitMap:

  • 按可用内存确定块大小(如 12.5MB 对应 1 亿 bit,覆盖 1 亿个整数
  • 将整数全集拆分为 43 个块
  • 遍历每个块,初始化对应大小 BitMap,仅标记当前块内整数,输出后释放内存

2. 实现伪代码

常量定义:
BLOCK_SIZE = 12.5MB (每块 1 亿整数 / 8)
BLOCK_COUNT = 43
BLOCK_RANGE = 1 亿 (每块覆盖范围)
INT_MIN = -2147483648

分块处理:
for 块编号 = 0 to 42:
// 计算当前块范围
块起始 = INT_MIN + 块编号 * BLOCK_RANGE
块结束 = 块起始 + BLOCK_RANGE - 1
if 是最后一块: 块结束 = 2147483647

// 初始化当前块 BitMap
创建字节数组 bitmap[BLOCK_SIZE],全部置 0

// 标记当前块内整数
for 每个整数 num:
if num 不在 [块起始, 块结束]: 跳过
块内偏移 = num - 块起始
字节索引 = 块内偏移 / 8
位索引 = 块内偏移 % 8
bitmap[字节索引] 的第 位索引 位 置为 1

// 输出当前块去重结果
for i = 0 to BLOCK_SIZE-1:
if bitmap[i] == 0: 跳过
for bit = 0 to 7:
if bitmap[i] 的第 bit 位为 1:
原整数 = 块起始 + (i * 8 + bit)
if 原整数 > 块结束: 退出内层循环
输出 原整数

// 释放当前块内存

3. 优化点

  • 哈希预分片:按 "整数 % 块数" 将 40 亿整数写入对应文件,仅遍历 1 次即可,避免重复遍历 43 次
  • 内存复用:复用缓冲区减少分配释放开销
  • 并行处理:多线程处理不同块,注意线程安全

三、面试追问解答

1. 为何不用哈希表?

  • 空间成本高(需 21GB+
  • 存在 哈希冲突 引入额外开销
  • 去重逻辑需先判断再插入,不如 BitMap 直接标记简洁高效

2. BitMap 缺点?

  • 仅适用于 整数去重/存在性判断,无法存额外信息(统计次数用 Counting BitMap
  • 整数分布不均时浪费内存,可结合范围判断优化

3. 32 位无符号整数调整?

  • 无需偏移,直接用整数作为偏移量
  • BitMap 长度不变,逻辑复用

四、工程落地建议

  • 优先用 calloc 分配内存(自动清零
  • 40 亿整数从文件 按块读取(如每次 1GB),避免一次性加载
  • 添加 内存分配文件读取失败 的容错逻辑
  • 64 位系统必选 64 位整数类型32 位系统需注意寻址限制

06 LeetCode - 394. Decode String

纯聊思路,没有实际编码

class Solution {
/**
* 输入: "3[a2[c]]"
* 模拟流程如下:
* 1. 遇到 '3' → num = 3
* 2. 遇到 '[' → strStack = [""], numStack = [3], ans = ""
* 3. 遇到 'a' → ans = "a"
* 4. 遇到 '2' → num = 2
* 5. 遇到 '[' → strStack = ["a"], numStack = [3, 2], ans = ""
* 6. 遇到 'c' → ans = "c"
* 7. 遇到 ']' → repeat = 2, prev = "a", repeated = "c" * 2, ans = "acc"
* 8. 遇到 ']' → repeat = 3, prev = "", repeated="acc" * 3, ans = "accaccacc"
* 返回结果: "accaccacc"
time: O(n × k), n = s的长度, k = 平均重复次数
space: O(n)
*/
public String decodeString(String s) {
Deque<String> strStack = new ArrayDeque<>();
Deque<Integer> numStack = new ArrayDeque<>();

StringBuilder ans = new StringBuilder();
int num = 0;

for (char c : s.toCharArray()) {
if (Character.isDigit(c)) {
// 构造完整数字 比如 "12" 要变成 12 而不是1和2
num = num * 10 + c - '0';
} else if (c == '[') {
// 遇到左括号 把当前结果和数字压入栈
strStack.push(ans.toString());
numStack.push(num);
ans = new StringBuilder(); // 进入下一层时清空当前结果
num = 0;
} else if (c == ']') {
String prevStr = strStack.pop(); // 括号前的字符串
int repeat = numStack.pop(); // 括号前的重复次数
String repeated = ans.toString().repeat(repeat); // 重复当前构建字符串
ans = new StringBuilder(prevStr).append(repeated); // 拼接结果
} else {
// 普通字母
ans.append(c);
}
}
return ans.toString();
}
}

07 LeetCode - 91. Decode Ways

纯聊思路,没有实际编码

class Solution {
public int numDecodings(String s) {
int n = s.length();
// bad case
if (n == 0) return 0;

// 构造字符串dp的时候牢记 + 1
// dp[i] = 以s[i]结尾的子串有几个decode ways
int[] dp = new int[n + 1];
// init. 空字符串时候 return 1
dp[0] = 1;

// 首字符是 '0'吗? 如果是 那就不能构成 item,如果不是 能构成1个 item
dp[1] = s.charAt(0) == '0' ? 0 : 1;

for (int i = 2; i <= n; i++) {
char a = s.charAt(i - 1);
char b = s.charAt(i - 2);

// 单个char 可以组成 item
if (a >= '1' && a <= '9')
dp[i] += dp[i - 1];
// 两个 char 一起组成
if (b == '1' || (b == '2' && a <= '6'))
dp[i] += dp[i - 2];
}
return dp[n];
}
}

反问

没有任何想问的