Как делать11 октября 2026 г.9 мин чтения
Базы данныхШаг 10 из 12

Redis: кэш, TTL и счётчики

Коротко

Redis — хранилище «ключ — значение» в оперативной памяти: команды выполняются за доли миллисекунды, а у каждого ключа может быть срок жизни (TTL). Поэтому его используют как кэш перед медленной базой или API, для счётчиков, лимитов запросов, коротких кодов и блокировок. Основная схема кэша — cache-aside: приложение сначала ищет данные в Redis, при промахе берёт их из источника и кладёт в кэш с TTL, а при изменении данных удаляет устаревший ключ.

Пошаговый урокУровень: СреднийRedisПрактика: ~1,5–3 ч

После материала вы сможете

  • поднять Redis 8.10 в Docker и работать со строками, хешами, множествами и списками через redis-cli
  • объяснить, как работают TTL, какие команды сбрасывают срок жизни и что происходит при нехватке памяти
  • реализовать кэш по схеме cache-aside на node-redis с инвалидацией и запасным путём при недоступном Redis
  • сделать счётчик на INCR и лимит запросов формы через MULTI с INCR и EXPIRE NX

Проверено на

Redis 8.10.2 (redis:8.10-alpine)node-redis 6.3.0Node.js 24 LTS (24.18)Express 5.3.0

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Что такое Redis и когда он нужен
  2. 02Запуск в Docker и первые команды
  3. 03Хеши, множества и списки — коротко
  4. 04TTL и вытеснение
  5. 05Cache-aside из Node.js
  6. 06Счётчики и rate limit
  7. 07Чего не хранить в Redis и частые ошибки
  8. 08Упражнения

Что такое Redis и когда он нужен

Redis — сервер структур данных в оперативной памяти. Каждое значение лежит под строковым ключом, а значение может быть строкой, хешем, списком, множеством и другими типами. Данные в памяти, поэтому чтение и запись очень быстрые; на диск Redis может сохранять снимки (RDB) или журнал команд (AOF), но это настройка, а не гарантия по умолчанию.

Медленный запрос к базе или внешнему API

Как решает Redis
кэш с TTL: повторные чтения берутся из памяти

Счётчики просмотров, лайков, попыток входа

Как решает Redis
атомарный INCR без гонок между серверами

Ограничение частоты запросов (rate limit)

Как решает Redis
счётчик на окно времени, который сам исчезает по TTL

Короткоживущие данные: коды подтверждения, сессии

Как решает Redis
SET с EX — ключ удалится сам

Очереди фоновых задач

Как решает Redis
списки и потоки; на них построены библиотеки вроде BullMQ

Redis не заменяет основную базу: это быстрый слой рядом с ней. Источник истины — PostgreSQL или другая СУБД, а в Redis лежат копии и временные данные, потеря которых не ломает бизнес. Как запустить PostgreSQL, разобрано в уроке направления; здесь его роль играет имитация медленной базы, чтобы урок не зависел от схемы.

Запуск в Docker и первые команды

Контейнер публикует порт только на 127.0.0.1: документация Redis по безопасности прямо говорит, что Redis рассчитан на доверенных клиентов в доверенной сети и его порт не должен быть доступен из интернета. Порт 56391 выбран, чтобы не конфликтовать с другим Redis на стандартном 6379.

bash
docker run -d --rm --name redis-lab -p 127.0.0.1:56391:6379 redis:8.10-alpine
docker exec redis-lab redis-cli INFO server | grep redis_version
docker exec -it redis-lab redis-cli
Запуск Redis 8.10 и интерактивного redis-cli внутри контейнера; --rm удалит контейнер после остановки
terminal
$ docker exec redis-lab redis-cli INFO server | grep redis_version
redis_version:8.10.2
$ docker exec redis-lab redis-cli PING
PONG
Реальный вывод: версия сервера и ответ на PING

Строка — базовый тип Redis: последовательность байтов, в которой может лежать текст, JSON или число. Число в строке можно атомарно увеличивать: INCR прибавляет единицу, INCRBY — заданное значение. Атомарно значит, что два сервера приложения, увеличивающие счётчик одновременно, не потеряют ни одного увеличения.

