Как стать автором
Поиск
Написать публикацию
Обновить

Система заказов: решаем проблему конкуренции без очередей

Уровень сложностиСложный
Время на прочтение22 мин
Количество просмотров610

Вводная часть

При разработке локального маркетплейса Django, и одна из задач, с которой пришлось поработать – это система бронирования товаров при создании заказа. Согласно требованиям, система должна уметь бронировать товары за покупателем, давать ему немного времени на оплату, а потом — если он не успел — освобождать эти товары для других.

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

Kafka first: как чуть не построили микросервисный комбайн

Первая мысль была – “надо все делать по уму” То есть: события, очередь, микросервисы и, конечно, Kafka. Мы начали проектировать систему с отдельным микросервисом, отвечающим за управление остатками. Вот как это должно было работать:

  • Основной Django-бэкенд общался с микросервисом остатков через gRPC с protobuf. Каждый поступивший запрос на создание заказа превращался в список словарей вида {product_id: amount, request_id: uuid} — по одному словарю на каждую товарную позицию. Эти позиции из множества заказов параллельно отправлялись в Kafka‑топик. Для каждого product_id формировалось отдельное сообщение, которое попадало в соответствующую партицию, например, по product_id% N. Это позволяло направлять все обращения к одному товару в одну и ту же очередь, избегая «гонок»: внутри партиции сообщения обрабатывались строго в порядке поступления (FIFO). Получив сообщение, микросервис сразу проверял наличие товара в Redis: если нужное количество было доступно, оно резервировалось (вычиталось из Redis), и позиция помечалась как успешная; если товара не хватало — резервирование не происходило, и позиция считалась неуспешной.

Рис. 1. Распараллеливание заказов в Kafka и проверка остатков
Рис. 1. Распараллеливание заказов в Kafka и проверка остатков
  • После этого каждое сообщение отправлялось в другой топик Kafka, где происходила сборка данных, относящихся к одному заказу. Сообщения группировались по request_id с использованием хеша от комбинации request_id и списка товаров ({product_id: amount}). Когда хеш совпадал, это означало, что заказ полностью обработан. Механизм напоминал TCP-пакетирование: пока не получен полный набор сообщений с одним хешем, заказ считался незавершённым.

Рис. 2. Сборка данных о наличии товаров заказа по хешу
Рис. 2. Сборка данных о наличии товаров заказа по хешу
  • Когда все сообщения по одному заказу были собраны, микросервис проверял, есть ли среди них неуспешные и для каких product_id. Если все позиции были успешны, микросервис отправлял в Django-бэкенд подтверждение заказа. Если среди них были неуспешные – заказ отклонялся, сделанные ранее брони отменялись, а сообщения, относящиеся к такому заказу, направлялись в топик Kafka для отмены. Там происходило восстановление данных в Redis: зарезервированные количества возвращались обратно по ключам product_id. Также микросервис возвращал Django-бэкенду список товаров, которых не хватило (их product_id и доступное количество).

Рис. 3. Реакция системы на неуспешные позиции в заказе
Рис. 3. Реакция системы на неуспешные позиции в заказе

На схеме система выглядела мощной и масштабируемой. Мы получали распараллеливание обработки товаров, неблокирующую архитектуру и минимизацию race conditions за счёт партиционирования. Но чем дольше мы обсуждали эту реализацию, тем яснее становилось, что она слишком громоздкая для нашей задачи.

Рис. 4. Вся схема целиком
Рис. 4. Вся схема целиком

Во-первых, нам пришлось бы писать сложную систему агрегации результатов из разных топиков, чтобы отслеживать статус каждого запроса.

Во-вторых, зависимость от Kafka сильно увеличивала сложность инфраструктуры: нужно было обеспечивать её отказоустойчивость, следить за партициями, настраивать масштабирование.

Кроме того, полноценная обработка отказов и гарантия согласованности между Redis, Kafka и базой данных превращались в отдельный проект внутри проекта.

Наконец, такая архитектура плохо подходила для локальной разработки и отладки: поднять весь стек из Kafka, Redis, gRPC и нескольких сервисов ради отладки одной логики — не самый удобный процесс.

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

Система бронирования на основе Redis + Lua

В первом варианте системы бронирования с микросервисом остатков на основе Kafka мы хотели использовать Redis для атомарности операций. В процессе обсуждения возникла идея, что всю необходимую логику можно реализовать прямо внутри Redis – без Kafka, очередей и отдельного микросервиса.

