11908 字
60 分钟
虾皮后端日常实习一面

虚拟内存和物理内存#

一、基本定义#

物理内存(Physical Memory)

  • 计算机上真实存在的 RAM 硬件
  • 地址从 0 开始连续编址,容量有限(如 8GB、16GB)
  • 直接被 CPU 访问,速度快但昂贵

虚拟内存(Virtual Memory)

  • 为每个进程提供的”假想”的独立内存空间
  • 通常是一个巨大的线性地址空间(32 位系统 4GB,64 位系统理论上可达数百 TB)
  • 程序看到的都是虚拟地址,由操作系统映射到物理地址

二、核心原理#

1. 地址映射机制#

虚拟地址 ---[MMU(内存管理单元)]---> 物理地址
|
v
页表(Page Table)

分页(Paging)机制:

  • 将虚拟内存和物理内存都划分为固定大小的块
  • 虚拟内存的块称为”页”(Page),通常 4KB
  • 物理内存的块称为”页框”(Page Frame)
  • 通过页表建立页到页框的映射关系

地址转换过程:

虚拟地址 = [页号 | 页内偏移]
查询页表
物理地址 = [页框号 | 页内偏移]

2. 按需分页(Demand Paging)#

不是所有页都需要同时在物理内存中:

  • 页表项包含”存在位”(Present Bit)
  • 访问不在内存的页触发缺页异常(Page Fault)
  • OS 从磁盘加载所需页到内存
  • 如果内存满了,通过页面置换算法换出某些页

3. 多级页表#

为节省页表空间(一级页表太大),采用分层结构:

64位系统典型结构:
虚拟地址 = [L4索引 | L3索引 | L2索引 | L1索引 | 偏移]
9位 9位 9位 9位 12位

只为实际使用的地址空间分配页表。

三、核心数据结构#

1. 页表(Page Table)#

struct page_table_entry {
unsigned long pfn : 40; // 页框号(Physical Frame Number)
unsigned int present : 1; // 存在位
unsigned int writable : 1; // 可写位
unsigned int user : 1; // 用户态可访问
unsigned int accessed : 1; // 访问位(用于LRU)
unsigned int dirty : 1; // 修改位(写回磁盘时检查)
unsigned int reserved : 19; // 其他标志位
};

2. TLB(Translation Lookaside Buffer)#

  • 页表查询的硬件缓存
  • 存储最近使用的虚拟地址 → 物理地址映射
  • 典型容量:64-512 项
  • 命中率通常>95%,极大提升性能

3. 反向页表(Inverted Page Table)#

用于物理页框到虚拟页的反向映射:

struct reverse_mapping {
struct process *owner; // 哪个进程拥有此页框
unsigned long vaddr; // 对应的虚拟地址
struct list_head list; // 多个进程可能共享(COW)
};

4. VMA(Virtual Memory Area)结构#

描述进程地址空间的连续区域:

struct vm_area_struct {
unsigned long vm_start; // 起始虚拟地址
unsigned long vm_end; // 结束虚拟地址
struct mm_struct *vm_mm; // 所属进程
pgprot_t vm_page_prot; // 访问权限
unsigned long vm_flags; // 标志(共享/私有/可执行等)
struct file *vm_file; // 映射的文件(内存映射文件)
};

四、体现的计算机思想#

1. 抽象(Abstraction)#

为程序提供简单、统一的内存视图,隐藏复杂的硬件细节。每个进程认为自己独占整个地址空间。

2. 虚拟化(Virtualization)#

将有限的物理资源虚拟成更大的逻辑资源,突破硬件限制。这是云计算、容器技术的思想源头。

3. 间接(Indirection)#

计算机科学名言:“任何问题都可以通过增加一层间接层解决”。虚拟内存就是在程序和物理内存间加了一层映射。

4. 时空权衡(Time-Space Tradeoff)#

  • 用磁盘空间(便宜但慢)扩展内存空间
  • 用 TLB 空间换取地址转换时间
  • 多级页表用查询时间换页表空间

5. 局部性原理(Locality)#

  • 时间局部性:最近访问的数据很可能再次访问
  • 空间局部性:相邻地址的数据很可能一起访问
  • 这使得分页机制高效可行(缺页率低)

6. 延迟加载(Lazy Evaluation)#

按需分页体现了”不到必要不执行”的思想,节省资源并加快启动速度。

7. 保护与隔离(Protection & Isolation)#

通过页表权限位实现进程间隔离,提供安全边界,这是现代操作系统安全的基础。

五、关键算法与实现技术#

1. 页面置换算法#

当物理内存满时,选择换出哪一页:

  • FIFO: 最简单,但可能换出常用页
  • LRU (Least Recently Used): 理论最优,但开销大
  • Clock 算法: LRU 的近似,用访问位实现
  • LFU (Least Frequently Used): 基于访问频率
  • 工作集算法: 基于局部性原理

Linux 使用改进的 Clock 算法(Two-handed clock)。

2. 写时复制(Copy-on-Write, COW)#

fork()创建子进程时:
父子进程共享相同物理页 → 页表项标记为只读
任一方尝试写入 → 触发异常 → 复制页面 → 各自修改副本

大幅提升 fork()效率,广泛用于数据库、容器等技术。

3. 内存映射文件(mmap)#

将文件直接映射到虚拟地址空间:

  • 读写内存即读写文件
  • 利用虚拟内存的页面管理
  • 进程间共享内存的标准方式

4. 大页(Huge Pages)#

使用更大的页(2MB 或 1GB 而非 4KB):

  • 减少页表层级和 TLB miss
  • 适合大内存数据库、虚拟机等场景

总结#

虚拟内存是计算机科学最优雅的设计之一。它通过抽象、虚拟化、间接等核心思想,用页表、TLB、多级映射等数据结构和算法,解决了内存管理的复杂问题,不仅使现代操作系统成为可能,还深刻影响了后续几乎所有的系统软件设计。

理解虚拟内存,就是理解计算机系统设计的精髓:如何用软件灵活性弥补硬件限制,如何在性能、安全、易用性间权衡,如何通过分层抽象管理复杂性。这些思想今天仍在指导云计算、容器、微服务等新技术的发展。

进程、线程和协程:从问题到应用的深度解析#

一、问题的起源与演进#

1. 进程的诞生 - 解决资源管理问题#

原始问题:早期计算机一次只能运行一个程序,CPU 利用率低下,用户需要等待程序执行完成。

解决思路

  • 引入分时系统多道程序设计
  • 需要一种机制来隔离不同程序的内存空间
  • 需要调度器来分配 CPU 时间

进程的出现:进程成为资源分配的基本单位,每个进程拥有独立的地址空间。

