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

Рассмотрим типичную базу данных, которую создали веб-программисты. (Не в нашей компании, конечное, а «какие-то другие програмисты из скверных команий».) Я сейчас утрирую, специально всё свёл к самому минималистичному варианту, на примере которого можно показать эффект провала производительности. «Типичная» база данных веб-программистов состоит из одной таблицы с тремя полями: первичным ключом, полем created_at (датой создания записи) и толстым jsonb, в котором хранятся в ненормализованном виде все данные. Возможны и другие поля, но они сейчас не важны.

create table test (
  pk bigint primary key, 
  c timestamptz not null default CURRENT_TIMESTAMP, 
  j jsonb not null
);
create index on test(c);
create index on test using gin (j);
=> \d test
                              Table "olleg.test"
 Column |           Type           | Collation | Nullable |      Default
--------+--------------------------+-----------+----------+-------------------
 pk     | bigint                   |           | not null |
 c      | timestamp with time zone |           | not null | CURRENT_TIMESTAMP
 j      | jsonb                    |           | not null |
Indexes:
    "test_pkey" PRIMARY KEY, btree (pk)
    "test_c_idx" btree (c)
    "test_j_idx" gin (j)

Типичный запрос к такой БД идёт с фильтрацией по полю jsonb (в моём примере j) и с обратной сортировкой по полю created_at (в моём примере c) с ограничением вывода количества записей — так веб-программисты часто реализуют paging по количеству записей, показывая пользователю наиболее свежие записи. Для моего примера это будет:

select pk from test where j@>'100' order by c desc limit 10;

Если БД небольшая, то всё ок. Числа подобрал условные, лишь бы показать эффект. Примеры показываю на PostgreSQL 10.

truncate test;
insert into test (pk,c,j)
  select pk,clock_timestamp(),array_to_json(array_fill(pk,ARRAY[10]))
    from generate_series(1,100000) as gs(pk);
analyze;
                         List of relations
 Schema | Name | Type  | Owner | Persistence | Size  | Description
--------+------+-------+-------+-------------+-------+-------------
 olleg  | test | table | olleg | permanent   | 20 MB |
(1 row)
 
                                 List of relations
 Schema |    Name    | Type  | Owner | Table | Persistence |  Size   | Description
--------+------------+-------+-------+-------+-------------+---------+-------------
 olleg  | test_c_idx | index | olleg | test  | permanent   | 2208 kB |
 olleg  | test_j_idx | index | olleg | test  | permanent   | 1984 kB |
 olleg  | test_pkey  | index | olleg | test  | permanent   | 2208 kB |
(3 rows)

И в этом случае тестовый запрос выполняется нормально, быстро, и при сканировании используется gin индекс.

=> explain analyze select pk from test where j@>'100' order by c desc limit 10;
                                                            QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=351.19..351.21 rows=10 width=16) (actual time=5.525..5.526 rows=1 loops=1)
   ->  Sort  (cost=351.19..351.44 rows=100 width=16) (actual time=5.525..5.526 rows=1 loops=1)
         Sort Key: c DESC
         Sort Method: quicksort  Memory: 25kB
         ->  Bitmap Heap Scan on test  (cost=248.78..349.02 rows=100 width=16) (actual time=5.519..5.519 rows=1 loops=1)
               Recheck Cond: (j @> '100'::jsonb)
               Heap Blocks: exact=1
               ->  Bitmap Index Scan on test_j_idx  (cost=0.00..248.75 rows=100 width=0) (actual time=5.511..5.512 rows=1 loops=1)
                     Index Cond: (j @> '100'::jsonb)
 Planning time: 0.150 ms
 Execution time: 5.546 ms
(11 rows)

Если база подрастёт в десять раз:

truncate test;
insert into test (pk,c,j)
  select pk,clock_timestamp(),array_to_json(array_fill(pk,ARRAY[10]))
  from generate_series(1,1000000) as gs(pk);
analyze;
                         List of relations
 Schema | Name | Type  | Owner | Persistence |  Size  | Description
--------+------+-------+-------+-------------+--------+-------------
 olleg  | test | table | olleg | permanent   | 205 MB |
(1 row)
 
                                List of relations
 Schema |    Name    | Type  | Owner | Table | Persistence | Size  | Description
--------+------------+-------+-------+-------+-------------+-------+-------------
 olleg  | test_c_idx | index | olleg | test  | permanent   | 21 MB |
 olleg  | test_j_idx | index | olleg | test  | permanent   | 46 MB |
 olleg  | test_pkey  | index | olleg | test  | permanent   | 21 MB |
(3 rows)

…то тогда планировщик начинает отказываться от использования gin в этом запросе.

explain analyze select pk from test where j@>'100' order by c desc limit 10;
                                                                 QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.42..465.54 rows=10 width=16) (actual time=402.973..403.016 rows=1 loops=1)
   ->  Index Scan Backward using test_c_idx on test  (cost=0.42..46511.43 rows=1000 width=16) (actual time=402.972..403.015 rows=1 loops=1)
         Filter: (j @> '100'::jsonb)
         Rows Removed by Filter: 999999
 Planning time: 0.132 ms
 Execution time: 403.026 ms
(6 rows)

Причём совершенно напрасно, если искусственно запретить использовать индекс на created_at для сортировки, например если удалить этот индекс (так и было сделано во время аварийной ситуации), то скорость такого запроса вырастет в этой БД в 34 раза. То есть явно это ошибка планировщика — отказываться от использования gin индекса.

drop index test_c_idx;
explain analyze select pk from test where j@>'100' order by c desc limit 10;
                                                              QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=1423.86..1423.88 rows=10 width=16) (actual time=11.644..11.646 rows=1 loops=1)
   ->  Sort  (cost=1423.86..1426.36 rows=1000 width=16) (actual time=11.643..11.644 rows=1 loops=1)
         Sort Key: c DESC
         Sort Method: quicksort  Memory: 25kB
         ->  Bitmap Heap Scan on test  (cost=407.75..1402.25 rows=1000 width=16) (actual time=11.633..11.634 rows=1 loops=1)
               Recheck Cond: (j @> '100'::jsonb)
               Heap Blocks: exact=1
               ->  Bitmap Index Scan on test_j_idx  (cost=0.00..407.50 rows=1000 width=0) (actual time=11.613..11.614 rows=1 loops=1)
                     Index Cond: (j @> '100'::jsonb)
 Planning time: 0.240 ms
 Execution time: 11.694 ms
(11 rows)

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

=> explain analyze select pk from test where j->>1='100' order by c desc limit 10;
                                                                 QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.42..98.45 rows=10 width=16) (actual time=289.033..289.061 rows=1 loops=1)
   ->  Index Scan Backward using test_c_idx on test  (cost=0.42..49011.43 rows=5000 width=16) (actual time=289.032..289.059 rows=1 loops=1)
         Filter: ((j ->> 1) = '100'::text)
         Rows Removed by Filter: 999999
 Planning time: 0.065 ms
 Execution time: 289.078 ms
(6 rows)
 
create index on test((j->>1));
analyze;
 
=> explain analyze select pk from test where j->>1='100' order by c desc limit 10;
                                                           QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=2.45..2.46 rows=1 width=16) (actual time=0.066..0.067 rows=1 loops=1)
   ->  Sort  (cost=2.45..2.46 rows=1 width=16) (actual time=0.066..0.066 rows=1 loops=1)
         Sort Key: c DESC
         Sort Method: quicksort  Memory: 25kB
         ->  Index Scan using test_expr_idx on test  (cost=0.42..2.44 rows=1 width=16) (actual time=0.047..0.048 rows=1 loops=1)
               Index Cond: ((j ->> 1) = '100'::text)
 Planning time: 0.273 ms
 Execution time: 0.086 ms
(8 rows)

Тут выигрыш в 3,3 тысячи раз по сравнению со случаем, когда индексы используются не для фильтрации, а для сортировки.

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

Гораздо более коварен другой вариант, когда индекс по полю created_at уже существует. Пока БД относительно небольшого размера, всё замечательно работает. Нет никаких правок, миграций, эксцессов, ничто не предвещает беды. Но база потихонечку растёт, в один прекрасный момент внезапно переваливает какой-то пороговый размер и планировщик начинает отказываться использовать gin для фильтрации, предпочитая перескочить на btree для обратной сортировки по датам. И это настолько сильно увеличивало время выполнения многих запросов, что сервис можно было считать полностью неработоспособным. И никаких очевидных причин катастрофы не наблюдалось.

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