Во время изучения возможностей Redis мы обратили внимание на Lua-скрипты – механизм, позволяющий запускать атомарные операции непосредственно в Redis. Хотя язык Lua может показаться непривычным Python-разработчикам, он оказался весьма подходящим инструментом для решения нашей задачи.

Lua-скрипты в Redis

Lua-скрипты позволяют запускать код прямо на стороне Redis, не переключаясь между клиентом и сервером. Они исполняются атомарно, то есть никто не сможет обратиться к Redis в процессе выполнения скрипта — сервер заблокирован до конца выполнения: либо вся операция выполнена, либо ничего не произошло (документация Redis).

Мы написали такой Lua-скрипт для бронирования:

lua_reservation_script_text = """

local available_key = KEYS[1]

local reserved_key = KEYS[2]

local required_amount = tonumber(ARGV[1])

if redis.call("EXISTS", available_key) == 0 or redis.call("EXISTS", reserved_key) == 0 then

    return {1, false}

end

local current_available = tonumber(redis.call("GET", available_key))

if current_available >= required_amount then

    redis.call("INCRBY", available_key, -required_amount)

    redis.call("INCRBY", reserved_key, required_amount)

    return {2, false}

else

    return {3, current_available}

end

"""

Он проверяет, есть ли в Redis два ключа для конкретного product_id: один содержит количество доступного товара, другой – количество уже забронированного. Если оба ключа существуют, скрипт сравнивает доступное количество с требуемым, и, если его хватает, уменьшает число доступных единиц и увеличивает количество забронированных.

Работа с Lua оказалась не такой гладкой, как хотелось бы. Скрипты в Lua возвращают значения, которые на первый взгляд кажутся понятными, но при трансляции типов данных Lua в Python-типы могут возникнуть сложности. Например, в Lua false не конвертируется в Python False так, как ожидается: в зависимости от ситуации можно получить None, 0, пустую строку и другие неожиданные значения. Кроме того, true и false в Lua возвращаются как вторые элементы списка, а Python-клиент Redis может неявно преобразовывать их в строки или числа.

Однако числа (кроме нуля) транслируются корректно, поэтому мы использовали IntEnum, чтобы значения и в Lua, и в Python были однозначно сопоставимы.

from enum import IntEnum

class ProductAmountCacheResults(IntEnum):

    KEY_NOT_EXIST = 1

    ENOUGH_AMOUNT = 2

    NOT_ENOUGH_AMOUNT = 3

Модель для логирования товарных остатков

Помимо стандартных Django-моделей товара, заказа и товара в заказе (которые очень похожи в разных Django-магазинах), у нас есть отдельная модель для логирования изменений остатков товаров на складе. Это транзакционная таблица, в которую записываются все изменения количества: бронирование, отмена бронирования, поступление новой партии товара. Логи позволяют агрегировать и анализировать все действия с остатками и при необходимости восстанавливать точную историю изменений.

class StockLogTypeChoices(models.IntegerChoices):

    BOOKING = 1, "Booking"

    CANCEL_BOOKING = 2, "Cancel Booking"

    STOCK_IN = 3, "Stock In"

class ProductStockLog(models.Model):

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

    product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name="stock_logs")

    created_at = models.DateTimeField(auto_now_add=True)

    amount = models.IntegerField()

    type = models.PositiveSmallIntegerField(choices=StockLogTypeChoices)

    user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="stock_logs", blank=True, null=True)

Про архитектуру проекта

Во вьюшках мы не работаем с моделями напрямую – вместо этого в проекте используется слоистая архитектура, в которой между представлениями (views) и моделями находится сервисный слой. Он инкапсулирует бизнес-логику и предоставляет интерфейсы для работы с данными. Такой подход помогает нам изолировать бизнес-логику, избежать её дублирования, упростить тестирование и улучшить модульность приложения.

Сервисный слой важен в нашем проекте по двум причинам. Во-первых, у нас большое количество моделей, и если каждый разработчик будет взаимодействовать с ними напрямую и по-разному (например, кто-то будет заполнять не все поля, кто-то забудет вызвать нужный метод), велика вероятность ошибок и расхождений в логике. Во-вторых, наши модели тесно связаны между собой: действия над одной сущностью часто требуют каскадных изменений других, обращения к Redis или вызовов внешних API. Поэтому нам важно контролировать порядок взаимодействия между объектами — цепочки создания, обновления или удаления не могут быть произвольными.

