PostgreSQL vs MariaDB для транзакционных CRUD‑приложений
PostgreSQL vs MariaDB: практический обзор индексов, миграций, JSON и возможностей запросов, которые начинают иметь значение, когда CRUD‑приложение выходит за рамки прототипа.

Когда CRUD‑приложение перерастает прототип
Прототип CRUD‑приложения обычно кажется быстрым: данных немного, команда маленькая, трафик предсказуем. Достаточно простых запросов, пары индексов и ручных исправлений схемы. Но потом у приложения появляются реальные пользователи, реальные сценарии и реальные дедлайны.
Рост меняет характер нагрузки. Списки и дашборды открывают целый день. Несколько человек правят одни и те же записи. Фоновые задачи начинают писать пакетами. Именно тогда «вчера работало» превращается в медленные страницы, случайные тайм‑ауты и ожидания блокировок в часы пик.
Вы, вероятно, пересекли рубеж, если видите такие вещи, как страницы‑списки, которые замедляются после 20‑й страницы, релизы, включающие бэфиллы данных (не только новые колонки), больше «гибких полей» для метаданных и интеграции, или тикеты поддержки с жалбами «сохранение занимает вечность» в периоды высокой нагрузки.
Вот тогда сравнение PostgreSQL и MariaDB перестаёт быть вопросом бренда и превращается в практическую дилемму. Для транзакционной CRUD‑нагрузки решают детали: варианты индексации по мере усложнения запросов, безопасность миграций на больших таблицах, хранение и запросы JSON, а также возможности запросов, позволяющие уменьшить логику на стороне приложения.
Здесь мы сосредоточимся на поведении баз данных — не углубляясь в подбор серверов, ценообразование в облаке или договоры с вендорами. Это важно, но часто проще изменить, чем стиль схемы и запросов, от которых зависит продукт.
Начните с требований приложения, а не с бренда базы
Лучше начинать не с «PostgreSQL или MariaDB», а с повседневного поведения приложения: создание записей, обновление нескольких полей, выдача отфильтрованных списков и корректность при одновременных кликах многих людей.
Запишите, что делают ваши самые загруженные экраны. Сколько чтений приходится на каждую запись? Когда случаются пики (утренние входы, отчёты в конце месяца, большие импорты)? Зафиксируйте точные фильтры и сортировки, потому что именно они позже определяют дизайн индексов и шаблоны запросов.
Потом определите ваши непреложные требования. Для многих команд это строгая согласованность для денег или запасов, аудит изменений «кто и что поменял», и отчёты, которые не разваливаются при эволюции схемы.
Операционная реальность важна не меньше функционала. Решите, будете ли вы использовать управляемую базу или хостить сами, как быстро нужно восстанавливаться из бэкапов и какой допустимый простой на обслуживание.
И, наконец, определите «достаточно быстро» в паре ясных целей. Например: p95‑задержка API в нормальной нагрузке (200–400 мс), p95 в пике (возможно 2× нормальной), максимальное допустимое ожидание блокировок при обновлениях (менее 100 мс) и ограничения по времени бэкапа/восстановления.
Основы индексации, которые влияют на скорость CRUD
Большинство CRUD‑приложений остаются быстрыми, пока таблицы не достигают миллионов строк и каждый экран не превращается в «список с фильтрами и сортировкой». В этот момент индексация — разница между запросом в 50 мс и тайм‑аутом в 5 секунд.
B‑tree‑индексы — стандартная рабочая лошадка и в PostgreSQL, и в MariaDB. Они помогают при фильтрации по колонке, соединениях по ключам и когда ORDER BY совпадает с порядком в индексе. Реальная разница по производительности обычно зависит от селективности (сколько строк соответствует) и от того, может ли индекс обслужить и фильтрацию, и сортировку, не просканировав лишние строки.
По мере взросления приложений составные индексы важнее одно‑колоночных. Частый паттерн — многотенантная фильтрация плюс статус и сортировка по времени, например (tenant_id, status, created_at). Поместите наиболее постоянный фильтр первым (часто tenant_id), затем следующий фильтр, затем колонку для сортировки. Это обычно лучше, чем отдельные индексы, которые оптимизатор не может эффективно объединить.
Различия проявляются в «умных» индексах. PostgreSQL поддерживает частичные (partial) и индекс‑по‑выражению (expression) индексы, которые отлично подходят для узконаправленных экранов (например, индексировать только «открытые» тикеты). Они мощные, но могут удивлять команды, если запросы не совпадают с предикатом точно.
Индексы не бесплатны. Каждая вставка и обновление также обновляют каждый индекс, поэтому легко ускорить один экран и незаметно замедлить все записи.
Простой способ держать дисциплину:
- Добавляйте индекс только для реального пути запроса (экрана или API‑вызова, который вы можете назвать).
- Отдавайте предпочтение одному хорошему составному индексу вместо множества пересекающихся.
- Перепроверяйте индексы после изменений в функциях и удаляйте мёртвый вес.
- Планируйте обслуживание: PostgreSQL требует регулярного
VACUUM/ANALYZEдля избежания раздувания; MariaDB тоже полагается на хорошие статистики и периодическую чистку. - Измеряйте до и после, а не полагайтесь на интуицию.
Индексация для реальных экранов: списки, поиск и пагинация
Большая часть времени CRUD‑приложений уходит на несколько экранов: список с фильтрами, поле поиска и страница деталей. Выбор СУБД важнее лишь в том смысле, совпадают ли ваши индексы с этими экранами, но оба движка дают разные инструменты по мере роста таблиц.
Для страниц‑списков думайте в таком порядке: сначала фильтр, затем сортировка, затем пагинация. Частый паттерн — «все тикеты для аккаунта X, статус в (open, pending), по убыванию времени». Составной индекс, начинающийся с колонок фильтра и заканчивающийся колонкой сортировки, обычно побеждает.
Пагинация требует особого внимания. OFFSET‑пагинация (страница 20 с OFFSET 380) замедляется по мере прокрутки, потому что база всё равно должна пройти предыдущие строки. Keyset‑пагинация более стабильна: вы передаёте последнее увиденное значение (например, created_at и id) и просите «следующие 20 старее этого». Она также уменьшает дубли и разрывы, когда новые строки появляются во время прокрутки.
В PostgreSQL есть полезный вариант для списков: «covering» индексы через INCLUDE, что позволяет выполнять index‑only scans, когда карта видимости это позволяет. MariaDB тоже умеет делать covering‑чтения, но обычно вы добиваетесь этого, прямо включив нужные колонки в определение индекса. Это делает индексы шире и дороже в обслуживании.
Вам, скорее всего, потребуются лучшие индексы, если конечная точка списка замедляется по мере роста таблицы, хотя она возвращает всего 20–50 строк, сортировка становится медленной, если не убрать ORDER BY, или I/O скачет при простых фильтрах. Более длинные запросы также увеличивают ожидания блокировок в пиковые периоды.
Пример: экран заказов, который фильтрует по customer_id и status и сортирует по created_at, обычно выигрывает от индекса (customer_id, status, created_at). Если позже вы добавите «поиск по номеру заказа», это обычно отдельный индекс, а не надстройка над индексом для списка.
Миграции: как держать релизы безопасными при росте данных
Миграции быстро перестают быть «изменить таблицу». Когда появляются реальные пользователи и история, нужно ещё уметь делать бэфиллы данных, ужесточать ограничения и очищать старые формы данных без слома приложения.
Безопасный дефолт — expand, backfill, contract. Добавляйте то, что нужно, так, чтобы не нарушать существующий код, копируйте или вычисляйте данные малыми шагами, а затем удаляйте старый путь только когда вы уверены.
На практике это значит: добавьте новую nullable‑колонку или таблицу, бэфиллите партиями, одновременно поддерживая согласованность записей при записи, затем валидируйте позже ограничениями NOT NULL, внешними ключами и уникальными правилами, и только потом удаляйте старые колонки, индексы и код.
Не все изменения схем равнозначны. Добавление колонки часто низкорискованно. Создание индекса на больших таблицах всё ещё может быть дорогим, поэтому планируйте его на периоды низкой нагрузки и измеряйте. Смена типа колонки — чаще всего самый рискованный шаг, потому что он может переписать данные или блокировать записи. Часто безопаснее создать новую колонку с нужным типом, бэфиллить её, а затем переключить чтение и запись.
Откаты (rollback) тоже меняют смысл на большом масштабе. Откат схемы иногда прост; откат данных часто — нет. Будьте чёткими насчёт того, что вы можете отменить, особенно если миграция включает удаляющие или потерянные при трансформации операции.
Поддержка JSON: гибкие поля без будущих проблем
JSON‑поля заманчивы: дополнительные поля форм, payloadы интеграций, настройки пользователей и заметки внешних систем могут поместиться без изменения схемы. Вопрос — что хранить в JSON, а что заслуживает реальной колонки.
В PostgreSQL и MariaDB JSON обычно хорошо подходит, когда поле редко фильтруется и в основном отображается, хранится для отладки, служит «блоком настроек» на пользователя/тенанта или используется для небольших опциональных атрибутов, не влияющих на отчёты.
Индексирование JSON часто вызывает сюрпризы. Запросить ключ JSON однажды — легко. Фильтрация и сортировка по нему на больших таблицах — это то место, где производительность может разрушиться. PostgreSQL предлагает мощные варианты индексирования JSON‑путей, но дисциплина всё равно нужна: индексируйте лишь несколько ключей, по которым вы действительно фильтруете, а остальной payload держите неиндексированным. MariaDB тоже умеет запрашивать JSON, но сложные схемы «поиска внутри JSON» часто становятся хрупкими и труднее поддерживаются быстрыми.
JSON ещё ослабляет ограничения. Труднее обеспечить «должно быть одним из этих значений» или «всегда присутствует» внутри неструктурированного блока, а инструменты отчётности обычно предпочитают типизированные колонки.
Правило, которое масштабируется: начинайте с JSON для неизвестных полей, но нормализуйте в колонки или дочерние таблицы, когда вы (1) фильтруете или сортируете по ним, (2) нужны ограничения, или (3) видите, что они появляются в дашбордах каждую неделю. Хранить полный ответ API доставки в JSON часто нормально. Поля вроде delivery_status и carrier обычно заслуживают реальных колонок, когда от них зависят поддержка и отчётность.
Функции запросов, которые всплывают в зрелых приложениях
Сначала большинство CRUD‑приложений работают на простых SELECT, INSERT, UPDATE и DELETE. Позже добавляются ленты активности, просмотры аудита, админские отчёты и поиск, который должен казаться мгновенным. Здесь выбор СУБД начинает выглядеть как обмен функциями.
CTE и подзапросы помогают держать сложные запросы читаемыми. Они удобны, когда результат строится по шагам (фильтруем заказы, соединяем платежи, считаем суммы). Но читаемость может скрывать стоимость. Когда запрос медленный, возможно, придётся переписать CTE как подзапрос или join и снова проверить план выполнения.
Оконные функции (window functions) важны, как только кто‑то попросит «ранжировать клиентов по тратам», «показать накопительные итоги» или «последний статус по тикету». Они часто заменяют громоздкие циклы на стороне приложения и сокращают число запросов.
Идемпотентные записи — ещё одно требование «взрослой» системы. Когда происходят повторные попытки (мобильные сети, фоновые задания), upsert‑ы позволяют безопасно писать без двойного создания записей:
- PostgreSQL:
INSERT ... ON CONFLICT - MariaDB:
INSERT ... ON DUPLICATE KEY UPDATE
Поиск — это фича, которая подкрадывается незаметно. Встроенный полнотекстовый поиск покрывает каталоги товаров, базы знаний и заметки поддержки. Поиск по триграммам полезен для подсказок и устойчивости к опечаткам. Если поиск становится ядром (сложное ранжирование, много фильтров, сильная нагрузка), внешняя поисковая система может стоить дополнительных усилий.
Пример: портал заказов начинается с «показать заказы». Год спустя требуется «показать последний заказ каждого клиента, ранжировать по месячным расходам и искать по неправильно написанным именам». Это уже возможности базы, а не только работа интерфейса.
Транзакции, блокировки и конкуренция под нагрузкой
Когда трафик низкий, большинство СУБД кажется достаточным. Под нагрузкой разница чаще связана с тем, насколько хорошо вы справляетесь с одновременными изменениями одних и тех же данных, а не с сырой скоростью. PostgreSQL и MariaDB могут обслуживать транзакционный CRUD, но нужно проектировать под конкуренцию.
Изоляция простыми словами
Транзакция — это набор шагов, которые должны выполниться вместе. Изоляция контролирует, что другие сессии видят во время выполнения этих шагов. Более высокая изоляция исключает неожиданные чтения, но может увеличить ожидания. Многие приложения стартуют с настроек по умолчанию и ужесточают изоляцию только для тех потоков, где это действительно нужно (например, списание денег и обновление заказа).
Что реально вызывает проблемы с блокировками
Проблемы с блокировками в CRUD‑приложениях обычно вызваны несколькими повторяющимися причинами: «горячие» строки, которые все обновляют, счётчики, меняющиеся при каждом действии, очереди задач, где много воркеров пытаются захватить одну и ту же «следующую задачу», и длинные транзакции, держащие блокировки пока выполняется другая работа (или пользователь думает). Чтобы снизить конкуренцию, держите транзакции короткими, обновляйте только необходимые колонки и избегайте сетевых вызовов внутри транзакции.
Полезная привычка — повторять операцию при конфликте. Если два агента поддержки сохраняют изменения в одном тикете одновременно, не стоит молча проваливать операцию. Обнаружьте конфликт, загрузите последнюю версию строки и предложите пользователю повторно применить изменения.
Чтобы выявлять проблемы рано, следите за дедлоками, долго работающими транзакциями и запросами, которые проводят время в ожидании, а не в выполнении. Включите лог медленных запросов как часть рутины, особенно после релизов, добавляющих новые экраны или фоновые задания.
Операции, важные после запуска
После запуска вы уже оптимизируете не только скорость запросов, но и восстановление, безопасные изменения и предсказуемую производительность.
Обычный следующий шаг — добавить реплику. Primary обрабатывает записи, а реплика может обслуживать тяжёлые чтения, как дашборды или отчёты. Это меняет представление о свежести данных: некоторые чтения могут отставать на секунды, поэтому приложению нужно знать, какие экраны должны читать с primary (например, «только что размещённый заказ»), а какие могут терпеть чуть более старые данные (например, недельные сводки).
Бэкапы — это лишь половина задачи. Важно, можно ли быстро и корректно восстановить. Регулярно тестируйте восстановление в отдельной среде и проверяйте базовые вещи: приложение подключается, ключевые таблицы на месте, критические запросы возвращают ожидаемые результаты. Часто команды поздно обнаруживают, что делали бэкап не того, или что время восстановления значительно превышает допустимый простой.
Апгрейды тоже перестают быть «нажать и надеяться». Планируйте окно обслуживания, читайте заметки о совместимости и тестируйте путь обновления на копии продакшн‑данных. Даже минорные версии могут менять планы выполнения запросов или поведение индексов и JSON‑функций.
Простая наблюдаемость окупается рано. Начните с логов медленных запросов и топа запросов по суммарному времени, насыщенности соединений, отставания репликации (если есть реплики), коэффициента попадания в кэш и давления I/O, а также ожиданий блокировок и событий дедлоков.
Как выбирать: практический процесс оценки
Если вы в тупике, перестаньте смотреть списки фич и проведите небольшой тест с вашей нагрузкой. Цель не идеальный бенчмарк, а избегание сюрпризов, когда таблицы достигают миллионов строк и цикл релизов ускоряется.
1) Соберите мини‑тест, похожий на продакшн
Выберите часть приложения, которая представляет реальную боль: одну‑две ключевые таблицы, пару экранов и пути записи за ними. Соберите ваши топ‑запросы (те, что за списками, страницами деталей и фоновых задачами). Наберите реалистичное число строк (хотя бы в 100× по сравнению с прототипом, с похожей формой данных). Добавьте индексы, которые, по вашему мнению, нужны, затем выполните те же запросы с теми же фильтрами и сортировками и зафиксируйте тайминги. Повторите при наличии записей (простой скрипт вставки и обновления строк подойдёт).
Быстрый пример — список «Клиенты», который фильтруется по статусу, ищет по имени, сортируется по последней активности и пагинируется. Один такой экран часто выявляет, «стареет» ли ваша индексация и поведение планировщика.
2) Репетируйте миграции как настоящий релиз
Создайте staging‑копию данных и отрепетируйте изменения, которые ожидаете: добавление колонки, смена типа, бэфилл, создание индекса. Измерьте время, блокирует ли это записи и что реально значит откат, когда данные уже изменены.
3) Используйте простую скоринговую карту
После тестирования оцените каждый вариант по производительности для ваших реальных запросов, корректности и безопасности (ограничения, транзакции, краевые случаи), риску миграций (блокировки, даунтайм, опции восстановления), усилиям операций (бэкап/восстановление, репликация, мониторинг) и комфортности команды.
Выберите базу, которая снижает риск на ближайшие 12 месяцев, а не ту, что победила в одном микротесте.
Частые ошибки и ловушки
Самые дорогие проблемы с базами часто начинаются как «быстрые победы». Обе СУБД способны обслуживать транзакционный CRUD, но неправильные привычки повредят любой из них по мере роста трафика и данных.
Одна из ловушек — рассматривать JSON как ярлык для всего. Гибкое поле extras нормально для действительно опциональных данных, но базовые поля вроде статуса, временных меток и внешних ключей должны оставаться реальными колонками. Иначе вы получите медленные фильтры, неудобную валидацию и болезненные переработки при росте потребностей отчётности.
Индексирование имеет свою ловушку: создание индекса для каждого фильтра, который вы видите на экране. Индексы ускоряют чтение, но замедляют записи и делают миграции тяжелее. Индексируйте то, что реально используют пользователи, а затем валидируйте нагрузкой.
Миграции могут «кусаться», когда блокируют таблицы. Большие единовременные изменения вроде переписи большой колонки, добавления NOT NULL с дефолтом или создания большого индекса могут блокировать записи на минуты. Разбейте рискованные изменения на шаги и планируйте их в периоды тишины приложения.
Также не полагайтесь вечно на настройки ORM по умолчанию. Когда список вырастает с 1 000 до 10 миллионов строк, вам потребуется читать планы запросов, находить отсутствующие индексы и исправлять медленные соединения.
Быстрые сигналы тревоги: JSON‑поля используются для основных фильтров и сортировок, число индексов растёт без измерения влияния на запись, миграции переписывают большие таблицы в одном деплое, и пагинация без стабильного порядка (что приводит к пропускам и дублированию строк).
Быстрый чек‑лист перед выбором
Перед принятием решения сделайте реальную проверку, исходя из ваших самых загруженных экранов и процесса релизов.
- Могут ли ваши топ‑экраны оставаться быстрыми в пике? Протестируйте самый медленный список с реальными фильтрами, сортировкой и пагинацией и подтвердите, что индексы соответствуют этим точным запросам.
- Можно ли безопасно доставлять изменения схемы? Опишите план expand‑backfill‑contract для следующего критического изменения.
- Есть ли чёткое правило для JSON vs колонок? Решите, какие ключи JSON должны быть доступны для поиска или сортировки, а какие по‑настоящему гибкие.
- Завиcите ли вы от конкретных возможностей запросов? Проверьте поведение upsert, оконных функций, CTE и необходимость функциональных или частичных индексов.
- Можете ли вы эксплуатировать базу после запуска? Докажите, что можете восстановиться из бэкапа, измеряйте медленные запросы и базируйте эталоны задержек и ожиданий блокировок.
Пример: от простого трекинга заказов до загруженного клиентского портала
Представьте клиентский портал, который начинается просто: клиенты входят, смотрят заказы, скачивают счета и открывают тикеты поддержки. На первой неделе любая транзакционная база кажется подходящей. Страницы загружаются быстро, схема небольшая.
Через несколько месяцев появляются моменты роста. Клиенты просят фильтры вроде «заказы, отправленные за последние 30 дней, оплаченные картой, с частичным возвратом». Саппорт хочет быстрые экспорты в CSV для еженедельных проверок. Финансы требует аудита: кто и когда поменял статус счёта, и с какого на какой. Шаблоны запросов становятся шире и разнообразнее, чем первоначальные экраны.
Вот где решение сводится к конкретным возможностям и их поведению под реальной нагрузкой.
Если вы добавляете гибкие поля (инструкции по доставке, кастомные атрибуты, метаданные тикетов), поддержка JSON важна, потому что рано или поздно вы захотите искать внутри этих полей. Будьте честны насчёт того, будет ли команда индексировать JSON‑пути, валидацию форм и поддерживать предсказуемую производительность по мере роста JSON.
Отчётность — ещё одно давление. В тот момент, когда вы объединяете заказы, счета, платежи и тикеты с множеством фильтров, вам понадобятся составные индексы, планировщик запросов и возможность эволюционировать индексы без простоя. Миграции перестают быть «запустить скрипт в пятницу» и становятся частью каждого релиза, потому что небольшое изменение схемы может затронуть миллионы строк.
Практический путь вперёд: запишите пять реальных экранов и экспортов, которые вы ожидаете через шесть месяцев, включите таблицы истории аудита с самого начала, бенчмаркните на реалистичном объёме данных вашими самыми медленными запросами (не «hello‑world» CRUD) и задокументируйте правила команды по использованию JSON, индексации и миграциям.
Если вы хотите двигаться быстро, не вручную строя каждый слой, AppMaster может генерировать production‑готовые бэкенды, веб‑и нативные мобильные приложения из визуальной модели. Это также подсказывает учитывать экраны, фильтры и бизнес‑процессы как реальные рабочие нагрузки запросов заранее, что помогает ловить риски индексации и миграций до продакшна.
Вопросы и ответы
Начните с описания реальной нагрузки: ваши самые загруженные экранные списки, фильтры, сортировки и пиковые пути записи. Обе СУБД могут хорошо работать для CRUD, но более безопасным выбором станет та, которая лучше впишется в то, как вы проектируете индексы, проводите миграции и пишете запросы в ближайший год, а не та, название которой кажется знакомым.
Если страницы‑списки замедляются при переходе на более поздние страницы — это признак затрат OFFSET. Если сохранение «зависает» в часы пик, вероятно, есть конфликт блокировок или длинные транзакции. Если релизы теперь включают бэфиллы и большие индексы, миграции стали проблемой надёжности, а не просто изменением схемы.
Предпочитайте один составной индекс для важного экранного запроса, упорядоченный так: самые постоянные фильтры первыми, а колонка сортировки — последней. Например, для многопользовательских списков хорошо подходят (tenant_id, status, created_at), потому что такой индекс поддерживает и фильтрацию, и сортировку без лишнего сканирования.
При OFFSET‑пагинации база вынуждена пропускать предыдущие строки, поэтому по мере роста номера страницы она замедляется. Лучше использовать keyset‑пагинацию: передаёте последнее увиденное значение (например, created_at и id) и запрашиваете «следующие 20 старее этого». Это стабильнее по производительности и уменьшает дубли и пропуски при появлении новых строк.
Добавляйте индекс только когда можете назвать конкретный экран или API‑вызов, который его использует, и проверяйте после релизов. Слишком много пересекающихся индексов тихо замедляют все вставки и обновления, и вы получите ощущение «случайной» тормознутости в часы пик записей.
Следуйте подходу «расширить — заполнить — сократить»: добавьте новые структуры совместимо, бэфиллите партиями, затем валидацией и ограничениями подтвердите корректность и только потом удаляйте старый путь. Это снижает риск при больших таблицах и постоянном трафике.
Храните в JSON данные типа payload, которые в основном показываются или нужны для отладки, и переводите ключевые поля в колонки, когда по ним начинают фильтровать, сортировать или строить отчёты. Это предотвращает медленные запросы в JSON и облегчает проверку обязательных значений и состояний.
Когда повторы и таймауты становятся обычным явлением (мобильные сети, фоновые задания), важны upsert‑операции, чтобы не создавать дубликаты. В PostgreSQL используйте INSERT ... ON CONFLICT, в MariaDB — INSERT ... ON DUPLICATE KEY UPDATE; в обоих случаях аккуратно определяйте уникальные ключи, чтобы повторы не приводили к созданию дубликатов.
Держите транзакции короткими, не выполняйте сетевые вызовы внутри транзакции и минимизируйте «горячие» строки, которые все обновляют (например, общие счётчики). При конфликте лучше выполнить повторную попытку или показать пользователю, что произошёл конфликт, чтобы изменения не терялись молча.
Да, если вы готовы мириться с небольшой задержкой данных на реплике для read‑heavy страниц вроде дашбордов и отчётов. Критичные чтения сразу после изменения (например, «только что размещённый заказ») держите на primary, и обязательно мониторьте lag репликации, чтобы не показывать слишком устаревшие данные.