begin;
create table test (
  pk bigint primary key, 
  c timestamptz not null default CURRENT_TIMESTAMP, 
  j jsonb not null);
create index on test(c);
create index on test using gin (j);
insert into test (pk,c,j) 
  select pk,clock_timestamp(),array_to_json(array_fill(pk,ARRAY[10])) 
    from generate_series(1,100000) as gs(pk);
analyze;
explain analyze select pk from test where j@>'100' order by c desc limit 10;
drop table test;
create table test (pk bigint primary key, 
                   c timestamptz not null default CURRENT_TIMESTAMP, 
                   j jsonb not null);
create index on test(c);
create index on test using gin (j);
insert into test (pk,c,j) 
  select pk,clock_timestamp(),array_to_json(array_fill(pk,ARRAY[10])) 
    from generate_series(1,1000000) as gs(pk);
analyze;
explain analyze select pk from test where j@>'100' order by c desc limit 10;
rollback;

Проблема наблюдалась на версиях: 9.5, 9.6, 10, 11 и 12.

$ psql -p 5412 -f test.sql
BEGIN
CREATE TABLE
CREATE INDEX
CREATE INDEX
INSERT 0 100000
ANALYZE
                                                            QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=351.19..351.21 rows=10 width=16) (actual time=4.697..4.698 rows=1 loops=1)
   ->  Sort  (cost=351.19..351.44 rows=100 width=16) (actual time=4.697..4.697 rows=1 loops=1)
         Sort Key: c DESC
         Sort Method: quicksort  Memory: 25kB
         ->  Bitmap Heap Scan on test  (cost=248.78..349.02 rows=100 width=16) (actual time=4.691..4.691 rows=1 loops=1)
               Recheck Cond: (j @> '100'::jsonb)
               Heap Blocks: exact=1
               ->  Bitmap Index Scan on test_j_idx  (cost=0.00..248.75 rows=100 width=0) (actual time=4.687..4.687 rows=1 loops=1)
                     Index Cond: (j @> '100'::jsonb)
 Planning Time: 0.188 ms
 Execution Time: 4.731 ms
(11 rows)
 
DROP TABLE
CREATE TABLE
CREATE INDEX
CREATE INDEX
INSERT 0 1000000
ANALYZE
                                                                 QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.42..465.54 rows=10 width=16) (actual time=425.537..425.580 rows=1 loops=1)
   ->  Index Scan Backward using test_c_idx on test  (cost=0.42..46511.43 rows=1000 width=16) (actual time=425.536..425.579 rows=1 loops=1)
         Filter: (j @> '100'::jsonb)
         Rows Removed by Filter: 999999
 Planning Time: 0.174 ms
 Execution Time: 425.590 ms
(6 rows)
 
ROLLBACK

Тестовый скрипт не показал проблему на: 13 и 14.

$ psql -p 5413 -f test.sql
BEGIN
CREATE TABLE
CREATE INDEX
CREATE INDEX
INSERT 0 100000
ANALYZE
                                                            QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=258.37..258.39 rows=10 width=16) (actual time=5.833..5.834 rows=1 loops=1)
   ->  Sort  (cost=258.37..258.39 rows=10 width=16) (actual time=5.831..5.831 rows=1 loops=1)
         Sort Key: c DESC
         Sort Method: quicksort  Memory: 25kB
         ->  Bitmap Heap Scan on test  (cost=248.08..258.20 rows=10 width=16) (actual time=5.819..5.820 rows=1 loops=1)
               Recheck Cond: (j @> '100'::jsonb)
               Heap Blocks: exact=1
               ->  Bitmap Index Scan on test_j_idx  (cost=0.00..248.07 rows=10 width=0) (actual time=5.813..5.814 rows=1 loops=1)
                     Index Cond: (j @> '100'::jsonb)
 Planning Time: 0.280 ms
 Execution Time: 5.866 ms
(11 rows)
 
