Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение17 мин
Количество просмотров8.9K

Порядок столбцов в таблицах влияет на компактность и производительность. При небольшом числе строк на это не обращают внимание. Если размер таблиц большой, то полезно даже небольшое уменьшение объема хранения. У столбцов в таблицах 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)

typlen = -1 это типы переменной длины. (ещё есть один единственный тип со значением -2, но я о нём ничего не знаю). Все используемые в столбцах таблиц 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
real	0m9,990s
user	0m1,881s
sys	0m0,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

real	0m28,332s
user	0m0,056s
sys	0m0,008s

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

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

Параметров у утилиты 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 с точки зрения оптимизации места хранения и рассмотрен пример перестановки столбцов.

Теги:
Хабы:
Всего голосов 23: ↑20 и ↓3+23
Комментарии6

Публикации

Ближайшие события