1. Репликация
  2. Репликация с несколькими ведущими узлами
  3. Конкурентные операции
  4. Секционирование
  5. Транзакции
  6. Слабые уровни изоляции

Заметки из книги Клеппмана “Высоконагруженные приложения”.

В этой части: Зачем нужно распределять данные по разным нодам или даже ЦОДам; Чем отличается синхронная и асинхронная репликация; Как можно организовать реплкацию ведущего и ведомаго узла; Какие анамалии возможны при асинхронной репликации и какие гарантии можно дать.

Распределенные данные

Вот несколько возможных причин для распределения базы данных между несколькими машинами:

  • Масштабируемость. Если объем данных, нагрузка по чтению или записи перерастают возможности одной машины, то можно распределить эту нагрузку на несколько компьютеров.
  • Отказоустойчивость/высокая доступность. Если приложение должно продолжать работать даже в случае сбоя одной из машин(или нескольких машин, или сети, или даже всего ЦОДа), то можно использовать избыточные компьютеры.
  • Задержка. При наличии пользователей по всему миру необходимо серверы в разных точках земного шара, что бы каждый пользователь обслуживался ЦОДом, географически расположенным максимально близко от него.

Масштабирование в расчете на более высокую нагрузку. Если вам нужно масштабировать свою систему в расчете на более высокую нагрузку, то простейший способ — купить более мощную машину. Иногда этот подход называют вертикальным масштабированием (vertical scaling, scaling up).

Можно объединить много процессоров, чипов памяти и жестких дисков под управлением одной операционной системы, а быстрые соединения между ними позволят любому из процессоров обращаться к любой части памяти или диска. В подобной архитектуре с разделяемой памятью (shared-memory architecture) можно рассматривать все компоненты как единую машину.

Главная проблема подхода с разделяемой памятью состоит в том, что стоимость растет быстрее, чем линейно: машина с вдвое большим количеством CPU, вдвое большим количеством памяти и двойным объемом дисков стоит отнюдь не вдвое дороже. Кроме того, вследствие узких мест вдвое более мощная машина не обязательно сможет справиться с двойной нагрузкой.

Другой подход: архитектура с разделяемым дисковым накопителем (shared-disk architecture), при которой применяется несколько машин с отдельными CPU и оперативной памятью, но данные хранятся в массиве дисков, совместно используемых всеми машинами, подключенными с помощью быстродействующей сети. Такая архитектура применяется при складировании данных, но конкуренция и накладные расходы на блокировки ограничивают масштабируемость этого подхода.

Архитектуры без разделения ресурсов (shared-nothing architectures), известные под названием горизонтального масштабирования (horizontal scaling, scaling out), приобрели немалую популярность. При этом подходе каждый компьютер или виртуальная машина, на которой работает база данных, называется узлом (node). Все узлы используют свои CPU, память и диски независимо друг от друга. Согласование узлов выполняется на уровне программного обеспечения с помощью обычной сети.

Репликация

Репликация (replication) означает хранение копий одних и тех же данных на нескольких машинах, соединенных с помощью сети.

Cуществует несколько возможных причин репликации данных:

  • ради хранения данных географически близко к пользователям (сокращения задержек);
  • чтобы система могла продолжать работать при отказе некоторых ее частей (повышения доступности);
  • для горизонтального масштабирования количества машин, обслуживающих запросы на чтение (повышения пропускной способности по чтению).

Ведущие и ведомые узлы

Одна из реплик назначается ведущим (leader) узлом. Клиенты, желающие записать данные в базу, должны отправить свои запросы ведущему узлу, который сначала записывает новые данные в свое локальное хранилище.

Другие реплики называются ведомыми (followers) узлами. Всякий раз, когда ведущий узел записывает в свое хранилище новые данные, он также отправляет информацию об изменениях данных всем ведомым узлам в качестве части журнала репликации (replication log) или потока изменений (change stream). Все ведомые узлы получают журнал от ведущего и обновляют соответствующим образом свою локальную копию БД, применяя все операции записи в порядке их обработки ведущим узлом.

Когда клиенту требуется прочитать данные из базы, он может выполнить запрос или к ведущему узлу, или к любому из ведомых. Однако запросы на запись разрешено отправлять только ведущему (ведомые с точки зрения клиента предназначены только для чтения).

Синхронная и асинхронная репликация

Важный фактор работы реплицируемой системы — синхронно или асинхронно выполняется репликация.

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

При асинхронной репликации ведущий узел отправляет сообщение, но не ждет ответа от ведомого.

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

Преимущество синхронной репликации: копия данных на ведомом узле гарантированно актуальна и согласуется с ведущим узлом. В случае внезапного сбоя последнего можно быть уверенным, что данные попрежнему доступны на ведомом узле. Недостаток же состоит в следующем: если синхронный ведомый узел не отвечает (из-за его сбоя, или сбоя сети, или по любой другой причине), то операцию записи завершить не удастся. Ведущему узлу придется блокировать все операции записи и ждать до тех пор, пока синхронная реплика не станет снова доступна.

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

