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

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

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

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

TL;DR

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

  • Кэш держится сравнительным свойством: поход в него должен обходиться дешевле той операции источника, которую он заменяет. На дешёвом чтении по ключу за каналом между ДЦ этого не остаётся: попадание в кэш в чужом ДЦ стоит 41,09 мс, чтение той же строки из базы под рукой — 0,09 мс.
  • Мастер не ждёт реплику. Разрыв сразу после залпа записей потерял 93 записи из 200; клиенту на все двести пришёл OK. Это не сбой, а объявленный договор асинхронной репликации.
  • WAIT покупает не то, за что его принимают: запись дорожает в 171,6 раза, а отменить её нечем — к моменту ответа она уже применена. Цена — ровно круг до реплики, и это проверено на трёх расстояниях.
  • Окно потерь задаёт отставание, а не круг. На одном и том же канале разный темп записи даёт 104, 3 и 0 потерянных записей; смотреть надо на разницу смещений в INFO replication.
  • Главное. Кластер из шести узлов, разложенный по датацентрам поровну, при разрыве отказал в обеих половинах — включая ту, у которой было большинство мастеров. Каждая половина проверена на своём свежем кластере. Большинство голосов необходимо, но недостаточно.
  • Проверен и сам измерительный стенд, и это стоило одного числа. В прежней версии у WAIT находилось «слагаемое в 42–44 мс неизвестной природы». Природа выяснилась: у ретранслятора, изображавшего канал, не был выставлен TCP_NODELAY. Прибор показывал сорок миллисекунд собственного залипания.
  • Те же утверждения проверены на 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 — таблица в отдельном разделе ниже. Ни одно не разошлось.

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

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

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

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

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

7. КЭШ В ЧУЖОМ ДЦ МЕДЛЕННЕЕ БАЗЫ В СВОЁМ
------------------------------------------------------------------
                                    медиана, мс   p95, мс
  кэш в своём ДЦ                       0.10      0.14
  база в своём ДЦ                      0.09      0.13
  кэш в чужом ДЦ                      41.09     41.36

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

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

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

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

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

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

1. ЗАПИСЬ ДОХОДИТ ДО РЕПЛИКИ НЕ СРАЗУ, И РАССТОЯНИЕ ЭТО ВИДНО
------------------------------------------------------------------
  реплика в том же ДЦ: запись видна через, мс   1.1
  реплика в другом ДЦ: запись видна через, мс   20.8
  разница, мс                                   19.7
наблюдение замераbench/stretched-cache/replication.py, Redis 7.0.15, канал между ДЦ 20 мс в одну сторону. В этом опыте при низкой нагрузке дополнительная задержка видимости почти совпала с искусственно добавленным перелётом в одну сторону. Совпадение не есть закон: обработка, буферизация и нагрузка добавляют свои слагаемые, и на загруженной репликации отставание будет больше перелёта.

Само по себе отставание бедой не является. Клиент к этому моменту давно получил 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

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

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

2. ПЕРЕКЛЮЧЕНИЕ НА РЕПЛИКУ ТЕРЯЕТ ПОДТВЕРЖДЁННЫЕ ЗАПИСИ
------------------------------------------------------------------
  записей подтверждено клиенту                  200
  разрыв сразу после записи:
    оказалось на реплике                        107
    потеряно                                    93 (46.5%)
  разрыв через секунду после записи:
    оказалось на реплике                        200
    потеряно                                    0 (0.0%)
наблюдение замераbench/stretched-cache/replication.py, Redis 7.0.15, канал 20 мс в одну сторону, 200 записей подряд. Обе строки — один и тот же опыт с одной разницей: успела ли репликация догнать до обрыва. Доля зависит от того, сколько записей помещается в канал, а не от Redis, и от прогона к прогону она заметно гуляет.

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

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

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

9. ОКНО ПОТЕРЬ ЗАДАЁТ ОТСТАВАНИЕ, А НЕ КРУГ
------------------------------------------------------------------
  200 записей залпом, обрыв сразу:
    подтверждено клиенту                        200
    оказалось на реплике                        96
    потеряно                                    104
    master_repl_offset                          6403
    slave_repl_offset                           2855
    отставание, байт                            3548
наблюдение замераbench/stretched-cache/offsets.py, Redis 7.0.15, канал 20 мс в одну сторону. Смещения сняты до обрыва: вопрос ровно в том, что лежало в канале в этот момент. Полный блок с тремя темпами записи — в записи прогона.

Два других темпа на том же канале дают другое: с паузой в 5 мс между записями теряется 3 записи при отставании 99 байт, а через секунду тишины — ноль при нулевом отставании. Канал один и тот же, круг тот же; меняется отставание, и вместе с ним потеря.

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

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

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

3. WAIT ДЕЛАЕТ ЗАПИСЬ ДОРОЖЕ, НО НЕ ДЕЛАЕТ ЕЁ ТРАНЗАКЦИЕЙ
------------------------------------------------------------------
  обычная запись, мс                            0.24
  запись с WAIT 1, мс                           41.73
  во сколько раз дороже                         171.6
  реплик подтвердило (из 20 записей по 1)       20
наблюдение замераbench/stretched-cache/replication.py, Redis 7.0.15, канал 20 мс в одну сторону, 20 записей. Кратность зависит от того, с чем сравнивать: чем дешевле обычная запись, тем она больше.

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

10. ЦЕНА WAIT РАСТЁТ ВМЕСТЕ С РАССТОЯНИЕМ, И ЭТО ПРОВЕРЯЕТСЯ
------------------------------------------------------------------
  в одну сторону    круг    обычная    с 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
наблюдение замераbench/stretched-cache/waitcost.py, Redis 7.0.15, 20 записей на строку, медиана. Задержка стоит только на канале мастер — реплика; клиент ходит к мастеру напрямую. Последний столбец и есть проверка: у фиксированного числа кругов он одинаков на всех трёх строках.

Последний столбец — единица на всех трёх расстояниях. То есть WAIT стоит ровно один круг до реплики, и с расстоянием растёт один к одному: круг вырос на 30 мс — цена выросла на 30,62; круг вырос на 40 — цена на 39,87. Сверх круга остаётся меньше полутора миллисекунд, и это уже работа самого Redis, а не расстояние.

Этот блок публикуется во второй редакции, и первая была неверна — причём неверна не выводом, а числами. В ней стояло: отношение к кругу 5,20 / 2,10 / 1,55, значит «фиксированное число кругов» не подтверждается, а сверх круга остаётся слагаемое в 42–44 мс, чем вызвано — замер не устанавливает. Вывод «с расстоянием цена растёт на один круг» был верен и тогда, потому что держался на разностях. А вот слагаемое оказалось не свойством 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. Права тут ни при чём — дисциплины очереди в ядре просто нет. Поэтому сверяться пришлось с тем, что доступно: измерить сам прибор.

Первое, что он показал, — что подозревать надо было не то.

11. ЧТО СТОИТ САМ СТЕНД, ЕСЛИ ЗАДЕРЖКУ ВЫКРУТИТЬ В НОЛЬ
------------------------------------------------------------------
  без канала вовсе: обычная запись, мс        0.20
  без канала вовсе: запись с WAIT 1, мс       0.42

  канал 0 мс, БЕЗ TCP_NODELAY (стенд, на котором снят блок 10):
      обычная запись, мс                      0.24
      запись с WAIT 1, мс                     44.00

  канал 0 мс, С TCP_NODELAY (исправленный стенд):
      обычная запись, мс                      0.18
      запись с WAIT 1, мс                     0.70

  цена прежнего стенда на одной записи, мс    +43.30
наблюдение замераbench/stretched-cache/relaycheck.py, Redis 7.0.15. Расстояния в опыте нет ни в одной из трёх строк: задержка канала нулевая везде, различается один флаг сокета. Разница между строками поэтому и означает цену флага, а не цену расстояния.

Вот и происхождение «слагаемого в 42–44 мс», которое блок 10 нашёл и не смог объяснить. Оно целиком принадлежит измерительному прибору. Redis ставит TCP_NODELAY на своих соединениях сам; ретранслятор создаёт два новых соединения, и на них флага не было. Дальше работает известная пара: алгоритм Нейгла придерживает мелкую порцию до подтверждения предыдущей, а отложенное подтверждение придерживается до сорока миллисекунд. Отсюда и ровно сорок с небольшим, и их независимость от расстояния, и то, что они появлялись только там, где по каналу шло несколько мелких сообщений подряд.

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

12. СКОЛЬКО РАЗ ЗАДЕРЖКА ПРИМЕНЯЕТСЯ К ОДНОЙ ОПЕРАЦИИ
------------------------------------------------------------------
  канал сериализующий, 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 границ сообщений не хранит, и соседние сообщения могут слиться или разойтись.

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

13. СЕРИАЛИЗУЮЩИЙ КАНАЛ ПРОТИВ КОНВЕЙЕРНОГО НА ТРЁХ РАССТОЯНИЯХ
------------------------------------------------------------------
  в одну сторону    круг   сериализующий   конвейерный   разница
           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, оба канала с TCP_NODELAY. Конвейерный ретранслятор ставит порции срок доставки и продолжает читать; сериализующий спит между чтением и отправкой в одном потоке. Различаются они только этим.

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

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

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

14. КАЛИБРОВКА: КАКУЮ ЗАДЕРЖКУ КАНАЛ ДАЁТ НА «ЗАПРОС — ОТВЕТ»
------------------------------------------------------------------
  GET напрямую, мс                            0.10

  через канал, GET мимо ретранслятора к тому же мастеру:
  в одну сторону    круг   сериализующий   конвейерный
           5 мс      10 мс         10.81 мс      10.79 мс
          20 мс      40 мс         40.98 мс      41.00 мс
          40 мс      80 мс         81.10 мс      81.11 мс
наблюдение замераbench/stretched-cache/relaycheck.py, Redis 7.0.15. На простом «запрос — ответ» оба ретранслятора дают одно и то же: одна порция туда, одна обратно, складывать нечего.

Как читать после этого всю статью. Там, где за операцию по каналу проходит одна порция в каждую сторону — блоки 7 и 1, — ретранслятор даёт ровно круг, и числа можно брать как есть. Там, где по каналу идёт несколько сообщений подряд — репликация под нагрузкой, конвейер команд, 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 способен пережить разделения сети, при которых доступно большинство первичных узлов и для каждого недоступного первичного узла доступна хотя бы одна реплика).

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

4. РАСКЛАД A: ВСЕ МАСТЕРА В ОДНОМ ДЦ, РЕПЛИКИ ВО ВТОРОМ
------------------------------------------------------------------
  ── опыт на свежем кластере, наблюдается ДЦ-1 (мастера)
     ДЦ-1: [46073, 38365, 48713]
       46073: мастер
       38365: мастер
       48713: мастер
     ДЦ-2: [54531, 48957, 34965]
       54531: реплика 46073
       48957: реплика 38365
       34965: реплика 48713
     ДЦ-1 (мастера): 3 мастеров из 3 узлов
       узел 46073: состояние ok           GET -> ответил
       узел 38365: состояние ok           GET -> отдал слот другому узлу
       узел 48713: состояние ok           GET -> отдал слот другому узлу
     роли на наблюдаемой половине не изменились

  ── опыт на свежем кластере, наблюдается ДЦ-2 (реплики)
     ДЦ-1: [42971, 44779, 43549]
       42971: мастер
       44779: мастер
       43549: мастер
     ДЦ-2: [47655, 34721, 52771]
       47655: реплика 42971
       34721: реплика 44779
       52771: реплика 43549
     право реплик наблюдаемой половины повыситься, до разрыва:
       узел 47655: канал up, смещение 201, validity-factor 10
       узел 34721: канал up, смещение 163, validity-factor 10
       узел 52771: канал up, смещение 443, validity-factor 10
     ДЦ-2 (реплики): 0 мастеров из 3 узлов
       узел 47655: состояние fail         GET -> CLUSTERDOWN
       узел 34721: состояние fail         GET -> CLUSTERDOWN
       узел 52771: состояние fail         GET -> CLUSTERDOWN
     роли на наблюдаемой половине не изменились
