Deep Engineering
Продвинутый·Опубликовано·65 МИН

Растянутый кэш на два датацентра: почему аналогия с базой ложная

«База же у нас растянута репликами, и ничего» — про базу это правда, но «растянуть» у базы и у кэша означает разное. Кэш держится сравнительным свойством: поход в него должен обходиться дешевле операции источника, которую он заменяет. На дешёвом чтении по ключу за каналом между ДЦ этого не остаётся — 41,09 мс против 0,09 мс до базы под рукой. А кластер, разложенный по датацентрам поровну, при разрыве отказал в обеих половинах сразу, включая ту, у которой большинство голосов. Заодно здесь проверен сам измерительный стенд: ретранслятор без TCP_NODELAY добавляет к записи с WAIT больше сорока миллисекунд, и замер на таком стенде мерил бы не расстояние, а дефект прибора.

Полное техническое изложение

TL;DR

Довод звучит разумно: «база у нас растянута на два датацентра — растянем и кэш». Аналогия ложная, и ломается она в трёх разных местах сразу.

  • Кэш держится сравнительным свойством: поход в него должен обходиться дешевле той операции источника, которую он заменяет. На дешёвом чтении по ключу за каналом между ДЦ этого не остаётся: попадание в кэш в чужом ДЦ стоит 41,09 мс, чтение той же строки из базы под рукой — 0,09 мс.
  • Мастер не ждёт реплику. Разрыв сразу после залпа записей потерял 93 записи из 200; клиенту на все двести пришёл OK. Это не сбой, а объявленный договор асинхронной репликации.
  • WAIT покупает не то, за что его принимают: запись дорожает в 171,6 раза, а отменить её нечем — к моменту ответа она уже применена. Цена — ровно круг до реплики, и это проверено на трёх расстояниях.
  • Окно потерь задаёт отставание, а не круг. На одном и том же канале разный темп записи даёт 104, 3 и 0 потерянных записей; смотреть надо на разницу смещений в INFO replication.
  • Главное. Кластер из шести узлов, разложенный по датацентрам поровну, при разрыве отказал в обеих половинах — включая ту, у которой было большинство мастеров. Каждая половина проверена на своём свежем кластере. Большинство голосов необходимо, но недостаточно.
  • Проверен и сам измерительный стенд. Ретранслятор, изображающий канал, без TCP_NODELAY сам добавляет к записи с WAIT 43,30 мс — даже при нулевой задержке. Такой прибор показывал бы сорок миллисекунд собственного залипания как свойство Redis.
  • Те же утверждения проверены на Redis 8.10.1 и Valkey 9.1.2. Ни одно не разошлось.
  • Что делать. Умолчание — свой кэш в каждом ДЦ, наполняется своим приложением. Расхождение между ДЦ складывается из отставания источника, задержки инвалидации, политики свежести и последствий отказов; TTL — одно слагаемое из четырёх.

Три способа растянуть кэш

Растягивают кэш одним из трёх способов, и они настолько разные, что общего у них только слово «растянуть».

  1. Один кластер на два ДЦ, узлы поровну. Самый убедительный вариант на схеме: три коробочки слева, три справа, разрыв посередине — и кажется, что каждая половина продолжит работать.
  2. Мастер в одном ДЦ, реплика в другом. Прямая калька с базы: там реплика в соседнем ДЦ, и здесь пусть будет.
  3. Приложение в обоих ДЦ ходит в один кэш. Формально это даже не растягивание кластера — просто один адрес на две площадки.

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

Аналогия, из которой всё растёт

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

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

У базы и кэша разные задачи. У них разная цена потери одной записи. И слово «растянуть» означает для них разные вещи. Дальше — по одному различию на раздел, и на каждое есть замер.

Оговорка: на чём снято и что из этого переносится

Все числа этой статьи сняты на 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 — таблица в отдельном разделе ниже. Ни одно не разошлось.

А числа не переносятся никуда. Здесь они и не нужны как абсолютные значения: содержательны кратность, знак и то, какая половина кластера ответила, а какая нет.

Различие первое: у кэша и базы разные задачи

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

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

Дальше измерена одна из этих сторон — задержка, — и измерена на самом невыгодном для кэша случае.

наблюдение замераbench/stretched-cache/latency.py, Redis 7.0.15 и PostgreSQL 16.13 на одном хосте, канал между ДЦ 20 мс в одну сторону. Абсолютные миллисекунды зависят от машины и от расстояния; содержателен порядок строк и то, что третья строка на два порядка выше первых двух. Это единственный класс операций, для которого стенд проверен как точный: калибровочный замер ниже показывает, что на «запросе — ответе» он даёт ровно круг.
медиана, мсp95, мс
кэш в своём ДЦ0,100,14
база в своём ДЦ0,090,13
кэш в чужом ДЦ41,0941,36

Кратность прогон называет сам: кэш в чужом ДЦ медленнее базы в своём примерно в 450 раз.

Здесь ломается третий способ — «приложение в обоих ДЦ ходит в один кэш», — но ломается он не вообще, а для этого класса запросов. Половина обращений пойдёт в чужой датацентр, заплатит круг и вернётся позже, чем вернулся бы поход в локальную базу. Если кэш стоял ради задержки на дешёвых чтениях, он её не уменьшает, а увеличивает — и вдобавок ставит между приложением и данными ещё одну систему, которая может отказать.

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

Из замера следует ровно одно, и это правило, а не число: выигрыш у кэша берётся из сравнения с источником, поэтому проверять надо не «быстрый ли Redis», а «дешевле ли поход в кэш той операции источника, которую он заменяет, на этом расстоянии». Для дешёвого чтения по ключу из прогретой таблицы ответ на сорока миллисекундах отрицательный. Для агрегата на сто миллисекунд — уже нет, и такой случай в этой статье не измерялся.

Различие второе: потеря строки и потеря записи стоят разного

Второй способ — мастер в одном ДЦ, реплика в другом — калькирует базу буквально. И первое, что замер показывает: расстояние видно.

наблюдение замераbench/stretched-cache/replication.py, Redis 7.0.15, канал между ДЦ 20 мс в одну сторону. В этом опыте при низкой нагрузке дополнительная задержка видимости почти совпала с искусственно добавленным перелётом в одну сторону. Совпадение не есть закон: обработка, буферизация и нагрузка добавляют свои слагаемые, и на загруженной репликации отставание будет больше перелёта.
запись видна на реплике черезмс
реплика в том же ДЦ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.

— Redis — Redis replication

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.

перевод

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

— PostgreSQL — High Availability, Load Balancing, and Replication

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

Бедой отставание становится в тот момент, когда реплику поднимают на место мастера, — то есть в том самом сценарии, ради которого её и держат в другом ДЦ.

наблюдение замераbench/stretched-cache/replication.py, Redis 7.0.15, канал 20 мс в одну сторону, 200 записей подряд. Обе строки — один и тот же опыт с одной разницей: успела ли репликация догнать до обрыва. Доля зависит от того, сколько записей помещается в канал, а не от Redis, и от прогона к прогону она заметно гуляет.
200 записей подтверждено клиентуна репликепотеряно
разрыв сразу после записи10793 (46,5%)
разрыв через секунду после записи2000 (0,0%)

Читать надо обе строки сразу, иначе из первой вынесешь «теряется почти всё», а из второй — «не теряется ничего». Неверно ни то ни другое: теряется то, что не успело уехать к реплике. Ни одна из потерянных записей об ошибке не сообщила: на все клиент получил OK.

Чем измеряется окно потерь, и почему не кругом

Из этого опыта напрашивается правило «под угрозой всё, что записано за последний круг до разрыва». В таком виде оно и ходит по статьям, и оно неверно: круг здесь совпал с потерей случайно. Проверяется это изменением одного лишь темпа записи при том же самом канале.

