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

Статья 7.1 · раздел 7 «Проектирование схемы и данных» · 47 из 109

Типы данных на весах: int и bigint, numeric и float8, varchar(n), timestamptz, uuid v4 и v7

опубликовано
читать
≈ 10 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18, shared_buffers 128 МБ и max_wal_size 1 ГБ по умолчанию; расширения pgstattuple и pg_walinspect; таблицы на 1, 5 и 10 млн строк; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Тип столбца задаёт, сколько байт значение занимает в таблице и в каждом индексе, и в каком порядке ключи ложатся в B-tree. Объявление таблицы разобрано в статье про CREATE TABLE; здесь типы измеряются. int и bigint весят одинаково, varchar(100) не экономит байт, numeric считает вдвое дольше bigint, а uuid v4 вставляется в 4,7 раза дольше v7: 68,4 против 14,7 с на 10 млн строк.

DBA / 01

Куда попадает вставка: 10 млн строк в первичном ключе

Куда попадает вставка: 10 млн строк в первичном ключе Слева bigint и uuid v7: все вставки идут в правый лист B-tree, листья заполнены на 90 процентов. Справа uuid v4: вставки попадают в любой из 49 468 листьев, листья заполнены на 69,8 процента. Внизу три карточки. bigint: индекс 214 МБ, 27 323 листа, 8 чтений буфера, 37 полных образов страниц в WAL, 9,8 секунды. uuid v7: 301 МБ, 38 315 листьев, 108 чтений, 39 образов, 14,7 секунды. uuid v4: 388 МБ, 49 468 листьев, 3 074 268 чтений, 90 145 образов, 68,4 секунды. 90 %90 %90 %90 %90 %70 %70 %70 %70 %70 % bigint и uuid v7: ключ растётuuid v4: ключ случайныйвставка только в правый край деревалистья заполнены на 90 %вставка в любой из 49 468 листьевлист делится пополам: заполнено 69,8 % bigintиндекс214 МБлистьев27 323заполнено90,0 %чтений буфера8FPI в WAL37время9,8 сuuid v7индекс301 МБлистьев38 315заполнено90,0 %чтений буфера108FPI в WAL39время14,7 сuuid v4индекс388 МБлистьев49 468заполнено69,8 %чтений буфера3 074 268FPI в WAL90 145время68,4 с
Монотонный ключ (bigint, uuid v7) пишет в правый край и оставляет листья заполненными на 90 %. Случайный uuid v4 попадает в любой лист, делит листья пополам и за 10 млн вставок прогоняет через shared_buffers 3 074 268 страниц.

Опыт 1: сколько байт стоит значение

Контейнер и таблица с одной строкой по одному значению каждого типа; pg_column_size показывает размер значения, как оно лежит в строке:

docker run -d --name dba-data-types-on-the-scale -e POSTGRES_PASSWORD=pg postgres:18
docker exec -it dba-data-types-on-the-scale psql -U postgres
CREATE TABLE s (i2 int2, i4 int4, i8 int8, f8 float8, ts timestamptz, u uuid, t text, v varchar(100), c char(100));
INSERT INTO s VALUES (1, 1, 1, 1.5, now(), gen_random_uuid(), 'abc', 'abc', 'abc');
SELECT pg_column_size(i2) i2, pg_column_size(i4) i4, pg_column_size(i8) i8, pg_column_size(f8) f8, pg_column_size(ts) tstz,
       pg_column_size(u) uuid, pg_column_size(t) text, pg_column_size(v) varchar100, pg_column_size(c) char100 FROM s;

вывод

 i2 | i4 | i8 | f8 | tstz | uuid | text | varchar100 | char100 
----+----+----+----+------+------+------+------------+---------
  2 |  4 |  8 |  8 |    8 |   16 |    4 |          4 |     101
(1 row)

'abc' в text и в varchar(100) занимает по 4 байта: 3 символа и однобайтовый заголовок. Лимит 100 ничего не резервирует, это проверка длины при вставке. char(100) дополняет значение пробелами до 100 символов, и в строку уходит 101 байт. Длинные значения text вытесняются в TOAST, см. значения в TOAST.