наблюдение замераbench/stretched-cache/cluster.py, Redis 7.0.15, шесть узлов, cluster-node-timeout 2000 мс, после разрыва восемь секунд на выборы. Каждая половина наблюдается на СВОЁМ свежем кластере с той же топологией: два независимых опыта, а не две проверки подряд. Номера портов случайны и в каждом прогоне свои; читаются роли и столбец состояния.

ДЦ-1 работает, ДЦ-2 не отвечает ничем. Реплики не стали мастерами: для повышения нужен голос большинства мастеров — Once the replica receives ACKs from the majority of masters, it wins the election (Как только реплика получает подтверждения от большинства мастеров, она выигрывает выборы), — а все мастера остались по ту сторону обрыва.

Это первый результат, который стоит забрать с собой: вторая половина не запасная. При потере первой она первую не заменит — во всяком случае, не сама.

Кульминация: отказали обе половины

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

5. РАСКЛАД B: УЗЛЫ ПОРОВНУ, МАСТЕРА ПОДЕЛЕНЫ 2 И 1
------------------------------------------------------------------
  ── опыт на свежем кластере, наблюдается ДЦ-1 (2 мастера)
     ДЦ-1: [52467, 45191, 54701]
       52467: мастер
       45191: мастер
       54701: реплика 52467
     ДЦ-2: [35445, 32777, 38349]
       35445: мастер
       32777: реплика 45191
       38349: реплика 35445
     право реплик наблюдаемой половины повыситься, до разрыва:
       узел 54701: канал up, смещение 201, validity-factor 10
     ДЦ-1 (2 мастера): 2 мастеров из 3 узлов
       узел 52467: состояние fail         GET -> CLUSTERDOWN
       узел 45191: состояние fail         GET -> CLUSTERDOWN
       узел 54701: состояние fail         GET -> CLUSTERDOWN
     роли на наблюдаемой половине не изменились

  ── опыт на свежем кластере, наблюдается ДЦ-2 (1 мастер)
     ДЦ-1: [35315, 47793, 43215]
       35315: мастер
       47793: мастер
       43215: реплика 35315
     ДЦ-2: [49019, 40491, 48663]
       49019: мастер
       40491: реплика 47793
       48663: реплика 49019
     право реплик наблюдаемой половины повыситься, до разрыва:
       узел 40491: канал up, смещение 163, validity-factor 10
       узел 48663: канал up, смещение 443, validity-factor 10
     ДЦ-2 (1 мастер): 1 мастеров из 3 узлов
       узел 49019: состояние fail         GET -> CLUSTERDOWN
       узел 40491: состояние fail         GET -> CLUSTERDOWN
       узел 48663: состояние fail         GET -> CLUSTERDOWN
     роли на наблюдаемой половине не изменились
наблюдение замераbench/stretched-cache/cluster.py, Redis 7.0.15. Два независимых опыта на двух свежих кластерах с одинаковой топологией. Так сделано намеренно: если проверять обе половины по очереди на одном кластере, повышение реплики в первой проверке меняет топологию для второй, и второй ответ относится уже к другому кластеру.

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

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

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

8. ЧТЕНИЯ В ОТКАЗАВШЕМ КЛАСТЕРЕ РЕШАЕТ ОТДЕЛЬНАЯ НАСТРОЙКА
------------------------------------------------------------------
  cluster-allow-reads-when-down no  (по умолчанию):
    узел 43151: master   состояние fail     20 ключей -> 20 CLUSTERDOWN
    узел 47729: master   состояние fail     20 ключей -> 20 CLUSTERDOWN
    узел 35021: slave    состояние fail     20 ключей -> 20 CLUSTERDOWN
    SET на том же узле                        -> CLUSTERDOWN

  cluster-allow-reads-when-down yes:
    узел 54835: master   состояние fail     20 ключей -> 5 ответил, 15 перенаправил
    узел 46259: master   состояние fail     20 ключей -> 9 ответил, 11 перенаправил
    узел 48093: slave    состояние fail     20 ключей -> 20 перенаправил
    SET на том же узле                        -> CLUSTERDOWN