Слой реализован в виде классов-контроллеров, каждый из которых отвечает за работу с конкретной моделью. В зависимости от положения модели в иерархии бизнес-объектов, контроллер одной модели может вызывать контроллер другой связанной модели.

Пример контроллера модели заказа (Order):

class OrderController:

    def init(self, obj: Order) -> None:

        self.obj = obj

        self._parent_controller = None  # вышестоящий контролер

        self._orderproduct_controller_list = None  # нижестоящие контроллеры товаров заказа

        self._payment_controller = None  # нижестоящий контролер платежки

        self._delivery_controller = None  # нижестоящий контролер доставки

    def initstate(self) -> None:

        """Инициализируем переменные экземпляра с помощью сеттеров и геттеров. Ниже приведен пример геттера и сеттера для orderproduct_controller_list."""

        orderproduct_controller_list = [

            OrderProductController(orderproduct) for orderproduct in self.obj.orderproducts.all()

        ]

        self._orderproduct_controller_list = orderproduct_controller_list

        self._payment_controller = PaymentController(self.obj.payment)

        self._delivery_controller = DeliveryController(self.obj.delivery)

    @property  # геттер

    def orderproduct_controller_list(self) -> list["OrderProductController"]:

        if self._orderproduct_controller_list is None:

            self._init_state()

        return self._orderproduct_controller_list

    @orderproduct_controller_list.setter  # сеттер

    def orderproduct_controller_list(self, value: list["OrderProductController"]) -> None:

        self._orderproduct_controller_list = value

    @classmethod

    def create(cls, <атрибуты для заказа: покупатель, товары и их количества и др.>) -> Self:

        <валидация входных данных, создание связанных сущностей через их контроллеры>

    def get_obj(self) -> Order:

        return self.obj

    def change_status(self, new_status: int) -> None:

        <бизнес-логика смены статуса self.obj и статусов связанных сущностей>

    <и другие нужные методы>

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

ProductStockLogController и Lua-скрипт

ProductStockLogController работает с моделью ProductStockLog, управляя её экземпляром, и одновременно инкапсулирует логику доступа к Redis и исполнения Lua-скриптов. Lua-скрипт для бронирования кешируется в классовой переменной lua_reservation_script, чтобы не регистрировать его заново при каждом вызове.

from typing import Self

from django.conf import settings

import redis

from redis.commands.core import Script

# импорты нужных БД моделей

class ProductStockLogController:

    redis_host = settings.REDIS_HOST

    redis_port = settings.REDIS_PORT

    redis_db = settings.REDIS_DB

    redis_client = None

    lua_reservation_script = None

    def init(self, obj: ProductStockLog) -> None:

        self.obj = obj

    @classmethod

    def getredis_client(cls) -> redis.StrictRedis:

        if not cls.redis_client:

            cls.redis_client = redis.StrictRedis(host=host, port=port, db=db)

        return cls.redis_client

    @classmethod

    def getlua_reservation_script(cls) -> Script:

        if not cls.lua_reservation_script:

            redis_client: redis.StrictRedis = cls._get_redis_client()

            cls.lua_reservation_script = redis_client.register_script(lua_reservation_script_text)

        return cls.lua_reservation_script

    @classmethod

    def executelua_reservation_script(

      cls, available_key: str, reserved_key: str, required_amount: int

    ) -> tuple[int, int | None]:

        lua_script: Script = cls._get_lua_reservation_script()

        result, current_amount = lua_script(keys=[available_key, reserved_key], args=[required_amount])

        return result, current_amount

Мы не создаём объекты напрямую из представлений, а используем классовый метод create, который работает как альтернативный конструктор и возвращает экземпляр контроллера, привязанный к конкретному экземпляру модели. Такой подход позволяет централизовать создание логов по остаткам, избежать дублирования кода и подготовить контроллер к выполнению последующих операций (например, обновлению кеша).

@classmethod

    def create(cls, product: Product | int, user: User, amount: int, type: int) -> Self:

        kwargs = {}

        if isinstance(product, Product):

            kwargs["product"] = product

        elif isinstance(product, int):

            kwargs["product_id"] = product

        product_stock_log = ProductStockLog.objects.create(**kwargs, amount=amount, type=type, user=user)

        return cls(product_stock_log)

Метод reserve_stock

Публичный интерфейс reserve_stock вызывается для бронирования товара. Внутри него сначала выполняется Lua-скрипт. Если нужные ключи в Redis не найдены, происходит их инициализация на основе агрегации логов из базы данных, после чего скрипт запускается повторно. Если товара недостаточно, возбуждается исключение. При успешной проверке создаётся новый ProductStockLog.

Для такого порядка действий метод должен уметь:

  • генерировать Redis-ключи, включающие product_id в своём имени;

  • агрегировать данные по таблице ProductStockLog из БД – на случай, если информация по нужному product_id в Redis отсутствует.

Обе задачи вынесены в отдельные методы контроллера, которые вызываются из метода reserve_stock.

from django.db.models import Sum

class ProductStockLogController:

    <...>


    @classmethod

    def reserve_stock(cls, product_id: int, required_amount: int, user: User) -> None:

        available_amount_cache_key: str = cls._get_available_amount_cache_key(product_id)

        reserved_amount_cache_key: str = cls._get_reserved_amount_cache_key(product_id)

        redis_client: redis.StrictRedis = cls._get_redis_client()

        result, current_amount = cls._execute_lua_reservation_script(

            available_amount_cache_key, reserved_amount_cache_key, required_amount

        )  # первый вызов Lua-скрипта 

        if result == ProductAmountCacheResults.KEY_NOT_EXIST:

            aggregated_available_amount: int = cls._aggregate_product_stock_logs_by_amount(product_id)

            aggregated_reserved_amount: int = cls._aggregate_product_stock_logs_by_booking(product_id)

            redis_client.mset({

                available_amount_cache_key: aggregated_available_amount,

                reserved_amount_cache_key: aggregated_reserved_amount,

            })

            result, current_amount = cls._execute_lua_reservation_script(

                available_amount_cache_key, reserved_amount_cache_key, required_amount

            )  # повторный вызов Lua-скрипта

        if result == ProductAmountCacheResults.NOT_ENOUGH_AMOUNT:

            raise ProductAmountError(

                product_id=product_id,

                required_amount=required_amount,

                current_amount=current_amount,

                error_message=f"Cant reserve product {product_id} with amount {required_amount}",

            )

        elif result == ProductAmountCacheResults.ENOUGH_AMOUNT:

            cls.create(product=product_id, user=user, amount=-required_amount, type=StockLogTypeChoices.BOOKING)

            return

    raise ValueError("Result mismatch")

    @classmethod

    def getavailable_amount_cache_key(cls, product_id: int) -> str:

        return f"product_available_amount_{product_id}"

    @classmethod

    def getreserved_amount_cache_key(cls, product_id: int) -> str:

        return f"product_reserved_amount_{product_id}"

    @classmethod

    def aggregateproduct_stock_logs_by_amount(cls, product_id: int) -> int:

        return (

            ProductStockLog.objects

            .filter(product_id=product_id)

            .aggregate(total_amount=Sum("amount"))["total_amount"] or 0

        )

    @classmethod

    def aggregateproduct_stock_logs_by_booking(cls, product_id: int) -> int:

        return -(

            ProductStockLog.objects

            .filter(

                product_id=product_id,

                type__in=[StockLogTypeChoices.CANCEL_BOOKING, StockLogTypeChoices.BOOKING],

            )

            .aggregate(total_amount=Sum("amount"))["total_amount"] or 0

        )

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

Рис. 5. Алгоритм бронирования товаров с использованием Redis и Lua-скрипта
Рис. 5. Алгоритм бронирования товаров с использованием Redis и Lua-скрипта

Другие полезные Lua-скрипты

Реализация атомарного бронирования с помощью Lua — это только начало. Подобным образом в контроллер можно добавить и другие скрипты для управления товарными остатками. Это позволит централизовать всю критичную бизнес-логику работы со складом на уровне Redis.

Чтобы не перегружать статью, обсудим только сами скрипты, а соответствующие интерфейсы в контроллере вы можете реализовать по аналогии с методом reserve_stock.

Вот скрипт, который выполняет обратную операцию по сравнению с бронированием: возвращает указанное количество товаров обратно в доступный пул и уменьшает значение в числе забронированных товаров.