наблюдение замераbench/stretched-cache/offsets.py, Redis 7.0.15, канал 20 мс в одну сторону. Смещения сняты до обрыва: вопрос ровно в том, что лежало в канале в этот момент. Во всех трёх опытах клиенту подтверждены все 200 записей.
200 записей, затем обрывна репликепотеряносмещение мастерасмещение репликиотставание, байт
залпом, обрыв сразу96104640328553548
с паузой 5 мс, обрыв сразу1973128381273999
залпом, обрыв через секунду200019273192730

Канал во всех трёх опытах один и тот же, круг тот же; меняется отставание, а вместе с ним и потеря.

Отсюда правило в верном виде: под угрозой то, что в момент разрыва лежит между master_repl_offset мастера и смещением реплики. Redis отдаёт оба числа в INFO replication, и по их разнице видно, сколько вы потеряете, если переключение случится прямо сейчас. Круг задаёт лишь нижнюю границу того, за какое время отставание вообще может сойти к нулю; само отставание зависит ещё от темпа записи, размера команд и от того, успевает ли реплика их применять.

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

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

наблюдение замераbench/stretched-cache/replication.py, Redis 7.0.15, канал 20 мс в одну сторону, 20 записей. Кратность зависит от того, с чем сравнивать: чем дешевле обычная запись, тем она больше.
мс
обычная запись0,24
запись с WAIT 141,73
во сколько раз дороже171,6
реплик подтвердило (из 20 записей, запрошена 1)20

Сорок один и семь десятых миллисекунды вместо ноля целых двадцати четырёх сотых — это цена ожидания, добавленная к каждой записи. Из чего она складывается, по одной точке сказать нельзя — и догадка «это два круга» напрашивается сама собой. Тот же опыт на трёх расстояниях даёт ответ точнее — и другой.

наблюдение замераbench/stretched-cache/waitcost.py, Redis 7.0.15, 20 записей на строку, медиана. Задержка стоит только на канале мастер — реплика; клиент ходит к мастеру напрямую. Последний столбец и есть проверка: у фиксированного числа кругов он одинаков на всех трёх строках.
в одну сторонукругобычнаяс WAIT 1WAIT / круг
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.

— Redis — Redis replication

То есть подорожанием в 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 мс.

наблюдение замераbench/stretched-cache/relaycheck.py, Redis 7.0.15. Расстояния в опыте нет ни в одной из трёх строк: задержка канала нулевая везде, различается один флаг сокета. Разница между строками поэтому и означает цену флага, а не цену расстояния.
SET + WAIT 1, расстояния нетобычная запись, мсс WAIT 1, мс
без канала вовсе0,200,42
канал 0 мс, без TCP_NODELAY0,2444,00
канал 0 мс, с TCP_NODELAY0,180,70

Эти сорок с лишним миллисекунд целиком принадлежат измерительному прибору, а не Redis: на таком стенде замер цены WAIT нашёл бы сверх круга постоянное слагаемое, которое нечем объяснить. Redis ставит TCP_NODELAY на своих соединениях сам; ретранслятор создаёт два новых соединения, и на них флаг надо ставить явно. Без него работает известная пара: алгоритм Нейгла придерживает мелкую порцию до подтверждения предыдущей, а отложенное подтверждение придерживается до сорока миллисекунд. Отсюда и ровно сорок с небольшим, и их независимость от расстояния — это таймер, а не путь, — и то, что они появляются только там, где по каналу идёт несколько мелких сообщений подряд.

Теперь — то самое подозрение про порции, тоже в числах. Сколько раз задержка применяется к одной операции:

наблюдение замераbench/stretched-cache/relaycheck.py, Redis 7.0.15. Счётчик порций стоит в самом ретрансляторе; контрольное окно без нагрузки отделяет служебный трафик репликации от нашего. Число порций на операцию не гарантировано: TCP границ сообщений не хранит, и соседние сообщения могут слиться или разойтись.
сериализующий канал, 20 мс в одну сторону, 20 × SET+WAITпорций
мастер → реплика, вхолостую0
реплика → мастер, вхолостую1
мастер → реплика, под нагрузкой40
реплика → мастер, под нагрузкой21
сверх фона, всего60
на одну операцию3,00
задержка применена, мс на операцию60,0
измерено, мс на операцию61,81

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

