Модуль 30

Distributed locks з Redis

Ось готовий урок, створений у стилі CS50, спеціально для тебе. Уяви, що ми зараз в аудиторії Сандерс-театру в Гарварді (або просто в Zoom з кавою), і розбираємо цю тему драйвово та по суті.


🎓 CS50: Distributed Locks з Redis

Привіт, світе! 👋 Радий вас бачити.

Сьогодні ми торкнемося теми, яка відділяє "просто код, що працює на моєму ноутбуці" від "систем, що тримають навантаження Чорної п’ятниці".

Ми говоритимемо про Distributed Locks (розподілені блокування) і про те, як Redis стає нашим рятівником у хаосі паралельних процесів.


1. 🔥 Вступ: Хаос Чорної п’ятниці

Уявіть ситуацію. Ви будуєте інтернет-магазин. На складі залишився один-єдиний iPhone 15 Pro Max за суперціною.

У цю саму секунду: * Андрій у Києві натискає кнопку "Купити". * Олена у Львові натискає кнопку "Купити".

Ваш код на сервері виглядає приблизно так: 1. Перевір, чи є товар (так, qty = 1). 2. Якщо є — оформи замовлення. 3. Зменш кількість товару (qty = 0).

Питання: Якщо запити Андрія та Олени прийдуть на сервер одночасно (або на два різних сервери вашого кластера), що станеться?

Обидва процеси перевірять наявність: "О, є 1 телефон!". Обидва оформлять замовлення. Обидва спишуть товар. У результаті: ви продали два телефони, а на складі був один.

Це називається Race Condition (стан гонитви). У розподілених системах, де у вас працює 10, 20 або 100 серверів одночасно, ви не можете просто використати змінну в пам’яті (mutex), бо у кожного сервера пам’ять своя.

Нам потрібен зовнішній арбітр. Хтось швидкий, надійний і такий, до якого мають доступ усі сервери. І цей арбітр — Redis.

Уявіть Redis як єдину вбиральню в літаку. Неважливо, скільки пасажирів (серверів) хочуть туди потрапити — якщо двері зачинені (Lock), решта мусить чекати.


2. 🧠 Теоретична база: Як це працює "під капотом"

Перш ніж писати код, зрозуміймо логіку. Redis працює переважно в один потік (single-threaded). Це означає, що всі команди виконуються по черзі. Це наша суперсила. Ніякі дві команди не можуть виконатися абсолютно одночасно.

Ключові поняття:

  1. Lock (Замок): Це просто запис у Redis (ключ-значення). Якщо запис існує — ресурс зайнятий.
  2. TTL (Time To Live): Час життя ключа. Це критично! Якщо ваш сервер "впаде" після того, як поставив замок, але перед тим, як його зняти — без TTL система заблокується навічно (Deadlock).
  3. Atomicity (Атомарність): Перевірка наявності замка і його встановлення мають відбуватися за один крок.

Що треба запам’ятати залізно:

Не можна робити так: "Перевір GET, якщо пусто — зроби SET". Між GET і SET може вклинитися інший процес!

Ми будемо використовувати команду, яка робить це атомарно. Раніше це була команда SETNX (SET if Not eXists), зараз це розширений SET з параметрами.


3. 🧪 Приклади: Від наївного до правильного

Для прикладів використаємо Python (псевдокод, зрозумілий кожному), але логіка ідентична для Java, Go чи JS.

❌ Спроба №1: Наївний підхід (Не робіть так!)

# Уявіть, що це функція покупки
def buy_item(item_id):
    if redis.get(f"lock:{item_id}"):
        return "Чекайте, товар обробляється іншим!"

    # <--- ТУТ МОЖЕ ВКЛИНИТИСЯ ІНШИЙ ПРОЦЕС!

    redis.set(f"lock:{item_id}", "locked")
    # ... логіка покупки ...
    redis.delete(f"lock:{item_id}")

Чому це погано: Це класичний Race Condition. Двоє можуть пройти перевірку if одночасно.

✅ Спроба №2: Атомарність (Вже краще)

Використовуємо аргумент nx=True (Only set if Not Exists).

def buy_item_safe(item_id):
    # Намагаємося встановити замок. 
    # Redis поверне True, ТІЛЬКИ якщо ключа ще не було.
    is_locked = redis.set(f"lock:{item_id}", "locked", nx=True)

    if not is_locked:
        return "Зайнято, спробуйте пізніше!"

    try:
        # ... логіка покупки ...
        pass
    finally:
        redis.delete(f"lock:{item_id}")

Що тут не так? Уявіть, що під час виконання логіки покупки світло вимкнули, і сервер згорів. Код не дійшов до finally. Ключ lock:item_id залишився в Redis назавжди. Товар більше ніхто ніколи не купить.

🚀 Спроба №3: Production Ready (З TTL та Унікальністю)

import uuid

def buy_item_pro(item_id):
    my_unique_id = str(uuid.uuid4()) # Унікальний підпис цього процесу

    # 1. Атомарно ставимо замок + авто-видалення через 10 секунд (ex=10)
    is_locked = redis.set(f"lock:{item_id}", my_unique_id, nx=True, ex=10)

    if not is_locked:
        return "Ресурс зайнятий"

    try:
        # ... критична секція (купівля товару) ...
        # Важливо: вона має виконуватися швидше, ніж 10 секунд!
        print("Купуємо товар...")
    finally:
        # 2. Безпечне видалення
        # Видаляємо замок ТІЛЬКИ якщо там записаний НАШ ID.
        # (Це робиться Lua-скриптом для атомарності, але логіка така):
        if redis.get(f"lock:{item_id}") == my_unique_id:
            redis.delete(f"lock:{item_id}")

Чому ми перевіряємо ID перед видаленням? Це захист від ситуації, коли ваш процес "завис" на 15 секунд. 1. Ваш замок (на 10 сек) протух і зник сам. 2. Інший процес прийшов і поставив свій замок. 3. Ваш процес "прокинувся" і виконав redis.delete. Без перевірки ID ви видалите чужий замок! 😱


4. 🛠 Практична частина

Час розім’яти пальці. Відкрийте термінал або уявіть це.

Завдання 1: "Швидкий палець" Створіть скрипт, який у циклі 100 разів намагається захопити лок. Запустіть цей скрипт у двох різних терміналах одночасно. Порахуйте, скільки разів кожному вдалося захопити ресурс. Мета: Переконатися, що сума успішних захоплень ніколи не перевищує 100 (або логічного ліміту), і немає перетинів.

Завдання 2: "Симуляція краху" Напишіть код, який ставить лок з TTL 5 секунд, але потім "засинає" (sleep) на 20 секунд. Спробуйте запустити другий скрипт, поки перший спить. Питання: Через скільки секунд другий скрипт зможе отримати доступ? (Має бути через 5, а не 20).

Завдання 3: "Чужий замок" Спробуйте реалізувати помилку, про яку я говорив у прикладі №3. 1. Процес А ставить лок (TTL 2 сек), спить 5 сек, потім видаляє лок. 2. Процес Б стартує через 3 сек після А. Спостереження: Перевірте логи Redis (MONITOR), чи видалив Процес А замок, який вже належав Процесу Б?

Завдання 4: Реальний кейс У вас є Cron-job (завдання за розкладом), який відправляє e-mail розсилку щоранку о 9:00. Ваш бекенд запущено на 4 серверах. Як використати Redis Lock, щоб користувачі не отримали 4 однакові листи?


5. 💡 Мислення як у розробника

Ви тепер знаєте механіку. Але як думає сеньйор?

  1. "Чи справді мені це треба?" Блокування сповільнюють систему. Якщо можна спроєктувати базу даних так, щоб вона сама рулила конфліктами (наприклад, Optimistic Locking в SQL), іноді це краще.
  2. Час життя (TTL) — це мистецтво. Занадто мало — замок зникне, поки ви ще працюєте (небезпечно). Занадто багато — якщо сервер впаде, ресурс буде недоступним довго. Порада: Ставте із запасом, але моніторте виконання.
  3. Бібліотеки. В реальному житті ми рідко пишемо сирі команди SET NX. В Python ми візьмемо redis-py lock, в Java — Redisson. Вони реалізують алгоритм Redlock (надійніша версія для кластера Redis), автоматичне подовження життя замка (watchdog) та інші фішки.

Типова помилка новачка: Думати, що Redis Lock — це 100% гарантія. У дуже складних розподілених системах (де годинники на серверах можуть розходитися) навіть це може дати збій (детальніше читайте "Martin Kleppmann on Redlock"). Але для 99% задач — це саме те, що треба.


6. 🧩 Підсумок

Отже, що ми сьогодні поклали в нашу "валізу знань":

  • Ми зрозуміли, що Distributed Lock — це спосіб координації серверів, щоб вони не билися за один ресурс.
  • Ми дізналися, що команда SET resource_name token NX PX time — це наш найкращий друг.
  • Ми навчилися, що видаляти замок треба обережно, перевіряючи, чи він все ще наш.

Тепер ви вмієте: Запобігати продажу неіснуючих товарів і подвійним списанням грошей. Ви зробили інтернет трохи безпечнішим і стабільнішим місцем.

Що далі? А що, якщо навантаження таке велике, що один Redis не справляється? Або сам Redis "впав"? Наступного разу ми поговоримо про Redis Sentinel, Cluster та те, як масштабувати пам'ять.

А поки що — це був CS50. До зустрічі! 👨‍💻