наблюдение замераbench/stretched-cache/readsdown.py, Redis 7.0.15. Тот же расклад B и тот же разрыв, опрашивается половина с большинством мастеров; различается одна настройка. Двадцать ключей вместо одного затем, что ключи раскладываются по слотам хешем, и по одному ключу нельзя сказать, что происходит с остальным пространством.

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

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

Правило, ради которого замер и поставлен. Большинство голосов необходимо, но недостаточно. Полное условие состоит из трёх частей, и третью обычно не называют вовсе:

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

Третья часть — не придирка: свежесть реплики проверяется настройкой cluster-replica-validity-factor, и реплика, отставшая сверх допустимого, выборы не начнёт. В опытах выше она не мешала, и это видно в строках «право реплик повыситься»: у всех реплик канал up ещё до разрыва. Но условие из спецификации без третьей части неполно, и полагаться на две из трёх — значит проверять не то.

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

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

Расклад, который выживает, и что он на самом деле выбирает

Условие можно выполнить руками: оставить в ДЦ-1 не свою реплику, а реплику того мастера, который уезжает в ДЦ-2.

6. РАСКЛАД C: ТО ЖЕ ДЕЛЕНИЕ, НО РЕПЛИКА ЧУЖОГО МАСТЕРА ДОМА
------------------------------------------------------------------
  ── опыт на свежем кластере, наблюдается ДЦ-1 (2 мастера + реплика третьего)
     ДЦ-1: [46819, 40665, 36267]
       46819: мастер
       40665: мастер
       36267: реплика 53509
     ДЦ-2: [53509, 39129, 47199]
       53509: мастер
       39129: реплика 46819
       47199: реплика 40665
     право реплик наблюдаемой половины повыситься, до разрыва:
       узел 36267: канал up, смещение 443, validity-factor 10
     ДЦ-1 (2 мастера + реплика третьего): 3 мастеров из 3 узлов
       узел 46819: состояние ok           GET -> ответил
       узел 40665: состояние ok           GET -> отдал слот другому узлу
       узел 36267: состояние ok           GET -> отдал слот другому узлу
     что изменилось в ролях за время разрыва:
       узел 36267: реплика 53509 -> мастер
наблюдение замераbench/stretched-cache/cluster.py, Redis 7.0.15, то же деление узлов, что в блоке 5, изменена только принадлежность реплик. Строка «что изменилось в ролях» печатается специально: она показывает повышение, а не позволяет его предположить. Вторая половина — на своём кластере, в записи прогона.

Теперь ДЦ-1 выживает: и голосов хватило, и было кого повысить. Последняя строка показывает это прямо — реплика, привезённая домой, стала мастером и приняла слоты потерянного. Все три узла перешли в состояние ok.

Но симметрии не появилось. В ДЦ-2 кэша по-прежнему нет, и это не дефект расклада, а его смысл. Такой раскладкой вы выбираете, какой датацентр переживёт разрыв.

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

Другие исходы возможны — просто это уже не «один растянутый кластер»: два независимых кэша, работа только на чтение в урезанном виде, продукт с active-active и явной моделью разрешения конфликтов (Conflict resolution is handled by conflict-free replicated data types (CRDTs) (Разрешение конфликтов выполняется бесконфликтными реплицируемыми типами данных (CRDT))), разрешение конфликтов на стороне приложения. У каждого своя цена, и ни один из них эта статья не измеряла.

Заодно видно, насколько хрупка эта конструкция. Между блоком 5 и блоком 6 одна перестановка реплики. Она нигде не записана как требование, не проверяется при старте и не переживает автоматическое переключение: после первого же отказа мастера роли перераспределятся, и «правильный» расклад станет тем, что в блоке 5, — молча.

То же самое на текущих сборках

