MySQL
January 11, 2021

Оптимизация OFFSET в MySQL

Штука вроде очевидная, но всё же стоит её записать.

В базах данных offset работает так: выбираются первые записи, где кол-во выбираемых записей равно offset+limit, потом записи до offset отбрасываются и остаток уже возвращается в запросе. Покажу на примере.

Как всегда, любимая табличка mobile_stat_device

mysql> show create table mobile_stat_device\G
*************************** 1. row ***************************
       Table: mobile_stat_device
Create Table: CREATE TABLE `mobile_stat_device` (
  `id` int unsigned NOT NULL AUTO_INCREMENT,
  `deviceUuid` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Unique device ID',
  `deviceUuidHash` int unsigned GENERATED ALWAYS AS (crc32(`deviceUuid`)) STORED NOT NULL,
  `createdAt` datetime NOT NULL COMMENT 'Inserting date',
  PRIMARY KEY (`id`),
  UNIQUE KEY `deviceUuid_udx` (`deviceUuidHash`,`deviceUuid`),
  KEY `deviceUuidHash_idx` (`deviceUuidHash`)
) ENGINE=InnoDB AUTO_INCREMENT=2490316 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci

С большим количеством записей

mysql> select count(1) from mobile_stat_device;
+----------+
| count(1) |
+----------+
|  2097152 |
+----------+

Запрос, который выполняется почти секунду:

mysql> select SQL_NO_CACHE * from mobile_stat_device order by id limit 10 offset 2050000;
+---------+--------------------------------------+----------------+---------------------+
| id      | deviceUuid                           | deviceUuidHash | createdAt           |
+---------+--------------------------------------+----------------+---------------------+
| 2377645 | 370f4d5e-519b-11eb-a85f-42010a9c0002 |     1792791173 | 2021-01-08 10:21:03 |
| 2377646 | 370f4d6c-519b-11eb-a85f-42010a9c0002 |     3206227716 | 2021-01-08 10:21:03 |
| 2377647 | 370f4d7b-519b-11eb-a85f-42010a9c0002 |     3766230312 | 2021-01-08 10:21:03 |
| 2377648 | 370f4d89-519b-11eb-a85f-42010a9c0002 |     2525345549 | 2021-01-08 10:21:03 |
| 2377649 | 370f4d98-519b-11eb-a85f-42010a9c0002 |     3387067681 | 2021-01-08 10:21:03 |
| 2377650 | 370f4da6-519b-11eb-a85f-42010a9c0002 |     2785299536 | 2021-01-08 10:21:03 |
| 2377651 | 370f4db5-519b-11eb-a85f-42010a9c0002 |     1202520612 | 2021-01-08 10:21:03 |
| 2377652 | 370f4dc3-519b-11eb-a85f-42010a9c0002 |     3937153461 | 2021-01-08 10:21:03 |
| 2377653 | 370f4dd2-519b-11eb-a85f-42010a9c0002 |     1015628201 | 2021-01-08 10:21:03 |
| 2377654 | 370f4de1-519b-11eb-a85f-42010a9c0002 |     2783037901 | 2021-01-08 10:21:03 |
+---------+--------------------------------------+----------------+---------------------+
10 rows in set, 1 warning (0.50 sec)

С очень любопытным explain:

mysql> explain format=json select SQL_NO_CACHE * from mobile_stat_device order by id limit 10 offset 2050000\G
*************************** 1. row ***************************
EXPLAIN: {
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "213695.60"
    },
    "ordering_operation": {
      "using_filesort": false,
      "table": {
        "table_name": "mobile_stat_device",
        "access_type": "index",
        "key": "PRIMARY",
        "used_key_parts": [
          "id"
        ],
        "key_length": "4",
        "rows_examined_per_scan": 2050010,
        "rows_produced_per_join": 2088550,
        "filtered": "100.00",
        "cost_info": {
          "read_cost": "4840.61",
          "eval_cost": "208855.00",
          "prefix_cost": "213695.61",
          "data_read_per_join": "318M"
        },
        "used_columns": [
          "id",
          "deviceUuid",
          "deviceUuidHash",
          "createdAt"
        ]
      }
    }
  }
}