DROP TABLE
CREATE TABLE
CREATE INDEX
CREATE INDEX
INSERT 0 1000000
ANALYZE
                                                            QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=504.19..504.21 rows=10 width=16) (actual time=8.436..8.437 rows=1 loops=1)
   ->  Sort  (cost=504.19..504.44 rows=100 width=16) (actual time=8.435..8.436 rows=1 loops=1)
         Sort Key: c DESC
         Sort Method: quicksort  Memory: 25kB
         ->  Bitmap Heap Scan on test  (cost=400.77..502.02 rows=100 width=16) (actual time=8.429..8.430 rows=1 loops=1)
               Recheck Cond: (j @> '100'::jsonb)
               Heap Blocks: exact=1
               ->  Bitmap Index Scan on test_j_idx  (cost=0.00..400.75 rows=100 width=0) (actual time=8.412..8.412 rows=1 loops=1)
                     Index Cond: (j @> '100'::jsonb)
 Planning Time: 0.215 ms
 Execution Time: 8.453 ms
(11 rows)
 
ROLLBACK

Я поискал в changelog к PostgreSQL 13, но ничего конкретного не нашёл. Хотя в этой версии были какие-то общие оптимизации планировщика, подходящие по смыслу, например:

Improve the optimizer's selectivity estimation for containment/match operators (Tom Lane)

Вообще, можно взять за правило ограничение в использовании jsonb. Его удобно использовать для тех случаев, когда его данные не используются для работы БД, его можно легко и просто сразу передать веб-страницам. Если же по данным осуществляются операции БД, например, фильтрация, сортировка, группировка и объединения, то такие данные лучше хранить вне jsonb, в структуре самой БД. Это связано с тем, что Postgres не умеет собирать статистику по внутренностям jsonb, он рассматривает его только целиком, что обычно совершенно бессмысленно (можно отключать, так как статистика по jsonb сильно раздувает таблицу статистики). Поэтому для запросов по своим собственным структурам он строит план гораздо качественнее, чем по данным внутри jsonb.

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


  1. zetx16
    12.10.2023 05:24
    +1

    Как вариант, для фильтруемых полей создавать сохраняемые вычисляемые столбцы, которые берут значения из json столбца.


  1. hardtop
    12.10.2023 05:24

    Полезный тест. Проблема в том, что сейчас не так то просто отказаться от json. Например, его использую в качестве неструктурированного EAV для каталога (правда каталог не большой < 10 тыс). Как всегда, надо тестировать заранее на больших данных - без этого никуда!


    1. splarv Автор
      12.10.2023 05:24
      +2

      А не надо от него отказываться. :) Он может быть полезен. И поскольку веб программисты очень часто используют json для своих собственных нужд удобно хранить данные тоже в json, чтобы не заморачиваться преобразованиями туда-сюда. Но если по каким-то полям json происходят частые запросы, тогда их имеет смысл оптимизировать. Например создавая специальные индексы для поиска по этим полям или, например, выносить их в таблицу в виде вычисляемого поля или еще как. Небольшие json работают не так уж плохо, никто не жаловался. :) Проблемные json обычно очень длинные.


    1. zetx16
      12.10.2023 05:24
      +1

      Как вариант, для фильтруемых полей создавать сохраняемые вычисляемые столбцы, которые берут значения из json столбца.


      1. splarv Автор
        12.10.2023 05:24

        Да, неплохой вариант, я забыл его упомянуть. Единственный его недостаток, что приходит в голову, то что эта функциональность появилась относительно недавно, а с проблемами сталкивался и в более старых версиях, где этой функциональности нет. Там помогает создание индекса по вычисляемому значению типа:
        create index on test((j->>1));


  1. tema8
    12.10.2023 05:24

    Полезная статья, спасибо. Я сталкивался с подобным, но без jsonb и gin, а с "обычными" колонками - в какой-то момент планировщик переключился с btree по user_id на относительно недавно созданный btree created_at


    1. splarv Автор
      12.10.2023 05:24
      +1

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


  1. CentALT
    12.10.2023 05:24
    +1

    1. splarv Автор
      12.10.2023 05:24

      Ага, выглядит похоже.


  1. evgeniyrru
    12.10.2023 05:24
    +1

    Вывод: обновляйте ваши постгресы, друзья мои!


    1. splarv Автор
      12.10.2023 05:24

      Поставил лайк, за то что использовали "постгрес", а не "постгря". :) Фраза "обновляйте ваши постгрясы" даже в голове не укладывается. :)