terminal
> SET site:title "Demo Booking"
OK
> GET site:title
"Demo Booking"
> SET page:views 10
OK
> INCR page:views
(integer) 11
> INCRBY page:views 5
(integer) 16
> INCR site:title
(error) ERR value is not an integer or out of range
> GET missing:key
(nil)
Реальная сессия redis-cli (вывод с --no-raw): строки, INCR и ошибка INCR по нечисловой строке

Хеши, множества и списки — коротко

Кроме строк, в повседневной работе нужны ещё три типа. Хеш — набор пар «поле — значение» под одним ключом, похож на объект или словарь. Множество — неупорядоченный набор уникальных строк, проверка принадлежности занимает O(1) — то есть не зависит от размера множества. Список — строки в порядке добавления.

terminal
> HSET service:1 title "Haircut" duration_min 60 price_kzt 8000
(integer) 3
> HGET service:1 price_kzt
"8000"
> HGETALL service:1
1) "title"
2) "Haircut"
3) "duration_min"
4) "60"
5) "price_kzt"
6) "8000"
> HINCRBY service:1 price_kzt 500
(integer) 8500
> SADD service:1:masters "aigerim" "dana" "aigerim"
(integer) 2
> SCARD service:1:masters
(integer) 2
> SISMEMBER service:1:masters "dana"
(integer) 1
> SMEMBERS service:1:masters
1) "aigerim"
2) "dana"
> RPUSH leads:recent "lead-101" "lead-102" "lead-103"
(integer) 3
> LRANGE leads:recent 0 -1
1) "lead-101"
2) "lead-102"
3) "lead-103"
> TYPE service:1
hash
> GET service:1
(error) WRONGTYPE Operation against a key holding the wrong kind of value
Продолжение сессии: хеш карточки услуги, множество мастеров без дублей, список последних заявок и ошибка WRONGTYPE

Строка

Команды
SET, GET, INCR, SET … EX
Когда использовать
кэш ответа в JSON, счётчики, коды, флаги

Хеш

Команды
HSET, HGET, HGETALL, HINCRBY
Когда использовать
объект с полями, которые читают и меняют по отдельности; счётчики по категориям

Множество

Команды
SADD, SISMEMBER, SCARD, SMEMBERS
Когда использовать
уникальные значения: теги, участники, «уже обработан»

Список

Команды
RPUSH, LPUSH, LRANGE, LTRIM
Когда использовать
лента последних событий, простые очереди

Тип закрепляется за ключом при создании: строковую команду GET к хешу Redis выполнит с ошибкой WRONGTYPE. Порядок элементов в SMEMBERS не гарантирован — множество неупорядочено. Для договорённости об именах ключей используйте префиксы через двоеточие: service:1, cache:services:v1, rl:lead-form:<ip> — так ключи легко искать и удалять группами.

TTL и вытеснение

TTL (time to live) — срок жизни ключа в секундах. Задать его можно сразу при записи (SET key value EX 60) или позже командой EXPIRE. TTL возвращает оставшиеся секунды, -1 — если срока нет, -2 — если ключа нет. Сессия ниже показывает поведение, которое чаще всего удивляет:

terminal
> SET otp:demo-user "4821" EX 60
OK
> TTL otp:demo-user
(integer) 60
> TTL site:title
(integer) -1
> TTL no:such:key
(integer) -2
> EXPIRE site:title 2
(integer) 1
# ...3 seconds later
> GET site:title
(nil)
> TTL site:title
(integer) -2
> SET visits:today 1 EX 100
OK
> INCR visits:today
(integer) 2
> TTL visits:today
(integer) 99
> SET visits:today 5
OK
> TTL visits:today
(integer) -1
> SET lock:report 1 NX EX 30
OK
> SET lock:report 1 NX EX 30
(nil)
> CONFIG GET maxmemory
1) "maxmemory"
2) "0"
> CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"
Реальная сессия: SET … EX, -1 и -2, истечение ключа, INCR сохраняет TTL, SET его сбрасывает, SET NX как простая блокировка
  • INCR, HSET, LPUSH меняют значение, но не трогают TTL; SET, GETSET и DEL перезаписывают ключ и сбрасывают срок — так описано в документации EXPIRE.
  • EXPIRE с NX ставит срок, только если его ещё нет; XX — только если он уже есть; GT и LT — только если новый срок больше или меньше текущего.
  • SET … NX EX 30 записывает ключ, только если его нет: второй вызов вернул (nil). Это основа простой блокировки «отчёт уже строится».
  • Истёкшие ключи Redis удаляет двумя путями: при обращении к ключу и фоновой проверкой случайной выборки ключей со сроком жизни.