2. 线程的诞生 - 解决进程开销问题#

新问题

  • 进程创建和切换开销大(需要切换地址空间、页表等)
  • 进程间通信复杂(需要 IPC 机制)
  • 同一应用内的并发需求无法高效满足

解决思路:在进程内部引入更轻量的执行单元

线程的出现:线程成为 CPU 调度的基本单位,共享进程资源但拥有独立的执行栈。

3. 协程的诞生 - 解决线程切换开销问题#

新问题

  • 线程切换仍需内核态切换(系统调用)
  • C10K 问题:处理万级并发连接时,线程开销过大
  • 异步 IO 编程复杂(回调地狱)

解决思路:用户态调度,避免内核介入

协程的出现:在用户态实现轻量级调度,程序员可控的协作式多任务。


二、执行流程对比#

进程切换流程#

1. 保存当前进程上下文(寄存器、PC、页表等)
2. 切换到内核态
3. 调度器选择下一个进程
4. 切换页表和地址空间(TLB失效)
5. 恢复新进程上下文
6. 切换回用户态
时间开销:微秒级(1-10μs)

线程切换流程#

1. 保存当前线程上下文(寄存器、PC、栈指针)
2. 切换到内核态
3. 调度器选择下一个线程
4. 如果是同进程线程,无需切换页表
5. 恢复新线程上下文
6. 切换回用户态
时间开销:亚微秒级(0.1-1μs)

协程切换流程#

1. 保存当前协程上下文(用户态寄存器、栈指针)
2. 用户态调度器选择下一个协程
3. 恢复新协程上下文
4. 继续执行
时间开销:纳秒级(几十到几百ns)
无内核介入,无系统调用

三、核心原理#

1. 进程原理#

资源隔离

进程地址空间(Linux为例):
0xFFFFFFFF ┌─────────────┐
│ 内核空间 │ (1GB)
0xC0000000 ├─────────────┤
│ 栈 │ ↓ 向下增长
├─────────────┤
│ 堆 │ ↑ 向上增长
├─────────────┤
│ BSS段 │
├─────────────┤
│ 数据段 │
├─────────────┤
│ 代码段 │
0x00000000 └─────────────┘

关键数据结构(Linux):

struct task_struct {
pid_t pid; // 进程ID
struct mm_struct *mm; // 内存描述符
struct files_struct *files; // 打开文件表
struct signal_struct *signal; // 信号处理
// ... 数百个字段
};

2. 线程原理#

资源共享模型

同一进程的线程共享:
✓ 代码段、数据段
✓ 堆内存
✓ 文件描述符
✓ 信号处理器
✓ 进程ID
线程独享:
✓ 线程ID(TID)
✓ 栈空间
✓ 寄存器状态
✓ 错误码(errno)

实现方式

  • 用户级线程:N:1 模型,多个用户线程映射到一个内核线程
  • 内核级线程:1:1 模型,一个用户线程对应一个内核线程
  • 混合模型:M 模型,Go 语言的 goroutine 采用此方式

3. 协程原理#

核心机制

# 协程本质:可中断可恢复的函数
def coroutine_example():
print("开始执行")
yield # 中断点,保存上下文
print("恢复执行")

调度策略

  • 协作式调度:协程主动让出 CPU(yield)
  • 无抢占:不会被强制中断
  • 事件驱动:通常配合事件循环

实现技术

  1. 栈切换:setjmp/longjmp、ucontext
  2. 状态机:async/await 语法糖(Python、JavaScript)
  3. CPS 变换:延续传递风格

四、深层原理细节#

1. 上下文切换的硬件支持#

; x86-64 上下文切换简化版
context_switch:
; 保存旧上下文
push rbx
push rbp
push r12-r15
mov [old_rsp], rsp
; 加载新上下文
mov rsp, [new_rsp]
pop r15-r12
pop rbp
pop rbx
ret

2. 内存屏障与缓存一致性#

进程/线程切换时需要:

  • 刷新 CPU 缓存
  • 使 TLB(页表缓存)失效
  • 同步内存屏障

协程切换无需这些操作,这是性能差异的关键。

3. 调度算法#

进程/线程调度(Linux CFS 为例):

虚拟运行时间 = 实际运行时间 × (NICE_0_LOAD / 权重)
选择vruntime最小的任务运行

协程调度

  • 通常基于事件循环(epoll/kqueue)
  • 协程状态:运行、就绪、阻塞
  • 无优先级抢占

五、实际应用场景#

1. 进程适用场景#

# 场景:CPU密集型任务并行 + 需要强隔离
from multiprocessing import Process
def cpu_intensive_task(data):
# 复杂计算
result = heavy_computation(data)
return result
if __name__ == '__main__':
processes = []
for i in range(4): # 利用多核
p = Process(target=cpu_intensive_task, args=(data[i],))
processes.append(p)
p.start()

典型应用

  • Chrome 浏览器(每个 tab 一个进程)
  • Nginx worker 进程
  • 数据库系统(PostgreSQL 多进程架构)

2. 线程适用场景#

// 场景:需要共享内存的并发任务
public class WebServer {
private ExecutorService threadPool =
Executors.newFixedThreadPool(200);
public void handleRequest(Request req) {
threadPool.submit(() -> {
// 共享缓存、配置等
processRequest(req);
});
}
}

典型应用

  • Web 服务器(Tomcat、Apache)
  • GUI 应用(主线程+工作线程)
  • 数据库连接池

3. 协程适用场景#

# 场景:高并发IO密集型任务
import asyncio
async def handle_client(reader, writer):
data = await reader.read(1024) # 非阻塞
response = await process_data(data) # 异步处理
writer.write(response)
await writer.drain()
async def main():
server = await asyncio.start_server(
handle_client, '0.0.0.0', 8888)
async with server:
await server.serve_forever()
# 可以轻松支持10万+并发连接

典型应用

  • 高并发网络服务(Go 的 goroutine、Erlang 进程)
  • 爬虫系统
  • 实时消息推送

六、性能对比与选择#

资源开销对比#

维度进程线程协程
内存MB 级KB 级(~2MB 栈)KB 级(~4KB 栈)
创建时间毫秒级微秒级纳秒级
切换时间1-10μs0.1-1μs<100ns
并发数量百级千级百万级

选择决策树#

是否需要强隔离?
├─ 是 → 进程
└─ 否 → 是否需要利用多核?
├─ 是(CPU密集)→ 线程/进程池
└─ 否(IO密集)→ 协程

七、混合应用实战#

// Go语言的优秀实践:进程+协程
package main
import (
"runtime"
"sync"
)
func main() {
// 设置使用所有CPU核心
runtime.GOMAXPROCS(runtime.NumCPU())
var wg sync.WaitGroup
// 启动百万协程处理任务
for i := 0; i < 1000000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// IO密集任务
handleConnection(id)
}(i)
}
wg.Wait()
}

总结#

这三种机制是计算机系统演进的必然结果,每一层都在解决上一层的痛点:

  1. 进程解决了资源隔离和并发执行问题
  2. 线程解决了进程开销和资源共享问题
  3. 协程解决了线程切换和高并发问题

好的!我们来由浅入深地分析 TCP 连接中频繁出现 TIME_WAIT 状态的问题,包括其产生原因、影响、解决方案及其优劣对比


频繁出现 TIME_WAIT 状态的问题#

一、什么是 TIME_WAIT?#

在 TCP 协议中,当一方(通常是主动关闭连接的一方)发送了 FIN 报文并收到对方的 ACKFIN 后,再回复 ACK,此时该连接进入 TIME_WAIT 状态。

  • 持续时间:默认为 2 × MSL(Maximum Segment Lifetime,最大报文段生存时间)。在 Linux 中,MSL 通常为 30 秒,所以 TIME_WAIT 默认持续 60 秒
  • 目的
    1. 确保最后一个 ACK 能被对方收到。如果 ACK 丢失,对方会重传 FIN,TIME_WAIT 状态可以再次响应。
    2. 防止旧连接的延迟报文干扰新连接。避免“迟到”的数据包被新连接误认为是自己的数据。

二、为什么会出现“频繁出现 TIME_WAIT”?#

常见场景:#

  • 短连接高频请求:如 Web 服务器、API 网关、爬虫、微服务调用等,客户端频繁建立/关闭连接。
  • 客户端主动关闭连接:谁先调用 close(),谁就进入 TIME_WAIT。如果服务端设计为由客户端关闭(常见于 HTTP/1.0 或无 Keep-Alive 的 HTTP/1.1),则客户端会堆积大量 TIME_WAIT。
  • 高并发短连接服务:比如 Nginx 作为反向代理频繁与后端建立短连接。

问题表现:#

  • netstat -n | grep TIME_WAIT | wc -l 显示大量连接处于 TIME_WAIT。
  • 端口耗尽(尤其客户端端口范围有限,如 32768~65535)。
  • 内存占用增加(每个 TIME_WAIT 连接仍占用内核资源)。
  • 新连接无法建立(“Cannot assign requested address” 错误)。

三、解决方案及优劣分析#

方案 1:让服务端主动关闭连接(角色反转)#

原理:#

让服务端(而非客户端)主动调用 close(),这样 TIME_WAIT 出现在服务端。但服务端通常连接数少、资源更充足,且可复用端口。

实际上,HTTP/1.1 默认 Keep-Alive 就是为了避免频繁建连/断连。若必须短连接,可通过 HTTP 头 Connection: close 由服务端发起关闭。

优点:#

  • 客户端无 TIME_WAIT,避免端口耗尽。
  • 服务端更容易集中管理连接状态。

缺点:#

  • 需要修改应用逻辑或协议行为。
  • 若服务端本身也是高并发短连接(如反向代理),问题只是转移,并未根治。

方案 2:启用 SO_REUSEADDRnet.ipv4.tcp_tw_reuse#

原理:#

  • SO_REUSEADDR:允许绑定处于 TIME_WAIT 的本地地址(端口),避免“Address already in use”。
  • tcp_tw_reuse(Linux 特有):允许将处于 TIME_WAIT 的 socket 用于新的 OUTGOING 连接(仅适用于客户端角色)。
Terminal window
# 查看
cat /proc/sys/net/ipv4/tcp_tw_reuse # 0 或 1
# 启用(临时)
echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse
# 永久:写入 /etc/sysctl.conf
net.ipv4.tcp_tw_reuse = 1

优点:#

  • 安全地复用 TIME_WAIT 连接,缓解端口耗尽。
  • 不破坏 TCP 协议语义(只用于新出站连接,且依赖时间戳判断报文新旧)。

缺点:#

  • 仅适用于客户端(出站连接),对服务端监听端口无效。
  • 依赖 TCP 时间戳(tcp_timestamps)开启(默认开启)。
  • 在 NAT 环境下可能有风险(时间戳不一致),但现代系统通常安全。

⚠️ 注意:不要启用 tcp_tw_recycle(Linux 4.12+ 已移除),它在 NAT 环境下会导致连接失败。


方案 3:缩短 TIME_WAIT 超时时间#

原理:#