На практике активизация в СУБД синхронной репликации обычно означает, что один из ведомых узлов — синхронный, а остальные — асинхронны. В случае замедления или недоступности синхронного ведомого узла в него превращается один из асинхронных ведомых узлов. Это гарантирует наличие актуальной копии данных по крайней мере на двух узлах: ведущем и одном синхронном ведомом. Такая конфигурация иногда называется полусинхронной (semi-synchronous).

Создание новых ведомых узлов

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

  1. Сделать согласованный снимок состояния БД ведущего узла на определенный момент времени — по возможности без блокировки всей базы.
  2. Скопировать снимок состояния на новый ведомый узел.
  3. Ведомый узел подключается к ведущему и запрашивает все изменения данных, произошедшие с момента создания снимка. Для этого нужно, чтобы снимок состояния соотносился с определенной позицией в журнале репликации ведущего узла. Сама позиция называется по-разному: в PostgreSQL — регистрационным номером транзакции в журнале (log sequence number), в MySQL — координатами в бинарном журнале (binlog coordinates).
  4. Когда ведомый узел завершил обработку изменений данных, произошедших с момента снимка состояния, говорят, что он наверстал упущенное. После этого он может продолжать обрабатывать поступающие от ведущего узла изменения данных.

Отказ ведомого узла: наверстывающее восстановление

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

Отказ ведущего узла: восстановление после отказа

Справиться с отказом ведущего узла сложнее: необходимо «повысить в звании» один из ведомых до ведущего, настроить клиенты на отправку записей новому ведущему, а другие ведомые должны начать получать изменения данных от нового ведущего. Этот процесс называется восстановлением после отказа (failover).

Автоматически процесс восстановления после отказа обычно состоит из следующих шагов:

  1. Установить отказ ведущего узла. Используется превышение времени ожидания: узлы постоянно обмениваются сообщениями друг с другом, и если один из них не отвечает в течение определенного времени (скажем, 30 секунд), то считается, что он не работает.
  2. Выбрать новый ведущий узел. Здесь можно задействовать процесс «выборов» (ведущий узел выбирается в соответствии с большинством оставшихся реплик), или же этот ведущий назначает предварительно выбранный узелконтроллер (controller node). Оптимальным кандидатом на роль нового ведущего узла обычно является реплика с наиболее свежими изменениями данных, полученными от старого (в целях минимизации потерь данных).
  3. Настроить систему на использование нового ведущего узла. Клиенты должны начать отправлять запросы на запись новому ведущему узлу. Если старый ведущий узел возобновляет работу, может оказаться, что он продолжает считать себя ведущим и не осознает решение остальных реплик считать его недееспособным. Система должна обеспечить превращение старого ведущего узла в ведомый и признание им нового ведущего.

У восстановления может быть много сложностей.

  • При использовании асинхронной репликации новый ведущий узел мог не получить все сообщения о записи от старого из-за отказа последнего.
  • При определенных сценариях сбоев может оказаться так, что два узла будут одновременно считать себя ведущими.
  • Сколько следует ждать, прежде чем объявить ведущий узел недоступным?

Реализация журналов репликации

Операторная репликация

В простейшем случае ведущий узел записывает в журнал каждый выполняемый запрос на запись (оператор) и отправляет данный журнал выполнения операторов ведомым узлам. В случае реляционной БД это значит, что каждый оператор INSERT, UPDATE или DELETE пересылается ведомым узлам, и каждый ведомый узел производит синтаксический разбор и выполнение этого оператора SQL так, как если бы он был получен от клиента.

Могут быть прболемы с недетерминированными функциями now(), rand() и т.д.

Перенос журнала упреждающей записи (WAL)

  • В случае журналированной подсистемы хранения(LSM-деревья) этот журнал представляет собой основное место хранения информации. Сегменты его сжимаются и подвергаются сборке мусора в фоновом режиме.
  • В случае B-дерева, перезаписывающего отдельные дисковые блоки, все изменения сначала записываются в журнал упреждающей записи, чтобы индекс можно было вернуть в согласованное состояние после фатального сбоя.

Так или иначе журнал представляет собой предназначенную только для дописывания данных в конец последовательность байтов, содержащую результаты всех операций записи в БД. Можно воспользоваться тем же журналом для создания реплики на другом узле: помимо записи журнала на диск, ведущий узел также отправляет его по сети ведомым узлам. А ведомые узлы, обрабатывая этот журнал, создают точные копии тех же структур данных, что и на ведущем.

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

Логическая (построчная) журнальная репликация

Альтернатива — использовать разные форматы журнала для репликации и подсистемы хранения; это даст возможность расцепить журнал репликации с внутренним устройством подсистемы хранения. Такой вид журнала называется логическим журналом (logical log), чтобы различать его с физическим представлением данных подсистемы хранения.

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

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

Формат логического журнала также удобнее для синтаксического разбора внешними приложениями. Этот аспект играет важную роль при необходимости отправить содержимое БД во внешнюю систему, например в склад данных для офлайн-анализа или построения пользовательских индексов и кэшей. Такая методика называется захватом изменений данных (change data capture).

Триггерная репликация

