Большая таблица ещё не диагноз
Сто миллионов строк не мешают быстрому поиску одной записи по хорошему индексу. Таблица на порядок меньше может тормозить из-за плохого плана, долгой транзакции или конкуренции за горячую строку. Размер базы нужен для capacity planning, но сам по себе не объясняет медленный запрос.
Смотрим частоту запросов, время выполнения, прочитанные строки, ожидание пула, блокировки и дисковый I/O. Запрос за 200 мс, который вызывается десять тысяч раз, может нагрузить базу сильнее редкого отчёта за пять секунд. Поэтому полезно разложить нагрузку по эндпоинтам и клиентам, а не искать только самый медленный SQL.
В PostgreSQL EXPLAIN ANALYZE показывает фактическое выполнение. Сравниваем ожидаемое и реальное число строк, время и участки плана. Большой разрыв часто означает, что планировщик неверно оценил данные. Важно помнить: ANALYZE запускает запрос, в том числе изменяющий данные. Для такого разбора нужны подходящая среда и контроль побочных эффектов.
Начинаем с периода, в котором пользователи замечают проблему. Сравниваем его с обычной работой: изменились частота запросов, размер выборки, число активных транзакций или ожидания? Средняя загрузка за сутки способна скрыть десятиминутный пик, поэтому размер окна здесь важен.
Полезно ранжировать запросы и по суммарному времени, и по отдельной задержке. Первый список покажет общий расход ресурса, второй медленные пользовательские пути. Они не обязаны совпадать. Частый короткий запрос может быть хорошей целью оптимизации, хотя ни один пользователь не назовёт именно его медленным.
На плане обращаем внимание на число проходов, фактические строки и объём промежуточного результата. JOIN, который хорошо работает на маленькой выборке, может стать дорогим при другом клиенте. Проверяем разные значения параметров, особенно крупные аккаунты и популярные фильтры, а не один удобный ID.
Блокировка требует поиска владельца и причины удержания. Запрос может выглядеть дорогим по времени, хотя большую часть ждёт другую транзакцию. Индекс на ожидающий запрос не освободит блокировку. Нужно понять, почему владелец долго не завершает работу и можно ли сократить конкурентный участок.
Перед тестом определяем безопасность измерения. Даже SELECT способен дать большую нагрузку, а изменение данных может повлиять на пользователей. Сначала используем подходящую копию или ограниченный сценарий, учитывая отличие данных и ресурсов. Цель диагностики состоит в том, чтобы найти причину, а не воспроизвести аварию без контроля.
Оптимизируем запросы перед масштабированием БД
На странице 50 заказов. Приложение сначала получает список, потом отдельно читает клиента для каждой строки. Получается 51 поход в БД, обычный N+1. Пакетная загрузка, подходящий JOIN и ограничение выборки часто дадут больше, чем новый сервер. Причём выигрыш останется и после роста нагрузки.
Индекс подбираем под реальные фильтры, сортировку и распределение данных. Его наличие не обязывает планировщик использовать его: для большой доли таблицы последовательное чтение может быть дешевле. Индекс также занимает место и замедляет запись. После добавления сравниваем оба пути, чтобы ускорение чтения не съело запас на вставках.
Отдельно смотрим на долгие транзакции. Пока приложение ждёт внешний API внутри транзакции, база может удерживать соединение, блокировки и старые версии строк. Перенос внешнего вызова за её границу способен помочь, но потребует явно разобрать промежуточные состояния и ошибки. Просто убрать транзакцию означает рискнуть целостностью.
При исправлении N+1 важно не перенести проблему в огромный JOIN. Связь одного заказа с сотнями позиций может размножить строки и объём передачи. Иногда лучше два пакетных запроса, иногда JOIN с ограниченной выборкой. Сравниваем реальную работу и размер ответа, а не количество SQL как единственный показатель.
Для составного индекса важны запросы, которые он обслуживает, и порядок условий. Один индекс не обязан одинаково помогать всем сочетаниям фильтров. Если продукт позволяет десятки произвольных сортировок, нужно выбрать важные пути или ограничить интерфейс. Индексировать каждую комбинацию станет дорого для записи.
Пагинация тоже влияет на рост. Чем глубже смещение в большой выборке, тем больше работы может потребовать пропуск предыдущих строк. Для последовательного просмотра иногда подходит курсор по устойчивому порядку. Но курсор должен учитывать одинаковые значения сортировки, новые записи и ожидания пользователя.
Долгие транзакции разбираем вместе с бизнес-процессом. Если бронь держит блокировку, пока клиент вводит карту, граница явно выбрана неудачно. Можно сохранить короткое удержание с отдельным сроком действия, а платёж обработать вне транзакции. После этого потребуются правила подтверждения и истечения, которые нельзя оставить случайному порядку событий.
Проверяем обслуживание базы: актуальность статистики, освобождение места, рост индексов и длительные изменения схемы. Добавление колонок или индексов тоже может конкурировать с рабочими запросами. Планируем его по возможностям выбранной БД и проверяем влияние. Быстрый запрос после миграции не оправдывает часы блокировки во время неё.
У кэша должна быть договорённость о свежести
Справочник доставки и остаток последнего товара нельзя кэшировать с одинаковыми правилами. Для каждой сущности нужны допустимая задержка обновления, инвалидирование и поведение при cache miss. Если клиент видит старую цену, надо заранее решить, какая цена попадёт в подтверждённый заказ.
Ещё считаем, что произойдёт без кэша. Если hit rate был 90%, база принимала только десятую часть чтений. Потеря кэша при том же потоке отправит в неё примерно в десять раз больше запросов. Помогают лимит параллельных обновлений, разные сроки истечения и временная выдача старого значения там, где это допустимо. Но эти меры нужно проверить на холодном старте, а не только на прогретом тесте.
Для кэша цены рассматриваем два момента: отображение и подтверждение заказа. На странице можно показывать значение с допустимой давностью, но перед оплатой проверить действующие условия. Если цена изменилась, клиент должен увидеть новый расчёт. Такое правило связывает кэш с продуктом, а не только с временем жизни ключа.
Инвалидация зависит от владения данными. Если цену меняют несколько систем, простой сброс ключа в одном сервисе может пропустить изменение из другого. Можно использовать версию или согласованный поток изменений, но надо определить источник и проверить пропуск. Время жизни ограничивает давность, однако не обещает мгновенную актуальность.
При одновременном истечении популярного ключа сотни запросов могут начать одно обновление. Ограничение обновляющих операций и временная выдача допустимого старого ответа снижают такой stampede. Ошибка обновления при этом должна быть видна, иначе кэш способен долго скрывать сломанную зависимость.
Следим за hit rate по важным классам данных. Общие 95% могут состоять из дешёвого справочника, пока самый дорогой запрос всегда промахивается. Выигрыш оцениваем по работе, снятой с базы, и задержке пользователя. Высокий процент попаданий сам по себе не является результатом.
Кэш не обязан быть первым шагом. Он добавляет ещё одно хранилище и новые состояния: отсутствует, устарел, обновляется, недоступен. Если индекс и небольшой ответ уже укладываются в бюджет, сложная инвалидация может стоить больше выгоды. Решение принимаем после измерения конкретного дорогого пути.
Реплика меняет то, что видит пользователь
Клиент сохранил адрес, обновил страницу и снова увидел старый. Запись прошла на primary, чтение ушло на отстающую реплику. Снаружи это выглядит как потеря данных. Для такого сценария можно читать primary или использовать другой механизм, который гарантирует видимость собственной записи.
Синхронная репликация тоже не одна настройка на все случаи. Подтверждение получения записи отличается от подтверждения её применения. Более строгий режим может добавить latency и зависимость от доступности реплики. Выбирать его нужно по нужной гарантии, понимая, при каком отказе запись остановится.
Read replicas разгружают чтение, но не делят поток записи. Нужно смотреть lag, скорость применения изменений и поведение failover. И ещё: реплика не заменяет бэкап. Ошибочное удаление может быстро повториться на всех копиях, поэтому восстановление проверяется отдельно.
Маршрутизацию чтения описываем по операциям. Каталог и старые отчёты могут терпеть lag, подтверждение изменения адреса обычно нет. Чтение с primary для нескольких чувствительных путей иногда проще универсального механизма ожидания реплики. Но оно должно остаться в расчёте нагрузки основной базы.
Реплика не бесплатна для primary: запись журналируется, передаётся и применяется на другой стороне. При тяжёлой записи или медленной реплике надо смотреть весь путь, а не ожидать линейного роста мощности от каждой новой копии. Особенно важно проверить поведение после длительного отставания.
В failover разбираем, кто выбирает новую primary и как старой запрещается принимать запись. Две базы, считающие себя основной, могут создать расходящиеся данные. Простая смена адреса подключения не решает владение записью. Эти правила должны соответствовать используемому механизму управления кластером.
Приложение тоже должно пережить переключение. Старые соединения оборвутся, часть результатов окажется неизвестной, пул начнёт подключаться заново. Проверяем таймауты и повтор операции с сохранением её смысла. Слишком много одновременных переподключений способны перегрузить уже ослабленный кластер.
Для восстановления из бэкапа полезно проверить не только запуск базы, но и полноту нужных данных, точку восстановления и сверку внешних операций. Часть записей могла подтверждаться другой системой независимо. Реплика, бэкап и план сверки решают разные части задачи, даже если все три называют защитой данных.
Партиционирование и шарды: где разница
Партиционирование делит таблицу на части, например события по месяцам. Старый месяц проще удалить целиком, а запрос за неделю сможет не читать остальные части. Это сработает, если условия запроса позволяют отсечь лишние партиции. Само по себе такое деление не распределяет нагрузку по независимым серверам.
Шардирование разносит данные по разным базам. Ключ шарда определяет локальность запросов и баланс нагрузки. Разделение по компании удобно, пока один крупный клиент не забрал большую часть ресурса своего шарда. Другой ключ может равномернее распределить записи, зато превратить обычный отчёт в сборку данных со всех баз.
До миграции разбираем уникальность ID, транзакции между шардами, запросы без ключа, перенос данных и восстановление. Если продукт постоянно соединяет сущности из разных частей, стоимость межшардовой работы может съесть весь выигрыш. Шарды добавляют мощность, но вместе с ней появляется новая работа у приложения и команды.
Партиции по времени хорошо подходят жизненному циклу событий, если запросы обычно знают нужный период. Запрос без условия по времени может всё равно пройти много частей. Поэтому сначала собираем реальные фильтры и только потом выбираем границы. Части ради частей могут усложнить сопровождение без ощутимого ускорения.
У шардирования заранее оцениваем распределение нагрузки, а не только объём хранения. Десять одинаковых по размеру частей могут различаться по активности в десятки раз. Крупная компания, горячая категория или массовая операция нарушают простое равенство. Нужны наблюдаемость и способ перераспределения.
При выборе ключа выписываем самые частые операции. Если заказ и его позиции лежат вместе, локальная транзакция проста. Если платёж и заказ всегда разнесены, обычное подтверждение потребует протокола между базами. Вопрос в том, какие гарантии нужны продукту и сколько дополнительной логики они создадут.
Запрос без ключа шарда может потребовать обращения ко всем частям. С увеличением их числа растут стоимость и вероятность, что одна часть задержит общий ответ. Для поиска и аналитики иногда нужен отдельный путь данных. Но его свежесть и восстановление тоже придётся определить.
Перемещение клиента между шардами проверяем до массового роста. Нужно копировать данные, не потерять новые изменения и согласованно переключить маршрутизацию. Если это возможно только с длительной остановкой, условие должно быть известно. Возможность масштабировать хранение полезна лишь вместе с возможностью управлять этим масштабом.
Планируем переключение и восстановление
Меняем по возможности одну вещь за раз и сравниваем характерную нагрузку: latency, throughput, блокировки, стоимость и запас при отказе. Если одновременно переписать SQL, поменять сервер и разнести данные, будет трудно понять, что помогло и что создало новую проблему.
Для миграции нужны проверка полноты данных, правила переключения записи и действия при расхождении. Rollback тоже надо разобрать заранее: после новых записей возврат старой схемы может уже не быть простым. Работа заканчивается, когда команда умеет эксплуатировать и восстанавливать новую схему, а не в момент успешного копирования таблиц.
До переноса выбираем контрольные признаки полноты. Количество строк полезно, но одинаковое количество не означает одинаковые данные. Сверяем идентификаторы, важные суммы, связи и выборочные записи. Проверки привязываем к бизнес-инвариантам, чтобы ошибка не спряталась за успешным техническим копированием.
Если запись продолжается, надо перенести изменения после исходной копии. Решаем, как упорядочить их, обработать повтор и заметить отставание. Двойная запись в две базы не становится безопасной только потому, что оба вызова есть в коде: один может завершиться, другой нет. Для расхождения нужен способ обнаружения и исправления.
Cutover разбиваем на действия с ясным владельцем: проверить догон, ограничить запись при необходимости, переключить читателей, проверить продукт, затем продолжить поток. У каждого шага должен быть критерий остановки. План, где единственное условие "скрипт завершился", не описывает качество перехода.
Rollback проверяем с учётом новых данных. Если пользователи уже писали в новую схему, возврат старой требует обратной совместимости или переноса этих изменений. Иногда безопаснее временно остановить функцию и исправить новый путь. Это решение нужно принять до события, когда команда будет торопиться.
После миграции оставляем период наблюдения и контролируем хвост задержки, ошибки, рост данных и обслуживание. Удаление старого пути выполняем после проверки, а не одновременно с переключением. Завершённая миграция означает, что данные верны, продукт работает и команда умеет жить с новым устройством базы.
Как выбрать следующий шаг для базы заказов
У базы заказов выросла задержка страницы списка. Сначала сравним периоды и разложим путь на ожидание соединения и выполнение. Обнаружили N+1 и выборку лишних полей. Исправление проверяем на маленьких и крупных компаниях. Затем смотрим запись и обслуживание, чтобы новый индекс не съел запас в другом сценарии.
Если после этого дорого повторно читать стабильные данные, оцениваем кэш со сроком свежести и поведением без него. Статус сразу после изменения остаётся отдельным путём. Для отчётов, которым допустимо отставание, рассматриваем реплику. Измеряем её lag и проверяем отказ, не выдавая вторую копию за защиту от любой потери данных.
Решение о партициях привязываем к реальным периодам запросов и удалению старой истории. Шарды рассматриваем только после проверки распределения и цены межшардовых операций. Для каждой добавленной части описываем владение записью, восстановление и миграцию. Дополнительная мощность должна оставаться управляемой обычной командой.
Переход готов, когда контрольные данные совпадают, новые записи не потеряны, продукт проверен и план при расхождении исполним. После cutover наблюдаем рабочую нагрузку, не удаляя прежний путь раньше проверки. Следующий шаг может снова быть локальным SQL, потому что после каждого улучшения ограничение меняется. Это нормальная последовательность, а не признак неудачного скейлинга.