Отдельный вопрос — что будет, когда закончится память. По умолчанию maxmemory равен 0 (без ограничения на 64-битных системах), а политика maxmemory-policy — noeviction: при достижении лимита Redis не удаляет ключи, а возвращает ошибку на команды записи. Для сервера, который работает только как кэш, документация советует allkeys-lru как хороший выбор по умолчанию.

noeviction

Что вытесняется
ничего: записи получают ошибку (значение по умолчанию)

allkeys-lru

Что вытесняется
давно не использованные ключи — частый выбор для кэша

allkeys-lfu

Что вытесняется
редко используемые ключи

volatile-lru, volatile-ttl

Что вытесняется
только ключи с TTL: давно не использованные или с самым коротким остатком

allkeys-lrm, volatile-lrm

Что вытесняется
давно не изменявшиеся ключи (появились в Redis 8.6)

Cache-aside из Node.js

Cache-aside (кэш «сбоку», ленивое заполнение) — самая распространённая схема: приложение само обращается и к кэшу, и к базе. Чтение: ищем ключ в Redis; нашли — отдаём; не нашли — читаем из источника, кладём в Redis с TTL и отдаём. Запись: меняем данные в источнике и удаляем ключ кэша, чтобы следующее чтение взяло свежие данные.

  1. 01Приложение
  2. 02Redis
  3. 03База данных
При промахе приложение само идёт в базу и кладёт ответ в Redis

Клиент — node-redis 6.3, его redis.io рекомендует для Node.js (ioredis поддерживается и документируется как более старый клиент; на нём, например, работает BullMQ из урока о фоновых задачах). В шестой версии, по руководству по переходу с v5, протокол по умолчанию — RESP3, минимальная версия Node.js — 20, а у команд появился таймаут по умолчанию 5 секунд.

bash
mkdir redis-cache && cd redis-cache
npm init -y
npm pkg set type=module
npm install redis@6.3.0
Проект на ES-модулях с node-redis 6.3.0
src/redis.js
import { createClient } from "redis";

export const redis = createClient({
  url: process.env.REDIS_URL ?? "redis://localhost:56391",
  // while reconnecting, fail a waiting command after 500 ms instead of the default 5 s
  commandOptions: { timeout: 500 },
});
redis.on("error", (err) => console.error("redis error:", err.code ?? err.name));
await redis.connect();
Одно подключение на приложение; ошибки соединения логируются, а не роняют процесс
src/db.js
// Stand-in for a slow database: in a real project this is SQL or an external API
const services = [
  { id: 1, title: "Haircut", priceKzt: 8000 },
  { id: 2, title: "Manicure", priceKzt: 10000 },
  { id: 3, title: "Massage", priceKzt: 15000 },
];
export const stats = { dbCalls: 0 };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function loadServicesFromDb() {
  stats.dbCalls++;
  await sleep(300); // a heavy query
  return structuredClone(services);
}

export async function updatePriceInDb(id, priceKzt) {
  await sleep(20);
  const service = services.find((s) => s.id === id);
  if (!service) throw new Error(`Service ${id} not found`);
  service.priceKzt = priceKzt;
}
Имитация медленной базы: 300 мс на запрос и счётчик обращений
src/cache.js
import { redis } from "./redis.js";

