实战技巧:使用冗余查询条件解锁MySQL中的索引

在使用 MySQL(或者任何其他数据库)时,了解索引的工作原理以及它们如何改善查询效率是至关重要的。索引是一种独立的数据结构,它维护了部分数据的副本,并通过结构化的方式实现快速数据检索。通常,这种结构是一个 B+ 树。

关于B+树可参考《MySQL之进阶:一篇文章搞懂MySQL索引之B+树》一文。

索引的"隐式屏蔽"

创建索引只是工作的一部分,你还需要知道如何编写查询,使得 MySQL 能高效地利用这些索引。编写查询时的一个常见错误之一是"隐式屏蔽"了索引。所谓隐式屏蔽索引,意味着你使得 MySQL 无法接触到索引值,从而失去利用索引的能力。

假设你有一个名为 todos 的表,其中包含一个 created_at 列,用于记录记录被创建的时间戳:

sql 复制代码
CREATE TABLE `todos` (
  `id` int NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `created_at` (`created_at`)
)

在这个表中,我们为 created_at 列添加了一个索引,以便可以迅速根据时间戳进行过滤。当我们针对 created_at 列执行查询,以查找最近 24 小时内创建的记录时,可以看到 MySQL 正如预期地使用了该索引:

sql 复制代码
mysql> EXPLAIN SELECT * FROM todos WHERE created_at > NOW() - INTERVAL 24 HOUR \G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: todos
   partitions: NULL
         type: range
possible_keys: created_at
          key: created_at
      key_len: 4
          ref: NULL
         rows: 1
     filtered: 100.00
        Extra: Using index condition

然而,如果我们将该列包装进某个函数中,就会屏蔽该列,导致 MySQL 无法再使用索引:

yaml 复制代码
mysql> EXPLAIN SELECT * FROM todos WHERE YEAR(created_at) = 2023 \G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: todos
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 1
     filtered: 100.00
        Extra: Using where

通过将 created_at 列包装进一个 YEAR 函数,我们实际上要求 MySQL 对 YEAR(created_at) 执行索引查找,而这是 MySQL 不支持的索引类型。MySQL 只维护对 created_at 列的索引。

在某些情况下,存在绕过索引屏蔽的方法。在之前的例子中,我们可以使用范围扫描(range scan),而不是 YEAR 函数来实现同样的结果:

sql 复制代码
mysql> EXPLAIN SELECT * FROM todos WHERE created_at BETWEEN '2023-01-01 00:00:00' AND '2023-12-31 23:59:59' \G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: todos
   partitions: NULL
         type: range
possible_keys: created_at
          key: created_at
      key_len: 4
          ref: NULL
         rows: 1
     filtered: 100.00
        Extra: Using index condition

通过移除函数包装,并将比较改为范围扫描,我们解锁了索引,让 MySQL 可以有效地使用它。

不幸的是,在某些情况下,你无法去除对列的函数包装。如果你不能避免包装列,可以尝试添加一个冗余条件,以便潜在地解锁现有索引。

MySQL 中的冗余条件

所谓冗余条件是那些看起来冗余、多余、不必要的条件。这种条件的添加或移除不会改变 MySQL 返回的结果集。

来看一个简单的例子来说明这一点。在此例中,我们选择 id 小于 5 的所有记录:

sql 复制代码
SELECT
  *
FROM
  todos
WHERE
  id < 5

在这种情况下,一条冗余条件可能是 id < 10

sql 复制代码
SELECT
  *
FROM
  todos
WHERE
  id < 5
  AND
  id < 10 -- 这条条件没有任何作用

这是一个冗余条件,因为它不会改变结果!id 小于 5 的记录必然也小于 10。因此,无论添加或移除它,结果都不会发生变化。显然,这里的冗余条件没有任何好处。

接下来,我们扩展一下 todos 表定义,添加一些新的列,比如 due_datedue_time(将日期和时间分开存储通常不是推荐做法,但为了说明问题,这么做是有帮助的):

r 复制代码
CREATE TABLE `todos` (
  `id` int NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `due_date` date NOT NULL,
  `due_time` time NOT NULL,
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `due_date` (`due_date`),
  KEY `created_at` (`created_at`)
)

基于这个表,如果你想查询在未来一天内到期的 todos,你可能会使用 ADDTIME 函数:

scss 复制代码
SELECT
  *
FROM
  todos
WHERE
  ADDTIME(due_date, due_time) BETWEEN NOW() AND NOW() + INTERVAL 1 DAY

虽然我们对 due_date 列设置了索引,但是这个索引无法被使用,因为我们对它进行了操作(加上 due_time)。不同于前面使用范围扫描的例子,此例中由于每行的 due_time 各不相同,我们无法直接移除函数包装。

