Порядок следования столбцов в таблицах PostgreSQL

от автора

Порядок столбцов в таблицах влияет на компактность и производительность. При небольшом числе строк на это не обращают внимание. Если размер таблиц большой, то полезно даже небольшое уменьшение объема хранения. У столбцов в таблицах PostgreSQL есть оптимальный  порядок и менее оптимальные с точки зрения размера физического хранения. Причина в выравнивании (aligning) и появлении пустых мест (padding). В блоке данных выравниваются все структуры: заголовки, поля, сами строки.

На 64-разраядных операционных системах, длина любой строки и заголовка строки  выравниваются по 8 байт. То есть если строка занимает 28 байт, то физически она займёт 32 байта. В конец строки будут добавлены пустые байты. Поля выравниваются сложнее — по 4, 8, 16 байт. В статье рассматривается перестановка столбцов на примере демонстрационной базы.

При изучении PostgreSQL многие доходят до вопроса перестановок столбцов. С виду задача простая. Найдя на просторах интернет скрипт, который выдаёт оптимальный порядок столбцов, я решил проверить какой эффект может быть. Скрипт написан специалистом, который дальше всех продвинулся в изучении перестановок столбцов («column tetris»). Воспользоваться скриптом просто:

wget https://raw.githubusercontent.com/NikolayS/postgres_dba/refs/heads/master/sql/p1_alignment_padding.sql ‘p1_alignment_padding.sql’ saved [6598/6598]

Встроенной демонстрационной базы в PostgreSQL нет. Существует база авиаперевозок, которой я решил воспользоваться. Это чрезвычайно аккуратная база, приближенная к реальным схемам хранения. Она является лучшим примером того, как стоит разрабатывать структуры схем хранения (ER modeling). Демобаза поставляется на двух языках и в трёх вариантах размеров: small, medium, big. Разные размеры позволяют тестировать запросы разной степени сложности. Я выбрал вариант medium, «как у всех». Скачивание и установка базы элегантна:

wget https://edu.postgrespro.com/demo-medium-en.zip Saving to: ‘demo-medium-en.zip’ ‘demo-medium-en.zip’ saved [64544920/64544920] time zcat demo-medium-en.zip | psql real    1m3.339s user    0m2.168s sys     0m0.255s

Создание базы заняло 1 минуту. Проверяю скрипт в деле:

psql -d demo demo=# \i p1_alignment_padding.sql          Table           | Table Size |     Comment      |      Wasted *      |      Suggested Columns Reorder        --------------------------+------------+------------------+--------------------+--------------------------------------  bookings.ticket_flights  | 154 MB     | Includes VARLENA | ~18 MB (11.68%)    | amount, fare_conditions, flight_id  +                           |            |                  |                    | ticket_no  bookings.boarding_passes | 109 MB     | Includes VARLENA | ~14 MB (13.25%)    | boarding_no, flight_id, seat_no     +                           |            |                  |                    | ticket_no  bookings.tickets         | 109 MB     | Includes VARLENA | ~6477 kB (5.81%)   | book_ref, contact_data, passenger_id+                           |            |                  |                    | passenger_name, ticket_no  bookings.bookings        | 30 MB      | Includes VARLENA |                    |   bookings.flights         | 6688 kB    | Includes VARLENA |                    |   bookings.seats           | 96 kB      | Includes VARLENA |                    |   bookings.airports_data   | 48 kB      | Includes VARLENA | ~832 bytes (1.69%) | airport_code, airport_name, city    +                           |            |                  |                    | timezone, coordinates  bookings.aircrafts_data  | 8192 bytes | Includes VARLENA |                    |  (8 rows)

Скрипт обещает, что переставив столбцы можно сэкономить примерно 11% места. О ужас, неужели создатели демонстрационной базы использовали неоптимальный порядок столбцов?! Предвкушая, как я расскажу об этом создателям демобазы, потирая ручки, я чуть было не принялся поверять рекомендации теста. Но до проверки руки дошли только спустя пару месяцев.

Скрипт выдал рекомендуемый порядок столбцов в столбце Suggested Columns Reorder. Как поменять порядок следования столбцов? Над этим бились лучшие умы, но ни к чему не пришли. Я поступил простым способом до которого смог додуматься. Выгрузил определения столбцов:

pg_dump -d demo -s -f demo.sql

