八股文 / 通用

程序员面试八股文集合 - 50 道高频题

涵盖数据结构与算法、操作系统、计算机网络、数据库、系统设计五大方向,适合后端开发、算法等技术岗针对性复习。


一、数据结构与算法(10道)

Q1:数组和链表的区别?各自适用场景?

维度 数组 链表
内存布局 连续内存空间 离散节点,通过指针连接
随机访问 $O(1)$(下标直接定位) $O(n)$(需从头遍历)
插入/删除 $O(n)$(需移动元素) $O(1)$(修改指针即可,已知位置时)
内存效率 高(无额外指针开销) 低(每个节点存储额外指针)
缓存友好性 好(连续内存,CPU Cache 命中率高) 差(离散内存,Cache Miss 多)

适用场景

  • 数组:频繁随机访问、数据量已知、需要高缓存性能(如矩阵运算、查表)
  • 链表:频繁插入删除、数据量不确定、不需要随机访问(如 LRU 缓存、多项式运算)

Q2:如何判断链表是否有环?如何找到环的入口?

判断是否有环 — 快慢指针(Floyd 判环法)

  • 慢指针每次走 1 步,快指针每次走 2 步
  • 如果有环,快慢指针一定会在环内相遇;如果无环,快指针会先到 null

找环入口

  1. 快慢指针相遇后,将其中一个指针重置到头节点
  2. 两个指针都每次走 1 步
  3. 再次相遇的节点就是环入口

数学证明:设头到入口距离为 $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 核心步骤

  1. 所有边按权值从小到大排序
  2. 依次取边,用并查集判断两端点是否已连通
  3. 未连通则加入 MST,已连通则跳过
  4. 选够 $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 表)避免重复计算,自底向上推导出最终答案。

三要素

  1. 最优子结构:大问题的最优解包含子问题的最优解
  2. 重叠子问题:不同路径会计算相同的子问题
  3. 状态转移方程:子问题间的递推关系

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(管道、共享内存等) 直接读写共享变量(需同步)
崩溃影响 一个进程崩溃不影响其他进程 一个线程崩溃可能导致整个进程崩溃

为什么需要线程

  1. 并发效率:同一进程内多线程并发执行,避免进程切换的高开销
  2. 资源共享:线程间共享内存,数据交换比进程间通信高效得多
  3. 响应性:UI 线程 + 工作线程,避免主线程阻塞导致界面卡顿

Q12:进程间通信(IPC)的几种方式及优缺点?

方式 原理 优点 缺点
管道(Pipe) 半双工字节流,父子进程间 简单 单向、仅限父子进程
命名管道(FIFO) 通过文件系统命名,无亲缘关系限制 跨进程 半双工、性能一般
消息队列 内核维护的消息链表 格式化消息、异步 有大小限制、拷贝开销
共享内存 多进程映射同一块物理内存 最快(无需拷贝) 需要同步机制(信号量)
信号量 计数器,控制共享资源访问 进程同步 只能传递简单信号
信号(Signal) 异步通知机制(如 SIGKILL) 简单、异步 信息量极少
Socket 网络通信,支持跨机器 通用、跨网络 开销最大

实际选型:同机高性能场景用共享内存 + 信号量;跨机器用 Socket;简单场景用管道。


Q13:什么是死锁?死锁产生的必要条件及解决方法?

死锁:两个或多个进程互相等待对方持有的资源,导致所有进程都无法继续执行。

四个必要条件(缺一不可):

条件 含义
互斥 资源同时只能被一个进程使用
持有并等待 进程持有已有资源的同时等待新资源
不可剥夺 已分配的资源不能被强制收回
循环等待 存在进程的循环等待链

解决方法

策略 方法 破坏的条件
预防 一次性申请所有资源 持有并等待
预防 资源排序,按顺序申请 循环等待
避免 银行家算法(检查安全序列)
检测 资源分配图检测环路
恢复 终止进程或强制剥夺资源

Q14:虚拟内存的作用?页面置换算法(LRU、FIFO)实现

虚拟内存的作用

  1. 扩展内存:让程序使用的地址空间大于物理内存
  2. 进程隔离:每个进程有独立的虚拟地址空间,互不干扰
  3. 内存保护:通过页表权限位防止越权访问
  4. 按需加载:只有访问到的页面才加载到物理内存(缺页中断)

页面置换算法