Все числа выше сняты на Redis 7.0.15. Ветка старая: к сентябрю 2026 актуальная линия Redis Open Source — 8.10.x, а Valkey ушёл в 9.1. Кластерная часть между Redis 7 и 8 менялась заметно, и «совпало однажды» не делает свойство неизменным. Поэтому те же утверждения прогнаны на всех трёх сборках — не производительность, а именно утверждения.

15. ТЕ ЖЕ УТВЕРЖДЕНИЯ НА ТЕКУЩИХ СБОРКАХ
------------------------------------------------------------------
                                                    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
  расклад B: состояние половины с большинством              fail            fail            fail
  расклад B: ответов на GET                                  нет             нет             нет
  reads-when-down no: отдано ключей из 60                      0               0               0
  reads-when-down yes: отдано ключей из 60                    15              15              15
наблюдение замераbench/stretched-cache/versions.py, канал между ДЦ 20 мс в одну сторону, один хост. Redis 8.10.1 и Valkey 9.1.2 собраны из исходников. Колонки не обязаны совпадать по числам; содержательны строки со свойствами. Строка «потеряно из 200» гуляет от прогона к прогону в каждой колонке и сравнению между колонками не подлежит.

Ни одно утверждение статьи не разошлось: мастер не ждёт реплику нигде, WAIT везде стоит круг, половина с большинством мастеров везде отказывает целиком, и судьбу чтений везде решает cluster-allow-reads-when-down. Заодно закрыт вопрос про Valkey: раньше статья опиралась на его документацию и честно говорила, что на форке не мерила. Теперь мерила.

Чего эта таблица не делает. Она не переносит числа блоков 1–10 на новые версии и не заменяет их. Опубликованная основа остаётся на 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 с разрешением конфликтовхорошаяна это и рассчитанзависит от продукта и модели конфликтовлокальная доступность не теряется

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

Когда растянутый кэш всё-таки допустим

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

Когда разрыв должен пережить один назначенный датацентр, а не оба. Расклад C работает: ДЦ-1 ответил всеми тремя узлами. Если у вас есть основной ДЦ и резервный, а не два равноправных, то «кластер выживает в основном» — законная цель. Но три условия: во-первых, вы должны понимать, что выбираете победителя заранее; во-вторых, что в резервном ДЦ кэша не будет; в-третьих, что расклад реплик надо проверять после каждого переключения, потому что кластер расставляет их сам.

Когда реплика в чужом ДЦ нужна не для работы, а чтобы не наполнять кэш с нуля. Большой прогретый кэш — это часы работы и заметная нагрузка на источник при холодном старте; именно про это предупреждение о том, что служба is likely to be unable to handle the resulting load on its dependencies (вероятно, не справится с возникшей нагрузкой на свои зависимости). Реплика, которую поднимают руками после аварии, эту нагрузку снимает. Условия честные и оба измерены: реплики сами не повышаются (блок 4: три узла в состоянии fail, ноль мастеров), и переключение теряет то, что не доехало — под угрозой отставание в момент разрыва (блоки 2 и 9). Для кэша второе допустимо: потерянные записи пересчитаются при промахе. Именно поэтому — только для кэша, и только если вы согласны переключать руками.

Когда поток записи редкий. Вторая строка блока 2 — это не оговорка, а отдельный результат: через секунду тишины на реплике оказались все двести записей, потеряно ноль. Блок 9 показывает то же самое ещё раз и точнее: опасен не объём данных, а отставание, которое темп записи успевает создать. Если реплика в среднем догоняет, терять при переключении почти нечего — и проверить это можно, не гадая, по разнице смещений.

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

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

Ограничения стенда, названные прямо

Задержка создаётся ретранслятором в пользовательском коде, а не netem. Ретранслятор добавляет задержку каждой порции, которую вернул recv(), а TCP границ сообщений не хранит. Это воспроизводимое приближение задержки, а не эмулятор сети на уровне пакетов: у него нет ни очередей, ни потерь, ни джиттера, ни ограничения полосы. Простые измерения вида «запрос — ответ» можно читать напрямую — блок 14 показывает, что там ретранслятор даёт ровно круг. Потоковую репликацию и WAIT желательно подтверждать отдельно через netem или сетевые пространства имён; в среде, где снималась эта статья, ядро собрано без CONFIG_NET_SCH_NETEM, и сделать это было нечем. Вместо сверки измерен сам прибор — блоки 11–14, — и два его дефекта найдены и устранены.