Триггеры позволяют регистрировать пользовательский код, автоматически запускаемый при возникновении в БД события изменения данных (транзакции записи). Триггер получает возможность занести изменения в отдельную таблицу, из которой их сможет прочитать внешний процесс. Затем этот внешний процесс сможет применить любую логику приложения, которая только понадобится.

Накладные расходы при триггерной репликации обычно выше, чем при других типах репликации, и она сильнее подвержена ошибкам и более ограниченна, чем встроенная репликация базы данных. Однако благодаря своей гибкости может оказаться полезной.

Проблемы задержки репликации

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

В такой масштабируемой по чтению архитектуре (read-scaling architecture) можно увеличивать возможности выдачи запросов только на чтение просто с помощью добавления новых ведомых узлов.

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

Описанная несогласованность — лишь временное состояние, если прекратить запись в базу данных и подождать немного, ведомые узлы постепенно наверстают упущенное и окажутся согласованными с ведущим узлом. Поэтому такой эффект называется конечной согласованностью (eventual consistency).

Термин «конечная» умышленно сделан столь неопределенным: в целом нет предела тому, насколько сильно может запаздывать реплика. При обычной эксплуатации задержка между операцией записи на ведущем узле и ее воспроизведением на ведомом узле — задержка репликации (replication lag) — может составлять лишь доли секунды и на практике быть совсем незаметной. Однако если система работает на пределе возможностей или присутствуют проблемы с сетью, задержка легко способна вырасти до нескольких секунд или даже минут.

Читаем свои же записи

Многие приложения позволяют пользователю отправить какие-либо данные, а затем просмотреть, что было отправлено. Новые данные должны сначала отправляться ведущему узлу, но при просмотре пользователем могут читаться с одного из ведомых узлов.

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

В этом случае нам необходима согласованность типа «чтение после записи» (read-after-write consistency), называемая также чтением своих записей (read-your-writes).

Она гарантирует, что, перезагрузив страницу, пользователь точно увидит внесенные им же изменения. Относительно других пользователей она ничего не обещает: внесенные ими изменения могут еще некоторое время не быть видны. Однако пользователь может быть уверен: внесенные им самим данные были сохранены правильно.

Как же реализовать согласованность типа «чтение после записи» в системе с репликацией с ведущим узлом?

  • Данные, которые пользователь мог изменить, читаются с ведущего узла, а все остальные — с одного из ведомых.
  • Отслеживать время последнего обновления и в течение одной минуты после этого читать все с ведущего узла.
  • Клиент способен запоминать метку даты/времени своей последней операции записи — тогда система сможет гарантировать, что обслуживающая все операции чтения этого пользователя реплика отражает изменения по крайней мере по состоянию на соответствующий метке момент времени. Если же реплика недостаточно актуальна, то можно или делегировать операцию чтения другой реплике, или отложить выполнение запроса, пока реплика не наверстает упущенное. Эта метка может быть логической (отражающей последовательность операций записи, например регистрационный номер транзакции в журнале) или фактическим временем системы.
  • Если реплики распределены по нескольким ЦОДам (ради географической близости к пользователю), то возникают дополнительные сложности. Все запросы, подлежащие обслуживанию ведущим узлом, необходимо маршрутизировать на ЦОД, в котором находится этот узел.

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

Монотонные чтения

Наш второй пример аномалии, происходящей при чтении с асинхронных ведомых узлов: пользователь может наблюдать движение в обратном направлении времени.

Это может произойти при выполнении пользователем нескольких операций чтения из различных реплик. Пользователь выполняет один и тот же запрос дважды: первый раз к ведомому узлу, который запаздывает лишь чуть-чуть, а второй — к ведомому узлу, запаздывающему сильнее. (Такой сценарий вполне вероятен при обновлении пользователем страницы, если все запросы маршрутизируются к случайным серверам.) Первый запрос возвращает комментарий, недавно добавленный пользователем, а второй не возвращает ничего, поскольку сильно запаздывающий ведомый узел еще не обработал эту операцию записи. Фактически второй запрос видит систему по состоянию на более ранний момент времени, чем первый.

Монотонное чтение записей гарантирует, что подобная аномалия не произойдет. Это не такая надежная гарантия, как сильная согласованность (strong consistency), но более твердая, чем конечная согласованность. Вы можете увидеть старое значение при чтении; монотонное чтение означает только одно: при нескольких операциях чтения подряд пользователь не увидит продвижения обратно по времени, то есть старые данные не будут читаться после новых.

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

Согласованное префиксное чтение

Наш третий пример аномалий задержки репликации касается нарушения причинно-следственной связи.

Согласованное префиксное чтение (consistent prefix reads) гарантирует, что если операции записи выполняются в определенной последовательности, то в ней же они будут и прочитаны.

Реализация этого метода особенно сложна в секционированных базах данных. Если БД всегда применяет операции записи в одном и том же порядке, то префикс при чтении всегда будет согласованным, так что аномалии не случится. Однако во многих распределенных базах различные секции функционируют независимо, поэтому глобального упорядочения операций записи нет: при чтении из БД пользователи видят одни части базы в более старом состоянии, а другие — в более новом.

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


Источники


Комментарии в Telegram-группе!