Files
hse-2026/materials/09-data-replication/seminar/readme.md
T
2026-11-12 11:31:33 +03:00

22 KiB
Raw Blame History

Семинар 9: Репликация

В рамках данного семинара предлагается разобраться с концепцией репликации данных. Идея простая — иногда вместо одного сервера необходимо завести его "реплики" (или копии), где будет лежать одна и та же информация.

Это позволяет добиться:

  • Отказоустойчивость и сохранность данных — при отказе одного из серверов, данные можно получить с другого сервера.
  • Увеличение производительности — если один сервер не справляется с обработкой всех запросов на чтение.
  • Уменьшение latency — расположить сервера-реплики ближе к клиентам (пример — CDN).

Репликация в СУБД

Как правило в существующих хранилищах (СУБД) репликация уже реализована и используется повсеместно.

Как правило выделяется узел, который принимает запросы от пользователя на запись. Далее этот узел должен отправить запрос на запись в остальные узлы (реплики).

Sync and async replication

Подход, когда есть один узел, принимающий запросы на запись и реплицирующий данные на другие реплики, называется leader-follower (ранее известный как master-slave).

sync replication

Источник

Выделяют два вида репликации по принципу "когда клиент получает ответ на запрос записи":

  • Синхронная репликация — данные сразу дублируются на все реплики, и только после подтверждения записи на других репликах, клиент получает ответ об успешной записи. Это позволяет избежать потери данных при отказе узла, который производит запись, но сложнее в реализации и увеличивает latency ответа клиенту.
  • Асинхронная репликация — клиент получает ответ сразу после успешной записи на узел, который изначально принял запрос пользователя. Данные на реплики дублируются в фоновой операции.

Возможна комбинация данных режимов: например, одна sync-реплика и несколько async-реплик: sync and async replica

Источник: Martin Kleppmann, Designing Data-Intensive Applications

Асинхронный режим репликации проще в реализации, уменьшает latency ответа клиенту, но может привести к потере данных (лидер не успел отправить всю информацию на реплики).

Также в async-репликации возможны так называемые stale чтения (чтение возвращает не самую актуальную информацию): stale read from async replica

Источник: Martin Kleppmann, Designing Data-Intensive Applications

Что реплицировать

Возникает вопрос, а что именно необходимо реплицировать.

Репликация запросов. Лидер, приняв запрос от клиента, выполняет аналогичный запрос на всех репликах, а затем возвращает ответ клиенту. Вариант прост для реализации и довольно экономный, однако возможны трудности с недетерминированными запросами. Если запрос полагается на вызов rand(), это значение на каждом из узлов может быть разным, что приведет к расхождению данных.

Row-based репликация. Если представить, что наша модель данных может быть представлена в виде таблицы (например, таблицы из пар ключ-значение), то можно реплицировать все изменения строк в данной таблице. Таким образом мы не будем страдать от недетерминированных запросов, так как предварительно лидер применяет все изменения к своему состоянию и только потом отправляет измененные значения строк. Однако в случае массовых операций обновления (PUT *=NULL) потребуется передавать довольно много информации.

Репликация файловой системы. Можно реплицировать файловую систему. При вызове системного вызова write(offset, "data") этот запрос проксируется на все реплики, и каждая из них выполняет запись в свою локальную файловую систему. Реализация возможна на уровне операционной системы, однако в таком подходе необходимо следить за совместимостью версий операционной системы и файловой системы, что не всегда возможно.

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

В современных СУБД для восстановления и репликации часто используют WAL (write-ahead log) — журнал предзаписи. При изменении данных СУБД формирует запись WAL и обновляет страницу в оперативной памяти; страница может измениться в памяти раньше, чем журнал попадёт на диск. Правило write-ahead требует сохранить WAL на диске до того, как туда будет записана изменённая страница. Если после сбоя страница на диске оказалась устаревшей, СУБД может воспроизвести журнал и восстановить недостающие изменения.

Для репликации лидер передаёт записи WAL реплике, а та применяет их у себя в том же порядке. При физической репликации узлам нужны совместимые форматы страниц и WAL.

write ahead log

Источник

Кворумная репликация

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

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

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

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

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