наблюдение замераbench/stretched-cache/relaycheck.py, Redis 7.0.15, оба канала с TCP_NODELAY. Конвейерный ретранслятор ставит порции срок доставки и продолжает читать; сериализующий спит между чтением и отправкой в одном потоке. Различаются они только этим.
в одну сторонукругсериализующийконвейерныйразница
5 мс10 мс16,06 мс11,06 мс+5,00
20 мс40 мс61,64 мс41,48 мс+20,16
40 мс80 мс121,86 мс81,36 мс+40,51

Подозрение подтверждается: сериализующий канал стоит ровно на один перелёт в одну сторону дороже на каждом расстоянии. Не на случайную величину — на ту самую задержку, которую он применяет к порции.

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

Последнее — калибровка, и она же граница применимости:

наблюдение замераbench/stretched-cache/relaycheck.py, Redis 7.0.15. На простом «запрос — ответ» оба ретранслятора дают одно и то же: одна порция туда, одна обратно, складывать нечего.
в одну сторонукругсериализующийконвейерный
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 способен пережить разделения сети, при которых доступно большинство мастер-узлов и для каждого недоступного мастер-узла доступна хотя бы одна реплика.

— Redis — Redis cluster specification

Первое требование помнят все — «нужно большинство». Второе читают по диагонали, и вся статья, по существу, про него. У 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 способен пережить разделения сети, при которых доступно большинство первичных узлов и для каждого недоступного первичного узла доступна хотя бы одна реплика).

Прежде чем дойти до расклада «поровну», стоит посмотреть на расклад, который кажется самым осторожным: все мастера в одном ДЦ, все реплики во втором.

наблюдение замераbench/stretched-cache/cluster.py, Redis 7.0.15, шесть узлов, cluster-node-timeout 2000 мс, после разрыва восемь секунд на выборы. Каждая половина наблюдается на СВОЁМ свежем кластере с той же топологией: два независимых опыта, а не две проверки подряд. Номера портов случайны и в каждом прогоне свои; читаются роли и столбец состояния.
ДЦузелсостояние кластерачто вернул GET
ДЦ-1 — три мастера46073okзначение
38365okотдал слот другому узлу
48713okотдал слот другому узлу
ДЦ-2 — три реплики (свой кластер)47655failCLUSTERDOWN
34721failCLUSTERDOWN
52771failCLUSTERDOWN

Роли за время разрыва не изменились ни на одной половине, а у всех реплик канал к мастеру до разрыва был 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: реплика 5246738349: реплика 35445
наблюдение замераbench/stretched-cache/cluster.py, Redis 7.0.15. Два независимых опыта на двух свежих кластерах с одинаковой топологией. Так сделано намеренно: если проверять обе половины по очереди на одном кластере, повышение реплики в первой проверке меняет топологию для второй, и второй ответ относится уже к другому кластеру.
ДЦузелсостояние кластерачто вернул GET
ДЦ-1 — 2 мастера52467failCLUSTERDOWN
45191failCLUSTERDOWN
54701failCLUSTERDOWN
ДЦ-2 — 1 мастер (свой кластер)49019failCLUSTERDOWN
40491failCLUSTERDOWN
48663failCLUSTERDOWN

Кэша не стало нигде. Ни в одном из двух датацентров, ни по одному ключу.

Прежде чем разбирать почему, стоит сказать, как этот результат получен, потому что метод здесь легко сделать неверным. Напрашивается проверить обе половины подряд на одном кластере: остановили дальнюю, спросили ближнюю, отпустили, остановили ближнюю, спросили дальнюю. Так делать нельзя. После первой проверки топология не обязана вернуться к исходной: реплика могла повыситься, 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, кластер продолжит обслуживать запросы, даже если может обработать запросы лишь о части ключей.

— Redis — Scale with Redis Cluster, cluster-require-full-coverage

Отсюда напрашивается вывод, что наблюдаемое поведение «шире написанного»: документация говорит про записи, а GET тоже получил CLUSTERDOWN. Вывод неверный. Про чтения отвечает другая настройка, и она тоже описана — cluster-allow-reads-when-down со значением no по умолчанию. Цепочка такая: неполное покрытие слотов → кластер помечен отказавшим → узел отказавшего кластера не обслуживает и чтения.

