2023 OceanBase 数据库大赛

比赛信息

使用 Docker 搭建开发环境

  • 启动报错 Docker Desktop - Unexpected WSL error,搜索半天解决方案,结果是没开 Windows Hypervisor Platform,解决方案在此
  • 如何在 CLion 中使用 Docker
  • 遇到 CLion 的 Bug,在 CLion 内置的 Docker 终端无法进行输入,我们直接使用 Docker 客户端打开终端。
  • 终端使用上下左右方向键显示奇怪的编码,似乎是因为默认使用的是 shell,它不支持方向键,我们可以使用 bash 来解决问题,解决方案
  • 使用 build 构建项目时,报错 $'\r': command not found,这是 Windows 的换行符和 Linux 不同导致的。为了避免在 clone 时进行换行符的转换,可以添加 autocrlf=false 到全局配置文件,或者可以在克隆的命令中添加该参数,更多详细的设置方法可以参考 GitHub 文档

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