面试八股/场景

记录一下八股

面试题总结

HashMap的底层原理

Hashap 是基于哈希表的数据结构,用于存储键值对(key-value )。其核心是将键的哈希值映射到数组索引位置,通过数组 +链表(在 Java 8 及之后是数组 +链表 +红黑树)来处理哈希冲突。

Hashmap 的默认初始容量为 16,负载因子为 0.75。也就是说,当存储的元素数量超过16x0.75=12个时, Hashmap 会触 发扩容操作,容量x2并重新分配元素位置。这种扩容是比较耗时的操作,频繁扩容会影响性能。

HashMap 的红黑树优化:

从Java8开始,为了优化当多个元素映射到同一个哈希桶(即发生哈希冲突)时的查找性能,当链表长度超过8时,链表会转变为红黑树。红黑树是一种自平衡二叉搜索树,能够将最坏情况下的査找复杂度从 O(n) 降低到 O(log n)。如果树中元素的数量低于 6,红黑树会转换回链表,以减少不必要的树操作开销。

hashCode()和 equals()的重要性:

HashMp 的键必须实现 hashcode()和 equals()方法。 hashcode()用于计算哈希值,以决定键的存储位置,而 equals()用于比较两个键是否相同。在 put 操作时,如果两个键的 hashcode()相同,但 equals()返回 false,则这两个键会被视为不同的键,存储在同一个桶的不同位置。

经典的“哈希冲突”案例

在 Java 中,最著名的例子就是字符串 "Aa""BB"

Java

1
2
3
4
5
6
String s1 = "Aa";
String s2 = "BB";

System.out.println(s1.hashCode()); // 输出 2112
System.out.println(s2.hashCode()); // 输出 2112
System.out.println(s1.equals(s2)); // 输出 false

发生了什么?

Java 字符串的哈希算法是 s[0]*31 + s[1]:

  • 'A' 是 65,'a' 是 97 \rightarrow 65 \times 31 + 97 = 2112

  • 'B' 是 66 \rightarrow 66 \times 31 + 66 = 2112

虽然算出来的数字一样,但显然 "Aa""BB" 是完全不同的字符串。

HashMap成环

由于线程1已经指向了B和A,线程2却先一步执行了扩容操作,而停止循环的条件就是e.next为null,当执行完扩容后,线程1苏醒,此时e为B,e.next为A,当前e的next指向A,导致A与B之间产生死循环,颠倒后依旧产生连接,就形成了环。

JDK1.7HashMap多线程死循环问题_哔哩哔哩_bilibili

Synchronized 和 ReentrantLock有什么区别?

实现层面

synchronized

  • 是 Java 的关键字,属于 JVM 层面的实现。

ReentrantLock

  • 是 JDK 提供的一个java.util.concurrent.locks.ReentrantLock),属于 API 层面的实现。

锁的释放

synchronized

  • 自动释放。代码执行完同步代码块,或者抛出异常时,JVM 会自动释放锁,不会导致死锁。

ReentrantLock

  • 手动释放。必须手动调用 unlock() 方法。

锁的公平性

synchronized

  • 只能是非公平锁。线程获取锁的顺序是不确定的,可能发生“饥饿”现象。

ReentrantLock

  • 默认是非公平锁(性能更好)。

  • 可以通过构造函数 new ReentrantLock(true) 指定为公平锁遵循先来后到原则),但性能会下降。

等待可中断

synchronized

  • 不可中断。如果一个线程正在等待获取锁,它不能被中断(interrupt),只能一直阻塞等待。

ReentrantLock

  • 可中断。通过 lockInterruptibly() 方法,可以让正在等待锁的线程响应中断,放弃等待去处理其他事情。

尝试获取锁

synchronized

  • 不行。要么拿到锁,要么阻塞死等。

ReentrantLock

  • 提供 tryLock() 方法。可以尝试获取锁,如果锁被占用,可以选择立即返回 false 或者等待一段指定的时间,非常灵活。
特性 synchronized(隐式锁) ReentrantLock(显式锁)
实现方式 关键字 (JVM 层面) 类 (JDK API 层面)
锁的释放 隐式自动释放 显式手动释放 (需在 finally 中 unlock)
公平性 非公平 默认非公平,可设置为公平
响应中断 不支持 支持 (lockInterruptibly)
条件队列 单个 (wait/notify) 多个 (Condition)
尝试获取 不支持 (死等) 支持 (tryLock)
灵活性

锁升级

锁升级流程图解

锁状态 触发条件 优点 缺点
偏向锁 只有一个线程反复加锁 加锁/解锁几乎零消耗 如果存在线程竞争,撤销偏向锁会有额外开销
轻量级锁 线程交替执行,无同时竞争 不会阻塞线程,利用 CAS 和自旋,响应速度快 如果始终得不到锁,自旋会空耗 CPU
重量级锁 发生长时间或多线程同时竞争 不会空耗 CPU,未获锁线程进入阻塞 线程阻塞/唤醒涉及上下文切换,开销大,较慢

CAS与自旋

什么是CAS(乐观锁)

  • 你看到原句是“Hello”(A)。

  • 你想改成“Hi”(B)。

  • 在你提交修改的一瞬间,系统检查文档当前还是不是“Hello”(V)。

    • 如果是,修改成功。

    • 如果文档已经被别人改成了“Hello World”,你的预期(A)和实际(V)不符,提交失败,你需要重新读取再尝试。

CAS 的三大问题

  1. ABA 问题(最著名的坑):

    • 虽然 V 还是 A,但不代表它没变过。它可能经历了 A -> B -> A 的过程。

    • 比喻:桌上有一杯水(A),你离开了一会儿。回来时水还是满的(A),你以为没人动过。实际上可能有人喝光了(B),又给你倒满了(A)。虽然结果一样,但过程可能由于“杯子被用过”而产生副作用。

    • 解决:加版本号。变成 1A -> 2B -> 3A。Java 中的 AtomicStampedReference 就是干这个的。给提交结果加版本号

  2. 只能保证一个变量的原子性:无法同时操作多个变量。

  3. CPU 开销大:这通常与“自旋”结合在一起,见下文。

什么是 自旋 ?

自旋是一种线程等待的策略。

当线程抢不到锁(或 CAS 失败)时,它不放弃 CPU,不进入阻塞状态(不睡觉),而是执行一个空循环(Loop),不断地检查“锁释放了吗?”或者“我能重试 CAS 了吗?”。

为什么要自旋?

为了避免上下文切换的开销。

  • 阻塞/唤醒:线程挂起和恢复需要操作系统介入,保存和恢复现场,非常耗时(可能比执行代码本身还慢)。

  • 自旋:如果锁被占用的时间很短,我在门口转两圈(自旋)锁就释放了,这样比“回家睡觉再被电话叫醒”快得多。

概念 作用 核心词 优缺点
CAS 实现原子更新 比较并交换 优点:非阻塞,性能好
缺点:ABA 问题
自旋 等待锁的策略 循环重试 优点:避免上下文切换
缺点:耗费 CPU

SpringBean的生命周期

阶段一:创建与注入 (的基础)

1. 实例化 (Instantiation)

  • 发生了什么: Spring 容器通过反射(Constructor.newInstance())调用 Bean 的构造函数。

  • 状态: 此时对象已经在堆内存中存在了,但它只是一个“空壳子”,里面的属性(依赖)全是 null

  • 类比: 刚买了一个毛坯房,房子建好了,但里面什么家具都没有。

2. 属性赋值 (Populate Properties)

  • 发生了什么: Spring 依据配置(XML 或 @Autowired@Value),将依赖的对象或属性值注入到 Bean 中。

  • 状态: 对象内部的依赖已经填充完毕。

  • 类比: 装修工进场,把沙发(Service)、电视(Dao)都搬进了房子里。

阶段二:容器感知 (Aware 接口回调)

3. Aware 接口回调 (Invoke Aware Interfaces)

  • 发生了什么: 如果 Bean 实现了特定的 Aware 接口,Spring 会把容器内部的一些资源“告诉”这个 Bean。

阶段三:初始化与增强 (最关键的步骤)

所谓的“初始化阶段”,就是 Spring 给你一个机会,在“属性都装好了”之后,但在“给别人使用”之前,让你执行一段自己的业务代码(比如连数据库、加载缓存、校验配置)。

Spring AOP(默认机制)使用的是 动态代理(Dynamic Proxy)。它的逻辑是委托(Delegation)

  • 原本的流程:

    调用者 \rightarrow 目标对象(Target)

  • Spring AOP 的流程:

    调用者 \rightarrow 代理对象(Proxy) \rightarrow 执行增强代码(Before) \rightarrow 调用目标对象 \rightarrow 执行增强代码(After) \rightarrow 返回

特性 Spring AOP AspectJ(静态代理)
原理 动态代理 (Proxy) 字节码织入 (Bytecode Weaving)
修改方式 运行时生成新类包裹目标 编译期/加载期直接修改目标类字节码
是否需要接口 JDK模式需要,CGLIB不需要 不需要
性能 稍慢(有反射和代理调用的开销) 极快(就是普通的方法调用)
能力范围 只能拦截 public 方法 的调用 无所不能:可拦截 private 方法、静态方法、构造函数、字段访问等
内部调用(this) 失效 有效 (完美解决自调用问题)
复杂度 低(Spring Boot 开箱即用) 高(需要配置编译器或 JVM Agent)

Redis和Memcached的区别

特性 Memcached Redis
数据结构 单一 (仅支持 String/二进制块) 丰富 (String, List, Hash, Set, ZSet, Bitmap, HyperLogLog, Stream, Geo)
线程模型 多线程 (Multi-threaded) 单线程 (主逻辑) + I/O 多线程 (Redis 6.0+)
持久化 不支持 (重启后数据丢失) 支持 (RDB 快照 & AOF 日志)
分布式支持 客户端实现 (服务端互不通信) 服务端支持 (Redis Cluster, Sentinel)
内存管理 Slab Allocation (预分配,无碎片,但在非均匀大小数据下有浪费) jemalloc/libc (按需分配,利用率高,但可能有内存碎片)
功能丰富度 仅缓存 Lua 脚本、发布/订阅 (Pub/Sub)、事务、Pipeline
最大值限制 Value 最大 1MB Value 最大 512MB

A. 数据类型 (Data Types) —— 最大的区别

  • Memcached: 就像一个巨大的 Map<String, String>。如果你想存一个列表,必须先把列表序列化成 JSON 字符串存进去;取出来时,必须把整个字符串取出来反序列化,修改后再序列化存回去。这非常消耗网络带宽和 CPU。

  • Redis: 就像一个瑞士军刀。你可以直接在服务端操作数据结构。

    • 例子: 你想给一个排行榜加分。

    • Redis: ZINCRBY rank_list 10 "user_id" (直接在内存里改数值,极快)。

    • Memcached: get -> 反序列化 -> value + 10 -> 序列化 -> set (并发下还需要加锁,否则数据不一致)。

B. 线程模型 (Threading Model) —— 性能的分水岭

  • Redis (单线程为主):

    • Redis 的核心操作(执行命令)是单线程的。

    • 优势: 没有线程切换开销,没有锁竞争(Lock-free),代码简单稳定。

    • 劣势: 无法利用多核 CPU 的优势(但在 6.0 版本引入了多线程 I/O 来处理网络读写,核心计算依然是单线程)。

  • Memcached (多线程):

    • 它使用主线程监听端口,Worker 线程处理读写。

    • 优势: 可以轻松利用 64 核 CPU 的计算能力,在极高并发(数十万 QPS 以上)且 Value 很小 的场景下,吞吐量可能高于 Redis。

C. 持久化 (Persistence) —— 数据安全性

  • Memcached: 把它当作一个易失性缓存。如果服务器断电或重启,所有缓存数据瞬间清空,数据库会瞬间面临巨大的“缓存击穿”压力。

  • Redis: 支持持久化。

    • RDB: 定期生成快照保存在磁盘。

    • AOF: 记录每一条写命令。

    • 用途: 重启后可以自动恢复数据,甚至可以用来做主数据库使用。

D. 分布式/集群 (Clustering)

  • Memcached: 服务端是“傻瓜式”的,节点之间互不通信。分布式的逻辑完全由客户端(Client)控制(通常使用一致性哈希算法)。如果你加一台服务器,客户端需要重新计算哈希。

  • Redis: 支持原生的 Redis Cluster。服务端之间会通信(Gossip 协议),支持自动分片、故障转移(Failover)。

内核内存 vs. 用户内存 (The Two Worlds)

操作系统(如 Linux)为了安全和稳定,把内存划分成了两个隔离的区域:

1. 内核内存 (Kernel Memory / Kernel Space)

  • 权限: 最高权限 (Ring 0)。CPU 可以执行任何指令,访问任何硬件地址。

  • 居住者: 操作系统内核代码、驱动程序(网卡驱动)、硬件缓冲区。

  • 特点: 如果这块区域的代码崩溃了,整个操作系统就蓝屏或死机了。它是神圣不可侵犯的。

2. 用户内存 (User Memory / User Space)

  • 权限: 受限权限 (Ring 3)。只能访问自己的内存空间,严禁直接访问硬件(不能直接读写网卡、硬盘)。

  • 居住者: 你运行的应用程序(Redis, Java JVM, 浏览器, QQ)。

  • 特点: 如果你的 Redis 崩了,只是这一个进程死掉,操作系统照样运行。

核心矛盾: 网卡收到的数据首先必须存放在内核内存(因为它是由驱动程序管理的)。但是,你的 Redis 运行在用户内存

代价: 数据必须从“内核态”拷贝 (Copy) 到“用户态”,应用程序才能处理。这就是 Redis 6.0 引入多线程 I/O 想要优化的那个“搬运”过程。###

Socket 是什么? (The Abstraction)

很多初学者认为 Socket 就是“插座”或者“连接”。在操作系统层面,Socket 本质上是内核内存中的两个缓冲区(Buffer)

当你创建一个 Socket(比如 Java 的 new Socket() 或 Redis 监听的端口),内核会在内核内存里为你申请两块地:

  1. 接收缓冲区 (Recv Buffer): 存放网卡发过来、等待你读取的数据。

  2. 发送缓冲区 (Send Buffer): 存放你写进去、等待网卡发送的数据。

Socket 就是应用程序(用户态)和网络协议栈(内核态)交互的句柄 (File Descriptor)

Redis数据的全生命周期流程

假设外网发来一个 TCP 包给 Redis(端口 6379),流程如下:

1. 物理层:网卡收货 (NIC)

  • 网线传来电信号,网卡将其转换为二进制数据。

  • DMA (Direct Memory Access): 网卡不经过 CPU,直接利用 DMA 技术,把数据包写入到 内核内存 中的一块公用区域(通常叫 Ring Buffer)。

  • 此时,数据在内核,但还不知道属于哪个 Socket。

2. 链路层 & 网络层:内核协议栈介入

  • 网卡给 CPU 发送中断 (Interrupt)

  • CPU 停下手中的活,运行网卡驱动程序。

  • 驱动程序把数据包包装成内核的数据结构(Linux 下叫 sk_buff)。

  • 操作系统检查 IP 头:是发给本机的吗?是的。

3. 传输层:分发给 Socket (Demultiplexing)

  • 操作系统检查 TCP 头:目标端口是 6379

  • 内核查找:“哪个 Socket 正在监听 6379 端口?” -> 找到了 Redis 的那个 Socket。

  • 关键动作: 内核把这个数据包(sk_buff)挂到该 Socket 的接收缓冲区 (Recv Buffer) 队列的尾部。

  • 此时,数据依然在内核内存中,但已经归属到了特定的 Socket 名下。

4. 应用层:数据搬运 (Copy to User)

  • Redis 主线程(或 I/O 线程)调用系统函数 read(socket_fd)

  • CPU 发生上下文切换: 从用户态切入内核态。

  • 内存拷贝: CPU 把数据从 内核内存(Socket 的接收缓冲区) 复制到 用户内存(Redis 定义的 buffer)

  • Redis 终于拿到了数据,开始解析处理 set name ...

那 Redis 6.0 的 I/O 线程到底解决了什么?

既然都要经过内核拷贝,那用主线程调 write 和用 I/O 线程调 write 有什么区别?

区别在于“谁在等”和“谁在分担开销”。

场景:如果你只有主线程(Redis < 6.0)

  • 主线程:“我要处理 10,000 个客户端的 get 请求。”

  • 对于每一个请求,主线程都要亲自调用 write

  • 每次 write,主线程都要经历:用户态->内核态切换 (耗时) + CPU 搬运数据 (耗时) + 内核态->用户态切换 (耗时)

  • 结果: 主线程大量时间花在和内核打交道、等待搬运上,导致它处理命令(KV 读写)的时间变少了。

场景:你有 I/O 线程(Redis 6.0+)

  • 主线程:“我要处理 10,000 个请求。算出结果后,我不亲自发了。”

  • 主线程把结果扔给 4 个 I/O 线程,说:“你们去调 write 发给客户。”

  • 主线程立刻转头去处理下一个命令的计算(KV 读写)。

  • I/O 线程们 并行地去调用 write,去承担上下文切换和 CPU 搬运数据的开销。

  • 结果: 主线程被解放了,只专注于纯内存计算,吞吐量大增。

内核线程,内核级线程,用户级线程

用户级线程是怎么“骗”过内核的?(M:N 模型)

用户级线程通过一个**中间层(Runtime/Scheduler)**来实现。

  • M 个用户级线程(比如 1000 个 Goroutine)。

  • N 个内核级线程(通常等于 CPU 核数,比如 8 个)。

  • 映射关系:

    • 这 8 个内核线程是“干活的苦力”。

    • 这 1000 个协程是“待办的任务”。

    • Go 语言的运行时(Runtime)负责把这 1000 个任务,源源不断地塞给这 8 个苦力去做。

    • 如果一个任务(协程)卡在 I/O 上了,Runtime 把它拿开,换一个新任务给苦力,苦力(内核线程)永远不休息,也永远不阻塞

我们可以把计算机系统看作一家大型工厂(操作系统):

  1. 内核线程 (Kernel Thread): 工厂的内部设施维护人员(只在核心区工作,不生产对外产品)。

  2. 内核级线程 (Kernel-Level Thread, KLT): 工厂正式聘用的流水线工人(有工牌,受人事部直接管理)。

  3. 用户级线程 (User-Level Thread, ULT): 包工头带来的临时工/外包团队(人事部不知道他们的存在,只知道包工头)。

一、 详细拆解:三者的定义与区别

1. 内核线程 (Kernel Thread / kthread)

这是最底层的存在,它们完全运行在内核空间(Ring 0)

  • 定义: 它是操作系统内核用来执行后台任务的线程。它没有用户地址空间(不映射用户内存),只能访问内核代码和数据。

  • 谁创建/管理: 操作系统内核。

  • 能干啥:

    • 将内存中的脏页刷写到磁盘(如 Linux 的 kflushpdflush)。

    • 处理软中断和网络包(如 ksoftirqd)。

    • 执行磁盘 I/O 调度。

  • 特点: 也就是我们常说的“纯内核线程”。你在 Linux 用 ps -ef 看到的那些名字带中括号 [kthreadd][migration] 的进程,就是它们。

  • 与用户程序的关系: 完全没关系。它们不运行你的 Java 或 Redis 代码,它们只服务于 OS 本身。

2. 内核级线程 (Kernel-Level Thread, KLT)

这是我们日常开发中最常接触的概念(虽然你可能没意识到)。 在 Linux 中,它们通常被称为 轻量级进程 (LWP, Light Weight Process)

  • 定义: 这是一个由内核管理、但用于执行用户态代码的执行实体。它是**“用户进程”在内核眼里的分身**。

  • 谁创建/管理: 操作系统内核调度器(如 CFS)。

  • 能干啥: 执行你的 main() 函数,执行 Redis 的主逻辑,执行 Java 的线程。

  • 特点:

    • 拥有双重身份: 既有内核栈(陷入内核时用),也有用户栈(运行程序时用)。

    • 可被独立调度: 操作系统知道它的存在,可以将它分配给任意 CPU 核心。

    • 高开销: 创建、销毁、切换都需要系统调用,涉及内核态/用户态切换,成本较高(微秒级)。

  • 代表: Java 的 java.lang.Thread (在 Linux 上),C++ 的 std::thread,Redis 的主线程和 I/O 线程。

3. 用户级线程 (User-Level Thread, ULT)

也叫协程、纤程、Green Thread。

  • 定义: 完全在用户空间实现的线程机制。内核完全不知道它们的存在,内核只看到承载它们的那个 KLT。

  • 谁创建/管理: 编程语言的运行时(Runtime)或库(如 Go Runtime, JVM)。

  • 能干啥: 处理高并发逻辑,阻塞式写法的异步执行。

  • 特点:

    • 极轻量: 切换只涉及寄存器保存,无需陷入内核,纳秒级。

    • 不可独立调度: 操作系统无法直接把一个 ULT 分配给 CPU,必须依附于一个 KLT 才能运行。

  • 代表: Go 的 Goroutine, Python 的 Gevent, Java 21 的 Virtual Thread。

二、 它们之间的映射模型 (The Mapping Models)

弄清楚关系的关键,在于理解用户级线程 (ULT)内核级线程 (KLT) 是如何搭配工作的。这主要有三种模型:

1. 多对一模型 (M : 1) —— 上古时代的产物

  • 描述: 多个用户级线程(M)跑在 1 个内核级线程(1)上。

  • 例子: 老版本的 Python 异步库,某些老旧的 JVM 实现(Green Threads)。

  • 缺点: 没有并行能力。因为底层只有一个 KLT,所以一次只能用 1 个 CPU 核。如果其中一个 ULT 阻塞了(比如发起系统调用),底层的 KLT 就阻塞了,其他所有 M 个 ULT 全都卡死。

2. 一对一模型 (1 : 1) —— 现代主流 (Redis, Nginx, Java)

  • 描述: 1 个用户级线程(逻辑上的线程)直接对应 1 个内核级线程。

  • 机制: 当你在 Java 里 new Thread(),操作系统就在底层真的创建一个 KLT(LWP)。

  • 优点: 真正的并行。一个线程阻塞,不影响其他线程。实现简单,直接依赖 OS 调度。

  • 缺点: 线程太贵,开不了一百万个。

  • 现状: Redis 的主线程和 I/O 线程,Java 目前默认的线程模型,都是 1:1。 所谓的“用户线程”此时只是 KLT 的一个句柄。

3. 多对多模型 (M : N) —— 高并发的未来 (Go,Java21 Virtual Threads)

  • 描述: M 个用户级线程(协程)动态映射到 N 个内核级线程上。

  • 机制:

    • Runtime 维护一个线程池(N 个 KLT)。

    • Runtime 维护一个任务队列(M 个 ULT)。

    • Runtime 负责把 ULT 喂给 KLT 执行。如果一个 ULT 阻塞了,Runtime 把它拿下来,换另一个 ULT 上去。

  • 优点: 既有 ULT 的轻量(可以开百万个),又有 KLT 的并行(利用多核 CPU)。

  • 现状: Go 语言之所以火,就是因为它的调度器(G-M-P 模型)把这个做到了极致。


三、 总结与对比表

特性 内核线程 (Kernel Thread) 内核级线程 (KLT / LWP) 用户级线程 (ULT / Coroutine)
可见性 仅内核可见 内核可见,用户可见 仅用户程序可见 (内核不可见)
内存空间 仅内核空间 用户空间 + 内核空间 仅用户空间
调度者 OS 调度器 OS 调度器 语言运行时 (Runtime)
切换开销 小 (无需切换地址空间) 中 (需切入内核态) 极小 (纯用户态操作)
并行性 利用多核 利用多核 依赖于底层的 KLT
阻塞影响 - 线程阻塞,释放 CPU 给别人 协程阻塞,Runtime 切换其他协程
典型例子 ksoftirqd, kworker java.lang.Thread, Redis 线程 Go Goroutine, Java Virtual Thread

一般什么情况下需要陷入内核态?

简单来说,“陷入内核态”(Trap into Kernel Mode) 也就是 CPU 从 特权级 3 (User Ring 3) 切换到 特权级 0 (Kernel Ring 0) 的过程。

一般情况下,陷入内核态主要有且仅有三种场景:


1. 系统调用 (System Call) —— 主动请求

这是最常见的情况。当你的程序需要做一些自己权限不够的事情时,必须主动向操作系统“打报告”。

因为用户程序不能直接操作硬件(硬盘、网卡、声卡)或管理内存,必须通过特定的接口(System Call Interface)请求内核代劳。

  • 硬件 I/O 操作:

    • 读写文件: read(), write(), open()(Redis 写日志、读数据库)。

    • 网络通信: socket(), connect(), send(), recv()(Redis 处理请求)。

    • 屏幕输出: printf()(底层调用 write 输出到标准输出)。

  • 进程控制:

    • 创建进程: fork(), exec()(Redis 做 RDB 快照时会 fork 子进程)。

    • 退出程序: exit()

  • 内存管理:

    • 申请内存: malloc() 在堆内存不够时,底层会调用 brk()mmap() 向内核要内存。

比喻: 你要去银行取钱(操作硬件资源),你不能自己冲进金库拿,必须填单子(系统调用),交给柜员(内核),柜员帮你拿出来给你。


2. 异常 (Exception) —— 内部错误或特殊事件

这是由 CPU 在执行指令时,内部检测到的意外情况。程序“闯祸了”或者遇到了“特殊指令”。

  • 缺页异常 (Page Fault):

    • 当你访问一块内存地址,CPU 发现这块数据不在物理内存里(可能被换到了磁盘 swap 分区,或者刚申请还没分配物理页),CPU 会暂停程序,陷入内核。内核负责把数据从磁盘加载到内存,然后恢复程序运行。
  • 程序错误:

    • 除以零: 代码里写了 100 / 0

    • 非法内存访问 (Segfault): 试图访问不属于你的内存地址(比如空指针解引用)。

    • 内核捕获这些错误后,通常会杀死进程(就是你看到的 Segmentation fault)。

  • 调试断点:

    • 当你用 GDB 调试代码打断点时,实际上是插入了一条特殊指令(如 x86 的 INT 3)。CPU 执行到这里会自动陷入内核,暂停程序,把控制权交给调试器。

比喻: 你在家里(用户态)做饭,突然锅炸了(除以零)或者你想进邻居家里(非法内存访问),这时候警察(内核)会立刻破门而入处理状况。


3. 硬件中断 (Hardware Interrupt) —— 被动打断

这是来自 CPU 外部的信号。无论你的程序正在干什么,只要硬件中断来了,CPU 必须无条件停下手头的工作,切到内核态去处理中断。

  • 时钟中断 (Clock Interrupt):

    • 最重要! 这是多任务操作系统的基石。

    • 硬件时钟每隔几毫秒就会发一次中断。内核收到中断后,会看:“Redis,你的时间片用完了,该让给 Nginx 跑一会儿了。”

    • 这就是为什么死循环的程序不会把电脑彻底卡死,因为内核会强行通过时钟中断夺回控制权。

  • I/O 完成中断:

    • 网卡: “新数据包到了!”(内核把数据拷贝到 Socket 缓冲区)。

    • 硬盘: “刚才你要读的数据我已经读完放到内存了!”

  • 键盘/鼠标输入:

    • 你按下一个键,键盘控制器发送中断,CPU 陷入内核读取按键码。

比喻: 你正在家里专心打游戏(用户态运行代码),突然快递员狂按门铃(网卡中断),或者你的闹钟响了(时钟中断),你必须停下游戏去开门或者关闹钟(陷入内核处理)。

触发方式 来源 例子 主动/被动
系统调用 程序代码 read(), fork(), sleep() 主动 (程序自己写的)
异常 CPU 内部 缺页、除零、Segfault 被动 (通常是闯祸了)
硬件中断 CPU 外部 网卡收包、时钟滴答、键盘 被动 (外部环境强制)

Redis单线程避免上下文切换的开销

1. 什么是“昂贵”的上下文切换?

首先,我们要明确,为什么切换线程很贵?

当 CPU 从 线程 A 切换到 线程 B 时,不仅仅是“换个人干活”这么简单,它涉及两个巨大的成本:

  1. 直接成本 (CPU 寄存器重置):

    • CPU 需要把线程 A 的“现场”(程序计数器 PC、堆栈指针 SP、通用寄存器等)保存到内存里。

    • 然后从内存里把线程 B 的“现场”恢复到寄存器里。

    • 这本身需要花费几微秒 ($\mu s$)。

  2. 间接成本 (Cache 失效 —— 这才是真正的杀手):

    • CPU 有 L1/L2/L3 高速缓存。线程 A 跑得正欢的时候,缓存Cache里全是线程 A 需要的数据(热数据)。

    • 突然切到线程 B,线程 B 要用的数据不在缓存里(Cache Miss)。

    • CPU 被迫去慢如蜗牛的内存(RAM)里拿数据。

    • 这会导致 CPU 的执行效率瞬间暴跌。


2. 多线程模式的痛点 (The “Context Switch Storm”)

假设 Redis 是传统的多线程模型(比如像早期的 Tomcat 或 MySQL):

  • 场景: 来了 1000 个请求。

  • 处理: 系统开启 1000 个线程(或者用线程池)。

  • 锁竞争: 线程 A 要修改 Key “user:1”,线程 B 也要修改。线程 B 抢不到锁,被迫挂起 (Block)

  • 自愿切换 (Voluntary Context Switch):

    • 因为抢不到锁,或者等待磁盘 I/O,线程 B 主动告诉操作系统:“我干不下去了,把 CPU 让给别人吧。”

    • 操作系统进行上下文切换。

  • 结果: 在高并发下,CPU 把大量的时间花在** “保存现场、恢复现场、调度线程、等待锁” **上,真正用来执行 set name gemini 这行代码的时间反而被挤压了。

3. Redis 的单线程魔法 (The “Run-to-Completion”)

Redis 的主处理逻辑(Command Execution)是单线程的,这意味着:

A. 彻底消灭“锁竞争”

  • 因为只有我一个人(主线程)在动数据,所以我根本不需要锁(Lock-free)

  • 没有锁,就不会出现“因为抢不到锁而挂起”的情况。

  • 不存在“挂起”,就没有“自愿上下文切换”。

  • 结果: Redis 主线程是一路狂奔的,它处理完一个请求,马上处理下一个,中间没有任何停顿。

B. 极致的 Cache 亲和性 (Cache Affinity)

  • 因为始终是这同一个线程在这一颗 CPU 核心上跑。

  • Redis 的核心数据结构(dict, ziplist, skiplist)和代码指令,会一直停留在 CPU 的 L1/L2 缓存里。

  • Cache Hit 率极高

  • 这就像一个熟练工人在自己的工位上干活,所有工具都在手边(L1 Cache),闭着眼睛都能拿到。

C. I/O 多路复用 (Epoll) —— 避免 I/O 阻塞

  • 你可能会问:“那如果读 Socket 数据的时候,数据还没来怎么办?线程不就阻塞了吗?”

  • Redis 使用 epoll (Linux)

  • 机制: Redis 告诉内核:“这一万个 Socket 你帮我盯着,谁有数据来了你告诉我。”

  • 非阻塞: Redis 主线程永远不会在某个 Socket 上死等(Block)。它只处理那些“已经准备好数据”的 Socket。

  • 所以,Redis 永远不会因为 I/O 等待而发生上下文切换。


4. 总结对比:多线程 vs 单线程

我们可以用去银行办事来比喻:

模式 场景比喻 上下文切换情况 效率
多线程模型 10 个窗口,10 个柜员。
所有的柜员都要抢同一个账本(锁)来记账。抢不到的柜员就去喝茶(切换)。柜员之间换班还要交接工作(保存现场)。
极高
(锁竞争、线程调度)

(大量时间花在抢锁和交接上)
Redis 单线程 1 个超级柜员 (Flash)。
只有他一个人,账本就在手边(Cache)。他动作极快,处理完张三马上处理李四,不用跟任何人抢账本,也不用交接班。
极低
(几乎为 0,除非时间片用完)
极高
(CPU 100% 用在干活上)

5. 什么时候 Redis 还是会切换?

虽然 Redis 尽力避免切换,但被动切换 (Involuntary Context Switch) 是无法避免的,因为操作系统才是老大。

  1. 时间片用完 (Time Slice): 操作系统采用了分时调度。给 Redis 的 10ms 用完了,不管你活干没干完,OS 必须强行把 CPU 抢走给别的进程(比如 SSH、系统日志)用一下。

Redis 单线程避免上下文切换的核心在于:它通过“非阻塞 I/O”和“单线程串行执行”,消灭了代码层面的“锁”和“等待”,从而让 CPU 始终处于全速运算的高效状态。

Redis写数据全生命周期

假设有一个 Java 客户端,发送了一条命令 SET user:1 "Alice"

第一阶段:请求到达与分发 (进 - I/O 阶段)

  1. 客户端发送

    • 外部的 Java 客户端(用户级线程)发起 TCP 连接,将命令 SET user:1 "Alice" 转换成 Redis 协议(RESP),通过网线发送出去。

    • 数据到达 Redis 服务器的网卡,进入操作系统的内核 Socket 缓冲区

  2. 主线程感知 (epoll)

    • Redis 的主线程正在运行事件循环(Event Loop),通过 epoll_wait 监听到这个 Socket 有数据来了(可读事件)。
  3. 任务分发 (Distribute)

    • 关键点:主线程不亲自去读数据。

    • 主线程把这个 Socket 连接分配给一组 I/O 线程(IO Thread Pool) 中的某一个。

  4. I/O 线程并行读取与解析

    • 多线程并行:被选中的 I/O 线程(内核级线程)发起系统调用 read(),从内核缓冲区把数据搬运到用户态。

    • 协议解析:I/O 线程解析数据流,识别出这是一条 SET 命令,参数是 user:1Alice

    • 注意:此时主线程会短暂等待,直到所有分配出去的 I/O 任务都完成读取和解析。

第二阶段:命令执行 (做 - 执行阶段)

  1. 主线程串行执行 (Execute)

    • 所有 I/O 线程都解析完后,汇报给主线程。

    • 单线程独占:主线程按照队列顺序,串行地拿到解析好的命令。

    • 内存操作:主线程在内存的 HashMap 中找到 user:1 这个槽位,填入 "Alice"

    • 原子性:因为只有主线程在动这个 Map,所以绝对安全,不需要加锁。

  2. 生成结果

    • 执行成功,主线程生成响应结果 +OK

    • 主线程把这个结果写入到该客户端的用户态输出缓冲区中。

第三阶段:响应返回 (出 - I/O 阶段)

  1. 任务再次分发

    • 主线程依然不亲自把数据发回网卡。

    • 它再次把“写回数据”的任务分配给 I/O 线程

  2. I/O 线程并行回写

    • I/O 线程并行地调用 write() 系统调用,把缓冲区里的 +OK 发送给内核 Socket 缓冲区。

    • 内核负责通过网卡把数据发回给 Java 客户端。

第四阶段:持久化 (存 - 后台阶段)

这部分是异步发生的,不影响给客户端返回结果的速度。

  1. 触发持久化

    • AOF:主线程刚才执行完 SET 后,顺手把这条命令写到了内核缓冲区(Page Cache)。

      • 后台线程 (BIO):稍后(如每秒)醒来,调用 fsync 把内核缓冲区的数据刷到物理磁盘。
    • RDB:如果满足了触发条件(如 1 分钟改了 1 万次)。

      • 主线程:调用 fork 生成子进程。

      • 子进程:默默地在后台把内存里的 user:1 等所有数据写成 RDB 文件。

Kafka为什么比RocketMQ快

参照物:传统 I/O (Standard I/O)

假设你要把磁盘上的一个文件通过网卡发给消费者(比如读取日志发送)。 代码通常是:read(file, buffer) -> write(socket, buffer)

这中间发生了 4 次拷贝 + 4 次上下文切换:

  1. DMA 拷贝:磁盘 -> 内核缓冲区(Read Buffer)。

  2. CPU 拷贝:内核缓冲区 -> 用户态缓冲区(数据进来了)。

  3. CPU 拷贝:用户态缓冲区 -> 内核 Socket 缓冲区(数据又出去了)。

  4. DMA 拷贝:Socket 缓冲区 -> 网卡。

痛点:数据在内核和用户态之间反复横跳,CPU 忙着搬运数据,没空干别的。

Kafka 的绝技:sendfile (数据管道)

Kafka 在发送消息给消费者时,调用了 Java 的 FileChannel.transferTo(),底层就是 Linux 的 sendfile 系统调用。

核心机制

sendfile 告诉内核:“把这个文件里的数据,直接发给那个 Socket,不要经过我的手(用户态)。”

流程(2 次拷贝 + 2 次切换):

  1. DMA 拷贝:磁盘 -> 内核缓冲区 (Page Cache)

  2. CPU/DMA 拷贝

    • 早期 Linux:内核缓冲区 -> Socket 缓冲区。

    • 现代 Linux (DMA Gather Copy):内核缓冲区 -> 直接给网卡(只把描述符给 Socket,真正做到了 CPU 0 拷贝)。

这里的零拷贝指的是零CPU拷贝。

RocketMQ 的绝技:mmap (内存映射)

RocketMQ 选择了 mmap(Memory Mapped Files),在 Java 中对应 MappedByteBuffer

核心机制

mmap 告诉内核:“把磁盘上的这个文件,映射到我的虚拟内存地址里来。”

流程:

  1. 建立映射:用户态的一个虚拟地址指针,直接指向内核的 Page Cache。

  2. 读写数据

    • RocketMQ 读取这个指针,就像读内存数组一样简单。

    • 操作系统负责在后台利用 DMA 把磁盘数据加载到 Page Cache(缺页中断机制)。

  3. 发送数据:RocketMQ 读取这块“内存”,然后 write 给 Socket。

    • 这里依然需要一次 CPU 拷贝(从映射内存拷贝到 Socket 缓冲区)。
特性 Kafka (sendfile) RocketMQ (mmap)
数据路径 磁盘 -> 内核 -> 网卡 磁盘 -> 内核 -> 用户态 -> 内核 -> 网卡
CPU 参与度 极低 (几乎不参与拷贝) 中等 (需要一次 CPU 拷贝)
用户态可见性 不可见 (黑盒传输) 可见 (可读、可修改)
适用场景 海量数据流式传输 (只管发,不处理) 复杂业务消息 (需要过滤 Tag、事务回查等)
Java API FileChannel.transferTo() MappedByteBuffer
限制 无法对内容进行逻辑处理 文件不能太大 (RocketMQ 限制 1GB)

Kafka 之所以吞吐量宇宙第一,是因为它放弃了对消息细节的掌控,直接用 sendfile 当了一个“甩手掌柜”,把数据直接丢给网卡。

RocketMQ 之所以功能强大(支持 Tag 过滤、复杂的事务状态),是因为它用了 mmap保留了对数据的访问权,虽然牺牲了一点点传输性能,但换来了业务灵活性。

一句话概括:Kafka 是为了“运货”而生,RocketMQ 是为了“验货”而生。

Page Cache 是 Linux 内核为了掩盖磁盘龟速而用闲置内存做的“障眼法”。

为什么RPC/HTTP2能比HTTP1.1快那么多

简单来说,HTTP/1.1 像是单车道,而 HTTP/2 + RPC 像是多车道高速公路,且路上跑的都是压缩后的跑车而不是臃肿的大卡车。

以下是具体的技术核心差异:


1. 多路复用 (Multiplexing) —— 解决核心痛点

这是 HTTP/2 相比 HTTP/1.1 最大的性能提升点,解决了“队头阻塞”(Head-of-Line Blocking)问题。

  • HTTP/1.1 的问题:

    在同一个 TCP 连接中,请求是串行的。浏览器/客户端必须等上一个请求响应回来,才能发下一个。如果第一个请求处理很慢(比如数据库卡了),后面的所有请求都会被堵住。

    • 补救措施: 浏览器通常会针对同一个域名建立 6 个 TCP 连接来并行传输,但这对服务器资源消耗很大。
  • HTTP/2 (RPC) 的方案:

    它引入了 流 (Stream) 和 帧 (Frame) 的概念。

    • 所有的请求和响应都共用同一个 TCP 连接

    • 不同的请求被拆分成许多小的二进制“帧”,这些帧像洗牌一样混在一起传输,每一帧都有 ID 标识属于哪个请求。

    • 结果: 请求 A 的数据包不需要等请求 B 处理完就能发送。高并发下,吞吐量极高。

2. 头部压缩 (HPACK) —— 节省带宽

在微服务架构中,RPC 调用非常频繁,HTTP 头部(Headers)占用的开销比你想象的要大。

  • HTTP/1.1 的问题:

    HTTP 是无状态的,每次请求都会携带完整的 Header(如 User-Agent, Cookie, Accept 等)。这些全是纯文本,往往几百字节甚至上 KB。如果你的请求体(Body)只有几十字节,那传输的有效数据比例极低。

  • HTTP/2 (RPC) 的方案:

    使用了 HPACK 算法。

    • 客户端和服务器共同维护一张动态表静态表

    • 如果发送过 User-Agent: Chrome,第二次只需要发送一个索引号(比如 1),服务器查表就知道是 User-Agent: Chrome

    • 这使得 Header 的大小几乎可以忽略不计。

3. 二进制分帧 (Binary Framing) —— 解析更快

计算机处理二进制数据远快于处理文本。

  • HTTP/1.1 的问题:

    是文本协议。解析文本需要处理换行符、空格、大小写等,对于高并发服务器来说,解析 JSON 或 HTTP 报文会消耗大量的 CPU 资源。

  • HTTP/2 (RPC) 的方案:

    是二进制协议。数据在传输层就已经被分割为更小的消息和帧,并采用二进制编码。机器解析起来非常高效,出错率低,且更紧凑。


4. 序列化协议 (Serialization) —— RPC 的独门秘籍

这一条主要针对 RPC(如 gRPC 使用的 Protocol Buffers)对比传统的 RESTful (HTTP/1.1 + JSON)。

  • HTTP/1.1 + JSON:

    JSON 是基于文本的,冗余度极高。比如 {“id”: 12345, “name”: “user”},你需要传输字段名 id 和 name。且 JSON 的解析(反序列化)非常耗 CPU。

  • RPC (Protobuf):

    使用 Protocol Buffers (Protobuf) 或 Thrift 等二进制序列化协议。

    • 体积小: 它是通过 ID 映射字段,不传输字段名,压缩后的体积通常只有 JSON 的 1/3 到 1/10。

    • 速度快: 二进制流直接映射到内存对象,序列化/反序列化速度比 JSON 快 5-10 倍。

5. 服务端推送 (Server Push)

虽然在 RPC 场景下用得相对少,但 HTTP/2 允许服务器在客户端请求之前“主动”推送资源,减少了往返延迟(RTT)。


总结对比

特性 HTTP/1.1 HTTP/2 (及 gRPC) 优势
传输格式 文本 (Text) 二进制 (Binary) 解析更快,体积更小
连接模型 串行 (Keep-Alive) 多路复用 (Multiplexing) 解决队头阻塞,极大提高并发
头部开销 巨大 (纯文本重复发送) HPACK 压缩 节省带宽
负载内容 通常是 JSON (大、慢) 通常是 Protobuf (小、快) 序列化性能提升明显
TCP 连接数 多个 (通常 6 个) 只需 1 个 降低服务器握手和资源开销

HeavyKeeper和LRU,LFU

可以将它们的关系理解为:LRU 和 LFU 是“怎么扔垃圾”,而 HeavyKeeper 是“怎么用极小的代价找出谁是大佬”。

以下是详细的对比和原理解析:


1. LRU (Least Recently Used) - 最近最少使用

核心逻辑: “如果数据最近被访问过,那么它将来被访问的几率也很大。”

  • 关注点: 时间(Recency)

  • 工作原理:

    • 新数据或刚被访问的数据放到队头。

    • 缓存满时,直接淘汰队尾的数据(最久没被摸过的)。

  • 优点:

    • 实现简单(HashMap + 双向链表)。

    • 适应突发性流量(Burst Traffic),因为热点往往是临近的。

  • 缺点:

    • 缓存污染(Cache Pollution): 如果进行一次全表扫描(读取大量数据但只用一次),会把原本的热点数据全部挤出缓存,导致缓存命中率急剧下降。

2. LFU (Least Frequently Used) - 最不经常使用

核心逻辑: “如果数据过去被访问多次,那么它将来被访问的几率也很大。”

  • 关注点: 频率(Frequency)

  • 工作原理:

    • 为每个数据维护一个计数器。

    • 缓存满时,淘汰计数器数值最小的数据。

  • 优点:

    • 抗扫描能力强。偶尔的一次性批量读取不会挤掉长期积累的热点数据。

    • 对于长期稳定的热点数据,命中率极高。

  • 缺点:

    • 实现复杂且内存开销大: 需要维护所有数据的计数器,且排序复杂度较高。

    • 旧数据滞留(Dusty Cache): 一个以前很热但现在没用的数据(比如上个月的爆款商品),因为计数器很高,会一直霸占缓存,如果不引入衰减机制,很难被淘汰。


3. HeavyKeeper - 专门抓“大象”的守门员

核心逻辑: “我不追求 100% 精确,但我用极小的内存就能告诉你谁是真正的热点(Top-K)。”

  • 关注点: 极低内存下的频率预估

  • 本质: 它不是一个完整的缓存系统,而是一个算法结构(通常基于 Count-Min Sketch 的改进)。它常被用于 Redis 的热点发现工具或现代缓存系统(如 Caffeine 库)的频率过滤器中。

  • 工作原理(指纹衰减):

    • 它使用类似哈希表的结构,但存储的是指纹(Fingerprint)和计数。

    • 关键创新: 传统的 Sketch 算法在哈希冲突时会错误地增加计数(导致高估)。HeavyKeeper 引入了衰减机制——当新元素进入并发生哈希冲突时,如果指纹不匹配,它会以一定概率减少(Decay)原有的计数器。

    • 结果: “小鼠流”(低频数据)的计数会被不断衰减消灭,“大象流”(高频数据)因为访问足够多,能抵抗衰减并幸存下来。

  • 优点:

    • 内存占用极小: 相比 LFU 记录所有 key 的完整计数,HeavyKeeper 只需要很少的 bucket 就能大概率找准热点。

    • 误差可控: 专门为 Top-K 场景设计,对高频数据极其准确。


总结对比表

特性 LRU (时间) LFU (频率) HeavyKeeper (概率频率)
全称 Least Recently Used Least Frequently Used HeavyKeeper
核心维度 最近访问时间 访问总次数 访问总次数 (概率性)
主要用途 缓存淘汰策略 缓存淘汰策略 热点检测 (Top-K) / 辅助 LFU
抗扫描能力 弱 (容易被冲刷)
空间开销 中 (存 Key + 链表指针) 大 (存 Key + 计数器) 极小 (哈希桶 + 指纹)
实现复杂度 低 ($O(1)$) 高 (需堆或多级链表) 中 (哈希 + 概率逻辑)
精准度 精确 精确 有误差 (但对热点准确)
典型应用 MySQL Buffer Pool (改进版), 操作系统页置换 传统缓存系统 Redis 热 key 发现, 网络流量分析

LRU代码实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
package com.lucius.interviewproject.LRU;

import java.util.HashMap;

public class LRUCache {

    // 定义双向链表节点
    class DLinkedNode {
        int key;
        int value;
        DLinkedNode prev;
        DLinkedNode next;

        public DLinkedNode() {}
        public DLinkedNode(int _key, int _value) {key = _key; value = _value;}
    }

    // 核心数据结构
    private HashMap<Integer, DLinkedNode> cache = new HashMap<>();
    private int size;
    private int capacity;
    private DLinkedNode head, tail; // 伪头部和伪尾部节点

    public LRUCache(int capacity) {
        this.size = 0;
        this.capacity = capacity;
        // 使用伪头部和伪尾部节点,避免处理复杂的 null 边界情况
        head = new DLinkedNode();
        tail = new DLinkedNode();
        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            return -1;
        }
        // 如果 key 存在,先通过哈希表定位,然后移动到链表头部
        moveToHead(node);
        return node.value;
    }

    public void put(int key, int value) {
        DLinkedNode node = cache.get(key);

        if (node == null) {
            // 如果 key 不存在,创建一个新节点
            DLinkedNode newNode = new DLinkedNode(key, value);
            // 添加进哈希表
            cache.put(key, newNode);
            // 添加至双向链表的头部
            addToHead(newNode);
            size++;

            // 如果超出容量,删除双向链表的尾部节点
            if (size > capacity) {
                DLinkedNode tail = removeTail();
                // 删除哈希表中对应的项
                cache.remove(tail.key);
                size--;
            }
        } else {
            // 如果 key 存在,更新 value,并移动到头部
            node.value = value;
            moveToHead(node);
        }
    }

    // --- 以下是辅助的链表操作方法 ---

    // 1. 将节点移动到头部 (也就是最近使用的位置)
    private void moveToHead(DLinkedNode node) {
        removeNode(node);
        addToHead(node);
    }

    // 2. 将节点插入到伪头部之后
    private void addToHead(DLinkedNode node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    // 3. 删除节点
    private void removeNode(DLinkedNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    // 4. 删除尾部节点(淘汰最久未使用的)
    private DLinkedNode removeTail() {
        DLinkedNode res = tail.prev;
        removeNode(res);
        return res;
    }
}

Redis的大key的问题怎么解决

由于 Redis 是单线程模型,处理一个巨大的 Key(无论是读取、写入还是删除)都会占用主线程,导致后续请求排队等待,造成 “卡顿”。

以下是系统化的解决思路,分为 发现删除治理(预防) 三个阶段。


一、 什么是 Big Key?(标准定义)

通常根据 Value 的大小或元素数量来判定:

  • String 类型: Value > 10KB(严格标准)或 1MB(宽松标准)。

  • 集合类型(Hash, List, Set, ZSet): 元素个数 > 5000 或 10000 个。


二、 如何发现 Big Key?

在治理之前,首先要定位它们。

  1. redis-cli --bigkeys 命令:

    • Redis 自带的工具,会扫描整个 Key 空间。

    • 缺点: 它是阻塞式的扫描(虽然用了 SCAN),在数据量巨大时可能会轻微影响性能,且只能返回每种类型最大的那个 Key,信息不全。

  2. MEMORY USAGE key 命令:

    • 如果你怀疑某个 Key 是大 Key,可以用这个命令查询其实际内存占用。
  3. RDB 文件分析(推荐):

    • 使用工具(如 rdb-toolsredis-rdb-tools)离线分析 RDB 备份文件。

    • 优点: 完全不影响线上 Redis 性能,生成的报告非常详细(包含 Key 名称、大小、类型)。

  4. 监控告警:

    • 如果在云服务商(如阿里云、AWS)上,通常控制台会有 “大 Key 分析” 功能。

    • 监控网络带宽,如果网络流出流量瞬间飙升,通常是有客户端读取了大 Key。


三、 如何安全地删除 Big Key?

绝对禁止直接使用 DEL 命令删除大 Key!

DEL 是同步阻塞操作,删除一个几百 MB 的 Key 可能会阻塞 Redis 几秒钟,导致故障转移或请求超时。

1. Redis 4.0 及以上版本(推荐)

使用 UNLINK 命令代替 DEL

  • 原理: UNLINK 只是将 Key 从元数据中解绑(逻辑删除),真正的内存回收操作会由后台线程(Lazy Free)异步执行,不会阻塞主线程

2. Redis 4.0 以下版本(手动渐进式删除)

如果版本较老,必须手动分批删除,避免阻塞:

  • Hash: 使用 HSCAN 每次扫描一部分字段,然后用 HDEL 删除。

  • List: 使用 LPOP/RPOPLTRIM 分批删除。

  • Set: 使用 SSCAN 扫描,SREM 删除。

  • ZSet: 使用 ZREMRANGEBYRANK 分批删除。


四、 如何彻底解决(设计与预防)?

删除只是治标,只有修改业务设计才能治本。

1. 拆分(Split)

将一个大 Key 拆分为多个小 Key。

  • 场景: 一个 Hash 存储了 100 万个用户对象。

  • 方案: 使用 Hash 取模设计。例如定义 hash_0hash_99 共100个 Bucket。

    • 存取时:id % 100 决定存入哪个 Key。

    • 这样每个 Key 的大小就只有原来的 1/100。

2. 压缩(Compression)

对于 String 类型的长文本(如 JSON、XML),使用压缩算法。

  • 方案: 写入 Redis 前使用 Gzip、Snappy 或 Protobuf 进行压缩/序列化。通常能减少 50%-80% 的体积。

3. 剪裁与清洗(Pruning)

不要把 Redis 当数据库用,只存 “热点数据”。

  • 场景: 一个 List 存了用户的历史浏览记录,长达几万条。

  • 方案: 业务上只需要展示最近 100 条。在写入时维持定长队列(LTRIM),或者定期清理过期数据。

4. 设置过期时间(TTL)

  • 防止 “僵尸” 大 Key 长期占用内存。给 Key 设置合理的 TTL,让其自动过期删除(注意:Redis 的自动过期在 4.0 之前删除大 Key 也可能阻塞,4.0+ 配置 lazyfree-lazy-expire yes 可解)。

5. 存储转移(Offloading)

  • 如果数据本身就是很大(例如图片二进制、长文章),且必须完整读取,那么它不适合存入 Redis。

  • 方案: 存入 S3、MongoDB 或 CDN,Redis 只存该数据的 URL 或索引 ID。


总结对照表

策略 适用场景 关键手段
发现 线上排查 / 离线分析 redis-cli --bigkeys, RDB Tools
应急处理 此时此刻必须删除 UNLINK (异步删除)
架构优化 集合元素过多 拆分 (Sharding/Bucketing)
架构优化 文本 Value 过大 压缩 (Gzip/Snappy)
架构优化 无效数据堆积 定期清理 (LTRIM) / 设置 TTL

Redis是怎么找到key存储在哪个节点上?

Redis 在集群模式(Redis Cluster)下,并不是直接把 Key 存到节点上的,而是引入了一个中间层:哈希槽 (Hash Slot)

简单来说,Redis 集群预分好了一万多个坑位(Slot),所有的 Key 都要先算一下自己该去哪个坑,而具体的节点(Node)只是负责管理这些坑位。

以下是具体的寻址过程,分为 理论计算客户端交互 两个层面。


一、 核心算法:CRC16 + 取模

Redis Cluster 固定的将数据空间划分为 16384 个哈希槽(编号 0 ~ 16383)。

当你要存储一个 Key(比如 set name lucius)时,Redis 会通过以下两步计算出这个 Key 属于哪个槽:

  1. 计算哈希值:使用 CRC16 算法对 Key 进行计算,得到一个整数。

  2. 取模运算:将得到的整数对 16384 取模。

公式:

$$ Slot = CRC16(key) \pmod{16384} $$

计算出 Slot 编号(比如 5000)后,Redis 只需要查看当前集群配置中,编号 5000 的槽是由哪个节点负责的,就能确定数据在哪个节点。


二、 生动比喻:快递分拣中心

为了方便理解,我们可以把 Redis Cluster 想象成一个巨大的物流分拣系统

  1. 货物 (Key):你要存的数据。

  2. 快递筐 (Hash Slot):系统里一共有 16384 个固定的筐子,编号 0-16383。

  3. 货车 (Node):实际负责运货的物理服务器(比如有 3 台服务器 A、B、C)。

流程是这样的:

  • 分配规则

    • 货车 A 负责运送 0 ~ 5500 号筐子。

    • 货车 B 负责运送 5501 ~ 11000 号筐子。

    • 货车 C 负责运送 11001 ~ 16383 号筐子。

  • 存货过程

    • 来了一个包裹 name

    • 系统算一下(CRC16),发现它应该进 5000 号筐子

    • 查表发现 5000 号筐子归 货车 A 管。

    • 于是包裹被丢进货车 A。

为什么要这么设计?

如果因为业务繁忙,你要加一辆货车 D,你不需要把所有包裹重新拆包计算一遍。你只需要把 货车 A 上的一部分筐子(比如 0~1000号)搬到 货车 D 上即可。

这就是 Redis Cluster 扩容方便的原因:数据迁移的单位是“槽”,而不是单个 Key。


三、 客户端是怎么知道去哪个节点的?

虽然服务器知道槽位映射关系,但**客户端(比如你的 Java 代码)**是怎么知道的呢?

通常有两种情况:

1. 笨客户端(每次都问,或问错了)—— MOVED 重定向错误

假设客户端不知道 Key name 在哪个节点,它随便连了一个节点(比如节点 B)发送命令:GET name

  1. 节点 B 收到命令,计算 CRC16('name') % 16384,发现属于 Slot 5000。

  2. 节点 B 查自己的小本本,发现 Slot 5000 归 节点 A 管。

  3. 节点 B 不会帮客户端去取数据(因为那是代理做的事),而是直接返回一个错误:

    MOVED 5000 192.168.1.100:6379

    (翻译:哥们你找错人了,这个 Key 归 5000 号槽管,那个槽在 IP 为 … 的节点上,你自己去找它吧。)

  4. 客户端收到报错,根据新地址,重新去连接节点 A。

2. 聪明客户端(Smart Client,如 Jedis, Lettuce)—— 本地缓存

为了性能,现在的客户端(如 Java 的 Jedis、Lettuce)在启动时,会先连接集群,把 Slot -> Node 的映射表 下载下来缓存在本地内存里。

  1. 你要 GET name

  2. Java 客户端在本地算一下:Slot = 5000。

  3. 查本地缓存:Slot 5000 -> 节点 A。

  4. 直接连接节点 A 发送请求。

    这避免了额外的网络跳转,效率最高。


四、 特殊情况:Hash Tag(强制特定 Key 去特定节点)

面试高频考点:

由于 CRC16 算法是随机的,user:1001 和 order:1001 很大概率会被分到不同的节点上。

这就导致一个问题:如果你想在一个事务(Multi/Exec)里同时操作这两个 Key,或者用 Lua 脚本同时处理它们,会报错! 因为 Redis 要求事务或脚本涉及的所有 Key 必须在同一个节点上。

解决方案:Hash Tag {}

你可以在 Key 中使用花括号 {}。Redis 计算 Hash 时,如果发现 Key 里有 {},就只计算 {} 内部字符串的 Hash 值。

  • Key 1: user:{1001} -> 计算 CRC16("1001")

  • Key 2: order:{1001} -> 计算 CRC16("1001")

因为 {} 里的内容一样,算出来的 Slot 肯定一样,它们就一定会落到同一个节点上。

没有 Hash Tag 之前,Redis 的分拣员(CRC16 算法)是非常老实的,它会把 Key 的每一个字符都算进去。

1. 没有 Hash Tag(老实模式)

分拣员看到什么就算什么,全名参与计算

  • Key A: user:1001

    • 算法输入"user:1001" (9 个字符)

    • 结果:哈希值 X $\rightarrow$ 对应 Slot 500

  • Key B: order:1001

    • 算法输入"order:1001" (10 个字符)

    • 结果:哈希值 Y $\rightarrow$ 对应 Slot 8000

因为 "user:1001""order:1001" 是完全不同的两个字符串,算出来的结果自然千差万别,所以它们被分到了不同的节点。


2. 有 Hash Tag(偷懒模式)

分拣员一旦看到 {},就只算花括号里面的内容外面的前缀后缀全当看不见

  • Key A: user:{1001}

    • 算法输入"1001" (只算这 4 个数字)

    • 结果:哈希值 Z $\rightarrow$ 对应 Slot 300

  • Key B: order:{1001}

    • 算法输入"1001" (还是只算这 4 个数字)

    • 结果:哈希值 Z $\rightarrow$ 对应 Slot 300

因为输入完全一样(都是 "1001"),所以算出来的 Slot 编号绝对一样,这两个 Key 就必定会去同一个节点“团聚”。

小贴士(Hash Tag使用风险)

虽然 Hash Tag 很好用,但千万不要滥用

如果你把几百万个 Key 都加上同一个 Hash Tag(比如 user:{beijing}),那么这几百万个 Key 算出来的 Slot 全都一样,它们会全部挤到同一台机器上。

后果: 这就导致了数据倾斜(Data Skew)—— 集群里的一台机器忙死(内存爆满、CPU 飙升),而其他机器闲死。

MySQL三层B+树能存储多少数据?

但在通常的估算标准下(主键为 BigInt,单行数据约 1KB),三层 B+ 树大约能存储 2000 万(2000w)条数据

1. 核心预设条件

MySQL 的 InnoDB 存储引擎有以下几个默认属性,这是计算的基础:

  • 页大小 (Page Size):默认是 16KB ($16384$ 字节)。

  • 指针大小:InnoDB 页指针为 6 字节

  • 主键类型:通常假设为 BigInt (8 字节)。如果用 Int (4 字节),存的更多。

2. B+ 树结构拆解

B+ 树分为非叶子节点(存索引和指针)和叶子节点(存真实数据)。

第一步:计算非叶子节点能存多少索引?

非叶子节点不存数据,只存“主键 + 指针”。

  • 单个索引项大小 = 主键大小 (8B) + 指针大小 (6B) = 14 字节

  • 单页可存索引数 = 页大小 / 索引项大小 = $16384 / 14 \approx 1170$ 个。

这意味着,一个非叶子节点可以指向 1170 个下级节点。

第二步:计算叶子节点能存多少数据?

叶子节点存储真实的行数据。这里变量最大的就是“一行数据的大小”。

  • 假设 1:一行数据大小为 1KB(比较常见的预设)。

  • 单页可存行数 = $16384 / 1024 = 16$ 条。

3. 三层 B+ 树容量计算

结构如下:

  1. 根节点(第1层):非叶子节点,指向 1170 个第2层节点。

  2. 分支节点(第2层):非叶子节点,每个节点指向 1170 个叶子节点。总共有 $1170 \times 1170$ 个叶子节点。

  3. 叶子节点(第3层):存数据。

计算公式:

$\text{总记录数} = (\text{根节点指针数}) \times (\text{第二层指针数}) \times (\text{叶子节点单页行数})$

代入数值(行数据 1KB):

$1170 \times 1170 \times 16 \approx \mathbf{21,902,400}$

结论 1: 如果一行数据是 1KB,三层能存约 2190 万 条数据。


4. 如果数据行很小怎么办?

有些表可能只有几个字段,一行数据可能只有 100 字节。如果不算页分裂等损耗:

  • 单页可存行数 = $16384 / 100 \approx 160$ 条。

  • 总容量 = $1170 \times 1170 \times 160 \approx \mathbf{2.19 亿}$。

结论 2: 数据行越小,三层 B+ 树能存的数据就越多,甚至能过亿。


5. 现实中的误差(Fill Factor)

上面的计算是理论最大值(所有页都填满 100%)。但在实际运行中:

  1. 页头页尾开销:每个页有 Header、Directory Slot 等元数据,大约占用几十到几百字节,不能全用来存数据。

  2. 页填充率:InnoDB 不会把页填得满满当当,为了减少页分裂,通常填充率在 1/2 到 15/16 之间。如果频繁随机插入,页碎片化会导致填充率下降。

如果按照 50%-75% 的利用率折算,通常认为三层树的舒适区确实就在 2000万 左右。一旦超过这个数量级,B+ 树可能分裂出第四层,导致磁盘 I/O 增加,查询性能轻微下降。

事务的四大特性(ACID)

为了保证上述逻辑的严密性,数据库理论规定事务必须满足 ACID 四个特性,这经常在面试中被问到:

  1. A - 原子性 (Atomicity)

    • 核心:要么全做,要么全不做。

    • 实现靠 Undo Log(回滚日志)。

  2. C - 一致性 (Consistency)

    • 核心:事务前后,数据必须符合逻辑(比如转账前后,两人的钱加起来总数不变)。
  3. I - 隔离性 (Isolation)

    • 核心:多个事务并发执行时,互不干扰(这就是我们之前讨论的 RR、RC 隔离级别)。

    • 实现靠 锁 + MVCC。

  4. D - 持久性 (Durability)

    • 核心:事务一旦提交,数据就是永久的,哪怕下一秒服务器爆炸,重启后数据依然在。

    • 实现靠 Redo Log(重做日志)。

MySQL的事务隔离级别

并发事务的三大问题

  1. 脏读 (Dirty Read):事务 A 读到了事务 B 未提交的数据。如果 B 回滚,A 读到的就是脏数据。

  2. 不可重复读 (Non-repeatable Read):事务 A 在同一个事务中两次读取同一行数据,结果不一样。这是因为在两次读取之间,事务 B 修改或删除了该数据并提交了。

  3. 幻读 (Phantom Read):事务 A 在同一个事务中两次查询同一个范围,第二次发现多了一些数据。这是因为在两次查询之间,事务 B 插入了新数据并提交了。

四种隔离级别详解

按照隔离程度从低到高排序:

1. 读未提交 (Read Uncommitted)

  • 描述:这是最低的隔离级别。一个事务可以读取到另一个事务未提交的修改。

  • 现象:相当于“裸奔”,没有任何隔离。

  • 存在问题:脏读、不可重复读、幻读都会发生。

  • 应用场景:实际开发中极少使用。

2. 读已提交 (Read Committed - RC)

  • 描述:一个事务只能读取到已经提交的数据。

  • 实现原理MVCC(多版本并发控制)。每次执行 SELECT 语句时,都会重新生成一个 Read View(一致性视图)。

  • 存在问题:解决了“脏读”,但可能发生“不可重复读”(因为每次 Select 都生成新视图,如果中间有人提交修改,你就能看到)。

  • 备注:这是大多数主流数据库(如 Oracle, PostgreSQL, SQL Server)的默认隔离级别,但不是 MySQL 的默认值。

3. 可重复读 (Repeatable Read - RR) —— MySQL 默认

  • 描述:确保在同一个事务中,多次读取同样的数据结果是一样的。

  • 实现原理MVCC。与 RC 不同的是,RR 级别下,Read View 是在事务启动后的第一次查询时生成的,之后一直复用这个视图。

  • 存在问题:解决了“脏读”和“不可重复读”。

  • 关于幻读:在标准 SQL 定义中,RR 是无法解决幻读的。但是,MySQL 的 InnoDB 引擎通过 MVCC + Next-Key Lock 技术,在 RR 级别下很大程度上避免了幻读(正如我上一个回答所解释的)。

4. 串行化 (Serializable)

  • 描述:最高的隔离级别。它强制事务串行执行,通过强制对读取的数据行加锁(共享锁),避免了并发冲突。

  • 存在问题:解决了所有问题(脏读、不可重复读、幻读)。

  • 代价:并发性能极差,容易导致大量的超时和锁竞争。

  • 应用场景:只有在对数据一致性要求极高且并发量很小的场景下才会使用。


隔离级别对比总结表

隔离级别 脏读 不可重复读 幻读 性能 备注
Read Uncommitted ✅ 可能 ✅ 可能 ✅ 可能 极高 极少使用
Read Committed (RC) ❌ 无 ✅ 可能 ✅ 可能 Oracle/PG 默认
Repeatable Read (RR) ❌ 无 ❌ 无 ❌ (大部分解决) MySQL 默认
Serializable ❌ 无 ❌ 无 ❌ 无 强制排队,性能差

为什么可重复读的情况下不能避免幻读

数据在被修改(Update/Delete)的时候,必须基于“最新”的版本进行,而不能基于“历史”版本。

时间点 事务 A 事务 B 现象/旁白
1 BEGIN;
SELECT * FROM users WHERE id = 5;
结果:Empty (空)
事务 A 确认 id=5 还没人用。
2 BEGIN;
INSERT INTO users (id, name) VALUES (5, '小明');
COMMIT;
事务 B 抢先插入了 id=5 并提交。
3 SELECT * FROM users WHERE id = 5;
结果:Empty (空)
MVCC 生效。事务 A 依然看不到小明。一切看似正常。
4 UPDATE users SET name = '被修改' WHERE id = 5;
结果:Query OK, 1 row affected
关键点来了!
Update 是“当前读”,它能看到最新的提交。它不仅修改成功了,还把这条记录的“版本号”改成了事务 A 自己的。
5 SELECT * FROM users WHERE id = 5;
结果:id=5, name=‘被修改’
【幻读发生】
事务 A 吓死了:“我刚才查还没有 id=5,怎么我一更新,它就突然冒出来,还被我改了?”

注意,在第二步的时候,事务2已经插入并且提交了,这个时候数据库的内容发生了变化,后续步骤4进行update的时候就得根据最新的数据进行update,然后更新成功,并且,

假设:

  • 事务 A 的 ID = 100

  • 事务 B 的 ID = 200

幻读示例(自己修改或插入的数据,trx_id会变为自己的,可见)

阶段 1:事务 A 开启,生成 ReadView

  • 事务 A 执行第一条 SELECT。

  • 生成 ReadView,记录当前活跃事务。此时系统里只有 A。

  • ReadView 只要生成了,在 RR 级别下就永远不会变了。

阶段 2:事务 B 插入并提交

  • 事务 B 插入 id=5

  • 这行数据现在的状态是:{id: 5, name: '小明', trx_id: 200}

  • 事务 B 提交。

阶段 3:事务 A 第一次查询 id=5

  • 事务 A 拿着 ReadView 来看这行数据。

  • 发现数据的 trx_id200

  • ReadView 判断:200 是在我(100)开启之后才进来的“将来的人”。

  • 结论:不可见。

阶段 4:事务 A 执行 UPDATE(关键转折点!)

  • 动作UPDATE users SET name = '被修改' WHERE id = 5;

  • 当前读:正如之前所说,UPDATE 也是一种读,但它是“当前读”。它不看 ReadView,直接看物理磁盘。它看到了 trx_id=200 的数据(因为 B 已经提交,物理上存在)。

  • 修改数据:事务 A 把数据改了。

  • 打标签:这是最重要的一步!事务 A 把这行数据的 trx_id 更新成了自己的 ID(100)。

  • 现在的行数据变成了:{id: 5, name: '被修改', trx_id: 100}

阶段 5:事务 A 第二次查询 id=5

  • 事务 A 再次执行 SELECT。

  • ReadView 变了吗? 没有,还是那个旧的 ReadView。

  • 数据变了吗? 变了。

  • 事务 A 再次检查可见性:

    • 这行数据的 trx_id 是多少? -> 100

    • 当前事务 A 的 ID 是多少? -> 100

    • 触发规则 1trx_id 等于我自己。

    • 结论可见!

你之所以能看到,是因为你通过 UPDATE 操作,把这行数据的所有权**“抢”**过来了。

  • 修改前:这行数据属于事务 B(trx_id=200),对你是“未来数据”,不可见。

  • 修改后:这行数据属于事务 A(trx_id=100),对你是“自家数据”,无条件可见

ReadView 到底存了什么?

当你第一次执行 SELECT * FROM user 时,生成的 ReadView 是一张黑名单,长这样:

1
2
3
4
5
{
  "m_ids": [100, 101, 102],   // 当前全数据库里,所有还没提交的事务 ID
  "min_trx_id": 100,          // 最小活跃 ID
  "max_trx_id": 103           // 下一个要分配的 ID(水位线)
}

请注意: 这个列表里完全没有提 user 表或 student 表的名字。它只规定了谁是老数据(可见),谁是活跃数据(不可见)

这个规则对全库所有表通用。

MVCC简单的流程

事务在第一次select的时候会去检查当前活跃事务,然后会查看下一个要分配的事务id,然后记录下来,后面查询的时候会先去查询当前行数据的trx_id,查看数据的trx_id如果小于当前事务的id就应该直接使用这条数据,如果大于自己的事务id,就去查undo log,顺着undolog版本链找到真正自己能看到的数据,然后修改查询得到的数据。

MySQL的锁

共享锁

在sql语句末尾加上for share代表共享锁,所有事务都可以读,但是不能修改,直至事务提交

1
select * from uploaded_file where id = 1981221658045493248 for share;

排他锁

在sql语句末尾加上for update代表排他锁,其他事务不能修改,也不能读。

1
select * from uploaded_file where id = 1981221658045493248 for update;

表锁

  • 定义:最基本的锁策略,开销最小。它会锁定整张表。

  • 特点

    • 无死锁:因为一次性获取所有需要的锁。

    • 并发度低:一个用户在写,其他用户都不能读写。

  • 适用场景:主要由 MyISAM 引擎使用;InnoDB 在特定情况下(如没有索引或手动指定 LOCK TABLES)也会用到,但通常尽量避免。

意向锁

  • 痛点:假设事务 A 锁住了表中的某一行(行锁),此时事务 B 想申请整个表的写锁(表锁)。事务 B 怎么知道表里有没有人正在改数据?它必须遍历每一行去检查,效率极低。

  • 定义意向锁是表级锁

    • 当事务 A 想要给某一行加锁时,必须先给表加一个“意向锁”。

    • 这就像在写字楼门口挂个牌子:“楼里有人(意向锁)”。

  • 作用:事务 B 看到门口有“意向锁”的牌子,就知道表里有人在忙,直接等待,不用进去逐个房间(行)检查了。它主要用于快速判断表锁和行锁的冲突

行锁

  • 定义:InnoDB 的核心特性。只锁定被操作的那一行数据。

  • 特点

    • 并发度高:多个人可以同时修改同一张表的不同行。

    • 开销大:需要加锁、解锁,且容易发生死锁

  • 关键点InnoDB 的行锁是加在索引上的。如果你查询时没有用到索引,InnoDB 会退化为锁定整张表(虽然实现机制上是把所有行都锁了),导致并发性能大跌。

间隙锁

  • 含义:只锁住两个记录之间的**“缝隙”**,不包含记录本身。

  • 锁住范围(10, 20) —— 指的是大于 10 且小于 20 的范围。

  • 作用纯粹是为了防止插入。别人不能在这个范围内 INSERT 任何数据(比如插 15)。但他如果要修改 id=20 这行数据,Gap Lock 是不管的。

Next-Key Lock

假设数据库表中只有三行数据:id = 10, 20, 30

  • 含义:锁住一段间隙 + 锁住间隙右边的那个记录。

  • 锁住范围(10, 20] —— 左开右闭区间

  • 组成:即锁住了 (10, 20) 这个缝隙,也锁住了 20 这个记录。

  • 作用:既不准你在缝隙里插数据(防幻读),也不准你修改右边那个记录。

$Next\text{-}Key \ Lock = Gap \ Lock + Record \ Lock$

MDL锁

  • 定义:它不是用来锁数据的,而是用来锁**表结构(Schema)**的。

  • 触发机制(系统自动控制,用户无需显式调用):

    • MDL 读锁:当你对表进行增删改查(DML)时,自动加 MDL 读锁。

    • MDL 写锁:当你对表结构进行修改(DDL,如 ALTER TABLE)时,自动加 MDL 写锁。

  • 互斥关系

    • 读锁和读锁不互斥(大家可以一起查数据)。

    • 读写互斥、写写互斥。这意味着,如果有长事务正在查询数据(持有 MDL 读锁),你此时想给表加个字段(申请 MDL 写锁),会被阻塞。

  • 危险场景(MDL 风暴)

    1. Session A 开启事务查数据(持有 MDL 读)。

    2. Session B 想加字段(申请 MDL 写,被阻塞)。

    3. Session C 及其后的所有查询(申请 MDL 读),因为写锁优先级通常高于读锁,或者写锁在排队,导致后续所有查询全部堵塞。

  • 结果:数据库线程瞬间爆满,导致宕机。

索引下推是什么?

MySQL 处理 SQL 语句时,主要分为两层:

  1. Server 层(服务器层): 负责解析 SQL、优化器生成执行计划、调用存储引擎接口、并处理最终的数据过滤(WHERE 条件判断)。

  2. Storage Engine 层(存储引擎层,如 InnoDB): 负责真正的数据存储、提取,通过索引在磁盘上找数据。

“下推”的意思是: 把原本只能在 Server 层做的事情,推给 存储引擎层去做。

场景复盘

我们使用你的例子:

  • 表结构: user 表,有列 id (主键), name, age, address 等。

  • 索引: 联合索引 idx_name_age (name, age)

  • SQL:

    SQL

    1
    
    SELECT * FROM user WHERE name LIKE '王%' AND age = 20;
    

    (注意:这里是 SELECT *,意味着必须回表拿到 address 等其他字段,不能仅靠覆盖索引)

为什么这个场景特殊?

根据最左前缀原则

  1. name LIKE '王%' 是范围查询。索引能帮我们快速定位到所有姓“王”的人的起始位置

  2. 但是,一旦使用了范围查询(Range),索引后续的列(这里是 age)就无法用于定位(Seek)了。

    • 索引里的数据排序是:先按 name 排,name 相同才按 age 排。

    • 对于 王五王六,他们的 age 是无序的(相对于整个“王%”区间),所以引擎必须扫描所有姓王的数据。

虽然无法用来定位,但 age 的值确实存在于索引树的叶子节点中。ICP 的核心就在于是否利用了这个已有的数据。

详细对比:无 ICP vs 有 ICP

假设数据库里有 4 条记录,索引结构 (name, age) 如下:

  1. (王五, 10) —— 主键 ID: 1

  2. (王六, 20) —— 主键 ID: 2 <– 目标数据

  3. (王七, 30) —— 主键 ID: 3

  4. (张三, 20) —— 主键 ID: 4

❌ 阶段一:没有 ICP (MySQL 5.6 之前)

在没有 ICP 的时候,存储引擎认为自己的任务就是找所有 name 以“王”开头的数据。它会忽略 age = 20 这个条件,因为按老规矩,范围查询后的列“失效”了。

执行流程:

  1. Server 层告诉 InnoDB:“给我找所有 name LIKE '王%' 的记录。”

  2. InnoDB 在索引树上找到第一条 (王五, 10)

    • InnoDB 此时无视 age=10 不等于 20 这个事实。

    • InnoDB 拿着 ID: 1 去聚簇索引回表,取出整行数据。

    • InnoDB 把整行数据返回给 Server 层

  3. Server 层拿到数据,进行 WHERE 判断:age 是 20 吗?

    • 发现是 10,丢弃
  4. InnoDB 继续找下一条 (王六, 20)

    • 回表,取整行,返回给 Server。
  5. Server 层 判断:age 是 20 吗?

    • 是,保留
  6. InnoDB 继续找下一条 (王七, 30)

    • 回表,取整行,返回给 Server。
  7. Server 层 判断:age 是 20 吗?

    • 不是,丢弃

结果: 进行了 3 次回表,Server 层做了 3 次判断,最后只得到了 1 条数据。做了很多无用功(多回了 2 次表)。


✅ 阶段二:开启 ICP (MySQL 5.6 及以后)

MySQL 意识到:“嘿,InnoDB 兄弟,虽然你正在扫描索引,但我需要的 age 其实就在你手里的索引节点上。你能不能顺便帮我看一眼?如果 age 不对,你就别回表了,直接看下一个。”

这就是 Index Condition Pushdown:把 age = 20 这个条件下推到存储引擎层。

执行流程:

  1. Server 层告诉 InnoDB:“给我找 name LIKE '王%' 的记录,同时,如果 age 不等于 20,你就别给我了。”

  2. InnoDB 在索引树上找到第一条 (王五, 10)

    • InnoDB 直接检查索引上的值age 是 10。

    • 不符合 age = 20

    • InnoDB 直接跳过(不回表,不返回给 Server)。

  3. InnoDB 继续找下一条 (王六, 20)

    • 检查索引:age 是 20。

    • 符合!

    • InnoDB 拿着 ID: 2 回表,取出整行数据,返回给 Server。

  4. Server 层 再次确认(兜底),保留数据。

  5. InnoDB 继续找下一条 (王七, 30)

    • 检查索引:age 是 30。

    • 不符合,直接跳过

结果: 只进行了 1 次回表。I/O 操作大幅减少,性能提升。

特性 判断 age=20 的位置 处理 (王五, 10) 处理 (王六, 20) 处理 (王七, 30) 总回表次数
无 ICP Server 层 (回表之后) 回表 -> 丢弃 回表 -> 保留 回表 -> 丢弃 3 次 (慢)
有 ICP 存储引擎层 (回表之前) 索引层直接丢弃 回表 -> 保留 索引层直接丢弃 1 次 (快)

怎么看有没有用到 ICP?

你可以使用 EXPLAIN 命令查看执行计划。

1
EXPLAIN SELECT * FROM user WHERE name LIKE '王%' AND age = 20;
  • 如果输出的 Extra 列中包含:Using index condition

  • 这就说明 ICP 生效了

(注:如果 Extra 是 Using where,说明在 Server 层过滤;如果是 Using index,说明是覆盖索引,不需要回表,性能更好)

正常的联合索引(完美匹配)

假设索引还是 (name, age)SQL: SELECT * FROM user WHERE name = '王五' AND age = 20;

在这种情况下,都是等值查询,符合最左前缀原则。

  • 怎么执行? InnoDB 里的 B+ 树是严格排序的:先按 name 排,name 此时固定是 ‘王五’,那么里面的数据就是严格按 age 排序的。 InnoDB 不需要“先找王五,再遍历过滤 age”,而是直接根据 B+ 树的算法,一次性跳(Seek)(王五, 20) 这个节点的位置。

  • 谁在做? 存储引擎 (InnoDB)。它利用 B+ 树结构直接定位数据。

  • Server 层在干嘛? Server 层只是给 InnoDB 下达了一个指令:“把 name='王五' AND age=20 的数据给我。” InnoDB 说:“好的,我通过索引直接定位到了,这是数据。” Server 层拿到数据,甚至不需要再判断一次(但在代码实现逻辑上可能会做双重确认),直接发给客户端。


索引下推 ICP(范围查询导致断档)

这是我们刚才聊的场景。 SQL: SELECT * FROM user WHERE name LIKE '王%' AND age = 20;

  • 怎么执行? 因为 name 是范围,B+ 树只能帮你定位到“姓王的开始了”和“姓王的结束了”。在这个范围内,age 是乱序的(相对全局而言)。 InnoDB 不能直接跳到 age=20 的位置,只能从“王”的第一个数据开始扫描

  • ICP 的作用: 在扫描过程中,InnoDB 顺便看一眼索引里的 age。如果不符合,就不回表了。

  • 谁在做? 还是 存储引擎 (InnoDB)。但是这次它不是“直接定位”,而是“扫描 + 顺便过滤”。

Server层处理“无法下推”的条件

这是 Server 层更重要的工作。如果 SQL 中包含不在索引里的字段条件,InnoDB 是无能为力的,必须由 Server 层来做。

举个例子:

  • 索引: (name, age)

  • SQL:

    1
    2
    3
    4
    
    SELECT * FROM user
    WHERE name LIKE '王%'    -- 索引前缀(用于范围扫描)
      AND age = 20          -- 索引下推(ICP 在 InnoDB 过滤)
      AND address = '北京';  -- 索引里没有(Server 层过滤)
    

详情见下方流程

索引下推查询的全流程

场景设定

  • 表结构: user (id, name, age, address)

  • 索引: idx_name_age (name, age) —— 联合二级索引

  • SQL 语句:

  • 1
    2
    3
    4
    
    SELECT * FROM user 
    WHERE name LIKE '王%'    -- 索引范围查询 (Range)
      AND age = 20          -- 索引下推过滤 (ICP)
      AND address = '北京';  -- 普通条件 (Server 层过滤)
    

2. 详细执行流程 (Pipeline)

这个流程就像一个漏斗,每经过一层,数据就变少一点,性能就越高。

第一步:Server 层(准备阶段)

  1. 解析与优化: MySQL 优化器分析 SQL,发现可以使用 idx_name_age 索引。

  2. 生成计划: 虽然 name 是范围查询导致 age 无法用于定位,但优化器决定开启 ICP

    • 标志: EXPLAIN 中的 Extra 显示 Using index condition
  3. 下发指令: Server 层告诉 InnoDB:“去 idx_name_age 索引里找 name 以 ‘王’ 开头的数据。同时,把 age = 20 这个条件带上,如果不满足就别给我了。

第二步:InnoDB 存储引擎层(ICP 核心阶段)

  1. 定位游标: InnoDB 在二级索引 B+ 树上,找到第一个 name 匹配 '王%' 的叶子节点记录(假设是 王五, 10岁, ID:1)。

  2. 索引内过滤 (ICP Check):

    • InnoDB 不急着回表

    • 它先看手头索引元组里的 age

    • 情况 A(不匹配): 发现 age 是 10(不等于 20)。

      • 动作: 直接忽略该条记录,指针移向下一条。(省下一次回表 I/O)
    • 情况 B(匹配): 指针移向下一条(假设是 王六, 20岁, ID:2)。

      • 检查 age 是 20。符合条件!准备回表。

第三步:InnoDB 存储引擎层(回表阶段)

  1. 读取主键: 拿到符合 ICP 条件的记录的主键 ID:2

  2. 回表 (Table Lookup): 拿着 ID:2 去**聚簇索引(主键索引)**树里查找。

  3. 提取行数据: 从聚簇索引叶子节点里读取完整的行数据(包含 name, age, address 等所有列)。

  4. 返回数据: 把这行完整数据返回给 Server 层。

第四步:Server 层(最终兜底阶段)

  1. 接收数据: Server 层拿到 王六 的完整行数据。

  2. 二次确认 (Double Check): 尽管 InnoDB 过滤过,Server 层依然会校验 name LIKE '王%'age = 20(流程规范)。

  3. 补充过滤: Server 层检查 SQL 中剩下的、没法下推的条件 —— address = '北京'

    • 如果 王六 的 address 是 ‘上海’ -> 丢弃

    • 如果 王六 的 address 是 ‘北京’ -> 放入结果集

  4. 发送结果: 将最终通过的记录发送给客户端。


3. 流程总结图解

步骤 所在层级 处理内容 数据状态 (示例) 关键作用
1 Server 生成执行计划 指令:Scan idx, Filter age=20 开启 ICP
2 InnoDB 扫描二级索引 (王五, 10) -> ❌ 直接丢弃
(王六, 20) -> ✅ 保留
ICP 核心:减少回表
3 InnoDB 回表 (聚簇索引) 拿 ID:2 去找完整行数据 最耗时的 I/O 操作
4 Server 最终过滤 检查 address='北京' 处理非索引列逻辑

PriorityQueue的相关API

操作 复杂度 (Time Complexity) 备注
offer() / add() $O(\log N)$ 插入操作,需要维护堆的属性。
poll() / remove() $O(\log N)$ 移除最小/最大元素,需要重新堆化。
peek() / element() $O(1)$ 仅查看堆顶元素。
remove(Object o) $O(N)$ 移除任意元素,需要线性搜索。
方法 签名 描述
int size() public int size() 返回队列中元素的数量。
void clear() public void clear() 移除队列中的所有元素。
boolean isEmpty() public boolean isEmpty() 如果队列不包含任何元素,则返回 true
Comparator<? super E> comparator() public Comparator<? super E> comparator() 返回用于对此队列中元素进行排序的比较器,或者返回 null(如果使用自然顺序)。
Object[] toArray() public Object[] toArray() 返回包含队列中所有元素的数组。注意:返回的数组不保证是排序的。
Licensed under CC BY-NC-SA 4.0