Java 并发 & 虚拟机

基本概念

并发和并行:关于并发和并行的含义其实存在一些争议,但无非是定义不同。比较常见的说法,并发是指多个任务在单核 CPU 上分时执行,并行是指多个任务在多核 CPU 上同时执行。另一种说法,并发是指多个任务的执行在时间上重叠,在单核 CPU 上表现为分时执行,在多核 CPU 上表现为并行执行,并行是并发的子集。(当然还有其他说法,挺无聊的)另外,在单线程中也可以做到并发,例如 I/O 多路复用。

同步和异步:同步方法在方法执行完成之后才返回,异步方法可以在方法执行完成之前返回。异步方法只意味着非阻塞调用,并不一定是在另一个线程中执行。例如 C# 的 async 方法是在当前线程中执行,在遇到 await 时会将方法的剩余部分注册为延续continuation),然后将控制权返回到方法的调用者,同时返回一个 Task 类型的对象,此时方法的调用者可以执行其他不依赖于方法结果的操作,从而避免阻塞。在 await 任务完成之后,延续会在线程池线程或者原始线程中执行。(参考 Task asynchronous programming model

以上关于单线程中并发和异步的示例,在基于事件的系统中都有体现。事件循环在单个线程中接收和处理事件,通常使用 I/O 多路复用接收事件,使用异步 I/O 实现非阻塞。可以使用轮询判断异步 I/O 是否完成,更好的做法是使用 UNIX 信号在异步 I/O 完成时通知应用程序。如果要在异步 I/O 完成之后执行某些操作,通常会使用一种称为延续的数据结构,记录完成该事件需要的信息。(推荐阅读《OSTEP》第 33 章 基于事件的并发)

思考:什么是死锁?产生条件?如何预防(Prevention)、避免(Avoidance)和检测死锁?

死锁简单来说就是 ABBA 问题,即持有资源 A 的线程要获取资源 B,而持有资源 B 的线程也要获取资源 A。产生死锁的条件:互斥,持有并等待,非抢占,循环等待。死锁避免要求知道全局信息,根据信息来判断分配资源是否会发生死锁,例如银行家算法。死锁检测可以通过构建等待图,然后判断图中是否有环来实现。

死锁预防:① 消除循环等待:使用一致的加锁顺序(全序/偏序),例如根据锁的地址获取锁,或者根据对象的哈希值获取锁。② 消除持有并等待:在执行操作之前原子地获取所有锁,但是很难做到且会有性能问题。③ 消除非抢占:使用 tryLock + unlock 的方式获取锁,但是会有活锁问题(可以通过随机化等待时间来避免),以及需要回滚中间执行的操作。④ 消除互斥:使用非阻塞算法(利用 CAS 原子指令)。

并发包

同步器

AbstractQueuedSynchronizer(AQS)为实现依赖 FIFO 等待队列(以链表形式实现)的同步器提供框架。AQS 使用 int 类型的整数表示状态,使用获取(acquire)和释放(release)操作修改状态。状态以及获取和释放的含义由同步器决定:在 ReentrantLock 中表示重入次数;在 Semaphore 中表示剩余的许可数量;在 ReentrantReadWriteLock 中,高 16 位表示读锁计数,低 16 位表示写锁计数。AQS 内部使用 LockSupport 实现线程的阻塞和唤醒,内部使用类似信号量的许可机制(最多一个许可),即使在 unpark 之后调用 park 也不会导致阻塞。

相比 synchronized 关键字,ReentrantLock 提供可中断锁、定时锁和公平锁,以及支持等待多个条件,但是需要在 finally 中显示地执行解锁操作。公平锁和非公平锁的区别是能否插队,当有其他线程在队列中等待时,公平锁总是会将当前加锁线程入队,而非公平锁会尝试加锁,从而避免更多的上下文切换。

Semaphore 提供指定数量的许可,允许多个线程访问资源。ReentrantReadWriteLock 提供可重入的读写锁,允许在持有写锁的情况下获取读锁,从而实现锁降级(写锁降级为读锁),不支持锁升级(读锁升级为写锁),适用于读多写少的场景。不支持锁升级是因为,如果两个读线程同时进行升级(由于读锁是共享锁),则会发生死锁。

CountDownLatch 允许一个或多个线程等待,直到一组操作完成,计数不能被重置。CyclicBarrier 允许一组线程等待彼此到达屏障,当所有线程到达屏障之后,会执行设置的 barrierAction 动作,然后唤醒所有线程,重置计数器。以上提到的同步器只有 CyclicBarrier 基于 ReentrantLock 实现,其他都是直接基于 AQS 实现的。

线程池

创建线程的方式有三种:继承 Thread 类 + 重写 run 方法;实现 Runnable 接口;实现 Callable 接口,然后构造 FutureTask 对象,FutureTask 实现自 RunnableFuture 接口,所以也可以看作 Runnable 对象。

使用线程池的目的是复用线程,避免创建/销毁线程的开销,以及控制线程的数量。过多的线程会占用大量内存,而且不一定能提高系统的性能,因为 CPU 核心数有限(对于 CPU 密集型负载来说)。Executors 工具类提供创建线程池的工厂方法,可以创建固定/动态大小(ThreadPoolExecutor)、支持计划任务(ScheduledThreadPoolExecutor)、支持工作窃取和分解任务(ForkJoinPool)的线程池。

线程池通常会使用队列存放任务(Runnable 类型),队列可以分为同步队列、有界队列和无界队列。当线程池的线程数量达到设置的最大值,且队列已满(不是无界队列),则会执行拒绝策略。JDK 内置的拒绝策略有:抛出异常、丢弃当前任务、丢弃最早的任务、在当前线程执行该任务。

当线程池的线程数量超过 corePoolSize 时,空闲线程在超时之后被销毁。如果使用 ExecutorService 的 submit 提交任务,需要注意异常会被捕获到返回的 Future 对象中。如果没有任务,在 corePoolSize 范围内的线程会在获取任务时被阻塞队列阻塞。如果要终止线程,可以调用 shutdown 方法,然后可以调用 awaitTermination 等待。

并发容器

可以使用 Collections.synchronizedxxx() 方法获取线程安全的容器,但是获取的容器只是简单地对所有方法使用 synchronized 关键字来实现线程安全。CopyOnWriteArrayList 读操作不需要加锁,写操作加锁且不会修改原数组,而是执行写时复制(COW)。ConcurrentLinkedQueue 是非阻塞的无界队列,没有使用锁而是只用 CAS 实现线程安全。

ArrayBlockingQueue 是有界阻塞队列,使用单个的 ReentrantLock 实现线程安全,使用两个 Condition(notEmpty 和 notFull)实现阻塞等待,不过也可以调用非阻塞的方法(在队列满/空时直接返回 false/null)。LinkedBlockingQueue 是无界阻塞队列,使用两个 ReentrantLock(putLock 和 takeLock)实现线程安全,同样使用两个 Condition 实现阻塞等待。

可以使用 Unsafe 或者 VarHandle 实现 CAS 操作。AtomicInteger 使用 CAS 保证原子性,AtomicStampedReference 使用版本号解决 CAS 的 ABA 问题,AtomicIntegerArray 提供原子修改数组的方法,AtomicIntegerFieldUpdater 使用反射和 Unsafe 提供对 volatile 字段原子更新的方法。

如果要设计线程安全的哈希表,最简单的方式是使用 synchronized 关键字修饰所有方法,但是并发性很差。首先,可以想到减少锁的粒度,将单个独占锁分解为每个桶一个锁(结合 CAS 提高性能)。但是,计数操作会修改共享变量,如果使用独占锁将成为性能瓶颈。如果使用原子变量进行计数,在高并发下性能不会更好,由于缓存失效以及频繁的重试。解决方案依然是减少锁的粒度,可以使用类似 LongAdder 的做法。接下来可以思考扩容问题,多个线程辅助扩容可以加快速度,基本上 ConcurrentHashMap 就是这样设计的。

