239 lines
22 KiB
Markdown
239 lines
22 KiB
Markdown
# Семинар 9: Репликация
|
||||
|
|
|
|||
|
|
В рамках данного семинара предлагается разобраться с концепцией репликации данных.
|
|||
|
|
Идея простая — иногда вместо одного сервера необходимо завести его "реплики" (или копии),
|
|||
|
|
где будет лежать одна и та же информация.
|
|||
|
|
|
|||
|
|
Это позволяет добиться:
|
|||
|
|
- Отказоустойчивость и сохранность данных — при отказе одного из серверов, данные можно получить с другого сервера.
|
|||
|
|
- Увеличение производительности — если один сервер не справляется с обработкой всех запросов на чтение.
|
|||
|
|
- Уменьшение latency — расположить сервера-реплики ближе к клиентам (пример — CDN).
|
|||
|
|
|
|||
|
|
## Репликация в СУБД
|
|||
|
|
|
|||
|
|
Как правило в существующих хранилищах (СУБД) репликация уже реализована и используется повсеместно.
|
|||
|
|
|
|||
|
|
Как правило выделяется узел, который принимает запросы от пользователя на запись. Далее этот узел должен
|
|||
|
|
отправить запрос на запись в остальные узлы (реплики).
|
|||
|
|
|
|||
|
|
### Sync and async replication
|
|||
|
|
|
|||
|
|
Подход, когда есть один узел, принимающий запросы на запись и реплицирующий данные на другие реплики,
|
|||
|
|
называется leader-follower (ранее известный как master-slave).
|
|||
|
|
|
|||
|
|

|
|||
|
|
|
|||
|
|
*[Источник](https://arpitbhayani.me/blogs/replication-strategies/)*
|
|||
|
|
|
|||
|
|
Выделяют два вида репликации по принципу "когда клиент получает ответ на запрос записи":
|
|||
|
|
- **Синхронная репликация** — данные сразу дублируются на все реплики, и только после
|
|||
|
|
подтверждения записи на других репликах, клиент получает ответ об успешной записи.
|
|||
|
|
Это позволяет избежать потери данных при отказе узла, который производит запись, но сложнее
|
|||
|
|
в реализации и увеличивает latency ответа клиенту.
|
|||
|
|
- **Асинхронная репликация** — клиент получает ответ сразу после успешной записи на узел, который
|
|||
|
|
изначально принял запрос пользователя. Данные на реплики дублируются в фоновой операции.
|
|||
|
|
|
|||
|
|
Возможна комбинация данных режимов: например, одна sync-реплика и несколько async-реплик:
|
|||
|
|

|
|||
|
|
|
|||
|
|
*Источник: Martin Kleppmann, Designing Data-Intensive Applications*
|
|||
|
|
|
|||
|
|
Асинхронный режим репликации проще в реализации, уменьшает latency ответа клиенту,
|
|||
|
|
но может привести к потере данных (лидер не успел отправить всю информацию на реплики).
|
|||
|
|
|
|||
|
|
Также в async-репликации возможны так называемые stale чтения (чтение возвращает не самую актуальную информацию):
|
|||
|
|

|
|||
|
|
|
|||
|
|
*Источник: Martin Kleppmann, Designing Data-Intensive Applications*
|
|||
|
|
|
|||
|
|
### Что реплицировать
|
|||
|
|
|
|||
|
|
Возникает вопрос, а что именно необходимо реплицировать.
|
|||
|
|
|
|||
|
|
**Репликация запросов**. Лидер, приняв запрос от клиента, выполняет аналогичный запрос на всех репликах,
|
|||
|
|
а затем возвращает ответ клиенту. Вариант прост для реализации и довольно экономный, однако возможны трудности
|
|||
|
|
с недетерминированными запросами. Если запрос полагается на вызов rand(), это значение на каждом из
|
|||
|
|
узлов может быть разным, что приведет к расхождению данных.
|
|||
|
|
|
|||
|
|
**Row-based репликация**. Если представить, что наша модель данных может быть представлена в виде таблицы
|
|||
|
|
(например, таблицы из пар ключ-значение), то можно реплицировать все изменения строк в данной таблице.
|
|||
|
|
Таким образом мы не будем страдать от недетерминированных запросов, так как предварительно лидер применяет
|
|||
|
|
все изменения к своему состоянию и только потом отправляет измененные значения строк. Однако в случае
|
|||
|
|
массовых операций обновления (PUT *=NULL) потребуется передавать довольно много информации.
|
|||
|
|
|
|||
|
|
**Репликация файловой системы**. Можно реплицировать файловую систему. При вызове системного вызова
|
|||
|
|
write(offset, "data") этот запрос проксируется на все реплики, и каждая из них выполняет запись в
|
|||
|
|
свою локальную файловую систему. Реализация возможна на уровне операционной системы, однако в таком
|
|||
|
|
подходе необходимо следить за совместимостью версий операционной системы и файловой системы, что не всегда
|
|||
|
|
возможно.
|
|||
|
|
|
|||
|
|
Идеального подхода к репликации не существует; необходимо выбирать его из решаемой задачи и имеющихся
|
|||
|
|
ограничений.
|
|||
|
|
|
|||
|
|
В современных СУБД для восстановления и репликации часто используют WAL (write-ahead log) — журнал
|
|||
|
|
предзаписи. При изменении данных СУБД формирует запись WAL и обновляет страницу в оперативной памяти;
|
|||
|
|
страница может измениться в памяти раньше, чем журнал попадёт на диск. Правило write-ahead требует сохранить WAL
|
|||
|
|
на диске до того, как туда будет записана изменённая страница. Если после сбоя страница на диске оказалась
|
|||
|
|
устаревшей, СУБД может воспроизвести журнал и восстановить недостающие изменения.
|
|||
|
|
|
|||
|
|
Для репликации лидер передаёт записи WAL реплике, а та применяет их у себя в том же порядке. При физической
|
|||
|
|
репликации узлам нужны совместимые форматы страниц и WAL.
|
|||
|
|
|
|||
|
|

|
|||
|
|
|
|||
|
|
*[Источник](https://martinfowler.com/articles/patterns-of-distributed-systems/write-ahead-log.html)*
|
|||
|
|
|
|||
|
|
### Кворумная репликация
|
|||
|
|
|
|||
|
|
Дожидаться подтверждения операции от всех реплик может быть накладно, однако хочется недопустить потери значения.
|
|||
|
|
В таких условиях возникла идмея кворумной репликации.
|
|||
|
|
|
|||
|
|
[Кворум](https://en.wikipedia.org/wiki/Quorum_(distributed_computing)) — это минимальное
|
|||
|
|
количество подтверждений, которое необходимо получить для того, чтобы считать операцию выполненной.
|
|||
|
|
|
|||
|
|
Допустим, мы хотим реплицировать данные на три реплики. При получении запроса будем отправлять запросы
|
|||
|
|
на запись на все три реплики, но дожидаться ответа будем только от двух реплик (от тех, что выполнили
|
|||
|
|
операцию быстрее). Если ответ получен от двух реплик, то считаем операцию выполненной.
|
|||
|
|
|
|||
|
|
Внимательный слушатель заметит, что при такой реализации запрос на чтение может вернуть неактуальные данные,
|
|||
|
|
если одна из реплик еще не успела применить обновления. Чтобы решить эту проблему, предлагается
|
|||
|
|
отправлять запросы на чтение на все реплики, дожидаться ответа от двух самых быстрых реплик и выбирать
|
|||
|
|
наиболее актуальную версию значения из полученных (тему версионирования затронем далее в курсе).
|
|||
|
|
|
|||
|
|
В таком случае нам неважно, какие реплики подтвердили запись и ответили на операцию чтения.
|
|||
|
|
Так как на операцию записи и чтения мы обращались на две реплики, как бы мы не выбрали эти множества
|
|||
|
|
реплик, они обязательно пересекутся как минимум по одной реплике. Эта реплика будет содержать
|
|||
|
|
наиболее актуальные данные.
|
|||
|
|
|
|||
|
|
Если обобщить задачу на произвольное число реплик N, то для кворума необходимо выбрать числа R и W.
|
|||
|
|
R — число реплик, от которых мы ждем ответа на операцию чтения, W — число реплик,
|
|||
|
|
которые должны подтвердить операцию записи. Чтобы система возвращала актуальные значения, должно
|
|||
|
|
выполняться условие `R + W > N`. В зависимости от отношения и сложности операций чтения и записи
|
|||
|
|
можно выбирать наиболее подходящие R и W для вашей системы.
|
|||
|
|
|
|||
|
|
Реализацию этой идеи можно увидеть в СУБД Cassandra
|
|||
|
|
(и почитать в paper'e о Dynamo, секция 4, sloppy quorum & hinted handoff).
|
|||
|
|
|
|||
|
|

|
|||
|
|
|
|||
|
|
*[Источник](https://www.designgurus.io/course-play/grokking-the-advanced-system-design-interview/doc/replication)*
|
|||
|
|
|
|||
|
|
В [учебном примере KV](../../08-scaling/seminar/kv/) есть режим `replication-quorum`.
|
|||
|
|
Прокси последовательно обращается ко всем узлам. Запись успешна, если её подтвердило большинство узлов.
|
|||
|
|
При чтении прокси возвращает значение, которое совпало в ответах большинства. Например, для трёх узлов
|
|||
|
|
ответы `v₂, v₂, v₁` дают `v₂`, а три разных значения приводят к ошибке.
|
|||
|
|
|
|||
|
|
Этот пример показывает голосование по значениям. Для выбора самой свежей версии, описанного выше,
|
|||
|
|
понадобятся метки версий и другое правило обработки ответов.
|
|||
|
|
|
|||
|
|
## RAID
|
|||
|
|
|
|||
|
|
[RAID](https://en.wikipedia.org/wiki/Standard_RAID_levels) (redundant array of inexpensive disks)
|
|||
|
|
— переносит идею репликации данных из распределенной системы на уровень одного компьютера.
|
|||
|
|
|
|||
|
|
Оказывается, к одному серверу можно подключить сразу несколько дисков с целью увеличения скорости доступа
|
|||
|
|
и надежности сохранности (пережить отказ диска).
|
|||
|
|
|
|||
|
|
Первая простая идея — поставить два диска рядом, писать половину данных на первый диск,
|
|||
|
|
вторую половину — на второй. Надежность не увеличивается, но зато увеличивается вместительность
|
|||
|
|
и скорость доступа. Такой подход называется RAID 0:
|
|||
|
|
|
|||
|
|
<p align="center">
|
|||
|
|
<img alt="https://en.wikipedia.org/wiki/File:RAID_0.svg" src="media/raid-0.png" width=200px/>
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
Вторая простая идея — это использовать несколько дисков для хранения одного и того же файла
|
|||
|
|
(или блока данных). Это называют RAID 1:
|
|||
|
|
|
|||
|
|
<p align="center">
|
|||
|
|
<img alt="https://en.wikipedia.org/wiki/File:RAID_1.svg" src="media/raid-1.png" width=200px/>
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
Можно также обратиться к теории алгебры и вспомнить коды Хэмминга и коды Рида-Соломона, которые
|
|||
|
|
позволяют детектировать ошибки в записи и хранении данных. На N полезных байт можно завести дополнительные
|
|||
|
|
K байт для исправления ошибок. Такой подход называется RAID 6 (используются коды Рида-Соломона):
|
|||
|
|
|
|||
|
|
<p align="center">
|
|||
|
|
<img alt="https://en.wikipedia.org/wiki/File:RAID_6.svg" src="media/raid-6.png" width=400px/>
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
Не погружаясь в детали алгебру, идею восстанавливающих кодов можно понять на примере операции XOR.
|
|||
|
|
Допустим есть две половины данных: A и B. Давайте посчитаем `C = A ^ B`. Распределим эти три блока по
|
|||
|
|
трём дискам. Тогда при потере одного из дисков мы всегда можем обратиться к оставшимся двум и восстановить
|
|||
|
|
данные с помощью великолепного свойства операции XOR: `A = C ^ B`.
|
|||
|
|
|
|||
|
|
## Erasure coding
|
|||
|
|
|
|||
|
|
Подход с RAID-массивами можно обратно перенести на уровень приложений в распределенных системах.
|
|||
|
|
|
|||
|
|
Стандартная репликация данных как правило влечет большой расход дискового пространства: приходится
|
|||
|
|
хранить на 200% больше данных, чем нужно.
|
|||
|
|
|
|||
|
|
Подход Erasure coding предлагает использовать коды коррекции ошибок на уровне репликации хранилищ.
|
|||
|
|
При записи очередного блока данных необходимо распределить этот блок и его parity-блоки (чексуммы)
|
|||
|
|
на несколько других реплик. Тем самым мы сможем добиться меньшего дублирования данных и по-прежнему
|
|||
|
|
уметь переживать отказы небольшого числа узлов.
|
|||
|
|
|
|||
|
|
Этим свойством пользуются при реализации хранилищ, которые должны работать в рамках большого кластера
|
|||
|
|
(сотни и тысячи узлов).
|
|||
|
|
|
|||
|
|
При реализации данного подхода часто обращаются к алгоритму
|
|||
|
|
[Erasure Coding in Windows Azure Storage](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/LRC12-cheng20webpage.pdf).
|
|||
|
|
|
|||
|
|
<p align="center">
|
|||
|
|
<img alt="Microsoft LRC" src="media/lrc.png" width=600px/>
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
## Линеаризуемость. Примеры для самостоятельного разбора
|
|||
|
|
|
|||
|
|
В каждой истории исходно `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` |
|
|||
|
|
|
|||
|
|
Эту историю линеаризовать нельзя. Чтобы первое чтение вернуло единицу, запись должна стоять перед ним.
|
|||
|
|
Первое чтение завершилось до вызова второго, поэтому второе обязано стоять ещё позже.
|
|||
|
|
Оно тоже должно вернуть единицу, поскольку других записей нет. Перекрытие записи с обоими чтениями
|
|||
|
|
не позволяет объяснить возврат к нулю.
|
|||
|
|
|
|||
|
|
Такой сценарий возможен при чтении с разных реплик, если первое чтение попало на реплику с новым
|
|||
|
|
значением, а второе — на отстающую. Первая история показывает, что чтение во время записи может
|
|||
|
|
возвращать разные значения, а вторая — почему порядок этих ответов имеет значение.
|
|||
|
|
|
|||
|
|
## Полезные материалы
|
|||
|
|
|
|||
|
|
- [Видео](https://youtu.be/gkg-FAEXIkY) и [вики-страница](https://en.wikipedia.org/wiki/CAP_theorem) о CAP-теореме
|
|||
|
|
- [Критика CAP-теоремы](https://jvns.ca/blog/2016/11/19/a-critique-of-the-cap-theorem/)
|
|||
|
|
- [Описание режимов репликации в Postgres](https://www.postgresql.org/docs/current/different-replication-solutions.html)
|
|||
|
|
- [Кворумная репликация в Dynamo](https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf) (секция 4)
|
|||
|
|
- Коды коррекции: [код Хэмминга](https://en.wikipedia.org/wiki/Hamming_code) и [коды Рида-Соломона](https://en.wikipedia.org/wiki/Reed%E2%80%93Solomon_error_correction)
|
|||
|
|
- [Понятное описание различных RAID-стандартов](https://en.wikipedia.org/wiki/Standard_RAID_levels)
|
|||
|
|
- [Особенности устройства и обращения с HDD и как выбирать длину блока](https://ioflood.com/blog/optimal-raid-stripe-size-and-filesystem-readahead-for-raid-10/#:~:text=In%20modern%20operating%20systems%2C%20the,raid%20cards%20default%20stripe%20size)
|
|||
|
|
- [How RAID-6 dual parity calculation works](https://igoro.com/archive/how-raid-6-dual-parity-calculation-works/)
|
|||
|
|
- [Описание Erasure coding в версии семинара 2018](https://github.com/osukhoroslov/distsys-course-hse/tree/2020/seminars/08-scalability#erasure-coding)
|
|||
|
|
- [Алгоритм Erasure Coding в Windows Azure Storage](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/LRC12-cheng20webpage.pdf)
|