MySQL索引原理、优化与实战应用全解析
一、什么是索引索引是一种用来高效查询的数据结构。从数据结构层面简单来说它是从二叉树演变过来的。以主键 ID 为例小的放左边大的放右边。在它的非叶子节点存放的是一页页的指针指向下一页叶子节点存放的是真实数据。叶子节点存放满时会以中间元素进行向上分裂。在最底层的数据页它是一个双向链表对区间查询进行了一个优化。相比较于 B 树来说B 树的所有非叶子节点只存放指针叶子节点才存放数据因此 B 树的层级更浅、查询更稳定。二、物理存储与内存管理1. 物理磁盘层面表结构数据、索引数据、表数据都是存放在它底层的 .idb 文件中。2. 内存层面我们经常查询的数据会存放在 InnoDB Buffer Pool 中的缓冲池中。在 Buffer Pool 中会有一页一页一页 16K的数据页我们从磁盘中查询的数据会缓存到这些数据页中数据分为数据页和索引页。这种数据页分为三种状态空闲页还未被使用使用页已经存放数据脏页存放了数据被修改了但是没有立即刷新磁盘InnoDB 就是通过三个单向链表对这些数据页进行管理空闲页的空闲链表使用页的使用链表LRU 冷热分离链表它的目的就是淘汰冷数据保留热数据。大概实现机制是这样的整个链表分为冷热两个区域前面 70% 存放热数据后面 30% 存放冷数据。如果后边区域的冷数据在一秒内被查询两次就放进前面的热数据中。HashMap 结构以 SQL 为 key以缓存数据为 value这是对索引再次做了一个优化。三、索引使用与优化1. 建立索引的原则我们建立索引要针对经常使用的条件字段来建立索引比如说经常用的筛选条件。要注意的地方从数据分布来看像性别、类型、逻辑删除字段等离散程度不大就没有必要建立索引因为离散程度不高即使建立了索引也是全表扫描。类似于家庭住址的较长字段也没有必要建立索引。我们建立索引的时候要尽量建立联合索引能覆盖查询就覆盖查询避免回表扫描因为普通二级索引在它的最底层叶子节点存放的是数据行的主键 ID。2. 避免索引失效的情况使用索引查询的时候也要避免索引失效的情况范围查询尽量使用大于等于如果业务不允许我们在业务代码中进行自行处理。范围查询要放在条件的最右侧。如果使用 OR如果 OR 前面有索引列后面没有那么所有索引全部失效。3. 索引性能分析与优化如果接口上线后还是慢就要使用 EXPLAIN 计划查看索引是否失效有没有走索引。我们的索引级别大概分为const主键索引eq_ref唯一索引ref二级索引range范围查询一般而言要优化到范围查询以上。四、代码层面的优化1. MyBatis-Plus 使用优化我们一般是使用的 MyBatis-Plus比如说我们在对抓拍数据进行展示时需要一些额外的设备信息字段这个就要根据设备编码去查询。如果我们在代码中 for 循环一条一条查询就很慢要使用 IN 方法一条 SQL 查出全部再对数据进行处理。此外这些常用热数据可以使用 Redis 进行一个缓存达到一个提速效果。2. 并发查询优化再比如说我们一个接口里面需要查询多条 SQL 进行数据汇总可以使用线程池并发查询达到一个提速的效果。五、MVCC 与事务1. MVCC 版本控制链MVCC 版本控制链是基于数据行的三个隐藏字段当前事务 ID、上一个版本的指针、数据行的行 ID再加上 undo log 实现一个版本链控制再加一个快照读实现了读已提交和可重复读这两个隔离级别。下面通过一个 Mermaid 流程图展示事务 ID 为 100 和 200 的两个事务如何通过 undo log 版本链和 ReadView 机制实现可重复读隔离级别flowchart TD subgraph 初始数据行 A[行记录 nameAlice trx_id50 roll_pointernull] end subgraph 事务100更新 B[事务100 开始 trx_id100] C[生成新版本 nameBob trx_id100 roll_pointer→旧版本] D[旧版本写入undo log nameAlice trx_id50 roll_pointernull] B -- C C -- D end subgraph 事务200更新 E[事务200 开始 trx_id200] F[生成新版本 nameCharlie trx_id200 roll_pointer→事务100版本] G[事务100版本写入undo log nameBob trx_id100 roll_pointer→事务50版本] E -- F F -- G end subgraph 版本链 H[最新版本 trx_id200 nameCharlie] I[中间版本 trx_id100 nameBob] J[旧版本 trx_id50 nameAlice] H --|roll_pointer| I I --|roll_pointer| J end subgraph 事务100的ReadView K[事务100 第二次查询 ReadView: m_ids[100,200] min_trx_id100 max_trx_id201] L[遍历版本链 trx_id200 max_trx_id? 不可见 trx_id100 自身? 可见 返回 trx_id100 的版本 nameBob] K -- L end subgraph 事务200的ReadView M[事务200 第二次查询 ReadView: m_ids[100,200] min_trx_id100 max_trx_id201] N[遍历版本链 trx_id200 自身? 可见 返回 trx_id200 的版本 nameCharlie] M -- N end H -- K H -- M在上图中事务 100 和事务 200 各自拥有独立的 ReadView。当事务 100 第二次查询时它沿着 undo log 版本链从最新版本开始遍历发现 trx_id200 大于等于 max_trx_id不可见继续向下找到 trx_id100 等于自身事务 ID因此读到的是自己更新后的版本name“Bob”。而事务 200 第二次查询时trx_id200 等于自身直接返回自己更新的版本name“Charlie”。两个事务各自读到的是自己事务开始时的快照互不干扰从而实现了可重复读隔离级别。2. Spring 事务注解失效场景Spring 事务注解 Transactional 失效的三种情况情景一内部调用导致事务失效情景二方法权限限制引起失效情景三异常捕获导致事务失效六、性能调优与架构设计1. 数据量优化表中数据什么时候最佳B 树高三层两千两百万性能最佳如果达到五千万以上就要开始考虑分表了分表的时候如何找到对应数据——哈希寻址算法。2. 线程池优化多线程以线程池为例我们创建一个线程池new 一个核心线程数为 16最大线程数为 80。当过来一个任务的时候交给一个线程去执行。如果核心线程都在工作中那么新来的任务就存放在队列中。如果队列存放满了再去创建临时线程直到达到最大线程数为止。一般我们项目中使用线程池分为静态线程池和临时线程池静态线程池常存于 JVM 内存中使用完不用关闭它就是一个 static 修饰的单例。一般用于会经常执行任务的场景比如说我们社会面数据接入线程池频繁的拉取 MQ还有一些搜索接口需要使用多线程进行数据汇总。临时线程池我们项目中一般用来进行批处理比如说我们的数据接入的很多很杂那么就存在一些脏数据字段使用 XXL-Job 进行定时任务数据清洗。那么定时任务开始时创建一个线程池最后执行完之后关闭掉。要注意的是它的 shutdown 方法它不是立马关闭线程池而是停止继续提交任务等到线程池内任务执行完毕后关闭线程池。如果说我们调用了第三方 HTTP 接口如果没有设置请求时间就可能会导致线程的长时间卡死浪费资源。3. 线程数设置原则之前听说过一个问题线程不是 new 的越多越好因为频繁的上下文切换会导致 CPU 的资源浪费。然后就写一些批处理任务的时候核心线程数直接设置一两百。后来在网上查了一些资料2000 年的时候当代 CPU 两个线程上下文的切换大约是 6-8 微秒2020 年左右的时候两个线程上下文切换只要 3-5 微秒那么也就是说一千个线程上下文切换也就占用几毫秒。只要不是系统内线程多的太夸张那么我们根据任务场景去适当的多建立一些线程问题也不大。因为我们业务场景中都是一些 IO 密集型任务比如说查一下数据库或者是调用第三方 HTTP 接口线程处于一直在等待的状态对于 CPU 没有太大的损耗。4. 线程池参数设置核心参数怎么设置我们公司有一套公式核心线程数 CPU 核心线程数 / 2我们的服务器一般是八核也就设置十六个核心线程最大线程数 CPU 核心线程数 × 10一般就设置为 80任务队列长度一般默认设置为 100拒绝策略默认抛出异常当然了要根据任务场景去灵活的判断你的队列应该设置多长采用什么样的拒绝策略四大拒绝策略。线程池是用哪个创建的new ThreadPoolExecutor()import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadPoolConfig { // 假设服务器为 8 核 CPU private static final int CPU_CORES Runtime.getRuntime().availableProcessors(); // 核心线程数 CPU 核数 / 2 private static final int CORE_POOL_SIZE CPU_CORES / 2; // 最大线程数 CPU 核数 × 10 private static final int MAX_POOL_SIZE CPU_CORES * 10; // 任务队列长度 private static final int QUEUE_CAPACITY 100; // 空闲线程存活时间秒 private static final long KEEP_ALIVE_TIME 60L; public static ThreadPoolExecutor createIoThreadPool() { return new ThreadPoolExecutor( CORE_POOL_SIZE, // 核心线程数48核/2 MAX_POOL_SIZE, // 最大线程数808核×10 KEEP_ALIVE_TIME, // 空闲线程存活时间 TimeUnit.SECONDS, // 时间单位 new ArrayBlockingQueue(QUEUE_CAPACITY), // 任务队列容量100 new ThreadPoolExecutor.AbortPolicy() // 拒绝策略抛出RejectedExecutionException ); } }以上代码根据文中公式动态获取 CPU 核数自动计算出核心线程数和最大线程数并设置了 100 容量的任务队列和默认的 AbortPolicy 拒绝策略。实际使用时可根据业务场景调整队列长度和拒绝策略。七、实战场景应用1. xx模块xx 模块对接第三方分页接口对方提供的字段可能有缺失或者有脏数据我们需要集成本地的数据或者第三方 HTTP 接口提供的数据进行一个替换使用线程池进行一个并发的调用。2. xx系统xx 项目需要展示第三方提供的一些 xx 等相关信息。接口和接口之间有依赖性在这里我用到了 CompletableFuture 然后进行了一个任务编排起到了一个提速的效果。比如说有 ABC 三个任务C 要依赖于 A 的结果才能执行。如果不用 CompletableFuture那么我们要等 AB 执行完拿到结果之后才能去执行 C。而使用 CompletableFuture 可以等 A 的结果执行完毕之后自动去回调 C 任务它就是起到了一个这样加速的效果。八、锁机制对比synchronized 和 Lock 的区别synchronized 是 Java 的内置锁基于操作系统的互斥锁实现的悲观锁可重入属于重量级锁。Lock 是基于 AQS 实现的它具有设置锁过期时间、中断锁等功能。比如说两个线程出现死锁问题我们可以设置一个锁的过期时间来解决死锁问题。它相比于 synchronized 来说 API 更加丰富、更加灵活。