我们运行一个 EXPLAIN,确认索引没有被使用:

sql 复制代码
mysql> EXPLAIN SELECT
    ->   *
    -> FROM
    ->   todos
    -> WHERE
    ->   ADDTIME(due_date, due_time) BETWEEN NOW() AND NOW() + INTERVAL 1 DAY \G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: todos
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 1
     filtered: 100.00
        Extra: Using where

为了解决这个问题,我们可以为 due_date 本身添加一个冗余条件。添加的条件必须逻辑上保证不会改变结果集,因此我们的冗余条件应该比实际条件更宽泛。

既然我们正在寻找未来 24 小时内到期的 todos,我们可以添加一个条件,查找今天或明天到期的 todos。这将包含我们所需的所有记录,以及一些并不需要的额外记录。

sql 复制代码
mysql> EXPLAIN SELECT
    ->   *
    -> FROM
    ->   todos
    -> WHERE
    ->   -- 实际条件
    ->   ADDTIME(due_date, due_time) BETWEEN NOW() AND NOW() + INTERVAL 1 DAY
    ->   AND
    ->   -- 冗余条件
    ->   due_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL 1 DAY \G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: todos
   partitions: NULL
         type: range
possible_keys: due_date
          key: due_date
      key_len: 3
          ref: NULL
         rows: 1
     filtered: 100.00
        Extra: Using index condition; Using where

这个冗余条件返回了一个比我们所需的更大的结果集,但重要的是,它允许 MySQL 使用索引。运行 EXPLAIN 后,我们可以看到 due_date 索引被使用了。

MySQL 会首先使用索引排除表中的大部分记录,然后利用较慢的 ADDTIME 对剩余记录进行过滤。冗余条件在这里完美地发挥了作用!

基于领域知识的冗余条件

上面我们讨论的冗余条件都是逻辑上不会改变结果集的。这类条件很好,因为它们容易推理,并且不需要额外的领域知识。

然而,作为人类,你可能拥有比数据库更高的领域知识。在某些情况下,你可以利用这种知识,添加不一定逻辑上无法改变结果,但你知道在实际场景中它不会改变结果的冗余条件。

仍然以 todos 表为例,我们添加一个 updated_at 列,用于存储记录最后一次更改的时间戳:

sql 复制代码
CREATE TABLE `todos` (
  `id` int NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `created_at` (`created_at`)
)

在这种场景下,我们仍然只有对 created_at 列的索引,但如果我们需要查询 updated_at,可以利用应用程序的领域知识添加冗余条件。

如果结合对应用程序的理解,我们可以确定 created_at 永远早于或等于 updated_at,那么我们可以利用这一点来改写查询。

这条查询会扫描整个表,因为 updated_at 没有索引:

sql 复制代码
SELECT
  *
FROM
  todos
WHERE
  updated_at < '2023-01-01 00:00:00'

这条查询虽然结果不变,但利用了 created_at 索引来剔除记录,然后过滤掉伪正例:

sql 复制代码
SELECT
  *
FROM
  todos
WHERE
  updated_at < '2023-01-01 00:00:00'
  AND
  created_at < '2023-01-01 00:00:00'

这个冗余条件之所以有效,是因为我们知道记录不能在创建之前被修改。根据你的应用场景,你可能会发现更多类似的"基于领域知识的冗余条件"。

何时使用冗余条件

最佳的索引策略总是依赖于应用场景,但通常情况下,最好为你频繁查询的条件建立索引。冗余条件的优势在于它不需要对数据库进行任何修改!你可以只修改查询或生成查询的应用程序,就能显著提升查询效率。因此,这种方法非常适合偶尔使用的查询,或者主查询条件无法轻易添加索引的情况。

相关推荐
CryptoRzz4 小时前
越南k线历史数据、IPO新股股票数据接口文档
java·数据库·后端·python·区块链
专注写bug4 小时前
Springboot——使用shyiko监听mysql的bin-log
mysql·binlog
学Java的bb4 小时前
MybatisPlus
java·开发语言·数据库
重生之我要当java大帝4 小时前
java微服务-尚医通-编写医院设置接口上
java·数据库·微服务
Mu.3874 小时前
初始Spring
java·数据库·spring
葡萄城技术团队4 小时前
突破Excel局限!SpreadJS让电子表格“活”起来
java·数据库·excel
J总裁的小芒果4 小时前
SQL Server 报错 当 IDENTITY_INSERT 设置为 OFF 时,不能为表 ‘ORDER_BTN‘ 中的标识列插入显式值
数据库
神的孩子都在歌唱4 小时前
PostgreSQL 向量检索方式(pgvector)
数据库·人工智能·postgresql