修改 net.ipv4.tcp_fin_timeout(注意:这个参数其实不影响 TIME_WAIT!

❗ 常见误区:tcp_fin_timeout 控制的是 FIN_WAIT_2 状态的超时,不是 TIME_WAIT

真正控制 TIME_WAIT 的是 MSL,而 Linux 中 MSL 是硬编码为 30 秒的,无法直接修改

但可通过以下方式间接“缩短”:

  • 不推荐:重新编译内核修改 MSL(维护成本高,不现实)。
  • 替代方案:配合 tcp_tw_reuse + 增大端口范围,比缩短时间更安全。

结论:不建议强行缩短 TIME_WAIT 时间,可能破坏 TCP 可靠性。#


方案 4:扩大客户端可用端口范围#

原理:#

默认端口范围(如 32768–65535)只有约 28000 个端口。高并发下很快耗尽。

Terminal window
# 查看
cat /proc/sys/net/ipv4/ip_local_port_range
# 扩大(例如 1024–65535)
echo "1024 65535" > /proc/sys/net/ipv4/ip_local_port_range

优点:#

  • 简单有效,直接增加可用端口数。
  • tcp_tw_reuse 配合效果更好。

缺点:#

  • 不能减少 TIME_WAIT 数量,只是延缓端口耗尽。
  • 使用低端口需注意权限(<1024 需 root)。

方案 5:使用长连接(Keep-Alive)或连接池#

原理:#

避免频繁创建/关闭连接。HTTP/1.1 默认 Keep-Alive,HTTP/2 多路复用,gRPC 基于长连接。

  • 客户端使用连接池(如 HttpClient、Redis 连接池)。
  • 服务端配置 Keep-Alive 超时(如 Nginx keepalive_timeout)。

优点:#

  • 从根本上减少 TIME_WAIT 产生
  • 性能更好(省去 TCP 握手开销)。

缺点:#

  • 需要应用层支持。
  • 服务端需管理更多长连接(内存、文件描述符)。

方案 6:使用负载均衡或代理层集中管理连接#

例如:

  • 客户端 → LVS/Nginx/Envoy → 后端服务
  • 由代理层与后端保持长连接,客户端与代理可短连接(TIME_WAIT 在代理上,但代理资源可控)

优点:#

  • 隔离问题,便于运维调优。
  • 代理可优化连接复用。

缺点:#

  • 架构复杂度增加。

四、总结:推荐策略#

场景推荐方案
客户端高频短连接启用 tcp_tw_reuse + 扩大端口范围 + 使用连接池
服务端被大量短连接冲击改为长连接(Keep-Alive) + 服务端主动关闭(如需)
代理/网关类服务使用连接池 + 合理配置 TIME_WAIT 参数
极端高并发架构上引入连接复用中间层(如 Sidecar、API Gateway)

五、关键命令速查#

Terminal window
# 查看 TIME_WAIT 数量
ss -tan state time-wait | wc -l
# 或
netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
# 启用 reuse(安全)
echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse
# 扩大端口范围
echo "1024 65535" > /proc/sys/net/ipv4/ip_local_port_range
# 永久生效:写入 /etc/sysctl.conf 并执行 sysctl -p

现代系统设计往往混合使用:进程保证隔离性,线程利用多核,协程处理高并发 IO。

Java 双亲委派机制详解以及 findClassloadClass 的区别#

一、什么是双亲委派机制#

双亲委派机制(Parent Delegation Model)是 Java 类加载器的核心机制。其工作流程是:

当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。每一个层次的类加载器都是如此,因此所有的加载请求最终都会传送到顶层的启动类加载器(Bootstrap ClassLoader)中。只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。

类加载器层次结构#

Bootstrap ClassLoader (启动类加载器)
Extension ClassLoader (扩展类加载器)
Application ClassLoader (应用类加载器)
Custom ClassLoader (自定义类加载器)

双亲委派的优点#

  1. 避免类的重复加载:父加载器已加载过的类,子加载器不会再加载
  2. 保护核心 API:防止核心 Java 类被篡改(例如自定义 java.lang.String 会被拒绝)

二、如何破坏双亲委派机制#

方法 1:重写 loadClass 方法#

双亲委派逻辑在loadClass()方法中实现,重写它可以改变委派行为:

public class CustomClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 不委派给父加载器,直接自己加载
if (name.startsWith("com.myapp")) {
return findClass(name);
}
// 其他类仍然使用双亲委派
return super.loadClass(name);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 从自定义路径加载类的字节码
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
return defineClass(name, classData, 0, classData.length);
}
private byte[] loadClassData(String className) {
// 实现从文件系统或网络加载字节码
// ...
return null;
}
}

方法 2:线程上下文类加载器(Thread Context ClassLoader)#

这是 JDK 本身破坏双亲委派的方式,用于解决 SPI(Service Provider Interface)问题:

// JDBC驱动加载示例
Thread.currentThread().setContextClassLoader(customClassLoader);
// DriverManager会使用线程上下文类加载器加载驱动
Class.forName("com.mysql.jdbc.Driver");

方法 3:OSGi 的模块化类加载#

OSGi 实现了网状的类加载器结构,不再是严格的树形层次。

三、loadClass vs findClass#

loadClass 方法#

  • 作用:实现双亲委派逻辑的入口方法
  • 标准流程
    1. 检查类是否已加载(findLoadedClass
    2. 委派给父加载器(parent.loadClass
    3. 父加载器加载失败后,调用findClass自己加载
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查类是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 2. 委派给父加载器
c = parent.loadClass(name, false);
} else {
// 父加载器为null,使用启动类加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器无法加载
}
if (c == null) {
// 3. 父加载器无法加载,自己加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

findClass 方法#

  • 作用:由子类实现的实际类查找和加载逻辑
  • 特点:不包含委派逻辑,只负责查找和定义类
  • 典型实现
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. 根据类名获取字节码
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException(name);
}
// 2. 将字节码转换为Class对象
return defineClass(name, classData, 0, classData.length);
}

核心区别总结#

特性loadClassfindClass
职责实现类加载的完整流程(含委派)只负责查找和定义类
是否包含双亲委派
何时重写想破坏双亲委派时自定义类加载逻辑但保持双亲委派时
推荐做法不推荐重写推荐重写

最佳实践:自定义类加载器时,应该重写findClass而不是loadClass,这样可以保持双亲委派机制,同时实现自定义的类加载逻辑。

Java 并发锁#

一、悲观锁与乐观锁#

悲观锁 (Pessimistic Lock)#

核心思想: 假设会发生并发冲突,每次访问数据时都会加锁

特点:

  • 读写都加锁,确保独占访问
  • 适合写操作频繁的场景
  • 实现简单,但性能开销大

典型实现: synchronized、ReentrantLock

// 悲观锁示例
synchronized(obj) {
// 独占访问
data++;
}

乐观锁 (Optimistic Lock)#

核心思想: 假设不会发生冲突,只在更新时检查是否被修改过

特点:

  • 不加锁,通过版本号或 CAS 实现
  • 适合读多写少的场景
  • 并发性能好,但可能重试

典型实现: CAS (Compare-And-Swap)、版本号机制

// CAS乐观锁示例
AtomicInteger count = new AtomicInteger(0);
count.compareAndSet(0, 1); // 期望值为0时更新为1

二、Java 并发工具详解#

1. synchronized#

用途: Java 内置的互斥锁,保证同一时刻只有一个线程执行代码块

使用场景:

// 修饰方法
public synchronized void method() {}
// 修饰代码块
synchronized(this) {
// 临界区代码
}

底层实现:

  • JVM 层面: 基于 Monitor 对象实现
  • 字节码: monitorenter 和 monitorexit 指令
  • 对象头: Mark Word 存储锁状态
  • 锁升级: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁
    • 偏向锁: 只有一个线程访问时,记录线程 ID
    • 轻量级锁: 多线程交替访问,CAS 自旋
    • 重量级锁: 竞争激烈时,操作系统互斥量实现

2. ReentrantLock (可重入锁)#

用途: 显式锁,功能比 synchronized 更强大

特点:

  • 可重入(同一线程可多次获取)
  • 可中断(lockInterruptibly)
  • 可设置超时(tryLock)
  • 支持公平/非公平锁
  • 支持多个条件变量(Condition)
ReentrantLock lock = new ReentrantLock(true); // 公平锁
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}

底层实现:

  • 基于AQS (AbstractQueuedSynchronizer) 框架
  • state 变量: 表示锁状态(0=未锁定)
  • CLH 队列: 等待线程排队的双向链表
  • CAS: 修改 state 实现加锁
  • LockSupport: park/unpark 实现线程阻塞/唤醒

3. Semaphore (信号量)#

用途: 控制同时访问资源的线程数量

使用场景: 限流、资源池管理

Semaphore semaphore = new Semaphore(3); // 允许3个线程同时访问
semaphore.acquire(); // 获取许可
try {
// 访问资源
} finally {
semaphore.release(); // 释放许可
}

底层实现:

  • 基于AQS的共享模式
  • state: 表示可用许可数
  • tryAcquireShared: CAS 减少许可
  • tryReleaseShared: CAS 增加许可
  • 支持公平/非公平模式

4. CountDownLatch (倒计数门栓)#

用途: 让一个或多个线程等待其他线程完成操作

使用场景: 主线程等待所有子线程完成

CountDownLatch latch = new CountDownLatch(3); // 计数器=3
// 工作线程
new Thread(() -> {
// 执行任务
latch.countDown(); // 计数-1
}).start();
// 主线程等待
latch.await(); // 阻塞直到计数=0

底层实现:

  • 基于AQS的共享模式
  • state: 初始化为 count 值
  • countDown(): CAS 使 state-1
  • await(): state!=0 时阻塞,=0 时所有线程唤醒
  • 一次性: 计数到 0 后无法重置

5. CyclicBarrier (循环栅栏)#

用途: 让一组线程互相等待,都到达屏障点后再一起执行

使用场景: 多线程计算后合并结果

CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("所有线程已到达");
});
// 每个线程
barrier.await(); // 等待其他线程

底层实现:

  • 基于ReentrantLock + Condition
  • count: 当前等待的线程数
  • parties: 需要等待的总线程数
  • Generation: 标识每轮等待
  • 可重用: 当 count=parties 时重置,开启下一代

与 CountDownLatch 区别:

  • CyclicBarrier 可重用,CountDownLatch 一次性
  • CyclicBarrier 线程相互等待,CountDownLatch 是一个或多个线程等待其他线程

6. Phaser (分阶段栅栏)#

用途: 多阶段同步,动态注册/注销参与者

使用场景: 复杂的多阶段任务协调

Phaser phaser = new Phaser(3); // 3个参与者
// 线程执行
phaser.arriveAndAwaitAdvance(); // 到达第一阶段并等待
// 继续执行
phaser.arriveAndAwaitAdvance(); // 到达第二阶段并等待
phaser.arriveAndDeregister(); // 完成并注销

底层实现:

  • 基于state 变量的位运算
  • state 的 64 位分为:
    • unarrived parties (未到达数)
    • parties (参与者数)
    • phase (当前阶段)
  • CAS: 原子更新 state
  • Treiber Stack: 等待队列
  • 动态性: 支持运行时增减参与者

与 CyclicBarrier 区别:

  • Phaser 支持多阶段,CyclicBarrier 只有一个屏障点
  • Phaser 可动态改变参与者数量
  • Phaser 更灵活但更复杂

三、核心对比总结#

工具用途可重用底层
synchronized互斥锁可重入Monitor
ReentrantLock显式锁可重入AQS 独占模式
Semaphore限流可重用AQS 共享模式
CountDownLatch等待完成一次性AQS 共享模式
CyclicBarrier相互等待可重用Lock+Condition
Phaser多阶段同步可重用State 位运算

AQS (AbstractQueuedSynchronizer) 是 Doug Lea 设计的同步框架,是 ReentrantLock、Semaphore、CountDownLatch 等的基础,通过 state 状态和 CLH 队列实现锁的获取与释放。

Redis ZSet 底层数据结构详解#

一、ZSet 的两种底层实现#

Redis 的有序集合(ZSet)根据数据规模采用不同的底层结构:

1. 小数据量:ziplist(压缩列表)#

满足以下两个条件时使用:

  • 元素数量 < 128 个(zset-max-ziplist-entries
  • 所有元素的值 < 64 字节(zset-max-ziplist-value

ziplist 特点:

  • 紧凑连续的内存布局
  • 节省内存空间
  • 按 score 排序存储
  • 格式:[member1][score1][member2][score2]...

2. 大数据量:skiplist(跳表)+ dict(哈希表)#

超过上述阈值后,使用组合结构:

  • 跳表:维护有序性,支持范围查询
  • 哈希表:O(1) 查找成员是否存在及其 score

二、跳表的实现原理#

核心思想#

跳表是一种多层索引的有序链表,通过空间换时间实现快速查找。

数据结构定义#

// 跳表节点
typedef struct zskiplistNode {
sds ele; // 成员对象
double score; // 分值
struct zskiplistNode *backward; // 后退指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 前进指针
unsigned long span; // 跨度(到下个节点的距离)
} level[]; // 层级数组
} zskiplistNode;
// 跳表结构
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 头尾指针
unsigned long length; // 节点数量
int level; // 最大层数
} zskiplist;

层级结构示例#

Level 3: head ---------------------------------> node4 ----> NULL
Level 2: head ----------> node2 -------------> node4 ----> NULL
Level 1: head --> node1 -> node2 -> node3 --> node4 ----> NULL
Level 0: head -> node1 -> node2 -> node3 -> node4 -> tail
(score: 1) (score: 5) (score: 10) (score: 15)

三、跳表的插入与删除#

在跳表(Skip List)中,插入和删除元素时对高层指针的维护是核心操作,其核心思想是通过“随机层数”决定节点的高度,并动态调整各层指针的指向,以保证跳表的有序性和查询效率。以下是具体流程:

一、插入元素时高层指针的维护流程#

插入操作需要先确定新节点的“随机层数”,再找到各层中需要调整指针的前驱节点,最后更新指针指向。

步骤 1:生成新节点的随机层数#

  • 跳表中每个节点的层数(高度)是随机决定的,通常遵循“几何分布”(例如 50%概率层数为 1,25%为 2,12.5%为 3,以此类推,直到最大层数)。
  • 新节点的层数记为new_level,若new_level超过跳表当前的最大层数max_level,则需要扩展跳表的最大层数(后续所有操作需考虑新增的层)。

步骤 2:寻找各层的前驱节点#

  • 从跳表的最高层(max_level)开始,沿着指针向前遍历:
    • 若当前节点的下一个节点(同层)的值小于插入值,则继续前进;
    • 若下一个节点的值大于插入值,或已到达链表尾部,则当前节点就是该层的“前驱节点”,记录该前驱节点,然后下降到下一层继续寻找。
  • 重复上述过程,直到遍历到第 1 层(最底层),最终得到一个“前驱节点数组”update[],其中update[i]表示第i层中,新节点的直接前驱。

步骤 3:更新各层指针#

  • 对于新节点的每一层(从 1 到new_level):
    • 将新节点的第i层指针指向其前驱节点update[i]在第i层的下一个节点(即new_node.forward[i] = update[i].forward[i]);
    • 将前驱节点update[i]的第i层指针指向新节点(即update[i].forward[i] = new_node)。
  • 若新节点的层数new_level大于原跳表的max_level,则更新跳表的max_levelnew_level

二、删除元素时高层指针的维护流程#

删除操作需要先找到待删除节点及其各层的前驱节点,再调整前驱节点的指针以跳过待删除节点。

步骤 1:寻找待删除节点及各层前驱节点#

  • 类似插入的步骤 2,从跳表最高层开始遍历:
    • 若当前节点的下一个节点(同层)的值小于待删除值,则继续前进;
    • 若下一个节点的值等于待删除值,则当前节点是该层的前驱节点,记录到update[]数组,然后下降到下一层;
    • 若下一个节点的值大于待删除值,或已到尾部,则下降到下一层继续寻找。
  • 遍历到第 1 层后,检查最底层的下一个节点是否为待删除节点(值相等)。若不存在,则删除失败;若存在,记待删除节点为del_node,其层数为del_level

步骤 2:更新各层指针(跳过待删除节点)#

  • 对于待删除节点的每一层(从 1 到del_level):
    • 将前驱节点update[i]的第i层指针指向del_node在第i层的下一个节点(即update[i].forward[i] = del_node.forward[i])。

步骤 3:调整跳表的最大层数(可选)#

  • 若删除的是跳表中最高层的唯一节点,需要检查并降低max_level:从当前max_level开始,若该层的头节点指针指向null,则max_level减 1,重复直到找到有有效节点的层。

关键逻辑总结#

  • 插入核心:通过随机层数决定节点高度,利用前驱数组记录各层插入位置,再“前后关联”新节点与前驱、后继节点。
  • 删除核心:先定位待删除节点及其各层前驱,再通过前驱节点“跳过”待删除节点,最后可能收缩跳表的最大层数。
  • 高层指针的维护本质是动态调整链表的“索引”关系,确保各层链表始终有序,从而维持跳表的查询效率(平均 O(log n))。

插入操作#

def insert(skiplist, score, member):
# 1. 随机生成层数(1-32层)
level = random_level() # 概率 P=0.25
# 2. 从最高层开始查找插入位置
update = [None] * MAX_LEVEL # 记录每层的前驱节点
current = skiplist.header
for i in range(skiplist.level - 1, -1, -1):
while current.level[i].forward and \
current.level[i].forward.score < score:
current = current.level[i].forward
update[i] = current # 记录该层的插入位置
# 3. 创建新节点
new_node = create_node(level, score, member)
# 4. 更新各层指针
for i in range(level):
new_node.level[i].forward = update[i].level[i].forward
update[i].level[i].forward = new_node
# 更新跨度
new_node.level[i].span = update[i].level[i].span - rank[0] + rank[i]
update[i].level[i].span = rank[0] - rank[i] + 1

时间复杂度: O(log N)

删除操作#

def delete(skiplist, score, member):
# 1. 查找目标节点
update = [None] * MAX_LEVEL
current = skiplist.header
for i in range(skiplist.level - 1, -1, -1):
while current.level[i].forward and \
(current.level[i].forward.score < score or \
(current.level[i].forward.score == score and \
current.level[i].forward.member < member)):
current = current.level[i].forward
update[i] = current
# 2. 删除节点(更新各层指针)
target = current.level[0].forward
if target and target.score == score and target.member == member:
for i in range(skiplist.level):
if update[i].level[i].forward == target:
update[i].level[i].forward = target.level[i].forward
update[i].level[i].span += target.level[i].span - 1
else:
update[i].level[i].span -= 1
free(target)

时间复杂度: O(log N)


四、为什么使用跳表而不是红黑树?#

1. 实现复杂度#

对比项跳表红黑树
代码量~200 行~500+ 行
实现难度简单直观复杂(旋转、变色)
调试难度容易困难

2. 范围查询性能#

Terminal window
# ZSet 常见操作
ZRANGE key 0 100 # 范围查询
ZRANGEBYSCORE key 10 20 # 按分数范围查询
  • 跳表:找到起点后顺序遍历即可,缓存友好
  • 红黑树:需要中序遍历,涉及大量指针跳转

3. 内存局部性#

  • 跳表:底层是链表,范围扫描时连续访问内存
  • 红黑树:树形结构分散在内存各处,缓存命中率低

4. 并发友好#

  • 跳表:插入/删除只影响局部节点,易于加锁粒度控制
  • 红黑树:旋转操作可能影响大范围节点

5. Redis 作者的说法#

“There are a few reasons:

  1. They are not very memory intensive (相比红黑树节省指针)
  2. A sorted set is often target of many ZRANGE operations (范围查询优势)
  3. They are simpler to implement (实现简单)” — Salvatore Sanfilippo (antirez)

性能对比#

操作跳表红黑树
查找O(log N)O(log N)
插入O(log N)O(log N)
删除O(log N)O(log N)
范围查询O(log N + M)O(log N + M)
内存占用中等较高
实现复杂度

五、总结#

Redis ZSet 选择跳表的核心原因:

  1. 简单高效:代码量少,易维护
  2. 范围查询优化:符合 Redis 使用场景
  3. 内存友好:相比红黑树节省空间
  4. 性能稳定:平均 O(log N),无最坏情况退化
  5. 并发友好:局部修改,易于实现无锁操作

这是一个工程实践与理论结合的经典案例,体现了 Redis “简单即美” 的设计哲学!

我来帮你复原这些 Redis 面试问题并给出完整的答案。

Redis 集群#

1. 对 Redis 集群的了解#

问题:请介绍一下 Redis Cluster 的基本概念和特点

完美答案:

Redis Cluster 是 Redis 的分布式解决方案,主要特点包括:

核心特性:

  • 自动分片:数据自动分散到多个节点,无需客户端手动分片
  • 高可用性:支持主从复制,主节点故障时自动 failover
  • 无中心架构:节点间通过 Gossip 协议通信,没有中心节点
  • 横向扩展:可动态添加/删除节点

架构特点:

  • 最少需要 3 个主节点(保证多数投票机制)
  • 建议每个主节点配置至少 1 个从节点
  • 节点间通过 cluster bus 通信(端口=客户端端口+10000)

2. Cluster 的 16384 和 CRC 取模#

问题:为什么 Redis Cluster 使用 16384 个槽位?数据如何分配到槽位?

完美答案:

槽位设计: Redis Cluster 将整个数据空间划分为16384 个槽位(0-16383)

