Redis часто воспринимают как пассивное хранилище: положил значение, забрал значение. Но у него есть целый пласт программируемости — возможность выполнять логику прямо на сервере, атомарно и рядом с данными. Streams добавляют журнал сообщений с consumer groups и подтверждениями, Lua-скрипты и Redis Functions позволяют выполнить произвольную логику как единую атомарную операцию, а модули расширяют сам движок новыми типами и командами.
Это шестая статья серии «Redis: глубокое погружение», и она стоит на фундаменте, заложенном во второй: всё, что тут работает, работает благодаря одному потоку. Здесь мы это обналичим — измеренной ценой отсутствия атомарности и измеренным поведением consumer groups при падении обработчика.
В статье
- Streams и consumer groups: at-least-once вживую
- Streams или брокер: где проходит граница
- Цена неатомарности: 9000 потерянных инкрементов из 10000
EVALпротивFUNCTION: честный замер- Модули — коротко
- Что дальше
- Источники
Streams и consumer groups: at-least-once вживую
Stream — это append-only журнал записей с автогенерируемыми ID. Consumer group поверх него даёт то, ради чего его обычно и берут: несколько обработчиков читают из общего журнала, каждая запись достаётся кому-то одному, а подтверждение (XACK) фиксирует, что работа сделана. Неподтверждённые записи копятся в PEL — Pending Entries List — и их можно забрать другому обработчику через XCLAIM.
Проверка на живом сервере: 1000 сообщений, три консьюмера читают батчами по 50. consumer-2 нормально обрабатывает и подтверждает два своих батча, а на третьем — обрабатывает, но не подтверждает и больше не читает. Это симуляция падения ровно в тот момент, который на практике и вызывает вопросы: работа сделана, XACK не дошёл.
Обработка каждого сообщения — это HINCRBY по его ID, поэтому число обработок каждого ID не предполагается, а измеряется.
redis:8.8 |
valkey/valkey:8.1 |
|
|---|---|---|
| Доставлено всего | 1000/1000 | 1000/1000 |
Зависло у consumer-2 (без XACK) |
50 | 50 |
XPENDING после падения |
count=50, consumers=map[consumer-2:50] |
то же |
XCLAIM (consumer-1, форс) |
забрал 50/50 | забрал 50/50 |
XPENDING после финального XACK |
count=0 |
count=0 |
| Обработано ровно 1 раз | 950 | 950 |
| Обработано дважды (реальные дубликаты) | 50 | 50 |
Как читать «50 на обоих образах». Число 50 — это размер батча, то есть параметр стенда, а не измеренная величина: падение по построению происходит на границе батча, поэтому зависнуть может только целый батч. Более того, равенство «дубликатов = размер зависшего батча» проверяется самим сценарием фатально — если бы образ повёл себя иначе, прогон бы упал, а не показал другое число. Поэтому совпадение Redis и Valkey здесь — это выполнение инварианта, а не независимо измеренный межобразный результат.
Содержательный результат — сам факт: после XCLAIM переобработка происходит, и дубликаты реальны. Те 50 сообщений, которые consumer-2 успел обработать до падения, но не успел подтвердить, забирает consumer-1 — и обрабатывает заново. HINCRBY по тем же ID щёлкает второй раз.
Это и есть at-least-once в чистом виде, не в теории. Consumer group не даёт exactly-once и не притворяется, что даёт: она гарантирует, что сообщение не потеряется, — но не что оно не будет обработано дважды. Идемпотентность обработчика — не рекомендация из чужой методички, а условие корректности, и вот эти 50 дублей показывают, чем платит тот, кто её не обеспечил.
Streams или брокер: где проходит граница
Streams похожи на Kafka по идее, и на этом сходство стоит остановить.
Чего Streams достаточно. Очередь задач внутри одного сервиса. Фан-аут событий нескольким обработчикам. Буфер, из которого читают воркеры. Всё, где объём укладывается в память, а история не нужна дольше нескольких часов или дней. В этих задачах Streams выигрывают простотой: у вас уже есть Redis, и не нужно поднимать ещё одну платформу.
Где начинается чужая территория. Streams живут в оперативной памяти — со всеми последствиями четвёртой статьи про репликацию и третьей про durability. Долгая история (недели, месяцы), партиционирование под масштаб, репликация с настраиваемыми гарантиями подтверждения, ретеншен по объёму с вытеснением на диск, exactly-once поверх транзакций — это задачи брокерной платформы, и решать их Redis Streams не умеет по конструкции, а не по недоработке.
Практический критерий простой: если поток сообщений — источник истины и его нельзя потерять, вопрос упирается ровно в ту границу durability, которую серия провела в статьях 3 и 4. Streams её не сдвигают.
Цена неатомарности: 9000 потерянных инкрементов из 10000
Классическая задача: прочитать значение, изменить, записать обратно. Между GET и SET есть окно, и в него влезает другой клиент.
Сценарий: 20 горутин, по 500 итераций — 10000 попыток read-modify-write над одним ключом. Сначала раздельными GET+SET, затем — тот же самый код внутри одного EVAL.
Сначала — доказательство, что гонка действительно гонялась. Это принципиально: «0 потерь» ничего не значит, если конкуренция просто не пересеклась. Каждая попытка — это окно от начала чтения до конца записи; после прогона считается, у скольких попыток в момент старта было активно окно другой горутины. Если пересеклось менее 50% попыток, прогон падает с ошибкой. Порог взят долей, а не «хоть одно пересечение»: при пороге «больше нуля» тест проходил бы и при одном пересечении из 10000.
Во всех 12 прогонах пересеклось 99.4–99.9% попыток — guard проходит с огромным запасом, конкурентность подтверждена измерением.
| Образ | Сценарий | attempts | final | потеряно | пересечений |
|---|---|---|---|---|---|
| redis:8.8 | race-without-lua | 10000 | 956 | 9044 | 9990/10000 |
| redis:8.8 | race-without-lua | 10000 | 942 | 9058 | 9985/10000 |
| redis:8.8 | race-without-lua | 10000 | 940 | 9060 | 9993/10000 |
| valkey:8.1 | race-without-lua | 10000 | 951 | 9049 | 9991/10000 |
| valkey:8.1 | race-without-lua | 10000 | 948 | 9052 | 9993/10000 |
| valkey:8.1 | race-without-lua | 10000 | 936 | 9064 | 9994/10000 |
| redis:8.8 | atomic-with-lua | 10000 | 10000 | 0 | 9981/10000 |
| redis:8.8 | atomic-with-lua | 10000 | 10000 | 0 | 9963/10000 |
| redis:8.8 | atomic-with-lua | 10000 | 10000 | 0 | 9949/10000 |
| valkey:8.1 | atomic-with-lua | 10000 | 10000 | 0 | 9972/10000 |
| valkey:8.1 | atomic-with-lua | 10000 | 10000 | 0 | 9986/10000 |
| valkey:8.1 | atomic-with-lua | 10000 | 10000 | 0 | 9944/10000 |
Без Lua теряется 9044–9064 инкремента из 10000 — то есть 90.4–90.6%. Это разброс, а не одно число: три прогона на каждом образе дали три разных значения.
Такой высокий процент ожидаем и объясним: 20 горутин непрерывно читают-пишут один ключ без бэкоффа, окно гонки между GET и SET остаётся открытым практически на всё время round-trip, поэтому почти каждая попытка наступает на чужую. В реальном коде с распределёнными во времени обращениями потери были бы куда скромнее — но они были бы.
С Lua — ноль потерь во всех шести прогонах, при той же доказанной конкурентности. И это не вывод из документации: каждый прогон явно проверяет, не нарушил ли сервер атомарность, и печатает фактическое число потерь.
Почему так — уже разобрано во второй статье: Lua-скрипт выполняется в том же единственном потоке, что и обычные команды. Он не может быть прерван чужой командой между шагами внутри себя ровно по той же причине, по которой атомарна каждая отдельная команда. Однопоточность из «архитектурного факта» здесь превращается в 9000 сохранённых инкрементов.
Стоит отметить границу: MULTI/EXEC решают похожую задачу иначе, и их механика — тема отдельной статьи про транзакции в KV и документных БД.
EVAL против FUNCTION: честный замер
Два способа выполнить один и тот же скрипт: EVAL шлёт тело скрипта в каждом вызове (сервер кеширует его по SHA после первого исполнения), FUNCTION LOAD + FCALL загружает библиотеку заранее, как при деплое, и вызывает по имени.
Методика: один и тот же скрипт (138 байт), один клиент без конкуренции, 2000 операций, 200 прогревочных отброшено.
Про этот замер надо рассказать неприятное, потому что первая его редакция была неверной. Она отбрасывала нулевые замеры и публиковала перцентили выживших. На этой машине time.Since() вокруг реального round-trip регулярно возвращает ровно 0 — таких замеров 34.9–46.4% в сериях ниже. Фильтр, отбрасывавший их, сам породил «разброс ±200 микросекунд», который тогда списали на виртуализационный шум.
Арифметика простая и от гипотез о часах не зависит. Нулевые замеры вносят в сумму ровно ноль, значит среднее по выжившим равно среднее/(1−доля нулей) — это тождество, а не модель. Отбрасывание нулей завышает результат ровно в 1/(1−доля нулей) раз, то есть в 1.54–1.87 раза при наблюдавшихся долях. А поскольку доля нулей у разных серий разная, разница перцентилей между сериями отражает прежде всего разницу долей нулей, а не латентность вызова.
Несмещённая оценка — elapsed/attempts: она берёт только две отметки, начало и конец серии, и от распределения времени по отдельным замерам не зависит вовсе.
| Прогон | Образ | EVAL | FCALL | Δ (EVAL−FCALL) | нулей EVAL / FCALL |
|---|---|---|---|---|---|
| 1 | redis:8.8 | 388.872µs | 378.433µs | +10.44µs | 41.5% / 45.2% |
| 2 | redis:8.8 | 392.193µs | 373.069µs | +19.12µs | 38.9% / 46.4% |
| 3 | redis:8.8 | 392.416µs | 387.998µs | +4.42µs | 37.4% / 42.3% |
| 4 | valkey:8.1 | 389.852µs | 393.860µs | −4.01µs | 38.5% / 34.9% |
| 5 | valkey:8.1 | 394.131µs | 379.625µs | +14.51µs | 37.7% / 43.8% |
| 6 | valkey:8.1 | 396.275µs | 387.021µs | +9.25µs | 38.0% / 41.9% |
Отдельно — про абсолютные числа в этой таблице, потому что дальше на них строится вывод. ~390 микросекунд на round-trip — это Docker Desktop на Windows, а не нативная латентность Redis: на голом Linux по loopback это единицы-десятки микросекунд. Переносить абсолют на прод нельзя, надёжна только разница внутри одного прогона. Абсолютные средние заметно дрейфовали даже между сессиями: серия в обратной ориентации на Redis шла на 334–386 мкс против 373–392 мкс в прямой — ещё одна причина читать дельту, а не абсолют. И не путать эту оговорку с предыдущей: артефакт нулей на виртуализацию списывать было неверно, а вот сама величина ~390 мкс — платформенная, и это правда.
Механизм самих нулей не установлен, и стенд его не устанавливает. Есть даже встречное свидетельство против наивного «низкое разрешение часов»: все ненулевые значения кратны 100 наносекундам, а часы с шагом 100 нс физически не могут округлить ~390 микросекунд до нуля. Что там происходит на уровне гипервизора — не диагностировано и здесь не утверждается. Для выводов это и не нужно: они следуют из арифметики.
Разделение эффекта команды и эффекта порядка. Серии идут последовательно, поэтому «медленнее» может значить и «эта команда дороже», и «эта серия шла первой». Каждая конфигурация прогнана и в обратной ориентации: если бы лидировал порядок, знак разницы при развороте сменился бы.
| Образ | Ориентация | Δ, среднее по 3 | Знак |
|---|---|---|---|
| redis:8.8 | EVAL первой | +11.33µs | 3/3 положительных |
| redis:8.8 | FCALL первой | +10.21µs | 3/3 положительных |
| valkey:8.1 | EVAL первой | +6.58µs | 2/3 |
| valkey:8.1 | FCALL первой | −2.71µs | 1/3 |
Разворот знак не поменял. Две ориентации дают систему уравнений, и для Redis из неё выходит: эффект команды ≈ +10.77 мкс, эффект порядка ≈ +0.56 мкс — то есть порядок серий практически ни при чём. Забегая вперёд: и эта система, и посчитанная ниже статистика верны ровно в рамках своей модели, где факторов всего два — команда и позиция. Третий, дрейф среды между сериями, в неё не входит, и именно он всё и решил (разбор — сразу после таблицы).
| Образ | Δ (n=6) | sd | t | 95% ДИ |
|---|---|---|---|---|
| redis:8.8 | +10.77µs | 6.13 | 4.31 (p≈0.008) | [+4.3, +17.2] |
| valkey:8.1 | +1.94µs | 9.15 | 0.52 (p≈0.62) | [−7.7, +11.5] |
Здесь напрашивался вывод «на redis:8.8 EVAL дороже FCALL примерно на 10.8 микросекунды» — шесть прогонов одного знака, эффект в обеих ориентациях, доверительный интервал не накрывает ноль. Вывод оказался неверным, и разбираться, почему, полезнее, чем его сформулировать.
Проверка на другой машине дала дельты +3.8 и −13.9 микросекунды — знак поменялся. Повторный блочный прогон на исходной методике уже на третьей машине дал −9.7 (ориентация EVAL-первой) и −2.0 (FCALL-первой): тот же знак, что и «опровергающий» замер, то есть противоположный опубликованному.
Причина не в статистике, а в том, что именно измерялось. Разворот ориентации отделяет позиционный эффект — какая серия идёт первой, — но не отделяет дрейф среды: между началом серии EVAL и концом серии FCALL проходят секунды, за которые платформа успевает уехать. Насколько уехать, видно из самих данных выше: абсолют между сессиями гулял в диапазоне 334–396 мкс. Искомый эффект — около 10 мкс, то есть втрое меньше собственного дрейфа платформы. Усреднение двух ориентаций такую разницу не спасает, а t-тест по шести блочным прогонам исправно посчитает доверительный интервал вокруг числа, которое дрейф и породил.
Замер, который отвечает на вопрос. Дрейф вычитается, если считать разницу не между двумя длинными сериями, а внутри пары коротких батчей, идущих встык: батч EVAL, сразу батч FCALL (порядок внутри пары чередуется), разность — на каждой паре, итог — медиана разностей. Батч из 500 вызовов идёт десятки миллисекунд, поэтому на него не действует и артефакт нулей. Режим добавлен в стенд флагом -paired; двадцать пар на прогон:
| Образ | Медианы разностей по прогонам | Положительных пар |
|---|---|---|
| redis:8.8 | +4.23, −2.21, −0.17, +1.70 мкс | 16/20, 9/20, 10/20, 14/20 |
| valkey:8.1 | −0.78, +5.03 мкс | 9/20, 16/20 |
Что отсюда следует. Устойчивого эффекта нет ни на Redis, ни на Valkey: медиана колеблется вокруг нуля, знак меняется от прогона к прогону, доля положительных пар ходит от 45% до 80% — то есть ведёт себя как монетка. Разница между EVAL и FCALL на этой платформе меньше порога различимости, и заявленные ранее ~11 мкс были артефактом блочной схемы, а не свойством команд.
Гипотеза о механизме при этом остаётся правдоподобной — EVAL шлёт 138 байт тела скрипта в каждом вызове, FCALL только короткое имя, лишние ~128 байт на запрос, — но именно гипотезой: на фоне ~300 мкс round-trip через Docker Desktop эти байты не видны. Проверять её нужно там, где знаменатель на порядок меньше: на нативном Linux по loopback, где round-trip — единицы-десятки микросекунд.
Отдельный урок — методический, и он дороже самого числа. Блочное сравнение «серия A целиком, потом серия B целиком» уязвимо к дрейфу среды, и никакая статистика поверх блоков этого не чинит: ни разворот ориентации, ни t-тест, ни доверительный интервал. Если измеряемая разница сопоставима с дрейфом платформы — сравнивать нужно парами, чередуя руки и вычитая внутри пары.
Практический вывод — скучный и правильный: на этой платформе разница между EVAL и FCALL неизмерима. Она не «мала» и не «равна нулю» — она тонет в шуме, и данных, чтобы назвать её величину, нет. Это согласуется с устройством движка: после первого исполнения сервер кеширует тело EVAL-скрипта по SHA1, и повторная передача 138 байт — единственное, что остаётся от разницы. Выбирать FUNCTION стоит по причинам, которые от микросекунд не зависят вовсе: явный деплой библиотек, именованные функции, версионирование, отсутствие необходимости таскать текст скрипта по коду приложения. Строить архитектурные решения на разнице, которую не удалось измерить, тем более не нужно.
Модули — коротко
Модули расширяют сам движок: добавляют типы данных и команды на уровне C-API. Так появились полнотекстовый поиск, работа с JSON, временные ряды, вероятностные структуры. Это принципиально другой уровень вмешательства, чем Lua: модуль становится частью сервера, а не скриптом поверх него.
Здесь на этом остановимся: модули на стенде серии не проверялись, и рассуждать о них по документации было бы ровно тем, чего серия избегает.
Что дальше
Завершает серию эксплуатация и карта выбора: пул соединений, мониторинг, backup/restore и типовые инциденты — включая тот, где консьюмер-группа копит PEL, а никто не замечает.
Если начинать серию с начала: модель данных и кодировки (в том числе что stream занимает в памяти), событийный цикл (откуда берётся атомарность, которую эта статья обналичила), персистентность и репликация с Cluster — две границы durability, которые Streams не сдвигают, и память с вытеснением — что происходит, когда стриму некуда расти.
Redis и Valkey в этом разделе ведут себя одинаково: consumer groups совпали (что, впрочем, инвариант сценария, а не измерение), гонка и атомарность совпали по существу. Разницы между образами стенд не показал вовсе. Не показал он и разницы между EVAL и FCALL: парный замер даёт медиану, колеблющуюся вокруг нуля на обоих движках (см. разбор выше — там же о том, почему блочная схема сначала показала несуществующие ~11 микросекунд). Контекст форка — в отдельной статье про ValkeyСкоро.
Источники
- Официальная документация: redis.io, раздел redis.io/docs.
- Streams: redis.io/docs — Streams.
- Скрипты и функции: redis.io/docs — Scripting, Redis Functions.
- Справочник команд (
XADD,XREADGROUP,XPENDING,XCLAIM,EVAL,FCALL): redis.io/commands. - Смежное на сайте: событийный цикл Redis (почему Lua атомарен), «KV и документные: транзакций почти нет» (
MULTI/EXEC/WATCH). - Стенд: digital-cookbook/databases/redis/deep-dive (
redis:8.8,valkey/valkey:8.1, модульstreams-lua: сценарииconsumer-groups,race-without-lua,atomic-with-lua,eval-vs-function).
Комментарии