Большинство проблем, связанных с БД, во время разработки остаются незамеченными, потому что мы пишем код и проверяем его правильность только при малой "заполненности" нашей БД. Поэтому, когда приложение выкатывается в продакшн, через некоторое время начинают появляться проблемы с производительностью БД, отдельные части приложения начинают работать всё медленнее и медленнее по мере роста самого БД.

Как выявить и отладить такие проблемы? В этой статье будет показано решение наиболее распространённых проблем с производительностью БД, вызванных неправильной индексацией. Примеры будут приведены для Postgres, MySQL и SQLite.

Как БД выполняет запрос?

Для того, чтобы начать оптимизировать запросы к БД, в первую очередь надо понимать, как БД выполняет сам запрос.

Как только SQL-код запроса разобран, оптимизатор запросов("query optimizer") просматривает этот запрос и предлагает последовательность операций, которые необходимо выполнить для получение запрашиваемого результата. Для простых запросов это может быть всего одна операция. Для сложных, включающих в себя JOIN, GROUP BY и другие подобные конструкции, требуются несколько операций, каждой из которых необходим результат выполнения предыдущей.

Существуют множество разных примитивных операций, но если рассматривать те, которые извлекают данные, то их можно разделить на 2 группы: операции, которые ищут нужные данные путем последовательного чтения, иногда их называют "последовательные сканы"("sequential scans") или "табличные сканы"("table scans"); и операции, которые для поиска данных используют индекс, часто это называют "поиск по индексу"("index searches") или "индексные сканы"("index scans").

Последовательный поиск медленнее чем поиск по индексу? В общем случае да, однако это неабсолютная истина. Для таблиц в БД, содержащих небольшое количество записей, время последовательного поиска и поиска по индексу не сильно отличается, и в зависимости от конкретного случая одно может быть немного быстрее другого. Нередко при работе с небольшими таблицами БД предпочитает использовать последовательный поиск даже при наличии индекса.

Однако для больших таблиц последовательный поиск часто становится причиной проблем с производительностью запросов, так как чем больше таблица, тем медленнее выполняется поиск. Для получения лучшей производительности БД необходимо настроить таблицы с нужными индексами, и тогда у оптимизатора запросов будет больше возможностей для работы.

Как можно понять, ключевым моментом в оптимизации медленных запросов является поиск последовательных сканов, которые выполняет БД, и добавление соответствующих индексов к таблицам, чтобы БД могла заменить скан на эквивалентные операции, использующие индекс.

В поисках медленных запросов

К сожалению, не существует универсального способа оценить БД, чтобы определить, какие индексы надо добавить для повышения производительности. Нужные вам индексы напрямую связаны с запросами, которые вы делаете, поэтому процесс начинается с определения запросов, которые тормозят ваше приложение.

Как обнаружить медленные запросы? Существуют несколько стратегий. Самая простая - прислушаться к вашим пользователям. На днях коллега сказал мне, что некоторые HTTP запросы, которые он делал к Flask API, которое я поддерживаю, выполнялись около 20 секунд. Получив точные данные об этих HTTP запросах, я смог найти SQL запрос, который нужно было оптимизировать.

Другой вариант: множество БД и фреймворков для БД могут быть настроены так, чтобы логировать запросы, который выполняются больше определённого времени. Это очень полезно в продакшн среде. Время от времени можно будет просматривать логи медленных запросов, чтобы определить, есть ли запросы, которые нуждаются в изучении.

Как найти и исправить последовательные сканы

Как только вы найдёте запрос, который нужно оптимизировать, можно приступить к следующему шагу - поиску медленных последовательных сканов в запросе, которые можно устранить добавлением индекса.

Хорошие новости заключаются в том, что вы можете "спросить" вашу БД, какие операции нужны для выполнения запроса. Это реализовано с помощью SQL слова EXPLAIN, которое является частью спецификации SQL.

Рассмотрим три БД, с которыми я обычно работаю, - MySQL, Postgres, SQLite, и все три способны "объяснить" запросы для отладки проблем с производительностью.

Пример схемы БД

Далее я покажу вам небольшую схему, которая будет использована для создания таблиц в Postgres, MySQL и SQLite. Также я покажу вам как анализировать запросы в каждой СУБД.

Поскольку я обычно работаю с Python, я покажу вам схему, которую я буду использовать в соответствии с требованиями фреймворка SQLAlchemy. Даже если вы не работаете с Python, эти классы должны быть очень просты для понимания.

Пусть будет БД, которая хранит информацию о статьях блога вместе с авторами. Вот её определение:

class Article(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    slug = db.Column(db.String(256), index=True, unique=True, nullable=False)
    title = db.Column(db.String(256))
    author_id = db.Column(db.Integer, db.ForeignKey('author.id'))


class Author(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(256), index=True, unique=True, nullable=False)

Обратите внимание, что в этой схеме столбецArticle.slug индексируется, а Article.title - нет. Это важно, потому что это позволит увидеть как поиск по индексируемым и неиндексируемым полям обрабатывается в разных СУБД.

Postgres

Прежде чем приступить к анализу производительности Postgres, вот как SQLAlchemy сгенерировала таблицы Postgres, соответствующие классам, которые были показаны в предыдущем разделе:

test=# \dt
             List of relations
 Schema |      Name      | Type  |  Owner
--------+----------------+-------+----------
 public | article        | table | postgres
 public | author         | table | postgres

test=# \d article
                                         Table "public.article"
      Column       |          Type          | Collation | Nullable |               Default
-------------------+------------------------+-----------+----------+-------------------------------------
 id                | integer                |           | not null | nextval('article_id_seq'::regclass)
 slug              | character varying(256) |           | not null |
 title             | character varying(256) |           |          |
 author_id         | integer                |           |          |
Indexes:
    "article_pkey" PRIMARY KEY, btree (id)
    "ix_article_slug" UNIQUE, btree (slug)
Foreign-key constraints:
    "article_author_id_fkey" FOREIGN KEY (author_id) REFERENCES author(id)

test=# \d author
                                    Table "public.author"
 Column |          Type          | Collation | Nullable |              Default
--------+------------------------+-----------+----------+------------------------------------
 id     | integer                |           | not null | nextval('author_id_seq'::regclass)
 name   | character varying(256) |           | not null |
Indexes:
    "author_pkey" PRIMARY KEY, btree (id)
    "ix_author_name" UNIQUE, btree (name)
Referenced by:
    TABLE "article" CONSTRAINT "article_author_id_fkey" FOREIGN KEY (author_id) REFERENCES author(id)

Оптимизация простых запросов

Используя таблицу article, получим первые 5 статей в алфавитном порядке с помощью следующего запроса:

  SELECT * FROM article ORDER BY article.slug LIMIT 5;

Давайте используем EXPLAIN на этом запросе, просто добавив его в качестве префикса:

test=# EXPLAIN SELECT * FROM article ORDER BY article.slug LIMIT 5;

                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Limit  (cost=0.27..1.26 rows=5 width=139)
   ->  Index Scan using ix_article_slug on article  (cost=0.27..43.38 rows=217 width=139)
(2 rows)

Этот запрос требует выполнения двух операций. Операция LIMITне очень интересна, но можно увидеть, что она зависит от поиска по индексу столбца slug, который используется для извлечения первых пяти записей в алфавитном порядке. Это действительно хорошо; индексное сканирование довольно эффективно по сравнение с последовательным.

В Postgres есть операция, которая даже более быстрая, чем индексный скан, - это "сканирование только по индексу"("index only scan"). Она используется, когда данные, которые нужны запросу, можно получить непосредственно из самого индекса, поэтому нет необходимость читать какие-либо данные из таблицы после поиска по индексу. В следующем примере вместо всех столбцов таблицы запрашивается только slug, а так как slug - это индексируемый столбец, Postgres решает использовать более эффективную операцию:

test=# EXPLAIN SELECT slug FROM article ORDER BY article.slug LIMIT 5;

                                          QUERY PLAN
----------------------------------------------------------------------------------------------
 Limit  (cost=0.27..1.26 rows=5 width=49)
   ->  Index Only Scan using ix_article_slug on article  (cost=0.27..43.38 rows=217 width=49)
(2 rows)

Что произойдёт, если мы выполним похожий запрос, но будем использовать столбец title, который не является индексируемым. Вот результат:

test=# EXPLAIN SELECT * FROM article ORDER BY article.title LIMIT 5;

                              QUERY PLAN
-----------------------------------------------------------------------
 Limit  (cost=10.77..10.79 rows=5 width=139)
   ->  Sort  (cost=10.77..11.32 rows=217 width=139)
         Sort Key: title
         ->  Seq Scan on article  (cost=0.00..7.17 rows=217 width=139)
(4 rows)

Как можно увидеть, теперь в запросе появилась третья операция - сортировка. В предыдущем случае сортировка по алфавиту могла быть получена непосредственно из индекса, но title не индексируется, поэтому сортировка в этом запросе должна происходить в памяти. Если читать этот запрос снизу вверх, то сначала в результате последовательного сканирования все записи попадают в память, затем строки сортируются, и, наконец, возвращаются первые пять строк.

Этот запрос будет выполняться всё медленнее и медленнее по мере роста числа записей в таблице article. В примере выше можно увидеть, что в таблице 217 строк. Представьте, насколько больше усилий потребовалось бы, если бы этот запрос выполнялся с 10 000 записей, все они должны были бы считываться в память для сортировки, чтобы вернуть первые пять и отбросить остальные.

Оптимизация JOIN

Давайте посмотрим, что произойдёт с запросом, использующие JOIN. Следующий запрос возвращает первые пять статей и их авторов, отсортированным в алфавитном порядке по имени автора:

test=# EXPLAIN SELECT article.*, author.name 
test=# FROM article, author 
test=# WHERE article.author_id = author.id ORDER BY author.name LIMIT 5;

                                         QUERY PLAN
---------------------------------------------------------------------------------------------
 Limit  (cost=0.14..8.36 rows=5 width=153)
   ->  Nested Loop  (cost=0.14..356.66 rows=217 width=153)
         Join Filter: (article.author_id = author.id)
         ->  Index Scan using ix_author_name on author  (cost=0.14..13.69 rows=103 width=18)
         ->  Materialize  (cost=0.00..8.25 rows=217 width=139)
               ->  Seq Scan on article  (cost=0.00..7.17 rows=217 width=139)
(6 rows)

Если вы предпочитаете альтернативный способ определения этого запроса с помощью JOIN, будьте уверены, что он даст такой же результат:

test=# EXPLAIN SELECT article.*, author.name 
test=# FROM article JOIN author ON article.author_id = author.id 
test=# ORDER BY author.name LIMIT 5;

                                         QUERY PLAN
---------------------------------------------------------------------------------------------
 Limit  (cost=0.14..8.36 rows=5 width=153)
   ->  Nested Loop  (cost=0.14..356.66 rows=217 width=153)
         Join Filter: (article.author_id = author.id)
         ->  Index Scan using ix_author_name on author  (cost=0.14..13.69 rows=103 width=18)
         ->  Materialize  (cost=0.00..8.25 rows=217 width=139)
               ->  Seq Scan on article  (cost=0.00..7.17 rows=217 width=139)
(6 rows)

Плохая новость - запросы начинаются с последовательного скана таблицы article. Результаты сканирования "материализуются" в виде таблицы в памяти. Затем операция JOIN объединяет эти данные вместе с индексным сканом столбца name таблицы author. В данном случае JOIN оценивается с помощью алгоритма "вложенного цикла"("nested loop"), который является самой базовой(и менее производительной) операцией JOIN в Postgres.

Вы можете задаться вопросом, почему Postgres выполняет последовательное сканирование всех статей, если можно было использовать внешний ключ author_id. Ответ может вас удивить: множество СУБД(Postgres в том числе) не создают автоматически индекс на внешние ключи("foreign key")! Первичный ключи("primary key") автоматически индексируются, но внешние ключи должны быть явно проиндексированы, если вы хотите, чтобы они были эффективными.

Для оптимизации этого запроса, мы можем добавить индекс на внешний ключ author_id. В схеме SQLAlchemy на Python, это будет выглядеть так:

class Article(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    slug = db.Column(db.String(256), index=True, unique=True, nullable=False)
    title = db.Column(db.String(256))
    author_id = db.Column(db.Integer, db.ForeignKey('author.id'), index=True)  # <-- add an index here

Или непосредственно в SQL:

CREATE INDEX ix_article_author_id ON article (author_id);

Теперь можно повторить предыдущий запрос и получить гораздо лучшее "разложение":

test=# EXPLAIN SELECT article.*, author.name 
test=# FROM article, author 
test=# WHERE article.author_id = author.id ORDER BY author.name LIMIT 5;

                                            QUERY PLAN
--------------------------------------------------------------------------------------------------
 Limit  (cost=0.29..1.72 rows=5 width=153)
   ->  Nested Loop  (cost=0.29..62.29 rows=217 width=153)
         ->  Index Scan using ix_author_name on author  (cost=0.14..13.69 rows=103 width=18)
         ->  Index Scan using ix_article_author_id on article  (cost=0.14..0.45 rows=2 width=139)
               Index Cond: (author_id = author.id)
(5 rows)

Это действительно лучше, но я надеялся, что Postgres использует более эффективный алгоритм для JOIN, чем вложенный цикл. Я думаю, в данном случае Postgres решил, что вложенный цикл будет лучшим решением, потому что в итоге мы ищем всего пять записей. Видно, что при сканировании нового индекса на внешний ключ author_id понадобилось посмотреть только 2 строки из 217 в таблице, поэтому вложенный цикл в итоге оказался не очень дорогим. Если бы в таблицах было гораздо больше строк, или понадобилось бы больше, чем пять записей, "решение" этого запроса могло отличаться. По этой причине очень важно использовать выражение EXPLAIN на конкретных запросов, которые выполняются медленно, и проводить анализ на реальной базе данных(или на её резервной копии("mirror")).

MySQL

Продолжим, давайте теперь используем аналогичную схему в MySQL. Вот как SQLAlchemy cгенерирует таблицы:

mysql> DESCRIBE article;
+-------------------+--------------+------+-----+---------+----------------+
| Field             | Type         | Null | Key | Default | Extra          |
+-------------------+--------------+------+-----+---------+----------------+
| id                | int          | NO   | PRI | NULL    | auto_increment |
| slug              | varchar(256) | NO   | UNI | NULL    |                |
| title             | varchar(256) | YES  |     | NULL    |                |
| author_id         | int          | YES  | MUL | NULL    |                |
+-------------------+--------------+------+-----+---------+----------------+
4 rows in set (0.01 sec)

mysql> SHOW INDEX FROM article;
+---------+------------+------------------------------+--------------+-------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table   | Non_unique | Key_name                     | Seq_in_index | Column_name       | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+---------+------------+------------------------------+--------------+-------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| article |          0 | PRIMARY                      |            1 | id                | A         |         217 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
| article |          0 | ix_article_slug              |            1 | slug              | A         |         217 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
| article |          1 | author_id                    |            1 | author_id         | A         |         104 |     NULL |   NULL | YES  | BTREE      |         |               | YES     | NULL       |
+---------+------------+------------------------------+--------------+-------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
3 rows in set (0.01 sec)

mysql> DESCRIBE author;
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int          | NO   | PRI | NULL    | auto_increment |
| name  | varchar(256) | NO   | UNI | NULL    |                |
+-------+--------------+------+-----+---------+----------------+
2 rows in set (0.00 sec)

mysql> SHOW INDEX FROM author;
+--------+------------+----------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table  | Non_unique | Key_name       | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+--------+------------+----------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| author |          0 | PRIMARY        |            1 | id          | A         |         103 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
| author |          0 | ix_author_name |            1 | name        | A         |         103 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
+--------+------------+----------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
2 rows in set (0.01 sec)

Оптимизация простых запросов

Начнём с того же простого запроса, который использовался для Postgres, чтобы выбрать первые пять статей, отсортированных в алфавитном порядке:

SELECT * FROM article ORDER BY article.slug LIMIT 5;

Посмотрим, что думает MySQL об этом запросе, добавив ключевое слово EXPLAIN:

mysql> EXPLAIN SELECT * FROM article ORDER BY article.slug LIMIT 5;
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+----------------+
| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra          |
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+----------------+
|  1 | SIMPLE      | article | NULL       | ALL  | NULL          | NULL | NULL    | NULL |  217 |   100.00 | Using filesort |
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+----------------+
1 row in set, 1 warning (0.00 sec)

Название операции "filesort" в MySQL может запутать, это просто означает, что записи нужно отсортировать, в отличии от индексируемых данных, которые не нуждаются в сортировке, поскольку индекс сам хранит данные отсортированными.

Это разочаровывает, потому что MySQL читает все записи в таблице article и сортирует их по столбцу slug, даже несмотря на то, что столбец article.slug индексирован. Я не совсем понимаю, почему так происходит, но я подозреваю, что MySQL решил, что при таком масштабе(217 записей) более эффективным будет отсортировать всю таблицу, чем вычитывать индекс для первых пяти записей, и затем вычитывать их из таблицы.

Посмотрим, сможем ли мы убедить MySQL использовать индекс article.slug. В следующем запросе, вместо того, чтобы запрашивать все столбцы таблицы, я запрашиваю только сам slug. Этот запрос может быть разрешён только на данных, хранящихся в индексе, поэтому я ожидаю, что MySQL предпочтёт индекс в этот раз:

mysql> EXPLAIN SELECT slug FROM article ORDER BY article.slug LIMIT 5;
+----+-------------+---------+------------+-------+---------------+-----------------+---------+------+------+----------+-------------+
| id | select_type | table   | partitions | type  | possible_keys | key             | key_len | ref  | rows | filtered | Extra       |
+----+-------------+---------+------------+-------+---------------+-----------------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | article | NULL       | index | NULL          | ix_article_slug | 1026    | NULL |    5 |   100.00 | Using index |
+----+-------------+---------+------------+-------+---------------+-----------------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

И да, на этот раз он предпочёл индекс.

Теперь вы, наверное, догадываетесь, что произойдёт, если мы отсортируем по столбцу title, которые не индексируется.

mysql> EXPLAIN SELECT * FROM article ORDER BY article.title LIMIT 5;
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+----------------+
| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra          |
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+----------------+
|  1 | SIMPLE      | article | NULL       | ALL  | NULL          | NULL | NULL    | NULL |  217 |   100.00 | Using filesort |
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+----------------+
1 row in set, 1 warning (0.00 sec)

Здесь MySQL снова возвращается к алгоритму filesort.

Оптимизация JOIN

Теперь давайте рассмотрим JOIN. Как и в случае с Postgres, следующий запрос извлекает первые пять статей и имена их авторов в алфавитном порядке по фамилии автора:

mysql> EXPLAIN SELECT article.*, author.name 
mysql> FROM article, author 
mysql> WHERE article.author_id = author.id ORDER BY author.name LIMIT 5;
+----+-------------+---------+------------+-------+----------------------+----------------------+---------+----------------+------+----------+-------------+
| id | select_type | table   | partitions | type  | possible_keys        | key                  | key_len | ref            | rows | filtered | Extra       |
+----+-------------+---------+------------+-------+----------------------+----------------------+---------+----------------+------+----------+-------------+
|  1 | SIMPLE      | author  | NULL       | index | PRIMARY              | ix_author_name       | 1026    | NULL           |    5 |   100.00 | Using index |
|  1 | SIMPLE      | article | NULL       | ref   | author_id            | author_id            | 5       | test.author.id |    2 |   100.00 | NULL        |
+----+-------------+---------+------------+-------+----------------------+----------------------+---------+----------------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)

Если вы предпочитаете JOIN версию этого запроса:

mysql> EXPLAIN SELECT article.*, author.name 
mysql> FROM article JOIN author ON article.author_id = author.id 
mysql> ORDER BY author.name LIMIT 5;
+----+-------------+---------+------------+-------+-----------------------------------+----------------+---------+----------------+------+----------+-------------+
| id | select_type | table   | partitions | type  | possible_keys                     | key            | key_len | ref            | rows | filtered | Extra       |
+----+-------------+---------+------------+-------+-----------------------------------+----------------+---------+----------------+------+----------+-------------+
|  1 | SIMPLE      | author  | NULL       | index | PRIMARY                           | ix_author_name | 1026    | NULL           |    5 |   100.00 | Using index |
|  1 | SIMPLE      | article | NULL       | ref   | author_id,author_id_2,author_id_3 | author_id      | 5       | test.author.id |    2 |   100.00 | NULL        |
+----+-------------+---------+------------+-------+-----------------------------------+----------------+---------+----------------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)