Размер значения ещё не размер строки: строка и индексная запись выравниваются, см. заголовок строки и выравнивание. Семь таблиц по миллиону строк и индексы по int4 и int8:

CREATE TABLE w_i4 (a int4); CREATE TABLE w_i8 (a int8);
CREATE TABLE w_i4i4 (a int4, b int4); CREATE TABLE w_i8i8 (a int8, b int8);
CREATE TABLE w_t (a text); CREATE TABLE w_v (a varchar(100)); CREATE TABLE w_c (a char(100));
INSERT INTO w_i4 SELECT g FROM generate_series(1,1000000) g;
INSERT INTO w_i8 SELECT g FROM generate_series(1,1000000) g;
INSERT INTO w_i4i4 SELECT g,g FROM generate_series(1,1000000) g;
INSERT INTO w_i8i8 SELECT g,g FROM generate_series(1,1000000) g;
INSERT INTO w_t SELECT 'abc' FROM generate_series(1,1000000);
INSERT INTO w_v SELECT 'abc' FROM generate_series(1,1000000);
INSERT INTO w_c SELECT 'abc' FROM generate_series(1,1000000);
CREATE INDEX ON w_i4 (a); CREATE INDEX ON w_i8 (a);
SELECT relname, pg_relation_size(oid) AS bytes, pg_relation_size(oid)/8192 AS pages,
       round(pg_relation_size(oid)::numeric/1000000,1) AS bytes_per_row
FROM pg_class WHERE relname IN ('w_i4','w_i8','w_i4i4','w_i8i8','w_t','w_v','w_c','w_i4_a_idx','w_i8_a_idx') ORDER BY 1;

вывод

  relname   |   bytes   | pages | bytes_per_row 
------------+-----------+-------+---------------
 w_c        | 134299648 | 16394 |         134.3
 w_i4       |  36249600 |  4425 |          36.2
 w_i4_a_idx |  22487040 |  2745 |          22.5
 w_i4i4     |  36249600 |  4425 |          36.2
 w_i8       |  36249600 |  4425 |          36.2
 w_i8_a_idx |  22487040 |  2745 |          22.5
 w_i8i8     |  44285952 |  5406 |          44.3
 w_t        |  36249600 |  4425 |          36.2
 w_v        |  36249600 |  4425 |          36.2
(9 rows)

Одна колонка int4 и одна int8 дают одинаковые 36 249 600 байт таблицы и 22 487 040 байт индекса. Заголовок строки занимает 24 байта, int4 доводит её до 28, а следующая строка начинается на границе 8 байт: 28 округляется до 32, как и 24 + 8. Поэтому два int4 тоже помещаются в 32 байта, а два int8 дают 40 и таблицу на 22 % больше (44 285 952 против 36 249 600). bigint в одиночном ключе бесплатен, а int4 в нём экономит только то, что соседний столбец добирает до границы. w_t и w_v совпадают до байта, w_c вырос в 3,7 раза.

Опыт 2: numeric, float8 и целые копейки

Формат numeric описан в numeric.c (REL_18_6, строки 96–98): число хранится группами по 4 десятичные цифры, на каждую группу один int16.

#define NBASE		10000
#define HALF_NBASE	5000
#define DEC_DIGITS	4			/* decimal digits per NBASE digit */

Размер растёт с числом групп, а не с типом:

CREATE TABLE sn (n numeric);
INSERT INTO sn VALUES (0), (1), (12345678), (1234.56), (123456789012), (0.000001), (1234567890123456789012345678);
SELECT n, pg_column_size(n) AS bytes FROM sn;

вывод

              n               | bytes 
------------------------------+-------
                            0 |     3
                            1 |     5
                     12345678 |     7
                      1234.56 |     7
                 123456789012 |     9
                     0.000001 |     5
 1234567890123456789012345678 |    17
(7 rows)

