Идеальная архитектура при работе с файлами в СУБД подразумевает, что файлы выносятся в отдельное хранилище. Вместе с тем не всегда пользователи следуют этому правилу: одним разработчикам так проще оперировать файлами, другие вынуждены реализовывать этот формат из-за требований к хранению исходного запроса и подписи, третьи не хотят поднимать и администрировать дополнительные хранилища. Таким образом, многие сталкиваются с необходимостью хранить данные в СУБД, но далеко не все понимают, каким способом это реализовать.

Варианты хранения файлов в СУБД PostgreSQL
В PostgreSQL пользователям доступно несколько вариантов хранения файлов в СУБД. Среди них:
простые таблицы;
pg_largeobject — каталог для больших объектов.
На каждом из них остановимся подробнее.
Простые таблицы
Обычные таблицы PostgreSQL позволяют работать с данными типа text, bytea, Jsonb.
При этом API PostgreSQL для работы с файлами формирует ряд простых команд, среди которых:
pg_ls_dir — получение списка файлов и директорий в указанной директории;
pg_stat_file — получение метаданных файла (размер, время изменения, права доступа и другие);
pg_read_file — чтение текстового файла и возвращение его содержимого в виде строки;
pg_read_binary_file — чтение файла (включая бинарные данные) и возвращение содержимого в формате bytea.
Примечание: полный список функций доступен здесь, но они не предназначены для работы с пользовательскими данными.
Вместе с тем, у простых таблиц есть два ограничения на работу с файлами.
Первое ограничение простых таблиц
Простые таблицы по умолчанию не подходят для работы с файлами (и любыми другими данными) объёмом более 1 ГБ. Так, в случае попытки их вставки будет выведена соответствующая ошибка.
create table user_data (
id bigint
, data_bytea bytea
);
# insert into user_data (id, data_bytea)
Values (8, pg_read_binary_file('/tmp/file.1gb.bytea'));
ERROR: file length too large
Time: 669.132 ms
# select pg_read_binary_file('/tmp/file.1gb.bytea');
ERROR: file length too large
Time: 666.684 ms
Этот барьер можно обойти, если вставлять данные фрагментами. Например, дробить файлы объёмом 5 ГБ на части по 100 МБ и хранить их в виде отдельных строк. Это позволяет обойти ограничение на размер поля, но усложняет архитектуру приложения и требует создания дополнительных таблиц для сборки фрагментов.
insert into user_data (id, data_bytea)
Values (81, pg_read_binary_file('/tmp/file.1gb.bytea', 0, 1024*1024*100));
insert into user_data (id, data_bytea)
Values (82, pg_read_binary_file('/tmp/file.1gb.bytea', (1024*1024*100)+1, 1024*1024*100));
insert into user_data (id, data_bytea)
Values (83, pg_read_binary_file('/tmp/file.1gb.bytea', (1024*1024*100)+1, 1024*1024*100));
Второе ограничение простых таблиц
Вторая проблема, с которой можно столкнуться при работе с файлами через простые таблицы — невозможность гарантировать стабильную скорость вставки данных.
Может возникнуть ситуация, когда хранилище работает штатно, транзакции проходят, но в какой-то момент вставка новых данных (особенно параллельная) начинает «тормозить» или зависать.
Смоделируем ситуацию: запускаем множественную вставку тяжёлых данных.
insert into user_data values (777, repeat('b', 10000)::bytea);
Чтобы понять причину зависания, обратимся к системному представлению pg_stat_activity. Посмотрим, чего именно ждут активные процессы:
SELECT pid,
extract(epoch from now() - state_change) as sec_state_change,
state_change,
wait_event,
wait_event_type,
state,
query
FROM pg_stat_activity
WHERE query LIKE 'insert into user_data%'
AND state = 'active';
Результат:
pid | sec_state_change | state_change | wait_event | wait_event_type | state | query
---------+------------------+-----------------------+---------------+-----------------+--------+----------------------------------------------
1013836 | 196.148285 | 17:58:03.280158+03 | ClientRead | Client | idle | SHOW TRANSACTION ISOLATION LEVEL
1013866 | 403.011387 | 17:54:36.417056+03 | BufferIO | IPC | active | insert into user_data (id,data) values ( 777
1013867 | 403.011375 | 17 17:54:36.417068+03 | | | active | insert into user_data (id,data) values ( 777
1013868 | 403.011407 | 17 17:54:36.417036+03 | BufferIO | IPC | active | insert into user_data (id,data) values ( 777
1013869 | 403.011383 | 17:54:36.41706+03 | BufferIO | IPC | active | insert into user_data (id,data) values ( 777
1013870 | 403.011375 | 17 17:54:36.417068+03 | | | active | insert into user_data (id,data) values ( 777
1013871 | 403.011384 | 17:54:36.417059+03 | BufferIO | IPC | active | insert into user_data (id,data) values ( 777
1013872 | 403.011389 | 17:54:36.417054+03 | BufferIO | IPC | active | insert into user_data (id,data) values ( 777
1013873 | 403.011077 | 17 17:54:36.417366+03 | BufferMapping | LWLock | active | insert into user_data (id,data) values ( 777
1013874 | 403.011070 | 17 17:54:36.417373+03 | | | active | insert into user_data (id,data) values ( 777
1013875 | 403.011138 | 17:54:36.417305+03 | | | active | insert into user_data (id,data) values ( 777
(12 rows)
На уровне мониторинга мы видим массовые ожидания событий BufferMapping и BufferIO.
Это происходит из-за того, что при вставке больших значений PostgreSQL вынужден «на лету» дробить данные на TOAST-чанки, сжимать их и искать свободное место для записи.
BufferMapping означает, что процессы конкурируют за возможность выделить новые страницы в буферном кеше под эти данные.
BufferIO показывает, что система уперлась в производительность дисковой подсистемы при записи этих страниц.
Это обусловлено выполнением сложных процессов «под капотом» в момент записи объектов. Об особенностях хранения больших значений подробно рассказано в документации и профильных статьях (например, здесь и здесь). Мы лишь отметим, что при высокой нагрузке этот механизм становится «бутылочным горлышком».
Для каждой пользовательской таблицы создается всего одна TOAST-таблица, вне зависимости от того, сколько колонок базовой таблицы содержат большие данные. Значения из всех столбцов попадают в это единое хранилище, имеющее жесткие ограничения:
максимальный размер — 32 ТБ;
максимальное количество строк (чанков) — около 4,2 млрд (ограничение int4 для идентификатора чанка).
Важно учитывать, что лимит в 4,2 млрд записей — это общее ограничение для всех столбцов таблицы суммарно, а не для каждого по отдельности.
Примечание: TOAST поддерживает четыре стратегии хранения столбцов на диске:
PLAIN — для значений фиксированной длины, таких как integer;EXTERNAL — для внешнего хранения несжатых данных;EXTENDED — для внешнего хранения сжатых данных;MAIN — для хранения внутри, но сжатых данных.
Механизм TOAST срабатывает в тех случаях, когда значение строки, которое должно храниться в таблице, по размеру больше, чем значение TOAST_TUPLE_THRESHOLD (обычно это 2 КБ). Соответственно, TOAST будет сжимать и/или выносить значения поля за пределы таблицы до тех пор, пока значение строки не станет меньше 2 КБ или уменьшить объем станет невозможно.
В результате принцип распределения объектов следующий:
если записываемые данные помещаются в исходную пользовательскую таблицу, то есть размером до 2 КБ, мы их пишем прямо в колонку;
если данные больше 2 КБ — мы их помещаем в специальную TOAST-таблицу, а в основной пользовательской таблице делаем ссылку на нее.

Если ваша система активно работает с файлами, этот лимит может быть исчерпан неожиданно, после чего вставка новых данных станет невозможной.
Преодолеть этот барьер можно с помощью секционирования (partitioning) основной таблицы. В этом случае для каждой секции будет создана своя TOAST-таблица, что отодвинет общий лимит. Однако это требует от администратора БД существенных усилий по проектированию и обслуживанию схемы данных.
Хранилище для больших объектов large object
Также для хранения больших объектов в PostgreSQL предусмотрена системная таблица pg_largeobject, в которой каждый файл идентифицируется по OID, назначаемому при его создании.
Примечание: поскольку pg_largeobject — системная таблица, ее нельзя секционировать.
API для работы с pg_largeobject напоминает стандартные файловые операции: объекты можно создавать, читать, изменять и удалять. Как и в случае с TOAST, здесь действуют ограничения: максимальный размер объекта — 32 ТБ, а допустимое количество объектов (строк) — около 4,2 млрд.
Для наглядности разберём, как работает этот механизм. Важно понимать, что в самой пользовательской таблице данные файла не хранятся, там записывается только ссылка (OID) на объект.
-- 1. Создаем таблицу, где будет храниться только идентификатор (OID) файла
CREATE TABLE lo (
id bigint PRIMARY KEY,
lo_oid oid
);
-- 2. Загружаем файл в хранилище и записываем полученный OID в нашу таблицу
-- (Предполагается, что файл '/tmp/file.2gb.bytea' уже существует на сервере)
INSERT INTO lo (id, lo_oid) VALUES (1, lo_import('/tmp/file.2gb.bytea'));
-- 3. Проверяем данные в пользовательской таблице
SELECT * FROM lo;
-- Результат:
-- id | lo_oid
-- ---+------------
-- 1 | 2032353971 <-- Это ссылка на объект
-- 4. Проверяем системную таблицу pg_largeobject, где физически лежат данные (разбитые на страницы)
SELECT loid, pageno, data FROM pg_largeobject WHERE loid = 2032353971 LIMIT 2;
-- Результат:
-- loid | pageno | data
-- -----------+--------+-------
-- 2032353971 | 0 | \x58546f...
-- 2032353971 | 1 | \x81234b...
-- 5. Удаление объекта
-- ВАЖНО: Удаление строки из таблицы 'lo' НЕ удаляет сам файл из pg_largeobject.
-- Его нужно удалять явно функцией lo_unlink:
SELECT lo_unlink(2032353971);
Однако у этого подхода есть нюансы. При интенсивной вставке можно столкнуться с конкуренцией за генерацию идентификаторов (OID) или исчерпанием их лимита. На уровне мониторинга это проявляется как ожидание блокировки OidGen:
select pid,wait_event,wait_event_type,state, left(query,15) from pg_stat_activity where backend_type = 'client backend';
pid | wait_event | wait_event_type | state | left
------+------------+-----------------+-------+-----------------
977260 | ClientRead | Client | idle | SHOW TRANSACTIO
977309 | | | active| INSERT INTO lo
977310 | OidGen | LWLock | active| INSERT INTO lo
977311 | | | active| INSERT INTO lo
977312 | | | active| INSERT INTO lo
977313 | | | active| INSERT INTO lo
977314 | BufferIO | IPC | active| INSERT INTO lo
977315 | | | active| INSERT INTO lo
977316 | BufferMapping| LWLock | active| INSERT INTO lo
977317 | | | active| INSERT INTO lo
977318 | BufferIO | IPC | active| INSERT INTO lo
977426 | | | active| select pid, wai
(12 rows)
Таким образом, в pg_largeobject:
можно хранить до 32 ТБ и 4,2 млрд объектов;
не поддерживается секционирование;
при удалении строки (oid) из пользовательской таблицы, данные в pg_largeobject остаются незатронутыми.
Кроме того, стоит отметить существенные эксплуатационные недостатки. Миграция объектов в другое хранилище затруднена из-за отсутствия встроенных удобных механизмов. Всё это делает pg_largeobject не самым удобным инструментом для полноценной работы с большими файлами в высоконагруженных системах.
Примечание: помимо этого в PostgreSQL для хранения больших объектов можно применить и различные самописные реализации, но они не гарантируют полной совместимости и корректной работы с СУБД.
Что может предложить Postgres Pro Enterprise для хранения больших объектов
В Postgres Pro Enterprise (начиная с 16-й версии) предусмотрены специализированные механизмы, которые снимают ограничения стандартного PostgreSQL и облегчают миграцию с Oracle. Для хранения данных в СУБД можно использовать три расширения:
pgpro_sfile — расширение для хранения больших объектов внутри БД;
pgpro_bfile — расширение для работы с файлами, хранящимися на диске;
dbms_lob — аналог пакета DBMS_LOB из Oracle, который использует pgpro_sfile и pgpro_bfile.
Теперь о каждом из них подробнее.
Модуль pgpro_sfile
Модуль pgpro_sfile позволяет хранить множество больших объектов (в терминологии расширения они называются sfile) и предоставляет функциональность, аналогичную BLOB в Oracle.
Ключевые особенности:
Снятие лимитов. Общий объём хранения и количество объектов ограничены только дисковым пространством и типом bigint (2^63 - 1). Это решает проблему 32 ТБ и 4 млрд объектов.
Структура хранения. Данные хранятся в наборе специальных таблиц (в схеме pgpro_sfile_data), управляемых модулем. Каждый объект разбивается на блоки, а блоки — на страницы размером ~8 КБ. В пользовательских таблицах хранятся только идентификаторы объектов sfile.
Производительность. Поддерживается параллельная запись и чтение. Например, можно вести запись в один большой объект из нескольких сессий одновременно, так как разные блоки могут записываться в разные секции хранилища параллельно.
Управление. Реализованы функции создания (sf_create), чтения (sf_read), записи (sf_write), обрезки (sf_trim) и удаления (sf_delete) объектов.
Подробное описание функций и архитектуры доступно в документации к модулю.
Модуль pgpro_bfile
pgpro_bfile — это расширение Postgres Pro Enterprise, предназначенное для работы с внешними файлами, которые хранятся на диске сервера. Модуль предоставляет возможности для управления ссылками на файлы вне базы данных, аналогичные типу BFILE из Oracle.
Тип bfile содержит два поля:
dir_id — ID каталога в таблице bfile_directories.
file_name — имя внешнего файла.
Ключевые особенности:
Безопасность. Доступ осуществляется через объекты-каталоги. Администратор создает "директорию" в БД (функция bfile_directory_create), привязанную к пути в файловой системе, и выдает права на чтение/запись конкретным пользователям (bfile_grant_directory).
Доступ к данным. Модуль предоставляет функции для чтения (bfile_read), записи (bfile_write), копирования и получения свойств файлов.
Виртуализация. Доступ к файлам осуществляется через функции виртуальных файлов Postgres, поэтому на них распространяются стандартные ограничения и настройки СУБД.
В целом модуль pgpro_bfile — оптимальное решение для работы с файлами любых размеров на внешнем файловом хранилище, когда сами данные не требуется хранить внутри базы.
Модуль dbms_lob
dbms_lob — это расширение, которое эмулирует интерфейс пакета DBMS_LOB из Oracle. Оно служит «оберткой» над pgpro_sfile и pgpro_bfile и критически важно для проектов миграции, так как позволяет переносить код приложений с минимальными изменениями.
Модуль оперирует следующими типами данных:
CLOB — тип для хранения текстовых данных большого объёма (например, длинные документы, XML/JSON-файлы). Является аналогом Oracle CLOB и хранит данные как стандартный тип text.
BLOB — тип для хранения бинарных данных (изображения, видео, PDF) в таблицах расширения pgpro_sfile. Является заменой Oracle BLOB и позволяет хранить файлы условно неограниченного размера.
BFILE — тип данных для ссылки на файл в файловой системе сервера, предоставляемый расширением pgpro_bfile. Является аналогом BFILE в Oracle.
dbms_lob предоставляет обширный API для взаимодействия с большими объектами, включая функции:
создание и преобразование: empty_blob(), empty_clob(), to_blob(), to_clob();
открытие и закрытие: open(), close(), isopen();
чтение: getlength(), read(), substr(), instr();
изменение: write(), writeappend(), erase(), trim();
операции с несколькими LOB: compare(), append(), copy().
Таким образом, модуль dbms_lob для Postgres Pro Enterprise не только полностью повторяет функциональность DBMS_LOB в Oracle, но и позволяет хранить данные как внутри базы, так и вне её. Это делает его лучшим решением при миграции приложений с Oracle.
Что в итоге
Хранение файлов в СУБД часто считается неоптимальным паттерном из-за роста нагрузки на БД и усложнения эксплуатации. Однако, если бизнес-требования диктуют такой подход, у пользователей PostgreSQL и Postgres Pro Enterprise есть надёжные инструменты.
Если стандартные механизмы PostgreSQL (TOAST, Large Objects) накладывают ограничения по объёму (32 ТБ) и количеству объектов, то специализированные модули Postgres Pro Enterprise (pgpro_sfile, dbms_lob) позволяют преодолеть эти барьеры, обеспечивая хранение петабайтов данных и предоставляя удобные интерфейсы для разработки и миграции с Oracle.