// Cache-aside: try the cache; on a miss load from the source and store with a TTL
export async function cached(key, ttlSeconds, load) {
  let redisOk = true;
  try {
    const hit = await redis.get(key);
    if (hit !== null) return { data: JSON.parse(hit), source: "cache" };
  } catch (err) {
    redisOk = false; // Redis is unavailable: serve from the source, don't fail the request
    console.warn(`cache read skipped: ${err.constructor.name}`);
  }
  const data = await load();
  if (redisOk) {
    await redis
      .set(key, JSON.stringify(data), { expiration: { type: "EX", value: ttlSeconds } })
      .catch((err) => console.warn(`cache write skipped: ${err.constructor.name}`));
  }
  return { data, source: "db" };
}

export async function invalidate(...keys) {
  await redis.del(keys);
}
Cache-aside: промах — в источник; Redis недоступен — тоже в источник, без ошибки для пользователя
src/catalog.js
import { cached, invalidate } from "./cache.js";
import { loadServicesFromDb, updatePriceInDb } from "./db.js";

const SERVICES_KEY = "cache:services:v1";
const SERVICES_TTL = 300; // 5 minutes: the upper bound of staleness if invalidation is missed

export function getServices() {
  return cached(SERVICES_KEY, SERVICES_TTL, loadServicesFromDb);
}

export async function changePrice(id, priceKzt) {
  await updatePriceInDb(id, priceKzt); // 1. write to the source of truth
  await invalidate(SERVICES_KEY); // 2. only then drop the stale copy
}
Каталог услуг: чтение через кэш, изменение цены — запись в базу, затем удаление ключа
src/demo-cache.js
import { redis } from "./redis.js";
import { getServices, changePrice } from "./catalog.js";
import { stats } from "./db.js";

async function timed(label, fn) {
  const t0 = performance.now();
  const { data, source } = await fn();
  const ms = (performance.now() - t0).toFixed(1);
  console.log(`${label}: ${source} in ${ms} ms, Haircut = ${data[0].priceKzt}`);
}

await redis.del("cache:services:v1");
await timed("1st read", getServices);
await timed("2nd read", getServices);
console.log("TTL:", await redis.ttl("cache:services:v1"));

await changePrice(1, 9000);
console.log("after invalidation exists:", await redis.exists("cache:services:v1"));
await timed("3rd read", getServices);
await timed("4th read", getServices);
console.log("db calls:", stats.dbCalls);

await redis.close();
Сценарий: два чтения, изменение цены, ещё два чтения
terminal
$ node src/demo-cache.js
1st read: db in 302.5 ms, Haircut = 8000
2nd read: cache in 0.7 ms, Haircut = 8000
TTL: 300
after invalidation exists: 0
3rd read: db in 308.2 ms, Haircut = 9000
4th read: cache in 0.7 ms, Haircut = 9000
db calls: 2
Реальный вывод: промах около 300 мс, попадание меньше 1 мс; после инвалидации — свежая цена и снова одно обращение к базе

Обратите внимание на запись с TTL: { expiration: { type: "EX", value: ttlSeconds } }. Вариант { EX: 300 } из многих примеров в node-redis 6.3 помечен в типах как устаревший. То же с закрытием: в определениях типов quit() помечен как устаревший в пользу close(), который дожидается выполнения отправленных команд, а destroy() обрывает их сразу.

  • Сначала запись в базу, потом удаление ключа. Если удалить ключ до записи, параллельный запрос успеет положить в кэш старые данные.
  • Удалять надёжнее, чем перезаписывать: два параллельных изменения могут записать в кэш значения в неправильном порядке.
  • TTL — страховка: даже если инвалидацию где-то забыли, устаревшие данные проживут не дольше 5 минут.
  • Версия в ключе (cache:services:v1) позволяет сменить формат данных: новая версия кода просто читает другой ключ.

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

terminal
warm: db in 305 ms
redis error: Error
redis error: ECONNREFUSED
redis error: ECONNREFUSED
redis error: ECONNREFUSED
cache read skipped: TimeoutError
redis error: ECONNREFUSED
redis stopped: db in 824 ms
Реальный вывод при остановленном Redis: ответ медленнее, но пользователь получает данные

Ещё одна проблема cache-aside — одновременный промах (cache stampede): когда популярный ключ истёк, сотни запросов одновременно идут в базу. Простые меры — разброс TTL (упражнение ниже) и блокировка на перестроение через SET … NX EX; подробный разбор стратегий инвалидации — в материале о кэшировании в направлении архитектуры.

Счётчики и rate limit

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

Rate limit (ограничение частоты) защищает форму заявки от спама: не больше 5 отправок в минуту с одного IP. Схема фиксированного окна: ключ rl:lead-form:<ip>, INCR на каждый запрос и TTL 60 секунд на первый запрос окна. Документация INCR предупреждает о гонке: если между INCR и EXPIRE процесс упадёт, ключ останется без срока и клиент будет заблокирован навсегда. Поэтому команды идут одним блоком MULTI/EXEC — Redis выполняет их подряд, не вклиниваясь командами других клиентов, — а EXPIRE с опцией NX не продлевает окно при каждом запросе.

src/counters.js
import { redis } from "./redis.js";

// Views per service per day: one hash per day, kept for 8 days
export async function countView(serviceId, day = new Date().toISOString().slice(0, 10)) {
  const key = `stats:views:${day}`;
  const [views] = await redis
    .multi()
    .hIncrBy(key, String(serviceId), 1)
    .expire(key, 8 * 24 * 3600, "NX")
    .exec();
  return views;
}

// Fixed window: at most `limit` requests per `windowSec` seconds for one client
export async function rateLimit(clientId, limit, windowSec) {
  const key = `rl:lead-form:${clientId}`;
  // MULTI/EXEC: INCR and EXPIRE are sent together, the counter can't be left without a TTL
  const [count, , ttl] = await redis
    .multi()
    .incr(key)
    .expire(key, windowSec, "NX") // NX: set the TTL only on the first request of the window
    .ttl(key)
    .exec();
  return { allowed: count <= limit, count, retryAfter: count <= limit ? 0 : ttl };
}
Счётчик просмотров по дням и лимит частоты фиксированным окном
src/demo-limits.js
import { redis } from "./redis.js";
import { countView, rateLimit } from "./counters.js";

await redis.del(["rl:lead-form:203.0.113.7", "rl:lead-form:198.51.100.20", "stats:views:2026-10-12"]);

for (const id of [1, 1, 2, 1, 3]) await countView(id, "2026-10-12");
console.log("views:", await redis.hGetAll("stats:views:2026-10-12"));
console.log("views TTL:", await redis.ttl("stats:views:2026-10-12"));

// The lead form: 5 submissions per 60 seconds per IP (addresses from documentation ranges)
for (let i = 1; i <= 7; i++) {
  const r = await rateLimit("203.0.113.7", 5, 60);
  console.log(`request ${i}: ${r.allowed ? "200 OK" : `429, retry after ${r.retryAfter}s`} (count ${r.count})`);
}
const other = await rateLimit("198.51.100.20", 5, 60);
console.log(`another IP: ${other.allowed ? "200 OK" : "429"} (count ${other.count})`);

await redis.close();
Пять просмотров и семь отправок формы с одного адреса (IP из диапазонов для документации)
terminal
$ node src/demo-limits.js
views: { '1': '3', '2': '1', '3': '1' }
views TTL: 691200
request 1: 200 OK (count 1)
request 2: 200 OK (count 2)
request 3: 200 OK (count 3)
request 4: 200 OK (count 4)
request 5: 200 OK (count 5)
request 6: 429, retry after 60s (count 6)
request 7: 429, retry after 60s (count 7)
another IP: 200 OK (count 1)
Реальный вывод: HGETALL в node-redis 6 возвращает обычный объект; 6-й и 7-й запросы получили 429
terminal
> TTL rl:lead-form:203.0.113.7
(integer) 60
> HGETALL stats:views:2026-10-12
1) "1"
2) "3"
3) "2"
4) "1"
5) "3"
6) "1"

$ docker exec redis-lab redis-cli --scan --pattern 'rl:*'
rl:lead-form:198.51.100.20
rl:lead-form:203.0.113.7
Что лежит в Redis после сценария: окно истечёт само, ключи найдены через --scan

Фиксированное окно простое, но неточное на границе: клиент может отправить 5 запросов в конце одной минуты и 5 в начале следующей. Для формы заявки этого достаточно; для платного API обычно берут скользящее окно или token bucket. Мы отдельно проверили сброс: с окном 2 секунды третий запрос был отклонён, а после паузы 2,1 секунды счёт начался заново.

Чего не хранить в Redis и частые ошибки

  • Единственную копию важных данных — заказы, платежи, заявки: со снимками RDB можно потерять последние минуты записей, при AOF с fsync раз в секунду — до секунды
  • Большие объекты в одном ключе: команды выполняются по очереди, и огромный GET задерживает остальные запросы
  • Данные без TTL «на всякий случай» — память конечна, а политика по умолчанию noeviction начнёт отвечать ошибками на запись
  • Персональные данные без необходимости и без срока жизни
  • Секреты и пароли в открытом виде; доступ к Redis — только из приложения, по паролю или ACL

Счётчик лимита никогда не сбрасывается

Причина
INCR без EXPIRE или EXPIRE вне MULTI
Что делать
MULTI с INCR и EXPIRE … NX

TTL пропал после обновления ключа

Причина
SET без EX перезаписывает ключ вместе со сроком
Что делать
передавать срок при каждой записи

Пользователи видят старую цену

Причина
кэш не инвалидирован или удалён до записи в базу
Что делать
запись в базу, затем DEL; TTL как страховка

WRONGTYPE Operation against a key…

Причина
команда не того типа или конфликт имён ключей
Что делать
префиксы в именах: cache:, rl:, stats:

Ошибки записи при заполненной памяти

Причина
политика noeviction и достигнут maxmemory
Что делать
задать maxmemory и allkeys-lru для кэша

Сайт падает, когда Redis недоступен

Причина
ошибка кэша не обработана
Что делать
запасной путь в источник и короткий таймаут

Про сравнение Redis с документными и другими NoSQL-базами — в отдельном материале направления. Главное правило урока: Redis ускоряет и разгружает основную базу, но не отвечает за сохранность данных, если вы явно не настроили для этого persistence, резервные копии и репликацию.

Упражнения

Как проверено4 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    redis-cli: строки, хеши, множества, списки, TTL, SET NX, CONFIG GET

    контейнер kb-redis-keshirovanie (в статье — redis-lab), redis:8.10-alpine = Redis 8.10.2; команды выполнены через docker exec redis-cli --no-raw, вывод в статье — реальный (строка '# ...3 seconds later' вместо sleep 3)

  • запущено, работает

    src/redis.js, db.js, cache.js, catalog.js, demo-cache.js

    Node.js 24.18, node-redis 6.3.0, node --check + запуск; промах ~300 мс, попадание <1 мс, инвалидация; запасной путь проверен остановкой контейнера (TimeoutError через 500 мс, данные из источника). Отдельно: при docker pause команда не завершилась за 20 с — таймаут node-redis действует только до отправки команды (по исходникам commands-queue.js 6.3.0), это отражено в статье

  • запущено, работает

    src/counters.js, demo-limits.js (HINCRBY + EXPIRE NX, rate limit MULTI INCR/EXPIRE NX/TTL)

    запуск на Redis 8.10.2: 5 разрешено, 6-й и 7-й — 429 с TTL 60; сброс окна проверен отдельным прогоном с окном 2 с (true true false, затем true после 2,1 с)

  • запущено, работает

    упражнения: getService + инвалидация двух ключей, ttlWithJitter, Express-middleware 429

    ex1: db → cache, TTL 120, после changePrice — db и 11000; ex2: TTL 329 312 306 302 306; ex3: Express 5.3.0, 7 запросов fetch — 201×5, 429 с Retry-After: 60 ×2

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.