Как видите, для MySQL этот запрос уже эффективен. В отличие от Postgres, MySQL автоматически индексирует внешние ключи(при использовании движка InnoDB), поэтому здесь нет ничего, нуждающегося в оптимизации.

SQLite

В качестве последнего примера, давайте посмотрим, как эти же запросы выполняются в SQLite. Вот схема, сгенерированная SQLAlchemy:

sqlite> .schema
CREATE TABLE author (
        id INTEGER NOT NULL,
        name VARCHAR(256) NOT NULL,
        PRIMARY KEY (id)
);
CREATE UNIQUE INDEX ix_author_name ON author (name);
CREATE TABLE article (
        id INTEGER NOT NULL,
        slug VARCHAR(256) NOT NULL,
        title VARCHAR(256),
        author_id INTEGER,
        PRIMARY KEY (id),
        FOREIGN KEY(author_id) REFERENCES author (id),
);
CREATE UNIQUE INDEX ix_article_slug ON article (slug);

Оптимизация простых запросов

Как и случаях с Postgres и MySQL, мы начнём с простого запроса, который возвращает первые пять статей, отсортированных в алфавитном порядке по их столбцу slug:

SELECT * FROM article ORDER BY article.slug LIMIT 5;

SQLite имеет два разных способа "объяснить"(имеется ввиду EXPLAIN) запрос. Директива EXPLAIN выводит очень подробный список низкоуровневых операций виртуальной машины, которые будут выполнены для исполнения запроса, а директива EXPLAIN QUERY PLAN - высокоуровневый список операций, которые мы видели для Postgres и MySQL. При работе с SQLite я всегда использую последнюю.

sqlite> EXPLAIN QUERY PLAN SELECT * FROM article ORDER BY article.slug LIMIT 5;
QUERY PLAN
`--SCAN TABLE article USING INDEX ix_article_slug

В этом запросе видно, что SQLite сканирует таблицу article, используя индекс по столбцу slug. Это значит, что индекс будет использован для поиска тех первых пяти строк, а затем только эти пять записей будут извлечены из реальной таблицы. На самом деле это очень хорошо.

Мы видели, что Postgres оптимизирует запрос ещё больше, когда информация может быть получена непосредственно из индекса, без необходимости читать таблицу. Имеет ли SQLite такую же оптимизацию? Есть только один способ узнать это:

sqlite> EXPLAIN QUERY PLAN SELECT slug FROM article ORDER BY article.slug LIMIT 5;
QUERY PLAN
`--SCAN TABLE article USING COVERING INDEX ix_article_slug

В данной случае мы возвращаем только slugs, а так как они хранятся в индексе, SQLite экономит время и не обращается к таблице. SQLite называет это покрывающим индексом("covering index").

Что произойдёт, если мы попытаемся отсортировать по столбцу title, который не индексируется?

sqlite> EXPLAIN QUERY PLAN SELECT * FROM article ORDER BY article.title LIMIT 5;
QUERY PLAN
|--SCAN TABLE article
`--USE TEMP B-TREE FOR ORDER BY

Видно, что при сканировании таблицы не используется индекс, и SQLite приходится выполнять сортировку в памяти или на диске(в зависимости от размера), которую от реализует путем создания временного b-tree. Чтобы оптимизировать этот запрос, достаточно просто добавить индекс для столбца title.

Оптимизация JOIN

Далее мы рассмотрим, как исполняются JOIN. Как и в случаях с Postgres и MySQL, следующий запрос извлекает первые пять статей и имена авторов в алфавитном порядке:

sqlite> EXPLAIN QUERY PLAN SELECT article.*, author.name 
sqlite> FROM article, author WHERE article.author_id = author.id 
sqlite> ORDER BY author.name LIMIT 5;
QUERY PLAN
|--SCAN TABLE article
|--SEARCH TABLE author USING INTEGER PRIMARY KEY (rowid=?)
`--USE TEMP B-TREE FOR ORDER BY

Ниже приведён альтернативный синтаксис с использование JOIN, который должен быть эквивалентным:

sqlite> EXPLAIN QUERY PLAN SELECT article.*, author.name 
sqlite> FROM article JOIN author ON article.author_id = author.id 
sqlite> ORDER BY author.name LIMIT 5;
QUERY PLAN
|--SCAN TABLE article
|--SEARCH TABLE author USING INTEGER PRIMARY KEY (rowid=?)
`--USE TEMP B-TREE FOR ORDER BY

Здесь SQLite начинает со сканирования таблицы article без индекса, а затем выполняет JOIN с таблицей author путем поиска первичного ключа(который всегда индексирован). Затем выполняется сортировка с помощью b-tree, чтобы получить первые пять элементов.

Это не очень хорошо, поэтому мы должны посмотреть, улучшит ли ситуацию добавление индекса к столбцу author_id, который используется в условии JOIN. С точки зрения SQLAlchemy это будет простое добавление index=True для столбца author_id.

class Article(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    slug = db.Column(db.String(256), index=True, unique=True, nullable=False)
    title = db.Column(db.String(256))
    author_id = db.Column(db.Integer, db.ForeignKey('author.id'), index=True)  # <-- add an index here

Используя SQL, можно добиться того же самого с помощью этого оператора:

CREATE INDEX ix_article_author_id ON article (author_id);

К сожалению, добавление индекса не изменило запрос:

sqlite> EXPLAIN QUERY PLAN SELECT article.*, author.name 
sqlite> FROM article JOIN author ON article.author_id = author.id 
sqlite> ORDER BY author.name LIMIT 5;
QUERY PLAN
|--SCAN TABLE article
|--SEARCH TABLE author USING INTEGER PRIMARY KEY (rowid=?)
`--USE TEMP B-TREE FOR ORDER BY

Чтобы понять это, я нашёл документацию о том, как SQLite разрешает JOIN. В SQLite JOIN всегда реализуются как вложенный цикл, и только внутренний цикл может быть оптимизирован с помощью индекса, так что для SQLite это как нельзя лучше подходит.

Медленные запросы без последовательных сканов

Хоть это и не является темой данной статьи, вы можете обнаружить, что сложный запрос выполняется медленно даже после того, как вы убедились, что все операции подкреплены индексами. Это может произойти, например, с запросами, требующими объединения большого количества таблиц, просто потому, что для создания желаемого результата базе данных необходимо считывать данные из множества различных мест. Что нужно сделать, чтобы оптимизировать такой запрос?

Когда запрос, не содержащий больших сканов, выполняется медленно, это указывает на то, что структура вашей базы данных может затруднять его эффективное выполнение. Здесь я не хочу сказать, что вы неправильно структурировали вашу БД. На самом деле всё как раз наоборот! Реляционная модель способствует созданию множества таблиц, каждая из которых с определённой информацией и с ключами на другие. Это приводит к тому, что запросы, которым нужно комбинировать фрагменты данных в единый результат, становятся более трудозатратными. Иногда точное следование за реляционной моделью на самом деле становится проблемой.

Процесс, называемый денормализацией, может использоваться для дублирования некоторых данных в реляционной БД с целью упрощения запросов. Конечно, это делает запись данных более дорогостоящей и сложной, но это может помочь уменьшить количество объединений, необходимых для выполнения ваших запросов.

Заключение

Я надеюсь эта статья поможет вам начать отладку ваших медленных запросов. В итоге, вот как можно оптимизировать медленные запросы к базе данных:

  • Найдите медленный запрос

  • Используйте EXPLAIN или EXPLAIN QUERY PLAN для этого запроса, чтобы увидеть как база данных интерпретирует его. Это нужно делать на реальной базе данных, в которой запрос оказался медленным. Делая это на меньшей БД, к примеру, можно не получить получить другие результаты.

  • Ищите сканы, которые могут быть преобразованы в индексные. Используйте документацию по базе данных, если вам нужна помощь в понимании некоторых частей вывода EXPLAIN.

  • Если вы не можете найти недостающие индексы, в запрос по-прежнему медленный, посмотрите, можно ли уменьшить сложность запроса, продублировав(денормализовав) некоторые данные.

Есть ли у вас другие приёмы для оптимизации медленной базы данных? Дайте знать об этом в комментариях!

Комментарии (28)


  1. Akina
    12.01.2024 12:10
    +9

    Совершенно пустая и практически бесполезная статья. К тому же написанная ну просто безобразно и нечитаемо, особенно коды и планы. Как по мне, оригинал явно не заслуживает того, чтобы тратить время на перевод.

    Да и перевод сделан совершенно безобразно. Несогласованные лица, падежи и прочее - чуть ли не через абзац... ну уж если автор озаботился переводом - надо же было после машинного перевода хотя бы прочитать, что получилось!


  1. Andrey_dv94
    12.01.2024 12:10

    плагиат, статья переведена другим человеком в рамках разработки проектов НАНОФИТ и Тимбилдер


  1. SpiderEkb
    12.01.2024 12:10
    +4

    Автор статьи не в курсе, что SQL движок может варьировать план запроса в зависимости от объема данных? Один и тот же запрос на разных средах (с разными данными) может иметь радикально разные планы.

    Поэтому все это имеет смысл делать на копии промсреды, но не на тестовых средах.

    Если говорить про оптимизацию запросов, то, к примеру, на наше платформе (DB2 for i) есть такая штука как "интерактивный SQL" - средство где можно не только погонять запросы в интеактиве и посмотреть результаты (прежде чем вставлять его в код), но и режим "демонстрации" где показывается как запрос будет выполняться

    Там еще и рекомендации оптимизатора можно посмотреть.

    Далее, из личного опыта. Сильно тормозят те запросы, где используется сортировка (order by, особенно по неиндексированным полям) или агрегирование (с использованием group by). И тут чаще всего выгоднее сделать запрос "линейным" (пусть и избыточным), а сортировку и/или агрегирование выполнять уже в коде в процессе потоковой постобработки результата.

    Есть еще ряд "финтов ушами", но, боюсь, это уже специфика нашей платформы - статические/динамические запросы (различаются тем, в какой момент строится план - один раз во время компиляции или каждый раз в рантайме), блочное чтение (fetch не по одной записи, а блоками по 1 000...10 000 записей в массив и т.п.


  1. erogov
    12.01.2024 12:10
    +2

    ключевым моментом в оптимизации медленных запросов является поиск последовательных сканов, которые выполняет БД, и добавление соответствующих индексов

    ...и ни слова про селективность. Дальше можно не читать.


  1. vagon333
    12.01.2024 12:10
    +1

    К сожалению, не существует универсального способа оценить БД, чтобы определить, какие индексы надо добавить для повышения производительности. 

    Не все так плохо.
    В SQL Server есть Dynamic Management Views (DMVs), можно получить рекомендации по индексам.
    Execution Plan позволяет проанализировать и получить рекомендации по индексам.
    В профайлере можно отловить запросы, занимающие время.
    Можно добавить Extended Events для автоматического логирования тупящих запросов.

    Вобщем, все движки сваливать в кучу и подводить черту - рисковое занятие.
    Не все движки одинаковые.


  1. ptr128
    12.01.2024 12:10
    +2

    Любую оптимизацию следует начинать с обновления статистик. Потому что план запроса зависит, в первую очередь, от них, а не от объемов данных и их селективности, о которых планировщик узнает именно из статистик.

    Особенно это относится к временным и промежуточным таблицам.


  1. mserg86
    12.01.2024 12:10
    +1

    При оптимизации запросов важно учитывать объемы данных, ведь от размера таблиц оптимизатор может выбрать разные сценарии, о чем было сказано в статье.

    На больших таблицах поведение может измениться от разных значений фильтров. Из-за разной статистики (распределения значений).

    Для OLAP нагрузок уже гораздо проще перейти на колоночные базы и индексы, работают быстро и без заморочек.

    Для OLTP нагрузок надо измерять наносеки, нормализацию базы проводить. И еще у индексов есть стоимость обслуживания, которая добавляет свои наносеки.

    В своей практике я пришел к простому выводу, все сложные запросы должны выбирать как можно меньше данных на каждом этапе. Иной раз лишний JOIN может увеличить время выполнения запроса в сотни раз (таблицы с миллионами записей).

    Также не упоминается о временных таблицах. Если запрос «никак не хочет» выполняться быстро, можно его раскидать во временные таблицы, речь про OLAP нагрузку разумеется.

    Вся эта тема с оптимизацией SQL дико долгая, чтоб я мог еще что-то добавить)


  1. ALexKud
    12.01.2024 12:10

    Лично я использую только хранимые процедуры. Тут и план запроса не выполняется каждый раз, а строится единожды при компиляции, и обработка данных в хранимых процедурах позволяет не таскать гигабайты трафика по сети клиенту туда сюда, и бизнес логика опять же в бд позволяет сильно упростить клиента, оставив ему только интерфейсные зависимости. И конечно, надо знать как оптимально обрабатывать данные на SQL без использования всяких тормозных fetch. Хоть у меня не web разработка, но через vpn и интернет мои приложения работают также быстро как и в локальной сети. Потому что трафик минимальный между клиентом и сервером. Потому что хранимые процедуры оптимизированы, созданы все необходимые индексы, периодически проверяется дефрагментация индексов и они перестраиваются и много ещё чего оптимизировано при разработке встроенных процедур. Конечно, у меня нишевая область использования моих систем, и я стараюсь избегать web подходов к разработке в нишевых областях, в которых она не нужна, а только добавляет ненужной сложности и замедляет работу приложений.


    1. ptr128
      12.01.2024 12:10

      Примеры будут приведены для Postgres, MySQL и SQLite.

      хранимые процедуры. Тут и план запроса не выполняется каждый раз, а строится единожды при компиляции

      И о какой из этих БД речь?


      1. ALexKud
        12.01.2024 12:10
        -1

        Я работаю исключительно с sql server. Было приложение где пришлось реализовать одну и ту же функциональность в SQL Server и sqlite. Для совместимости в SQL server использовал те же запросы sqlite во встроенных процедурах sql server. Текст запросов переделывать не пришлось вообще. За исключением получения даты и количества записей. В общем разницы не нашел в быстродействии. Оптимизировал в SQL server.


        1. ptr128
          12.01.2024 12:10
          -1

          Тогда какое отношение Ваш комментарий имеет к этой статье? Её хабы: MySQL PostgreSQL SQLite


          1. ALexKud
            12.01.2024 12:10
            -1

            А то что любая оптимизация будет хорошо работать. Если я оптимизировал в SQL server, то в Sqlite она тоже будет оптимальна. Даже запрос менять не надо. Методы оптимизации во всех базах одинаковые практически, только это и хотел сказать. Причина медленного запроса должна быть устранена, оптимизация медленного запроса уже признак неправильной архитектуры или плохого знания sql вкупе с игнорированием общеизвестных рекомендаций по построению запросов.


            1. ptr128
              12.01.2024 12:10

              Запрос, оптимально работающий в MS SQL, легко может уйти в нирвану даже в PostgreSQL, не говоря уже об SQLite. И наоборот - тоже дело обычное.

              И Вы так и не ответили на вопрос: "какое отношение Ваш комментарий имеет к этой статье? Её хабы: MySQL PostgreSQL SQLite"

              Особенно с учетом того, что ни один из них не компилирует SQL запросы до их первого исполнения при запуске в хранимой процедуре. В последнем так вообще хранимых процедур нет и говорить об их компиляции бессмысленно.

              И это, на практике, просто замечательно. На MS SQL очень часто приходится требовать это насильно через WITH RECOMPILE. Потому что между запусками хранимой процедуры могут меняться как статистики, так и селективность выборки в зависимости от параметров процедуры. И скомпилированный изначально план запроса может оказаться совершенно не эффективен при последующих вызовах.


              1. ALexKud
                12.01.2024 12:10
                -1

                Мне ни разу не понадобилось включать компиляцию процедур. Просто архитектура приложений делается так чтобы использовалась статистика + рекомендации по составлению запросов+оптимизация по индексам+ соединение малых таблиц к большим+ и т д. Это справедливо для любых бд.


                1. ptr128
                  12.01.2024 12:10

                  Мне ни разу не понадобилось включать компиляцию процедур.

                  Я даже не знаю, как это можно сделать на MS SQL. Расскажите.

                  Я писал наоборот, о вреде компиляции хранимых процедур MS SQL по умолчанию, которую приходится запрещать указанием WITH RECOMPILE для всей процедуры или конкретных запросов в ней.

                  архитектура приложений делается

                  При чем тут архитектура, если, в зависимости от параметров процедуры, оптимальные планы запросов в ней могут быть совершенно разными?

                  соединение малых таблиц к большим

                  Не верно. Должно быть соединение выборок в зависимости от их селективности. А это совсем не обязательно связано с размерами таблиц.

                  Демонстрирую. Создаем две таблицы и хранимую процедуру:

                  DROP TABLE IF EXISTS tmp_tmp;
                  CREATE TABLE tmp_tmp (
                    ID  int PRIMARY KEY,
                    SessionId int NOT NULL,
                    Val int NOT NULL
                  );
                  
                  INSERT INTO tmp_tmp(ID, SessionId, Val)
                  SELECT N.number+T.number*1000 AS ID,
                    CASE WHEN T.number<500 THEN N.number
                      ELSE N.number+T.number*1000 END AS SessionId,
                     N.number AS Val
                  FROM master.dbo.spt_values T
                  JOIN master.dbo.spt_values N ON N.type='P'
                    AND N.number BETWEEN 1 AND 1000
                  WHERE T.type='P'
                    AND T.number BETWEEN 0 AND 999;
                  
                  CREATE INDEX tmp_tmp_SessionId_Idx ON tmp_tmp (SessionId, Val);
                  CREATE INDEX tmp_tmp_Val_Idx ON tmp_tmp (Val, SessionId);
                  UPDATE STATISTICS tmp_tmp WITH FULLSCAN;
                  
                  DROP TABLE IF EXISTS tmp_tmp2;
                  CREATE TABLE tmp_tmp2 (
                    ID  int PRIMARY KEY,
                    Val int NOT NULL,
                    Gr int NOT NULL
                  );
                  
                  INSERT INTO tmp_tmp2(ID, Val, Gr)
                  SELECT N.number+T.number*1000 AS ID,
                    CASE WHEN T.number<500 THEN T.number
                      ELSE N.number END AS Val,
                    CASE WHEN T.number<500 THEN N.number
                      ELSE N.number+T.number*1000 END AS Gr
                  FROM master.dbo.spt_values T
                  JOIN master.dbo.spt_values N ON N.type='P'
                    AND N.number BETWEEN 1 AND 1000
                  WHERE T.type='P'
                    AND T.number BETWEEN 0 AND 999;
                  
                  CREATE INDEX tmp_tmp2_Val_Idx ON tmp_tmp2 (Val, Gr);
                  CREATE INDEX tmp_tmp2_Gr_Idx ON tmp_tmp2 (Gr, Val);
                  UPDATE STATISTICS tmp_tmp2 WITH FULLSCAN;
                  
                  CREATE OR ALTER PROC tmp_proc (@gr int, @sessionid int)
                  AS
                  SELECT *
                  FROM tmp_tmp H
                  JOIN tmp_tmp2 D ON D.Val=H.Val
                  WHERE D.Gr=@gr AND H.SessionId=@sessionid
                  

                  Запускаем с разными параметрами. Так:

                  EXEC tmp_proc 100, 600100;
                  
                  Table 'tmp_tmp2'. Scan count 1, logical reads 3, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
                  Table 'tmp_tmp'. Scan count 1, logical reads 3, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
                  
                  (1 row affected)
                  
                   SQL Server Execution Times:
                     CPU time = 0 ms,  elapsed time = 0 ms.

                  И так:

                  EXEC tmp_proc 600100, 100;
                  
                  Table 'tmp_tmp2'. Scan count 1000, logical reads 3000, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
                  Table 'tmp_tmp'. Scan count 1, logical reads 6, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
                  
                  (1 row affected)
                  
                   SQL Server Execution Times:
                     CPU time = 2 ms,  elapsed time = 2 ms.
                  

                  Теперь меняем процедуру, добавив принудительную перекомпиляцию запроса:

                  CREATE OR ALTER PROC tmp_proc (@gr int, @sessionid int)
                  AS
                  SELECT *
                  FROM tmp_tmp H
                  JOIN tmp_tmp2 D ON D.Val=H.Val
                  WHERE D.Gr=@gr AND H.SessionId=@sessionid
                  OPTION (RECOMPILE)

                  Повторяем те же самые вызовы:

                  EXEC tmp_proc 100, 600100;
                  
                  Table 'tmp_tmp2'. Scan count 1, logical reads 3, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
                  Table 'tmp_tmp'. Scan count 1, logical reads 3, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
                  
                   SQL Server Execution Times:
                     CPU time = 0 ms,  elapsed time = 0 ms.
                  
                  EXEC tmp_proc 600100, 100;
                  
                  Table 'tmp_tmp'. Scan count 1, logical reads 3, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
                  Table 'tmp_tmp2'. Scan count 1, logical reads 3, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
                  
                   SQL Server Execution Times:
                     CPU time = 0 ms,  elapsed time = 0 ms.
                  

                  Теперь планировщик смог понять, что в этих запросах выгодней выбирать данные из таблиц в разном порядке и план запроса стал оптимален в обеих случаях.

                  И если речь не о примитивных запросах в пет-проектах, а о реальных приложениях, то при малейших сомнениях ставится RECOMPILE на всю процедуру или на запросы, в которых прямо или косвенно участвуют параметры процедуры.


                  1. ALexKud
                    12.01.2024 12:10

                    Спрасибо, не знал. Все и так работает неплохо. Уже 4 года . Лог в БД накапливается за год до 3 млн. записей, использую его в мониторинге, где встроенная процедура запускается 1 раз в секунду. В процедуре выборка из лога идет подзапросом. Но систему это не грузит совершенно. добавлены необходимые индексы. В моей публикации есть картинка программы мониторинга, если интересно.


                    1. ptr128
                      12.01.2024 12:10

                      Даже на выборке из одной таблицы, но по множеству полей, селективность может заметно различаться, в зависимости от параметров. Просто на столь крошечных объемах Вы это не замечаете. Были бы миллиарды записей, как у меня, то сразу бы это ощутили.

                      встроенная процедура запускается 1 раз в секунду

                      В моей публикации есть картинка программы мониторинга, если интересно.

                      Не интересно. Это задача ElasticSearch, ClickHouse или InfluxDB, получающих логи, например, через Kafka. Но уж никак не MS SQL.


                      1. ALexKud
                        12.01.2024 12:10

                        на моих небольших объемах БД использовать это не нужно. Сервер справляется замечательно сам, с моей помощью в оптимизации индексов. А если все хорошо, то не зачем использовать что-то еще. Чем проще тем лучше.


                      1. ptr128
                        12.01.2024 12:10

                        Но статью и комментарии читают далеко не только те, у кого небольшие объемы БД. А Вы не указали, даже неявно, что Ваши рекомендации подходят только для небольших объемов, где время на рекомпиляцию планов запросов сравнимо с временем выполнения самих запросов.


                      1. SpiderEkb
                        12.01.2024 12:10

                        С индексами тоже не все так просто. На больших таблицах с высокой плотностью изменений данных любой лишний индекс дает увеличение нагрузки на всю систему в целом. Поэтому всегда приходится балансировать.


                      1. ptr128
                        12.01.2024 12:10

                        Если действительно требуется индексация большого количества столбцов и сократить размер индексов, делая их частичными, хешированными и вынося ряд полей в INCLUDE не получается, то может все же эта таблица должна храниться в ColumnStore?


                  1. SpiderEkb
                    12.01.2024 12:10

                    RECOMPILE тоже занимает определенное время и дает нагрузку на сервер. Если у вас таких RECOMPILE будет несколько десятков тысяч (работающих параллельно) и они будут регулярно вызываться сотни миллионов раз в сутки каждый - вы это неизбежно почувствуете. Палка о двух концах.

                    Вот такое вот как-то получил от сопровождения:

                    Из PEX статистики работы ******  видно, что 33% времени и 36% ресурсов  CPU  тратится на выполнение QSQRPARS в программе *******, т.е. парсинг статических выражений при подготовке SQL запроса,  

                    Поскольку ****** один из наиболее активно используемых сервис модулей, необоснованное повышенное ресурсопотребление является малодопустимым. Просьба инициировать доработку *******.

                    Здесь речь идет как раз о "динамическом" SQL - когда план запроса строится при каждом его вызове.


                    1. ALexKud
                      12.01.2024 12:10

                      С рекомпиляцией процедура работает на 2 порядка медленней, сейчас попробовал. Время на компиляцию съедает весь профит.


                      1. ptr128
                        12.01.2024 12:10

                        Покажите. И не забудьте указать, как меняются её параметры при разных вызовах.


                      1. ALexKud
                        12.01.2024 12:10

                        в профайлере 5 мс и с принудительной перекомпиляцией 228 мс разница


                      1. ptr128
                        12.01.2024 12:10

                        Вы издеваетесь? Покажите так, как я показал Вам, чтобы можно было повторить эксперимент. Это называется "научный метод познания".


                    1. ptr128
                      12.01.2024 12:10

                      RECOMPILE тоже занимает определенное время и дает нагрузку на сервер.

                      Время на RECOMPILE, обычно, на порядки меньше, чем время выполнения запросов.

                      Если у вас таких RECOMPILE будет несколько десятков тысяч (работающих параллельно)

                      Если среди этих десятков тысяч найдется хотя бы сотня запросов, выбравших без RECOMPILE на три порядка (как в моем примере - 3000 logical reads вместо 3) менее оптимальный план запросов, то уже окупится.

                      Здесь речь идет как раз о "динамическом" SQL - когда план запроса строится при каждом его вызове.

                      А при чем тут динамический SQL, который требует не только компиляции плана запроса, но еще и парсинга с запросами метаданных? RECOMPILE вызывает лишь sp_cache_remove, query_post_compilation_showplan и sp_cache_insert.

                      И я не призывал лепить RECOMPILE на все процедуры. Делать это следует ровно как я писал: "при малейших сомнениях ставится RECOMPILE на всю процедуру или на запросы, в которых прямо или косвенно участвуют параметры процедуры"


                      1. ALexKud
                        12.01.2024 12:10

                        У меня не такой большой объем данных на этой задаче, но он компенсировался сложностью разработки идеи программируемых тестов и расчета и показа метрологических параметров в реальном времени в мониторинге. Да и динамический SQL пришлось тоже применять для расчета по формулам в зависимости от многих параметров.