为什么是 16384?

  1. 心跳包大小考虑

    • 节点间通过 Gossip 协议交换槽位信息
    • 16384 个槽位用 bitmap 表示只需 2KB(16384÷8=2048 字节)
    • 如果是 65536 个槽,需要 8KB,心跳包过大影响性能
  2. 集群规模考虑

    • Redis 官方推荐集群节点数不超过 1000 个
    • 实际生产环境通常几十个节点
    • 16384 个槽位完全够用
  3. CRC16 的结果范围

    • CRC16 算法结果是 16 位,范围 0-65535
    • 对 16384 取模正好是 2 的 14 次方,位运算高效

数据分配算法:

HASH_SLOT = CRC16(key) & 16383 // 等价于 CRC16(key) % 16384

流程:

  1. 对 key 计算 CRC16 校验和
  2. 对 16384 取模得到槽位号
  3. 根据槽位找到对应的节点
  4. 将请求路由到该节点

Hash Tag 支持:

  • 如果 key 包含{},只对{}内的内容计算 hash
  • 例如:{user:1000}:name{user:1000}:age会分配到同一槽位
  • 用于多 key 操作(MGET、事务等)

3. 主从同步(全量和增量同步)#

问题:Redis 的主从复制是如何实现的?全量同步和增量同步的区别?

完美答案:

全量同步(Full Resynchronization)#

触发场景:

  • 第一次建立主从关系
  • 从节点重启后 runid 变化
  • 复制积压缓冲区数据丢失

同步流程:

1. 从节点发送PSYNC命令(携带runid和offset)
2. 主节点判断需要全量同步,返回+FULLRESYNC
3. 主节点执行BGSAVE生成RDB快照
4. 主节点将RDB文件发送给从节点
5. 从节点清空旧数据,加载RDB文件
6. 主节点将BGSAVE期间的写命令缓存到复制缓冲区
7. RDB传输完成后,发送缓冲区内的增量命令

优化点:

  • Redis 2.8.18 后支持无盘复制(repl-diskless-sync yes)
  • 直接通过 socket 发送 RDB,不写磁盘

增量同步(Partial Resynchronization)#

触发场景:

  • 从节点短暂断线重连
  • 复制积压缓冲区还保留着断线期间的数据

核心机制:

  1. 复制偏移量(replication offset)

    • 主从都维护一个偏移量
    • 每传输 N 字节数据,偏移量+N
  2. 复制积压缓冲区(replication backlog)

    • 主节点维护的 FIFO 队列,默认 1MB
    • 保存最近的写命令
    • 配置:repl-backlog-size 1mb
  3. 服务器运行 ID(runid)

    • 每个 Redis 实例的唯一标识
    • 重启后 runid 会改变

同步流程:

1. 从节点发送PSYNC <runid> <offset>
2. 主节点检查:
- runid是否匹配
- offset是否还在复制积压缓冲区内
3. 如果满足条件,返回+CONTINUE
4. 主节点发送从offset之后的增量数据
5. 从节点执行这些命令完成同步

对比总结:

特性全量同步增量同步
数据量传输全部数据只传输差异数据
性能开销大(CPU、IO、网络)
适用场景首次同步、长时间断线短暂断线
Redis 版本所有版本2.8+

4. RESP 协议#

问题:Redis 的传输协议 RESP 是什么?如何实现的?

完美答案:

RESP(Redis Serialization Protocol 是 Redis 客户端和服务器通信的协议。

协议特点:

  • 简单易实现
  • 快速解析
  • 人类可读

数据类型(RESP2):

  1. 简单字符串(Simple Strings)+ 开头

    +OK\r\n
  2. 错误(Errors)- 开头

    -ERR unknown command\r\n
  3. 整数(Integers): 开头

    :1000\r\n
  4. 批量字符串(Bulk Strings)$ 开头

    $6\r\nfoobar\r\n // 6是长度
    $-1\r\n // NULL
  5. 数组(Arrays)* 开头

    *3\r\n // 3个元素
    $3\r\nSET\r\n
    $3\r\nkey\r\n
    $5\r\nvalue\r\n

实际例子:

客户端发送 SET name redis

*3\r\n
$3\r\nSET\r\n
$4\r\nname\r\n
$5\r\nredis\r\n

服务器响应:

+OK\r\n

客户端发送 GET name

*2\r\n
$3\r\nGET\r\n
$4\r\nname\r\n

服务器响应:

$5\r\nredis\r\n

RESP3(Redis 6.0+)

  • 新增更多数据类型(布尔、Double、Map、Set 等)
  • 更好的类型系统
  • 支持流式传输

5. Gossip 协议#

问题:Redis Cluster 的 Gossip 协议是什么?如何实现的?

完美答案:

Gossip 协议是 Redis Cluster 节点间交换信息的通信协议,实现最终一致性。

核心机制#

1. 消息类型:

  • MEET:请求加入集群
  • PING:心跳检测,携带自己的状态和部分其他节点的状态
  • PONG:PING/MEET 的响应
  • FAIL:宣告某节点下线
  • PUBLISH:发布订阅消息

2. 通信端口:

  • 客户端端口:6379
  • 集群总线端口:16379(客户端端口+10000)

3. Gossip 工作流程:

每秒执行:
1. 随机选择5个节点
2. 选出其中最久没通信的节点
3. 发送PING消息
PING消息内容:
- 发送者的状态信息
- 发送者已知的2-3个随机节点的状态
- 槽位分配信息(压缩bitmap)
收到PING后:
- 更新发送者的状态
- 更新消息中其他节点的状态
- 回复PONG消息

故障检测流程#

1. 主观下线(PFAIL - Probably Fail):

- 节点A超过cluster-node-timeout未响应PING
- 当前节点标记A为PFAIL
- 这只是本节点的判断

2. 客观下线(FAIL):

- 收集其他节点对A的判断
- 如果超过半数的主节点认为A是PFAIL
- 将A标记为FAIL
- 广播FAIL消息给所有节点

3. 故障转移:

1. 从节点发现主节点FAIL
2. 从节点之间选举(Raft算法)
3. 获得多数投票的从节点晋升为主节点
4. 新主节点广播PONG消息,宣布接管槽位
5. 其他节点更新槽位映射

Gossip 协议优势#

去中心化:无单点故障 ✅ 可扩展性:新节点容易加入 ✅ 容错性:部分节点故障不影响整体

Gossip 协议缺陷#

网络开销:节点越多,消息越多(O(N²)) ❌ 收敛时间:信息传播需要时间,最终一致性 ❌ 脑裂风险:网络分区可能导致数据不一致

优化配置#

Terminal window
# 节点超时时间(毫秒)
cluster-node-timeout 15000
# Gossip消息中包含的节点数
cluster-gossip-nodes 2
# 从节点迁移的槽位数阈值
cluster-migration-barrier 1

总结#

这些问题考察了 Redis 集群的核心知识:

  1. 架构设计:16384 槽位的设计权衡
  2. 数据同步:全量/增量复制保证数据一致性
  3. 通信协议:RESP 保证高效序列化
  4. 分布式协调:Gossip 实现去中心化集群管理

我来帮你复原这两个面试问题并详细讲解答案。

问题 20:500 万条数据,查询学生成绩进行排序,找出前 100 名你怎么做?#

问题分析#

这是一个典型的Top-K 问题,考察候选人对大数据量排序的优化思路。

解决方案#

方案 1:数据库层面优化(推荐)#

-- 使用LIMIT直接获取前100名
SELECT student_id, student_name, score
FROM student_scores
ORDER BY score DESC
LIMIT 100;

优化要点:

  • 建立索引:在 score 字段上建立降序索引
    CREATE INDEX idx_score_desc ON student_scores(score DESC);
  • 索引优势:MySQL 会使用索引避免全表排序,直接返回前 100 条
  • 时间复杂度:O(100) 而非 O(n log n)

方案 2:分页优化#

-- 如果需要分页展示
SELECT student_id, student_name, score
FROM student_scores
ORDER BY score DESC
LIMIT 100 OFFSET 0;

方案 3:应用层算法(堆排序)#

如果数据需要在应用层处理:

public List<Student> getTop100(List<Student> students) {
// 使用小顶堆,保持堆大小为100
PriorityQueue<Student> minHeap = new PriorityQueue<>(
100,
Comparator.comparingInt(Student::getScore)
);
for (Student student : students) {
if (minHeap.size() < 100) {
minHeap.offer(student);
} else if (student.getScore() > minHeap.peek().getScore()) {
minHeap.poll();
minHeap.offer(student);
}
}
// 转换为降序列表
List<Student> result = new ArrayList<>(minHeap);
result.sort(Comparator.comparingInt(Student::getScore).reversed());
return result;
}

优势:

  • 空间复杂度:O(100)
  • 时间复杂度:O(n log 100)
  • 只需遍历一次数据

方案 4:分布式场景#

如果数据分布在多个节点:

  1. Map 阶段:每个节点查询本地 Top 100
  2. Reduce 阶段:汇总所有结果,再取 Top 100
-- 各节点执行
SELECT * FROM student_scores ORDER BY score DESC LIMIT 100;
-- 汇总后再排序
SELECT * FROM (
SELECT * FROM node1_top100
UNION ALL
SELECT * FROM node2_top100
UNION ALL
SELECT * FROM node3_top100
) AS combined
ORDER BY score DESC
LIMIT 100;

问题 21:一条 SQL 的执行流程#

完整执行流程(以 MySQL 为例)#

客户端 → 连接器 → 查询缓存 → 解析器 → 优化器 → 执行器 → 存储引擎

详细步骤讲解#

1. 连接器(Connector)#

mysql -h host -P port -u user -p password
  • 功能:建立连接,验证用户身份和权限
  • 连接管理:维护连接状态(长连接/短连接)
  • 权限获取:获取该用户的权限信息

2. 查询缓存(Query Cache)⚠️ MySQL 8.0 已移除#

SELECT * FROM users WHERE id = 1;
  • 命中缓存:直接返回结果
  • 未命中:继续执行后续流程
  • 失效机制:表有更新时,该表所有缓存失效

3. 解析器(Parser)#

词法分析:

SELECT student_name FROM students WHERE score > 90;

识别关键字:SELECT, FROM, WHERE 识别表名:students 识别字段:student_name, score

语法分析:

  • 检查 SQL 语法是否正确
  • 构建语法树(AST)
  • 错误示例:SELEC → 语法错误

4. 预处理器(Preprocessor)#

  • 验证表和字段:检查表、字段是否存在
  • 权限验证:检查是否有该表的查询权限
  • 语义检查:检查 SQL 语义是否合理

5. 优化器(Optimizer)#

这是最复杂的阶段,决定 SQL 如何执行:

SELECT * FROM t1
JOIN t2 ON t1.id = t2.t1_id
WHERE t1.status = 1 AND t2.type = 'A';

优化决策:

  • 选择索引:决定使用哪个索引(如果有多个)
  • 表连接顺序:决定先查 t1 还是 t2
  • JOIN 方式:选择 Nested Loop、Hash Join 还是 Merge Join
  • 成本计算:评估不同执行计划的代价

示例:

方案1: 全表扫描 t1 → 成本: 10000
方案2: 使用 idx_status → 成本: 500 ✓ 选择此方案

6. 执行器(Executor)#

执行前检查:

-- 再次验证权限
SELECT privilege FROM mysql.user WHERE user = 'current_user';

执行过程:

1. 调用存储引擎接口打开表
2. 根据索引取第一行,判断条件
3. 满足条件 → 加入结果集
4. 取下一行,重复步骤2-3
5. 返回结果集给客户端

存储引擎 API 调用:

InnoDB handler APIs:
- ha_index_init() // 初始化索引
- ha_index_next() // 获取下一行
- ha_index_end() // 结束索引扫描

7. 存储引擎(Storage Engine)#

InnoDB 执行层:

  1. Buffer Pool 检查:数据页是否在内存
  2. 读取数据页:从磁盘读取(如果不在内存)
  3. 行锁机制:如果是更新操作,加行锁
  4. MVCC:根据事务隔离级别返回对应版本数据
  5. 返回数据:将数据返回给执行器

流程图示例#

用户输入: SELECT name FROM users WHERE id = 1;
[连接器] 验证身份 → 建立连接
[解析器] 词法分析 → 语法分析 → 生成解析树
[优化器] 选择索引idx_id → 确定执行计划
[执行器] 调用InnoDB接口 → 权限验证
[存储引擎] 通过主键索引查找 → 返回数据行
[执行器] 组装结果集
[连接器] 返回给客户端

关键优化点#

  1. 连接层:使用连接池减少连接开销
  2. 缓存层:Redis 等外部缓存替代 Query Cache
  3. 解析层:使用预编译语句(PreparedStatement)
  4. 优化层:合理使用索引,避免全表扫描
  5. 执行层:批量操作减少网络往返
  6. 存储层:SSD 磁盘,增大 Buffer Pool

from https://www.nowcoder.com/feed/main/detail/917d57f3bee9400198c5e2b52348bd50?sourceSSR=users

虾皮后端日常实习一面
https://mizuki.mysqil.com/posts/盘牛客面经/虾皮后端日常实习一面/
作者
Laoli
发布于
2025-10-17
许可协议
CC BY-NC-SA 4.0
封面
示例歌曲
示例艺术家
封面
示例歌曲
示例艺术家
0:00 / 0:00