字节跳动 - 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_1→FIN_WAIT_2→TIME_WAIT(等待 2MSL,确保服务端收到最后一个 ACK)→CLOSED - 服务端:
CLOSE_WAIT→LAST_ACK→CLOSED
TIME_WAIT 的作用
- 客户端发送最后一个
ACK后,会等待 2 倍 MSL(MSL = 报文最大生存时间,默认 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];
}
}
反问
没有任何想问的