Проверяется это тем же раскладом, прогнанным дважды.

наблюдение замераbench/stretched-cache/readsdown.py, Redis 7.0.15. Тот же расклад «поровну» и тот же разрыв, опрашивается половина с большинством мастеров; различается одна настройка. Двадцать ключей вместо одного затем, что ключи раскладываются по слотам хешем, и по одному ключу нельзя сказать, что происходит с остальным пространством.
настройкаузелрольсостояние кластерачтение 20 ключей
cluster-allow-reads-when-down no (по умолчанию)43151мастерfail20 CLUSTERDOWN
47729мастерfail20 CLUSTERDOWN
35021репликаfail20 CLUSTERDOWN
cluster-allow-reads-when-down yes54835мастерfail5 отдал, 15 перенаправил
46259мастерfail9 отдал, 11 перенаправил
48093репликаfail20 перенаправил

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: реплика 5350947199: реплика 40665
наблюдение замераbench/stretched-cache/cluster.py, Redis 7.0.15, то же деление узлов, что в опыте выше, изменена только принадлежность реплик. Строка «что изменилось в ролях» печатается специально: она показывает повышение, а не позволяет его предположить. Вторая половина — на своём кластере, в записи прогона.
ДЦузелсостояние кластерачто вернул GET
ДЦ-1 — 2 мастера + реплика третьего46819okзначение
40665okотдал слот другому узлу
36267okотдал слот другому узлу
ДЦ-2 — 1 мастер (свой кластер)47073failCLUSTERDOWN
40639failCLUSTERDOWN
34151failCLUSTERDOWN

Теперь ДЦ-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 менялась заметно, и «совпало однажды» не делает свойство неизменным. Поэтому те же утверждения прогнаны на всех трёх сборках — не производительность, а именно утверждения.

наблюдение замераbench/stretched-cache/versions.py, канал между ДЦ 20 мс в одну сторону, один хост. Redis 8.10.1 и Valkey 9.1.2 собраны из исходников. Колонки не обязаны совпадать по числам; содержательны строки со свойствами. Строка «потеряно из 200» гуляет от прогона к прогону в каждой колонке и сравнению между колонками не подлежит.
Redis 7.0.15Redis 8.10.1Valkey 9.1.2
версия7.0.158.10.19.1.2
обычная запись, мс0,170,180,18
мастер ждёт репликунетнетнет
видна на реплике через, мс21,421,921,1
WAIT 1, мс41,4641,4841,29
WAIT / круг1,041,041,03
потеряно из 200 при разрыве72106116
расклад «поровну»: состояние половины с большинствомfailfailfail
расклад «поровну»: ответов на GETнетнетнет
reads-when-down no: отдано ключей из 60000
reads-when-down yes: отдано ключей из 60151515

Ни одно утверждение статьи не разошлось: мастер не ждёт реплику нигде, 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 здесь — тоже свойства, а не измеренные величины: они зависят от вашего темпа записи и от того, сколько времени греется ваш кэш.
РаскладЗадержка у себяПри разрыве между ДЦ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 мс в одну сторону — кроме замеров, где расстояние и есть переменная опыта. Двадцать миллисекунд выбраны не «чтобы было хуже», а как обычное расстояние между датацентрами в пределах одной страны.

Чем измерено

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

Унести в работу

  • Не переносить довод «база растянута и работает» на кэш: кэш держится сравнительным свойством, и попадание в чужом ДЦ стоило 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 из 6

Кластер из шести узлов разложен поровну: в ДЦ-1 два мастера и одна реплика, в ДЦ-2 один мастер и две реплики. Канал между ДЦ оборван. Что произойдёт с ДЦ-1, у которого большинство мастеров?

Источники и что читать дальше

10 ИСТОЧНИКОВ

  1. 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/
  2. 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/
  3. 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/
  4. 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/
  5. 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
  6. 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
  7. 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/
  8. 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/
  9. 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/
  10. 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/