博客/技术探讨

Глубокий анализ: В эпоху ИИ базы данных вступают в «эпоху унифицированного хранения» (Часть II)

· YMatrix Team
#技术探讨

4. Пути чтения и записи данных

4.1 Общий обзор пути записи

Для адаптации к различным сценариям в YMatrix предусмотрены три режима записи, которые определяются совместно табличными параметрами prefer_load_mode и rowstore_size.

Процесс записи в различных режимах

4.1.1 Режим Single

В режиме Single данные накапливаются в локальной памяти до объёма 1 МБ, после чего напрямую записываются в строкувое хранилище (rowstore). Процесс аналогичен традиционной записи в структуру Heap в PostgreSQL: данные размещаются непосредственно в Shared Buffers.

Стратегия вытеснения в Shared Buffers

  • Преимущества: минимальная задержка вставки, низкое потребление памяти.

  • Недостатки:

    • При массовой загрузке требуется дополнительный этап слияния.

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

    • Эффективность аналитических запросов (AP) и индексов BRIN ниже, чем у columnstore.

    • Отсутствие сжатия приводит к увеличению объёма данных и логов WAL в несколько раз.

4.1.2 Режим Bulk

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

  • Преимущества:

    • Сжатие данных снижает нагрузку на диск и I/O.

    • Высокая эффективность аналитических запросов и BRIN.

    • Отсутствие дополнительного этапа преобразования, ниже write amplification.

  • Недостатки:

    • Потребление памяти значительно выше, чем у rowstore.

    • Задержка одиночной вставки выше, чем в режиме Single.

4.1.3 Режим Normal

Умный режим вставки. Данные копируются в локальную память. При достижении размера rowstore_size они напрямую преобразуются в columnstore и сбрасываются на диск. Если по окончании вставки объем данных в локальной памяти не достигает значения (rowstore_size / 2), они записываются в текущий rowstore; в противном случае преобразуются в columnstore и сбрасываются на диск. Режим по умолчанию — normal.

4.1.4 Сравнение скорости записи в разных режимах

Одиночная вставка (single insert)

Тип теста Количество транзакций на клиент Количество клиентов Количество потоков Средняя задержка (мс) TPS (включая подключение) TPS (не включая подключение)
single_insert.sql 100 000 1 1 1.98 505.03 505.06
bulk_insert.sql 100 000 1 1 15.282 65.44 65.44
normal_insert.sql 100 000 1 1 1.968 508.02 508.05

Малая пакетная вставка

Режим Общее время выполнения Общее количество секунд (с) Количество строк в секунду (строк/с)
single 2 мин 27 с 147 ≈ 680
normal 2 мин 28 с 148 ≈ 676
bulk 4 мин 55 с 295 ≈ 339

Массовая загрузка (COPY из CSV)

Режим Количество строк Время выполнения (секунд)
bulk 50 000 000 69.287
single 50 000 000 50.868
normal 50 000 000 70.612

Сравнение MARS3 Single с Heap

Сжатие достигает примерно 10 раз, производительность TPS ниже, чем у Heap, примерно на 25%.

Имя таблицы Коэффициент сжатия Экономия хранилища
fi_voucher 7:01 86%
fi_voucher_b 15:01 93%
aai_voucher 4,5:1 78%
aai_voucher_record 8,9:1 89%

4.1.5 Сравнение объёма логов WAL

При вставке одинакового объёма данных скорость генерации WAL совпадает, но общий объём логов в MARS3 составляет примерно 1/3 от объёма для Heap, что существенно экономит место на диске.

4.2 Общий обзор пути чтения

Поскольку сжатие в формате Range использует переменную длину данных, невозможно применить готовый механизм Shared Buffers. Поэтому был разработан кэш только для чтения, поддерживающий данные переменной длины. Он предназначен для обработки запросов, поступающих по пути сканирования индекса — такие запросы чувствительны к задержке. С помощью кэширования мы снижаем эффект увеличения объема чтения (read amplification), возникающий при работе с колоночным хранилищем. При этом в кэше хранится формат, оптимизированный для сканирования индекса: скан точно знает, какой кортеж требуется, поэтому формат кэша и механизм чтения позволяют быстро найти нужную строку.

Для движка MARS3 реализован кэш, аналогичный Shared Buffers, называемый varbuffer. Varbuffer в основном используется для оптимизации сканирования индексов. Поскольку в columnstore все данные записываются и считываются напрямую, varbuffer кэширует распакованные данные stripe. На диске при этом хранятся только сжатые блоки. Если в рабочей нагрузке преобладает сканирование индексов, данный параметр можно настроить под конкретные условия (требуется перезапуск системы).

① Сначала проверяется varbuffer
   Попадание → напрямую используется распакованный stripe (отображается как stripe до сжатия)

② Промах → выполняется buffer io
       ↓
   Попадание в OS cache?
       Да → получен сжатый stripe (без дискового IO)
       Нет → реальное чтение с диска

③ Получен сжатый stripe
   → распаковка
   → помещение в varbuffer
postgres=# show mx_varbuffer_size ;                                                                                                                                                                                                                         
 mx_varbuffer_size                                                                                                                                                                                                                                          