锁优化

思考:减少锁竞争的方式有哪些?

减小锁的范围,减小锁的粒度,读写锁/读写分离,线程私有。

ThreadLocal 的构造函数是空的,所以创建的对象没有和线程绑定,只有当调用 get/set 方法之后才会绑定。实际上,Thread 类有一个 ThreadLocalMap 类型的实例字段,set 方法会获取当前 ThreadLocal 对象的哈希值,使用 WeakReference 类型的数组存储 key/value(类似 WeakHashMap),key 就是 ThreadLocal 对象,value 就是 set 的参数。(该哈希表使用的是开放寻址法处理冲突)

通常所说的 ThreadLocal 存在内存泄露问题是指,当不再使用设置的 value 时(假设是很大的对象),如果存在该 ThreadLocal 的强引用,或者即使该 ThreadLocal 对象已经被回收,但是之后没有对 ThreadLocalMap 做操作,依然无法回收 value 对象。因为 ThreadLocalMap 的 Entry 对 value 有强引用,而只有在执行下一次 set/remove 操作时,该 Entry 以及 Entry 的 value 才会变为不可达的,所以最好在不使用时显式地执行 remove 操作。

思考:HotSpot 虚拟机对 synchronized 的优化有哪些?(详见《深入理解 Java 虚拟机》第 13 章)

自适应的自旋:动态调整自旋的时间,尽量避免重量级锁(互斥锁)的上下文切换开销,以及避免自旋过久占用 CPU 资源的开销。

锁消除:虚拟机在即时编译时,对不可能发生竞争的锁进行消除(依赖逃逸分析)。

锁粗化:如果连续地对相同对象加锁再解锁,会导致不必要的性能损耗,此时虚拟机会加大锁的范围(合并锁)。

轻量级锁:当目标对象没有被锁定,虚拟机会使用 CAS 获取对象的轻量级锁,目的是避免重量级锁的上下文切换开销。如果目标对象被轻量级锁定,那么自旋一段时间,超时之后升级为重量级锁。或者,如果有两个线程在等待获取轻量级锁,那么将锁升级为重量级锁。(轻量级锁本质上是复制对象的 Mark Word 到当前线程的栈帧中,然后使用 CAS 修改该对象的 Mark Word)

偏向锁:对象锁会偏向第一个加锁的线程,目的是减少无竞争情况下轻量级锁的 CAS 开销。即如果线程 A 对某个对象加锁(使用 CAS 修改 Mark Word),且该对象是第一次被加锁,那么线程 A 之后再次获取该对象锁时就不需要执行实际的加锁操作。但是,只要有其他线程尝试获取该对象的锁,那么偏向锁就会被撤销,变为未锁定或者轻量级锁定状态。(简单来说是这样,当然还有重偏向,以及如果调用过未重写的 Object::hashCode() 方法就不能偏向,之类的东西)

思考:轻量级锁和偏向锁优化什么情况下可以提升性能?什么情况下会降低性能?

轻量级锁和偏向锁都假设锁竞争发生的概率很小,如果真的发生(激烈的)锁竞争,那么轻量级锁和偏向锁很快就会升级为重量级锁,“优化”反而会额外增加轻量级锁 CAS 操作的开销以及撤销偏向的开销。

在 Java 15 中,偏向锁已弃用,详见 JEP 374: Deprecate and Disable Biased Locking。主要原因是偏向锁的实现代码复杂且具有侵入性,妨碍 HotSpot 虚拟机中同步系统设计的更改,以及在当前环境下偏向锁的适用范围有限。

内存区域

简单来说:程序计数器存储当前执行的字节码指令的地址;栈存储方法级别的数据,方法调用会创建栈帧,存储局部变量、返回地址等数据,有虚拟机栈和本地方法栈两种;堆存储对象数据;方法区也被称为类区,存储类级别的数据,包括类的字节码、静态变量和常量(在运行时常量池中)等数据。

思考:HotSpot 虚拟机使用 new 关键字创建对象的过程?对象在堆中的内存布局?

检查 new 指令的参数是否在运行时常量池中有对应的符号引用,然后检查该符号引用代表的类是否执行过类加载(区分类加载和加载,类加载包括加载、链接和初始化)。如果类加载完成,则为对象分配内存(使用 CAS + 重试保证原子性,或者使用线程私有内存),然后设置对象头。最后调用构造器执行初始化。

对象存储在堆中,由对象头、实例数据和对齐填充组成。对象头存储运行时数据(Mark Word)和指向类型元数据的指针。实例数据存储字段值,包括继承的字段,默认相同宽度的字段会被存放在一起,在该前提下父类字段会在子类之前。对齐填充的作用是内存对齐,HotSpot 虚拟机使用的是 8 字节对齐。

垃圾收集

基本概念

由于程序计数器、虚拟机栈和本地方法栈是线程私有的,占用的内存随方法返回或者线程结束而回收,所以不是垃圾收集器关注的重点。而堆和方法区是线程共享的,占用的内存何时回收是动态的,垃圾收集器会通过可达性分析判断什么对象可以回收。

最简单的想法是使用引用计数判断对象是否可以被回收,但是无法处理循环引用的问题。所以,通常是通过图的可达性分析来判断对象是否可以被回收,实际上就是维护一个可达的 GC Roots 的对象集,如果某个对象从 GC Roots 不可达,那么该对象就可以被回收。

引用类型:强引用、软引用、弱引用和虚引用。除强引用外的其他引用都对应一个继承自 Reference 抽象类的类,可以存储对其他对象的引用(称为 referent)。简单来说,强可达的对象不会被回收,软可达的对象会在内存溢出之前被回收,弱可达的对象会在下次垃圾收集时被回收,虚可达的对象已经是 finalized 的,不可达的对象可以被回收。(参考 java.lang.ref 文档)

思考:什么是 finalization 机制?finalize 方法为什么被弃用?

当对象不可达时,如果对象没有重写 finalize 方法,则对象可以直接被回收。如果对象已重写 finalize 方法且该方法没有被调用过,则对象是 finalizable 的。虚拟机的 Finalizer 线程会将该对象加入队列排队,等待调用其 finalize 方法,调用之后对象是 finalized 的。如果对象是 finalized 且依然不可达,那么对象就会被回收变为 reclaimed。

垃圾收集器至少需要两个周期才能回收 finalizable 对象,并且被该对象引用的所有不可达对象会被保留,直到该对象被回收。此外,JVM 不保证会调用所有 finalizable 对象的 finalize 方法。(推荐阅读 How to Handle Java Finalization’s Memory-Retention Issues

finalization 机制存在问题,会导致性能问题、死锁和挂起。finalizer 的错误可能导致资源泄露;没有办法取消 finalization;不同对象的 finalize 方法调用之间没有指定的顺序。此外,无法保证 finalization 的完成时间。finalize 方法只能在 finalizable 对象上经过不确定的延迟之后调用。(参考 finalize 文档)

回收算法

分代收集理论:假设对象存活概率随对象年龄(经历垃圾收集的次数)的增加而增加,且跨代引用很少发生,则可以根据对象年龄,将堆划分成不同的区域(例如,新生代和老年代),然后使用不同的频率进行回收。相对于扫描所有对象而言,能够有效减少时间开销。

类加载机制

类的生命周期有 7 个阶段,各个阶段通常是交叉混合执行的,解析阶段可能在初始化之后开始。通常所说的类加载是指加载、连接和初始化。简单来说,加载阶段将类加载到方法区,验证阶段验证字节码是否符合规范,准备阶段执行静态字段的默认初始化(有例外情况),解析阶段将常量池的符号引用替换为直接引用。初始化阶段执行编译器生成的类构造器 <clint>() 方法(不是对象构造器),包括静态字段的显示初始化和静态初始化块。

类加载器用于实现加载阶段的“通过类的全限定名获取该类的二进制字节流”动作。在虚拟机中,类由类加载器和类的全限定名唯一确定。如果相同的类被不同类加载器加载,那么 instanceof 的判断结果就是 false。双亲委派机制是指,类加载器总是会将加载请求委派给父类加载器(父子类加载器是组合关系而不是继承关系),只有当父类加载器无法完成该加载请求时,子类加载器才会尝试加载,从而确保核心类(例如 Object)的唯一性。

MySQL

参考《高性能 MySQL》和官方文档。使用 MySQL 的 Sakila 示例数据库,使用 MySQL 版本 8.0.41 + InnoDB 存储引擎。各种术语的官方定义可以看 MySQL 术语表,锁相关的内容可以看 InnoDB Locking,更多数据库概念可以看 CUM 15-445 课程总结。任何描述都是简化的,不会涵盖所有情况,更多细节只能看文档。或者说没有必要去记细节,任何细节都取决于实现,而实现会随着版本更新而变化,需要学习的是整体的策略。

基本概念

快照(snapshot):数据在特定时间的表示。一致性读consistent read):根据快照显示查询结果,也被称为一致性非锁定读。在读已提交和可重复读隔离级别下执行 SELECT 语句的默认模式是一致性读,也就是说会使用多版本并发控制MVCC)读取数据。读已提交在每次执行一致性读时都会重置快照,而可重复读只在第一次一致性读时建立快照。锁定读locking read):使用 SELECT ... FOR SHARE 或者 SELECT ... FOR UPDATE 读取数据,会加读锁或者写锁,在事务提交或者回滚时释放(2PL)。

MVCC 是使用撤销日志Undo Log)和读取视图Read View)实现的。事务在修改记录之后会记录撤销日志,事务的回滚指针会指向该日志。读取视图包含一致性读不可见的事务 ID(事务 ID 不会在启动事务之后立即分配),读取记录时会比较记录的事务 ID 和读取视图,如果该记录对当前事务不可见,则执行撤销日志直到达到可见状态。删除操作被视为修改操作,通过修改删除标志位实现,只有当该版本记录对所有事务不可见时,才会被真正删除。撤销日志也会被记录到重做日志Redo Log)中,实现崩溃恢复。

关于幻读的问题:DML 语句可以破坏快照,从而在可重复读隔离级别会出现幻读。(参考一致性读文档)如果将快照读和当前读混用,也会出现幻读。

1
2
3
4
5
6
7
8
9
10
11
SELECT COUNT(c1) FROM t1 WHERE c1 = 'xyz';
-- Returns 0: no rows match.
DELETE FROM t1 WHERE c1 = 'xyz';
-- Deletes several rows recently committed by other transaction.

SELECT COUNT(c2) FROM t1 WHERE c2 = 'abc';
-- Returns 0: no rows match.
UPDATE t1 SET c2 = 'cba' WHERE c2 = 'abc';
-- Affects 10 rows: another txn just committed 10 rows with 'abc' values.
SELECT COUNT(c2) FROM t1 WHERE c2 = 'cba';
-- Returns 10: this txn can now see the rows it just updated.

常用命令

1
2
3
4
5
6
7
8
9
10
SHOW FULL PROCESSLIST;
SHOW ENGINE INNODB STATUS;
SELECT * FROM performance_schema.data_locks\G
SHOW STATUS LIKE 'Last_query_cost';
SHOW [GLOBAL | SESSION] VARIABLES [LIKE 'pattern' | WHERE expr];
START TRANSACTION; BEGIN; COMMIT; ROLLBACK;
[CREATE | ALTER | DROP | OPTIMIZE | ANALYZE | CHECK | REPAIR ] TABLE tbl_name;
SHOW TABLE STATUS [LIKE 'pattern' | WHERE expr];
SHOW INDEX FROM tbl_name;
EXPLAIN [FORMAT=TREE | ANALYZE] select_statement; SHOW WARNINGS;

前缀索引

使用前缀索引可以减少索引的空间开销,内部节点的字符串比较会更高效,但是选择性会更低,那么匹配的主键会更多,从而需要更多次回表。所以需要选择合适的前缀长度,尽可能提高索引的选择性。索引的选择性是指不重复的索引值(基数,cardinality)和数据表的记录总数的比值。无法利用前缀索引执行分组、排序和覆盖索引扫描。

执行如下语句,city_demo 最终有 19200 条记录。

1
2
3
4
CREATE TABLE city_demo(city VARCHAR(50) NOT NULL);
INSERT INTO city_demo(city) SELECT city FROM city;
INSERT INTO city_demo(city) SELECT city FROM city_demo; -- 重复执行 5 次
UPDATE city_demo SET city = (SELECT city FROM city ORDER BY RAND() LIMIT 1); -- 随机化数据

查询出现次数最多的前 10 个城市,然后使用各种前缀测试索引的选择性。前缀长度为 7 比较合适,因为统计数量的偏差不算很大。

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
SELECT COUNT(*) AS c, city
FROM city_demo GROUP BY city ORDER BY c DESC LIMIT 10;
+----+-----------------+
| c | city |
+----+-----------------+
| 54 | Ondo |
| 51 | London |
| 49 | Olomouc |
| 49 | Pontianak |
| 47 | Kurgan |
| 46 | Almirante Brown |
| 46 | Changzhou |
| 46 | Funafuti |
| 46 | Jodhpur |
| 45 | Plock |
+----+-----------------+
SELECT COUNT(*) AS c, LEFT(city, 3) AS pref
FROM city_demo GROUP BY pref ORDER BY c DESC LIMIT 10;
+-----+------+
| c | pref |
+-----+------+
| 477 | San |
| 197 | Cha |
| 171 | Tan |
| 157 | al- |
| 152 | Sou |
| 148 | Bat |
| 146 | Sal |
| 146 | Shi |
| 126 | Kam |
| 125 | Val |
+-----+------+
SELECT COUNT(*) AS c, LEFT(city, 7) AS pref
FROM city_demo GROUP BY pref ORDER BY c DESC LIMIT 10;
+----+---------+
| c | pref |
+----+---------+
| 76 | San Fel |
| 66 | Valle d |
| 63 | Santiag |
| 54 | Ondo |
| 51 | London |
| 49 | Pontian |
| 49 | Olomouc |
| 47 | Kurgan |
| 46 | Jodhpur |
| 46 | Almiran |
+----+---------+

不能只根据前缀索引选择性的值确定前缀长度,例如前缀长度为 5 的选择性看上去很接近完整列的选择性,但是如果查看出现次数最多的前 10 个城市,和完整列的结果相比,会发现数据分布很不均匀。如果查询以 South 前缀的某个城市,那么回表的次数会更多。

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
SELECT COUNT(DISTINCT city) / COUNT(*) AS sel,
COUNT(DISTINCT LEFT(city, 3)) / COUNT(*) AS sel3,
COUNT(DISTINCT LEFT(city, 4)) / COUNT(*) AS sel4,
COUNT(DISTINCT LEFT(city, 5)) / COUNT(*) AS sel5,
COUNT(DISTINCT LEFT(city, 6)) / COUNT(*) AS sel6,
COUNT(DISTINCT LEFT(city, 7)) / COUNT(*) AS sel7
FROM city_demo;
+--------+--------+--------+--------+--------+--------+
| sel | sel3 | sel4 | sel5 | sel6 | sel7 |
+--------+--------+--------+--------+--------+--------+
| 0.0312 | 0.0236 | 0.0293 | 0.0305 | 0.0309 | 0.0310 |
+--------+--------+--------+--------+--------+--------+
SELECT COUNT(*) AS c, LEFT(city, 5) AS pref
FROM city_demo GROUP BY pref ORDER BY c DESC LIMIT 10;
+-----+--------+
| c | pref |
+-----+--------+
| 118 | South |
| 104 | Santa |
| 78 | Chang |
| 76 | San F |
| 69 | Toulo |
| 68 | Xi´an |
| 66 | Valle |
| 64 | Saint |
| 64 | Shimo |
| 63 | Santi |
+-----+--------+

多列索引

在 film_actor 上有主键索引 PRIMARY KEY (actor_id,film_id),有普通索引 KEY idx_fk_film_id (film_id),以下查询计划表示合并两个索引的查询结果。索引合并策略有时效果不错,但更多时候表明表的索引建得很槽糕。如果优化器需要对多个索引做相交操作(由于多个 AND 条件),那么查看是否可以使用多列索引进行优化。

如果优化器需要对多个索引做联合操作(由于多个 OR 条件),且索引的选择性不高时,通常会在缓存、排序和合并上消耗大量 CPU 和内存资源。然而,优化器不会将这些操作计算到查询成本中,优化器只关心随机页面读取,所以有时索引合并的性能还不如全表扫描。这还会影响并发的查询,此时使用 UNION 改写,将单个查询拆分为多个查询,可以避免单个查询的执行时间过长,影响其他并发的查询(由于 2PL 协议,单个查询会在提交时才释放锁,当然不同隔离级别有细微差别)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
EXPLAIN SELECT film_id, actor_id FROM film_actor
WHERE actor_id = 1 OR film_id = 1\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: film_actor
partitions: NULL
type: index_merge
possible_keys: PRIMARY,idx_fk_film_id
key: PRIMARY,idx_fk_film_id
key_len: 2,2
ref: NULL
rows: 29
filtered: 100.00
Extra: Using union(PRIMARY,idx_fk_film_id); Using where

聚簇索引

聚簇索引的每个叶子节点都包含主键值、事务 ID、用于事务和 MVCC 的回滚指针,以及所有的剩余列。二级索引的叶子节点存储主键值而不是物理指针,所以在移动主键索引中的记录时不需要修改二级索引。如果按照主键顺序插入行,聚簇索引不会发生页分裂,从而插入性能更高(减少磁盘 I/O)也不会产生内部碎片。不应该使用随机的主键(例如 UUID),如果没有按照主键顺序插入数据,可以使用 OPTIMIZE TABLE 命令重新组织表来消除内部碎片。

对于高并发负载,按主键顺序插入会产生较多竞争,可以通过更改 innodb_autoinc_lock_mode 配置来提升性能。简单来说,有传统、连续和交错三种锁定模式,不同模式会根据语句的不同使用互斥锁(Lock)或者轻量级锁(CAS)。

查询优化

可以根据慢查询日志来优化查询,使用 EXPLAIN 查看执行计划。基本准则:只选择需要的列,而不是使用 SELECT *,从而允许覆盖索引优化、减少时间(I/O)和空间开销。不要重复执行相同的查询,可以在应用层使用缓存。对于不是很重要的查询,可以将大查询分解为小查询,将查询的时间分散到一个时间段中,减少查询对服务器性能的影响(减少单次查询持有锁的时间,以及避免事务日志堆积)。例如:DELETE 大量数据,可以使用 LIMIT 分解执行;当中间查询结果能缓存和重用时,可以将连接查询分解,然后在应用层做连接。

应用 WHERE 条件的方式:① 将条件从服务器下推到存储引擎,直接在索引中使用条件过滤记录(索引条件下推,ICP),从而减少服务器访问存储引擎的次数,以及存储引擎访问基表的次数。② 使用覆盖索引获取记录(不需要回表),然后在服务器使用条件过滤记录。③ 回表之后,在服务器层使用条件。

MySQL 的局限性:无法将 UNION 外层的条件下推到内层。某些时候,等值传递会有问题(详细看书)。针对单个语句而言,无法利用多核并行执行查询,无法同时对某个表进行查询和更新(特指相关子查询)。

UNION

由于 UNION 查询无法使用到外层条件,所以需要手动将条件下推到 UNION 的各个子查询中。最好使用 UNION ALL 而不是 UNION,这样可以避免对临时表去重(UNION 查询总是会创建临时表)。

COUNT

COUNT(*) 统计行数,COUNT(expr) 统计非 NULL 值的数量。性能 COUNT(*)=COUNT(1)>COUNT(主键列)>COUNT(普通列),如下所示 COUNT(*) 实际上是 COUNT(0)。根据条件统计数量,可以使用 SUM(IF(expr, 1, 0)) 或者 COUNT(expr OR NULL)。如果允许使用近似值代替精确值,则可以去掉 WHERE 和 DISTINCT 之类的条件,优化查询性能。利用索引覆盖扫描优化性能,因为索引会比基表更小,减少 I/O 次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mysql> EXPLAIN SELECT COUNT(*) FROM film\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: film
partitions: NULL
type: index
possible_keys: NULL
key: idx_fk_language_id
key_len: 1
ref: NULL
rows: 1000
filtered: 100.00
Extra: Using index
1 row in set, 1 warning (0.00 sec)

mysql> SHOW WARNINGS\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select count(0) AS `COUNT(*)` from `sakila`.`film`
1 row in set (0.00 sec)

IN & EXIST

常见的说法是,在子查询数据较少时使用 IN,而在子查询数据较大时使用 EXIST。因为使用 IN 是不相关子查询,会创建临时表,然后在临时表中查找匹配的数据。而使用 EXIST 是相关子查询,会直接在内表中查找匹配的数据(多次执行子查询)。但是,实际上优化器会做优化,使用 IN 并不意味着就会创建临时表。下面查询所有没有交易记录的顾客信息,执行计划显示该查询被转化为相关子查询,会使用覆盖索引查找匹配的数据。(推荐阅读 Optimizing Subqueries with the EXISTS Strategy

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
mysql> EXPLAIN SELECT * FROM customer WHERE customer_id IN (
-> SELECT customer_id FROM payment
-> )\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: customer
partitions: NULL
type: ALL
possible_keys: PRIMARY
key: NULL
key_len: NULL
ref: NULL
rows: 599
filtered: 100.00
Extra: NULL
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: payment
partitions: NULL
type: ref
possible_keys: idx_fk_customer_id
key: idx_fk_customer_id
key_len: 2
ref: sakila.customer.customer_id
rows: 1
filtered: 100.00
Extra: Using index; FirstMatch(customer)
2 rows in set, 1 warning (0.00 sec)

mysql> SHOW WARNINGS\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `sakila`.`customer`.`customer_id` AS `customer_id`,`sakila`.`customer`.`store_id` AS `store_id`,`sakila`.`customer`.`first_name` AS `first_name`,`sakila`.`customer`.`last_name` AS `last_name`,`sakila`.`customer`.`email` AS `email`,`sakila`.`customer`.`address_id` AS `address_id`,`sakila`.`customer`.`active` AS `active`,`sakila`.`customer`.`create_date` AS `create_date`,`sakila`.`customer`.`last_update` AS `last_update` from `sakila`.`customer` semi join (`sakila`.`payment`) where (`sakila`.`payment`.`customer_id` = `sakila`.`customer`.`customer_id`)
1 row in set (0.00 sec)

mysql> EXPLAIN ANALYZE SELECT * FROM customer WHERE customer_id IN (
-> SELECT customer_id FROM payment
-> )\G
*************************** 1. row ***************************
EXPLAIN: -> Nested loop semijoin (cost=271 rows=599) (actual time=0.0621..2.97 rows=599 loops=1)
-> Table scan on customer (cost=61.2 rows=599) (actual time=0.048..0.401 rows=599 loops=1)
-> Covering index lookup on payment using idx_fk_customer_id (customer_id=customer.customer_id) (cost=0.25 rows=1) (actual time=0.00418..0.00418 rows=1 loops=599)

1 row in set (0.00 sec)

而使用 EXIST 也不意味着执行相关子查询,如果将 payment 中的索引 idx_fk_customer_id 删除,然后重新执行上述查询。执行计划显示,子查询会生成索引临时表,索引用于去重,然后外表使用该索引查找匹配的数据。如果临时表较小,则会使用 MEMORY 存储引擎创建内存临时表,否则使用 InnoDB 存储引擎创建磁盘临时表。(推荐阅读 Optimizing Subqueries with Materialization

1
ALTER TABLE payment DROP FOREIGN KEY fk_payment_customer, DROP INDEX idx_fk_customer_id;
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
mysql> EXPLAIN SELECT * FROM customer WHERE EXISTS (
-> SELECT 1 FROM payment WHERE customer.customer_id = customer_id
-> )\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: customer
partitions: NULL
type: ALL
possible_keys: PRIMARY
key: NULL
key_len: NULL
ref: NULL
rows: 599
filtered: 100.00
Extra: NULL
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: <subquery2>
partitions: NULL
type: eq_ref
possible_keys: <auto_distinct_key>
key: <auto_distinct_key>
key_len: 2
ref: sakila.customer.customer_id
rows: 1
filtered: 100.00
Extra: NULL
*************************** 3. row ***************************
id: 2
select_type: MATERIALIZED
table: payment
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 16086
filtered: 100.00
Extra: NULL
3 rows in set, 2 warnings (0.00 sec)

mysql> SHOW WARNINGS\G
*************************** 1. row ***************************
Level: Note
Code: 1276
Message: Field or reference 'sakila.customer.customer_id' of SELECT #2 was resolved in SELECT #1
*************************** 2. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `sakila`.`customer`.`customer_id` AS `customer_id`,`sakila`.`customer`.`store_id` AS `store_id`,`sakila`.`customer`.`first_name` AS `first_name`,`sakila`.`customer`.`last_name` AS `last_name`,`sakila`.`customer`.`email` AS `email`,`sakila`.`customer`.`address_id` AS `address_id`,`sakila`.`customer`.`active` AS `active`,`sakila`.`customer`.`create_date` AS `create_date`,`sakila`.`customer`.`last_update` AS `last_update` from `sakila`.`customer` semi join (`sakila`.`payment`) where (`<subquery2>`.`customer_id` = `sakila`.`customer`.`customer_id`)
2 rows in set (0.00 sec)

mysql> EXPLAIN ANALYZE SELECT * FROM customer WHERE EXISTS (
-> SELECT 1 FROM payment WHERE customer.customer_id = customer_id
-> )\G
*************************** 1. row ***************************
EXPLAIN: -> Nested loop inner join (cost=963672 rows=9.64e+6) (actual time=6.27..6.92 rows=599 loops=1)
-> Table scan on customer (cost=61.2 rows=599) (actual time=0.0789..0.422 rows=599 loops=1)
-> Single-row index lookup on <subquery2> using <auto_distinct_key> (customer_id=customer.customer_id) (cost=3241..3241 rows=1) (actual time=0.0107..0.0107 rows=1 loops=599)
-> Materialize with deduplication (cost=3241..3241 rows=16086) (actual time=6.18..6.18 rows=599 loops=1)
-> Table scan on payment (cost=1633 rows=16086) (actual time=0.245..3.54 rows=16044 loops=1)

1 row in set, 1 warning (0.01 sec)

JOIN

确保 ON 或者 USING 的列上有索引。确保 GROUP BY 和 ORDER BY 只涉及一个表中的列,从而允许利用索引优化(松散/紧密索引扫描、利用索引排序)。如果需要对聚合的结果做超级聚合,可以使用 GROUP BY xxx WITH ROLLUP(注意查看查询计划,确定是否有性能问题),也可以使用其他等价语句或者在应用层聚合。

上面执行 SHOW WARNINGS\G 都会显示半连接(semi join),所谓半连接就是从左表中查询和右表匹配的行。反连接(anti join)正好相反,从左表中查询和右表不匹配的行。下面的查询和上面的等价,但是更慢,没有使用半连接,在覆盖索引中使用 LIMIT 1 去重,在最后使用临时表去重(实际上没有必要,LIMIT 1 已经去重)。(推荐阅读 Optimizing IN and EXISTS Subquery Predicates with Semijoin and Antijoin Transformations

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
mysql> EXPLAIN SELECT DISTINCT customer.* FROM customer INNER JOIN payment USING(customer_id)\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: customer
partitions: NULL
type: ALL
possible_keys: PRIMARY
key: NULL
key_len: NULL
ref: NULL
rows: 599
filtered: 100.00
Extra: Using temporary
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: payment
partitions: NULL
type: ref
possible_keys: idx_fk_customer_id
key: idx_fk_customer_id
key_len: 2
ref: sakila.customer.customer_id
rows: 1
filtered: 100.00
Extra: Using index; Distinct
2 rows in set, 1 warning (0.00 sec)

mysql> SHOW WARNINGS\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select distinct `sakila`.`customer`.`customer_id` AS `customer_id`,`sakila`.`customer`.`store_id` AS `store_id`,`sakila`.`customer`.`first_name` AS `first_name`,`sakila`.`customer`.`last_name` AS `last_name`,`sakila`.`customer`.`email` AS `email`,`sakila`.`customer`.`address_id` AS `address_id`,`sakila`.`customer`.`active` AS `active`,`sakila`.`customer`.`create_date` AS `create_date`,`sakila`.`customer`.`last_update` AS `last_update` from `sakila`.`customer` join `sakila`.`payment` where (`sakila`.`payment`.`customer_id` = `sakila`.`customer`.`customer_id`)
1 row in set (0.00 sec)

mysql> EXPLAIN ANALYZE SELECT DISTINCT customer.* FROM customer INNER JOIN payment USING(customer_id)\G
*************************** 1. row ***************************
EXPLAIN: -> Table scan on <temporary> (cost=331..341 rows=599) (actual time=3.3..3.38 rows=599 loops=1)
-> Temporary table with deduplication (cost=331..331 rows=599) (actual time=3.3..3.3 rows=599 loops=1)
-> Nested loop inner join (cost=271 rows=599) (actual time=0.0668..2.13 rows=599 loops=1)
-> Table scan on customer (cost=61.2 rows=599) (actual time=0.0492..0.404 rows=599 loops=1)
-> Limit: 1 row(s) (cost=0.25 rows=1) (actual time=0.00271..0.00273 rows=1 loops=599)
-> Covering index lookup on payment using idx_fk_customer_id (customer_id=customer.customer_id) (cost=0.25 rows=1) (actual time=0.00262..0.00262 rows=1 loops=599)

1 row in set (0.00 sec)

LIMIT

如果偏移量很大,例如 LIMIT 10000, 20,那么会扫描 10020 条记录,而只有最后 20 条是有效的。如果行中有很多数据,那么 I/O 次数就会很多。MySQL 的 LIMIT OFFSET 似乎不会下推到索引,从而 10020 条记录都会回表查询。查询计划中确实没有下推,奇怪的是索引只会扫描 10020 条记录,说明索引是有 LIMIT OFFSET 信息的。(参考 Limit Offset 下推

优化方式:① 使用覆盖索引执行 LIMIT,索引中只包含必要的列(如 ORDER BY 的列,当然默认包含主键),那么 LIMIT 扫描的数据会减少很多,最后使用主键做连接得到完整数据。(不能使用 IN,因为 ERROR 1235 (42000): This version of MySQL doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery')② 如果只允许顺序翻页的话,通过记录上个页面的边界值,下次查询就可以使用该值定位到目标位置,而不会做无效的扫描。③ 一次性获取多页数据,在应用层缓存起来(类似缓存 I/O)。④ 其他方法,使用预先计算的汇总表,或者使用冗余表。

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
mysql> EXPLAIN ANALYZE SELECT * FROM payment ORDER BY customer_id LIMIT 10000, 20\G
*************************** 1. row ***************************
EXPLAIN: -> Limit/Offset: 20/10000 row(s) (cost=1633 rows=20) (actual time=11.6..11.6 rows=20 loops=1)
-> Sort: payment.customer_id, limit input to 10020 row(s) per chunk (cost=1633 rows=16086) (actual time=10.6..11.4 rows=10020 loops=1)
-> Table scan on payment (cost=1633 rows=16086) (actual time=0.434..5.5 rows=16044 loops=1)

1 row in set (0.01 sec)

mysql> EXPLAIN ANALYZE SELECT * FROM payment FORCE INDEX (idx_fk_customer_id) ORDER BY customer_id LIMIT 10000, 20\G
*************************** 1. row ***************************
EXPLAIN: -> Limit/Offset: 20/10000 row(s) (cost=3129 rows=20) (actual time=11.1..11.1 rows=20 loops=1)
-> Index scan on payment using idx_fk_customer_id (cost=3129 rows=10020) (actual time=1.14..10.8 rows=10020 loops=1)

1 row in set (0.01 sec)

mysql> EXPLAIN ANALYZE SELECT * FROM payment INNER JOIN (SELECT payment_id FROM payment ORDER BY customer_id LIMIT 10000, 20) AS lim USING(payment_id)\G
*************************** 1. row ***************************
EXPLAIN: -> Nested loop inner join (cost=3151 rows=20) (actual time=2.29..2.32 rows=20 loops=1)
-> Table scan on lim (cost=641..644 rows=20) (actual time=2.27..2.28 rows=20 loops=1)
-> Materialize (cost=641..641 rows=20) (actual time=2.27..2.27 rows=20 loops=1)
-> Limit/Offset: 20/10000 row(s) (cost=639 rows=20) (actual time=2.12..2.12 rows=20 loops=1)
-> Covering index scan on payment using idx_fk_customer_id (cost=639 rows=10020) (actual time=0.178..1.89 rows=10020 loops=1)
-> Single-row index lookup on payment using PRIMARY (payment_id=lim.payment_id) (cost=0.25 rows=1) (actual time=0.00204..0.00207 rows=1 loops=20)

1 row in set (0.00 sec)

mysql> EXPLAIN ANALYZE SELECT * FROM payment WHERE payment_id IN (SELECT payment_id FROM (SELECT payment_id FROM payment ORDER BY customer_id LIMIT 10000, 20) AS lim)\G
*************************** 1. row ***************************
EXPLAIN: -> Nested loop inner join (cost=35413 rows=321720) (actual time=11.3..15.9 rows=20 loops=1)
-> Table scan on payment (cost=1633 rows=16086) (actual time=0.382..6.93 rows=16044 loops=1)
-> Single-row index lookup on <subquery2> using <auto_distinct_key> (payment_id=payment.payment_id) (cost=646..646 rows=1) (actual time=460e-6..460e-6 rows=0.00125 loops=16044)
-> Materialize with deduplication (cost=646..646 rows=20) (actual time=2.07..2.07 rows=20 loops=1)
-> Table scan on lim (cost=641..644 rows=20) (actual time=2.06..2.06 rows=20 loops=1)
-> Materialize (cost=641..641 rows=20) (actual time=2.06..2.06 rows=20 loops=1)
-> Limit/Offset: 20/10000 row(s) (cost=639 rows=20) (actual time=2.05..2.06 rows=20 loops=1)
-> Covering index scan on payment using idx_fk_customer_id (cost=639 rows=10020) (actual time=0.095..1.82 rows=10020 loops=1)

1 row in set (0.02 sec)

NOT IN

从文本文件读取数据,num 列有 100 万个 1、100 万个 2 和 1 个 3,测试一下 NOT IN (1, 2) 是否会使用索引。EXPLAIN ANALYZE 的结果显示,查询被分为三个区间,这样就可以利用索引范围扫描。(推荐阅读 Optimizing INSERT StatementsMySQL EXPLAIN ANALYZEA must-know about NOT IN in SQL - more antijoin optimization)。

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
SET @@GLOBAL.local_infile = 1;
CREATE TABLE test (num TINYINT NOT NULL, dummy TINYINT NOT NULL DEFAULT 0);
LOAD DATA LOCAL INFILE 'data.txt' INTO TABLE test (num);
ALTER TABLE test ADD INDEX idx_num (num);

mysql> EXPLAIN SELECT * FROM test WHERE num NOT IN (1, 2)\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: test
partitions: NULL
type: range
possible_keys: idx_num
key: idx_num
key_len: 1
ref: NULL
rows: 3
filtered: 100.00
Extra: Using index condition
1 row in set, 1 warning (0.00 sec)

mysql> SHOW WARNINGS\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `sakila`.`test`.`num` AS `num`,`sakila`.`test`.`dummy` AS `dummy` from `sakila`.`test` where (`sakila`.`test`.`num` not in (1,2))
1 row in set (0.00 sec)

mysql> EXPLAIN ANALYZE SELECT * FROM test WHERE num NOT IN (1, 2)\G
*************************** 1. row ***************************
EXPLAIN: -> Index range scan on test using idx_num over (num < 1) OR (1 < num < 2) OR (2 < num), with index condition: (test.num not in (1,2)) (cost=3.38 rows=3) (actual time=0.0355..0.0383 rows=1 loops=1)

1 row in set (0.00 sec)

mysql> EXPLAIN SELECT * FROM test WHERE num NOT IN (3)\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: test
partitions: NULL
type: ALL
possible_keys: idx_num
key: NULL
key_len: NULL
ref: NULL
rows: 1996905
filtered: 50.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)

mysql> SHOW WARNINGS\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `sakila`.`test`.`num` AS `num`,`sakila`.`test`.`dummy` AS `dummy` from `sakila`.`test` where (`sakila`.`test`.`num` <> 3)
1 row in set (0.00 sec)

mysql> EXPLAIN ANALYZE SELECT * FROM test WHERE num NOT IN (3)\G
*************************** 1. row ***************************
EXPLAIN: -> Filter: (test.num <> 3) (cost=201305 rows=998453) (actual time=0.0239..1133 rows=2e+6 loops=1)
-> Table scan on test (cost=201305 rows=2e+6) (actual time=0.0222..976 rows=2e+6 loops=1)

1 row in set (1.26 sec)

Redis

参考 官方文档对象编码数据类型命令列表代码仓库(7.4.2),事务可编程性,《Redis 设计与实现》。

基本概念

Redis 服务器是事件驱动程序,主要使用单线程 + I/O 多路复用处理客户端请求。不过也会使用后台线程,例如,使用 UNLINK 命令可以异步删除大键(Redis 4.0),以及将网络 I/O 委托给其他线程来提高性能(Redis 6.0)。Redis 的瓶颈在内存和网络,CPU 很少成为 Redis 的瓶颈,而且由于多线程的复杂性,Redis 核心逻辑没有使用多线程设计。如果要提高 CPU 的利用率,可以在同一个机器中启动多个 Redis 实例,并将它们视为不同的服务器。

过期删除策略:定时删除会将 CPU 时间用在和当前任务无关的过期键上,影响服务器的响应时间。惰性删除检查当前处理的键是否过期,如果不对过期键执行命令就会导致内存泄露。定期删除结合前两种策略的优点,是时间和空间的折中。Redis 使用惰性删除 + 定期删除的策略,定期删除从一定数量的数据库中随机检查一定数量的键,如果过期就删除。

Key 淘汰策略:配置 maxmemory 来指定使用内存的上限,当超过该限制时会执行淘汰策略。概括一下:① 不淘汰键,此时只支持读命令,写命令会报错。② 使用 LRU、LFU 或者随机淘汰键。③ 使用 LRU、LFU 或者随机淘汰有过期时间的键,或者淘汰 TTL 最短的键。

Redis 使用的是近似 LRU,随机抽取少量的键,然后淘汰其中最久未被访问的键(Redis 3.0 会跟踪候选键来提高性能),不使用标准 LRU 的原因是会消耗更多内存(因为需要维护访问链表)。LFU 使用概率计数器(Morris 计数器)估计键的访问频率,结合衰减期以便计数器随时间减少,也使用上述采样方式淘汰访问频率最小的键。

通用命令

1
2
3
4
5
6
7
8
SELECT index
INFO [section [section ...]]
DEL key [key ...] | UNLINK key [key ...]
EXPIRE key seconds [NX | XX | GT | LT] | TTL key | TIME | PERSIST key
TYPE key | OBJECT ENCODING key | OBJECT IDLETIME key | OBJECT REFCOUNT key
SAVE | BGSAVE [SCHEDULE] | BGREWRITEAOF
WATCH key [key ...] | UNWATCH | MULTI | DISCARD | EXEC
EVAL script numkeys [key [key ...]] [arg [arg ...]]

数据结构

SDS

在 Redis 中,当无需修改字符串时,使用的是 C 字符串。否则,使用简单动态字符串(simple dynamic string,SDS)。相比 C 字符串有如下优点:① 获取长度的时间复杂度为 \(O(1)\)。② 自动扩容,空间预分配,不会自动缩容,但支持手动缩容。③ 二进制安全,不根据 \0 空字符判断字符串结束。

1
2
3
4
5
6
struct __attribute__ ((__packed__)) hisdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

ziplist

ziplist 是特殊编码的双向链表,非常节省内存。它存储字符串和整数值,其中整数被编码为实际整数,而不是一系列的字符。它允许在列表的任意一侧进行 push 和 pop 操作,该操作需要重新分配内存(realloc + memmove),时间复杂度和列表使用的内存量相关。

ziplist 的布局如下,<uint32_t zlbytes> 表示列表占用的字节数,<uint32_t zltail> 表示列表最后一个节点的偏移量,<uint16_t zllen> 表示列表中的节点数量(当节点数量超过 \(2^{16}-2\) 时,该值被设置为 \(2^{16}-1\),此时需要遍历整个列表才能知道有多少节点),<uint8_t zlend> 是列表的结束标识(被设置为 0xFF)。

1
<zlbytes> <zltail> <zllen> <entry> <entry> ...  <entry> <zlend>

entry 的布局如下,<prevlen> 表示前一个节点的长度(如果小于 254 字节,则占用单个字节,否则占用 5 个字节,第一个字节被设置为 0xFE,后四个字节表示长度),<encoding><entry-data> 的编码方式取决于节点的内容。添加/删除节点可能引发级联更新(Cascade Update),不过新版 Redis 会提前计算所需空间,时间复杂度是 \(O(n)\)。

1
2
3
<prevlen> <encoding> <entry-data>
<prevlen from 0 to 253> <encoding> <entry>
0xFE <4 bytes unsigned little endian prevlen> <encoding> <entry>

listpack

由于经常发生和 ziplist 的代码相关的崩溃报告,而 ziplist 实现复杂(主要源于级联更新)难以审计,所以对 ziplist 进行改进。listpack 的实现更紧凑、解析速度更快以及更易于审计和理解。listpack 的布局如下,由于没有使用偏移量,头部只占用 6 字节(ziplist 头部占用 10 字节)。依然使用 0xFF 单个字节作为结束标识,原因是这样可以很容易地识别列表的结束,而不需要额外保存末尾地址。由于没有使用偏移量,要快速定位最后一个元素实现反向遍历,很容易想到倒着存储元素的长度信息,element 的布局如下。

1
2
<tot-bytes> <num-elements> <element-1> ... <element-N> <listpack-end-byte>
<encoding-type><element-data><element-tot-len>

quicklist

quicklist 是以 ziplist 或者 listpack 为节点的双向链表,结合 linkedlist 插入/删除快速以及 ziplist/listpack 节省空间的特点。

intset

inset 存储整数集合(不包含重复项),按照从小到大的顺序存储在 contents 数组中。所有元素的编码方式取决于 encoding 字段,可以将元素编码为 16、32 和 64 字节。如果当前编码方式无法存储新元素,则会将所有元素的编码方式都升级为更大的类型(不会自动降级)。因为引发升级的元素总是比现有元素都小/大,所以该新元素会存放到列表开头/结尾。

1
2
3
4
5
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;

hashtable

ht_table 表示两个哈希表,一般只使用 ht_table[0],而 ht_table[1] 在扩容/缩容时使用。哈希表的大小是 2 的幂,扩容的大小为第一个大于等于 ht_used[0] * 2 的 \(2^{n}\),缩容的大小为第一个大于等于 ht_used[0] 的 \(2^{n}\)。扩容/缩容是渐进式的,在每次对哈希表执行操作时,会 rehash 一个桶(由 rehashidx 标识)。

扩容的时机:当没有在执行 BGSAVE 或者 BGREWRITEAOF 命令且负载因子大于等于 1,当在执行 BGSAVE 或者 BGREWRITEAOF 命令且负载因子大于等于 5。因为在持久化时会创建子进程,操作系统使用写时复制(COW)优化子进程的使用,所以如果存在子进程,服务器会尽可能避免扩容(避免修改共享的页面),从而避免写时复制的开销。缩容的时机:当负载因子小于 0.1 时自动缩容。

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
struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; /* Next entry in the same hash bucket. */
};

struct dict {
dictType *type;

dictEntry **ht_table[2];
unsigned long ht_used[2];

long rehashidx; /* rehashing not in progress if rehashidx == -1 */

/* Keep small vars at end for optimal (minimal) struct padding */
unsigned pauserehash : 15; /* If >0 rehashing is paused */

unsigned useStoredKeyApi : 1; /* See comment of storedHashFunction above */
signed char ht_size_exp[2]; /* exponent of size. (size = 1<<exp) */
int16_t pauseAutoResize; /* If >0 automatic resizing is disallowed (<0 indicates coding error) */
void *metadata[];
};

skiplist

skiplist 使用 zset 结构作为底层实现,由跳表和哈希表组成。跳表维护多层链表,支持正序/倒序遍历,节点负责存储字符串元素 ele 和浮点数分值 score,同时会维护该节点在各层中的下一个节点的跨度 span(用于快速计算排名)。程序根据幂次定律生成节点的层数,节点层高为 k 层的概率是 (1 - ZSKIPLIST_P) * (ZSKIPLIST_P) ^ (k - 1)(其中 ZSKIPLIST_P = 0.25)。 使用跳表可以提高 ZRANGE 和 ZRANK 的效率,使用哈希表可以提高根据字符串查找分值的效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* ZSETs use a specialized version of Skiplists */
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;

typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;

数据类型

1
2
3
4
5
6
7
8
9
struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
};

String

使用场景:存储字节序列(文本、序列化对象和二进制数组)或者充当计数器。默认最大可以存储 512 MB 的字符串。编码方式:① int,在保存 64 位有符号整数时使用,可以看作 Java 中的 long 类型。② embstr(embedded string),默认在字符串长度小于等于 44 字节时使用,该编码将 redisObject 和 sdshdr 存储在单个内存块中,不可修改。③ raw,该编码将 redisObject 和 sdshdr 存储在不同内存块中。

1
2
3
SET key value [NX | XX] [EX seconds | PX milliseconds]
GET key | MGET key [key ...]
INCR key | INCRBY key increment | INCRBYFLOAT key increment

List

使用场景:实现栈和队列。最多可以存储 \(2^{32}-1\) 个元素。编码方式:ziplist | listpacklinkedlistquicklist

1
2
3
4
5
[LPUSH | RPUSH] key element [element ...]
[LPOP | RPOP] key [count]
LREM key count element
LRANGE key start stop
LLEN key

Set

使用场景:存储唯一值,计算交集、并集和差集。最多可以存储 \(2^{32}-1\) 个元素。编码方式:intsetlistpackhashtable(值被设置为 NULL)。

1
2
3
4
5
6
SADD key member [member ...]
SREM key member [member ...]
SISMEMBER key member
SMEMBERS key | SSCAN key cursor [MATCH pattern] [COUNT count]
[SINTER | SUNION | SDIFF] key [key ...]
SCARD key

Hash

使用场景:存储键值对(对象)。最多可以存储 \(2^{32}-1\) 个键值对。编码方式:ziplist | listpackhashtable

1
2
3
4
HSET key field value [field value ...] | HSETNX key field value
HDEL key field [field ...]
HGET key field | HGETALL key | HSCAN key cursor [MATCH pattern] [COUNT count] [NOVALUES]
HLEN key

Sorted set(ZSet)

使用场景:排行榜、速率限制器。首先按照分数排序(浮点数),然后按照字典序排序(元素都是字符串)。编码方式:ziplist | listpackskiplist

1
2
3
4
5
ZADD key [NX | XX] [GT | LT] score member [score member ...]
ZREM key member [member ...]
ZRANGE key start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count] [WITHSCORES]
[ZRANK | ZREVRANK] key member [WITHSCORE]
ZCARD key | ZCOUNT key min max

持久化机制

持久化机制:① RDB(Redis Database),以指定的时间间隔创建数据的快照。② AOF(Append Only File),记录所有写命令。(不建议单独使用 AOF)③ No persistence,不持久化。④ RDB + AOF:组合使用 RDB 和 AOF。

相比 AOF,RDB 文件表示更紧凑,允许更快地重启(就是使用快照恢复数据)。而 AOF 文件会更大,Redis 会自动重写 AOF 来减少文件大小。在发生故障时 RDB 会丢失更多数据(因为是指定时间间隔持久化),而 AOF 默认每秒执行 fsync(也可以配置为不执行 fsync,让操作系统决定何时刷盘,或者每次查询都执行,但是会很慢),最多丢失一秒的数据。如果数据很大 fork 会占用 CPU 从而阻塞客户端命令,RDB 相比 AOF 执行 fork 的频率更高(RDB 必然会执行 fork,AOF 只有在重写时执行 fork)。

重写是根据当前数据记录写命令,而不是根据旧的 AOF 文件。在 Reids 7.0 之前,重写期间到达的所有写命令最终会写入磁盘两次,因为重写 AOF 的同时 AOF 操作也依然在执行(保证数据安全),所以写命令会写入旧文件,然后还会使用缓冲区记录写命令,等待 AOF 重写完成之后将其追加到新文件。在 Redis 7.0 之后,使用 Multi Part AOF 机制,AOF 文件被分为基本文件(最多一个)和增量文件(可能有多个)。基本文件表示重写 AOF 时的数据快照(RDB 或 AOF 格式),增量文件记录创建基本文件之后的增量更改。Redis 使用临时清单文件跟踪基本文件和增量文件,当 AOF 重写完成,Redis 执行原子切换使改临时清单文件生效。

应用场景

缓存数据

缓存穿透:频繁请求数据库中不存在的数据。解决方案:① 用户权限和参数校验,提前过滤无效请求。② 将无效键加入缓存同时设置较短的过期时间(区分无效值和正常空值),如果之后数据库中有相关数据,会导致短时间的不一致。或者可以在更新数据时删除缓存,从而保证一致性。③ 使用布隆过滤器(定期重建),一种概率数据结构,由一个位数组和多个哈希函数组成。插入元素会将所有哈希函数计算的索引位置置为 1。查询元素只要有一个计算出的索引位置不为 1,那么该元素就必定不在集合中。否则,有可能在集合中(由于哈希冲突,存在误报)。

缓存击穿:热点数据过期。解决方案:① 访问热点数据的同时重置过期时间。② 发现缓存失效时,加锁重建缓存,避免所有请求都查询数据库。其他线程会尝试加锁(使用 trylock,因为缓存重建完成之后就不需要互斥),失败之后休眠一段时间再查询缓存。③ 逻辑过期,不设置 TTL,而是在值中增加过期时间字段。如果查询缓存发现已经逻辑过期,依然加锁重建缓存,但是加锁失败的线程直接返回过期数据。

缓存雪崩:大量数据过期。解决方案:① 设计随机的过期时间(类似 Raft 里的随机选举超时),避免同时过期。

通用方案:① 缓存预热、多级缓存。② 为缓存业务添加限流(可以整体限流也可以根据用户 ID 限流)、降级(简化响应,保证核心功能稳定)和熔断(拒绝请求,防止故障扩散)策略。限流策略:固定窗口,对时间窗口内的请求计数,但是窗口切换时存在双倍流量的问题;滑动窗口,将窗口分为细粒度的时间片做计数,通过计算窗口内所有时间片计数的和来判断是否达到流量阈值,内存开销较大;漏桶算法,将请求放入桶中(排队),以恒定速率处理请求,不能处理突发流量;令牌桶算法,将令牌放入桶中(计数),以恒定的速率生成令牌,允许突发流量。③ 使用 Redis 集群提高服务可用性。

缓存一致性问题:① 更新数据不处理缓存,存在不一致的问题。② 更新数据时删除缓存。如果事务提交之后删除缓存,假设另一个请求已经读取到旧数据,但直到很久之后才设置缓存,也会存在不一致(可以延迟双删)。之所以删除缓存而不是更新缓存,是因为如果同时有多个请求更新相同的数据,更新缓存会有时序问题。③ 订阅 MySQL binlog(阿里 canal 组件,利用主从节点的日志复制),解析日志然后利用消息队列更新缓存(适合数据不过期的场景)。

分布式锁

使用 SET 命令获取分布式锁:① 设置过期时间,避免客户端宕机导致锁无法被释放。② 设置锁的持有者,在释放锁(删除键)时检查当前客户端是否持有锁,如果持有才执行释放操作(使用 Lua 脚本保证原子性)。避免客户端 A 持有的锁过期之后,释放客户端 B 持有的锁。③ 使用 Lua 脚本原子地执行 CAS 操作来解锁。

1
2
SET lock owner NX EX max-lock-time
EVAL ...script... 1 lock owner
1
2
3
4
5
6
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end