3 байта заголовка плюс по 2 на группу: 12345678 — две группы (7 байт), 28 цифр — семь (17). Цена арифметики: 5 млн строк с суммой в копейках int8, в numeric(12,2) и в float8, параллельные воркеры выключены, каждый запрос запущен три раза:

CREATE TABLE m (cents int8, n numeric(12,2), d float8);
INSERT INTO m SELECT c, c / 100.0, c / 100.0 FROM (SELECT (random() * 1000000)::int8 AS c FROM generate_series(1, 5000000)) s;
VACUUM ANALYZE m;
SET max_parallel_workers_per_gather = 0;
\timing on
SELECT sum(cents) FROM m;  -- три раза; затем sum(n) и sum(d)
\timing off
SELECT sum(cents) / 100.0 AS cents, sum(n) AS numeric, sum(d) AS float8 FROM m;
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8 AS float8_eq, 0.1::numeric + 0.2 = 0.3 AS numeric_eq;

вывод (время девяти запусков, порядок: cents, n, d; после — итоги и сравнение)

Time: 114.643 ms
Time: 113.742 ms
Time: 117.186 ms
Time: 225.146 ms
Time: 225.795 ms
Time: 242.948 ms
Time: 134.305 ms
Time: 131.879 ms
Time: 136.424 ms
        cents         |    numeric     |      float8       
----------------------+----------------+-------------------
 25000914076.24000000 | 25000914076.24 | 25000914076.23777
(1 row)
 float8_eq | numeric_eq 
-----------+------------
 f         | t
(1 row)

Сумма numeric совпала с суммой целых копеек, float8 ушёл в хвост .23777, и 0.1 + 0.2 = 0.3 для float8 ложно. По времени numeric примерно вдвое дольше bigint (медианы 225,8 и 114,6 мс) и в 1,7 раза дольше float8 (134,3 мс). Деньги — numeric(p,s) или целые копейки; float8 годится для измерений, где округление в 16-й цифре не важно.

timestamp и timestamptz: момент или показание часов

timestamp хранит показание часов без зоны, timestamptz приводит значение к UTC при записи и показывает в зоне сессии. Одна и та же строка '2026-10-28 12:00', вставленная из двух зон:

CREATE TABLE ev (session_tz text, ts timestamp, tz timestamptz);
SET TimeZone = 'Europe/Moscow';
INSERT INTO ev VALUES (current_setting('TimeZone'), '2026-10-28 12:00', '2026-10-28 12:00');
SET TimeZone = 'America/New_York';
INSERT INTO ev VALUES (current_setting('TimeZone'), '2026-10-28 12:00', '2026-10-28 12:00');
SET TimeZone = 'UTC';
SELECT * FROM ev;

вывод

    session_tz    |         ts          |           tz           
------------------+---------------------+------------------------
 Europe/Moscow    | 2026-10-28 12:00:00 | 2026-10-28 09:00:00+00
 America/New_York | 2026-10-28 12:00:00 | 2026-10-28 16:00:00+00
(2 rows)

В ts обе строки одинаковы, хотя события разделяют 7 часов, и восстановить, из какой зоны пришла запись, нельзя. tz хранит момент: 09:00 и 16:00 UTC. Размер у timestamptz тот же 8 байт, что у int8 (опыт 1), так что выбор определяется смыслом, а не байтами.

Опыт 3: bigint, uuid v4 и uuid v7 на 10 млн строк

В PostgreSQL 18 появилась uuidv7() (заметки о выпуске); gen_random_uuid() по-прежнему выдаёт v4. Версия видна по первой цифре третьей группы:

SELECT uuidv7(), gen_random_uuid();

вывод

                uuidv7                |           gen_random_uuid            
--------------------------------------+--------------------------------------
 01a17fbf-44a1-7b15-acd6-f3ffb9d73c7f | ed764133-8106-4097-9c7a-35bde9c3c1d7
(1 row)