-------------------                                                                                                                                                                                                                                         
 1GB                                                                                                                                                                                                                                                        
(1 row)

4.2.1 Влияние varbuffer на сканирование по индексам

Согласно теоретическим оценкам, чем больше размер varbuffer, тем выше эффективность сканирования индексов, при этом влияние на последовательное сканирование менее существенное. Также необходимо учитывать влияние OS cache: перед каждым тестом следует очистить OS cache и перезапустить базу данных.

Формирование набора данных

CREATE TABLE t_m3 (
  id   bigint,
  ts   timestamptz,
  v    double precision,
  pad  int
)
USING MARS3
DISTRIBUTED BY (id);

INSERT INTO t_m3
SELECT
  (g % 1000000)::bigint AS id,
  '2026-01-01'::timestamptz + (g || ' seconds')::interval AS ts,
  (random()*1000)::float8 AS v,
  (g % 1000)::int AS pad
FROM generate_series(1, 20000000) g;
ANALYZE t_m3;

CREATE INDEX idx_t_m3_id_ts ON t_m3 (id, ts);
ANALYZE t_m3;

Проверка результатов

adw=# show mx_varbuffer_size;
 mx_varbuffer_size
-------------------
 64MB
(1 row)

adw=# \q
[mxadmin@sdw ~]$ /usr/bin/time -f "pass1: %e s" bash -c '
> for i in $(seq 1 20000); do
>   psql -X -qAt -c "SELECT v FROM t_m3 WHERE id=$i AND ts='\''2026-01-02 00:00:00+00'\'';" >/dev/null
> done'
pass1: 238.07 s


adw=# show mx_varbuffer_size;
 mx_varbuffer_size
-------------------
 1GB
(1 row)

adw=# \q
[mxadmin@sdw ~]$ /usr/bin/time -f "pass2: %e s" bash -c '
> for i in $(seq 1 20000); do
>   psql -X -qAt -c "SELECT v FROM t_m3 WHERE id=$i AND ts='\''2026-01-02 00:00:00+00'\'';" >/dev/null
> done'
pass2: 238.72 s

adw=# select * from t_m3 where ts = '2026-01-02 00:00:00+00';
   id   |           ts           |         v         | pad
--------+------------------------+-------------------+-----
 115200 | 2026-01-02 08:00:00+08 | 681.0068987279756 | 200
(1 row)

Поскольку каждый запрос возвращает не более 0–1 строк и практически не происходит повторного использования stripe, не формируется рабочий набор, пригодный для кэширования, поэтому разница между двумя вариантами незначительна.

Перепроектирование должно удовлетворять 4 условиям:

  • Стабильное срабатывание сканирования индексов

  • За один запрос возвращается средний объем данных

  • Повторяющиеся запросы многократно попадают в одну и ту же партию stripe, формируя горячую область

  • Размер горячего рабочего набора находится в диапазоне между 64MB и 1GB

  • Из-за влияния OS cache перед каждой проверкой необходимо выполнить очистку OS cache командой echo 3 > /proc/sys/vm/drop_caches и перезапустить базу данных

Вывод теста: При условиях холодного запуска (с очисткой OS page cache и перезапуском базы данных) при выполнении 1000 агрегированных запросов MxVIndexScan на одном и том же горячем окне увеличение параметра mx_varbuffer_size с 64MB до 1GB снизило общее время выполнения с 87.986 с до 17.514 с (примерно 5.02-кратное ускорение, снижение времени выполнения на 80.1%). Данный результат показывает, что объем varbuffer играет определяющую роль в повторном использовании распакованных колонковых данных при сканировании индексов в MARS3; при недостаточном объеме возникают значительные накладные расходы на повторную распаковку и вытеснение из кэша.

DROP TABLE IF EXISTS t_m3_vb_test;

CREATE TABLE t_m3_vb_test (
    device_id   int,                -- один из ключевых столбцов запроса (высокая кардинальность)
    ts          timestamptz,        -- временной измерение (второй столбец индекса)
    metric_id   smallint,           -- идентификатор метрики (низкая кардинальность)
    v1          double precision,   -- часто используемый числовой столбец
    v2          double precision,   -- часто используемый числовой столбец
    v3          double precision,   -- часто используемый числовой столбец
    status      int,                -- столбец состояния (низкая кардинальность)
    tag         int                 -- заполнительный столбец (увеличивает размер рабочего набора)
)
USING MARS3
DISTRIBUTED BY (device_id);

CREATE INDEX idx_t_m3_vb_test_dev_ts ON t_m3_vb_test (device_id, ts);
ANALYZE t_m3_vb_test;

-- Около 30 миллионов записей
INSERT INTO t_m3_vb_test
SELECT
    d.device_id,
    t.ts,
    m.metric_id,
    (random() * 1000)::float8 AS v1,
    (random() * 1000)::float8 AS v2,
    (random() * 1000)::float8 AS v3,
    (random() * 10)::int      AS status,
    (random() * 100000)::int  AS tag
FROM generate_series(1, 100) AS d(device_id)
CROSS JOIN generate_series(
    '2026-01-01 00:00:00+00'::timestamptz,
    '2026-01-01 23:59:59+00'::timestamptz,
    '1 second'::interval
) AS t(ts)
CROSS JOIN generate_series(1, 4) AS m(metric_id);

64 MB varbuffer

При каждом выполнении index scan происходит многократный доступ к одним и тем же stripe / range. Объем varbuffer недостаточен для размещения распакованных колонковых данных, относящихся к данному горячему окну, поэтому на каждой итерации возникает большое количество:

  • Повторных чтений stripe

  • Повторных распаковок

  • Повторного формирования исполнительных пакетов

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

adw=# show mx_varbuffer_size ;
 mx_varbuffer_size
-------------------
 64MB
(1 row)

adw=# set enable_seqscan to off;
SET
adw=# set enable_bitmapscan to off;
SET
adw=# \timing on
Timing is on.
adw=# DO $$
DECLARE i int;
DECLARE s1 float8;
DECLARE s2 float8;
DECLARE s3 float8;
BEGIN
  FOR i IN 1..1000 LOOP
    SELECT sum(v1), avg(v2), max(v3)
      INTO s1, s2, s3
    FROM t_m3_vb_test
    WHERE device_id = 42
      AND ts >= '2026-01-01 10:00:00+00'
      AND ts <  '2026-01-01 14:00:00+00';
  END LOOP;
END$$;
DO
Time: 87986.026 ms (01:27.986)

1 GB varbuffer

При размере varbuffer 1GB:

  • Распакованные колонковые данные, относящиеся к горячему окну, с высокой вероятностью могут длительное время храниться в varbuffer

  • Первый выполненный запрос осуществляет прогрев кэша

  • Последующие 999 запросов в основном попадают в varbuffer

  • Исключаются повторная распаковка и повторная подготовка колонковых пакетов

Поэтому общее время выполнения значительно снижается.

adw=# show mx_varbuffer_size ;
 mx_varbuffer_size
-------------------
 1GB
(1 row)

adw=# set enable_seqscan to off;
SET
adw=# set enable_bitmapscan to off;
SET
adw=# \timing on
Timing is on.
adw=# DO $$
DECLARE i int;
DECLARE s1 float8;
DECLARE s2 float8;
DECLARE s3 float8;
BEGIN
  FOR i IN 1..1000 LOOP
    SELECT sum(v1), avg(v2), max(v3)
      INTO s1, s2, s3
    FROM t_m3_vb_test
    WHERE device_id = 42
      AND ts >= '2026-01-01 10:00:00+00'
      AND ts <  '2026-01-01 14:00:00+00';
  END LOOP;
END$$;
DO
Time: 17514.349 ms (00:17.514)

4.2.2 Влияние varbuffer на последовательное сканирование

В предыдущих тестах мы вручную устанавливали параметры enable_seqscan и enable_bitmapscan, чтобы принудительно использовать сканирование по индексу. В этой проверке параметры не устанавливаются, и оценивается влияние varbuffer на последовательное сканирование. Результаты теста показывают, что разница практически отсутствует.

varbuffer в основном оптимизирует случайный/горячий доступ через индексы, а не основной путь последовательного сканирования.

64 MB varbuffer

adw=# set enable_bitmapscan to off;
SET
adw=# set enable_indexscan to off;
SET
adw=# \timing on
Timing is on.
adw=# DO $$
DECLARE i int;
DECLARE s1 float8;
DECLARE s2 float8;
DECLARE s3 float8;
BEGIN
  FOR i IN 1..1000 LOOP
    SELECT sum(v1), avg(v2), max(v3)
      INTO s1, s2, s3
    FROM t_m3_vb_test
    WHERE device_id = 42
      AND ts >= '2026-01-01 10:00:00+00'
      AND ts <  '2026-01-01 14:00:00+00';
  END LOOP;
END$$;
DO
Time: 29928.241 ms (00:29.928)

1 GB varbuffer

adw=# set enable_bitmapscan to off;
SET
adw=# set enable_indexscan to off;
SET
adw=# show mx_varbuffer_size ;
 mx_varbuffer_size
-------------------
 1GB
(1 row)

adw=# \timing on
Timing is on.
adw=# DO $$
DECLARE i int;
DECLARE s1 float8;
DECLARE s2 float8;
DECLARE s3 float8;
BEGIN
  FOR i IN 1..1000 LOOP
    SELECT sum(v1), avg(v2), max(v3)
      INTO s1, s2, s3
    FROM t_m3_vb_test
    WHERE device_id = 42
      AND ts >= '2026-01-01 10:00:00+00'
      AND ts <  '2026-01-01 14:00:00+00';
  END LOOP;
END$$;
DO
Time: 28216.994 ms (00:28.217)

4.3 Сжатие и его влияние на производительность

Каждые compress_threshold строк (по умолчанию 1200) мы называем один Range; данные одного столбца в пределах одного Range (включая compress_threshold строк) называются stripe; если данные этого столбца слишком большие, то stripe разбивается на несколько блоков по 1 МБ, и при чтении не считывается сразу весь объем данных на compress_threshold строк. Более подробные детали см. в разделе Range и Stripe.

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

4.3.1 Влияние compress_threshold на чтение

id compress_threshhold=1200 3600 10000 50000
1 8.8032 7.7846 6.6471 6.6824
2 0.981 0.997 2.2284 2.2322
3 3.3512 3.3143 3.3184 3.3177
4 7.7059 7.7326 6.6034 5.5994
5 5.5732 5.5042 5.5939 6.633
6 0.478 0.38 0.545 0.545
7 1.1908 1.1917 2.2172 2.2222
8 3.3602 3.3129 3.3018 3.3227
9 8.8429 7.74 7.7751 7.7795
10 3.3835 4.4239 5.5496 5.5499
11 0.845 0.743 2.2307 2.2299
12 1.1998 1.1887 2.2164 2.2138
13 6.6514 6.6351 6.6784 6.6424
14 0.837 0.776 1.1364 1.1275
15 1.1436 1.1359 1.183 1.1783
16 1.1768 1.1787 2.2673 2.2757
17 9.9419 8.8705 8.8922 8.8771
18 11.11337 11.11522 10.10384 10.10647
19 4.4792 3.3942 4.4918 4.467
20 2.2668 2.2389 3.3475 3.3346
21 11.11391 10.10633 11.1119 10.10742
22 3.3009 2.2864 3.372 3.3377
sum 97.73958 92.05015 100.81134 99.78189

4.3.2 Влияние compress_threshold на запись

Тестовый сценарий compress_threshold 1200 3600 10000 50000
Производительность записи Партиционированная таблица (строк/с) 852334 919031 1055371 1057424
Производительность записи Непартиционированная таблица (строк/с) 991463 1033751 1054292 1076714
Производительность запросов time_bucket=1h (диапазон 1 день) 357 мс 342 мс 333 мс 324 мс
Производительность запросов time_bucket=1d (диапазон 1 месяц) 7384 мс 6312 мс 6008 мс 6025 мс
Производительность запросов time_bucket=30d (диапазон 1 год) 94278 мс 75101 мс 68184 мс 69006 мс
Производительность запросов Точечный запрос 19.416 мс 20.043 мс 23.692 мс 37.844 мс

4.3.3 Сжатие индексов

CREATE INDEX idx_name ON table_name
USING mars3btree (column_list)
WITH (
    compresstype = 'lz4',
    compresslevel = 1,
    compressctid = true,
    minmax = true
);
  • compresstype: lz4 (быстрое сжатие/распаковка), zstd (высокая степень сжатия).

  • compresslevel: 1–9 (1 — скорость, 6–9 — степень сжатия).

  • compressctid: сжатие столбца CTID (рекомендовано включать).

В сценарии работы одного из клиентов после включения сжатия индексов:

На кластере TOB влияние оказалось незначительным: нагрузка на CPU и память узлов практически не изменилась, при этом удалось сэкономить 24% места в разделах Ymatrix. На кластере TOC эффект оказался выраженным: за счет некоторого увеличения нагрузки на CPU и память удалось сэкономить 63% места в разделах Ymatrix. При запросе GPS‑данных за 1 день по 50 автомобилям пропускная способность сервиса с включенным сжатием индексов в 3,3 раза выше, чем без сжатия.При запросе GPS‑данных за 3 дня по 50 автомобилям пропускная способность сервиса с включенным сжатием индексов в 1,1 раза выше, чем без сжатия.

4.3.4 Влияние ключа сортировки на сжатие

Как уже упоминалось ранее, сжатие (будь то алгоритмы zstd/lz4 или кодирования RLE/dict/bitpack) опирается на ключевой факт: чем более регулярными являются данные внутри одного блока или одного stripe, тем выше эффективность сжатия. Методы lz4/zstd полагаются на повторяющиеся подстроки и повторяющиеся паттерны. После сортировки комбинации полей внутри одного блока становятся более похожими, особенно в широких таблицах. Когда данные близких сущностей или близких моментов времени сгруппированы в одном stripe, диапазон значений внутри блока сужается, усиливаются повторения и последовательные повторения, уменьшается размер словаря, а также снижается разрядность (bit‑ширина) при использовании delta/bitpacking.

Всё это повышает эффективность кодирования и универсального сжатия.

adw=# CREATE TABLE t_sort_good (                                                                                                                                                                                                                            
  device_id   int NOT NULL,                                                                                                                                                                                                                                 
  ts          timestamptz NOT NULL,                                                                                                                                                                                                                         
  site_id     int NOT NULL,                                                                                                                                                                                                                                 
  status      smallint NOT NULL,                                                                                                                                                                                                                            
  v1          double precision NOT NULL,                                                                                                                                                                                                                    
  v2          double precision NOT NULL,                                                                                                                                                                                                                    
  attrs       jsonb NOT NULL                                                                                                                                                                                                                                
)                                                                                                                                                                                                                                                           
USING MARS3                                                                                                                                                                                                                                                 
WITH (compresstype=zstd,compresslevel=3,mars3options='prefer_load_mode=bulk')                                                                                                                                                                               
ORDER BY (device_id, ts);                                                                                                                                                                                                                                   
NOTICE:  Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'device_id' as the Greenplum Database data distribution key for this table.                                                                                                       
HINT:  The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew.                                                                                                  
CREATE TABLE 

adw=# CREATE TABLE t_sort_bad (                                                                                                                                                                                                                             
  device_id   int NOT NULL,                                                                                                                                                                                                                                 
  ts          timestamptz NOT NULL,                                                                                                                                                                                                                         
  site_id     int NOT NULL,                                                                                                                                                                                                                                 
  status      smallint NOT NULL,                                                                                                                                                                                                                            
  v1          double precision NOT NULL,                                                                                                                                                                                                                    
  v2          double precision NOT NULL,                                                                                                                                                                                                                    
  attrs       jsonb NOT NULL                                                                                                                                                                                                                                
)                                                                                                                                                                                                                                                           
USING MARS3                                                                                                                                                                                                                                                 
WITH (compresstype=zstd,compresslevel=3,mars3options='prefer_load_mode=bulk')                                                                                                                                                                               
ORDER BY (ts, device_id);                                                                                                                                                                                                                                   
NOTICE:  Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'device_id' as the Greenplum Database data distribution key for this table.                                                                                                       
HINT:  The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew.                                                                                                  
CREATE TABLE 

Затем генерируется исходный источник данных, и данные одновременно вставляются в обе таблицы.

DROP TABLE IF EXISTS t_src;
CREATE UNLOGGED TABLE t_src AS
WITH
  dev AS (
    SELECT d AS device_id,
           (d % 200) AS site_id,
           (1000 + (d % 500))::double precision AS base
    FROM generate_series(1, 10000) AS d
  ),
  pts AS (
    SELECT device_id, site_id, base,
           g AS seq,
           (timestamp '2026-01-01' + (g || ' seconds')::interval) AS ts
    FROM dev
    CROSS JOIN generate_series(1, 1000) AS g
  )
SELECT
  device_id,
  ts,
  site_id,
  -- status: изменение каждые 300 секунд, формирование длинного сегмента
  ((seq / 300) % 4)::smallint AS status,
  -- v1/v2: базовое значение устройства + небольшие колебания
  (base + (seq % 10) * 0.1)::double precision AS v1,
  (base * 0.1 + (seq % 20) * 0.01)::double precision AS v2,
  -- attrs: ключи имеют схожие шаблоны, значения изменяются по устройству/времени
  jsonb_build_object(
    'fw',  '1.2.' || (device_id % 10),
    'model', 'm' || (device_id % 50),
    'tag',  'S' || site_id,
    'k',    seq % 5
  ) AS attrs
FROM pts;

INSERT INTO t_sort_good SELECT * FROM t_src;
INSERT INTO t_sort_bad  SELECT * FROM t_src;

Для обеспечения точности данных после завершения вставки выполняются несколько команд VACUUM FULL + VACUUM.

adw=# select segid,level,total_nruns,visible_nruns,invisible_nruns,level_size from matrixts_internal.mars3_level_stats('t_sort_good') where level_size  '0 bytes';                                                                                        
 segid | level | total_nruns | visible_nruns | invisible_nruns | level_size                                                                                                                                                                                 
-------+-------+-------------+---------------+-----------------+------------                                                                                                                                                                                
     0 |     1 |           1 |             1 |               0 | 8839 kB                                                                                                                                                                                    
     1 |     1 |           1 |             1 |               0 | 8933 kB                                                                                                                                                                                    
     3 |     1 |           1 |             1 |               0 | 8552 kB                                                                                                                                                                                    
     2 |     1 |           1 |             1 |               0 | 8728 kB                                                                                                                                                                                    
(4 rows)                                                                                                                                                                                                                                                    

adw=# select segid,level,total_nruns,visible_nruns,invisible_nruns,level_size from matrixts_internal.mars3_level_stats('t_sort_bad') where level_size  '0 bytes';                                                                                         
 segid | level | total_nruns | visible_nruns | invisible_nruns | level_size                                                                                                                                                                                 
-------+-------+-------------+---------------+-----------------+------------                                                                                                                                                                                
     0 |     2 |           1 |             1 |               0 | 26 MB                                                                                                                                                                                      
     1 |     2 |           1 |             1 |               0 | 26 MB                                                                                                                                                                                      
     3 |     2 |           1 |             1 |               0 | 25 MB                                                                                                                                                                                      
     2 |     2 |           1 |             1 |               0 | 25 MB                                                                                                                                                                                      
(4 rows)                                                                                                                                                                                                                                                  

adw=# \dt+ t_sort_bad                                                                                                                                                                                                                                       
                           List of relations                                                                                                                                                                                                                
 Schema |    Name    | Type  |  Owner  | Storage |  Size  | Description                                                                                                                                                                                     
--------+------------+-------+---------+---------+--------+-------------                                                                                                                                                                                    
 public | t_sort_bad | table | mxadmin | mars3   | 103 MB |                                                                                                                                                                                                 
(1 row)                                                                                                                                                                                                                                                     

adw=# \dt+ t_sort_good                                                                                                                                                                                                                                      
                           List of relations                                                                                                                                                                                                                
 Schema |    Name     | Type  |  Owner  | Storage | Size  | Description                                                                                                                                                                                     
--------+-------------+-------+---------+---------+-------+-------------                                                                                                                                                                                    
 public | t_sort_good | table | mxadmin | mars3   | 35 MB |                                                                                                                                                                                                 
(1 row)   

adw=# select * from t_sort_good limit 10;                                                                                                                                                                                                                   
 device_id |           ts           | site_id | status |   v1   |         v2         |                        attrs                                                                                                                                         
-----------+------------------------+---------+--------+--------+--------------------+-----------------------------------------------------                                                                                                                 
         3 | 2026-01-01 00:00:01+08 |       3 |      0 | 1003.1 | 100.31000000000002 | {"k": 1, "fw": "1.2.3", "tag": "S3", "model": "m3"}                                                                                                                  
         3 | 2026-01-01 00:00:02+08 |       3 |      0 | 1003.2 | 100.32000000000001 | {"k": 2, "fw": "1.2.3", "tag": "S3", "model": "m3"}                                                                                                                  
         3 | 2026-01-01 00:00:03+08 |       3 |      0 | 1003.3 | 100.33000000000001 | {"k": 3, "fw": "1.2.3", "tag": "S3", "model": "m3"}                                                                                                                  
         3 | 2026-01-01 00:00:04+08 |       3 |      0 | 1003.4 | 100.34000000000002 | {"k": 4, "fw": "1.2.3", "tag": "S3", "model": "m3"}                                                                                                                  
         3 | 2026-01-01 00:00:05+08 |       3 |      0 | 1003.5 | 100.35000000000001 | {"k": 0, "fw": "1.2.3", "tag": "S3", "model": "m3"}                                                                                                                  
         3 | 2026-01-01 00:00:06+08 |       3 |      0 | 1003.6 | 100.36000000000001 | {"k": 1, "fw": "1.2.3", "tag": "S3", "model": "m3"}                                                                                                                  
         3 | 2026-01-01 00:00:07+08 |       3 |      0 | 1003.7 |             100.37 | {"k": 2, "fw": "1.2.3", "tag": "S3", "model": "m3"}                                                                                                                  
         3 | 2026-01-01 00:00:08+08 |       3 |      0 | 1003.8 | 100.38000000000001 | {"k": 3, "fw": "1.2.3", "tag": "S3", "model": "m3"}                                                                                                                  
         3 | 2026-01-01 00:00:09+08 |       3 |      0 | 1003.9 | 100.39000000000001 | {"k": 4, "fw": "1.2.3", "tag": "S3", "model": "m3"}                                                                                                                  
         3 | 2026-01-01 00:00:10+08 |       3 |      0 |   1003 |              100.4 | {"k": 0, "fw": "1.2.3", "tag": "S3", "model": "m3"}                                                                                                                  
(10 rows)                                                                                                                                                                                                                                                   

adw=# select * from t_sort_bad limit 10;                                                                                                                                                                                                                    
 device_id |           ts           | site_id | status |   v1   |         v2         |                         attrs                                                                                                                                        
-----------+------------------------+---------+--------+--------+--------------------+-------------------------------------------------------                                                                                                               
         1 | 2026-01-01 00:00:01+08 |       1 |      0 | 1001.1 | 100.11000000000001 | {"k": 1, "fw": "1.2.1", "tag": "S1", "model": "m1"}                                                                                                                  
        12 | 2026-01-01 00:00:01+08 |      12 |      0 | 1012.1 | 101.21000000000001 | {"k": 1, "fw": "1.2.2", "tag": "S12", "model": "m12"}                                                                                                                
        15 | 2026-01-01 00:00:01+08 |      15 |      0 | 1015.1 |             101.51 | {"k": 1, "fw": "1.2.5", "tag": "S15", "model": "m15"}                                                                                                                
        20 | 2026-01-01 00:00:01+08 |      20 |      0 | 1020.1 |             102.01 | {"k": 1, "fw": "1.2.0", "tag": "S20", "model": "m20"}                                                                                                                
        23 | 2026-01-01 00:00:01+08 |      23 |      0 | 1023.1 | 102.31000000000002 | {"k": 1, "fw": "1.2.3", "tag": "S23", "model": "m23"}                                                                                                                
        35 | 2026-01-01 00:00:01+08 |      35 |      0 | 1035.1 |             103.51 | {"k": 1, "fw": "1.2.5", "tag": "S35", "model": "m35"}                                                                                                                
        38 | 2026-01-01 00:00:01+08 |      38 |      0 | 1038.1 | 103.81000000000002 | {"k": 1, "fw": "1.2.8", "tag": "S38", "model": "m38"}                                                                                                                
        40 | 2026-01-01 00:00:01+08 |      40 |      0 | 1040.1 |             104.01 | {"k": 1, "fw": "1.2.0", "tag": "S40", "model": "m40"}                                                                                                                
        44 | 2026-01-01 00:00:01+08 |      44 |      0 | 1044.1 | 104.41000000000001 | {"k": 1, "fw": "1.2.4", "tag": "S44", "model": "m44"}                                                                                                                
        47 | 2026-01-01 00:00:01+08 |      47 |      0 | 1047.1 | 104.71000000000001 | {"k": 1, "fw": "1.2.7", "tag": "S47", "model": "m47"}                                                                                                                
(10 rows) 

Проще говоря:

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

  • Одинаковый объем данных, отличается только ключ сортировки:

    • Общий размер t_sort_good: 35 МБ

    • Общий размер t_sort_bad: 103 МБ (примерно в 3 раза больше)

  • По статистике run/level: каждый сегмент содержит только один run, но:

    • У t_sort_good run находится на уровне 1, размер каждого seg: около 8.5–8.9 МБ

    • У t_sort_bad run находится на уровне 2, размер каждого seg: около 25–26 МБ

Для t_sort_good сортировка по (device_id, ts) → группировка похожих данных → благоприятно для сжатия.

В пределах одного stripe/блока находятся данные «одного устройства за непрерывный период времени».

Это дает:

  • Длинные повторяющиеся сегменты в столбцах device_id, site_id, status (отлично подходит для RLE/словарного кодирования)

  • Малые приращения внутри блока для столбцов ts, v1, v2 (изменяются медленно во времени, отлично подходит для delta/bitpacking)

  • Высокая схожесть шаблонов ключей в attrs (jsonb) внутри одного устройства (также благоприятно для zstd/lz4)

Поэтому при одинаковом количестве строк физический размер значительно меньше.

Для t_sort_bad сортировка по (ts, device_id) → смешение разных устройств → неблагоприятно для сжатия.

В один момент времени попадают данные множества устройств, поэтому внутри одного блока:

  • device_id меняется почти в каждой строке (словарь больше, длина RLE‑сегментов близка к 1)

  • site_id и status также разрознены (run‑length нарушен)

  • Шаблоны в attrs более разнообразные (уменьшается число повторяющихся фрагментов)

  • Даже при возрастающем ts «случайность» других столбцов сводит на нет общую выгоду от сжатия

Поэтому коэффициент сжатия значительно ухудшается, а увеличение физического размера в 3 раза является нормальным.

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

В MARS3 ключ сортировки определяет степень схожести данных внутри блоков записи (run/stripe). При группировке похожих данных диапазон значений внутри блока сужается, повторы концентрируются, и все механизмы: словарное кодирование, RLE, delta, а также универсальное сжатие — работают максимально эффективно, обеспечивая меньший физический объем. Напротив, при смешении данных из-за неверного ключа сортировки распределение внутри блока становится разрозненным, повторы разбиваются, эффективность сжатия и кодирования резко падает, что даже увеличивает сложность и стоимость фоновой обработки.

5. Обновление и удаление

5.1 Unique Mode

Unique Mode — это режим, предоставляемый MARS3 для специфических моделей записи: он преобразует задачу «обновить запись» в «повторную вставку новой записи по уникальному ключу», а движок автоматически выполняет замену старой и новой версии для одного и того же ключевого значения. Основная ценность этого режима — позволить бизнесу использовать более простой и унифицированный способ записи (INSERT) для выражения семантики обновления в сценариях с высокочастотной записью, сократить использование и затраты явного оператора UPDATE, а также сохранить управляемость организации данных при непрерывной записи.

Сценарии применения:

  • Непрерывная запись последнего состояния по ключу сущности: сильное измерение сущностей (устройства, транспортные средства, пользователи, заказы), повторная запись последнего значения по одному ключу

  • Высокочастотная запись малыми партиями: потребность в простом, стабильном и устойчивом пути записи

  • Запросы в основном на последние значения/последние снимки: например, панели последнего состояния, статусы оповещений, панели последних метрик

  • Отсутствие необходимости в семантике физического удаления: бизнесу не требуется частый DELETE (или недействительность/истечение срока действия можно выразить другими способами)

5.1.1 Отличие Unique Mode от Upsert

В режиме Unique Mode уникальный ключ определяется ключом сортировки ORDER BY (...) при создании таблицы: при вставке новых данных с тем же уникальным ключом, что и у существующей записи, движок воспринимает это как обновление данных по этому ключу — достаточно выполнить простой INSERT без явного UPDATE. В отличие от традиционного upsert (insert .. on conflict), Unique Mode выполняет слияние при чтении: данные записываются в хранилище при вставке, а при чтении гарантируется получение актуальной версии через цепочку версий и правила видимости, дубликаты удаляются при компактации. Upsert же выполняет слияние при записи: при вставке сразу проверяется возможное повторение, что приводит к дополнительным затратам на запись.

Такая предварительная проверка позволяет избежать затрат на вставку кортежа в кучу с последующим удалением при его дублировании. HEAP_INSERT_IS_SPECULATIVE — так называемая спекулятивная вставка: при обнаружении конфликта она отменяется без отмены всей транзакции. Другие сеансы могут ожидать подтверждения спекулятивной вставки (превращения в обычный кортеж) или её отмены.

Поэтому производительность записи в Unique Mode значительно выше, чем у традиционного upsert: по результатам тестов она примерно в 1,5 раза выше.

5.1.2 Обработка JSONB

При повторной записи по одному ключу в режиме Unique Mode скалярные столбцы по умолчанию перезаписываются новыми значениями. Но для столбцов типа JSONB движок поддерживает семантику инкрементного слияния: новая версия JSONB не записывается напрямую, а формируется по правилу:

postgres=# SELECT jsonb_concat('[1,2]'::jsonb, '[2,3]'::jsonb);                                                                                                                                                                                             
 jsonb_concat                                                                                                                                                                                                                                               
--------------                                                                                                                                                                                                                                              
 [1, 2, 2, 3]                                                                                                                                                                                                                                               
(1 row)                                                                                                                                                                                                                                                     

postgres=# SELECT jsonb_concat('{"k":1,"x":2}'::jsonb, '{"k":null}'::jsonb);                                                                                                                                                                                
    jsonb_concat                                                                                                                                                                                                                                            
---------------------                                                                                                                                                                                                                                       
 {"k": null, "x": 2}                                                                                                                                                                                                                                        
(1 row)                                                                                                                                                                                                                                                     

postgres=# SELECT jsonb_concat('{"a":1,"b":2}'::jsonb, '{"b":99,"c":3}'::jsonb);                                                                                                                                                                            
       jsonb_concat                                                                                                                                                                                                                                         
---------------------------                                                                                                                                                                                                                                 
 {"a": 1, "b": 99, "c": 3}                                                                                                                                                                                                                                  
(1 row) 

new_jsonb = jsonb_concat(old_jsonb, incoming_jsonb)

База данных внутри вызывает функцию jsonb_concat для выполнения слияния.

Это позволяет бизнесу записывать только инкрементный JSONB при работе с динамическими атрибутами, расширениями или тегами, а слияние выполняет движок — исключается дополнительная сложность и конкурентные конфликты при чтении старого значения, слиянии и записи обратно на уровне приложения.

5.1.3 Риски и лучшие практики

  • Риск идемпотентности при слиянии массивов: добавление элементов в JSONB-массив без дедубликации может привести к накоплению повторяющихся элементов при повторных попытках записи. Рекомендуется избегать долгосрочного добавления деталей событий в массив; при необходимости идемпотентности введите event_id и гарантируйте уникальность на верхнем уровне, либо используйте структуру object/map с перезаписью по ключу.

  • Управление ростом JSONB: слияние JSONB постепенно увеличивает размер полей, что может повысить затраты на чтение и нагрузку на фоновое обслуживание. Рекомендуется выполнять управление объёмом JSONB (обрезка, разделение таблиц, сегментация, хранение только последних N записей).

  • Null не равно удалению: запись {"k": null} устанавливает значение k в null, но не удаляет ключ. Для семантики удаления требуется отдельная схема (например, специальное поле-маркер, отдельная таблица удалений, договоренность на бизнес-уровне).

5.2 Стратегия реализации UPDATE/DELETE

Назначение UpdateChain — обеспечить корректность при конкурентном обновлении одной строки. В PG Heap обновление не изменяет строку на месте: старый кортеж помечается как удаленный, вставляется новый, а по ctid старая версия связывается с новой, образуя цепочку обновлений.

В MARS3 удаление/обновление не записывает xmax в заголовок кортежа, как в Heap: аналогичные метки «удаление/обновление» записываются в файл Delta. Кроме того, для сохранения этих данных после compaction/flush требуется файл Link. Информация xmax/удаления из Heap в MARS3 хранится в Delta, а Link гарантирует сохранность информации об удалении в процессе компактации.

Таким образом:

  • Heap: цепочка версий в основном опирается на заголовок кортежа + цепочку ctid (указатели версий прямо в данных)

  • MARS3: семантика версий/удаления вынесена в обходные структуры Delta + Link, поэтому корректность зависит не только от файлов данных, но и от сохранности, целостности и непрерывности Delta/Link при flush/compaction/vacuum.

Ранние версии MARS3 при конкурентном обновлении одной строки сразу завершались с ошибкой без поддержки UpdateChain, что не обеспечивало корректность. После внедрения UpdateChain блокировка логической строки выполняется через TupleLock, а поиск последней версии идет по цепочке обновлений — конкурентные обновления выполняются последовательно, гарантируя корректность семантики UPDATE.

5.3 Сборка мусора и освобождение пространства

Аналогично PostgreSQL, в YMatrix обновление и удаление не изменяют исходное пространство данных, а реализуются через механизм множественных версий кортежей:

  • Обновления и удаления в MARS3 не выполняются на месте: старые данные скрываются через файлы DELTA и информацию о версиях, контролируя видимость

  • Удаление в MARS3 выполняется через DELETE: информация об удалении записывается в файл Delta соответствующего Run, физическое удаление происходит при слиянии Run

  • Обновление в MARS3 выполняется через UPDATE: сначала удаляется исходная запись, затем вставляется новая

Мертвые кортежи (невидимые runs), образующиеся при обновлении и удалении, периодически очищаются фоновым процессом autovacuum, либо вручную командой vacuum. Кроме того, при компактации малые Run сливаются в один большой, невидимые runs удаляются. Команда vacuum full идет дальше: она сливает множество малых Run в один большой. При отсутствии новых записей выполнение vacuum full обычно объединяет все партии до предела, итоговый размер каждой партии приблизительно равен значению max_runsize (примечание: во время vacuum full также могут образовываться невидимые runs).

Проще говоря:

  1. vacuum выполняет flush + удаление невидимых runs и сброс rowstore

  2. vacuum full дополнительно выполняет слияние поверх операций vacuum