lua_increment_stock_script_text = """

local available_key = KEYS[1]

local reserved_key = KEYS[2]

local amount_to_add = tonumber(ARGV[1])

if redis.call("EXISTS", available_key) == 0 or redis.call("EXISTS", reserved_key) == 0 then

    return 1

end

redis.call("INCRBY", available_key, amount_to_add)

redis.call("INCRBY", reserved_key, -amount_to_add)

return 4

"""

Чтобы интерпретировать результат выполнения, в ProductAmountCacheResults добавлено еще одно значение - STOCK_UPDATED:

class ProductAmountCacheResults(IntEnum):

    KEY_NOT_EXIST = 1

    ENOUGH_AMOUNT = 2

    NOT_ENOUGH_AMOUNT = 3

    STOCK_UPDATED = 4  # новый ключ

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

Потенциальные проблемы

Несогласованность Redis и основной БД при откате транзакций

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

Но если внутри транзакции возникает исключение и она откатывается, то изменения в Redis остаются. Это происходит из‑за того, что Redis не связан с механизмом транзакций основной БД, а Lua‑скрипты, исполняемые через Redis‑клиент, не «участвуют» в откате.

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

Чтобы минимизировать риски расхождения между кешем и БД, мы предусмотрели механизм ручного отката изменений в Redis. Один из способов — использовать блок try‑except, в котором при возникновении исключения вызывается функция, возвращающая Redis в исходное состояние. Однако более надёжным решением является применение контекстного менеджера: он автоматически вызывает нужную функцию отката, если внутри блока возникло исключение. Для каждого публичного интерфейса ProductStockLogController можно задать соответствующий «обратный» интерфейс, который будет использоваться как функция отката в контекстном менеджере. Это позволяет явно определить логику восстановления кеша при ошибке и избежать дублирования кода в случаях, когда откат может потребоваться в нескольких местах.

Восстановление кеша при полном отказе Redis

Один из важных вопросов, который возникает при использовании Redis в качестве кеша остатков — что произойдёт при полном отказе Redis, например, при очистке всех данных или при потере кеша в результате сбоя?

Наша архитектура учитывает такую ситуацию. Все публичные интерфейсы ProductStockLogController сначала обращаются к Redis и работают с текущими значениями из кеша. Однако если необходимые ключи отсутствуют (что может означать как раз потерю кеша), это не приводит к аварийной остановке или расхождению данных. Вместо этого реализовано поведение “ленивого восстановления”: при отсутствии нужных ключей мы единоразово агрегируем значения из основной базы данных по логам ProductStockLog, наполняем Redis новыми значениями и повторно исполняем нужный Lua-скрипт. Это позволяет кешу автоматически восстанавливаться из достоверного источника — основной базы данных.

Ключевое требование при этом — чтобы товарные логи в основной БД были консистентны с фактическими заказами. То есть, если заказ создан и подтверждён, должен быть создан соответствующий лог списания остатков. Если бронирование отменено — должен быть лог на возврат. Только при соблюдении этой консистентности агрегация из БД даст точную картину остатков и обеспечит корректную работу системы после сбоя Redis.

Таким образом, Redis остаётся важным, но не критически уязвимым элементом системы. 

Он может потерять данные (например, после перезапуска), но не должен быть полностью недоступен. Пока Redis работает, даже с пустым состоянием, кеш восстановится автоматически при следующем обращении к нужному интерфейсу, и система продолжит функционировать без потерь.

“Вечные” бронирования

Как уже упоминалось, для каждого товара в Redis хранятся два отдельных счётчика: количество доступного товара и количество забронированного. В нашей реализации они не имеют TTL — ключи живут вечно, пока мы сами их не изменим или не удалим. Это сделано осознанно: мы хотим, чтобы Redis всегда содержал актуальные данные по остаткам, без риска неожиданного исчезновения информации из-за истечения времени жизни ключей.

В результате, при создании новых заказов количество доступного товара стремится к нулю, а количество забронированного – к бесконечности. Если не уменьшать количество забронированных товаров при финализации заказа, система будет считать, что все заказы продолжают находиться “в подвешенном” состоянии.