Если обобщить задачу на произвольное число реплик N, то для кворума необходимо выбрать числа R и W. R — число реплик, от которых мы ждем ответа на операцию чтения, W — число реплик, которые должны подтвердить операцию записи. Чтобы система возвращала актуальные значения, должно выполняться условие R + W > N. В зависимости от отношения и сложности операций чтения и записи можно выбирать наиболее подходящие R и W для вашей системы.

Реализацию этой идеи можно увидеть в СУБД Cassandra (и почитать в paper'e о Dynamo, секция 4, sloppy quorum & hinted handoff).

Dynamo replication

Источник

В учебном примере KV есть режим replication-quorum. Прокси последовательно обращается ко всем узлам. Запись успешна, если её подтвердило большинство узлов. При чтении прокси возвращает значение, которое совпало в ответах большинства. Например, для трёх узлов ответы v₂, v₂, v₁ дают v₂, а три разных значения приводят к ошибке.

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

RAID

RAID (redundant array of inexpensive disks) — переносит идею репликации данных из распределенной системы на уровень одного компьютера.

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

Первая простая идея — поставить два диска рядом, писать половину данных на первый диск, вторую половину — на второй. Надежность не увеличивается, но зато увеличивается вместительность и скорость доступа. Такой подход называется RAID 0:

https://en.wikipedia.org/wiki/File:RAID_0.svg

Вторая простая идея — это использовать несколько дисков для хранения одного и того же файла (или блока данных). Это называют RAID 1:

https://en.wikipedia.org/wiki/File:RAID_1.svg

Можно также обратиться к теории алгебры и вспомнить коды Хэмминга и коды Рида-Соломона, которые позволяют детектировать ошибки в записи и хранении данных. На N полезных байт можно завести дополнительные K байт для исправления ошибок. Такой подход называется RAID 6 (используются коды Рида-Соломона):

https://en.wikipedia.org/wiki/File:RAID_6.svg

Не погружаясь в детали алгебру, идею восстанавливающих кодов можно понять на примере операции XOR. Допустим есть две половины данных: A и B. Давайте посчитаем C = A ^ B. Распределим эти три блока по трём дискам. Тогда при потере одного из дисков мы всегда можем обратиться к оставшимся двум и восстановить данные с помощью великолепного свойства операции XOR: A = C ^ B.

Erasure coding

Подход с RAID-массивами можно обратно перенести на уровень приложений в распределенных системах.

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

Подход Erasure coding предлагает использовать коды коррекции ошибок на уровне репликации хранилищ. При записи очередного блока данных необходимо распределить этот блок и его parity-блоки (чексуммы) на несколько других реплик. Тем самым мы сможем добиться меньшего дублирования данных и по-прежнему уметь переживать отказы небольшого числа узлов.

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

При реализации данного подхода часто обращаются к алгоритму Erasure Coding in Windows Azure Storage.

Microsoft LRC

Линеаризуемость. Примеры для самостоятельного разбора

В каждой истории исходно x = 0, а все операции указаны в таблице. Числа обозначают моменты на общей условной временной оси. write(x, 1) записывает единицу, read(x) возвращает значение x.

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

Для каждой истории попробуйте расставить точки линеаризации или объяснить, почему это невозможно.

История 1

Клиент Операция Вызов Ответ Результат
A write(x, 1) 1 6 OK
B Первое read(x) 2 3 0
B Второе read(x) 4 5 1

Эту историю можно линеаризовать. Выберем точку первого чтения в момент 2,5, записи — в момент 3,5, второго чтения — в момент 4,5. Получим порядок «чтение 0, запись 1, чтение 1». Все точки лежат внутри интервалов своих операций. Второе чтение может увидеть единицу ещё до ответа на запись, поскольку эти операции перекрываются во времени.

История 2

Клиент Операция Вызов Ответ Результат
A write(x, 1) 1 6 OK
B Первое read(x) 2 3 1
B Второе read(x) 4 5 0

Эту историю линеаризовать нельзя. Чтобы первое чтение вернуло единицу, запись должна стоять перед ним. Первое чтение завершилось до вызова второго, поэтому второе обязано стоять ещё позже. Оно тоже должно вернуть единицу, поскольку других записей нет. Перекрытие записи с обоими чтениями не позволяет объяснить возврат к нулю.

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

Полезные материалы