Разрыв в кластерном замере изображается сигналом STOP. Процесс жив, порт открыт, но узел не отвечает и не шлёт heartbeat: для оставшейся половины он неотличим от узла за оборванным каналом. Убить их было нельзя — вторую половину надо было опросить тоже. Каждая сторона наблюдается на своём свежем кластере с заданной руками топологией, поэтому последствия первой проверки не достаются второй; настоящее разделение сети через файрвол или сетевые пространства имён было бы ещё точнее.

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

Граница версий

Основные свойства сверены с актуальной документацией Redis и Valkey. Числа основного прогона получены на Redis 7.0.15 и сохранены как воспроизводимая основа. Redis 8.x заметно развил кластерную часть, поэтому ключевые утверждения — репликация, WAIT, расклад B, cluster-allow-reads-when-down — дополнительно прогнаны на Redis 8.10.1 и Valkey 9.1.2 (блок 15) и не считаются неизменными только потому, что однажды совпали.

Опубликованный прогон: Redis 7.0.15, PostgreSQL 16.13, один хост, канал 20 мс в одну сторону — кроме блоков 10 и 13, где расстояние и есть переменная опыта. Двадцать миллисекунд выбраны не «чтобы было хуже», а как обычное расстояние между датацентрами в пределах одной страны.

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

Чем измерено

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

Исправления

12 сентября 2026

Здесь собрано то, что в этом материале было сказано неверно, и то, чем это снято. Каждая запись держится на прогоне или на документе, а не на том, что мы передумали.

Было

«Под угрозой всё, что записано за последний круг до разрыва».

Стало

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

Чем снято

Три темпа записи на ОДНОМ и том же канале: залпом — 104 потерянные записи при отставании 3548 байт, с паузой 5 мс — 3 записи при 99 байтах, через секунду тишины — ноль при нулевом отставании. Круг во всех трёх одинаков, потеря разная (bench/stretched-cache/offsets.py).

Было

У цены WAIT сверх круга находилось «слагаемое в 42–44 мс, одинаковое на всех расстояниях», и происхождение его объявлялось неустановленным.

Стало

Слагаемого нет. Оно принадлежало измерительному прибору: канал между ДЦ изображает ретранслятор в пользовательском коде, и на двух его соединениях не был выставлен TCP_NODELAY. Пара «алгоритм Нейгла — отложенное подтверждение» добавляла ровно сорок миллисекунд.

Чем снято

Опыт без расстояния вовсе: через канал с НУЛЕВОЙ задержкой запись с WAIT 1 стоила 44,00 мс без флага и 0,70 мс с ним. Отдельно нашёлся второй дефект того же стенда — сериализация потока добавляла ещё один перелёт, и увидеть её удалось только после того, как убрали залипание вчетверо большее (bench/stretched-cache/relaycheck.py).

Было

Про отказ кластера на чтениях было сказано, что наблюдаемое поведение «шире написанного»: документация говорит про записи, а GET тоже получил CLUSTERDOWN.

Стало

Не шире. За чтения отвечает отдельная настройка cluster-allow-reads-when-down со значением no по умолчанию, и она тоже описана. Цепочка: неполное покрытие слотов → кластер помечен отказавшим → узел отказавшего кластера не обслуживает и чтения.

Чем снято

Замер на обеих настройках: с no все 20 ключей из 20 получают CLUSTERDOWN на каждом узле выжившей половины (bench/stretched-cache/readsdown.py).

Было

Обе половины разрезанного кластера проверялись по очереди на одном и том же кластере.

Стало

Каждая половина проверяется на своём свежем кластере с одинаковой топологией.

Чем снято

Повышение реплики в первой проверке меняет топологию для второй, и второй ответ относится уже к другому кластеру. Это исправление МЕТОДА, а не числа: вывод после него не изменился, но до него он держался на некорректном опыте (bench/stretched-cache/cluster.py).

Было

«Если кэш обязан быть общим, значит, в нём лежит не кэш».

Стало

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

Чем снято

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

Было

«TTL — это бюджет рассогласования, названный в секундах».

Стало

TTL — одно слагаемое из трёх. Под наблюдаемое расхождение ложатся ещё отставание самого источника и задержка инвалидации, и ни одно из них TTL не сокращает.

Чем снято

Отставание источника измерено отдельно: реплика в другом ДЦ видит запись через 20,8 мс против 1,1 мс в своём (bench/stretched-cache/replication.py). Это слагаемое лежит под расхождением кэшей независимо от того, какой TTL выбран.

Было

«Всё, что нельзя пересчитать из базы, в кэше лежать не должно».

Стало

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

Чем снято

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

Расхожие заблуждения

Утверждение

База у нас растянута на два ДЦ и работает — значит, и кэш можно

На самом деле

Аналогия ломается в трёх местах сразу. Задачи разные: база — источник истины, ради которого не жалко платить круг между ДЦ, а кэш нужен затем, чтобы повторное получение данных обходилось ДЕШЕВЛЕ обращения к источнику. «Дешевле» — это и задержка, и снятая нагрузка, и несделанный пересчёт, и лимит внешнего вызова; свойство в любом случае сравнительное. Замерена одна сторона — задержка — и на самом невыгодном для кэша случае: 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 (Как только реплика получает подтверждения от большинства мастеров, она выигрывает выборы), — а все мастера остались по ту сторону обрыва. Вторая половина не запасная: при потере первой она её не заменит, во всяком случае не сама.

Утверждение

Правильный расклад реплик можно проверить глазами по схеме

На самом деле

Нельзя: на схеме видно число узлов в каждом ДЦ, а не то, чья реплика где стоит. Между блоком 5 (отказали обе половины) и блоком 6 (ДЦ-1 выжил) разница — одна перестановка реплики. Она нигде не записана как требование, не проверяется при старте и не переживает автоматическое переключение: после первого же отказа мастера кластер расставит роли сам, и «правильный» расклад молча станет тем, что в блоке 5.

Утверждение

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. Круг задаёт лишь нижнюю границу того, за какое время отставание может сойти к нулю.

Утверждение

Общий кэш на два ДЦ нужен, потому что приложение работает в обоих

На самом деле

Приложение в двух ДЦ — довод за два кэша, а не за один общий: половина обращений уйдёт в чужой ДЦ и заплатит круг. Но «общий кэш» сам по себе не улика, и ходовое правило «если кэш обязан быть общим, значит, в нём лежит не кэш» неверно. Общий внешний кэш, в который ходят несколько приложений, — нормальная конструкция: он лучше снимает нагрузку с источника, чем набор процессных кэшей, избавляет от расхождения между узлами приложения и переживает выкладку. Красный флаг не «общий», а НЕВОССТАНОВИМОСТЬ. Проверка простая: удалите кэш целиком и посмотрите. Стало медленнее, данные восстановились — это кэш, и растягивать его через ДЦ незачем. Потерялись сессии, блокировки, счётчики — это хранилище состояния, и разговор о нём другой.

Утверждение

Если число измерено, оно измеряет то, что мы хотели измерить

На самом деле

Не обязательно, и эта статья — свой собственный контрпример. У WAIT сверх круга находилось «слагаемое в 42–44 мс, одинаковое на всех расстояниях», и происхождение его долго оставалось неустановленным. Установлено оно оказалось не в Redis, а в измерительном приборе: канал между ДЦ изображает ретранслятор в пользовательском коде, и на двух его соединениях не был выставлен TCP_NODELAY. Пара «алгоритм Нейгла — отложенное подтверждение» добавляла к каждой операции ровно сорок миллисекунд. Проверяется это опытом без расстояния вовсе: через канал с НУЛЕВОЙ задержкой запись с 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/