Первые 48 бит v7 — время в миллисекундах, следом 12 бит доли миллисекунды (uuid.c, generate_uuidv7, строки 608–613):

 * UUID version 7 consists of a Unix timestamp in milliseconds (48 bits) and
 * 74 random bits, excluding the required version and variant bits. To ensure
 * monotonicity in scenarios of high-frequency UUID generation, we employ the
 * method "Replace Leftmost Random Bits with Increased Clock Precision (Method 3)",
 * described in the RFC. This method utilizes 12 bits from the "rand_a" bits
 * to store a 1/4096 (or 2^12) fraction of sub-millisecond precision.

Опыт: таблица из одного столбца id с первичным ключом, 10 млн строк за один INSERT ... SELECT, три варианта подряд; после замера таблица удаляется, чтобы данные не превысили 2 ГБ. Скрипт run.sql до вставки делает CHECKPOINT и сбрасывает статистику ввода-вывода, после вставки снимает размер WAL по LSN, счётчики pg_stat_io клиентского бэкенда и pgstatindex из pgstattuple:

\set ON_ERROR_STOP 1
DROP TABLE IF EXISTS k;
CHECKPOINT;
CREATE TABLE k (id :type PRIMARY KEY);
SELECT pg_stat_reset_shared('io'), pg_stat_reset_shared('wal') \gset
SELECT pg_current_wal_lsn() AS l0, clock_timestamp() AS t0 \gset
INSERT INTO k SELECT :expr FROM generate_series(1, :rows) g;
SELECT pg_current_wal_lsn() AS l1, clock_timestamp() AS t1 \gset
SELECT pg_stat_force_next_flush();
SELECT :'kind' AS kind, round(extract(epoch FROM :'t1'::timestamptz - :'t0'::timestamptz)::numeric, 1) AS seconds,
       pg_size_pretty(pg_wal_lsn_diff(:'l1', :'l0')) AS wal, (SELECT wal_fpi FROM pg_stat_wal) AS fpi,
       (SELECT sum(reads) FROM pg_stat_io WHERE object = 'relation' AND context = 'normal' AND backend_type = 'client backend') AS buf_reads,
       (SELECT sum(writes) FROM pg_stat_io WHERE object = 'relation' AND context = 'normal' AND backend_type = 'client backend') AS buf_writes,
       pg_size_pretty(pg_table_size('k')) AS heap;
SELECT :'kind' AS kind, pg_size_pretty(index_size::bigint) AS index_size, tree_level, leaf_pages, avg_leaf_density, leaf_fragmentation FROM pgstatindex('k_pkey');
docker exec -i dba-data-types-on-the-scale psql -U postgres -c 'CREATE EXTENSION pgstattuple'
docker exec -i dba-data-types-on-the-scale psql -U postgres -v kind=uuid_v7 -v type=uuid -v expr='uuidv7()' -v rows=10000000 -f - < run.sql
# bigint: -v type=bigint -v expr=g;  uuid v4: -v type=uuid -v expr='gen_random_uuid()'

вывод (три прогона, строки сведены в две таблицы; fpi — полностраничные образы в WAL, buf_reads и buf_writes — чтения и записи страниц в shared_buffers клиентским бэкендом)

  kind   | seconds |   wal   |  fpi  | buf_reads | buf_writes |  heap  
---------+---------+---------+-------+-----------+------------+--------
 bigint  |     9.8 | 1244 MB |    37 |         8 |      49616 | 346 MB
 uuid_v7 |    14.7 | 1409 MB |    39 |       108 |      70435 | 422 MB
 uuid_v4 |    68.4 | 2042 MB | 90145 |   3074268 |    3133106 | 422 MB

  kind   | index_size | tree_level | leaf_pages | avg_leaf_density | leaf_fragmentation 
---------+------------+------------+------------+------------------+--------------------
 bigint  | 214 MB     |          2 |      27323 |            90.04 |                  0
 uuid_v7 | 301 MB     |          2 |      38315 |            89.99 |                  0
 uuid_v4 | 388 MB     |          2 |      49468 |            69.78 |              49.81

