У високонавантажених розподілених системах оперативна пам'ять слугує головним рубежем оптимізації затримок. In-memory сховище Redis виконує команди в однопотоковому циклі обробки подій (Event Loop), гарантуючи абсолютну атомарність кожної окремої операції над структурами даних (Strings, Hashes, Sorted Sets, Streams). Мережева взаємодія між Node.js та Redis базується на текстово-бінарному протоколі RESP (REdis Serialization Protocol).
Під час взаємодії Node.js та Redis ключовим фактором, що обмежує пропускну здатність, є мережева затримка переходу (Round Trip Time, RTT). Якщо додаток послідовно надсилає 10 команд із очікуванням кожної відповіді окремо, затримка мережі в 1 мс призведе до сумарного часу виконання щонайменше 10 мс — навіть якщо виконання кожної інструкції в оперативній пам'яті Redis триває частки мікросекунди.
Для оптимізації пропускної здатності використовуються два фундаментально різні механізми:
MULTI / EXEC): гарантують сувору ізольованість і послідовність виконання. Жодна операція від паралельних сесій не може виконатися всередині блоку MULTI...EXEC. Проте транзакції потребують оптимістичних блокувань через команду WATCH для контролю стану ключів.При класичному підході Cache-Aside ключам призначається фіксований час життя (TTL). У момент одночасного закінчення TTL у популярного гарячого ключа, до якого надходять сотні запитів на секунду, кеш повертає null усім клієнтам одночасно. Виникає ефект «набігаючого натовпу» (Cache Stampede / Dogpiling / Thundering Herd): сотні паралельних процесів Node.js одночасно б'ють у реляційну СУБД з однаковим важким запитом, спричиняючи насичення пулу з'єднань, тротлінг CPU та падіння бази даних.
Для нейтралізації цієї проблеми в розподілених архітектурах застосовується алгоритм ймовірнісного раннього оновлення (Probabilistic Early Expiration / XFetch) (дослідження Vattani, Chierichetti, Lowenstein). Замість жорсткого блокування потоків алгоритм динамічно обчислює математичну ймовірність того, що поточний клієнт повинен асинхронно оновити значення кешу до фактичного закінчення терміну TTL:
now - (beta * delta * ln(random())) > expiryде:
delta — фактичний час (у мілісекундах), витрачений на обчислення значення під час попереднього розрахунку.beta — коефіцієнт випередження (за замовчуванням beta = 1.0; значення більше 1.0 робить оновлення більш раннім).random() — випадкове число з рівномірним розподілом у діапазоні (0, 1].expiry — часова мітка закінчення терміну дії TTL у мілісекундах (Unix Epoch).Якщо умова виконується, один із клієнтів бере на себе фонове асинхронне оновлення, тоді як решта вхідних запитів продовжують миттєво отримувати валідне значення з кешу із субмілісекундною затримкою.
Взаємодія з розподіленим кешем та механізмами координації доступу базується на розмежуванні шляхів читання та критичних секцій:
[ Рівень додатку Node.js ]
|
+---------------+---------------+
| |
v v
[ Читання з кешу ] [ Критична секція ]
| |
v v
+---------------+ +---------------+
| Redis Кеш | | Розподілений |
| (XFetch тест) | | замок(Redlock)|
+---------------+ +---------------+
/ \ |
Hit & Свіжий Hit & Потребує оновлення v
/ \ +-----------------+
v v | SET key val |
[ Відповідь ] [ Фонова задача ] | NX PX 10000 |
| +-----------------+
v |
[ Запит до БД ] Кворум отримано?
| / \
v Так Ні
[ Оновлення кешу ] | |
v v
[ Робота секції ] [ Повтор / Помилка ]
|
v
[ Зняття замка ]
(Lua скрипт перевірки токена)HINCRBY) стискає затримку із сотень мілісекунд у межі одного мережевого кадру.Нижче наведено промислову архітектуру на базі ioredis з реалізацією алгоритму ймовірнісного кешування XFetch та надійного розподіленого замка з атомарним Lua-скриптом і фоновим сторожовим таймером (Watchdog).
import Redis from "ioredis";
export interface CacheEnvelope<T> {
value: T;
computedAt: number;
computeDurationMs: number;
ttlMs: number;
}
export class ProbabilisticCacheService {
constructor(
private readonly redis: Redis,
private readonly defaultBeta = 1.0
) {}
/**
* Обчислення необхідності раннього випереджувального оновлення (XFetch)
*/
private shouldRefreshEarly(
computedAt: number,
computeDurationMs: number,
ttlMs: number,
beta: number
): boolean {
const expiryTimestamp = computedAt + ttlMs;
const now = Date.now();
// Якщо фізичний строк дії вже вичерпано
if (now >= expiryTimestamp) {
return true;
}
// Випадкова величина в діапазоні (0, 1]
const random = Math.max(Math.random(), 0.0001);
// Формула XFetch: now - (beta * delta * ln(random)) > expiry
const probabilisticThreshold = now - (beta * computeDurationMs * Math.log(random));
return probabilisticThreshold > expiryTimestamp;
}
/**
* Отримання з кешу з автоматичним імовірнісним упередженням
*/
public async getOrCompute<T>(
key: string,
ttlSeconds: number,
computeFn: () => Promise<T>,
beta = this.defaultBeta
): Promise<T> {
const rawData = await this.redis.get(key);
if (rawData) {
try {
const envelope: CacheEnvelope<T> = JSON.parse(rawData);
const needsRefresh = this.shouldRefreshEarly(
envelope.computedAt,
envelope.computeDurationMs,
envelope.ttlMs,
beta
);
if (needsRefresh) {
// Асинхронно запускаємо фонове оновлення без блокування клієнта
this.triggerBackgroundRefresh(key, ttlSeconds, computeFn).catch(() => {
// Помилки фонового оновлення логуються в телеметрію
});
}
return envelope.value;
} catch (parseError) {
// У разі пошкодження JSON перераховуємо заново
}
}
// Cache Miss: синхронний розрахунок і збереження
return this.executeAndStore(key, ttlSeconds, computeFn);
}
private async triggerBackgroundRefresh<T>(
key: string,
ttlSeconds: number,
computeFn: () => Promise<T>
): Promise<void> {
// Семафор захисту від дублювання фонових розрахунків
const lockAcquired = await this.redis.set(`refresh_lock:${key}`, "1", "NX", "EX", 15);
if (!lockAcquired) {
return; // Інший под уже виконує фонове оновлення
}
try {
await this.executeAndStore(key, ttlSeconds, computeFn);
} finally {
await this.redis.del(`refresh_lock:${key}`);
}
}
private async executeAndStore<T>(
key: string,
ttlSeconds: number,
computeFn: () => Promise<T>
): Promise<T> {
const start = Date.now();
const value = await computeFn();
const duration = Date.now() - start;
const envelope: CacheEnvelope<T> = {
value,
computedAt: Date.now(),
computeDurationMs: Math.max(duration, 1),
ttlMs: ttlSeconds * 1000,
};
// Зберігаємо в Redis із запасом TTL, щоб ключ залишався доступним для XFetch
const storageTtl = ttlSeconds * 2;
await this.redis.set(key, JSON.stringify(envelope), "EX", storageTtl);
return value;
}
}import Redis from "ioredis";
import { randomUUID } from "node:crypto";
export class DistributedLockService {
// Атомарний Lua-скрипт: видаляти ключ тільки за умови збігу токена власника
private static readonly RELEASE_LOCK_LUA = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
// Атомарний Lua-скрипт автопродовження блокування (Watchdog)
private static readonly EXTEND_LOCK_LUA = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
`;
constructor(private readonly redis: Redis) {}
/**
* Захоплення розподіленого замка з валідацією токена та сторожовим таймером
*/
public async acquire(
resourceKey: string,
ttlMs = 5000,
acquireTimeoutMs = 3000,
retryIntervalMs = 50
): Promise<{ token: string; release: () => Promise<boolean> }> {
const lockKey = `lock:${resourceKey}`;
const token = randomUUID();
const deadline = Date.now() + acquireTimeoutMs;
while (Date.now() < deadline) {
// SET resource_key token NX PX ttl
const acquired = await this.redis.set(lockKey, token, "PX", ttlMs, "NX");
if (acquired === "OK") {
// Запуск фонового таймера (Watchdog) для подовження строку замка
const watchdogTimer = this.startWatchdog(lockKey, token, ttlMs);
const release = async (): Promise<boolean> => {
clearInterval(watchdogTimer);
const result = await this.redis.eval(
DistributedLockService.RELEASE_LOCK_LUA,
1,
lockKey,
token
);
return result === 1;
};
return { token, release };
}
// Пауза перед наступною спробою
await new Promise((resolve) => setTimeout(resolve, retryIntervalMs));
}
throw new Error(`Failed to acquire distributed lock for [${resourceKey}] within ${acquireTimeoutMs}ms`);
}
private startWatchdog(lockKey: string, token: string, ttlMs: number): NodeJS.Timeout {
// Продовжуємо замок кожні ttlMs / 3
const interval = Math.max(Math.floor(ttlMs / 3), 500);
const timer = setInterval(async () => {
try {
const renewed = await this.redis.eval(
DistributedLockService.EXTEND_LOCK_LUA,
1,
lockKey,
token,
ttlMs.toString()
);
if (renewed !== 1) {
// Замок втрачено або перехоплено
clearInterval(timer);
}
} catch (err) {
// Мережевий збій продовження
}
}, interval);
// Unref таймера, щоб не блокувати завершення процесу Node.js
timer.unref();
return timer;
}
}Зчитування конверта з кешу. Математичний розрахунок порогу на основі тривалості розрахунку та логарифма випадкового числа.
Якщо поріг перевищено, фоновий воркер бере семафор і надсилає запит до СУБД без затримки для клієнта.
Для операцій зміни стану клієнт виконує атомарну команду SET NX PX із криптографічним UUID-токеном.
Періодичний виклик Lua-скрипта PEXPIRE кожні TTL/3 запобігає достроковому зняттю замка при довгому I/O.
Атомарне видалення ключа в Redis за допомогою Lua-скрипта виключно за умови збігу токена власника.
import Redis, { RedisOptions } from "ioredis";
export function createProductionRedisClient(): Redis {
const options: RedisOptions = {
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD,
db: 0,
// Запобігання зависанню запитів під час розриву мережі
maxRetriesPerRequest: 3,
enableReadyCheck: true,
autoResubscribe: true,
autoResendUnfulfilledCommands: true,
// Експоненціальний алгоритм повторного підключення
retryStrategy(times: number) {
const delay = Math.min(times * 100, 3000);
return delay;
},
// Перепідключення під час специфічних мережевих помилок
reconnectOnError(err: Error) {
const targetError = "READONLY";
if (err.message.includes(targetError)) {
// Відбувається під час failover репліки на майстер у кластері
return true;
}
return false;
},
// Налаштування сокетів Keep-Alive та таймаутів
keepAlive: 10000,
connectTimeout: 5000,
commandTimeout: 2000,
};
const client = new Redis(options);
client.on("connect", () => console.info("[Redis] TCP Connection established"));
client.on("ready", () => console.info("[Redis] Client ready to process commands"));
client.on("error", (err) => console.error("[Redis] Connection error:", err.message));
client.on("close", () => console.warn("[Redis] Connection closed"));
return client;
}Воркер захоплює замок із TTL 3 секунди через SET lock_key 1 NX PX 3000. Виконання операцій затягнулося через збирання сміття V8 GC або затримки мережі на 4 секунди. Redis автоматично видалив ключ після закінчення TTL. Другий процес успішно захопив цей самий замок. Перший процес оговтався і викликав redis.del('lock_key'), випадково видаливши замок другого процесу! Рішення: використовувати унікальний UUID-токен і знімати замок виключно через атомарний Lua-скрипт із перевіркою токена.
Розробник огортає 500 незалежних команд SET або HSET у блок MULTI...EXEC, вважаючи, що це оптимізує запис. На відміну від конвеєризації (Pipelining), блок MULTI змушує Redis буферизувати чергу команд в оперативній пам'яті сервера на весь час транзакції, створюючи навантаження на RAM та блокуючи інші атомарні операції. Якщо команди не потребують суворої транзакційної неподільності, завжди застосовуйте pipeline().
Розгортання блокувань SET NX PX на стандартному кластері Redis Sentinel з асинхронною реплікацією. Процес захоплює замок на майстрі. До того як операція реплікувалася на слейв, майстер падає. Sentinel перемикає роль майстра на репліку. Другий процес звертається до нового майстра й успішно захоплює той самий замок! Для критичних операцій необхідний повноцінний алгоритм Redlock по N незалежних майстрах або перехід на консенсусні системи (etcd, ZooKeeper).
Якщо у клієнта ioredis не налаштовано commandTimeout, а на боці Redis виконується важка операція (наприклад, KEYS * або неоптимальний скрипт Lua), проміси всіх вхідних запитів у Node.js зависають. Черга команд накопичується в купі пам'яті (Heap), спричиняючи аварійну зупинку додатку через Out Of Memory (OOM).
Генерація криптографічно стійкого UUIDv4 для ідентифікації процесу-власника
Виконання атомарної інструкції SET key token NX PX ttl у Redis із перевіркою статусу
Реєстрація асинхронного таймера для періодичного подовження TTL через Lua PEXPIRE
Виконання цільової бізнес-логіки з гарантією монопольного доступу до ресурсу
Атомарне видалення ключа тільки за умови збігу токена власника з очищенням таймера Watchdog