汇总范围:最近几次发来的字节面试题和面经,包括国际化内容安全平台研发岗(工程岗)、Data 北京全栈岗。 每道题都有原题描述、思路、代码和讲解。算法题的 Java 代码、拉链表 SQL 和重复邮箱 SQL 都实际运行验证过。 Go 代码和 Flink 代码因为环境限制没能运行,已逐行检查。 练习原则:看懂答案不等于会写。每道题都要合上答案、关掉代码补全,在白纸上默写到一次写对。
答题纪律:先口述思路和边界 → 说暴力解法的复杂度 → 写最优解 → 用题目例子走一遍 → 说复杂度和隐藏考点。卡住时先写一个能跑的简单版本,不能只讲思路。链表题一律加虚拟头节点。
来源:字节·国际化内容安全平台研发(工程岗),招聘方给的常考题(10 月)
题目
实现链表反转,输入头节点,返回反转后的链表头节点。
type ListNode struct {
Val int
Next *ListNode
}
func reverseList(head *ListNode) *ListNode {
}答案
思路:用三个指针:prev(已反转部分的头)、cur(当前节点)、next(暂存下一个节点)。每一步把 cur 的指针指向 prev,然后三个指针整体往后挪一位。
复杂度:时间 O(n),空间 O(1)。递归写法空间是 O(n)(递归调用栈),链表很长时可能栈溢出,所以推荐迭代写法。
Go 版
type ListNode struct {
Val int
Next *ListNode
}
func reverseList(head *ListNode) *ListNode {
var prev *ListNode // 已反转部分的头节点,初始为空
cur := head // 当前要处理的节点
for cur != nil {
next := cur.Next // 1. 先保存下一个节点,否则改指针后就找不到了
cur.Next = prev // 2. 当前节点指向前一个节点,完成反转
prev = cur // 3. prev 前进一步
cur = next // 4. cur 前进一步
}
return prev // 循环结束时 cur 为空,prev 就是新的头节点
}Java 版(已实际运行验证)
public ListNode reverseList(ListNode head) {
ListNode prev = null, cur = head;
while (cur != null) {
ListNode next = cur.next; // 保存下一个节点
cur.next = prev; // 反转指针
prev = cur; // prev 前进
cur = next; // cur 前进
}
return prev;
}边界情况(主动说):空链表返回空;只有一个节点返回它本身。代码不需要特殊处理,循环自然覆盖。
用例走一遍:1→2→3。第一轮 1 指向空,第二轮 2 指向 1,第三轮 3 指向 2,返回 3,得到 3→2→1。
可能的追问: - 「递归怎么写?」→ 先递归反转后面的部分,再让 head.next.next = head,head.next = null,返回新的头节点。说明空间复杂度是 O(n)。 - 「反转从位置 m 到 n 的部分?」→ 用一个虚拟头节点,找到第 m-1 个节点,然后对中间部分逐个用头插法。 - 「每 k 个一组反转?」→ 每段先数够 k 个,再反转这一段,接回原链表;不足 k 个的保持原样。
来源:字节 Data 北京全栈岗,其他候选人面经(11 月 6 日)
题目
将两个单链表相加。
输入:(2 -> 4 -> 3) + (5 -> 6 -> 4)
输出:7 -> 0 -> 8
答案
题目:(2→4→3) + (5→6→4) = 7→0→8(数字逆序存储,即 342 + 465 = 807)。
思路:同时遍历两个链表,逐位相加并带上进位;用一个虚拟头节点简化代码。循环条件写成“两个链表有一个没走完,或者还有进位”,这样长度不同和最后的进位都能自然处理。
复杂度:时间 O(max(m, n)),结果链表之外空间 O(1)。
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0), tail = dummy; // 虚拟头节点,避免单独处理第一个节点
int carry = 0;
while (l1 != null || l2 != null || carry != 0) {
int sum = carry;
if (l1 != null) { sum += l1.val; l1 = l1.next; }
if (l2 != null) { sum += l2.val; l2 = l2.next; }
carry = sum / 10; // 进位
tail.next = new ListNode(sum % 10); // 当前位
tail = tail.next;
}
return dummy.next;
}已验证:243 + 564 得 7→0→8;9999 + 1 得 0→0→0→0→1(最后的进位要生成新节点);0 + 0 得 0。
隐藏考点:长度不同、最后有进位、不要先转成整数再相加(长链表会溢出)。
追问:「如果数字是正序存储的呢(7→2→4→3 表示 7243)?」先把两个链表反转再用这个方法,或者用两个栈把节点倒过来,再从低位开始加。
来源:字节 Data 北京全栈岗,其他候选人面经(11 月 7 日)
题目
一个单向链表,输入头节点,删除链表的倒数第 n 个节点,输出结果链表。
case 1:链表 1->2->3->4->5,n = 2,输出 [1,2,3,5]
case 2:链表 1,n = 1,输出 []
case 3:链表 1->2,n = 1,输出 [1]
答案
思路:快慢指针。快指针先走 n 步,然后两个指针一起走;快指针走到最后一个节点时,慢指针正好停在要删除节点的前一个。用虚拟头节点处理“删除的是头节点”的情况。
复杂度:一次遍历,时间 O(L),空间 O(1)。
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0); // 虚拟头节点:删除头节点时不用特殊处理
dummy.next = head;
ListNode fast = dummy, slow = dummy;
for (int i = 0; i < n; i++) { // 快指针先走 n 步
if (fast.next == null) throw new IllegalArgumentException("n 大于链表长度");
fast = fast.next;
}
while (fast.next != null) { // 一起走,直到快指针到最后一个节点
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next; // 慢指针的下一个就是要删的节点
return dummy.next; // 不能返回 head:head 可能已经被删掉了
}已验证:[1,2,3,4,5] 删倒数第 2 个得 [1,2,3,5];[1] 删倒数第 1 个得 [];[1,2] 删倒数第 1 个得 [1];[1,2] 删倒数第 2 个得 [2](删的是头节点)。
易错点:返回值要用 dummy.next,不能用 head,因为头节点可能被删了。
来源:字节 Data 北京全栈岗,其他候选人面经(11 月 7 日)
题目
输入:1->3->4->8,n = 2
输出:1->4->8
答案
思路:用虚拟头节点,走到第 n−1 个节点(要删节点的前一个),把它的 next 跳过一个。
public ListNode removeNth(ListNode head, int n) {
if (n <= 0) throw new IllegalArgumentException("n 必须大于 0");
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode prev = dummy;
for (int i = 1; i < n; i++) { // 走到第 n-1 个节点
if (prev.next == null) throw new IllegalArgumentException("n 大于链表长度");
prev = prev.next;
}
if (prev.next == null) throw new IllegalArgumentException("n 大于链表长度");
prev.next = prev.next.next; // 跳过第 n 个节点
return dummy.next;
}已验证:1→3→4→8 删第 2 个得 1→4→8;删第 1 个得 3→4→8;删第 4 个得 1→3→4;n 越界会报错。
来源:字节 Data 北京全栈岗,其他候选人面经(11 月 7 日)
题目
给你链表的头节点 head,每 k 个节点一组进行翻转,返回修改后的链表。k 是正整数,小于或等于链表长度。如果节点总数不是 k 的整数倍,最后剩余的节点保持原有顺序。不能只改节点内部的值,需要实际交换节点。
输入:head = [1,2,3,4,5], k = 2,输出:[2,1,4,3,5]
输入:head = [1,2,3,4,5], k = 3,输出:[3,2,1,4,5]
答案
思路:每次先往后数 k 个节点,不够 k 个就停止(剩下的保持原样);够 k 个就把这一段原地反转,再接回原链表。
需要记住四个指针: - groupPrev:这一组的前一个节点(第一组就是虚拟头节点) - kth:这一组的第 k 个节点(反转后它变成这一组的头) - groupNext:下一组的第一个节点 - 反转时把 prev 初始化为 groupNext,反转完这一组的尾巴自然就接上了下一组
public ListNode reverseKGroup(ListNode head, int k) {
if (head == null || k <= 1) return head;
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode groupPrev = dummy; // 当前组的前一个节点
while (true) {
// 1. 找到当前组的第 k 个节点,不够 k 个就结束
ListNode kth = groupPrev;
for (int i = 0; i < k && kth != null; i++) kth = kth.next;
if (kth == null) break;
ListNode groupNext = kth.next; // 下一组的第一个节点
// 2. 反转当前组。prev 从 groupNext 开始,反转后组尾直接连上下一组
ListNode prev = groupNext, cur = groupPrev.next;
while (cur != groupNext) {
ListNode next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
// 3. 接回原链表:原来的第一个节点现在是组尾,作为下一组的 groupPrev
ListNode oldFirst = groupPrev.next;
groupPrev.next = kth; // 组头变成原来的第 k 个
groupPrev = oldFirst;
}
return dummy.next;
}已验证:[1,2,3,4,5] k=2 得 [2,1,4,3,5];k=3 得 [3,2,1,4,5];k=1 不变;[1,2,3,4] k=4 得 [4,3,2,1];[1,2,3,4,5,6] k=3 得 [3,2,1,6,5,4]。
画图讲解(面试时边画边讲):以 1→2→3→4→5、k=2 为例。第一组:groupPrev 是虚拟头,kth 是 2,groupNext 是 3。反转后是 2→1→3,虚拟头指向 2,groupPrev 移到 1。第二组同理得到 4→3→5。第三组只剩 5,不够 2 个,停止。
复杂度:每个节点被访问常数次,时间 O(n),空间 O(1)。
如果实在写不出来的保底方案:把整个链表的值读进数组,每 k 个一段在数组里反转,再写回链表。但题目要求不能只改值、必须交换节点,所以这只能作为“先写个能跑的版本”,写完要说明“这个版本改的是值,题目要求交换节点,我再改成原地反转”。
来源:字节 Data 北京全栈岗,其他候选人面经(11 月 7 日)
题目
给你一个单链表的头节点 head,判断该链表是否为回文链表。
输入:[1,2,2,1],输出:true
输入:[1,2],输出:false
答案
思路: - 简单解法:把值放进数组,用双指针从两头往中间比较。时间 O(n),空间 O(n)。先写这个保底。 - 最优解(空间 O(1),加分):①快慢指针找中点;②反转后半段;③前半段和反转后的后半段逐个比较;④把后半段再反转回来,恢复原链表。
public boolean isPalindrome(ListNode head) {
if (head == null || head.next == null) return true;
// 1. 找中点:快指针每次两步,慢指针每次一步;结束时 slow 停在前半段的最后一个节点
ListNode slow = head, fast = head;
while (fast.next != null && fast.next.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 2. 反转后半段
ListNode secondHead = reverse(slow.next);
// 3. 比较。以后半段为准:奇数长度时中间节点归前半段,不参与比较
ListNode p1 = head, p2 = secondHead;
boolean ok = true;
while (p2 != null) {
if (p1.val != p2.val) { ok = false; break; }
p1 = p1.next;
p2 = p2.next;
}
// 4. 恢复原链表(加分点:不破坏调用方的数据)
slow.next = reverse(secondHead);
return ok;
}
private ListNode reverse(ListNode head) {
ListNode prev = null, cur = head;
while (cur != null) {
ListNode next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
}已验证:[1,2,2,1] 为 true,而且判断完链表恢复成 [1,2,2,1];[1,2] 为 false;[1,2,3,2,1] 为 true;单个节点和空链表为 true;[1,2,3] 为 false。
隐藏考点:奇数长度的中间节点怎么处理;判断完要恢复链表;多线程同时读这个链表时,临时修改会有问题,这种情况就用数组解法。
来源:字节 Data 北京全栈岗,其他候选人面经(11 月 6 日);字节 Data 北京全栈岗,其他候选人面经(11 月 7 日)
题目
给定一个字符串 s,找出其中不含有重复字符的最长子串的长度。
输入:"abcabcabc",输出:3(无重复字符的最长子串是 abc)
输入:"abcabcbb",输出:3
输入:"bbbbb",输出:1
答案
题目:abcabcabc 返回 3。
思路:滑动窗口。用一个哈希表记录每个字符最后一次出现的位置。右指针每走一步,如果当前字符在窗口里出现过,左指针直接跳到它上次出现位置的下一位;每一步更新最大长度。
复杂度:时间 O(n);空间 O(字符集大小)。
public int lengthOfLongestSubstring(String s) {
if (s == null || s.isEmpty()) return 0;
Map<Character, Integer> last = new HashMap<>(); // 字符 -> 最后一次出现的位置
int left = 0, best = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
Integer prev = last.get(c);
if (prev != null && prev >= left) { // 只有上次出现在当前窗口内,才需要移动左边界
left = prev + 1;
}
last.put(c, right);
best = Math.max(best, right - left + 1);
}
return best;
}已验证:abcabcabc 为 3,bbbbb 为 1,pwwkew 为 3,空字符串为 0,abba 为 2,单个空格为 1。
隐藏考点:prev >= left 这个判断不能少。以 abba 为例,走到最后一个 a 时,a 上次的位置是 0,已经在窗口左边了,如果不加判断,左边界会倒退回 1,结果就错了。
优化:字符集只有 ASCII 时,用长度 128 的数组代替哈希表,更快。
数组版本(字符都在 ASCII 范围内时更快):
public int lengthOfLongestSubstring(String s) {
int[] last = new int[128]; // 每个字符最后一次出现的位置
Arrays.fill(last, -1);
int left = 0, best = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
if (last[c] >= left) left = last[c] + 1; // 重复字符在窗口内,左边界跳过它
last[c] = right;
best = Math.max(best, right - left + 1);
}
return best;
}已验证:abcabcbb 为 3,bbbbb 为 1,abba 为 2,空字符串为 0。
来源:字节 Data 北京全栈岗,其他候选人面经(11 月 7 日)
题目
给定一个只包括 ‘(’,‘)’,‘{’,‘}’,‘[’,’]’ 的字符串 s,判断字符串是否有效:左括号必须用相同类型的右括号闭合;左括号必须以正确的顺序闭合;每个右括号都有一个对应的相同类型的左括号。
"()" → true,"()[]{}" → true,"(]" → false,"([])" → true,"([)]" → false
答案
思路:用栈。遇到左括号,就把对应的右括号压栈;遇到右括号,就弹出栈顶比较,不一样或者栈是空的就返回 false。最后栈必须为空。
public boolean isValid(String s) {
if (s == null || s.length() % 2 == 1) return false; // 长度为奇数一定无效,提前返回
Deque<Character> stack = new ArrayDeque<>(); // 用 ArrayDeque,不用过时的 Stack 类
for (char c : s.toCharArray()) {
if (c == '(') stack.push(')'); // 压入期望出现的右括号,比较时更简单
else if (c == '[') stack.push(']');
else if (c == '{') stack.push('}');
else if (stack.isEmpty() || stack.pop() != c) return false;
}
return stack.isEmpty(); // 还有没闭合的左括号也是无效
}已验证:()、()[]{}、([])、{[]} 为 true;(]、([)]、(、)( 为 false。
复杂度:时间 O(n),空间 O(n)。
隐藏考点:右括号多出来时栈是空的,要先判断再弹出;最后要检查栈为空;长度为奇数可以提前返回;Java 里推荐用 ArrayDeque 而不是 Stack(Stack 是同步的,性能差)。
来源:字节 Data 北京全栈岗,其他候选人面经(11 月 7 日)
题目
编写一个函数来查找字符串数组中的最长公共前缀。如果不存在公共前缀,返回空字符串。
输入:["flower","flow","flight"],输出:"fl"
输入:["dog","racecar","car"],输出:""
答案
思路:纵向扫描。以第一个字符串为基准,逐个位置检查所有字符串在这个位置的字符是否相同;某个字符串到头了或者字符不同,就返回到当前位置为止的前缀。
public String longestCommonPrefix(String[] strs) {
if (strs == null || strs.length == 0) return "";
String first = strs[0];
for (int i = 0; i < first.length(); i++) {
char c = first.charAt(i);
for (int j = 1; j < strs.length; j++) {
// 其他字符串到头了,或者这个位置的字符不同
if (i >= strs[j].length() || strs[j].charAt(i) != c) {
return first.substring(0, i);
}
}
}
return first; // 第一个字符串整体就是公共前缀
}已验证:[“flower”,“flow”,“flight”] 得 “fl”;[“dog”,“racecar”,“car”] 得 "“;只有一个字符串时返回它本身;含空字符串时返回”“;["abc","abcd"] 得”abc"。
复杂度:时间 O(所有字符串的字符总数),最坏情况每个字符都比较一次;空间 O(1)。
追问:「另一种思路?」把字符串排序后,只需要比较第一个和最后一个字符串的公共前缀(字典序最远的两个)。但排序是 O(n log n),不一定更快。
来源:字节 Data 北京全栈岗,其他候选人面经(11 月 6 日)
题目
input: "[1,2,3,[44,5]]"
output: [1,2,3,[44,5]]
"[1,2,3]" -> [1,2,3]
要求:遍历字符串;输出总是数组;输入不会是空字符串
答案
题目:"[1,2,3,[44,5]]" 转成 [1,2,3,[44,5]],"[1,2,3]" 转成 [1,2,3]。
思路:用栈。遇到 [ 新建一个列表,如果栈里已有列表,就把新列表加进栈顶列表,然后入栈;遇到数字就读完整个数字(可能是多位数或负数),加入栈顶列表;遇到 ] 就出栈,最后一次出栈的就是结果;逗号和空格跳过。
复杂度:每个字符只看一次,时间 O(n);栈的深度等于嵌套层数,空间 O(n)。
public List<Object> parse(String s) {
if (s == null) throw new IllegalArgumentException("input is null");
s = s.trim();
if (s.isEmpty() || s.charAt(0) != '[') throw new IllegalArgumentException("must start with [");
Deque<List<Object>> stack = new ArrayDeque<>(); // 栈顶是当前正在填充的列表
List<Object> result = null;
int i = 0, n = s.length();
while (i < n) {
char c = s.charAt(i);
if (c == '[') {
if (stack.isEmpty() && result != null) { // 已经有一个完整的顶层数组了,又出现一个
throw new IllegalArgumentException("more than one top-level array at " + i);
}
List<Object> list = new ArrayList<>();
if (!stack.isEmpty()) stack.peek().add(list); // 嵌套:挂到外层列表上
stack.push(list);
i++;
} else if (c == ']') {
if (stack.isEmpty()) throw new IllegalArgumentException("unmatched ] at " + i);
result = stack.pop(); // 最后一次出栈的就是最外层
i++;
} else if (c == '-' || Character.isDigit(c)) {
int start = i++;
while (i < n && Character.isDigit(s.charAt(i))) i++; // 读完整个多位数
if (stack.isEmpty()) throw new IllegalArgumentException("number outside array at " + start);
stack.peek().add(Long.parseLong(s.substring(start, i)));
} else if (c == ',' || c == ' ') {
i++;
} else {
throw new IllegalArgumentException("illegal char '" + c + "' at " + i);
}
}
if (!stack.isEmpty()) throw new IllegalArgumentException("unmatched [");
return result;
}已验证:[1,2,3,[44,5]]、[1,2,3]、[]、[[],[1,[2,[3]]]]、带空格和负数的 [ -7 , 10 ] 都正确;[1,2、[1]]、[a]、[1][2] 都能报错。
隐藏考点:多位数(44 不能拆成 4 和 4)、负数、空数组、多层嵌套、括号不匹配、非法字符;数字用 Long 防止溢出。
追问:「能用递归写吗?」能:写一个函数解析从某个 [ 开始的数组,遇到嵌套的 [ 就递归调用自己,遇到 ] 返回。递归代码更短,但嵌套很深时可能栈溢出,栈的写法更稳。
来源:字节·国际化内容安全平台研发(工程岗),招聘方给的常考题(10 月)
题目
输入:[[1,3],[2,6],[8,10],[15,18]]
输出:[[1,6],[8,10],[15,18]]
解释:区间 [1,3] 和 [2,6] 重叠,合并为 [1,6]
答案
思路: - 暴力解法:两两比较是否重叠,反复合并,时间 O(n²),会扣分,只口头提一下。 - 最优解:先按区间起点排序,排序后能重叠的区间一定挨在一起。然后一次遍历:如果当前区间的起点小于等于结果中最后一个区间的终点,就合并(终点取两者较大值);否则把当前区间作为新区间加入结果。
复杂度:排序 O(n log n),遍历 O(n),总体 O(n log n);结果数组的额外空间 O(n)。
Go 版
import "sort"
func merge(intervals [][]int) [][]int {
if len(intervals) <= 1 {
return intervals
}
// 按起点排序。注意:这会修改传入的切片,如果调用方不希望被修改,要先复制一份
sort.Slice(intervals, func(i, j int) bool {
return intervals[i][0] < intervals[j][0]
})
res := make([][]int, 0, len(intervals)) // 预分配容量,避免多次扩容
res = append(res, []int{intervals[0][0], intervals[0][1]}) // 复制一份,不和输入共享底层数组
for _, cur := range intervals[1:] {
last := res[len(res)-1]
if cur[0] <= last[1] { // 有重叠(相接也算重叠,比如 [1,4] 和 [4,5])
if cur[1] > last[1] {
last[1] = cur[1] // 终点取较大值;last 和结果共享底层数组,直接修改即可
}
} else {
res = append(res, []int{cur[0], cur[1]}) // 没有重叠,加入新区间
}
}
return res
}Java 版(已实际运行验证)
public int[][] merge(int[][] intervals) {
if (intervals == null || intervals.length <= 1) {
return intervals == null ? new int[0][] : intervals;
}
int[][] arr = intervals.clone(); // 不修改调用方传入的数组
Arrays.sort(arr, (a, b) -> Integer.compare(a[0], b[0])); // 按起点排序;用 compare 避免减法溢出
List<int[]> res = new ArrayList<>();
int start = arr[0][0], end = arr[0][1];
for (int i = 1; i < arr.length; i++) {
if (arr[i][0] <= end) {
end = Math.max(end, arr[i][1]); // 重叠:扩展终点
} else {
res.add(new int[]{start, end}); // 不重叠:保存上一个区间
start = arr[i][0];
end = arr[i][1];
}
}
res.add(new int[]{start, end}); // 别忘了最后一个区间
return res.toArray(new int[0][]);
}隐藏考点(主动讲): 1. 包含关系:[1,10] 和 [2,3],合并后终点必须取较大值 10,而不是直接用后一个区间的终点。这是最常见的错误。 2. 首尾相接:[1,4] 和 [4,5] 算重叠,合并为 [1,5],所以判断用小于等于。 3. 最后一个区间:循环结束后要把最后一个区间加进结果。 4. 排序比较器:Java 里用 Integer.compare 而不是 a[0] - b[0],后者在数值很大时会溢出。 5. 不修改输入:排序会改变原数组,必要时先复制。 6. 非法输入:区间起点大于终点、区间长度不是 2,可以提一句做参数校验。
已验证的用例: - [[1,3],[2,6],[8,10],[15,18]] → [[1,6],[8,10],[15,18]] - [[1,4],[4,5]] → [[1,5]] - [[1,10],[2,3]] → [[1,10]] - [[8,10],[1,3]](乱序)→ [[1,3],[8,10]] - 空数组 → 空数组
可能的追问: - 「插入一个新区间再合并?」→ 区间已有序时可以一次遍历:先加入所有在新区间左边的,再合并重叠的,最后加入右边的,O(n)。 - 「数据量很大放不进内存?」→ 外部排序,分块排序后多路归并,归并的同时做合并。
来源:字节·国际化内容安全平台研发,面试现场题(10 月)
题目
给定 n 个 Agent 工具和依赖关系 dependencies,其中 [a,b] 表示工具 a 必须在工具 b 完成后才能执行。请返回最少的并行执行批次;同一批中的工具可以并行。如果存在循环依赖,返回空列表。
示例:n=4,dependencies=[[1,0],[2,0],[3,1],[3,2]],结果为 [[0],[1,2],[3]]
答案
思路:分层的拓扑排序(Kahn 算法)
[a, b] 表示 a 依赖 b,所以连一条从 b 指向 a 的边,同时 a 的入度加 1。入度就是“还有几个前置工具没完成”。为什么批次数最少:每个工具都在它的所有前置工具完成后的第一时间进入批次,不会被无谓推迟,所以总批次数等于最长依赖链的长度。
Java 版(已运行验证)
public List<List<Integer>> batches(int n, int[][] deps) {
List<List<Integer>> res = new ArrayList<>();
if (n <= 0) return res;
// 1. 建图:next[b] 存放依赖 b 的工具;indeg[a] 是 a 还没完成的前置工具数
List<List<Integer>> next = new ArrayList<>();
for (int i = 0; i < n; i++) next.add(new ArrayList<>());
int[] indeg = new int[n];
for (int[] d : deps) {
int a = d[0], b = d[1];
if (a < 0 || a >= n || b < 0 || b >= n) {
throw new IllegalArgumentException("工具编号越界");
}
next.get(b).add(a);
indeg[a]++;
}
// 2. 第一批:没有任何依赖的工具
List<Integer> cur = new ArrayList<>();
for (int i = 0; i < n; i++) {
if (indeg[i] == 0) cur.add(i);
}
// 3. 逐批推进
int done = 0;
while (!cur.isEmpty()) {
res.add(cur);
done += cur.size();
List<Integer> nxt = new ArrayList<>();
for (int u : cur) {
for (int v : next.get(u)) {
if (--indeg[v] == 0) nxt.add(v); // 前置工具全部完成,进入下一批
}
}
Collections.sort(nxt); // 批内排序,保证输出稳定(可选)
cur = nxt;
}
// 4. 有工具没执行到,说明存在循环依赖
return done == n ? res : new ArrayList<>();
}Go 版
func batches(n int, deps [][]int) [][]int {
if n <= 0 {
return [][]int{}
}
next := make([][]int, n) // next[b] 存放依赖 b 的工具
indeg := make([]int, n) // indeg[a] 是 a 还没完成的前置工具数
for _, d := range deps {
a, b := d[0], d[1]
next[b] = append(next[b], a)
indeg[a]++
}
cur := []int{}
for i := 0; i < n; i++ {
if indeg[i] == 0 {
cur = append(cur, i)
}
}
res := [][]int{}
done := 0
for len(cur) > 0 {
res = append(res, cur)
done += len(cur)
nxt := []int{}
for _, u := range cur {
for _, v := range next[u] {
indeg[v]--
if indeg[v] == 0 {
nxt = append(nxt, v)
}
}
}
sort.Ints(nxt)
cur = nxt
}
if done < n {
return [][]int{} // 存在循环依赖
}
return res
}复杂度:时间 O(n + m),m 是依赖关系的条数;空间 O(n + m)。批内排序最坏多一个 O(n log n),不需要稳定输出时可以去掉。
已验证:题目示例、有环、没有依赖、自己依赖自己、重复依赖、多层依赖、空输入,7 个用例全部正确。
隐藏考点: 1. 边的方向:[a, b] 是 a 依赖 b,边从 b 指向 a,写之前先和面试官确认。 2. 循环依赖:统计执行数量是否等于 n 就够了,自己依赖自己([0, 0])也会被识别成环。 3. 没有依赖的工具自然出现在第一批;重复的依赖关系入度加两次也减两次,结果仍正确;工具编号越界要校验。 4. 用邻接表,不用邻接矩阵,避免 O(n²) 空间。
用例走一遍:n=4,依赖 [[1,0],[2,0],[3,1],[3,2]]。入度:0 号为 0,1 号为 1,2 号为 1,3 号为 2。第一批 [0];0 完成后 1、2 入度变 0,第二批 [1,2];都完成后 3 的入度减到 0,第三批 [3]。共执行 4 个,返回 [[0],[1,2],[3]]。
可能的追问: - 「每个工具执行时间不同,最短总耗时是多少?」按拓扑顺序算每个工具的最早开始时间(所有前置工具结束时间的最大值),总耗时是所有结束时间的最大值,对应的链就是关键路径。 - 「每批最多并行 k 个怎么办?」问题会变难,一般没有已知的高效最优解法,工程上用启发式方法,比如优先执行后面挂着更长依赖链的工具。 - 「真实的智能体工具编排怎么实现?」不必等一整批结束,任何一个工具完成就检查下游能否启动;Go 里每个工具一个 goroutine,用 channel 通知完成,用 WaitGroup 等待全部结束;还要处理失败、超时和并发数限制。
来源:字节·国际化内容安全平台研发(工程岗),招聘方给的常考题(10 月)
题目
class Event {
on(eventName, fn) {}
trigger(eventName, data) {}
off(eventName, fn) {}
}
答案
题目:实现 on(订阅)、trigger(触发)、off(取消订阅)。
思路:维护一个“事件名 → 处理函数列表”的映射。on 往列表里加,off 从列表里删,trigger 遍历列表依次调用。
隐藏考点(主动讲,都是加分项): 1. 并发安全:多线程同时订阅和触发,要用线程安全的容器或加锁。 2. 触发过程中取消订阅:如果处理函数在执行时取消了自己的订阅,普通列表在遍历时被修改会出错。解决:遍历一个快照(写时复制)。 3. 一个处理函数出错不影响其他:每个处理函数单独捕获异常。 4. 只触发一次(once):常见的追问。 5. 取消订阅时怎么识别函数:Java 里同一个 lambda 对象引用才能被删掉;Go 里函数不能用等号比较,所以要返回一个订阅 ID,用 ID 来取消。这一点是 Go 版的关键考点。 6. 内存泄漏:列表空了要把事件名从映射里删掉。
Java 版(已实际运行验证)
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
public class EventBus {
// 事件名 -> 处理函数列表。ConcurrentHashMap 保证并发安全;
// CopyOnWriteArrayList 写时复制,遍历时拿到的是快照,触发过程中增删订阅也不会出错
private final ConcurrentHashMap<String, CopyOnWriteArrayList<Consumer<Object>>> handlers =
new ConcurrentHashMap<>();
// 订阅:事件不存在就原子地创建列表,然后加入处理函数
public void on(String event, Consumer<Object> fn) {
if (event == null || fn == null) {
throw new IllegalArgumentException("event/fn must not be null");
}
handlers.computeIfAbsent(event, k -> new CopyOnWriteArrayList<>()).add(fn);
}
// 取消订阅:fn 为空时取消该事件的全部订阅;列表空了就删掉事件,防止内存泄漏
public void off(String event, Consumer<Object> fn) {
List<Consumer<Object>> list = handlers.get(event);
if (list == null) return;
if (fn == null) {
handlers.remove(event);
return;
}
list.remove(fn);
if (list.isEmpty()) handlers.remove(event, list);
}
// 只触发一次:包一层,执行前先把自己取消掉
public void once(String event, Consumer<Object> fn) {
Consumer<Object>[] holder = new Consumer[1];
holder[0] = data -> {
off(event, holder[0]);
fn.accept(data);
};
on(event, holder[0]);
}
// 触发:遍历快照依次调用;单个处理函数出错只记录,不影响其他处理函数
public int trigger(String event, Object data) {
List<Consumer<Object>> list = handlers.get(event);
if (list == null) return 0;
int success = 0;
for (Consumer<Object> fn : list) {
try {
fn.accept(data);
success++;
} catch (Exception e) {
// 生产环境记日志和监控
}
}
return success;
}
}Go 版
package main
import (
"fmt"
"sync"
)
type Handler func(data interface{})
type subscription struct {
id uint64
fn Handler
once bool
}
type Event struct {
mu sync.RWMutex // 读写锁:触发时读,订阅和取消时写
subs map[string][]*subscription // 事件名 -> 订阅列表
nextID uint64 // 自增订阅 ID
}
func NewEvent() *Event {
return &Event{subs: make(map[string][]*subscription)}
}
// On 订阅事件,返回订阅 ID。Go 的函数不能用 == 比较,所以用 ID 来取消订阅
func (e *Event) On(name string, fn Handler) uint64 {
return e.add(name, fn, false)
}
// Once 订阅只触发一次的事件
func (e *Event) Once(name string, fn Handler) uint64 {
return e.add(name, fn, true)
}
func (e *Event) add(name string, fn Handler, once bool) uint64 {
if fn == nil {
return 0
}
e.mu.Lock()
defer e.mu.Unlock()
e.nextID++
e.subs[name] = append(e.subs[name], &subscription{id: e.nextID, fn: fn, once: once})
return e.nextID
}
// Off 取消订阅;id 为 0 表示取消该事件的全部订阅
func (e *Event) Off(name string, id uint64) {
e.mu.Lock()
defer e.mu.Unlock()
if id == 0 {
delete(e.subs, name)
return
}
e.removeLocked(name, id)
}
// removeLocked 调用前必须已持有写锁。
// 删除时新建切片,不修改旧切片,正在触发的遍历拿的是旧切片,不受影响(写时复制)
func (e *Event) removeLocked(name string, id uint64) bool {
list := e.subs[name]
for i, s := range list {
if s.id == id {
newList := make([]*subscription, 0, len(list)-1)
newList = append(newList, list[:i]...)
newList = append(newList, list[i+1:]...)
if len(newList) == 0 {
delete(e.subs, name) // 列表空了删掉事件,防止内存泄漏
} else {
e.subs[name] = newList
}
return true
}
}
return false
}
// Trigger 同步触发,返回成功执行的处理函数个数
func (e *Event) Trigger(name string, data interface{}) int {
e.mu.RLock()
list := e.subs[name] // 拿到当前列表的快照
e.mu.RUnlock() // 先释放锁再执行处理函数,避免处理函数里调用 On/Off 时死锁
count := 0
for _, s := range list {
if s.once {
e.mu.Lock()
removed := e.removeLocked(name, s.id) // 并发触发时,只有成功删除的那次才执行
e.mu.Unlock()
if !removed {
continue
}
}
if e.safeCall(s.fn, data) {
count++
}
}
return count
}
// safeCall 捕获单个处理函数的 panic,不影响其他处理函数
func (e *Event) safeCall(fn Handler, data interface{}) (ok bool) {
defer func() {
if r := recover(); r != nil {
fmt.Println("handler panic recovered:", r)
ok = false
}
}()
fn(data)
return true
}讲解要点(面试时按这个顺序讲): 1. 数据结构:映射加列表,时间复杂度:订阅 O(1),触发 O(k),k 是这个事件的订阅数;取消 O(k)。 2. 并发:Java 用并发容器;Go 用读写锁,触发时先拿快照、释放锁,再执行处理函数,否则处理函数里如果调用订阅或取消会死锁。 3. 快照为什么安全:取消订阅时新建切片,不改旧切片,正在遍历的快照不受影响。 4. 异常隔离:Java 用 try-catch,Go 用 defer 加 recover。 5. Go 的函数不能比较,所以用订阅 ID。
可能的追问: - 「异步触发怎么做?」→ Java 把处理函数提交到线程池;Go 每个处理函数开一个 goroutine,但要控制并发数(用带缓冲的 channel 当信号量,或者用 goroutine 池),避免 goroutine 爆炸;需要等全部执行完就用 WaitGroup。 - 「处理函数执行很慢怎么办?」→ 改成异步,加超时控制(Go 用 context)。 - 「跨进程怎么做?」→ 用消息队列,比如 Kafka。
来源:字节 Data 北京全栈岗,其他候选人面经(11 月 6 日)
题目
给一个浮点数,求这个浮点数的平方根(不能用库函数)。
4.0 -> 2.0
2 -> 1.414…
答案
题目:给一个浮点数,求平方根,不能用 Math.sqrt。
两种解法: - 二分查找:答案在 0 到 max(1, x) 之间,不断二分,直到区间足够小。注意 x 小于 1 时,平方根比 x 本身大(比如 0.25 的平方根是 0.5),所以上界不能直接取 x,要取 max(1, x)。 - 牛顿迭代(最优解,加分):反复执行 r = (r + x / r) / 2。收敛非常快,每迭代一次有效位数大约翻倍,几次就能达到双精度。
// 牛顿迭代
public double sqrt(double x, double eps) {
if (x < 0 || Double.isNaN(x)) return Double.NaN; // 负数没有实数平方根
if (x == 0 || Double.isInfinite(x)) return x;
double r = x >= 1 ? x : 1.0; // 初值:x 小于 1 时从 1 开始
for (int iter = 0; iter < 100; iter++) { // 限制迭代次数,防止死循环
double next = (r + x / r) / 2;
if (Math.abs(next - r) <= eps * next) return next; // 用相对误差判断收敛
r = next;
}
return r;
}
// 二分查找
public double sqrtBinary(double x, double eps) {
if (x < 0 || Double.isNaN(x)) return Double.NaN;
if (x == 0 || Double.isInfinite(x)) return x;
double lo = 0, hi = Math.max(1.0, x); // x 小于 1 时上界要取 1
for (int iter = 0; iter < 200 && hi - lo > eps * Math.max(1.0, lo); iter++) {
double mid = lo + (hi - lo) / 2;
if (mid * mid < x) lo = mid; else hi = mid;
}
return lo + (hi - lo) / 2;
}已验证:4.0、2.0、0.25、1e-10、1e10、0、1、-1,两种方法都和 Math.sqrt 一致(负数返回 NaN)。
隐藏考点(本题加分最多的地方): 1. x 在 0 到 1 之间:二分的上界要取 1,否则会出错。 2. 负数:返回 NaN 或者抛异常,和面试官确认。 3. 精度判断用相对误差:x 很大时,绝对误差 1e-12 永远达不到;x 很小时,绝对误差又太宽松。 4. 浮点数不能用等号比较,要用误差范围。 5. 限制迭代次数,防止精度设得太严导致死循环。 6. 复杂度:二分大约需要 log(范围 / 精度) 次;牛顿迭代是平方收敛,次数少得多。
来源:字节 Data 北京全栈岗,其他候选人面经(11 月 6 日)
题目
Kafka 源表 access_kafka,字段:user_id STRING,page STRING,event_time TIMESTAMP(3)。
需求: - 基于事件时间,5 分钟滚动窗口,统计每个页面 UV - 允许最大乱序延迟 30 秒 - 迟到超过 30 秒、但在 2 分钟内的数据仍然可以更新窗口结果 - 超过 2 分钟的极端迟到数据输出到侧输出流
答案
| 要求 | 实现方式 | 纯 SQL 能不能做 |
|---|---|---|
| 基于事件时间,5 分钟滚动窗口,按页面算 UV | 事件时间属性 + 滚动窗口 + COUNT(DISTINCT user_id) |
能 |
| 最大乱序 30 秒 | Watermark = 事件时间 − 30 秒 | 能 |
| 迟到 30 秒以上、2 分钟以内仍能更新结果 | 允许迟到(allowedLateness)2 分钟,窗口会再次输出更新后的结果 | 只能用实验性参数,且只对旧版窗口语法生效 |
| 超过 2 分钟的极端迟到数据输出到侧输出流 | sideOutputLateData | 不能,必须用 DataStream API |
开口先说这段结论: > “前两个要求用 Flink SQL 就能实现。第三个允许迟到,SQL 里只有实验性参数支持;第四个侧输出流,SQL 不支持。所以我会先写 SQL 版本,再用 DataStream API 写完整版本。”
主动点出这一点,本身就是这道题的隐藏分:说明你真的懂 Flink SQL 和 DataStream 的能力边界,而不是只会背语法。
-- 源表:在 DDL 里声明事件时间和 Watermark
CREATE TABLE access_kafka (
user_id STRING,
page STRING,
event_time TIMESTAMP(3),
-- Watermark = 当前最大事件时间 - 30 秒,容忍 30 秒乱序
WATERMARK FOR event_time AS event_time - INTERVAL '30' SECOND
) WITH (
'connector' = 'kafka',
'topic' = 'access',
'properties.bootstrap.servers' = 'xxx:9092',
'properties.group.id' = 'page_uv',
'scan.startup.mode' = 'group-offsets',
'format' = 'json'
);
-- 5 分钟滚动窗口,按页面统计 UV(窗口表值函数写法,Flink 1.13 以后推荐)
SELECT
window_start,
window_end,
page,
COUNT(DISTINCT user_id) AS uv
FROM TABLE(
TUMBLE(TABLE access_kafka, DESCRIPTOR(event_time), INTERVAL '5' MINUTE)
)
GROUP BY window_start, window_end, page;允许迟到的实验性参数(被追问时再说,不确定版本就说“需要按版本确认”): 旧版窗口语法 GROUP BY TUMBLE(event_time, INTERVAL '5' MINUTE), page 配合 table.exec.emit.late-fire.enabled、table.exec.emit.late-fire.delay、table.exec.emit.allow-lateness 这几个参数,可以实现迟到数据更新窗口结果。它们是实验性参数,新版窗口表值函数不支持,生产上用之前要按所用 Flink 版本确认。
说明:沙箱里没有 Flink 环境,这段没能实际运行。写法基于 Flink 1.15 到 1.18 的 API。Flink 2.x 里
Time.minutes(...)改用Duration.ofMinutes(...),面试时可以顺口提一句。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 1. Kafka 数据源
KafkaSource<Access> source = KafkaSource.<Access>builder()
.setBootstrapServers("xxx:9092")
.setTopics("access")
.setGroupId("page_uv")
.setStartingOffsets(OffsetsInitializer.committedOffsets(OffsetResetStrategy.EARLIEST))
.setValueOnlyDeserializer(new AccessDeserializer()) // 把 JSON 反序列化成 Access 对象
.build();
// 2. Watermark:容忍 30 秒乱序;某个分区长时间没数据时不卡住整体进度
WatermarkStrategy<Access> watermark = WatermarkStrategy
.<Access>forBoundedOutOfOrderness(Duration.ofSeconds(30))
.withTimestampAssigner((event, ts) -> event.getEventTime()) // 事件时间,毫秒
.withIdleness(Duration.ofMinutes(1));
// 3. 侧输出流标签:用来接收超过允许迟到时间的数据(匿名子类是为了保留泛型信息)
OutputTag<Access> tooLateTag = new OutputTag<Access>("too-late") {};
// 4. 按页面分组,5 分钟滚动窗口,允许迟到 2 分钟,更晚的数据进侧输出流
SingleOutputStreamOperator<PageUv> uvStream = env
.fromSource(source, watermark, "access_kafka")
.keyBy(Access::getPage)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.allowedLateness(Time.minutes(2))
.sideOutputLateData(tooLateTag)
.aggregate(new UvAgg(), new AttachWindowInfo());
// 5. 正常结果:窗口会因迟到数据重复输出,下游必须按 (page, window_start) 做 upsert
uvStream.sinkTo(upsertSink);
// 6. 极端迟到数据:写到单独的 topic 或表,交给离线修正,同时监控迟到量
uvStream.getSideOutput(tooLateTag).sinkTo(lateDataSink);
env.execute("page-uv");// 增量聚合:窗口里只保存去重后的 user_id 集合,每来一条数据就更新一次,不保存全部原始数据
public class UvAgg implements AggregateFunction<Access, Set<String>, Long> {
public Set<String> createAccumulator() { return new HashSet<>(); }
public Set<String> add(Access e, Set<String> acc) { acc.add(e.getUserId()); return acc; }
public Long getResult(Set<String> acc) { return (long) acc.size(); }
public Set<String> merge(Set<String> a, Set<String> b) { a.addAll(b); return a; } // 滚动窗口用不到,会话窗口才需要
}
// 给聚合结果补上页面和窗口起止时间
public class AttachWindowInfo extends ProcessWindowFunction<Long, PageUv, String, TimeWindow> {
public void process(String page, Context ctx, Iterable<Long> uvs, Collector<PageUv> out) {
out.collect(new PageUv(page, ctx.window().getStart(), ctx.window().getEnd(), uvs.iterator().next()));
}
}以窗口 [10:00, 10:05) 为例: 1. 10:05:30 的数据到达时,Watermark 推进到 10:05:00,越过窗口结束时间,窗口第一次输出结果。这就是“容忍 30 秒乱序”。 2. 之后 2 分钟内(Watermark 在 10:05:00 到 10:07:00 之间),有属于这个窗口的迟到数据到达,窗口会重新计算并再次输出新的 UV。 3. Watermark 超过 10:07:00 以后,再来的属于这个窗口的数据,进侧输出流,窗口状态也会被清理。
注意一个细节:允许迟到的 2 分钟是从 Watermark 越过窗口结束时间开始算的,所以相对事件时间来说,总容忍度是 30 秒乱序加 2 分钟迟到。
table.optimizer.distinct-agg.split.enabled),先按 user_id 的哈希分桶聚合,再汇总,缓解热门页面的数据倾斜。withIdleness。来源:字节 Data 北京全栈岗,其他候选人面经(11 月 6 日)
题目
业务订单维表(MySQL)会发生:新增订单、修改订单状态、修改支付时间。字段:order_id、user_id、order_status(待支付 / 已支付 / 已取消)、pay_time、update_time(业务更新时间)。
每天凌晨同步前一天的变更数据到数仓,增量表 ods_order_inc(每日分区 dt,当天所有新增和变更的订单,一条订单最多一条)。历史拉链维表 dim_order_zip,字段:order_id, user_id, order_status, pay_time, start_date, end_date, is_current。start_date 是版本生效日期;end_date 是失效日期,9999-12-31 代表当前有效;is_current 是否当前有效(1/0)。
需求:写出拉链表当日合并的 SQL 逻辑(Hive SQL),生成新一日的拉链数据。
答案
拉链表每天的新数据由两部分 UNION 而成: 1. 历史部分:昨天的拉链表全部保留。其中“当前有效、并且今天发生了变化”的那一版要闭链:结束日期改成昨天,标记为失效。 2. 新增部分:今天有变化的订单(包括新订单),生成一条新版本:开始日期为今天,结束日期为 9999-12-31,标记为当前有效。
-- 拉链表按天分区,每个分区存一份全量快照:dim_order_zip 的 dt='${dt}' 分区由 dt-1 分区加当天增量生成
INSERT OVERWRITE TABLE dim_order_zip PARTITION (dt = '${dt}')
SELECT order_id, user_id, order_status, pay_time, start_date, end_date, is_current
FROM (
-- 第一部分:昨天的拉链,对“当前有效且今天真正有变化”的版本闭链
SELECT
z.order_id, z.user_id, z.order_status, z.pay_time, z.start_date,
CASE WHEN z.end_date = '9999-12-31' AND c.order_id IS NOT NULL
THEN date_sub('${dt}', 1) ELSE z.end_date END AS end_date,
CASE WHEN z.end_date = '9999-12-31' AND c.order_id IS NOT NULL
THEN 0 ELSE z.is_current END AS is_current
FROM (SELECT * FROM dim_order_zip WHERE dt = date_sub('${dt}', 1)) z
LEFT JOIN changed c ON z.order_id = c.order_id
UNION ALL
-- 第二部分:今天有变化的订单生成新版本
SELECT order_id, user_id, order_status, pay_time,
'${dt}' AS start_date, '9999-12-31' AS end_date, 1 AS is_current
FROM changed
) t;其中 changed(今天真正发生变化的订单)用下面的 WITH 子句得到。Hive 语法要求 WITH 写在 INSERT OVERWRITE 之前,面试时把它放在整条语句的最前面:
WITH inc AS (
-- 增量去重:同一订单一天内如果有多条,只取更新时间最新的一条(题目说最多一条,但要防御)
SELECT order_id, user_id, order_status, pay_time
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY update_time DESC) AS rn
FROM ods_order_inc
WHERE dt = '${dt}'
) a
WHERE rn = 1
),
cur AS (
-- 昨天拉链里的当前有效版本
SELECT * FROM dim_order_zip
WHERE dt = date_sub('${dt}', 1) AND end_date = '9999-12-31'
),
changed AS (
-- 新订单,或者关心的字段确实变了;只改了 update_time、字段没变的不生成新版本
SELECT i.*
FROM inc i
LEFT JOIN cur c ON i.order_id = c.order_id
WHERE c.order_id IS NULL
OR COALESCE(i.user_id, '') <> COALESCE(c.user_id, '')
OR COALESCE(i.order_status, '') <> COALESCE(c.order_status, '')
OR COALESCE(i.pay_time, '') <> COALESCE(c.pay_time, '')
)验证过的场景(DuckDB 实测): - 订单状态从待支付变成已支付:旧版本结束日期改为昨天、标记失效,新版本从今天开始。 - 订单今天没变化:保持不变。 - 当天新下的订单,而且一天内出现了两条:只生成一条新版本,用最新那条。 - 历史上已经失效的版本:不受影响。 - 只改了更新时间、关心的字段没变:不生成多余版本。 - 每个订单合并后恰好有一条当前有效版本。 - 同一天重跑:结果完全一致。
NULL <> 'x' 的结果是 NULL,不是 true,所以比较字段时用 COALESCE 处理,否则支付时间从空变成有值的情况会漏掉。WHERE start_date <= '某天' AND end_date >= '某天'。9999-12-31 这个分区,历史失效数据按结束日期落到各自分区。每天只需要重写当前有效分区和昨天这个分区,历史分区不用动。来源:字节 Data 北京全栈岗,其他候选人面经(11 月 7 日)
题目
表 Person:id int,email varchar。查找出所有重复的电子邮件。
输入:(1, a@b.com), (2, c@d.com), (3, a@b.com)
输出:a@b.com
答案
SELECT email AS Email
FROM Person
WHERE email IS NOT NULL -- 空值不算重复
GROUP BY email
HAVING COUNT(*) > 1;另一种写法(自连接,面试官可能让你写第二种):
SELECT DISTINCT p1.email AS Email
FROM Person p1
JOIN Person p2 ON p1.email = p2.email AND p1.id <> p2.id;两种写法的对比:GROUP BY 只扫一遍表,效率高,推荐;自连接在数据量大时开销大,但思路直观。
隐藏考点(实测发现的,主动讲加分): 1. 空值:GROUP BY 会把所有空邮箱归为一组,如果有两条空邮箱,结果里会出现一个空值,所以要加 WHERE email IS NOT NULL。 2. 大小写和空格:MySQL 常用的排序规则默认不区分大小写,A@b.com 和 a@b.com 会被当成同一个邮箱;业务上如果要区分,就要注意排序规则;反过来,如果要把前后带空格的也算重复,可以用 LOWER(TRIM(email)) 分组。 3. 追问「删除重复的邮箱,只保留 id 最小的一条」:
DELETE p1 FROM Person p1
JOIN Person p2 ON p1.email = p2.email AND p1.id > p2.id;“核心是 IoC(控制反转)和依赖注入:对象的创建和依赖关系交给容器管理,代码里只声明需要什么。第二是 AOP(面向切面):通过动态代理把事务、日志、权限这类横切逻辑从业务代码里抽出来,
@Transactional就是靠 AOP 实现的(有接口时用 JDK 动态代理,没有时用 CGLIB)。第三是 Spring Boot 的自动配置:引入 starter 依赖后,按条件注解自动装配对应的 Bean,大大减少配置。”
经验可以讲的坑:同一个类里方法 A 调用方法 B,B 上的 @Transactional 不生效,因为没有经过代理对象。
上一位候选人答成了“扫描配置文件”,不够。完整答案: > “请求先到 DispatcherServlet。应用启动时,RequestMappingHandlerMapping 会扫描所有 @Controller 里的 @RequestMapping 注解,建立‘URL 和请求方法’到‘具体 Java 方法’的映射表。请求进来后,DispatcherServlet 用这张表找到处理方法,再通过 HandlerAdapter 调用它:中间要做参数解析(路径参数、请求体反序列化),执行拦截器,调用方法,最后把返回值转换成 JSON 或视图返回。”
“MySQL(InnoDB)是行存储,数据按主键组织成 B+ 树。按主键或索引定位少量行非常快,支持事务和高并发的小查询,适合在线业务的点查和更新。但做分析时,比如对上亿行算某个字段的汇总,它要把整行读出来,而且单机执行,就很慢。 OLAP 引擎(比如 Doris)是列存储:只读需要的列,同一列的数据类型一致,压缩率高;用向量化执行,一次处理一批数据;多台机器并行计算;还有分区分桶裁剪、预聚合和物化视图。所以大范围扫描和聚合很快。反过来,它不擅长单行更新和高并发的点查。 所以:在线业务的点查和事务用 MySQL,复杂分析和报表用 OLAP。”
上一位候选人答不上来,这是送分题: > “时序:Kafka 只保证单个分区内有序,全局无序。要让同一个用户或订单的消息有序,就用它的 ID 作为消息的 key,保证进同一个分区。生产端重试可能导致乱序,要开启幂等生产者。消费端一个分区只能被组内一个消费者消费;如果用多线程处理,要按 key 分配线程,否则会打乱顺序。 > 持久化:每个分区在磁盘上是一组只追加写的日志文件,顺序写所以很快;写入先进操作系统的页缓存,异步刷盘;可靠性主要靠多副本:生产端 acks 设为 all,消息要写入所有同步副本(ISR)才算成功。旧数据按时间或大小过期删除,也可以开启按 key 压缩,只保留每个 key 的最新值。”
「接口做缓存 OK 吗?」 > “可以,但要看场景。适合:读多写少、参数组合有限、能接受短暂不一致的数据。不适合:每个人都不一样的个性化结果(命中率低)、要求强一致的数据。比如画像服务按用户 ID 缓存,命中率取决于访问是不是集中在热点用户上,冷用户多的话缓存作用有限。 > 做缓存要处理三个问题:穿透(查不存在的数据,用空值缓存或布隆过滤器)、击穿(热点 key 过期瞬间大量请求打到数据库,用互斥锁重建或逻辑过期)、雪崩(大量 key 同时过期,过期时间加随机值)。”
「本地缓存、Redis、MySQL 三级缓存,一致性怎么保证?」(上一位候选人讲乱了) > “读:本地缓存没有查 Redis,Redis 没有查 MySQL,查到后逐级回填。 > 写:先更新 MySQL,再删除 Redis 缓存(删除比更新更安全),同时通过消息广播,让所有实例删除各自的本地缓存。 > 兜底:本地缓存过期时间设得很短(比如几秒到几十秒),即使广播丢了也能很快自愈;Redis 也设过期时间。对一致性要求高的可以延迟双删:更新数据库后删一次缓存,过一小段时间再删一次,防止并发读把旧值写回缓存。 > 原则:三级缓存只适合读多写少、能接受秒级不一致的数据;要求强一致的数据不要放进本地缓存。”
“从几个层面:①多级缓存:热点数据放本地缓存,其次 Redis,最后才查存储;②存储选型:按用户 ID 查询,用 KV 存储(HBase、Redis 集群等),按用户 ID 分片;③减少请求次数:提供批量查询接口,合并多次调用;④减少数据量:只返回调用方需要的字段,用高效的序列化格式(比如 Protobuf);⑤线程模型:异步非阻塞 IO,合理配置连接池;⑥冷热隔离:热数据放内存,冷数据放磁盘存储;⑦保护:限流,以及服务过载时返回默认画像的降级方案;⑧最后才是加机器横向扩容。”
“事务的四个特性:原子性、一致性、隔离性、持久性。 隔离级别从低到高:读未提交、读已提交、可重复读、串行化。避免脏读,至少要用读已提交。MySQL 默认是可重复读,靠 MVCC(多版本并发控制)实现:每行数据有一条版本链,事务读的是它开始时能看到的版本,所以读不加锁。 幻读:普通查询靠 MVCC 的快照避免;
SELECT ... FOR UPDATE这类加锁读,靠间隙锁(next-key lock)锁住范围,防止别的事务插入。”
“①先开慢查询日志,找出执行最多、最慢的 SQL;②用 EXPLAIN 看执行计划:type 是不是全表扫描(ALL),用了哪个索引(key),预估扫描多少行(rows),Extra 里有没有 Using filesort(额外排序)、Using temporary(临时表);③针对性优化:加或调整索引,联合索引遵循最左前缀,尽量用覆盖索引避免回表;避免索引失效(索引列上用函数、隐式类型转换、以 % 开头的 LIKE);深分页改成按上次的 ID 往后查;大查询拆小。④最后考虑读写分离、分库分表。”
「Agent 的流程定义(flow schema)怎么设计,怎么执行?」 > “用 JSON 或 YAML 定义:节点列表(每个节点有 ID、类型:大模型调用、工具调用、条件判断、代码执行;输入参数从哪里取;输出写到哪里),加上边(从哪个节点到哪个节点,可以带条件)。执行时:先校验 schema(字段合法、节点引用存在、有没有循环依赖),然后按依赖关系做拓扑排序,入度为 0 的节点先执行,没有依赖关系的节点可以并行,节点之间通过一个共享的上下文传递数据;每个节点有超时和重试。” > (这正好是你上次那道“Agent 工具依赖批次”题,可以顺势说:“这本质上就是一个拓扑排序问题。”)
「为什么要把 Agent 拆成多个节点?」(上一位候选人只说了“快、省 token”) > “①可控:确定性的步骤用代码,只把需要理解和生成的步骤交给模型;②可观测:每一步的输入输出都能查,出了问题知道是哪一步;③可重试:某一步失败只重跑这一步;④可替换:每个节点可以用不同的模型,简单任务用小模型,省成本;⑤上下文更短更准:每个节点只拿它需要的信息,效果更好;⑥可以并行、可以单独评测。”
「RAG 怎么评估?」(上一位候选人只说了召回率) > “分三层:①检索:召回率(正确的文档有没有被找回来)、排序质量(正确文档排第几,比如 MRR);②生成:答案是否忠于检索到的内容(有没有编造)、是否回答了问题、是否正确;③端到端和线上:人工整理一套问题和标准答案做回归,上线后看转人工率、点赞点踩。也有现成的评估框架,比如 RAGAS。”
岗位画像里的“全栈”是指后端加数仓开发,前端不是重点。被问到如实说: > “前端我能看懂 Vue 和 React 的代码,能改业务页面;复杂页面会用 AI 生成,然后自己 review 和调试。我的主力是后端和数据。”
top(CPU 和内存)、free -h(内存)、df -h(磁盘)、tail -f(实时看日志)、grep、less、ps -ef、netstat 或 ss(端口和连接)、jstack、jmap(Java 排查)。面经里通过的候选人讲了一个很典型的接口性能调优故事:数据大盘接口 7 到 8 秒,用 Arthas 定位到慢 SQL,用 EXPLAIN 分析后优化索引、冗余字段,再加 Redis 缓存。这种故事面试官很爱听。请从你自己的经历里找一个真实的性能优化案例(比如美团查询接口的限流和批量查询、阿里的某个慢查询),按“现象 → 定位工具 → 原因 → 方案 → 效果”整理好。一定要用真实经历,不要照搬别人的。
「一个合理的开发流程是怎样的?」 > “需求阶段:产品输出需求文档,开发参与评审,提前发现不合理的地方。设计阶段:写技术方案,做方案评审。开发阶段:编码、单元测试、代码评审。测试阶段:联调、测试,重要功能做压测。上线阶段:灰度发布,观察监控,有回滚方案。上线后:复盘和持续监控。”
「质量保障怎么做?」 > “分两类:存量功能迭代,重点做新旧版本的结果比对,确保改动没有影响原有逻辑;新增需求,前期和需求方确认清楚验收标准。工程上:单元测试、代码评审、灰度发布、监控告警。”
「线上问题一般怎么定位?」 > “先止血,再找根因。先看监控指标确定影响范围和开始时间,再看这个时间点有没有发布或配置变更,有就先回滚。然后用链路追踪 ID 串起整条调用链,搜错误日志,定位是哪一环出问题,再看是网络超时、下游异常、资源不足还是代码问题。”
「常见的异常有哪些,怎么解决?」 > “网络类:调用下游超时,加超时、重试、熔断;启动类:实例启动失败,看启动日志和配置;资源类:内存溢出、CPU 打满、连接池耗尽,用 jstack、jmap 等工具分析;业务类:和下游沟通,从接口设计上改进。”
「开发接口时会考虑哪些?」 > “①分层清楚:按领域划分,对外接口和内部实现分开,预留扩展点;②性能:要不要缓存、数据库索引是否合理;③幂等:重复调用结果一致;④高可用:超时、重试、限流、降级;⑤安全:参数校验、权限检查;⑥可观测:日志、监控指标、链路追踪。”
「Java 里怎么实现发布订阅?」 > “最简单的是观察者模式,自己维护事件名到监听器列表的映射,就是算法题第一题的写法。框架层面,Spring 有 ApplicationEvent 和事件监听注解;Guava 有 EventBus;跨服务的发布订阅用消息队列,比如 Kafka。”
五步,每步讲完停一下: ①先问约束:每天多少条内容、高峰 QPS、审核时效要求(实时拦截还是事后审核)、内容类型(文本、图片、视频)。 ②接入层:业务方通过接口或消息队列提交内容,同步接口用于必须先审后发的场景,异步消息用于先发后审。 ③机审层:文本先过关键词等规则(快、便宜),再调用模型打分;图片视频先算哈希,和已知违规内容比对,再调用模型。多个模型结果汇总成一个风险分。 ④决策层:策略引擎按风险分和规则给出结果:通过、拦截或送人工。策略可配置、可灰度、可回滚。 ⑤人审和回流:人工审核按优先级分派任务,结果回流到标注数据,用来评估和改进模型、调整阈值。 保障:接入要幂等(同一内容重复提交不重复审核);模型服务超时要有降级(比如降级为只用规则,或默认送人审);全链路要能追溯每条内容是谁、在什么时间、按哪条规则处理的。
可能的追问: - 「高峰流量怎么扛?」→ 消息队列削峰,机审服务水平扩展;同一内容先查结果缓存,避免重复审核。 - 「误判了怎么办?」→ 申诉通道、人工复核;误判样本回流,调整规则和模型;新规则先灰度,看拦截率变化再全量。 - 「规则怎么做到热更新?」→ 规则存在配置中心,服务监听变更后在内存中重新加载;带版本号,出问题一键回滚。
背景知识:国际化内容安全平台大概包括什么
「怎么设计一个审核策略的管理和发布系统?」 > ①策略模型:一条策略包括生效范围(哪个产品、哪个地区、哪类内容)、判断条件(模型分数阈值、规则组合)、处置动作(通过、拦截、送人审、降低分发)。 > ②版本管理:每次修改生成新版本,旧版本保留,可以对比差异、一键回滚。 > ③发布流程:编辑 → 评审 → 先用历史数据回放验证(这条策略如果上线,会多拦截多少、误判多少)→ 灰度发布(先放一小部分流量)→ 观察拦截率、误判率 → 全量。 > ④下发到各地区:策略存在配置中心,各地区的审核服务监听变更、在内存中热加载;每个地区可以有自己的差异化策略,适应当地的合规要求。 > ⑤安全:发布要审批和留痕;关键指标异常时自动告警,必要时自动回滚。
「审核平台怎么调用和管理模型?」 > “模型由算法团队提供服务,平台这边负责‘消费’:①统一的模型接入层,屏蔽不同模型的接口差异;②版本管理和灰度切换,新模型先影子运行,只打分不决策,和旧模型结果对比,没问题再切换;③超时和降级,模型服务慢或挂了,降级为规则审核或默认送人审,不能让内容卡住;④结果缓存,同一内容不重复调用;⑤监控每个模型的调用量、延迟、失败率和分数分布,分数分布突然变化往往说明模型或数据有问题。”
“国际化业务通常按地区部署多套服务,原因有两个:一是延迟,二是数据合规,很多地区要求用户数据存储在本地,不能随意出境。所以一般是:每个地区独立部署审核服务和数据存储;策略、配置这类不含用户数据的内容由中心统一管理、下发到各地区;跨地区只同步必要的、合规允许的数据。某个地区出问题时,故障被隔离在该地区,不影响其他地区。”
被问到没做过的细节时,先说原理,再说明“这块我没有直接的线上经验,但思路是……”,不要冷场。
「你觉得审核管理过程可以怎么用 AI?」 > “我会从三个场景切入: > ①策略配置助手:运营用自然语言描述想拦截的内容,AI 生成策略草稿,再由人确认,降低配置门槛; > ②策略效果分析:拦截率、误判率异常时,智能体自动下钻到地区、产品、内容类型,给出可能的原因; > ③审核运营问答:运营问‘这条内容为什么被拦’,AI 根据审核日志和策略版本给出解释,并附上依据。 > 原则和我做智能体平台时一样:AI 负责理解和生成草稿,判断和发布权留给规则和人,每一步可追溯、可回滚。”
“查询服务优化我会从五个方面做: ①找问题:分析查询日志,找出最慢、最频繁、扫描量最大的查询; ②路由:点查走 KV 或 MySQL,分析查询走 OLAP,不同类型的查询分开; ③减少计算:高频查询做预聚合或物化视图,结果做缓存(数据更新时失效); ④保护:执行前预估扫描量,超过阈值的转成异步任务;大查询和小查询分队列,互不影响;按应用限流; ⑤规范:强制分区条件、禁止 SELECT *、限制返回行数。” 挂靠经历:美团的智能体平台把查询封装成 20 多个接口,覆盖 HBase、MySQL、Hive 三种数据源;批量查询时用令牌桶限流到 5000 QPS,超出的排队。
“我在阿里灯塔负责 8000 多个任务的治理:分层规范、指标口径统一、近百项质量监控、15 分钟故障响应。做治理的经验是:先用数据摸清现状(无主表、零查询表、重复加工),再按收益排序,把规范变成平台上的卡点,而不是只写文档。”
“埋点治理我会分四步: ①规范:统一事件命名、公共属性和每个事件的字段定义,所有埋点在平台上登记; ②流程:埋点需求走‘申请、评审、开发、测试验证、上线’,没登记的事件不能上线; ③自动校验:实时比对上报数据和登记的定义,发现缺字段、类型错误、未登记的新事件就告警; ④质量和成本:监控丢失率、重复率、延迟,以及客户端和服务端的对账;长期没人查询的埋点下线。” 被追问没做过的细节时,先讲思路,再说明“这块我没有直接的线上经验”,不要冷场。
这是学习用的典型案例,不是你的经历,面试中讲成“我的排查思路和常见方案”。数字都是示例。
某公司有一个运营数据大盘,首页有十几个图表:今日交易额、订单数、各城市排名、近 7 天趋势等。前端打开首页会同时调用十几个接口,后端从 MySQL 里实时查询和计算。
随着业务增长,出现了三个问题: 1. 慢:首页打开要 7 到 8 秒,个别图表接口超过 10 秒。 2. 扛不住:每天早上 9 点运营集中打开大盘,接口超时、数据库 CPU 飙到 90% 以上。 3. 会连带出问题:大盘查询和下单业务共用同一个数据库,高峰期把数据库打满,连带影响了下单。
目标:P99 延迟降到 300 毫秒以内;支撑早高峰 10 倍的流量;大盘出问题时不能影响核心业务。
原则:先测量,再优化。不要凭感觉改代码。
先看 APM 监控(比如 SkyWalking、公司内部的链路追踪系统),找到一次慢请求的完整调用链,看每一段花了多少时间:网关、应用内处理、数据库、缓存、下游接口。
这一步的结论(示例):80% 的时间花在数据库查询上,另外有几个图表接口是串行调用三四个下游服务。
Arthas 是阿里开源的 Java 线上诊断工具,不用重启、不用加日志就能看到方法的执行耗时。常用命令:
# 看整体:CPU、内存、线程、GC 的实时情况
dashboard
# 找最忙的 3 个线程,看它们在干什么(CPU 飙高时用)
thread -n 3
# 追踪方法内部每一步的耗时,只记录耗时超过 1 秒的调用,记录 5 次
trace com.example.DashboardService getCityRank '#cost > 1000' -n 5
# 查看方法的入参和返回值,展开 2 层(排查“为什么慢在这组参数上”)
watch com.example.DashboardService getCityRank '{params, returnObj}' -x 2
# 生成火焰图,看 CPU 时间花在哪些方法上
profiler start
profiler stop --format html这一步的结论(示例):trace 显示 getCityRank 里一条 SQL 平均 4 秒;另一个接口在循环里逐条查数据库(典型的 N+1 查询问题)。
-- 开启慢查询日志,记录超过 1 秒的 SQL
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;找到慢 SQL 后用 EXPLAIN 看执行计划,重点看四个字段:
| 字段 | 看什么 | 不好的信号 |
|---|---|---|
| type | 访问方式 | ALL(全表扫描)、index(全索引扫描) |
| key | 实际用了哪个索引 | 为空,说明没用上索引 |
| rows | 预估扫描行数 | 远大于返回的行数 |
| Extra | 额外操作 | Using filesort(额外排序)、Using temporary(临时表) |
这一步的结论(示例):城市排名的 SQL 是 type=ALL、扫描 800 万行,还有 Using temporary; Using filesort。
原 SQL:
SELECT city_id, SUM(amount) AS gmv
FROM orders
WHERE DATE(create_time) = '2026-11-07' AND status = 'PAID'
GROUP BY city_id
ORDER BY gmv DESC
LIMIT 20;问题: 1. DATE(create_time) 在索引列上用了函数,索引失效。 2. 没有合适的联合索引,只能全表扫描。
优化:
-- 1. 去掉索引列上的函数,改成范围条件
WHERE create_time >= '2026-11-07 00:00:00' AND create_time < '2026-11-08 00:00:00'
AND status = 'PAID'
-- 2. 建联合索引:等值条件的列在前,范围条件的列在后;把要用到的列都放进来,形成覆盖索引
ALTER TABLE orders ADD INDEX idx_status_time_city_amount (status, create_time, city_id, amount);为什么这么建: - 等值在前、范围在后:联合索引遇到范围条件后,后面的列就不能再用来过滤了,所以 status 放前面。 - 覆盖索引:查询需要的列都在索引里,不用回表查整行,减少大量随机 IO。
常见的索引失效场景(面试高频):索引列上用函数或计算;隐式类型转换(字符串字段用数字比较);以 % 开头的 LIKE;不满足最左前缀;OR 条件里有一边没有索引。
原代码:先查出 20 个城市,再在循环里逐个查城市名称,一共 21 次数据库查询。
// 优化前:循环里查数据库
for (CityGmv item : list) {
item.setCityName(cityMapper.selectById(item.getCityId()).getName());
}
// 优化后:一次批量查询,再在内存里拼装
List<Long> ids = list.stream().map(CityGmv::getCityId).collect(Collectors.toList());
Map<Long, String> names = cityMapper.selectByIds(ids).stream()
.collect(Collectors.toMap(City::getId, City::getName));
list.forEach(item -> item.setCityName(names.get(item.getCityId())));如果每次都要关联用户表、商品表取名称,可以在订单表里冗余常用字段(比如城市名称),避免多表关联。代价:数据重复,源头变化时要同步更新。适合变化不频繁的字段。
-- 慢:要先扫描 100 万行再丢掉
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
-- 快:记住上一页最后一个 ID,从它往后查
SELECT * FROM orders WHERE id > #{lastId} ORDER BY id LIMIT 20;这一步的效果(示例):城市排名 SQL 从 4 秒降到 300 毫秒。但高峰期数据库压力仍然很大,因为每次打开大盘都要实时聚合。
根本问题:大盘的图表本质上都是聚合查询,每次打开都从明细数据里实时算一遍,用户越多算得越多,而且算出来的结果几乎都一样。
方案: - 准实时指标(今日交易额、订单数):用 Flink 消费订单的 binlog(通过 Canal 或 Flink CDC),按分钟聚合,结果写进 Redis 或 OLAP 引擎。接口直接读结果,不再扫明细。 - 历史趋势(近 7 天、近 30 天):用离线任务每天预算好,写入结果表。 - 灵活的多维分析:把明细同步到 Doris 这类 OLAP 引擎,对高频查询建物化视图,查询时自动命中预聚合结果。
为什么这是最有效的一步:把“每次查询都计算”变成“数据变化时计算一次”,查询成本和用户数基本无关了。
代价:数据有分钟级的延迟,要在页面上标注“数据更新时间”;多了一条数据链路,要做实时数据和离线数据的对账。
问题:大盘查询和下单业务共用一个数据库,查询把数据库打满会连累下单。
方案: - 大盘只读从库(或者干脆只读 OLAP 和 Redis),主库只服务交易写入。 - 主从延迟的问题:从库数据可能落后几百毫秒。对大盘来说可以接受;对“下单后马上查订单”这种场景,要强制读主库。
场景:订单表到了几十亿行,单表查询和写入都变慢。
方案: - 水平拆分:按用户 ID 做哈希,拆成多个库、多张表(比如 4 个库、每库 64 张表)。按用户 ID 查询时直接定位到某张表。 - 新问题:按非分片键查询(比如按商家、按时间范围查)需要扫所有分表。解法:用 Canal 监听 binlog,把数据同步到 ES,复杂条件查询走 ES。 - 再一个新问题:ES 是近实时的,写入后默认约 1 秒才能查到。如果业务要求“写完马上能查到”:写入后的短时间内,先查 Redis 或主库里的最新数据;或者写入 ES 时让请求等到数据可查再返回(代价是写入变慢)。
面试要点:每引入一个组件都会带来新问题,要能讲清楚“为什么引入、带来了什么问题、怎么解决”。
请求 → 本地缓存(Caffeine,每台机器各有一份,纳秒到微秒级)
→ 没命中 → Redis(集中缓存,约 1 毫秒)
→ 没命中 → 数据库或 OLAP
→ 查到后逐级回填
| 问题 | 是什么 | 解决方案 |
|---|---|---|
| 穿透 | 查一个根本不存在的数据,缓存和数据库都没有,每次都打到数据库 | 缓存空值(设短过期时间);用布隆过滤器提前拦截不存在的 key |
| 击穿 | 某个热点 key 过期的瞬间,大量请求同时打到数据库 | 互斥锁:只让一个请求去重建缓存,其他请求等待或返回旧值;或者“逻辑过期”:数据不设物理过期,后台异步刷新 |
| 雪崩 | 大量 key 同一时间过期,或者 Redis 整个挂了 | 过期时间加随机值打散;多级缓存兜底;Redis 用集群加哨兵保证高可用;数据库前加限流 |
问题:早上 9 点所有人都看“全国今日交易额”,这一个 key 的请求全部落到 Redis 集群的同一个节点上,把这个节点打满。
方案:热点数据放本地缓存(最有效);或者把一个 key 复制成多份(比如 gmv_today_0 到 gmv_today_9),读的时候随机选一个,分散到不同节点。
常用方案(Cache Aside):读的时候先读缓存,没有再读数据库并写回缓存;更新的时候先更新数据库,再删除缓存。
为什么是删除而不是更新缓存:两个请求并发更新时,更新缓存的顺序可能和更新数据库的顺序不一致,导致缓存里留下旧值;删除就没有这个问题,下次读时自然加载最新值。
删除缓存失败怎么办:用 Canal 订阅 binlog,异步删除缓存,失败了放进消息队列重试;同时缓存一定要设过期时间,作为最后的兜底。
本地缓存怎么失效:每台机器各有一份,可以通过消息广播让所有机器删除;或者干脆把本地缓存的过期时间设得很短,接受几秒的不一致。
一个图表接口要调用三个下游服务,原来是一个接一个调,总耗时是三者之和。改成并行,总耗时约等于最慢的那一个。
// 专用线程池:不要用公共线程池,避免和其他业务互相影响
ExecutorService pool = new ThreadPoolExecutor(
20, 50, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500), // 有界队列,防止任务无限堆积导致内存溢出
new ThreadPoolExecutor.CallerRunsPolicy()); // 队列满了由调用线程执行,起到减速的作用
CompletableFuture<Long> gmv = CompletableFuture
.supplyAsync(() -> orderService.todayGmv(), pool)
.completeOnTimeout(-1L, 500, TimeUnit.MILLISECONDS) // 超时返回默认值(Java 9 以后)
.exceptionally(e -> -1L); // 出错返回默认值,不影响其他数据
CompletableFuture<Long> users = CompletableFuture
.supplyAsync(() -> userService.activeUsers(), pool)
.completeOnTimeout(-1L, 500, TimeUnit.MILLISECONDS)
.exceptionally(e -> -1L);
CompletableFuture.allOf(gmv, users).join(); // 等所有任务结束
return new Overview(gmv.join(), users.join());要点:用独立的有界线程池;每个任务都要有超时和失败兜底;一个数据源出问题,只让对应的图表显示“暂无数据”,整个页面照常打开。
首页十几个图表接口,改成一个聚合接口一次返回(后端内部并行查询),减少网络往返和网关压力。
在哪里限:网关层(按用户、按接口)和应用层(按接口)各限一层。 怎么限:令牌桶最常用,它允许一定的突发流量,又能把平均速率控制住。单机限流用 Guava 的 RateLimiter;集群限流用 Sentinel 或 Redis 加 Lua 脚本。
令牌桶和漏桶:令牌桶按固定速率往桶里放令牌,请求拿到令牌才能执行,桶里攒的令牌可以应对突发;漏桶以固定速率处理请求,不允许突发,适合需要平滑输出的场景。
下游服务的错误率或慢调用比例超过阈值(比如 1 分钟内超过 50%),就熔断:后续请求直接走降级逻辑,不再调用下游;过一段时间进入“半开”状态,放少量请求试探,成功了就恢复。
作用:防止一个下游服务变慢,导致调用方的线程全部卡在等待上,最后整个服务被拖垮(这叫雪崩效应)。
调用链上的超时要逐层递减:网关 3 秒,应用 2 秒,调用下游 500 毫秒。如果下游的超时比上游还长,上游已经放弃了,下游还在白白干活。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首页打开时间 | 7 到 8 秒 | 约 300 毫秒 |
| 单个图表接口 P99 | 超过 4 秒 | 约 50 毫秒(缓存命中)/ 约 200 毫秒(未命中) |
| 早高峰数据库 CPU | 90% 以上 | 30% 左右 |
| 对下单业务的影响 | 高峰期偶发超时 | 完全隔离,无影响 |
每一步贡献了多少(讲的时候能显出你懂原理):索引和 SQL 优化解决了单次查询慢;预计算解决了重复计算;缓存解决了高并发;并行化解决了串行等待;限流、熔断、隔离解决了互相连累。
第一层:「接口慢你会怎么排查?」 > 先看监控和链路追踪定位慢在哪一段;应用内部用 Arthas 的 trace 找到具体方法;数据库用慢查询日志加 EXPLAIN;然后按“数据库 → 架构 → 缓存 → 应用 → 高可用”的顺序优化,先做成本低的。
第二层:「为什么不一开始就加缓存?」 > 缓存只能掩盖慢查询,不能解决它。缓存一失效,慢查询照样把数据库打满,这就是缓存击穿和雪崩的根源。所以先把查询本身优化好,再用缓存扛并发。
第二层:「预计算和缓存有什么区别?」 > 缓存是把“查询结果”存起来,第一次还是要实时计算,而且失效后要重新计算;预计算是在数据变化时就把结果算好,查询时永远不需要实时聚合。大盘这种“读远多于写、结果大家都一样”的场景,预计算更彻底。
第三层:「预计算的数据和实时数据对不上怎么办?」 > 页面上标注数据更新时间;实时结果和离线结果定期对账,差异超过阈值告警;最终以离线结果为准。
第三层:「缓存删除失败导致脏数据怎么办?」 > 用 binlog 订阅异步删除,失败进消息队列重试;缓存设过期时间兜底;对一致性要求高的场景用延迟双删。
第三层:「熔断以后用户看到的是什么?」 > 对应的图表显示缓存里的旧数据或者“暂无数据”,页面其他部分正常。降级是按图表粒度做的,不是整页失败。
适合的场景: - 「如果让你优化一个很慢的查询接口,你会怎么做?」 - 「设计一个支撑高并发的查询服务」 - 「缓存怎么用?会遇到什么问题?」 - 「怎么保证服务的高可用?」
说法: > “我的思路是先测量再优化。比如一个大盘接口很慢,我会……(按第 2 到 7 节的顺序讲)”
和你真实经历的衔接:讲到限流时,可以说你真实做过的部分: > “我在美团做智能体平台时遇到过类似的问题:查头部商品的商家时,一次请求会放大成大量下游查询,我们用令牌桶把请求速率限制在每秒 5000 次,超出的排队,失败的有限重试,持续失败就降级。”
底线:方法论可以讲得很深,但不要把这个案例说成是你做的。面试官追问“你们当时数据库是什么配置”“这个项目几个人做的”,编出来的细节很容易露馅。
离职原因 > “家里老人生病,需要人照顾,我主动离职回去处理了一段时间,现在已经安排好了,可以全职投入工作。”(说完就停,不展开;不要说成裁员,背调能查到离职类型。)
为什么做工程或全栈岗 > “我最早就是做 Java 后端,在美团的岗位也是后端,日常做的是数据服务和平台开发。这个岗位结合了我的工程能力和数据处理经验。”
怎么学一门新语言(Go) > “先过一遍核心语法和特性,重点看和 Java 不一样的地方,比如 goroutine 和 channel、接口隐式实现、错误处理;然后读团队现有代码,照着写小功能;遇到问题查官方文档和标准库源码。估计一两周能上手写业务代码。”
AI 怎么用 > “我用 AI 写单元测试、生成样板代码、辅助排查问题,核心逻辑和代码 review 自己把关。”(不要说“主要靠 AI 写代码”或“AI 让写代码不重要了”,这两次面试都因为类似的话减分。)
| 优先级 | 题目 | 核心思路(一句话) |
|---|---|---|
| ★★★ | 反转链表 | 三个指针:prev、cur、next |
| ★★★ | 合并两个有序链表 | 虚拟头节点,每次接上较小的那个 |
| ★★★ | 环形链表(判断是否有环) | 快慢指针,相遇就有环 |
| ★★★ | 链表的中间节点 | 快慢指针,快的走两步慢的走一步 |
| ★★★ | 两数之和 | 哈希表存“值到下标”,查 target 减当前值 |
| ★★ | 相交链表 | 两个指针走完自己的链表就换到对方的链表,相遇点就是交点 |
| ★★ | 反转链表的第 m 到 n 个节点 | 虚拟头节点,找到第 m−1 个,后面逐个头插 |
| ★★ | 删除排序链表中的重复元素 | 当前值等于下一个值就跳过下一个 |
| ★★ | LRU 缓存 | 哈希表加双向链表;或者直接用 Java 的 LinkedHashMap |
| ★★ | 最小栈 | 再用一个栈同步记录当前最小值 |
| ★ | 字母异位词分组 | 排序后的字符串作为哈希表的 key |
| ★ | 二分查找 | 注意 mid = left + (right - left) / 2 防溢出,以及循环条件 |