Три разницы. Ширина. uuid вдвое шире bigint: индекс v7 на 41 % больше (301 против 214 МБ), таблица тоже шире (422 против 346 МБ). Плотность листьев у обоих 90 %: вставки идут в правый край, и страницы там делятся так, что слева остаётся 90 % (как в статье про B-tree). Порядок. v4 ложится в любой лист и делит его пополам: плотность 69,8 %, leaf_fragmentation 49,81 (около половины листьев лежат не в логическом порядке файла), индекс ещё на 29 % больше v7 при тех же 10 млн ключей. Память. Индекс v4 весит 388 МБ при 128 МБ shared_buffers, поэтому почти каждая вставка читает свой лист: 3 074 268 страниц, около 23,5 ГиБ, прошло через буфер, против 108 у v7. Диск при этом был в кеше ОС, иначе разрыв был бы больше. Первая запись страницы после контрольной точки пишет её целиком, и у v4 таких образов 90 145 вместо 39, отсюда 2042 против 1409 МБ WAL (причина — полностраничные записи).

WAL вставки на 10 млн строк (1244–2042 МБ) больше max_wal_size в 1 ГБ, и начало диапазона l0 могло быть переиспользовано раньше, чем его прочтёт pg_walinspect. Поэтому разделения посчитаны на 1 млн строк по WAL между l0 и l1:

CREATE EXTENSION pg_walinspect;
SELECT sum(count) AS page_splits FROM pg_get_wal_stats(:'l0', :'l1', per_record => true)
WHERE "resource_manager/record_type" LIKE 'Btree/SPLIT%';

вывод (три отдельных прогона по 1 млн строк: bigint, uuid_v7, uuid_v4; считаются разделения всех уровней)

 page_splits 
-------------
        2741
 page_splits 
-------------
        3849
 page_splits 
-------------
        4861

Число растёт примерно как число листьев, но не объясняет разрыва во времени: он в чтениях и в образах страниц. В InnoDB таблица и есть дерево по первичному ключу, поэтому там разница сильнее: 1306,4 с у v4 против 22,3 с у v7 на 5 млн строк (кластерный индекс InnoDB). В PostgreSQL таблица остаётся кучей, и v4 бьёт только по индексам.

Что из этого следует

  • Одиночный числовой ключ: bigint. В таблице и индексе он стоит как int4, а потолок int4 в 2 147 483 647 не нужно будет обходить.
  • Размер строки зависит от порядка столбцов, не только от типов. Пары int4 умещаются в границу 8 байт; смешанный порядок с int8 добавляет пустоты (замер на миллионе строк — в статье про заголовок строки).
  • varchar(n) и text весят одинаково. Лимит ставь там, где он правило предметной области (длина кода страны), не как оптимизацию. char(n) не используй.
  • Деньги: numeric(p,s) или целые копейки. float8 для денег накапливает хвост (.23777), а numeric вдвое медленнее bigint на сумме.
  • Момент времени: timestamptz. timestamp годится для дат календаря и расписаний «в местное время», где зона не важна.
  • Если нужен uuid в ключе, это v7. v7 шире bigint на 41 % в индексе, но v4 ещё на 29 % шире v7 и читает страницу на каждую вставку. Версию v4 оставь для значений, которые не должны угадываться по времени.

Чек-лист

  1. Найди широкие и случайные ключи: SELECT indexrelid::regclass, pg_relation_size(indexrelid) FROM pg_index WHERE indexrelid > 16383 ORDER BY 2 DESC LIMIT 10; и тип первого столбца этих индексов.
  2. Для индекса с uuid v4 запусти pgstatindex('имя_индекса'): avg_leaf_density около 70 и высокая leaf_fragmentation указывают на случайные вставки.
  3. Проверь денежные столбцы на float8 и real: SELECT table_name, column_name FROM information_schema.columns WHERE data_type IN ('double precision','real');
  4. Найди timestamp без зоны для моментов событий: SELECT table_name, column_name FROM information_schema.columns WHERE data_type = 'timestamp without time zone';
  5. Убери контейнер вместе с томом: docker rm -f -v dba-data-types-on-the-scale.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru