TOAST был введен в PostgreSQL для обработки данных строки, превышающих 8 КБ, сжимая большие колонки в отдельное физическое хранилище. Когда логическая репликация передает изменения через WAL, настройка REPLICA IDENTITY определяет, какие старые значения кортежей включаются. По умолчанию REPLICA IDENTITY DEFAULT отправляет только первичный ключ, тогда как REPLICA IDENTITY FULL отправляет полное старое изображение строки.
Когда таблица содержит колонки JSONB или TEXT, превышающие ~2 КБ и сжимаемые в TOAST, операции UPDATE, которые изменяют только некорректируемые колонки, могут не извлекать внешние TOAST-значения для записи WAL. Процесс логического декодирования пропускает неизмененные указатели TOAST, чтобы сократить I/O, и в результате подписчик получает NULL или отсутствующие значения для этих больших полей во время разрешения конфликта.
Переключение на REPLICA IDENTITY FULL заставляет PostgreSQL включать полное старое значение кортежа в запись WAL, явно извлекая все TOAST-значения из внешнего хранилища во время коммита. Хотя это гарантирует полноту данных для операций UPSERT, это значительно увеличивает объем WAL — часто на 300-500% для широких таблиц JSONB — поскольку каждое UPDATE должно регистрировать полное предыдущее изображение строки.
Финансовая торговая платформа нуждалась в репликации снимков приказов из первичного кластера PostgreSQL 15 в хранилище данных для регуляторной отчетности. Таблица market_data хранила идентификаторы инструментов и большие полезные нагрузки JSONB (10-50 КБ) с информацией о глубине рынка. Репликация использовала pglogical с REPLICA IDENTITY DEFAULT (только первичный ключ). Процесс ETL на стороне хранилища данных пытался выполнять операции UPSERT, чтобы поддерживать таблицу медленно изменяющегося измерения, требуя старые значения JSONB для расчета дельта-изменений для аудиторского журнала.
В периоды высоких объемов торговли, когда книга заказов часто обновлялась, но полезная нагрузка JSONB оставалась неизменной, потоки логической репликации отправляли записи UPDATE, содержащие только первичный ключ и новые данные кортежа. Старые значения TOAST-изображений JSONB не включались в набор изменений, поскольку оператор UPDATE коснулся только колонки метки времени updated_at. Процесс ETL не смог получить состояние JSONB до обновления, что сделало невозможным вычисление точных дельт движения цены для дорожной карты аудита, что нарушило требования MiFID II о соблюдении.
Решение 1: Переключение на REPLICA IDENTITY FULL Такой подход заставил бы издателя записывать полное старое изображение строки в WAL для каждого UPDATE, включая все содержимое JSONB из хранилища TOAST. Плюсами были гарантированная полнота данных и простота реализации без необходимости изменений в схеме. Однако минусы были значительными: генерация WAL увеличилась бы примерно на 400%, учитывая 50 КБ полезные нагрузки, что угрожало исчерпанию дискового пространства на первичном сервере и увеличивало бы задержку сети до хранилища данных. Для таблицы, обрабатывающей 10 000 обновлений в секунду, это было слишком рискованно для стабильности в производственной среде.
Решение 2: Журналирование на уровне приложения с отдельной таблицей истории
Команда рассмотрела вариант создания триггера на первичном сервере, который копировал старые значения JSONB в отдельную таблицу market_data_history перед обновлением. Плюсами были то, что логическая репликация могла бы отдельно воспроизводить эту историю, избегая проблемы с пропущенными TOAST на основной таблице, сохраняя при этом небольшой объем WAL основной таблицы. Минусы включали дополнительную нагрузку двойной записи на первичном сервере (увеличение задержки транзакций), дополнительные требования к хранилищу, растущие с удвоенной скоростью, и сложность в логике ETL для корреляции записей истории с изменениями основной таблицы с использованием идентификаторов транзакций и меток времени.
Решение 3: Использование REPLICA IDENTITY с покрывающим индексом, включающим хэш JSONB
Эта стратегия включала создание функционального индекса на md5(jsonb_column::text) и включение этого хэша в составной индекс REPLICA IDENTITY. Плюсами были то, что изменения в содержимом JSONB были бы обнаружены через изменение хэша в WAL без отправки полной полезной нагрузки. Минусы включали невозможность извлечь фактическое старое значение (только его хэш), что было недостаточно для регуляторного требования продемонстрировать точное состояние до обновления, и накладные расходы на обслуживание индекса на таблицах с высокой текучестью.
Команда выбрала Решение 2 (Журналирование на уровне приложения), но с модификацией. Они использовали оптимизацию частичного обновления JSONB, доступную в версиях 14 и выше, и внедрили триггер BEFORE UPDATE, который сохранял только измененные пути (различия), а не всю старую строку. Это снизило рост таблицы истории, обеспечивая при этом доступность всех необходимых данных предшествующего изображения. Они оставили REPLICA IDENTITY DEFAULT на основной таблице, чтобы избежать раздувания WAL, направляя ETL для объединения с таблицей истории для реконструкции аудита.
Размер потока репликации оставался стабильным, предотвращая давление на основное хранилище. Процесс ETL успешно реконструировал полные аудиторские тракты, объединяя текущее состояние строк с сохраненными различиями из таблицы истории. Соответствие регуляторным требованиям было достигнуто с увеличением первичного хранилища всего на 15% (по сравнению с 400% для REPLICA IDENTITY FULL) и минимальным воздействием на пропускную способность транзакций.
Почему логическое декодирование PostgreSQL пропускает значения TOAST, даже если колонка изменена?
Многие кандидаты предполагают, что любое UPDATE автоматически извлекает все TOAST-значения для WAL. Однако PostgreSQL выполняет "разжатие кортежа TOAST" только тогда, когда исполнение фактически читает данные для изменения. Если UPDATE изменяет другую колонку (например, SET updated_at = NOW()) без ссылки на колонку JSONB в своем целевом списке или операторе WHERE, указатель TOAST остается неизменным, и внешнее хранилище не используется. Таким образом, запись WAL содержит только кортеж на диске с его указателем, но не фактические данные. Поскольку логическое декодирование восстанавливает кортежи из WAL без доступа к таблицам кучи или TOAST для старых версий, пропущенное значение появляется как NULL в потоке изменений.
Как REPLICA IDENTITY FULL взаимодействует с обновлениями HOT (только кучи)?
Кандидаты часто упускают, что REPLICA IDENTITY FULL отключает обновления HOT для таблицы. Обновления HOT позволяют PostgreSQL связывать версии строк в одной и той же странице данных без обновления каждой записи индекса, при условии, что ни одна индексированная колонка не изменяется. Когда активно REPLICA IDENTITY FULL, каждое UPDATE должно регистрировать полное старое изображение строки для репликации, что требует от системы идентификации строки уникально по ее полному содержимому. Это нарушает оптимизацию HOT, поскольку логическая репликация требует полных данных для сравнения кортежей, что заставляет обновлять индексы для каждой версии строки даже при изменении неиндексированных колонок. Следовательно, таблицы с этой настройкой испытывают больший рост индексов и увеличенные I/O, что является критическим компромиссом для таблиц с высокой текучестью.
В чем разница между сжатием TOAST и сжатием WAL PostgreSQL, и как они взаимодействуют в процессе логической репликации?
Этот вопрос отделяет глубокие знания систем от поверхностного понимания. TOAST сжимает размер строки с использованием LZ4 или PGLZ перед хранением больших колонок в внешних таблицах. WAL сжатие (включаемое через wal_compression=lz4) сжимает полные изображения страниц, записываемых в WAL для повышения эффективности восстановления после сбоев. Однако, когда используется REPLICA IDENTITY FULL, старые данные кортежа, отправленные для логического декодирования, извлекаются до сжатия записи WAL для хранения. Таким образом, логический декодер получает несжатые данные TOAST (если они извлечены), в то время как физический файл WAL может хранить их в сжатом виде, если они являются частью полного изображения страницы, что различным образом влияет на пропускную способность сети и I/O диска.