и отредактировал порядок следования в текстовом редакторе, переставляя строки в полученном файле в mcedit:

редактирование файла дампа - перестановка строк в файле

редактирование файла дампа — перестановка строк в файле

Дальше создал базу для новых таблиц и выполнил скрипт, создающий структуру объектов:

postgres=# create database demo1; \c demo1 \\ \i demo.sql CREATE DATABASE You are now connected to database "demo1" as user "postgres". SET ...

Я что-то упустил? Опытные администраторы СУБД, думаю, догадались.

Осталось перегрузить данные из исходных таблиц в новые (упустил я не это, про упущенное будет дальше), с рекомендованным скриптом порядком следования столбцов:

time pg_dump -d demo -a | psql -d demo1 real    3m38.040s user    0m1.981s sys     0m0.259s

Перегрузка выполнялась довольно долго ~3 минуты 38 секунд. Создание базы demo скриптом от разработчиков длилась 1 минуту, то есть существенно быстрее. Что я упустил или недосмотрел? Упустил (самомнение поправило: банально забыл) то, что скрипт дампа создал индексы на таблицах. Именно их наличие существенно замедлило загрузку данных командами COPY, которые использует утилита pg_dump. Ну да ладно, не повторять же всё заново. Посмотрим размеры баз:

postgres=# select pg_size_pretty(a), pg_size_pretty(b), 100*(a-b)/(a+b) "%" from (select pg_database_size('demo') a , pg_database_size('demo1') b);  pg_size_pretty | pg_size_pretty | %   ----------------+----------------+----   703 MB        | 812 MB         | -7 (1 row)

Это что такое? Размер базы данных увеличился на 7%! Стало хуже. Вспомнив, что загрузка выполнялась при наличии индексов на таблицах я предположил, что виноваты индексы — их структура не оптимальна. Перестройка индексов:

\timing on \\ REINDEX SCHEMA bookings; Time: 23059,135 ms (00:23,059)

Перестройка индексов прошла быстро — за 23 секунды. Проверим размеры баз:

demo1=# select pg_size_pretty(a), pg_size_pretty(b), 100*(a-b)/(a+b) "%" from (select pg_database_size('demo') a , pg_database_size('demo1') b);  pg_size_pretty | pg_size_pretty | %  ----------------+----------------+---  703 MB         | 703 MB         | 0 (1 row)

Стало лучше, но разницы, обещанной скриптом, нет. Что скажет скрипт на этот раз:

demo1=# \i p1_alignment_padding.sql           Table           | Table Size |     Comment      | Wasted * | Suggested Columns Reorder  --------------------------+------------+------------------+----------+---------------------------  bookings.ticket_flights  | 154 MB     | Includes VARLENA |          |   bookings.boarding_passes | 109 MB     | Includes VARLENA |          |   bookings.tickets         | 109 MB     | Includes VARLENA |          |   bookings.bookings        | 30 MB      | Includes VARLENA |          |   bookings.flights         | 6688 kB    | Includes VARLENA |          |   bookings.seats           | 96 kB      | Includes VARLENA |          |   bookings.airports_data   | 48 kB      | Includes VARLENA |          |   bookings.aircrafts_data  | 8192 bytes | Includes VARLENA |          |  (8 rows)

Скрипт сказал танцуют все, что не видит лишнего места (Wasted *) и порядок столбцов оптимален. Я пригорюнился (но не приуныл), восхитился мудростью разработчиков демобазы, укорил себя за малодушные сомнения в создателях базы и слепую веру скриптам из интернет. Но червь сомнения глодал — а может, скрипт ошибается и есть в жизни счастье оптимальный порядок следования столбцов?

Padding и aligning

Придётся изучить теорию. Сами по себе перестановки столбцов просты и до сих пор сложность статьи несложная. Но выбор оптимального порядка столбцов сложен и предсказать, что получится сложно, так как результат частично зависит и от самих данных, хранящихся в таблице. Я без иронии писал, что создатель скрипта дальше всех продвинулся в изучении перестановок столбцов, это действительно так. Прогресс (как и постгрес) не стоит на месте, у нас есть шанс углубить и расширить знания. В этом разделе сложность статьи — «сложная». В столбце typalign таблицы системного каталога pg_type и attalign таблицы pg_attribute указано выравнивание для полей разных типов. Все типы:

select distinct typname, typlen, typalign from pg_type where typname not like 'pg_%' and typname not like '\_%' order by typname;                 typname                | typlen | typalign  ---------------------------------------+--------+----------  aclitem                               |     16 | d  administrable_role_authorizations     |     -1 | d ...  void                                  |      4 | i  xid                                   |      4 | i  xid8                                  |      8 | d  xml                                   |     -1 | i  yes_or_no                             |     -1 | i (179 rows)

Все используемые в столбцах таблиц PostgreSQL типы:

select distinct atttypid::regtype, attlen, attalign from pg_attribute order by attalign;               atttypid              | attlen | attalign  ------------------------------------+--------+----------  name                               |     64 | c  boolean                            |      1 | c  "char"                             |      1 | c  cstring                            |     -2 | c  ...  smallint                           |      2 | s  tid                                |      6 | s (51 rows)

Выравнивание может быть:
c (char), 1 байт, то есть без выравнивания
s (short), 2 байта
i (int), 4 байта
d (double) по 8 байт

как выравниваются строки таблиц

как выравниваются строки таблиц

Padding — добавление неиспользуемого места, чтобы выполнить выравнивание (aligning).
Например, создаются две таблицы с разным порядком столбцов и вставляются строки с одинаковыми значениями:

create table t1 (c1 varchar(1), c2 bigserial , c3 date, c4 timestamp); create table t2 (c1 bigserial , c2 timestamp, c3 date, c4 varchar(1)); insert into t1 values('A', 1, now(), current_timestamp); insert into t2 values(1, current_timestamp, now(), 'A');

Строки будут храниться в виде последовательности байт в HEX:

как смотреть физическое расположение строк в блоке

как смотреть физическое расположение строк в блоке

Пример в виде картинки с другими типами данных:

образ строк с разным расположением столбцов

образ строк с разным расположением столбцов

В примере на картинке перестановка столбцов не даёт результата — длина строк одинакова. Причина? Вся строка выравнивается до 8 байт, поэтому без разницы в конец строки или после первого поля будут добавлены пустые байты.

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

Перестановка столбцов в демонстрационной базе

 Пробовать все варианты перестановок накладно, поэтому решил воспользоваться простыми правилами: столбцы фиксированной ширины сделать первыми; чем шире столбец, тем ближе к началу. То есть расположу столбцы фиксированной ширины в следующем порядке их typalign: d, i, s, c. Поля переменной ширины (varlena) начинают выравниваются по их typalign (attalign), только если их длина больше 126 байт (мало кто задастся вопросом почему). Длинных полей в демобазе не должно быть, поэтому не буду это учитывать. Снова выгружаю файл:

pg_dump -d demo -s -f demo.sql

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

Скрытый текст

\d aircrafts_data

                Table «bookings.aircrafts_data»

    Column     |     Type     | Collation | Nullable | Default

—————+—————+————+———-+———

 range         | integer      |           | not null |

 aircraft_code | character(3) |           | not null |

 model         | jsonb        |           | not null |

 

\d airports_data

                Table «bookings.airports_data»

    Column    |     Type     | Collation | Nullable | Default

—————+—————+————+———-+———

 coordinates  | point        |           | not null |

 airport_code | character(3) |           | not null |

 airport_name | jsonb        |           | not null |

 city         | jsonb        |           | not null |

 timezone     | text         |           | not null |

 

\d boarding_passes

                  Table «bookings.boarding_passes»

   Column    |         Type         | Collation | Nullable | Default

————-+———————-+————+———-+———

 boarding_no | integer              |           | not null |

 flight_id   | integer              |           | not null |

 seat_no     | character varying(4) |           | not null |

 ticket_no   | character(13)        |           | not null |

 

\d bookings

                        Table «bookings.bookings»

    Column    |           Type           | Collation | Nullable | Default

—————+—————————+————+———-+———

 book_date    | timestamp with time zone |           | not null |

 book_ref     | character(6)             |           | not null |

 total_amount | numeric(10,2)            |           | not null |

 

\d flights

                                              Table «bookings.flights«

       Column        |           Type           | Collation | Nullable |                  Default                  

———————+—————————+————+———-+———————————————

 scheduled_departure | timestamp with time zone |           | not null |

 scheduled_arrival   | timestamp with time zone |           | not null |

 actual_departure    | timestamp with time zone |           |          |

 actual_arrival      | timestamp with time zone |           |          |

 flight_id           | integer                  |           | not null | nextval(‘flights_flight_id_seq’::regclass)

 flight_no           | character(6)             |           | not null |

 departure_airport   | character(3)             |           | not null |

 arrival_airport     | character(3)             |           | not null |

 status              | character varying(20)    |           | not null |

 aircraft_code       | character(3)             |           | not null |

 

\d seats

                          Table «bookings.seats»

     Column      |         Type          | Collation | Nullable | Default

——————+————————+————+———-+———

 aircraft_code   | character(3)          |           | not null |

 seat_no         | character varying(4)  |           | not null |

 fare_conditions | character varying(10) |           | not null |

 

\d ticket_flights

                     Table «bookings.ticket_flights»

     Column      |         Type          | Collation | Nullable | Default

——————+————————+————+———-+———

 flight_id       | integer               |           | not null |

 amount          | numeric(10,2)         |           | not null |

 fare_conditions | character varying(10) |           | not null |

 ticket_no       | character(13)         |           | not null |

 

\d tickets

                        Table «bookings.tickets«

     Column     |         Type          | Collation | Nullable | Default

—————-+————————+————+———-+———

 book_ref       | character(6)          |           | not null |

 contact_data   | jsonb                 |           |          |

 passenger_id   | character varying(20) |           | not null |

 passenger_name | text                  |           | not null |

 ticket_no      | character(13)         |           | not null |

Учтя предыдущую проблему с индексами, создаю две копии отредактированного файла со структурой объектов. В одном из файлов, в самом конце удаляю последние строки, которые и создают индексы, начиная со строки:

ALTER TABLE ONLY bookings.aircrafts_data     ADD CONSTRAINT aircrafts_pkey PRIMARY KEY (aircraft_code);

Создаю новую базу данных, выполняю скрипт без создания индексов:

postgres=# create database demo2; \c demo2 \\ \i demo2.sql CREATE DATABASE You are now connected to database "demo1" as user "postgres". SET ...

Перегружаю данные:

time pg_dump -d demo -a | psql -d demo1 real0m9,990s user0m1,881s sys0m0,344s

Перегрузка данных заняла 10 секунд, вместо 3 минут 38 секунд.
Запускаю скрипт с созданием индексов:

time psql -d demo2 -f demo2copy.sql ... SET psql:demo1a.sql:24: ERROR:  schema "bookings" already exists ... ALTER TABLE ALTER TABLE  real0m28,332s user0m0,056s sys0m0,008s

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

Стоит всомнить про упущение. Загрузка данных до создания индексов заняла 10+28=38 секунд. Загрузка данных в таблицы с индексами заняла 3 минуты 28 секунд, то есть медленнее в 5,5 раз. Плюс индексы были неоптимальны и по размеру и по производительности при их использовании. Перестройка индексов заняла 23 секунды. Это цена упущения. Более того, если не перестроить индексы, то всё будет работать, но только медленно.

Параметров у утилиты pg_dump, которые позволяли бы при загрузке данных (режим pg_dump -a) удалять индексы, загружать данные, а потом добавлять индексы отсутствуют. Такой параметр сделал бы работу утилиты более удобной.

Проверяю размеры баз данных:

demo2=# select pg_size_pretty(a), pg_size_pretty(b), 100*(a-b)/(a+b) "%" from (select pg_database_size('demo') a , pg_database_size('demo2') b);  pg_size_pretty | pg_size_pretty | %  ----------------+----------------+---  703 MB         | 697 MB         | 0 (1 row)

После перестановки столбцов размер базы уменьшился на 6Мб. Уменьшение небольшое, но всё-таки уменьшение. Размеры индексов не уменьшились. Уменьшились размеры таблиц ticket_flights со 154Мб до 148Мб (на 3.9%), flights с 6688Кб до 6048Кб (9.57%) или после полного вакуумирования с 6336Кб до 5976Кб (на 9.9%):

demo2=# \dt+ bookings.*                                                  List of relations   Schema  |      Name       | Type  |  Owner   | Persistence | Access method |  Size   |        Description         ----------+-----------------+-------+----------+-------------+---------------+---------+---------------------------  bookings | aircrafts_data  | table | postgres | permanent   | heap          | 16 kB   | Aircrafts (internal data)  bookings | airports_data   | table | postgres | permanent   | heap          | 56 kB   | Airports (internal data)  bookings | boarding_passes | table | postgres | permanent   | heap          | 109 MB  | Boarding passes  bookings | bookings        | table | postgres | permanent   | heap          | 30 MB   | Bookings  bookings | flights         | table | postgres | permanent   | heap          | 6048 kB | Flights  bookings | seats           | table | postgres | permanent   | heap          | 96 kB   | Seats  bookings | ticket_flights  | table | postgres | permanent   | heap          | 148 MB  | Flight segment  bookings | tickets         | table | postgres | permanent   | heap          | 109 MB  | Tickets (8 rows)

Можно ли что-то улучшить?

Посмотрим, почему не уменьшилcя размер таблицы boarding_passes. До перестановки столбцов:

demo=# \d boarding_passes                   Table "bookings.boarding_passes"    Column    |         Type         | Collation | Nullable | Default  -------------+----------------------+-----------+----------+---------  ticket_no   | character(13)        |           | not null |   flight_id   | integer              |           | not null |   boarding_no | integer              |           | not null |   seat_no     | character varying(4) |           | not null |  ...  demo=# select t_data, lp_len, t_hoff from heap_page_items(get_raw_page('boarding_passes','main',0)) limit 3;                           t_data                          | lp_len | t_hoff  ----------------------------------------------------------+--------+--------  \x1d3030303534333532303832323900003bed000001000000073148 |     51 |     24  \x1d3030303534333532303832323400003bed000002000000073241 |     51 |     24  \x1d3030303534333532303831393100003bed000003000000073244 |     51 |     24 (3 rows)

После перестановки столбцов размер строки уменьшился с 51 байта до 49 байт:

demo2=# \d boarding_passes                   Table "bookings.boarding_passes"    Column    |         Type         | Collation | Nullable | Default  -------------+----------------------+-----------+----------+---------  boarding_no | integer              |           | not null |   flight_id   | integer              |           | not null |   seat_no     | character varying(4) |           | not null |   ticket_no   | character(13)        |           | not null |  ...  demo2=# select t_data, lp_len, t_hoff from heap_page_items(get_raw_page('boarding_passes','main',0)) limit 3;                         t_data                        | lp_len | t_hoff  ------------------------------------------------------+--------+--------  \x010000003bed00000731481d30303035343335323038323239 |     49 |     24  \x020000003bed00000732411d30303035343335323038323234 |     49 |     24  \x030000003bed00000732441d30303035343335323038313931 |     49 |     24 (3 rows)

Место не уменьшилось, так как вся строка выравнивается до 8 байт и для 51 байт, 49 байт ближайшее значение одинаково: 56 байт. А вот, если уменьшить строку на один байт, то длина строки была бы 48 байт.

Какие столбцы можно было бы сделать меньше при проектировании схемы хранения? Номер посадочного талона (boarding_no) отведено 4 байта. В нём хранится последовательность создания посадочных талонов, начиная с 1. Даже с учетом перевыпуска посадочных талонов, значение вряд ли превысит 32 тысячи. Поменяем тип integer на int2:

demo=# create table boarding_passes2 (flight_id integer, boarding_no int2, seat_no text, ticket_no text); insert into boarding_passes2 select flight_id, boarding_no, seat_no, ticket_no from boarding_passes; select t_data, lp_len, t_hoff from heap_page_items(get_raw_page('boarding_passes2','main',0)) limit 1; \dt+ boarding_pass*  CREATE TABLE INSERT 0 1894295                       t_data                      | lp_len | t_hoff  --------------------------------------------------+--------+--------  \x3bed000001000731481d30303035343335323038323239 |     47 |     24 (1 row)                                              List of relations   Schema  |       Name       | Type  |  Owner   | Persistence | Access method |  Size  |   Description    ----------+------------------+-------+----------+-------------+---------------+--------+-----------------  bookings | boarding_passes  | table | postgres | permanent   | heap          | 109 MB | Boarding passes  bookings | boarding_passes2 | table | postgres | permanent   | heap          | 94 MB  |  (2 rows)

Размер строки уменьшился на 2 байта и стал 47 байт. Строка будет занимать 48 байт.
Место, занимаемое таблицей уменьшилось на 13.7%. Это хороший результат.

Заключение

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


ссылка на оригинал статьи https://habr.com/ru/articles/895638/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *