Растянутый кэш на два датацентра: почему аналогия с базой ложная
«База же у нас растянута репликами, и ничего» — про базу это правда, но «растянуть» у базы и у кэша означает разное. Кэш держится сравнительным свойством: поход в него должен обходиться дешевле операции источника, которую он заменяет. На дешёвом чтении по ключу за каналом между ДЦ этого не остаётся — 41,09 мс против 0,09 мс до базы под рукой. А кластер, разложенный по датацентрам поровну, при разрыве отказал в обеих половинах сразу, включая ту, у которой большинство голосов. Заодно здесь проверен сам измерительный стенд: ретранслятор без TCP_NODELAY добавляет к записи с WAIT больше сорока миллисекунд, и замер на таком стенде мерил бы не расстояние, а дефект прибора.
Полное техническое изложение
TL;DR
- Мысль «растянем кэш на два датацентра» опирается на аналогию с базой. База растянута и работает — значит, и кэш можно. Аналогия неверна.
- Кэш нужен затем, чтобы повторное получение данных обходилось дешевле обращения к источнику. «Дешевле» — это и задержка, и снятая нагрузка, и несделанный пересчёт, и лимит внешнего вызова. Замерена одна сторона — задержка — на самом невыгодном для кэша случае: 41,09 мс в чужом ДЦ против 0,09 мс до базы под рукой.
- Мастер не ждёт реплику. При разрыве сразу после залпа записей потерялись
93 записи из 200. Клиенту на все двести пришёл
OK. - Окно потерь задаёт отставание, а не круг. На одном и том же канале разный темп записи даёт 104, 3 и 0 потерянных записей.
- Кластер поровну на два ДЦ при разрыве отказал в обеих половинах — включая ту, где было большинство мастеров. Каждая половина проверена на своём свежем кластере.
- Проверен и сам измерительный стенд. Если у ретранслятора, изображающего
канал между ДЦ, не выставлен
TCP_NODELAY, прибор добавляет к каждой записи сWAITсорок с лишним миллисекунд собственного залипания — и их легко принять за свойство Redis. - Те же утверждения прогнаны на Redis 8.10.1 и Valkey 9.1.2 — ни одно не разошлось.
- Как надо: свой кэш в каждом ДЦ. Расхождение между ними допустимо и складывается из отставания источника, задержки инвалидации, политики свежести и последствий отказов; TTL — одно слагаемое из четырёх.
Три способа растянуть кэш
Растягивают кэш одним из трёх способов:
- один кластер на два ДЦ, узлы поровну;
- мастер в одном ДЦ, реплика в другом;
- приложение в обоих ДЦ ходит в один кэш.
Довод под всеми тремя один: база у нас растянута репликами и работает, значит, и кэш так можно.
Довод не глупый. Он просто неверен, и неверен в трёх разных местах.
Кэш и база решают разные задачи
База — источник истины. Её нельзя потерять, и ради этого не жалко тратить время на дорогу между датацентрами.
Кэш нужен затем, чтобы повторное получение данных обходилось дешевле обращения к источнику. «Дешевле» — не обязательно «быстрее»: это и снятая нагрузка, и несделанный дорогой пересчёт, и не потраченный лимит внешнего вызова, и защита горячего ключа. Свойство в любом случае сравнительное: отодвиньте кэш дальше источника — и его не станет.
Дальше измерена одна из этих сторон — задержка, — и измерена на самом невыгодном для кэша случае.
| медиана, мс | p95, мс | |
|---|---|---|
| кэш в своём ДЦ | 0,10 | 0,14 |
| база в своём ДЦ | 0,09 | 0,13 |
| кэш в чужом ДЦ | 41,09 | 41,36 |
Кэш в чужом ДЦ медленнее базы в своём примерно в 450 раз — эту кратность печатает сам прогон.
Третий способ умирает здесь. Половина запросов пойдёт в чужой ДЦ, заплатит дорогу туда и обратно и вернётся позже, чем вернулся бы поход в свою базу.
Из первых двух строк не следует, что кэш не нужен: там измерено чтение одной строки по ключу из маленькой таблицы в памяти — самое дешёвое, что база умеет. Следует другое, и это правило, а не число: поход в кэш обязан быть дешевле той операции источника, которую он заменяет, на этом расстоянии. Для дешёвого чтения по ключу ответ на сорока миллисекундах отрицательный. Для агрегата, который считается сто миллисекунд, — уже нет, и такой случай здесь не измерялся.
Терять записи кэша — норма, терять строки базы — авария
Второй способ: мастер здесь, реплика там. Мастер отвечает клиенту
OK, не дожидаясь реплики. Это не настройка, а объявленное поведение:
Redis uses by default asynchronous replication
(Redis по умолчанию использует асинхронную репликацию).
Теперь поднимем реплику на место мастера — то есть сделаем то, ради чего её и держат.
| 200 записей подтверждено клиенту | на реплике | потеряно |
|---|---|---|
| разрыв сразу после записи | 107 | 93 (46,5%) |
| разрыв через секунду после записи | 200 | 0 (0,0%) |
Обе строки надо читать вместе. Теряется не «почти всё» и не «ничего», а то,
что не успело уехать к реплике. Ни одна из потерянных записей об ошибке не
сообщила: на все клиент получил OK.
Окно потерь задаёт отставание, а не круг
Напрашивается правило «под угрозой всё, что записано за последний круг». В таком виде оно и ходит по статьям, и оно неверно: круг здесь совпал с потерей случайно — двести быстрых записей как раз в него и уложились. Проверяется это изменением одного лишь темпа записи, при том же канале.
| 200 записей, затем обрыв | на реплике | потеряно | отставание, байт |
|---|---|---|---|
| залпом, обрыв сразу | 96 | 104 | 3548 |
| с паузой 5 мс, обрыв сразу | 197 | 3 | 99 |
| залпом, обрыв через секунду | 200 | 0 | 0 |
Канал во всех трёх опытах один и тот же, круг тот же; меняется отставание, а вместе с ним и потеря.
Правило в верном виде: под угрозой то, что в момент разрыва лежит между
master_repl_offset мастера и смещением реплики. Оба числа Redis отдаёт в
INFO replication, и по их разнице видно, сколько вы потеряете, если
переключение случится прямо сейчас.
И вот здесь аналогия ломается. Для базы потерянный хвост — авария: строку никто не пересчитает. Для кэша потеря записи — обычное дело: промах, поход в источник, запись обратно.
Значит, вся машинерия, ради которой терпят растянутую базу, кэшу по умолчанию
не нужна. А платить за неё придётся: команда WAIT, которой обычно предлагают
«сделать репликацию синхронной», делает запись дороже в 171,6 раза —
41,73 мс вместо 0,24 мс на каждую запись.
Из чего складывается эта цена, по одной точке сказать нельзя — и догадка «это два круга» напрашивается сама собой. Тот же опыт на трёх расстояниях даёт ответ точнее — и другой.
| в одну сторону | круг | обычная | с WAIT 1 | WAIT / круг |
|---|---|---|---|---|
| 5 мс | 10 мс | 0,15 мс | 10,90 мс | 1,09 |
| 20 мс | 40 мс | 0,14 мс | 41,52 мс | 1,04 |
| 40 мс | 80 мс | 0,20 мс | 81,40 мс | 1,02 |
Отношение к кругу — единица на всех трёх расстояниях. То есть WAIT стоит
ровно один круг до реплики, и с расстоянием растёт один к одному.
Этому числу можно верить только потому, что проверен сам прибор. Канал между
ДЦ изображает ретранслятор, и если на его соединениях не выставлен
TCP_NODELAY, пара «алгоритм Нейгла — отложенное подтверждение» добавляет
сорок с лишним миллисекунд к каждой операции, где по каналу идёт несколько
мелких сообщений подряд: при нулевой задержке запись с WAIT 1 стоит 44,00 мс
без флага и 0,70 мс с ним. Такой прибор мерил бы себя, и сверх круга оставалось
бы постоянное слагаемое, которое нечем объяснить. Полный разбор — в подробной
версии статьи.
Покупает же WAIT не гарантию: он сообщает, сколько реплик подтвердило, но
отменить запись не может — на мастере она уже применена и уже видна читателям.
Для дёшево восстанавливаемого кэша платить круг на каждую запись трудно
оправдать. Но если прогрев стоит часов или источник не переживёт лавины
холодного старта, надёжность репликации приобретает цену, и сравнивать её надо
со стоимостью повторного наполнения, а не с нулём.
Слово «растянуть» у базы и у кэша означает разное
Асинхронная горячая реплика Postgres в другом ДЦ — отдельная система: читающая, отстающая, и приложение об этом знает. Она ничего не решает за мастера.
Оговорка обязательна: «база растянута» — не описание архитектуры. У Postgres есть и синхронная репликация, при которой коммит ждёт удалённый standby, бывают и базы с кворумом. Сравнивать надо конкретную модель репликации с конкретной и называть оба конца: ниже сравнивается асинхронная реплика Postgres с кластером Redis.
Кластер Redis на два ДЦ — одна система, которая принимает решения голосованием. Условие выживания записано в спецификации, и в нём два требования:
Redis Cluster is able to survive partitions where the majority of the master
nodes are reachable and there is at least one reachable replica for every master
node that is no longer reachable.
Кластер Redis способен пережить разделения сети, при которых доступно большинство мастер-узлов и для каждого недоступного мастер-узла доступна хотя бы одна реплика.
Первое помнят все: нужно большинство. Второе читают по диагонали. Из-за него всё и происходит.
Главное: отказали обе половины
Шесть узлов, по три в каждом ДЦ, мастера поделены два и один. Схема симметричная, и этим она убеждает.
Карта перед разрывом, как её напечатал прогон bench/stretched-cache/cluster.py:
| ДЦ-1 — 2 мастера из 3 | ДЦ-2 — 1 мастер из 3 |
|---|---|
| 52467: мастер | 35445: мастер |
| 45191: мастер | 32777: реплика 45191 |
| 54701: реплика 52467 | 38349: реплика 35445 |
| ДЦ | узел | состояние кластера | что вернул GET |
|---|---|---|---|
| ДЦ-1 — 2 мастера | 52467 | fail | CLUSTERDOWN |
| 45191 | fail | CLUSTERDOWN | |
| 54701 | fail | CLUSTERDOWN | |
| ДЦ-2 — 1 мастер (свой кластер) | 49019 | fail | CLUSTERDOWN |
| 40491 | fail | CLUSTERDOWN | |
| 48663 | fail | CLUSTERDOWN |
Кэша не стало нигде. Ни в одном из двух датацентров.
С ДЦ-2 всё понятно: один мастер из трёх, большинства нет.
А у ДЦ-1 большинство было — два мастера из трёх. Голосов хватило. Но повышать
оказалось некого: реплика того мастера, что остался в ДЦ-2, тоже осталась в
ДЦ-2 (посмотрите на карту выше). Слоты этого мастера не покрыл никто, а
кластер с неполным покрытием (cluster-require-full-coverage yes по умолчанию)
перестаёт принимать записи.
Почему отказали и чтения
За чтения отвечает другая настройка — cluster-allow-reads-when-down, по
умолчанию no. Напрашивается вывод, что наблюдаемое поведение «шире
написанного»; вывод неверен — поведение описано. Цепочка такая:
неполное покрытие слотов → кластер помечен отказавшим → узел отказавшего
кластера не обслуживает и чтения. Проверяется тем же раскладом, прогнанным
дважды.
| настройка | узел | роль | состояние кластера | чтение 20 ключей |
|---|---|---|---|---|
cluster-allow-reads-when-down no (по умолчанию) | 43151 | мастер | fail | 20 CLUSTERDOWN |
| 47729 | мастер | fail | 20 CLUSTERDOWN | |
| 35021 | реплика | fail | 20 CLUSTERDOWN | |
cluster-allow-reads-when-down yes | 54835 | мастер | fail | 5 отдал, 15 перенаправил |
| 46259 | мастер | fail | 9 отдал, 11 перенаправил | |
| 48093 | реплика | fail | 20 перенаправил |
SET на том же узле в обоих прогонах получил CLUSTERDOWN. Со значением yes узел отвечает только по тем ключам, чьи слоты остались за
ним: из двадцати выжившая половина отдала четырнадцать, остальные перенаправила
на мастер за оборванным каналом. В записи отказано в обоих случаях. То есть
настройка не возвращает кэш — она превращает «не работает ничего» в «работает
часть чтений на части пространства ключей».
Отсюда правило: большинство голосов необходимо, но недостаточно. Полное
условие состоит из трёх частей: большинство мастеров на своей стороне, плюс
доступная реплика каждого потерянного мастера, плюс достаточная свежесть этой
реплики — отставшая сверх cluster-replica-validity-factor выборы не начнёт. В
этих опытах свежесть не мешала: прогон печатает и это.
Проверить это глазами по схеме нельзя: на схеме видно число узлов, а не то, чья реплика где стоит. Расставляет реплики сам кластер — при создании и после каждого переключения.
Переложить реплику руками можно, и тогда одна половина выживет. Но только одна: так вы выбираете, какой датацентр переживёт разрыв.
Границу этого вывода надо очертить точно. Утверждение не «оба датацентра не могут работать», а вот такое: в одном обычном кластере Redis нельзя получить симметричный разрыв, при котором обе половины независимо продолжают принимать записи, а потом автоматически сливаются обратно. Другие исходы возможны — два независимых кэша, работа только на чтение в урезанном виде, продукт с active-active и явной моделью разрешения конфликтов, — просто это уже не «один растянутый кластер», и здесь они не измерялись.
Как надо
- Кэш в каждом ДЦ свой, наполняется своим приложением из своего источника. Разрыв канала для него — не событие.
- Цена в идеальном установившемся режиме — отдельное наполнение каждого горячего ключа в каждом регионе. Это нижняя граница, а не оценка: «два промаха — и всё» было бы обманом: реальную нагрузку задают вытеснение, перезапуски, переключения, выкладки, инвалидация, холодный старт и переброс трафика. Отсюда и то, чего у одного общего кэша не было: два независимых домена лавины.
- Расхождение между ДЦ допустимо, но им управляет не один только TTL. Наблюдаемое расхождение складывается из четырёх слагаемых: отставание самого источника, задержка инвалидации, политика свежести (собственно TTL и его родня — обновление заранее, скользящий срок, отдача устаревшего значения на время пересчёта) и последствия отказов и восстановления. Бюджетом надо называть сумму.
- Синхронная инвалидация между ДЦ — дорогая сделка: ожидание подтверждения возвращает круг, от которого мы ушли, и возвращает его в горячий путь записи. Если ждать всё-таки приходится, дело не в TTL: требование «подтверждаем запись только после того, как её увидят в другом регионе» — это требование к модели согласованности кэш-слоя, и решается оно версионированием, маршрутизацией читателя в тот же регион или выносом кэша с пути корректности.
- Одноразовый кэш должен быть восстановим из авторитетного источника или из повторяемого вычисления. Источником бывает база, объектное хранилище, журнал событий, внешний API или канонический расчёт — важно не что это, а что путь восстановления известен.
- Свой кэш в каждом ДЦ убирает канал между ДЦ из пути к кэшу, но не делает приложение независимым от него. Если запись всё равно идёт в мастер базы в другом ДЦ, разрыв остаётся событием для приложения.
- Общий кэш остаётся кэшем. Ходовое правило звучит так: «если кэш обязан быть общим, значит, в нём лежит не кэш». Это неверно. Общий внешний кэш на несколько приложений — нормальная конструкция: он лучше снимает нагрузку с источника и переживает выкладку приложения. Красный флаг не «общий», а невосстановимость. Проверка простая: удалите кэш целиком. Стало медленнее, данные восстановились — это кэш. Потерялись сессии, блокировки, счётчики — это хранилище состояния, и разговор о нём другой.
Когда растянутый кэш всё-таки допустим
- Если разрыв должен пережить один назначенный ДЦ, а не оба. Это работает — но вы выбираете победителя заранее, во втором ДЦ кэша не будет, а расклад реплик придётся проверять после каждого переключения.
- Если реплика в чужом ДЦ нужна не для работы, а чтобы не наполнять кэш с нуля. Условия: реплики сами не повышаются, переключать придётся руками, и потеряется то, что в момент разрыва лежало между смещениями мастера и реплики. Для кэша последнее терпимо: записи пересчитаются при промахе.
- Если поток записи редкий. Вторая строка замера с переключением и весь замер смещений: опасен не объём данных, а отставание, которое темп записи успевает создать. Проверяется по разнице смещений, а не по расстоянию между ДЦ.
- Если операция источника дороже круга. Замер задержки сравнил кэш в чужом ДЦ с самым дешёвым, что умеет база. Для агрегата, который считается сто миллисекунд, сравнение другое, и кэш за сорок миллисекунд его выигрывает. Числа для дорогих операций здесь не снимались.
Общее у всех четырёх — то, чего в них нет: ни в одном не обещано, что обе половины одного кластера независимо продолжат принимать записи. Как только в требовании появляется слово «оба», из перечисленного не годится ничего.
TL;DR
Довод звучит разумно: «база у нас растянута на два датацентра — растянем и кэш». Аналогия ложная, и ломается она в трёх разных местах сразу.
- Кэш держится сравнительным свойством: поход в него должен обходиться дешевле той операции источника, которую он заменяет. На дешёвом чтении по ключу за каналом между ДЦ этого не остаётся: попадание в кэш в чужом ДЦ стоит 41,09 мс, чтение той же строки из базы под рукой — 0,09 мс.
- Мастер не ждёт реплику. Разрыв сразу после залпа записей потерял
93 записи из 200; клиенту на все двести пришёл
OK. Это не сбой, а объявленный договор асинхронной репликации. WAITпокупает не то, за что его принимают: запись дорожает в 171,6 раза, а отменить её нечем — к моменту ответа она уже применена. Цена — ровно круг до реплики, и это проверено на трёх расстояниях.- Окно потерь задаёт отставание, а не круг. На одном и том же канале разный
темп записи даёт 104, 3 и 0 потерянных записей; смотреть надо на разницу
смещений в
INFO replication. - Главное. Кластер из шести узлов, разложенный по датацентрам поровну, при разрыве отказал в обеих половинах — включая ту, у которой было большинство мастеров. Каждая половина проверена на своём свежем кластере. Большинство голосов необходимо, но недостаточно.
- Проверен и сам измерительный стенд. Ретранслятор, изображающий канал,
без
TCP_NODELAYсам добавляет к записи сWAIT43,30 мс — даже при нулевой задержке. Такой прибор показывал бы сорок миллисекунд собственного залипания как свойство Redis. - Те же утверждения проверены на Redis 8.10.1 и Valkey 9.1.2. Ни одно не разошлось.
- Что делать. Умолчание — свой кэш в каждом ДЦ, наполняется своим приложением. Расхождение между ДЦ складывается из отставания источника, задержки инвалидации, политики свежести и последствий отказов; TTL — одно слагаемое из четырёх.
Три способа растянуть кэш
Растягивают кэш одним из трёх способов, и они настолько разные, что общего у них только слово «растянуть».
- Один кластер на два ДЦ, узлы поровну. Самый убедительный вариант на схеме: три коробочки слева, три справа, разрыв посередине — и кажется, что каждая половина продолжит работать.
- Мастер в одном ДЦ, реплика в другом. Прямая калька с базы: там реплика в соседнем ДЦ, и здесь пусть будет.
- Приложение в обоих ДЦ ходит в один кэш. Формально это даже не растягивание кластера — просто один адрес на две площадки.
Разбирать их приходится по отдельности, потому что ломаются они в разных местах. Но растут все три из одной мысли, и с неё стоит начать.
Аналогия, из которой всё растёт
Довод в пользу растягивания обычно не проговаривают целиком: он кажется самоочевидным. Полностью он звучит так: база у нас растянута на два датацентра репликами, работает годами, никто не жалуется — значит, и с кэшем так можно.
Это не глупый довод. Он опирается на настоящий и удачный опыт: растянутая база действительно работает, и инженер, который её построил, имеет право на обобщение. Обобщение просто оказывается неверным — и не в мелочи, а во всех трёх местах, где оно опирается на слово «так же».
У базы и кэша разные задачи. У них разная цена потери одной записи. И слово «растянуть» означает для них разные вещи. Дальше — по одному различию на раздел, и на каждое есть замер.
Оговорка: на чём снято и что из этого переносится
Все числа этой статьи сняты на Redis 7.0.15, а сказанное дальше относится и к Valkey, и к текущим версиям самого Redis. Разберёмся, что при этом переносится, а что нет.
Valkey — форк Redis, и он сам об этом говорит:
Valkey is a fork of the open-source Redis (REmote DIctionary Server) database created in 2009 by the Italian hacker Salvatore "antirez" Sanfilippo
(Valkey — форк открытой базы данных Redis (REmote DIctionary Server), созданной в 2009 году итальянским хакером Сальваторе „antirez“ Санфилиппо).
Это позволяет перенести не числа, а договор: там, где документация Valkey
описывает то же поведение теми же словами, утверждение статьи держится на
цитате из неё, а не на аналогии.
Ссылаться на общее прошлое, однако, мало: поведение меняется от версии к версии, и это надо проверять, а не предполагать. Поэтому все содержательные утверждения статьи прогнаны заново на Redis 8.10.1 и Valkey 9.1.2 — таблица в отдельном разделе ниже. Ни одно не разошлось.
А числа не переносятся никуда. Здесь они и не нужны как абсолютные значения: содержательны кратность, знак и то, какая половина кластера ответила, а какая нет.
Различие первое: у кэша и базы разные задачи
База — источник истины. Её нельзя потерять, и ради этого не жалко платить круг между датацентрами: строка, доехавшая до второго ДЦ за сорок миллисекунд, всё равно дешевле строки, которой нет нигде.
Кэш нужен затем, чтобы повторное получение данных обходилось дешевле обращения к источнику. «Дешевле» — не обязательно «быстрее»: это может быть и снятая нагрузка с источника, и несделанный дорогой пересчёт, и не потраченный лимит внешнего вызова, и сглаженный всплеск, и защита горячего ключа. Свойство в любом случае сравнительное, а не абсолютное, и потому его можно потерять, не сломав ни одной настройки, — просто передвинув кэш дальше источника.
Дальше измерена одна из этих сторон — задержка, — и измерена на самом невыгодном для кэша случае.
| медиана, мс | p95, мс | |
|---|---|---|
| кэш в своём ДЦ | 0,10 | 0,14 |
| база в своём ДЦ | 0,09 | 0,13 |
| кэш в чужом ДЦ | 41,09 | 41,36 |
Кратность прогон называет сам: кэш в чужом ДЦ медленнее базы в своём примерно в 450 раз.
Здесь ломается третий способ — «приложение в обоих ДЦ ходит в один кэш», — но ломается он не вообще, а для этого класса запросов. Половина обращений пойдёт в чужой датацентр, заплатит круг и вернётся позже, чем вернулся бы поход в локальную базу. Если кэш стоял ради задержки на дешёвых чтениях, он её не уменьшает, а увеличивает — и вдобавок ставит между приложением и данными ещё одну систему, которая может отказать.
Чего этот замер не говорит, и это важнее самих чисел. Первые две строки почти совпали, и из этого не следует, что кэш не нужен. Здесь измерена самая дешёвая операция базы: пять тысяч строк, всё в памяти, чтение по первичному ключу, никакой конкуренции за соединения. Кэш ставят не против такого запроса, а против соединений, агрегатов по миллионам строк и тех же запросов, повторённых тысячу раз в секунду.
Из замера следует ровно одно, и это правило, а не число: выигрыш у кэша берётся из сравнения с источником, поэтому проверять надо не «быстрый ли Redis», а «дешевле ли поход в кэш той операции источника, которую он заменяет, на этом расстоянии». Для дешёвого чтения по ключу из прогретой таблицы ответ на сорока миллисекундах отрицательный. Для агрегата на сто миллисекунд — уже нет, и такой случай в этой статье не измерялся.
Различие второе: потеря строки и потеря записи стоят разного
Второй способ — мастер в одном ДЦ, реплика в другом — калькирует базу буквально. И первое, что замер показывает: расстояние видно.
| запись видна на реплике через | мс |
|---|---|
| реплика в том же ДЦ | 1,1 |
| реплика в другом ДЦ | 20,8 |
| разница | 19,7 |
Само по себе отставание бедой не является. Клиент к этому моменту давно получил
OK: мастер отвечает, не дожидаясь реплики, и это не особенность настройки, а
объявленный договор.
Redis uses by default asynchronous replication, which being low latency and high
performance, is the natural replication mode for the vast majority of Redis use
cases.
Redis по умолчанию использует асинхронную репликацию, которая, будучи низколатентной и высокопроизводительной, является естественным режимом репликации для подавляющего большинства сценариев использования Redis.
So the master does not wait every time for a command to be processed by the
replicas, however it knows, if needed, what replica already processed what
command.
Итак, мастер не ждёт каждый раз, пока команда будет обработана репликами, однако он знает, если это потребуется, какая реплика какую команду уже обработала.
У Valkey та же страница говорит то же самое:
Valkey uses by default asynchronous replication
(Valkey по умолчанию использует асинхронную репликацию) — дальше слово в слово.
Заодно стоит убрать одно недоразумение, на котором держится половина споров: асинхронность — не отличие кэша от базы. У Postgres потоковая репликация по умолчанию тоже асинхронная, и последствия документация называет прямо:
If the primary server crashes then some transactions that were committed may not
have been replicated to the standby server, causing data loss.
Если основной сервер аварийно завершится, некоторые зафиксированные транзакции могут оказаться не реплицированными на резервный сервер, что приведёт к потере данных.
То есть окно потерь есть у обоих. Различаются они не наличием окна, а ценой попадания в него, и вот к ней мы и идём.
Бедой отставание становится в тот момент, когда реплику поднимают на место мастера, — то есть в том самом сценарии, ради которого её и держат в другом ДЦ.
| 200 записей подтверждено клиенту | на реплике | потеряно |
|---|---|---|
| разрыв сразу после записи | 107 | 93 (46,5%) |
| разрыв через секунду после записи | 200 | 0 (0,0%) |
Читать надо обе строки сразу, иначе из первой вынесешь «теряется почти всё», а
из второй — «не теряется ничего». Неверно ни то ни другое: теряется то, что не
успело уехать к реплике. Ни одна из потерянных записей об ошибке не сообщила:
на все клиент получил OK.
Чем измеряется окно потерь, и почему не кругом
Из этого опыта напрашивается правило «под угрозой всё, что записано за последний круг до разрыва». В таком виде оно и ходит по статьям, и оно неверно: круг здесь совпал с потерей случайно. Проверяется это изменением одного лишь темпа записи при том же самом канале.
| 200 записей, затем обрыв | на реплике | потеряно | смещение мастера | смещение реплики | отставание, байт |
|---|---|---|---|---|---|
| залпом, обрыв сразу | 96 | 104 | 6403 | 2855 | 3548 |
| с паузой 5 мс, обрыв сразу | 197 | 3 | 12838 | 12739 | 99 |
| залпом, обрыв через секунду | 200 | 0 | 19273 | 19273 | 0 |
Канал во всех трёх опытах один и тот же, круг тот же; меняется отставание, а вместе с ним и потеря.
Отсюда правило в верном виде: под угрозой то, что в момент разрыва лежит
между master_repl_offset мастера и смещением реплики. Redis отдаёт оба
числа в INFO replication, и по их разнице видно, сколько вы потеряете, если
переключение случится прямо сейчас. Круг задаёт лишь нижнюю границу того, за
какое время отставание вообще может сойти к нулю; само отставание зависит ещё
от темпа записи, размера команд и от того, успевает ли реплика их применять.
Вот здесь аналогия с базой ломается второй раз. Окно потерь, как мы только что видели, есть и у базы. Разная у них не механика, а цена: для базы потерянный хвост — авария, строку никто не пересчитает, её нет нигде. Для кэша потеря записи — норма, ради которой кэш и устроен так, как устроен: промах, поход в источник, запись обратно. То есть машинерия, которая оправдывает растянутую базу, кэшу по умолчанию не нужна, — а заплатить за неё придётся.
Счёт можно увидеть прямо. WAIT — тот самый инструмент, которым обычно
предлагают «сделать репликацию синхронной».
| мс | |
|---|---|
| обычная запись | 0,24 |
запись с WAIT 1 | 41,73 |
| во сколько раз дороже | 171,6 |
| реплик подтвердило (из 20 записей, запрошена 1) | 20 |
Сорок один и семь десятых миллисекунды вместо ноля целых двадцати четырёх сотых — это цена ожидания, добавленная к каждой записи. Из чего она складывается, по одной точке сказать нельзя — и догадка «это два круга» напрашивается сама собой. Тот же опыт на трёх расстояниях даёт ответ точнее — и другой.
| в одну сторону | круг | обычная | с WAIT 1 | WAIT / круг |
|---|---|---|---|---|
| 5 мс | 10 мс | 0,15 мс | 10,90 мс | 1,09 |
| 20 мс | 40 мс | 0,14 мс | 41,52 мс | 1,04 |
| 40 мс | 80 мс | 0,20 мс | 81,40 мс | 1,02 |
Последний столбец — единица на всех трёх расстояниях. То есть WAIT стоит
ровно один круг до реплики, и с расстоянием растёт один к одному: круг вырос
на 30 мс — цена выросла на 30,62; круг вырос на 40 — цена на 39,87. Сверх круга
остаётся от 0,90 до 1,52 мс, и это уже работа самого Redis, а не
расстояние.
Постоянного слагаемого сверх круга здесь нет, и это не само собой разумеется.
На стенде, где ретранслятор не ставит TCP_NODELAY, тот же замер нашёл бы
сверх круга сорок с лишним миллисекунд, одинаковых на всех расстояниях, и
отношение к кругу перестало бы быть единицей. Вывод «с расстоянием цена растёт
на один круг» устоял бы и там, потому что держится на разностях. А вот
слагаемое было бы не свойством Redis, а дефектом измерительного прибора — об
этом следующий раздел.
Купленное же свойство слабее ожидаемого: WAIT сообщает, сколько реплик
подтвердило, но не отменяет запись, если их меньше. К моменту ответа запись на
мастере уже применена и уже видна читателям — откатывать нечем. Документация
говорит это прямо:
However WAIT is only able to ensure there are the specified number of
acknowledged copies in the other Redis instances, it does not turn a set of
Redis instances into a CP system with strong consistency: acknowledged writes
can still be lost during a failover, depending on the exact configuration of the
Redis persistence.
Однако WAIT способен лишь обеспечить наличие указанного числа подтверждённых копий на других экземплярах Redis; он не превращает набор экземпляров Redis в CP-систему со строгой согласованностью: подтверждённые записи всё ещё могут быть потеряны при переключении, в зависимости от конкретной настройки постоянного хранения Redis.
То есть подорожанием в 171,6 раза покупается не гарантия, а отчёт о числе копий. Стоит ли он того — вопрос не про Redis, а про то, во что обходится повторное наполнение кэша; к нему статья возвращается в разделе о том, что делать.
Проверка прибора: чем на самом деле был канал между ДЦ
Вопрос этого раздела лежит уровнем глубже остальных. Остальные — о том, что следует из замера. Этот — о том, что замер на самом деле моделирует.
Канал между датацентрами во всех замерах изображает не tc netem, а
ретранслятор в пользовательском коде: процесс слушает порт, читает порцию,
выжидает и передаёт дальше. Подозрение к такому прибору конкретное: задержка
ставится на каждую порцию, которую вернул recv(), а TCP границ сообщений не
хранит. Значит, измеряться может не «Redis плюс фиксированная задержка
распространения», а «Redis плюс задержка, умноженная на то, на сколько порций
разбился поток».
Сверить с netem в этой среде нельзя: ядро собрано без
CONFIG_NET_SCH_NETEM, и tc qdisc add ... netem отвечает
Error: Specified qdisc kind is unknown. Права тут ни при чём — дисциплины
очереди в ядре просто нет. Поэтому сверяться пришлось с тем, что доступно:
измерить сам прибор.
Первое, что показал прибор: главный вклад даёт не дробление на порции.
Ретранслятор без TCP_NODELAY сам по себе добавляет к каждой записи с WAIT
43,30 мс.
SET + WAIT 1, расстояния нет | обычная запись, мс | с WAIT 1, мс |
|---|---|---|
| без канала вовсе | 0,20 | 0,42 |
канал 0 мс, без TCP_NODELAY | 0,24 | 44,00 |
канал 0 мс, с TCP_NODELAY | 0,18 | 0,70 |
Эти сорок с лишним миллисекунд целиком принадлежат измерительному прибору, а
не Redis: на таком стенде замер цены WAIT нашёл бы сверх круга постоянное
слагаемое, которое нечем объяснить. Redis ставит TCP_NODELAY на своих
соединениях сам; ретранслятор создаёт два новых соединения, и на них флаг
надо ставить явно. Без него работает известная пара: алгоритм Нейгла
придерживает мелкую порцию до подтверждения предыдущей, а отложенное
подтверждение придерживается до сорока миллисекунд. Отсюда и ровно сорок с
небольшим, и их независимость от расстояния — это таймер, а не путь, — и то,
что они появляются только там, где по каналу идёт несколько мелких сообщений
подряд.
Теперь — то самое подозрение про порции, тоже в числах. Сколько раз задержка применяется к одной операции:
сериализующий канал, 20 мс в одну сторону, 20 × SET+WAIT | порций |
|---|---|
| мастер → реплика, вхолостую | 0 |
| реплика → мастер, вхолостую | 1 |
| мастер → реплика, под нагрузкой | 40 |
| реплика → мастер, под нагрузкой | 21 |
| сверх фона, всего | 60 |
| на одну операцию | 3,00 |
| задержка применена, мс на операцию | 60,0 |
| измерено, мс на операцию | 61,81 |
Три порции на операцию: сама запись, запрос подтверждения и подтверждение. Но на критическом пути их две, а не три — первые две идут в одну сторону и могли бы уйти вместе. У сериализующего ретранслятора они не уходят вместе: пока поток спит, он не читает следующую порцию. Цена этого измеряется прямо:
| в одну сторону | круг | сериализующий | конвейерный | разница |
|---|---|---|---|---|
| 5 мс | 10 мс | 16,06 мс | 11,06 мс | +5,00 |
| 20 мс | 40 мс | 61,64 мс | 41,48 мс | +20,16 |
| 40 мс | 80 мс | 121,86 мс | 81,36 мс | +40,51 |
Подозрение подтверждается: сериализующий канал стоит ровно на один перелёт в одну сторону дороже на каждом расстоянии. Не на случайную величину — на ту самую задержку, которую он применяет к порции.
И вот что тут поучительнее каждого дефекта по отдельности. Если залипание в сорок миллисекунд сидит в обоих каналах, этой разницы не видно вовсе: она тонет в артефакте, который больше её самой. Два дефекта стенда маскируют друг друга. Найти каждый по отдельности, глядя на итоговые миллисекунды, нельзя — только разложив прибор на части.
Последнее — калибровка, и она же граница применимости:
| в одну сторону | круг | сериализующий | конвейерный |
|---|---|---|---|
GET напрямую | — | 0,10 мс | 0,10 мс |
| 5 мс | 10 мс | 10,81 мс | 10,79 мс |
| 20 мс | 40 мс | 40,98 мс | 41,00 мс |
| 40 мс | 80 мс | 81,10 мс | 81,11 мс |
Как читать после этого всю статью. Там, где за операцию по каналу проходит
одна порция в каждую сторону — замер задержки и замер видимости записи на
реплике, — ретранслятор даёт ровно круг, и
числа можно брать как есть. Там, где по каналу идёт несколько сообщений подряд
— репликация под нагрузкой, конвейер команд, WAIT, — прибор добавлял своё, и
оба слагаемых теперь измерены и убраны. Остаётся погрешность третьего рода, и
устранить её нечем: у ретранслятора нет ни очередей, ни потерь, ни джиттера, ни
ограничения полосы. Это приближение задержки, а не эмулятор сети на уровне
пакетов, и абсолютные миллисекунды на потоковых операциях надо читать именно
так.
Качественные выводы — знак, кратность, порядок событий, какая половина кластера ответила — от этого не зависят вовсе.
Различие третье: «растянуть» означает разное
Осталось самое главное — первый способ, «узлы поровну». И здесь аналогия ломается не в оценке цены, а в понимании самого слова.
Возьмём ту конфигурацию, которую обычно и имеют в виду, когда говорят «база растянута»: асинхронная горячая реплика Postgres в другом датацентре. Это отдельная система: читающая, отстающая, и приложение об этом знает. Она не голосует и не решает судьбу мастера; при разрыве канала мастер продолжает работать, а реплика продолжает отставать, пока канал не вернётся.
Оговорка здесь обязательна, иначе получится ровно та ошибка, против которой
статья и написана. «База растянута» — не описание архитектуры: у Postgres есть
и синхронная репликация, при которой коммит ждёт удалённый standby
(if the standby is the last one in a synchronous group, setting this to on will result in commits waiting for the standby to confirm receipt
(если резервный сервер последний в синхронной группе, значение on приведёт к тому, что фиксации будут ждать подтверждения приёма от резервного)),
а бывают и базы с кворумом. Сравнивать надо не «базу» с «кэшем», а конкретную
модель репликации с конкретной моделью репликации — с ответами про
синхронность, про то, кто принимает решение о переключении, и про поведение при
разрыве. Ниже сравнивается асинхронная реплика Postgres с кластером Redis, и
оба конца названы поимённо не для формальности.
Кластер Redis в двух датацентрах — это одна система, принимающая решения большинством голосов. Условие, при котором она переживает разрыв, записано в спецификации, и требований в нём два, а не одно:
Redis Cluster is able to survive partitions where the majority of the master
nodes are reachable and there is at least one reachable replica for every master
node that is no longer reachable.
Кластер Redis способен пережить разделения сети, при которых доступно большинство мастер-узлов и для каждого недоступного мастер-узла доступна хотя бы одна реплика.
Первое требование помнят все — «нужно большинство». Второе читают по диагонали,
и вся статья, по существу, про него. У Valkey формулировка та же:
Valkey Cluster is able to survive partitions where the majority of the primary nodes are reachable and there is at least one reachable replica for every primary node that is no longer reachable
(Кластер Valkey способен пережить разделения сети, при которых доступно большинство первичных узлов и для каждого недоступного первичного узла доступна хотя бы одна реплика).
Прежде чем дойти до расклада «поровну», стоит посмотреть на расклад, который кажется самым осторожным: все мастера в одном ДЦ, все реплики во втором.
| ДЦ | узел | состояние кластера | что вернул GET |
|---|---|---|---|
| ДЦ-1 — три мастера | 46073 | ok | значение |
| 38365 | ok | отдал слот другому узлу | |
| 48713 | ok | отдал слот другому узлу | |
| ДЦ-2 — три реплики (свой кластер) | 47655 | fail | CLUSTERDOWN |
| 34721 | fail | CLUSTERDOWN | |
| 52771 | fail | CLUSTERDOWN |
Роли за время разрыва не изменились ни на одной половине, а у всех реплик канал
к мастеру до разрыва был up — свежесть ничего не блокировала. Это пригодится
ниже.
ДЦ-1 работает, ДЦ-2 не отвечает ничем. Реплики не стали мастерами: для
повышения нужен голос большинства мастеров — Once the replica receives ACKs from the majority of masters, it wins the election
(Как только реплика получает подтверждения от большинства мастеров, она выигрывает выборы), —
а все мастера остались по ту сторону обрыва.
Это первый результат, который стоит забрать с собой: вторая половина не запасная. Если первая пропадёт, вторая её не заменит — по крайней мере, сама.
Кульминация: отказали обе половины
Теперь расклад, ради которого всё и затевается. Шесть узлов, по три в каждом датацентре, мастера поделены два и один. На схеме это выглядит симметрично, и именно симметрия убеждает.
Карта, которую прогон bench/stretched-cache/cluster.py напечатал перед
разрывом, — на кластере, где наблюдается ДЦ-1:
| ДЦ-1 — 2 мастера из 3 | ДЦ-2 — 1 мастер из 3 |
|---|---|
| 52467: мастер | 35445: мастер |
| 45191: мастер | 32777: реплика 45191 |
| 54701: реплика 52467 | 38349: реплика 35445 |
| ДЦ | узел | состояние кластера | что вернул GET |
|---|---|---|---|
| ДЦ-1 — 2 мастера | 52467 | fail | CLUSTERDOWN |
| 45191 | fail | CLUSTERDOWN | |
| 54701 | fail | CLUSTERDOWN | |
| ДЦ-2 — 1 мастер (свой кластер) | 49019 | fail | CLUSTERDOWN |
| 40491 | fail | CLUSTERDOWN | |
| 48663 | fail | CLUSTERDOWN |
Кэша не стало нигде. Ни в одном из двух датацентров, ни по одному ключу.
Прежде чем разбирать почему, стоит сказать, как этот результат получен, потому
что метод здесь легко сделать неверным. Напрашивается проверить обе половины
подряд на одном кластере: остановили дальнюю, спросили ближнюю, отпустили,
остановили ближнюю, спросили дальнюю. Так делать нельзя. После первой проверки
топология не обязана вернуться к исходной: реплика могла повыситься,
configEpoch — смениться, слоты — переехать, и вторая половина мерилась бы уже
на другом кластере. Две последовательные проверки — не две стороны одного
разрыва. Поэтому каждая сторона наблюдается на своём свежем кластере с той же,
заданной руками топологией.
Разберём, почему проиграл ДЦ-2, — это как раз ожидаемо. У него один мастер из
трёх, большинства нет, и спецификация не оставляет вариантов:
Redis Cluster is not available in the minority side of the partition
(Кластер Redis недоступен на той стороне разделения, где меньшинство).
Так и должно быть: если бы меньшинство продолжало обслуживать записи, кластер
разъехался бы на два расходящихся набора данных.
Интересен ДЦ-1. У него большинство мастеров — два из трёх. Голосов на
повышение реплики ему хватает. И всё равно CLUSTERDOWN.
Причина видна на карте выше, и её стоит проследить пальцем. Мастер, оставшийся в ДЦ-2, — узел 35445. Его реплика — узел 38349 — тоже осталась в ДЦ-2. Значит, в ДЦ-1 некого повышать: слоты этого мастера не покрыты ни одним доступным узлом. Это ровно то второе требование из спецификации, которое читают по диагонали.
Дальше срабатывает настройка, из-за которой отказ становится тотальным, а не частичным:
If this is set to yes, as it is by default, the cluster stops accepting writes
if some percentage of the key space is not covered by any node. If the option is
set to no, the cluster will still serve queries even if only requests about a
subset of keys can be processed.
Если установлено yes, как по умолчанию, кластер перестаёт принимать записи, если некоторая часть пространства ключей не покрыта ни одним узлом. Если параметр установлен в no, кластер продолжит обслуживать запросы, даже если может обработать запросы лишь о части ключей.
Отсюда напрашивается вывод, что наблюдаемое поведение «шире написанного»:
документация говорит про записи, а GET тоже получил CLUSTERDOWN. Вывод
неверный. Про чтения отвечает другая настройка, и она тоже описана —
cluster-allow-reads-when-down со значением no по умолчанию. Цепочка такая: неполное покрытие слотов →
кластер помечен отказавшим → узел отказавшего кластера не обслуживает и
чтения.
Проверяется это тем же раскладом, прогнанным дважды.
| настройка | узел | роль | состояние кластера | чтение 20 ключей |
|---|---|---|---|---|
cluster-allow-reads-when-down no (по умолчанию) | 43151 | мастер | fail | 20 CLUSTERDOWN |
| 47729 | мастер | fail | 20 CLUSTERDOWN | |
| 35021 | реплика | fail | 20 CLUSTERDOWN | |
cluster-allow-reads-when-down yes | 54835 | мастер | fail | 5 отдал, 15 перенаправил |
| 46259 | мастер | fail | 9 отдал, 11 перенаправил | |
| 48093 | реплика | fail | 20 перенаправил |
SET на том же узле в обоих прогонах получил CLUSTERDOWN.
И вот что из этого следует на самом деле — это интереснее разобранной ошибки.
Со значением yes узел отвечает только по тем ключам, чьи слоты остались за
ним: из двадцати выжившая половина отдала четырнадцать, а остальные
перенаправила на мастер, который сейчас за оборванным каналом. В записи отказано
в обоих случаях.
То есть настройка не возвращает кэш. Она превращает «не работает ничего» в «работает часть чтений на части пространства ключей», и треть ключей — слоты мастера за обрывом — недоступна при любом её значении.
Правило, ради которого замер и поставлен. Большинство голосов необходимо, но недостаточно. Полное условие состоит из трёх частей, и третью обычно не называют вовсе:
большинство мастеров на своей стороне
+ доступная реплика каждого потерянного мастера
+ эта реплика достаточно свежа, чтобы ей разрешили повышаться
Третья часть — не придирка: свежесть реплики проверяется настройкой
cluster-replica-validity-factor, и реплика, отставшая сверх допустимого,
выборы не начнёт. В опытах выше она не мешала: прогон печатает, что у всех
реплик канал up ещё до разрыва. Но условие из
спецификации без третьей части неполно, и полагаться на две из трёх — значит
проверять не то.
При раскладке «поровну» условие выполняется не всегда — и, что важнее, проверить его глазами по схеме из трёх коробочек нельзя: на схеме видно число узлов, а не то, чья реплика где стоит. Кластер назначает реплики сам, при создании и при каждом последующем переключении.
Вот отсюда и берётся главный вывод. Растягивают ради симметрии — «чтобы пережили оба». Симметрия и есть то, что убивает: у ровно поделённого кластера условие выживания не выполняется ни у кого, и в самый нужный момент кэша не оказывается в обоих датацентрах сразу.
Расклад, который выживает, и что он на самом деле выбирает
Условие можно выполнить руками: оставить в ДЦ-1 не свою реплику, а реплику того мастера, который уезжает в ДЦ-2.
Карта после перестановки в прогоне bench/stretched-cache/cluster.py —
переставлена одна реплика, больше ничего:
| ДЦ-1 — 2 мастера и реплика третьего | ДЦ-2 — 1 мастер и две чужие реплики |
|---|---|
| 46819: мастер | 53509: мастер |
| 40665: мастер | 39129: реплика 46819 |
| 36267: реплика 53509 | 47199: реплика 40665 |
| ДЦ | узел | состояние кластера | что вернул GET |
|---|---|---|---|
| ДЦ-1 — 2 мастера + реплика третьего | 46819 | ok | значение |
| 40665 | ok | отдал слот другому узлу | |
| 36267 | ok | отдал слот другому узлу | |
| ДЦ-2 — 1 мастер (свой кластер) | 47073 | fail | CLUSTERDOWN |
| 40639 | fail | CLUSTERDOWN | |
| 34151 | fail | CLUSTERDOWN |
Теперь ДЦ-1 выживает: и голосов хватило, и было кого повысить. Прогон говорит
это прямо — узел 36267: реплика 53509 -> мастер: реплика, привезённая домой,
стала мастером и приняла слоты потерянного. Все три узла перешли в состояние ok.
Но симметрии не появилось. В ДЦ-2 кэша по-прежнему нет, и это не дефект расклада, а его смысл. Такой раскладкой вы выбираете, какой датацентр переживёт разрыв.
Границу этого вывода стоит очертить точно, иначе он превратится в лозунг. Утверждение не «оба датацентра не могут работать», а вот такое: в одном обычном кластере Redis нельзя получить симметричный разрыв, при котором обе половины независимо продолжают принимать записи, а потом автоматически сливаются обратно в один кластер. Причина не в раскладке, а в том, что кластер один и решение он принимает одно.
Другие исходы возможны — просто это уже не «один растянутый кластер»: два
независимых кэша, работа только на чтение в урезанном виде, продукт с
active-active и явной моделью разрешения конфликтов
(Conflict resolution is handled by conflict-free replicated data types (CRDTs)
(Разрешение конфликтов выполняется бесконфликтными реплицируемыми типами данных (CRDT))),
разрешение конфликтов на стороне приложения. У каждого своя цена, и ни один из
них эта статья не измеряла.
Заодно видно, насколько хрупка эта конструкция. Между двумя прогонами — одна перестановка реплики. Она нигде не записана как требование, не проверяется при старте и не переживает автоматическое переключение: после первого же отказа мастера роли перераспределятся, и «правильный» расклад молча станет тем, что отказал в обеих половинах.
То же самое на текущих сборках
Все числа выше сняты на Redis 7.0.15. Ветка старая: к сентябрю 2026 актуальная линия Redis Open Source — 8.10.x, а Valkey ушёл в 9.1. Кластерная часть между Redis 7 и 8 менялась заметно, и «совпало однажды» не делает свойство неизменным. Поэтому те же утверждения прогнаны на всех трёх сборках — не производительность, а именно утверждения.
| Redis 7.0.15 | Redis 8.10.1 | Valkey 9.1.2 | |
|---|---|---|---|
| версия | 7.0.15 | 8.10.1 | 9.1.2 |
| обычная запись, мс | 0,17 | 0,18 | 0,18 |
| мастер ждёт реплику | нет | нет | нет |
| видна на реплике через, мс | 21,4 | 21,9 | 21,1 |
WAIT 1, мс | 41,46 | 41,48 | 41,29 |
| WAIT / круг | 1,04 | 1,04 | 1,03 |
| потеряно из 200 при разрыве | 72 | 106 | 116 |
| расклад «поровну»: состояние половины с большинством | fail | fail | fail |
расклад «поровну»: ответов на GET | нет | нет | нет |
reads-when-down no: отдано ключей из 60 | 0 | 0 | 0 |
reads-when-down yes: отдано ключей из 60 | 15 | 15 | 15 |
Ни одно утверждение статьи не разошлось: мастер не ждёт реплику нигде, WAIT
везде стоит круг, половина с большинством мастеров везде отказывает целиком, и
судьбу чтений везде решает cluster-allow-reads-when-down. Заодно это ответ и
про Valkey: поведение форка здесь не выведено из его документации, а измерено.
Чего эта таблица не делает. Она не переносит числа основного прогона на новые версии и не заменяет их. Опубликованная основа остаётся на 7.0.15; таблица отвечает на другой вопрос — не разъехалось ли поведение.
Что делать вместо этого
Без этого раздела весь разбор бесполезен, поэтому по пунктам. Первый ответ — хороший вариант по умолчанию, но не единственный: в конце раздела перечислены случаи, где выбирают иначе.
Кэш в каждом ДЦ свой, независимый. Не реплика, не половина кластера — отдельная установка, которую наполняет своё приложение из своего источника. Разрыв канала тогда не событие для кэша: обе площадки продолжают работать, каждая со своими данными.
Оговорка к этому важна, и без неё получается лозунг. Независимые региональные кэши убирают WAN-зависимость из пути к кэшу, но не делают приложение целиком независимым от канала между ДЦ. Если запись всё равно идёт в мастер базы в другом датацентре, разрыв остаётся событием для приложения — просто перестаёт быть событием для кэша. Разносить эти две доступности надо явно, иначе «независимый кэш» превращается в обещание, которого он не давал.
Цена этого — лишние промахи. Первый запрос на ключ промахнётся в ДЦ-1 и сходит в источник, первый такой же запрос в ДЦ-2 промахнётся ещё раз. Но формулировать это надо аккуратно: в идеальном установившемся режиме, без вытеснения и перезапусков, минимальная дополнительная цена — отдельное наполнение каждого горячего ключа в каждом регионе. Это нижняя граница, а не оценка.
Реальную нагрузку на источник задаёт не арифметика ключей, а вытеснение, перезапуски, переключения, перешардирование, выкладки, смена версии ключа, инвалидация, холодный старт и переброс трафика между ДЦ. Каждое из этих событий наполняет кэш заново, и теперь — в каждом датацентре отдельно. Отсюда и то, чего у одного общего кэша не было: два независимых домена лавины, две разные доли попаданий и два набора графиков, за которыми надо следить.
Расхождение между ДЦ допустимо, и им управляют — но не одним только TTL. Два независимых кэша неизбежно разойдутся, и вопрос не «как этого избежать», а «на сколько секунд можно разойтись». TTL — самая прямая ручка в этом вопросе, и поэтому её легко принять за весь ответ — «TTL и есть бюджет рассогласования, названный в секундах». Это неверно, и вот из чего складывается наблюдаемое расхождение на самом деле:
- отставание самого источника. Если в каждом ДЦ своя читающая реплика базы, её отставание ложится под расхождение кэшей, а не вместо него;
- задержка инвалидации — сколько идёт до второго ДЦ событие «ключ изменился», если оно вообще идёт;
- политика свежести — собственно TTL, а с ним и его родня: обновление заранее, скользящий срок, отдача устаревшего значения на время пересчёта;
- последствия отказов и восстановления — после переключения или переброса трафика кэш в одном ДЦ холодный, а в другом прогретый.
TTL — одно слагаемое из четырёх. Называть бюджетом надо сумму, и считать её надо по своей схеме, а не по этой статье.
Синхронная инвалидация между датацентрами — дорогая сделка, и решать её надо не TTL. Соблазн есть: удалили ключ здесь — удалите и там, дождитесь подтверждения. Это возвращает круг, от которого мы только что ушли, и возвращает его в горячий путь записи. Для одноразового кэша сделка почти всегда плохая: локальность и была тем, ради чего кэш разносили по регионам.
Но если ждать всё-таки приходится, вывод «значит, TTL выбран неправильно» неверен, и неверен он не по мелочи, а по существу. Требование «подтверждаем запись только после того, как её увидят в другом регионе» — это требование к модели согласованности кэш-слоя, а не к сроку жизни ключа. Одним TTL оно не решается: нужна либо другая модель (версионирование, маршрутизация читателя к тому же региону, ограниченная устарелость с явной границей), либо кэш надо убрать с пути корректности. TTL управляет тем, на сколько можно разойтись, а не тем, можно ли разойтись вообще.
И WAIT не бессмыслен для кэша — он просто редко окупается. Категоричное
«для кэша WAIT бессмыслен» — ошибка того же рода, что и предыдущие две. Для
дёшево восстанавливаемого кэша платить круг на каждую запись действительно трудно
оправдать: потеря записи предусмотрена конструкцией. Но кэш кэшу рознь. Если
прогрев стоит часов, если источник не переживёт лавины холодного старта, если
за ним внешний API с лимитом или тяжёлый пересчёт, — надёжность репликации
приобретает цену, и сравнивать её надо не с нулём, а со стоимостью повторного
наполнения и с допустимым окном потерь. Промышленный разбор той же ловушки
говорит о ней без обиняков:
a cache can be a big and highly damaging blast radius
(кэш может быть большим и крайне разрушительным радиусом поражения).
Заметьте, что и сама эта статья ниже допускает удалённую реплику ради тёплого
кэша: это тот же аргумент, просто про другой инструмент.
Источник истины — не обязательно база, но он обязан быть. Ходовая формулировка — «всё, что нельзя пересчитать из базы, в кэше лежать не должно» — для системного разбора слишком узка. Правило шире: одноразовый кэш должен быть восстановим из авторитетного источника или из повторяемого вычисления. Авторитетным источником бывает база, объектное хранилище, журнал событий, внешний API, другая служба или канонический результат расчёта. Что именно — не важно; важно, что он есть и что путь восстановления известен.
Общий кэш остаётся кэшем, и «общий» — не улика. Ходовое правило звучит так: «если кэш обязан быть общим, значит, в нём лежит не кэш». Это не так. Общий внешний кэш, в который ходят несколько приложений, — нормальная и распространённая конструкция: он снимает нагрузку с источника лучше, чем набор процессных кэшей, избавляет от расхождения между узлами приложения и переживает выкладку приложения, не начиная с холодного.
Красный флаг не в слове «общий», а в невосстановимости. Кэш перестаёт быть кэшем, когда потеря записи превращается не в промах, а в потерю состояния: единственная копия сессии, блокировка, счётчик, состояние процесса, данные, которых больше нигде нет. Проверка на это простая и не требует спора о терминах: удалите кэш целиком и посмотрите, что произошло. Стало медленнее, данные восстановились — это кэш, и растягивать его через ДЦ незачем. Потерялись деньги, сессии, блокировки — это не кэш, а хранилище состояния, и разговор о нём другой; ему нужна отдельная статья, и она будет.
Шесть раскладов рядом, и чего они стоят
Всё, что выше, — разбор трёх способов растянуть кэш и одного способа не растягивать. Способов больше, и держать их в голове списком неудобно — поэтому вот они рядом. Таблица не замер: в ней нет ни одного числа, и быть не может, потому что три из шести строк здесь никто не мерил. Это карта, по которой выбирают, что мерить дальше.
| Расклад | Задержка у себя | При разрыве между ДЦ | RPO содержимого кэша | RTO: когда снова тепло |
|---|---|---|---|---|
| Один кэш на два ДЦ, ходят оба | у дальней стороны плохая (замерено) | дальняя сторона теряет кэш | неприменимо: копия одна | сразу, если ближняя жива |
| Кластер Redis на два ДЦ | смешанная | зависит от кворума и покрытия слотов (замерено) | отставание реплики шарда | после выборов, если было кого повышать |
| Мастер здесь, реплика в чужом ДЦ | хорошая на стороне мастера | переключение вручную плюс окно потерь (замерено) | отставание репликации в момент разрыва | время ручного переключения |
| Свой кэш в каждом ДЦ | хорошая | кэш переживает разрыв | потеря одноразовая по устройству | прогрев своего региона |
| Кэш в процессе плюс региональный | лучшая | изоляция по регионам | два слоя, оба одноразовые | прогрев двух слоёв |
| Active-active с разрешением конфликтов | хорошая | на это и рассчитан | зависит от продукта и модели конфликтов | локальная доступность не теряется |
Читать эту таблицу надо не как рейтинг, а как список вопросов к своей схеме. Порядок вопросов такой: что именно кэшируется и можно ли это потерять; во сколько обходится та операция источника, которую кэш заменяет; какое расстояние до источника; какое расхождение допустимо; что должно пережить разрыв; сколько времени кэш греется заново. Ответы на них и выбирают строку — а не то, как конструкция выглядит на схеме.
Когда растянутый кэш всё-таки допустим
Статья написана против растянутого кэша, но не против всех его случаев. Есть четыре, где растянутая конфигурация защитима, — и все четыре опираются на те же замеры.
Когда разрыв должен пережить один назначенный датацентр, а не оба. Расклад из последнего прогона работает: ДЦ-1 ответил всеми тремя узлами. Если у вас есть основной ДЦ и резервный, а не два равноправных, то «кластер выживает в основном» — законная цель. Условий три: вы должны понимать, во-первых, что выбираете победителя заранее; во-вторых, что в резервном ДЦ кэша не будет; в-третьих, что расклад реплик надо проверять после каждого переключения, потому что кластер расставляет их сам.
Когда реплика в чужом ДЦ нужна не для работы, а чтобы не наполнять кэш с
нуля. Большой прогретый кэш — это часы работы и заметная нагрузка на источник
при холодном старте; именно об этом предупреждает тот же промышленный разбор:
служба is likely to be unable to handle the resulting load on its dependencies
(вероятно, не справится с возникшей нагрузкой на свои зависимости).
Реплика, которую поднимают руками после аварии, эту нагрузку снимает. Условия
честные, и оба измерены: реплики сами не повышаются (три узла в
состоянии fail, ноль мастеров), а переключение теряет то, что не доехало:
под угрозой отставание в момент разрыва. Для кэша второе
допустимо: потерянные записи пересчитаются при промахе. Именно поэтому — только
для кэша, и только если вы согласны переключать руками.
Когда поток записи редкий. Вторая строка замера с переключением — не оговорка, а отдельный результат: через секунду тишины на реплике оказались все двести записей, не потеряно ни одной. Замер смещений показывает то же самое ещё раз и точнее: опасен не объём данных, а отставание, которое темп записи успевает создать. Если реплика в среднем догоняет, терять при переключении почти нечего — и проверить это можно, не гадая, по разнице смещений.
Когда операция источника дороже круга. Замер задержки сравнивает кэш в чужом ДЦ с самым дешёвым, что умеет база: чтением по первичному ключу из прогретой таблицы. Для агрегата, который считается сто миллисекунд, сравнение другое, и кэш за сорок миллисекунд его выигрывает. Правило из замера формулируется без чисел и работает в обе стороны: поход в кэш обязан быть дешевле той операции источника, которую он заменяет, на этом расстоянии. Числа для дорогих операций здесь не снимались, и приводить их было бы нечестно; но и запрещать такой кэш этот замер не даёт права.
Общее у всех четырёх — то, чего в них нет. Ни в одном не обещано, что обе половины одного кластера независимо продолжат принимать записи. Как только в требовании появляется это слово — «оба», — из перечисленного не годится ничего, и выбирать надо из другой строки таблицы выше: свой кэш в каждом ДЦ, слоистый кэш или продукт с active-active и явной моделью конфликтов.
Как воспроизвести числа
Восемь скриптов, все на живом Redis, все печатают то, что вернули процессы.
python3 bench/stretched-cache/replication.py # блоки 1-3
python3 bench/stretched-cache/cluster.py # блоки 4-6
python3 bench/stretched-cache/latency.py # блок 7
python3 bench/stretched-cache/readsdown.py # блок 8
python3 bench/stretched-cache/offsets.py # блок 9
python3 bench/stretched-cache/waitcost.py # блок 10
python3 bench/stretched-cache/relaycheck.py # блоки 11-14
python3 bench/stretched-cache/versions.py # блок 15
Нужен redis-server в PATH, для кластера ещё и redis-cli, для замера
задержки — работающий PostgreSQL (адрес в DE_TEST_DATABASE_URL). Другую
сборку можно подсунуть через REDIS_SERVER_BIN и REDIS_CLI_BIN — так снята
таблица версий. Ничего после себя скрипты не оставляют: узлы поднимаются во временных
каталогах и убиваются в finally.
Ограничения стенда, названные прямо
Задержка создаётся ретранслятором в пользовательском коде, а не netem.
Ретранслятор добавляет задержку каждой порции, которую вернул recv(), а TCP
границ сообщений не хранит. Это воспроизводимое приближение задержки, а не
эмулятор сети на уровне пакетов: у него нет ни очередей, ни потерь, ни
джиттера, ни ограничения полосы. Простые измерения вида «запрос — ответ» можно
читать напрямую — калибровка показывает, что там ретранслятор даёт ровно круг.
Потоковую репликацию и WAIT желательно подтверждать отдельно через netem
или сетевые пространства имён; в среде, где снималась эта статья, ядро собрано
без CONFIG_NET_SCH_NETEM, и сделать это было нечем. Вместо сверки измерен сам
прибор: цена флага TCP_NODELAY и цена сериализации порций сняты отдельно, а
остальные замеры с каналом сняты на конвейерном ретрансляторе с выставленным
флагом.
Разрыв в кластерном замере изображается сигналом STOP. Процесс жив, порт
открыт, но узел не отвечает и не шлёт heartbeat: для оставшейся половины он
неотличим от узла за оборванным каналом. Убить их было нельзя — вторую половину
надо было опросить тоже. Каждая сторона наблюдается на своём свежем
кластере с заданной руками топологией, поэтому последствия первой проверки не
достаются второй; настоящее разделение сети через файрвол или сетевые
пространства имён было бы ещё точнее.
У ретранслятора есть рубильник cut(), и он не декоративный: разрыв между
датацентрами — это не «стало медленно», а «перестало ходить вовсе», и
изображать его увеличенной задержкой нельзя.
Граница версий
Основные свойства сверены с актуальной документацией Redis и Valkey. Числа
основного прогона получены на Redis 7.0.15 и сохранены как воспроизводимая
основа. Redis 8.x заметно развил кластерную часть, поэтому ключевые
утверждения — репликация, WAIT, расклад «поровну», cluster-allow-reads-when-down —
дополнительно прогнаны на Redis 8.10.1 и Valkey 9.1.2 и не
считаются неизменными только потому, что однажды совпали.
Опубликованный прогон: Redis 7.0.15, PostgreSQL 16.13, один хост, канал 20 мс в одну сторону — кроме замеров, где расстояние и есть переменная опыта. Двадцать миллисекунд выбраны не «чтобы было хуже», а как обычное расстояние между датацентрами в пределах одной страны.
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и как это читать.
bench/stretched-cache/replication.pybench/stretched-cache/cluster.pybench/stretched-cache/latency.pybench/stretched-cache/readsdown.pybench/stretched-cache/offsets.pybench/stretched-cache/waitcost.pybench/stretched-cache/relaycheck.pybench/stretched-cache/versions.pybench/stretched-cache/link.py
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Довод звучит разумно: «база у нас растянута на два датацентра — растянем и кэш». Аналогия ложная, и ломается она в трёх разных местах сразу.
- Кэш держится сравнительным свойством: поход в него должен обходиться дешевле той операции источника, которую он заменяет. На дешёвом чтении по ключу за каналом между ДЦ этого не остаётся: попадание в кэш в чужом ДЦ стоит 41,09 мс, чтение той же строки из базы под рукой — 0,09 мс.
- Мастер не ждёт реплику. Разрыв сразу после залпа записей потерял 93 записи из 200; клиенту на все двести пришёл
OK. Это не сбой, а объявленный договор асинхронной репликации. WAITпокупает не то, за что его принимают: запись дорожает в 171,6 раза, а отменить её нечем — к моменту ответа она уже применена. Цена — ровно круг до реплики, и это проверено на трёх расстояниях.- Окно потерь задаёт отставание, а не круг. На одном и том же канале разный темп записи даёт 104, 3 и 0 потерянных записей; смотреть надо на разницу смещений в
INFO replication. - Главное. Кластер из шести узлов, разложенный по датацентрам поровну, при разрыве отказал в обеих половинах — включая ту, у которой было большинство мастеров. Каждая половина проверена на своём свежем кластере. Большинство голосов необходимо, но недостаточно.
- Проверен и сам измерительный стенд. Ретранслятор, изображающий канал, без
TCP_NODELAYсам добавляет к записи сWAIT43,30 мс — даже при нулевой задержке. Такой прибор показывал бы сорок миллисекунд собственного залипания как свойство Redis. - Те же утверждения проверены на Redis 8.10.1 и Valkey 9.1.2. Ни одно не разошлось.
- Что делать. Умолчание — свой кэш в каждом ДЦ, наполняется своим приложением. Расхождение между ДЦ складывается из отставания источника, задержки инвалидации, политики свежести и последствий отказов; TTL — одно слагаемое из четырёх.
На самом деле
- Аналогия ломается в трёх местах сразу. Задачи разные: база — источник истины, ради которого не жалко платить круг между ДЦ, а кэш нужен затем, чтобы повторное получение данных обходилось дешевле обращения к источнику. «Дешевле» — это и задержка, и снятая нагрузка, и несделанный пересчёт, и лимит внешнего вызова; свойство в любом случае сравнительное. Замерена одна сторона — задержка — и на самом невыгодном для кэша случае: 41,09 мс в чужом ДЦ против 0,09 мс до базы под рукой. Цена потери разная: потеря строки базы — авария, потеря записи кэша — предусмотренный промах. И само слово «растянуть» означает разное: асинхронная горячая реплика Postgres — отдельная отстающая система, а кластер Redis на два ДЦ — одна система, принимающая решения большинством голосов. Сравнивать надо конкретную модель репликации с конкретной: у Postgres есть и синхронная репликация, бывают и базы с кворумом.
- Замерено обратное: при разрыве канала отказали обе половины. Шесть узлов, по три в каждом ДЦ, мастера поделены 2 и 1 — и все шесть узлов ответили
CLUSTERDOWN. Каждая половина проверена на своём свежем кластере с той же топологией, а не двумя проверками подряд на одном: последствия первой проверки не могли достаться второй. У ДЦ-1 было большинство мастеров и голосов на повышение хватало, но реплика мастера, оставшегося в ДЦ-2, тоже осталась в ДЦ-2 — повышать было некого. Спецификация называет два требования, а не одно: the majority of the master nodes are reachable and there is at least one reachable replica for every master node that is no longer reachable. Большинство необходимо, но недостаточно — и полное условие состоит даже не из двух частей, а из трёх: третья в том, что реплика должна быть достаточно свежей, иначеcluster-replica-validity-factorне даст ей начать выборы. - Не поднимутся. В раскладе «все мастера в ДЦ-1, все реплики в ДЦ-2» после разрыва в ДЦ-2 оказалось ноль мастеров из трёх узлов, состояние всех трёх —
fail, наGETвсе три вернулиCLUSTERDOWN. Для повышения реплике нужен голос большинства мастеров — Once the replica receives ACKs from the majority of masters, it wins the election, — а все мастера остались по ту сторону обрыва. Вторая половина не запасная: если первая пропадёт, вторая её не заменит — по крайней мере, сама. - Нельзя: на схеме видно число узлов в каждом ДЦ, а не то, чья реплика где стоит. Между раскладом, где отказали обе половины, и раскладом, где ДЦ-1 выжил, разница — одна перестановка реплики. Она нигде не записана как требование, не проверяется при старте и не переживает автоматическое переключение: после первого же отказа мастера кластер расставит роли сам, и «правильный» расклад молча станет тем, при котором отказали обе половины.
- Замерено: обычная запись 0,24 мс, запись с
WAIT 1— 41,73 мс, дороже в 171,6 раза. Цена — ровно один круг до реплики: на расстояниях 5, 20 и 40 мс в одну сторону она равна 10,90, 41,52 и 81,40 мс, а отношение к кругу — 1,09, 1,04 и 1,02. Купленное же свойство слабее ожидаемого: к моменту ответа запись на мастере уже применена и уже видна читателям, откатить её нечем. Документация говорит это прямо: it does not turn a set of Redis instances into a CP system with strong consistency: acknowledged writes can still be lost during a failover. Для дёшево восстанавливаемого кэша сделка обычно не окупается: он платит круг на каждую запись за данные, потеря которых предусмотрена промахом. Но и «бессмысленной» её не назовёшь: если прогрев кэша стоит часов или источник не переживёт лавины холодного старта, надёжность репликации имеет цену, и сравнивать её надо со стоимостью повторного наполнения и допустимым окном потерь. - Отказ тотальный: в замере
GETвернулCLUSTERDOWNна каждом узле выжившей половины, а не только по слотам потерянного мастера. Отсюда напрашивается вывод, что наблюдаемое поведение «шире написанного». Вывод неверен:cluster-require-full-coverageотвечает за записи (If this is set to yes, as it is by default, the cluster stops accepting writes if some percentage of the key space is not covered by any node), а за чтения отвечает отдельная настройкаcluster-allow-reads-when-downсо значениемnoпо умолчанию, и она тоже описана. Цепочка такая: неполное покрытие слотов → кластер помечен отказавшим → узел отказавшего кластера не обслуживает и чтения. Замерено на обеих настройках: сnoвсе 20 ключей из 20 получаютCLUSTERDOWNна каждом узле. - Не переживёт. Со значением
yesузел отвечает только по тем ключам, чьи слоты остались за ним: в замере из двадцати ключей выжившая половина отдала четырнадцать (5 на одном мастере, 9 на другом), остальные перенаправила на мастер, который сейчас за оборванным каналом, — и туда клиент не доедет. Реплика в той же половине перенаправила все двадцать. В записи отказано при обоих значениях настройки. То естьyesпревращает «не работает ничего» в «работает часть чтений на части пространства ключей», а треть пространства — слоты мастера за обрывом — недоступна при любом значении. - Простит, пока реплика остаётся репликой. Отставание становится потерей ровно в тот момент, ради которого реплику и держат, — при переключении. Замерено: разрыв сразу после залпа из двухсот записей потерял 93 из них (46,5%), и ни на одну клиент не получил ошибки — на все пришёл
OK. Разрыв через секунду тишины не потерял ничего. Ходовое правило «под угрозой всё, что записано за последний круг» неверно: круг совпал с потерей случайно. На одном и том же канале три темпа записи дают 104, 3 и 0 потерянных записей при отставании 3548, 99 и 0 байт. Под угрозой то, что в момент разрыва лежит междуmaster_repl_offsetмастера и смещением реплики; оба числа Redis отдаёт вINFO replication. Круг задаёт лишь нижнюю границу того, за какое время отставание может сойти к нулю. - Приложение в двух ДЦ — довод за два кэша, а не за один общий: половина обращений уйдёт в чужой ДЦ и заплатит круг. Но «общий кэш» сам по себе не улика, и ходовое правило «если кэш обязан быть общим, значит, в нём лежит не кэш» неверно. Общий внешний кэш, в который ходят несколько приложений, — нормальная конструкция: он лучше снимает нагрузку с источника, чем набор процессных кэшей, избавляет от расхождения между узлами приложения и переживает выкладку. Красный флаг не «общий», а невосстановимость. Проверка простая: удалите кэш целиком и посмотрите. Стало медленнее, данные восстановились — это кэш, и растягивать его через ДЦ незачем. Потерялись сессии, блокировки, счётчики — это хранилище состояния, и разговор о нём другой.
- Не обязательно, и прибор надо проверять отдельно от предмета. Канал между ДЦ в этой статье изображает ретранслятор в пользовательском коде, и если на двух его соединениях не выставлен
TCP_NODELAY, пара «алгоритм Нейгла — отложенное подтверждение» добавляет к каждой операции сорок с лишним миллисекунд — постоянное слагаемое сверх круга, одинаковое на всех расстояниях, которое легко принять за свойство Redis. Проверяется это опытом без расстояния вовсе: через канал с нулевой задержкой запись сWAIT 1стоит 44,00 мс без флага и 0,70 мс с ним. Отдельно поучительно, что дефектов у такого стенда два и они маскируют друг друга: сериализация потока в ретрансляторе добавляет ещё один перелёт, и увидеть её можно, только убрав залипание, которое больше её самой.
Что разобрано
- Три способа растянуть кэш
- Аналогия, из которой всё растёт
- Оговорка: на чём снято и что из этого переносится
- Различие первое: у кэша и базы разные задачи
- Различие второе: потеря строки и потеря записи стоят разного
- Проверка прибора: чем на самом деле был канал между ДЦ
- Различие третье: «растянуть» означает разное
- Кульминация: отказали обе половины
- Расклад, который выживает, и что он на самом деле выбирает
- То же самое на текущих сборках
- Что делать вместо этого
- Шесть раскладов рядом, и чего они стоят
- Когда растянутый кэш всё-таки допустим
- Как воспроизвести числа
- Чем измерено
Унести в работу
- Не переносить довод «база растянута и работает» на кэш: кэш держится сравнительным свойством, и попадание в чужом ДЦ стоило 41,09 мс против 0,09 мс у чтения из базы под рукой.
- Не считать асинхронную репликацию страховкой записи: разрыв сразу после залпа потерял 93 записи из 200, и клиенту на все двести пришёл
OK. - Не закрывать эту дыру через
WAIT: запись дорожает в 171,6 раза, а отменить её нечем — к моменту ответа она уже применена. - Смотреть на разницу смещений в
INFO replication, а не на круг: на одном и том же канале разный темп записи дал 104, 3 и 0 потерянных записей. - Не раскладывать кластер поровну на два ДЦ: при разрыве отказали обе половины, включая ту, где было большинство мастеров. Большинство голосов необходимо, но недостаточно.
Расхожие заблуждения
База у нас растянута на два ДЦ и работает — значит, и кэш можно
Аналогия ломается в трёх местах сразу. Задачи разные: база — источник истины, ради которого не жалко платить круг между ДЦ, а кэш нужен затем, чтобы повторное получение данных обходилось дешевле обращения к источнику. «Дешевле» — это и задержка, и снятая нагрузка, и несделанный пересчёт, и лимит внешнего вызова; свойство в любом случае сравнительное. Замерена одна сторона — задержка — и на самом невыгодном для кэша случае: 41,09 мс в чужом ДЦ против 0,09 мс до базы под рукой. Цена потери разная: потеря строки базы — авария, потеря записи кэша — предусмотренный промах. И само слово «растянуть» означает разное: асинхронная горячая реплика Postgres — отдельная отстающая система, а кластер Redis на два ДЦ — одна система, принимающая решения большинством голосов. Сравнивать надо конкретную модель репликации с конкретной: у Postgres есть и синхронная репликация, бывают и базы с кворумом.
Разложим узлы поровну между датацентрами — переживём потерю любого из них
Замерено обратное: при разрыве канала отказали обе половины. Шесть узлов, по три в каждом ДЦ, мастера поделены 2 и 1 — и все шесть узлов ответили CLUSTERDOWN. Каждая половина проверена на своём свежем кластере с той же топологией, а не двумя проверками подряд на одном: последствия первой проверки не могли достаться второй. У ДЦ-1 было большинство мастеров и голосов на повышение хватало, но реплика мастера, оставшегося в ДЦ-2, тоже осталась в ДЦ-2 — повышать было некого. Спецификация называет два требования, а не одно: the majority of the master nodes are reachable and there is at least one reachable replica for every master node that is no longer reachable
(доступно большинство мастер-узлов и для каждого недоступного мастер-узла доступна хотя бы одна реплика). Большинство необходимо, но недостаточно — и полное условие состоит даже не из двух частей, а из трёх: третья в том, что реплика должна быть достаточно свежей, иначе cluster-replica-validity-factor не даст ей начать выборы.
Реплики во втором ДЦ — это запасной кластер: если первый ДЦ упадёт, они поднимутся
Не поднимутся. В раскладе «все мастера в ДЦ-1, все реплики в ДЦ-2» после разрыва в ДЦ-2 оказалось ноль мастеров из трёх узлов, состояние всех трёх — fail, на GET все три вернули CLUSTERDOWN. Для повышения реплике нужен голос большинства мастеров — Once the replica receives ACKs from the majority of masters, it wins the election
(Как только реплика получает подтверждения от большинства мастеров, она выигрывает выборы), — а все мастера остались по ту сторону обрыва. Вторая половина не запасная: если первая пропадёт, вторая её не заменит — по крайней мере, сама.
Правильный расклад реплик можно проверить глазами по схеме
Нельзя: на схеме видно число узлов в каждом ДЦ, а не то, чья реплика где стоит. Между раскладом, где отказали обе половины, и раскладом, где ДЦ-1 выжил, разница — одна перестановка реплики. Она нигде не записана как требование, не проверяется при старте и не переживает автоматическое переключение: после первого же отказа мастера кластер расставит роли сам, и «правильный» расклад молча станет тем, при котором отказали обе половины.
WAIT делает репликацию синхронной и закрывает вопрос потерь
Замерено: обычная запись 0,24 мс, запись с WAIT 1 — 41,73 мс, дороже в 171,6 раза. Цена — ровно один круг до реплики: на расстояниях 5, 20 и 40 мс в одну сторону она равна 10,90, 41,52 и 81,40 мс, а отношение к кругу — 1,09, 1,04 и 1,02. Купленное же свойство слабее ожидаемого: к моменту ответа запись на мастере уже применена и уже видна читателям, откатить её нечем. Документация говорит это прямо: it does not turn a set of Redis instances into a CP system with strong consistency: acknowledged writes can still be lost during a failover
(он не превращает набор экземпляров Redis в CP-систему со строгой согласованностью: подтверждённые записи всё ещё могут быть потеряны при переключении). Для дёшево восстанавливаемого кэша сделка обычно не окупается: он платит круг на каждую запись за данные, потеря которых предусмотрена промахом. Но и «бессмысленной» её не назовёшь: если прогрев кэша стоит часов или источник не переживёт лавины холодного старта, надёжность репликации имеет цену, и сравнивать её надо со стоимостью повторного наполнения и допустимым окном потерь.
При неполном покрытии слотов перестанут работать только недостающие ключи
Отказ тотальный: в замере GET вернул CLUSTERDOWN на каждом узле выжившей половины, а не только по слотам потерянного мастера. Отсюда напрашивается вывод, что наблюдаемое поведение «шире написанного». Вывод неверен: cluster-require-full-coverage отвечает за записи (If this is set to yes, as it is by default, the cluster stops accepting writes if some percentage of the key space is not covered by any node
(если установлено yes, как по умолчанию, кластер перестаёт принимать записи, если некоторая часть пространства ключей не покрыта ни одним узлом)), а за чтения отвечает отдельная настройка cluster-allow-reads-when-down со значением no по умолчанию, и она тоже описана. Цепочка такая: неполное покрытие слотов → кластер помечен отказавшим → узел отказавшего кластера не обслуживает и чтения. Замерено на обеих настройках: с no все 20 ключей из 20 получают CLUSTERDOWN на каждом узле.
Поставлю cluster-allow-reads-when-down yes — и кэш переживёт разрыв
Не переживёт. Со значением yes узел отвечает только по тем ключам, чьи слоты остались за ним: в замере из двадцати ключей выжившая половина отдала четырнадцать (5 на одном мастере, 9 на другом), остальные перенаправила на мастер, который сейчас за оборванным каналом, — и туда клиент не доедет. Реплика в той же половине перенаправила все двадцать. В записи отказано при обоих значениях настройки. То есть yes превращает «не работает ничего» в «работает часть чтений на части пространства ключей», а треть пространства — слоты мастера за обрывом — недоступна при любом значении.
Отставание реплики в 20 мс — мелочь, кэш столько простит
Простит, пока реплика остаётся репликой. Отставание становится потерей ровно в тот момент, ради которого реплику и держат, — при переключении. Замерено: разрыв сразу после залпа из двухсот записей потерял 93 из них (46,5%), и ни на одну клиент не получил ошибки — на все пришёл OK. Разрыв через секунду тишины не потерял ничего. Ходовое правило «под угрозой всё, что записано за последний круг» неверно: круг совпал с потерей случайно. На одном и том же канале три темпа записи дают 104, 3 и 0 потерянных записей при отставании 3548, 99 и 0 байт. Под угрозой то, что в момент разрыва лежит между master_repl_offset мастера и смещением реплики; оба числа Redis отдаёт в INFO replication. Круг задаёт лишь нижнюю границу того, за какое время отставание может сойти к нулю.
Общий кэш на два ДЦ нужен, потому что приложение работает в обоих
Приложение в двух ДЦ — довод за два кэша, а не за один общий: половина обращений уйдёт в чужой ДЦ и заплатит круг. Но «общий кэш» сам по себе не улика, и ходовое правило «если кэш обязан быть общим, значит, в нём лежит не кэш» неверно. Общий внешний кэш, в который ходят несколько приложений, — нормальная конструкция: он лучше снимает нагрузку с источника, чем набор процессных кэшей, избавляет от расхождения между узлами приложения и переживает выкладку. Красный флаг не «общий», а невосстановимость. Проверка простая: удалите кэш целиком и посмотрите. Стало медленнее, данные восстановились — это кэш, и растягивать его через ДЦ незачем. Потерялись сессии, блокировки, счётчики — это хранилище состояния, и разговор о нём другой.
Если число измерено, оно измеряет то, что мы хотели измерить
Не обязательно, и прибор надо проверять отдельно от предмета. Канал между ДЦ в этой статье изображает ретранслятор в пользовательском коде, и если на двух его соединениях не выставлен TCP_NODELAY, пара «алгоритм Нейгла — отложенное подтверждение» добавляет к каждой операции сорок с лишним миллисекунд — постоянное слагаемое сверх круга, одинаковое на всех расстояниях, которое легко принять за свойство Redis. Проверяется это опытом без расстояния вовсе: через канал с нулевой задержкой запись с WAIT 1 стоит 44,00 мс без флага и 0,70 мс с ним. Отдельно поучительно, что дефектов у такого стенда два и они маскируют друг друга: сериализация потока в ретрансляторе добавляет ещё один перелёт, и увидеть её можно, только убрав залипание, которое больше её самой.
Проверьте себя
Кластер из шести узлов разложен поровну: в ДЦ-1 два мастера и одна реплика, в ДЦ-2 один мастер и две реплики. Канал между ДЦ оборван. Что произойдёт с ДЦ-1, у которого большинство мастеров?
Источники и что читать дальше
10 ИСТОЧНИКОВ
- Redis — Redis replicationОфициальная документация. Договор репликации, названный прямым текстом: «Redis uses by default asynchronous replication, which being low latency and high performance, is the natural replication mode for the vast majority of Redis use cases» (Redis по умолчанию использует асинхронную репликацию, которая, будучи низколатентной и высокопроизводительной, является естественным режимом репликации для подавляющего большинства сценариев использования Redis). Оттуда же — про то, что мастер не ждёт: «So the master does not wait every time for a command to be processed by the replicas, however it knows, if needed, what replica already processed what command» (Итак, мастер не ждёт каждый раз, пока команда будет обработана репликами, однако он знает, если это потребуется, какая реплика какую команду уже обработала). И оговорка про WAIT, ради которой страница цитируется в статье третий раз: «However WAIT is only able to ensure there are the specified number of acknowledged copies in the other Redis instances, it does not turn a set of Redis instances into a CP system with strong consistency: acknowledged writes can still be lost during a failover, depending on the exact configuration of the Redis persistence» (Однако WAIT способен лишь обеспечить наличие указанного числа подтверждённых копий на других экземплярах Redis; он не превращает набор экземпляров Redis в CP-систему со строгой согласованностью: подтверждённые записи всё ещё могут быть потеряны при переключении, в зависимости от конкретной настройки постоянного хранения Redis).https://redis.io/docs/latest/operate/oss_and_stack/management/replication/
- Redis — Redis cluster specificationОфициальная документация. Условие выживания кластера при разрыве, в котором два требования, а не одно: «Redis Cluster is able to survive partitions where the majority of the master nodes are reachable and there is at least one reachable replica for every master node that is no longer reachable» (Кластер Redis способен пережить разделения сети, при которых доступно большинство мастер-узлов и для каждого недоступного мастер-узла доступна хотя бы одна реплика). Второе требование в статье и оказывается тем, о котором забывают. Оттуда же — приговор проигравшей половине: «Redis Cluster is not available in the minority side of the partition» (Кластер Redis недоступен на той стороне разделения, где меньшинство). И механика выборов, объясняющая, почему у ровного деления голосов не хватает никому: «Once the replica receives ACKs from the majority of masters, it wins the election» (Как только реплика получает подтверждения от большинства мастеров, она выигрывает выборы).https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/
- Redis — Scale with Redis ClusterОфициальная документация. Страница, где описан параметр, из-за которого отказ распространяется на весь кластер, а не на недостающие ключи: «cluster-require-full-coverage <yes/no>: If this is set to yes, as it is by default, the cluster stops accepting writes if some percentage of the key space is not covered by any node. If the option is set to no, the cluster will still serve queries even if only requests about a subset of keys can be processed» (cluster-require-full-coverage <yes/no>: если установлено yes, как по умолчанию, кластер перестаёт принимать записи, если некоторая часть пространства ключей не покрыта ни одним узлом. Если параметр установлен в no, кластер продолжит обслуживать запросы, даже если может обработать запросы лишь о части ключей). На той же странице описана и настройка, отвечающая за чтения: «cluster-allow-reads-when-down <yes/no>», по умолчанию no. Именно она, а не полнота покрытия, решает, ответит ли узел отказавшего кластера на GET, — и это измерено отдельным прогоном.https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/
- Redis — Active-Active geo-distributed databasesОфициальная документация. Ссылка нужна затем, чтобы строка «active-active» в таблице раскладов не висела без первоисточника. Сама конструкция описана так: «Active-Active databases are geo-distributed databases that span multiple Redis Enterprise Software clusters» (Базы Active-Active — это геораспределённые базы, охватывающие несколько кластеров Redis Enterprise Software), а разрешение конфликтов у неё явное и встроенное: «Conflict resolution is handled by conflict-free replicated data types (CRDTs)» (Разрешение конфликтов выполняется бесконфликтными реплицируемыми типами данных (CRDT)). Это другой продукт и другая модель, и статья его не измеряла — но и делать вид, что «обе половины пишут независимо» невозможно в принципе, после такой страницы нельзя.https://redis.io/docs/latest/operate/rs/databases/active-active/
- PostgreSQL — High Availability, Load Balancing, and ReplicationОфициальная документация. Источник для той половины сравнения, которая касается базы. Про асинхронность по умолчанию: «When using streaming replication, servers will typically be configured as asynchronous» (При использовании потоковой репликации серверы обычно настраиваются как асинхронные), и про то, чем это оборачивается: «If the primary server crashes then some transactions that were committed may not have been replicated to the standby server, causing data loss» (Если основной сервер аварийно завершится, некоторые зафиксированные транзакции могут оказаться не реплицированными на резервный сервер, что приведёт к потере данных). То есть «база растянута» само по себе не означает ни синхронности, ни отсутствия окна потерь, — ровно это статья и утверждает.https://www.postgresql.org/docs/current/high-availability.html
- PostgreSQL — Replication configuration parametersОфициальная документация. Настройки, из-за которых «растянутая база» бывает совсем разной:
synchronous_standby_namesиsynchronous_commit. Про последнюю документация говорит прямо: «if the standby is the last one in a synchronous group, setting this to on will result in commits waiting for the standby to confirm receipt» (если резервный сервер последний в синхронной группе, значение on приведёт к тому, что фиксации будут ждать подтверждения приёма от резервного). Сравнивать надо конкретную модель репликации с конкретной, а не «базу» с «кэшем»: у одной только базы этих моделей несколько.https://www.postgresql.org/docs/current/runtime-config-replication.html - Amazon — Caching challenges and strategiesИсточник. Промышленный разбор ровно тех ловушек, которые статья измеряет: «a cache can be a big and highly damaging blast radius» (кэш может быть большим и крайне разрушительным радиусом поражения), и отдельно про то, почему холодный кэш опаснее отсутствия кэша: «the service is likely to be unable to handle the resulting load on its dependencies» (служба, вероятно, не справится с возникшей нагрузкой на свои зависимости). Отсюда же берётся аргумент в пользу тёплой реплики в чужом ДЦ — единственный случай, где растянутая конструкция в статье защищается.https://aws.amazon.com/builders-library/caching-challenges-and-strategies/
- Valkey — ReplicationОфициальная документация. Та же страница у форка, слово в слово с заменой имени: «Valkey uses by default asynchronous replication, which being low latency and high performance, is the natural replication mode for the vast majority of Valkey use cases» (Valkey по умолчанию использует асинхронную репликацию, которая, будучи низколатентной и высокопроизводительной, является естественным режимом репликации для подавляющего большинства сценариев использования Valkey). И про WAIT: «However WAIT is only able to ensure there are the specified number of acknowledged copies in the other Valkey instances, it does not turn a set of Valkey instances into a CP system with strong consistency» (Однако WAIT способен лишь обеспечить наличие указанного числа подтверждённых копий на других экземплярах Valkey; он не превращает набор экземпляров Valkey в CP-систему со строгой согласованностью). Ссылка нужна затем, чтобы утверждения статьи о договоре репликации не приходилось переносить на Valkey по аналогии.https://valkey.io/topics/replication/
- Valkey — Cluster specificationОфициальная документация. Условие выживания у форка сформулировано так же, с той же парой требований: «Valkey Cluster is able to survive partitions where the majority of the primary nodes are reachable and there is at least one reachable replica for every primary node that is no longer reachable» (Кластер Valkey способен пережить разделения сети, при которых доступно большинство первичных узлов и для каждого недоступного первичного узла доступна хотя бы одна реплика). И там же: «Valkey Cluster is not available in the minority side of the partition» (Кластер Valkey недоступен на той стороне разделения, где меньшинство).https://valkey.io/topics/cluster-spec/
- Valkey — HistoryИсточник. Происхождение форка, названное самим проектом: «Valkey is a fork of the open-source Redis (REmote DIctionary Server) database created in 2009 by the Italian hacker Salvatore "antirez" Sanfilippo» (Valkey — форк открытой базы данных Redis (REmote DIctionary Server), созданной в 2009 году итальянским хакером Сальваторе „antirez“ Санфилиппо). Отсюда и правило статьи: общее наследство позволяет переносить на Valkey то, что записано в его собственной документации теми же словами, и не позволяет переносить числа замера.https://valkey.io/topics/history/