算法 策略 优缺点
FIFO 淘汰最先进入内存的页面 简单,但可能淘汰常用页面(Belady 异常)
LRU 淘汰最近最久未使用的页面 效果好,但精确实现开销大
Clock(近似 LRU) 环形链表 + 访问位,给一次”二次机会” 兼顾性能和效果,Linux 实际采用
OPT 淘汰未来最长时间不使用的页面 理论最优,但无法实现(需预知未来)

LRU 实现:哈希表 + 双向链表,getput 均 $O(1)$。


Q15:用户态和内核态的区别?如何切换?

维度 用户态 内核态
权限 只能访问用户空间 可访问所有内存和硬件
指令 不能执行特权指令 可执行所有指令
安全性 崩溃不影响系统 崩溃可能导致系统宕机

切换场景(用户态 → 内核态):

  1. 系统调用:程序主动调用 read()/write() 等(软中断 int 0x80syscall
  2. 异常:除零、缺页等 CPU 异常
  3. 外部中断:键盘输入、网络数据到达等硬件中断

切换过程:保存用户态上下文(寄存器、PC)→ 切换到内核栈 → 执行内核代码 → 恢复上下文 → 返回用户态。


Q16:什么是孤儿进程和僵尸进程?如何避免?

类型 定义 危害
孤儿进程 父进程先退出,子进程被 init(PID=1)收养 无危害,init 会正常回收
僵尸进程 子进程已退出,但父进程未调用 wait() 回收其 PCB 有害,占用 PID 和内核资源,大量僵尸会耗尽 PID

避免僵尸进程的方法

  1. 父进程调用 wait()/waitpid() 回收子进程
  2. 注册 SIGCHLD 信号处理函数,在回调中调用 waitpid
  3. 两次 fork():父 → 子 → 孙,子进程立即退出,孙进程被 init 收养

Q17:同步与互斥的区别?信号量和互斥锁的使用场景

概念 含义
互斥 同一时刻只有一个线程能访问共享资源(排他性)
同步 多个线程按特定顺序执行(协调性)
机制 特点 使用场景
互斥锁(Mutex) 只有加锁线程能解锁;二值(锁定/解锁) 保护临界区(如共享变量读写)
信号量(Semaphore) 计数器,任何线程可 P/V 操作 控制并发数(如连接池大小限制)、生产者-消费者同步
条件变量 配合互斥锁使用,等待特定条件满足 生产者-消费者模型
读写锁 多读单写 读多写少场景

Q18:协程是什么?与线程相比的优势?

维度 线程 协程
调度方式 OS 内核调度(抢占式) 用户态调度(协作式)
切换开销 大(内核态切换、保存寄存器) 极小(用户态切换,仅保存少量上下文)
并发量 通常数千个 轻松数万~数十万个
内存占用 每个线程 ~1MB 栈 每个协程 ~几 KB
同步 需要锁 单线程内无需锁(无竞争)

协程优势

  1. 高并发低开销:适合 I/O 密集型任务(网络请求、文件读写)
  2. 编程模型简洁async/await 语法避免回调地狱
  3. 无锁编程:单线程内协程交替执行,不存在竞态条件

代表实现: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 为例):

  1. 浏览器缓存 → 2. 操作系统缓存(hosts 文件)→ 3. 本地 DNS 服务器
  2. 本地 DNS → 根域名服务器(返回 .com 顶级域服务器地址)
  3. 本地 DNS → 顶级域服务器(返回 example.com 权威服务器地址)
  4. 本地 DNS → 权威 DNS 服务器(返回 www.example.com 的 IP)
  5. 本地 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 到页面显示的全过程

  1. URL 解析:浏览器判断是搜索词还是 URL,提取协议、域名、路径
  2. DNS 解析:域名 → IP 地址(浏览器缓存 → OS 缓存 → 本地 DNS → 递归查询)
  3. TCP 连接:三次握手建立连接(HTTPS 还需 TLS 握手)
  4. 发送 HTTP 请求:构造请求报文(方法、头部、Cookie 等)
  5. 服务端处理:负载均衡 → Web 服务器 → 应用逻辑 → 数据库查询 → 返回响应
  6. 浏览器接收响应:解析状态码(200/301/404 等)
  7. 解析渲染
    • HTML → DOM 树
    • CSS → CSSOM 树
    • DOM + CSSOM → 渲染树
    • Layout(布局计算)→ Paint(绘制)→ Composite(合成)
  8. 加载子资源:JS、CSS、图片等(可能触发重排/重绘)
  9. 执行 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+ 树

  1. 支持范围查询和排序(哈希不行)
  2. 叶子节点链表相连,范围扫描高效
  3. 树高度低(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;重试请求发现订单号已存在,直接返回”已支付”,不会重复扣款。