Важно понимать, что это не баг, а скорее зона ответственности другой части системы. Конкретный момент, когда нужно уменьшать счётчик бронирований без увеличения доступного запаса, зависит от ваших бизнес-процессов: в одном проекте – это получение товара клиентом, в другом — отгрузка со склада или подтверждение оплаты. Главное — чтобы этот шаг был реализован и согласован с логикой бронирования.

Возможные улучшения и доработки

Хотя текущая система бронирования выглядит надёжно, есть пространство для оптимизации. Одна из ключевых проблем — промахи в Redis-кеше, которые приводят к агрегации товарных логов из основной БД. Это может занимать непредсказуемое количество времени, особенно при высоких нагрузках и большом количестве записей. В моменты пикового трафика такие промахи могут происходить чаще, что приводит к росту времени обработки отдельных запросов и, как следствие, увеличению среднего времени ответа.

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

Реализация этого механизма включает несколько шагов:

  1. Все “сырые” продуктовые логи по-прежнему пишутся в основную БД. Однако при накоплении определённого количества (например, 1000 записей на один товар) инициируется процесс уплотнения.

  2. Уплотнение заключается в том, что из накопленных логов формируется одна агрегированная запись – корневая запись, отражающая текущее состояние по количеству товара. После этого исходные логи удаляются, а в основной БД остаётся только агрегированная запись. Эти действия выполняются внутри Celery-таски и оборачиваются в транзакцию. В результате мы всегда храним фиксированный объём информации – одну такую запись плюс текущие свежие логи.

  3. Для надёжности и последующего анализа каждый лог при создании не только сохраняется в основной БД, но и отправляется в Kafka через Django-продюсер. Kafka принимает сообщения независимо от готовности консьюмера и надёжно их буферизует, обеспечивая гарантированную доставку логов для дальнейшей обработки.

  4. Далее сообщения из Kafka обрабатываются консьюмером – микросервисом, который сохраняет данные во вторую БД. Этот микросервис реализует идемпотентную обработку, чтобы избежать дублирования данных при повторной доставке. Вторая БД может быть развёрнута в отдельном контейнере или на выделенном сервере и не используется в повседневной работе системы.

  5. Отслеживать достижение порогового количества логов можно разными способами. Например, можно прямо подсчитывать количество записей в БД (наименее эффективно, так как требует агрегации), использовать для каждого товара счётчики в Redis, инкрементируя их при каждой новой записи, либо ориентироваться на id логов, если они представляют собой BIGINT с автоматически увеличивающимся значением. Также возможна реализация контроля на стороне Kafka-консьюмера: он может отслеживать количество логов по каждому товару (например, через Redis) и при достижении порога инициировать уплотнение, вызвав API основного Django-приложения или поставив задачу в Celery.

  6. Агрегация логов зависит от цели подсчёта. Поскольку в Redis мы храним два счётчика – количество доступного и количество забронированного товара – у нас есть два типа уплотнённых логов:

  • для расчёта доступного количества товара агрегируются все типы логов;

  • для расчёта забронированного – только логи типа booking и cancel booking.

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

Такой подход, основанный на событии накопления логов (например, при достижении 1000 записей), обеспечивает стабильную производительность и предсказуемое поведение. Его преимущество в том, что он опирается на сами данные (количество логов), а не на произвольные временные интервалы. Для сравнения, можно было бы уплотнять логи, скажем, каждые 10 дней. Однако любые решения, завязанные на время, потенциально менее надёжны: в реальных системах невозможно точно предсказать, когда именно будет достигнут нужный объём данных.

Вторая БД выступает в роли “подстраховки” — она нужна не для повседневной работы, а для надёжности хранения и возможности последующего анализа. Логи по своей природе предполагают неизменность, а поскольку мы нарушаем это правило в основной системе, разумно сохранять исходные данные отдельно.

Итоги

Мы могли реализовать сложную, масштабируемую систему на очередях, с распределённой обработкой, продвинутыми механизмами согласования и другими архитектурными изысками. Но вместо этого мы выбрали и реализовали решение на базе Redis, с атомарными Lua-скриптами и прямым управлением кешем.

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

Заказчик хочет запуститься побыстрее, но без существенной потери качества. Мы выбрали более прямолинейный путь, который при этом остаётся достаточно гибким и масштабируемым. Впереди — выход в продакшн, и мы уже видим, какие доработки и улучшения могут сделать систему ещё надёжнее.

Теги:
Хабы:
+1
Комментарии4

Публикации

Ближайшие события