八股文 / 通用
程序员面试八股文集合 - 50 道高频题
涵盖数据结构与算法、操作系统、计算机网络、数据库、系统设计五大方向,适合后端开发、算法等技术岗针对性复习。
一、数据结构与算法(10道)
Q1:数组和链表的区别?各自适用场景?
| 维度 | 数组 | 链表 |
|---|---|---|
| 内存布局 | 连续内存空间 | 离散节点,通过指针连接 |
| 随机访问 | $O(1)$(下标直接定位) | $O(n)$(需从头遍历) |
| 插入/删除 | $O(n)$(需移动元素) | $O(1)$(修改指针即可,已知位置时) |
| 内存效率 | 高(无额外指针开销) | 低(每个节点存储额外指针) |
| 缓存友好性 | 好(连续内存,CPU Cache 命中率高) | 差(离散内存,Cache Miss 多) |
适用场景:
- 数组:频繁随机访问、数据量已知、需要高缓存性能(如矩阵运算、查表)
- 链表:频繁插入删除、数据量不确定、不需要随机访问(如 LRU 缓存、多项式运算)
Q2:如何判断链表是否有环?如何找到环的入口?
判断是否有环 — 快慢指针(Floyd 判环法):
- 慢指针每次走 1 步,快指针每次走 2 步
- 如果有环,快慢指针一定会在环内相遇;如果无环,快指针会先到
null
找环入口:
- 快慢指针相遇后,将其中一个指针重置到头节点
- 两个指针都每次走 1 步
- 再次相遇的节点就是环入口
数学证明:设头到入口距离为 $a$,入口到相遇点为 $b$,相遇点回到入口为 $c$。慢指针走了 $a+b$,快指针走了 $a+b+k(b+c)$。由 $2(a+b) = a+b+k(b+c)$ 得 $a = (k-1)(b+c) + c$,即从头走 $a$ 步等于从相遇点走 $c$ 步(绕若干圈后)到入口。
Q3:手写快排、归并排序、堆排序的实现及复杂度分析
| 排序算法 | 平均时间 | 最坏时间 | 空间 | 稳定性 |
|---|---|---|---|---|
| 快速排序 | $O(n \log n)$ | $O(n^2)$ | $O(\log n)$ | 不稳定 |
| 归并排序 | $O(n \log n)$ | $O(n \log n)$ | $O(n)$ | 稳定 |
| 堆排序 | $O(n \log n)$ | $O(n \log n)$ | $O(1)$ | 不稳定 |
快速排序核心思路:选基准 → 分区(小的左边,大的右边)→ 递归排序左右两部分。
def quick_sort(arr, left, right):
if left >= right:
return
pivot = arr[(left + right) // 2]
i, j = left, right
while i <= j:
while arr[i] < pivot: i += 1
while arr[j] > pivot: j -= 1
if i <= j:
arr[i], arr[j] = arr[j], arr[i]
i += 1
j -= 1
quick_sort(arr, left, j)
quick_sort(arr, i, right)
归并排序核心思路:递归拆分到单元素 → 两两合并有序数组。
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
def merge(a, b):
res, i, j = [], 0, 0
while i < len(a) and j < len(b):
if a[i] <= b[j]:
res.append(a[i]); i += 1
else:
res.append(b[j]); j += 1
return res + a[i:] + b[j:]
堆排序核心思路:建大顶堆 → 堆顶(最大值)和末尾交换 → 缩小堆范围重新调整。
Q4:二叉树的前序、中序、后序遍历的递归与非递归实现
递归实现(以中序为例):
def inorder(root):
if not root: return []
return inorder(root.left) + [root.val] + inorder(root.right)
非递归实现(中序 — 用栈模拟):
def inorder_iterative(root):
res, stack, cur = [], [], root
while cur or stack:
while cur:
stack.append(cur)
cur = cur.left
cur = stack.pop()
res.append(cur.val)
cur = cur.right
return res
三种遍历的访问顺序:
- 前序:根 → 左 → 右(非递归:入栈时访问)
- 中序:左 → 根 → 右(非递归:出栈时访问)
- 后序:左 → 右 → 根(非递归:前序变体反转,或双栈法)
Q5:红黑树与 AVL 树的区别?为什么 Java HashMap 用红黑树?
| 维度 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡条件 | 严格平衡(左右子树高度差 ≤ 1) | 近似平衡(最长路径 ≤ 2 倍最短路径) |
| 查找性能 | 略优(树更矮) | 略差(树稍高) |
| 插入/删除 | 慢(需更多旋转维持严格平衡) | 快(旋转次数更少) |
| 旋转次数 | 插入最多 2 次,删除最多 $O(\log n)$ 次 | 插入最多 2 次,删除最多 3 次 |
HashMap 选红黑树的原因:HashMap 的场景是频繁插入和删除,红黑树在这方面性能更好。查找性能的微小差距(常数级别)在 HashMap 场景中可以忽略,而插入删除的旋转开销差距更显著。
Q6:哈希表如何解决冲突?开放地址法和链地址法优缺点?
冲突解决方法:
| 方法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 链地址法(拉链法) | 每个桶维护一个链表/红黑树 | 实现简单;删除方便;装载因子可 > 1 | 额外指针开销;缓存不友好 |
| 开放地址法 | 冲突时按规则探测下一个空位 | 缓存友好(连续内存);无指针开销 | 删除复杂(需标记墓碑);装载因子不能太高 |
| 再哈希法 | 用第二个哈希函数计算新位置 | 分布更均匀 | 多一次哈希计算 |
开放地址法的探测策略:
- 线性探测:$h(k, i) = (h(k) + i) \mod m$,容易产生聚集
- 二次探测:$h(k, i) = (h(k) + c_1 i + c_2 i^2) \mod m$,减少聚集
- 双重哈希:$h(k, i) = (h_1(k) + i \cdot h_2(k)) \mod m$,效果最好
Java HashMap:链地址法,链表长度 > 8 时转红黑树,< 6 时退回链表。
Q7:最小生成树(Prim/Kruskal 算法)的实现与区别
| 维度 | Prim | Kruskal |
|---|---|---|
| 策略 | 从一个顶点出发,每次选最短的跨切边 | 将所有边按权值排序,依次选不构成环的边 |
| 数据结构 | 优先队列(最小堆) | 并查集(判环) |
| 时间复杂度 | $O(E \log V)$(堆优化) | $O(E \log E)$ |
| 适用场景 | 稠密图(边多) | 稀疏图(边少) |
Kruskal 核心步骤:
- 所有边按权值从小到大排序
- 依次取边,用并查集判断两端点是否已连通
- 未连通则加入 MST,已连通则跳过
- 选够 $V-1$ 条边后结束
Q8:最短路径算法(Dijkstra 和 Floyd)的区别与应用场景
| 维度 | Dijkstra | Floyd |
|---|---|---|
| 解决问题 | 单源最短路径 | 多源最短路径(所有点对) |
| 负权边 | 不支持 | 支持(但不能有负权环) |
| 时间复杂度 | $O(E \log V)$(堆优化) | $O(V^3)$ |
| 空间复杂度 | $O(V)$ | $O(V^2)$ |
| 适用场景 | 导航(从 A 到所有点的最短路) | 小规模图的全局最短路 |
Dijkstra 核心思想:贪心。维护一个已确定最短距离的集合,每次从未确定的点中选距离最小的加入,然后松弛其邻居。
Floyd 核心思想:动态规划。$dp[k][i][j]$ 表示经过前 $k$ 个点中转,$i$ 到 $j$ 的最短距离。状态转移:$dp[k][i][j] = \min(dp[k-1][i][j],\ dp[k-1][i][k] + dp[k-1][k][j])$。
Q9:动态规划的核心思想是什么?举一个典型例题(如背包问题)
核心思想:将原问题分解为重叠子问题,通过记录子问题的解(备忘录/DP 表)避免重复计算,自底向上推导出最终答案。
三要素:
- 最优子结构:大问题的最优解包含子问题的最优解
- 重叠子问题:不同路径会计算相同的子问题
- 状态转移方程:子问题间的递推关系
0-1 背包问题:$n$ 个物品,容量为 $W$ 的背包,每个物品有重量 $w_i$ 和价值 $v_i$,求最大价值。
状态定义:$dp[i][j]$ = 前 $i$ 个物品、容量为 $j$ 时的最大价值
转移方程:$dp[i][j] = \max(dp[i-1][j],\ dp[i-1][j-w_i] + v_i)$
def knapsack(weights, values, W):
n = len(weights)
dp = [0] * (W + 1)
for i in range(n):
for j in range(W, weights[i] - 1, -1):
dp[j] = max(dp[j], dp[j - weights[i]] + values[i])
return dp[W]
Q10:如何用两个栈实现队列?时间复杂度如何?
思路:栈 A 负责入队,栈 B 负责出队。出队时如果 B 为空,将 A 全部倒入 B。
class MyQueue:
def __init__(self):
self.stack_in = []
self.stack_out = []
def push(self, x):
self.stack_in.append(x)
def pop(self):
if not self.stack_out:
while self.stack_in:
self.stack_out.append(self.stack_in.pop())
return self.stack_out.pop()
时间复杂度:push $O(1)$,pop 均摊 $O(1)$。每个元素最多被搬运 2 次(入 A 一次,倒入 B 一次),所以 $n$ 次操作总共 $O(n)$,均摊每次 $O(1)$。
二、操作系统(10道)
Q11:进程和线程的区别?为什么需要线程?
| 维度 | 进程 | 线程 |
|---|---|---|
| 定义 | 资源分配的基本单位 | CPU 调度的基本单位 |
| 独立性 | 独立地址空间,互不干扰 | 共享进程的地址空间和资源 |
| 开销 | 创建/切换开销大(需切换页表、刷新 TLB) | 创建/切换开销小(共享地址空间) |
| 通信 | 需要 IPC(管道、共享内存等) | 直接读写共享变量(需同步) |
| 崩溃影响 | 一个进程崩溃不影响其他进程 | 一个线程崩溃可能导致整个进程崩溃 |
为什么需要线程:
- 并发效率:同一进程内多线程并发执行,避免进程切换的高开销
- 资源共享:线程间共享内存,数据交换比进程间通信高效得多
- 响应性:UI 线程 + 工作线程,避免主线程阻塞导致界面卡顿
Q12:进程间通信(IPC)的几种方式及优缺点?
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 管道(Pipe) | 半双工字节流,父子进程间 | 简单 | 单向、仅限父子进程 |
| 命名管道(FIFO) | 通过文件系统命名,无亲缘关系限制 | 跨进程 | 半双工、性能一般 |
| 消息队列 | 内核维护的消息链表 | 格式化消息、异步 | 有大小限制、拷贝开销 |
| 共享内存 | 多进程映射同一块物理内存 | 最快(无需拷贝) | 需要同步机制(信号量) |
| 信号量 | 计数器,控制共享资源访问 | 进程同步 | 只能传递简单信号 |
| 信号(Signal) | 异步通知机制(如 SIGKILL) | 简单、异步 | 信息量极少 |
| Socket | 网络通信,支持跨机器 | 通用、跨网络 | 开销最大 |
实际选型:同机高性能场景用共享内存 + 信号量;跨机器用 Socket;简单场景用管道。
Q13:什么是死锁?死锁产生的必要条件及解决方法?
死锁:两个或多个进程互相等待对方持有的资源,导致所有进程都无法继续执行。
四个必要条件(缺一不可):
| 条件 | 含义 |
|---|---|
| 互斥 | 资源同时只能被一个进程使用 |
| 持有并等待 | 进程持有已有资源的同时等待新资源 |
| 不可剥夺 | 已分配的资源不能被强制收回 |
| 循环等待 | 存在进程的循环等待链 |
解决方法:
| 策略 | 方法 | 破坏的条件 |
|---|---|---|
| 预防 | 一次性申请所有资源 | 持有并等待 |
| 预防 | 资源排序,按顺序申请 | 循环等待 |
| 避免 | 银行家算法(检查安全序列) | — |
| 检测 | 资源分配图检测环路 | — |
| 恢复 | 终止进程或强制剥夺资源 | — |
Q14:虚拟内存的作用?页面置换算法(LRU、FIFO)实现
虚拟内存的作用:
- 扩展内存:让程序使用的地址空间大于物理内存
- 进程隔离:每个进程有独立的虚拟地址空间,互不干扰
- 内存保护:通过页表权限位防止越权访问
- 按需加载:只有访问到的页面才加载到物理内存(缺页中断)
页面置换算法:
| 算法 | 策略 | 优缺点 |
|---|---|---|
| FIFO | 淘汰最先进入内存的页面 | 简单,但可能淘汰常用页面(Belady 异常) |
| LRU | 淘汰最近最久未使用的页面 | 效果好,但精确实现开销大 |
| Clock(近似 LRU) | 环形链表 + 访问位,给一次”二次机会” | 兼顾性能和效果,Linux 实际采用 |
| OPT | 淘汰未来最长时间不使用的页面 | 理论最优,但无法实现(需预知未来) |
LRU 实现:哈希表 + 双向链表,get 和 put 均 $O(1)$。
Q15:用户态和内核态的区别?如何切换?
| 维度 | 用户态 | 内核态 |
|---|---|---|
| 权限 | 只能访问用户空间 | 可访问所有内存和硬件 |
| 指令 | 不能执行特权指令 | 可执行所有指令 |
| 安全性 | 崩溃不影响系统 | 崩溃可能导致系统宕机 |
切换场景(用户态 → 内核态):
- 系统调用:程序主动调用
read()/write()等(软中断int 0x80或syscall) - 异常:除零、缺页等 CPU 异常
- 外部中断:键盘输入、网络数据到达等硬件中断
切换过程:保存用户态上下文(寄存器、PC)→ 切换到内核栈 → 执行内核代码 → 恢复上下文 → 返回用户态。
Q16:什么是孤儿进程和僵尸进程?如何避免?
| 类型 | 定义 | 危害 |
|---|---|---|
| 孤儿进程 | 父进程先退出,子进程被 init(PID=1)收养 | 无危害,init 会正常回收 |
| 僵尸进程 | 子进程已退出,但父进程未调用 wait() 回收其 PCB |
有害,占用 PID 和内核资源,大量僵尸会耗尽 PID |
避免僵尸进程的方法:
- 父进程调用
wait()/waitpid()回收子进程 - 注册
SIGCHLD信号处理函数,在回调中调用waitpid - 两次
fork():父 → 子 → 孙,子进程立即退出,孙进程被 init 收养
Q17:同步与互斥的区别?信号量和互斥锁的使用场景
| 概念 | 含义 |
|---|---|
| 互斥 | 同一时刻只有一个线程能访问共享资源(排他性) |
| 同步 | 多个线程按特定顺序执行(协调性) |
| 机制 | 特点 | 使用场景 |
|---|---|---|
| 互斥锁(Mutex) | 只有加锁线程能解锁;二值(锁定/解锁) | 保护临界区(如共享变量读写) |
| 信号量(Semaphore) | 计数器,任何线程可 P/V 操作 | 控制并发数(如连接池大小限制)、生产者-消费者同步 |
| 条件变量 | 配合互斥锁使用,等待特定条件满足 | 生产者-消费者模型 |
| 读写锁 | 多读单写 | 读多写少场景 |
Q18:协程是什么?与线程相比的优势?
| 维度 | 线程 | 协程 |
|---|---|---|
| 调度方式 | OS 内核调度(抢占式) | 用户态调度(协作式) |
| 切换开销 | 大(内核态切换、保存寄存器) | 极小(用户态切换,仅保存少量上下文) |
| 并发量 | 通常数千个 | 轻松数万~数十万个 |
| 内存占用 | 每个线程 ~1MB 栈 | 每个协程 ~几 KB |
| 同步 | 需要锁 | 单线程内无需锁(无竞争) |
协程优势:
- 高并发低开销:适合 I/O 密集型任务(网络请求、文件读写)
- 编程模型简洁:
async/await语法避免回调地狱 - 无锁编程:单线程内协程交替执行,不存在竞态条件
代表实现:Python asyncio、Go goroutine、Kotlin coroutine、Lua coroutine。
Q19:什么是零拷贝技术?如何实现?
传统数据传输(读文件 → 发网络)需要 4 次拷贝 + 4 次上下文切换:
磁盘 → 内核缓冲区 → 用户缓冲区 → Socket缓冲区 → 网卡
零拷贝:减少数据在内核态和用户态之间的拷贝次数。
| 技术 | 拷贝次数 | 原理 |
|---|---|---|
mmap + write |
3 次 | 用户空间直接映射内核缓冲区,省掉内核→用户的拷贝 |
sendfile |
2~3 次 | 内核内部直接从文件缓冲区传到 Socket 缓冲区,不经过用户空间 |
sendfile + DMA gather |
2 次 | 只拷贝描述符到 Socket,DMA 直接从文件缓冲区搬到网卡 |
splice |
2 次 | 利用管道在内核缓冲区之间移动数据 |
应用场景:Kafka(日志文件发送)、Nginx(静态文件服务)、RocketMQ。
Q20:什么是大端和小端模式?如何判断系统是大端还是小端?
定义(以 0x12345678 为例):
| 模式 | 存储方式(低地址 → 高地址) | 代表 |
|---|---|---|
| 大端(Big-Endian) | 12 34 56 78(高字节在低地址) |
网络字节序、Java 虚拟机 |
| 小端(Little-Endian) | 78 56 34 12(低字节在低地址) |
x86/x64、ARM(默认) |
判断方法:
import sys
print(sys.byteorder) # 'little' 或 'big'
int x = 1;
if (*(char*)&x == 1) printf("小端");
else printf("大端");
为什么需要关注:网络传输使用大端序(网络字节序),本地处理通常是小端,跨平台通信需要做字节序转换(htonl/ntohl)。
三、计算机网络(10道)
Q21:TCP 和 UDP 的区别?各自的典型应用场景?
| 维度 | TCP | UDP |
|---|---|---|
| 连接方式 | 面向连接(三次握手) | 无连接 |
| 可靠性 | 可靠(确认、重传、排序) | 不可靠(可能丢包/乱序) |
| 传输方式 | 字节流 | 数据报 |
| 拥塞控制 | 有(慢启动、拥塞避免等) | 无 |
| 头部开销 | 20 字节起 | 8 字节 |
| 速度 | 较慢 | 快 |
应用场景:
- TCP:HTTP/HTTPS、FTP、SMTP(可靠性要求高)
- UDP:视频直播、DNS 查询、游戏实时通信、QUIC 协议(速度要求高)
Q22:三次握手和四次挥手的详细过程?为什么需要三次握手?
三次握手(建立连接):
客户端 服务端
|--- SYN(seq=x) --->| ① 客户端发起连接请求
|<-- SYN+ACK(seq=y, ack=x+1) ---| ② 服务端确认并发起自己的连接请求
|--- ACK(ack=y+1) ->| ③ 客户端确认服务端的请求
为什么不是两次:两次握手无法防止历史连接的 SYN 导致误建连接。如果客户端发了一个旧的 SYN,服务端收到后直接建立连接并分配资源,浪费。三次握手让客户端有机会通过第三步 ACK 来确认”这是一个有效的连接请求”。
四次挥手(断开连接):
主动方 被动方
|--- FIN --->| ① 主动方请求关闭
|<-- ACK ----| ② 被动方确认(此时被动方可能还有数据要发)
|<-- FIN ----| ③ 被动方也准备关闭
|--- ACK --->| ④ 主动方确认(进入 TIME_WAIT,等 2MSL)
TIME_WAIT 等 2MSL 的原因:确保最后的 ACK 能到达被动方;确保旧连接的数据包在网络中消亡。
Q23:TCP 如何保证可靠性(如超时重传、滑动窗口)?
| 机制 | 作用 |
|---|---|
| 序列号 + 确认号 | 保证数据有序、检测丢包 |
| 超时重传(RTO) | 发送后未收到 ACK,超时后重发 |
| 快速重传 | 收到 3 个重复 ACK,立即重传(不等超时) |
| 滑动窗口 | 控制发送速率,允许发送方在未收到 ACK 前发送多个分组 |
| 流量控制 | 接收方通过窗口大小(rwnd)告知发送方自己的处理能力 |
| 拥塞控制 | 慢启动 → 拥塞避免 → 快重传 → 快恢复,动态调整发送速率 |
| 校验和 | 检测传输中的比特错误 |
Q24:HTTP 和 HTTPS 的区别?SSL/TLS 握手过程?
| 维度 | HTTP | HTTPS |
|---|---|---|
| 端口 | 80 | 443 |
| 加密 | 明文 | SSL/TLS 加密 |
| 证书 | 不需要 | 需要 CA 证书 |
| 性能 | 快 | 握手增加 1-2 RTT |
TLS 1.2 握手过程(简化):
客户端 服务端
|-- ClientHello(支持的加密套件)-->|
|<- ServerHello + 证书 + 公钥 ---|
| [客户端验证证书] |
|-- 预主密钥(用服务端公钥加密)-->|
| [双方根据预主密钥生成会话密钥] |
|-- ChangeCipherSpec + Finished ->|
|<- ChangeCipherSpec + Finished --|
| [后续用对称密钥加密通信] |
核心:非对称加密交换密钥 → 对称加密传输数据。兼顾安全性(非对称)和性能(对称)。
Q25:HTTP/1.1、HTTP/2、HTTP/3 的改进点?
| 版本 | 关键改进 |
|---|---|
| HTTP/1.0 | 每次请求建立新 TCP 连接 |
| HTTP/1.1 | 持久连接(Connection: keep-alive)、管道化(Pipeline,但有队头阻塞) |
| HTTP/2 | 多路复用(一个 TCP 连接并发多个请求)、头部压缩(HPACK)、服务器推送、二进制帧 |
| HTTP/3 | 基于 QUIC(UDP),彻底解决 TCP 队头阻塞、0-RTT 连接建立、内置加密 |
队头阻塞问题的演进:
- HTTP/1.1:请求按序响应,一个慢请求阻塞后续所有
- HTTP/2:应用层多路复用解决了 HTTP 层队头阻塞,但 TCP 层丢包仍会阻塞所有流
- HTTP/3:改用 UDP + QUIC,每个流独立,一个流丢包不影响其他流
Q26:GET 和 POST 的区别?PUT 和 PATCH 的区别?
| 维度 | GET | POST |
|---|---|---|
| 语义 | 获取资源(幂等、安全) | 创建/提交数据(非幂等) |
| 参数位置 | URL 查询字符串 | 请求体(Body) |
| 数据长度 | 受 URL 长度限制(约 2KB~8KB) | 无限制 |
| 缓存 | 可被浏览器缓存 | 默认不缓存 |
| 书签/历史 | 可收藏、留在历史记录 | 不会 |
| 安全性 | 参数暴露在 URL(不适合敏感数据) | 相对安全(但不加密仍明文) |
| 维度 | PUT | PATCH |
|---|---|---|
| 语义 | 全量替换资源 | 部分更新资源 |
| 幂等性 | 幂等(多次执行结果相同) | 不一定幂等 |
| 请求体 | 包含资源的完整表示 | 只包含需要修改的字段 |
Q27:什么是 DNS 解析过程?递归查询与迭代查询的区别?
DNS 解析完整流程(以访问 www.example.com 为例):
- 浏览器缓存 → 2. 操作系统缓存(hosts 文件)→ 3. 本地 DNS 服务器
- 本地 DNS → 根域名服务器(返回
.com顶级域服务器地址) - 本地 DNS → 顶级域服务器(返回
example.com权威服务器地址) - 本地 DNS → 权威 DNS 服务器(返回
www.example.com的 IP) - 本地 DNS 缓存结果并返回给客户端
| 类型 | 行为 | 角色 |
|---|---|---|
| 递归查询 | 客户端发一次请求,DNS 服务器负责查到底 | 客户端 → 本地 DNS |
| 迭代查询 | DNS 服务器返回”你去问谁”,由请求方自己继续查 | 本地 DNS → 根/顶级/权威 |
Q28:什么是 CDN?如何实现就近访问?
CDN(内容分发网络):将内容缓存到全球各地的边缘节点,用户访问时被引导到最近的节点获取内容。
就近访问的实现:
| 技术 | 原理 |
|---|---|
| DNS 智能解析 | 根据用户 IP 所在地区,解析到最近的 CDN 节点 IP |
| Anycast | 多个节点共享同一 IP,网络路由自动选最近的 |
| HTTP 302 重定向 | 中心调度器根据负载和位置重定向到最优节点 |
CDN 适合的内容:静态资源(图片、CSS、JS、视频)、下载文件。不适合实时动态数据。
Q29:什么是 SYN Flood 攻击?如何防御?
攻击原理:攻击者发送大量伪造源 IP 的 SYN 包,服务端为每个 SYN 分配资源(半连接队列),等待不会到来的 ACK,最终半连接队列被填满,正常连接无法建立。
防御方法:
| 方法 | 原理 |
|---|---|
| SYN Cookie | 不分配资源,将连接信息编码在 SYN+ACK 的序列号中,收到第三步 ACK 时验证并恢复 |
| 半连接队列扩容 | 增大 tcp_max_syn_backlog |
| 缩短 SYN 超时 | 减小半连接的存活时间 |
| 防火墙限流 | 对单 IP 的 SYN 请求限速 |
| CDN/负载均衡 | 在前端过滤异常流量 |
Q30:从输入 URL 到页面显示的全过程
- URL 解析:浏览器判断是搜索词还是 URL,提取协议、域名、路径
- DNS 解析:域名 → IP 地址(浏览器缓存 → OS 缓存 → 本地 DNS → 递归查询)
- TCP 连接:三次握手建立连接(HTTPS 还需 TLS 握手)
- 发送 HTTP 请求:构造请求报文(方法、头部、Cookie 等)
- 服务端处理:负载均衡 → Web 服务器 → 应用逻辑 → 数据库查询 → 返回响应
- 浏览器接收响应:解析状态码(200/301/404 等)
- 解析渲染:
- HTML → DOM 树
- CSS → CSSOM 树
- DOM + CSSOM → 渲染树
- Layout(布局计算)→ Paint(绘制)→ Composite(合成)
- 加载子资源:JS、CSS、图片等(可能触发重排/重绘)
- 执行 JS:可能修改 DOM,触发重新渲染
四、数据库(10道)
Q31:数据库事务的 ACID 特性及实现原理?
| 特性 | 含义 | 实现方式(InnoDB) |
|---|---|---|
| Atomicity(原子性) | 事务要么全部成功,要么全部回滚 | Undo Log(回滚日志) |
| Consistency(一致性) | 事务前后数据满足约束条件 | 由 AID 共同保证 |
| Isolation(隔离性) | 并发事务互不干扰 | MVCC + 锁 |
| Durability(持久性) | 事务提交后数据不丢失 | Redo Log(重做日志)+ WAL |
WAL(Write-Ahead Logging):先写日志,再写数据。即使崩溃,也能通过 Redo Log 恢复已提交事务的数据。
Q32:事务隔离级别有哪些?如何解决脏读、不可重复读、幻读?
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现 |
|---|---|---|---|---|
| 读未提交(Read Uncommitted) | 可能 | 可能 | 可能 | 无锁 |
| 读已提交(Read Committed) | 解决 | 可能 | 可能 | 每次 SELECT 生成新的 Read View |
| 可重复读(Repeatable Read) | 解决 | 解决 | InnoDB 基本解决 | 事务开始时生成 Read View(MVCC) |
| 串行化(Serializable) | 解决 | 解决 | 解决 | 读加共享锁,写加排他锁 |
名词解释:
- 脏读:读到其他事务未提交的数据
- 不可重复读:同一事务两次读同一行,结果不同(被其他事务修改)
- 幻读:同一事务两次范围查询,结果行数不同(被其他事务插入/删除)
InnoDB 默认:可重复读(RR),通过 MVCC + Next-Key Lock 基本解决幻读。
Q33:什么是索引?B+ 树索引和哈希索引的区别?
| 维度 | B+ 树索引 | 哈希索引 |
|---|---|---|
| 数据结构 | 多路平衡搜索树,叶子节点链表相连 | 哈希表 |
| 等值查询 | $O(\log n)$ | $O(1)$ |
| 范围查询 | 支持(叶子节点有序链表) | 不支持 |
| 排序 | 支持(天然有序) | 不支持 |
| 模糊查询 | 支持前缀匹配(LIKE 'abc%') |
不支持 |
| 哈希冲突 | 无 | 需要处理 |
为什么 MySQL InnoDB 默认用 B+ 树:
- 支持范围查询和排序(哈希不行)
- 叶子节点链表相连,范围扫描高效
- 树高度低(3~4 层即可支撑千万级数据),磁盘 I/O 少
Q34:聚簇索引和非聚簇索引的区别?
| 维度 | 聚簇索引 | 非聚簇索引(二级索引) |
|---|---|---|
| 存储方式 | 叶子节点存储完整行数据 | 叶子节点存储主键值 |
| 数量 | 每张表只能有 1 个(通常是主键) | 可以有多个 |
| 查询效率 | 主键查询最快(数据就在叶子节点) | 需要回表(先查二级索引得到主键,再查聚簇索引) |
| 顺序 | 数据物理存储顺序与索引一致 | 数据物理存储顺序与索引无关 |
回表:通过非聚簇索引查到主键后,再根据主键去聚簇索引查完整行数据。可以通过覆盖索引避免回表。
Q35:什么是覆盖索引?举例说明
覆盖索引:查询的字段全部包含在索引中,无需回表查聚簇索引,直接从索引中返回数据。
举例:
-- 表:users(id, name, age, email)
-- 索引:idx_name_age(name, age)
-- 覆盖索引(只需 name 和 age,索引中全有)
SELECT name, age FROM users WHERE name = '张三';
-- EXPLAIN 显示 Extra: Using index
-- 非覆盖索引(还需要 email,索引中没有,需回表)
SELECT name, age, email FROM users WHERE name = '张三';
优化技巧:高频查询只需少数字段时,建联合索引覆盖这些字段,避免回表。
Q36:数据库锁的分类(行锁、表锁、间隙锁)及使用场景?
| 锁类型 | 粒度 | 特点 | 使用场景 |
|---|---|---|---|
| 表锁 | 整张表 | 开销小、加锁快、冲突概率高 | MyISAM 默认、DDL 操作 |
| 行锁 | 单行记录 | 开销大、加锁慢、冲突概率低 | InnoDB 默认、高并发 OLTP |
| 间隙锁(Gap Lock) | 索引记录之间的间隙 | 防止幻读(阻止其他事务在间隙中插入) | RR 隔离级别下的范围查询 |
| Next-Key Lock | 行锁 + 间隙锁 | InnoDB 的默认行锁实现 | 解决幻读 |
| 锁模式 | 说明 |
|---|---|
| 共享锁(S 锁) | 读锁,多个事务可同时持有 |
| 排他锁(X 锁) | 写锁,与其他任何锁互斥 |
| 意向锁 | 表级锁,用于快速判断表中是否有行锁 |
Q37:数据库的三大范式是什么?是否一定要遵循?
| 范式 | 要求 | 举例 |
|---|---|---|
| 1NF | 每列是原子的(不可再分) | 地址不能是”北京市海淀区”一列,应拆为省/市/区 |
| 2NF | 满足 1NF + 非主键列完全依赖主键(消除部分依赖) | 订单详情表不应包含商品名称(只依赖商品 ID 而非订单 ID) |
| 3NF | 满足 2NF + 非主键列不依赖其他非主键列(消除传递依赖) | 学生表不应有”院长姓名”(依赖院系,院系依赖学生) |
是否一定要遵循:不一定。实际中为了查询性能会做反范式设计(适当冗余):
- 高频查询需要 JOIN 多表时,冗余字段避免 JOIN
- 数据仓库/OLAP 场景,宽表设计优先查询效率
- 原则:OLTP 尽量遵循范式,OLAP 适度反范式
Q38:SQL 优化的常见手段
| 手段 | 说明 |
|---|---|
避免 SELECT * |
只查需要的字段,减少 I/O 和网络传输 |
| 使用 EXPLAIN | 查看执行计划,确认是否走索引 |
| 合理建索引 | 对 WHERE/JOIN/ORDER BY 的高频字段建索引 |
| 避免索引失效 | 不对索引列做函数运算、隐式类型转换、LIKE '%abc' |
| 避免 N+1 查询 | 用 JOIN 或 IN 替代循环中逐条查询 |
| 分页优化 | 深分页用 WHERE id > last_id LIMIT N 替代 OFFSET |
| 批量操作 | INSERT INTO ... VALUES (...), (...), (...) 替代逐条插入 |
| 读写分离 | 写操作走主库,读操作走从库 |
| 合理使用缓存 | 热点数据缓存到 Redis,减少 DB 压力 |
Q39:什么是数据库的读写分离?如何保证主从一致性?
读写分离:写操作发到主库,读操作发到从库。主库通过 binlog 同步数据到从库。
写请求 → 主库(Master)
↓ binlog 同步
读请求 → 从库(Slave)
主从一致性问题:主库写入后,从库可能还没同步完,读到旧数据。
解决方案:
| 方案 | 原理 | 缺点 |
|---|---|---|
| 强制读主 | 关键读操作直接查主库 | 失去读写分离的意义 |
| 半同步复制 | 主库等待至少一个从库确认收到 binlog 再返回 | 增加写延迟 |
| 延迟判断 | 读之前检查从库延迟(Seconds_Behind_Master),延迟大则读主库 |
实现复杂 |
| 中间件路由 | 数据库中间件记录最近写入的 key,短时间内的读走主库 | 需要中间件支持 |
Q40:Redis 的持久化机制(RDB 和 AOF)及优缺点?
| 维度 | RDB(快照) | AOF(追加日志) |
|---|---|---|
| 原理 | 定时将内存数据生成快照文件 | 记录每条写命令,追加到日志文件 |
| 触发 | save/bgsave(fork 子进程) |
每条命令/每秒/不同步三种策略 |
| 文件大小 | 小(二进制压缩) | 大(文本命令,但可 bgrewriteaof 压缩) |
| 恢复速度 | 快 | 慢(需重放所有命令) |
| 数据安全性 | 可能丢失最近一次快照后的数据 | 最多丢 1 秒数据(everysec 策略) |
Redis 4.0+ 混合持久化:AOF 重写时以 RDB 格式写入前半部分 + AOF 增量,兼顾恢复速度和数据安全。
实际建议:两者都开启。RDB 用于灾难恢复和备份,AOF 保证数据完整性。
五、系统设计(10道)
Q41:设计一个分布式 ID 生成器(如雪花算法)
需求:全局唯一、趋势递增、高性能、高可用。
雪花算法(Snowflake)结构(64 bit):
0 | 41 bit 时间戳 | 10 bit 机器ID | 12 bit 序列号
| 部分 | 位数 | 说明 |
|---|---|---|
| 符号位 | 1 | 固定 0 |
| 时间戳 | 41 | 毫秒级,可用约 69 年 |
| 机器 ID | 10 | 支持 1024 个节点(5 bit 数据中心 + 5 bit 机器) |
| 序列号 | 12 | 同一毫秒内递增,支持 4096 个/ms/机器 |
优点:趋势递增(时间戳在高位)、不依赖外部存储、性能极高。 缺点:依赖机器时钟,时钟回拨会导致 ID 重复。解决:记录上次时间戳,回拨时等待或报错。
其他方案:数据库自增(简单但有瓶颈)、UUID(无序、太长)、Redis INCR(依赖 Redis 可用性)。
Q42:如何设计一个秒杀系统(高并发、防超卖)?
核心挑战:瞬间高并发、库存不能超卖、用户体验好。
架构设计:
用户 → CDN(静态页面)→ 网关(限流/鉴权)→ 秒杀服务 → Redis 预减库存 → MQ → 订单服务 → DB
| 层面 | 策略 |
|---|---|
| 前端 | 按钮置灰防重复点击、CDN 缓存静态页、URL 动态化防刷 |
| 网关 | 限流(令牌桶)、黑名单、验证码 |
| 服务层 | Redis 原子操作(DECR)预减库存,库存 ≤ 0 直接返回 |
| 防超卖 | Redis DECR 原子操作 + Lua 脚本保证原子性 |
| 异步下单 | 预减成功后发 MQ 消息,异步创建订单(削峰填谷) |
| 数据库 | 乐观锁(UPDATE SET stock = stock - 1 WHERE stock > 0) |
Q43:如何实现 LRU 缓存?时间复杂度如何优化?
LRU(Least Recently Used):淘汰最近最久未使用的数据。
数据结构:哈希表 + 双向链表
- 哈希表:
key → 链表节点,$O(1)$ 查找 - 双向链表:维护访问顺序,最近访问的在头部,最久未访问的在尾部
操作:
get(key):哈希表查到节点 → 移到链表头部 → 返回值。$O(1)$put(key, val):已存在则更新并移到头部;不存在则插入头部,超容量则删除尾部。$O(1)$
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.cache = OrderedDict()
self.capacity = capacity
def get(self, key):
if key not in self.cache:
return -1
self.cache.move_to_end(key)
return self.cache[key]
def put(self, key, value):
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.capacity:
self.cache.popitem(last=False)
Q44:设计一个线程池需要考虑哪些参数?
| 参数 | 说明 | 如何设置 |
|---|---|---|
| 核心线程数 | 常驻线程数,即使空闲也不回收 | CPU 密集型:CPU 核心数 + 1;I/O 密集型:2 * CPU 核心数 |
| 最大线程数 | 线程池允许的最大线程数 | 根据系统资源和并发量评估 |
| 等待队列 | 核心线程满后任务排队等待 | 有界队列(ArrayBlockingQueue)防止 OOM |
| 拒绝策略 | 队列满 + 线程达上限时如何处理新任务 | 抛异常 / 丢弃 / 调用者执行 / 丢弃最旧 |
| 空闲存活时间 | 超过核心线程数的线程空闲多久后回收 | 根据流量特征设置 |
| 线程工厂 | 自定义线程名称、优先级等 | 统一命名便于排查问题 |
执行流程:新任务 → 核心线程未满则创建新线程 → 满则入队列 → 队列满则创建非核心线程 → 达上限则执行拒绝策略。
Q45:如何设计一个短链服务(生成、存储、跳转)?
核心流程:
长URL → 生成短码 → 存储映射 → 用户访问短链 → 301/302重定向到长URL
短码生成方案:
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 哈希算法 | MurmurHash(长URL) → 取前 6-8 位 Base62 | 简单,但有碰撞风险 |
| 自增 ID + Base62 | 数据库自增 ID 转 62 进制 | 无碰撞,但 ID 可预测 |
| 预生成 | 提前生成一批短码存入池中 | 无碰撞、高性能,维护成本高 |
存储:短码 → 长URL 存 MySQL + Redis 缓存热点。
跳转:301(永久重定向,浏览器缓存,后续不再请求服务器)vs 302(临时重定向,每次经过服务器,便于统计点击量)。通常用 302。
Q46:分布式系统如何保证数据一致性(CAP 理论)?
CAP 定理:分布式系统最多同时满足两项:
| 属性 | 含义 |
|---|---|
| Consistency(一致性) | 所有节点在同一时刻看到相同数据 |
| Availability(可用性) | 每个请求都能收到响应(不保证最新) |
| Partition tolerance(分区容忍性) | 网络分区时系统仍能运行 |
分布式系统必须容忍网络分区(P),因此只能在 C 和 A 之间选择:
- CP:保一致性,牺牲可用性(ZooKeeper、etcd、HBase)
- AP:保可用性,牺牲一致性(Cassandra、DynamoDB、Eureka)
实际方案:大多数系统选择 BASE(最终一致性):基本可用 + 软状态 + 最终一致。通过消息队列、异步同步等方式,允许短暂不一致,最终达到一致。
Q47:分库分表的常见方案及优缺点?
分表策略:
| 策略 | 原理 | 优缺点 |
|---|---|---|
| 水平分表 | 按行拆分(如按 user_id % 16) | 每表数据量减少,查询快;但跨表查询复杂 |
| 垂直分表 | 按列拆分(如大字段独立成表) | 热数据和冷数据分离;但需 JOIN |
分库策略:
| 策略 | 原理 | 适用场景 |
|---|---|---|
| 水平分库 | 不同数据分到不同数据库实例 | 单库容量/连接数瓶颈 |
| 垂直分库 | 按业务域拆分(用户库、订单库、商品库) | 微服务架构 |
常见问题:
- 分布式事务:Seata、TCC、消息最终一致
- 跨库 JOIN:应用层组装或宽表冗余
- 全局排序/分页:各分片取 Top N 再合并
- 全局唯一 ID:雪花算法
中间件:ShardingSphere、MyCat、Vitess。
Q48:如何设计一个限流算法(令牌桶、漏桶)?
| 算法 | 原理 | 特点 |
|---|---|---|
| 固定窗口 | 统计固定时间窗口内请求数 | 简单,但窗口边界可能突刺(两个窗口交界处 2 倍流量) |
| 滑动窗口 | 将窗口切分为多个小格子,滑动统计 | 平滑,解决边界突刺 |
| 漏桶 | 请求入桶,以固定速率流出 | 严格平滑,但无法应对突发流量 |
| 令牌桶 | 以固定速率生成令牌,请求需获取令牌才能通过 | 允许一定程度突发(桶内有存量令牌) |
令牌桶算法(最常用):
import time
class TokenBucket:
def __init__(self, rate, capacity):
self.rate = rate
self.capacity = capacity
self.tokens = capacity
self.last_time = time.time()
def allow(self):
now = time.time()
self.tokens = min(self.capacity, self.tokens + (now - self.last_time) * self.rate)
self.last_time = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
实际应用:Nginx 限流(limit_req)、Sentinel、Guava RateLimiter。
Q49:微服务中服务发现和负载均衡的实现原理?
服务发现:
| 模式 | 原理 | 代表 |
|---|---|---|
| 客户端发现 | 服务注册到注册中心,客户端查询注册中心获取实例列表并自行选择 | Eureka、Nacos |
| 服务端发现 | 客户端请求负载均衡器,由它查询注册中心并转发 | AWS ELB、Kubernetes Service |
注册中心:服务启动时注册自己的 IP:Port,定时发送心跳;消费者订阅服务列表并缓存到本地。
负载均衡策略:
| 策略 | 原理 |
|---|---|
| 轮询(Round Robin) | 依次分配请求 |
| 加权轮询 | 按权重分配(性能好的机器权重高) |
| 随机 | 随机选择一个实例 |
| 最少连接数 | 选当前连接数最少的实例 |
| 一致性哈希 | 相同 key 的请求总是到同一实例(有利于缓存命中) |
Q50:如何保证接口的幂等性?举例说明
幂等性:同一操作执行多次,结果与执行一次相同。
为什么需要幂等:网络超时重试、消息队列重复消费、用户重复点击等场景都可能导致接口被多次调用。
实现方案:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 唯一请求 ID | 客户端生成唯一 ID,服务端去重(Redis SETNX) |
通用 |
| 数据库唯一约束 | 对业务唯一键加唯一索引,重复插入直接报错 | 创建类操作 |
| Token 机制 | 先获取 Token,提交时校验并删除 Token | 表单提交 |
| 乐观锁 | UPDATE SET amount = amount - 100 WHERE version = v |
更新类操作 |
| 状态机 | 订单状态只能单向流转(待支付→已支付),不能逆向 | 有状态业务 |
举例:支付接口使用唯一订单号 + Redis 去重。第一次请求扣款成功并记录订单号到 Redis;重试请求发现订单号已存在,直接返回”已支付”,不会重复扣款。