Для того, что бы отдать 680 байт mysql прочитал 318 мегабайт. Кол-во затронутых записей rows_examined_per_scan = 2050010 = 2050000(offset) + 10(limit)

И что самое плохое в этом запросе - с увеличением offset производительность и дальше будет деградировать.

Как быть?

В данном конкретном случае, можно воспользоваться тем, что id идут по порядку и переписать запрос так

mysql> select SQL_NO_CACHE * from mobile_stat_device where id between 2377645 and 2377654 order by id;
+---------+--------------------------------------+----------------+---------------------+
| id      | deviceUuid                           | deviceUuidHash | createdAt           |
+---------+--------------------------------------+----------------+---------------------+
| 2377645 | 370f4d5e-519b-11eb-a85f-42010a9c0002 |     1792791173 | 2021-01-08 10:21:03 |
| 2377646 | 370f4d6c-519b-11eb-a85f-42010a9c0002 |     3206227716 | 2021-01-08 10:21:03 |
| 2377647 | 370f4d7b-519b-11eb-a85f-42010a9c0002 |     3766230312 | 2021-01-08 10:21:03 |
| 2377648 | 370f4d89-519b-11eb-a85f-42010a9c0002 |     2525345549 | 2021-01-08 10:21:03 |
| 2377649 | 370f4d98-519b-11eb-a85f-42010a9c0002 |     3387067681 | 2021-01-08 10:21:03 |
| 2377650 | 370f4da6-519b-11eb-a85f-42010a9c0002 |     2785299536 | 2021-01-08 10:21:03 |
| 2377651 | 370f4db5-519b-11eb-a85f-42010a9c0002 |     1202520612 | 2021-01-08 10:21:03 |
| 2377652 | 370f4dc3-519b-11eb-a85f-42010a9c0002 |     3937153461 | 2021-01-08 10:21:03 |
| 2377653 | 370f4dd2-519b-11eb-a85f-42010a9c0002 |     1015628201 | 2021-01-08 10:21:03 |
| 2377654 | 370f4de1-519b-11eb-a85f-42010a9c0002 |     2783037901 | 2021-01-08 10:21:03 |
+---------+--------------------------------------+----------------+---------------------+
10 rows in set, 1 warning (0.00 sec)

Explain:

mysql> explain format=json select SQL_NO_CACHE * from mobile_stat_device where id between 2377645 and 2377654 order by id\G
*************************** 1. row ***************************
EXPLAIN: {
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "2.57"
    },
    "ordering_operation": {
      "using_filesort": false,
      "table": {
        "table_name": "mobile_stat_device",
        "access_type": "range",
        "possible_keys": [
          "PRIMARY"
        ],
        "key": "PRIMARY",
        "used_key_parts": [
          "id"
        ],
        "key_length": "4",
        "rows_examined_per_scan": 10,
        "rows_produced_per_join": 10,
        "filtered": "100.00",
        "cost_info": {
          "read_cost": "1.57",
          "eval_cost": "1.00",
          "prefix_cost": "2.57",
          "data_read_per_join": "1K"
        },
        "used_columns": [
          "id",
          "deviceUuid",
          "deviceUuidHash",
          "createdAt"
        ],
        "attached_condition": "(`test`.`mobile_stat_device`.`id` between 2377645 and 2377654)"
      }
    }
  }
}

Вот, стало значительно веселее.

Если необходимо пробежаться по всем записям таблички чтобы, например, сделать экспорт, то можно использовать условие id > самого большого id прошлой выборки, но id <= самого большого id прошлой выборки + limit + 1. Условием остановки итераций будет значение id >= максимального значения id

Запросы для отображения по страницам - для чего обычно и используют лимиты и офсеты, тут смысла не имеют из-за большого количества данных. Если же наложить какие-то внешние фильтры, уменьшив там самым кол-во выбираемых данных, то и offset будет работать быстро.