/sku-limiter

gRPC, redis, tests

Primary LanguageGo

Некоторые особенности реализации

  • gRPC сервис (с REST ручками).
  • В качестве персистентного хранилища используется только redis.
  • Структура хранения данных следующая:
    • Для лимитов по sku: sku - акция - [лимит - период акции (окно) - дата старта акции]
    • Для историй покупок: user_id - sku - [акция - количество - номер заказа - дата когда покупка выпадет за окно]

Примечание: для избежания ситуации теоретического совпадения ключей sku и user_id к ключам sku в начале добавляется префикс "-"

[] - всё что указано в скобках хранится в виде закодированной строки. Для кодирования/декодирования использовался пакет msgpack. Судя по бенчмаркам в гите, работает быстрее стандартного пакета в 5 раз.

Всё что вне [] хранится в redis как хэши (hget, hset), т.е. к ним имеем быстрый доступ для поиска.


  • Есть тесты (в качеcтве мока редиса используется miniredis) и бенчмармки.
  • Для ограничения времени запросов на клиент редиса при создании навешаны таймауты.
Проблема удаления устаревших данных
  • При каждом дёргании ручки затрагивающей покупки пользователя (добавление, возврат, просмотр, удаление) происходит удаление покупок пользователя вышедших за окно (сравнивается текущее время и хранящаяся дата выхода за окно).
  • Второй механизм очистки неактуальных записей это время жизни ключа (TTL), время жизни ставится на ключ user_id. Время ставится исходя из покупки пользователя у которой самый долгий срок жизни (самое больше окно, при каждой новой покупке TLL актуализируется). Т.е. если даже ручку пользователя долго не дёргают, все его покупки очистятся по времени жизни ключа и "мёртвых" записей отжирающих память не будет.

================================================

ТЗ

sku-limiter, неформальное название - "в одни руки две штуки"

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

сервис подразумевает как лимит по продажам конкретного товара (СКУ) одному юзеру, так и лимит по продажам товара со скидкой в зависимости от конкретной маркетинговой акции.

настройки

любому СКУ (int64) можно задать:

  • лимит (int32) на количество единиц доступных юзеру (int64) для покупки
  • интервал (окно) времени, в который это ограничение действует с точностью до секунд (int64) - секунды обсуждаются!
  • marketing_action_id ограничения (default = 0, т.е. вне акций) - ссылка на маркетинговую акцию, если есть (int64)
ограничения:
  • ограничений на кол-во marketing_action_id для одного СКУ нет, значит первичный ключ - СКУ + marketing_action_id
  • кол-во СКУ (в некотором приближении и лимитов) - 6М
  • типичный интервал времени (окно) лимита - 30 дней
апи (ручки):
  1. задать лимиты для списка СКУ, пример:
    {
        "111": { // sku
            "0": { // лимит на покупки вне акций
            "limit": 10,
            "sec": 14 * 86400 // 14 days
            },
            "7": { // лимит на 7-ю акцию
            "limit": 5,
            "sec": 7 * 86400
            }
        },
        "222": [
            "0": {
            "limit": 50,
            "sec": 30 * 86400
            }
        ]
    }
    
  2. получить лимиты по списку СКУ и опционально акциям
  3. удалить лимиты по списку СКУ и опционально акциям (+удалить счетчики остатков у юзеров)

в рабочем режиме входящий поток на этих ручках будет редким и потенциально небольшим (веб-морда или интеграция)

счетчики остатков

для каждого юзера считаем его покупки по всем СКУ, а не только тех, что есть в настройках.

####ограничения:

  • кол-во активных (покупающих) юзеров в типичное окно времени - 10М
  • среднее кол-во покупаемых СКУ на активного юзера за типичное окно времени - 7-10
  • ИТОГО ожидаемое кол-во активных счетчиков - 100М
апи (write-ручки):

для ведения счетчиков в сервис будет направлено два потока данных:

  1. покупки, состоящие из:

    • user_id (int32), order_id (int32), order_ts (секунды, int64)
    • и массив СКУ, входящих в этот заказ [sku, marketing_action_id, qty (кол-во единиц)]
  2. отмены и возвраты ранее купленных товаров, которые должны "плюсовать" расход лимитов и состоящие из:

    • user_id (int32), order_id (int32), return_ts (секунды, int64)
    • и массив возвращаемых/отменяемых СКУ [sku, qty (кол-во единиц)] - marketing_action_id отсутствует!

    в рабочем режиме входящий поток на этих ручках будет до 15 rps, основной трафик на покупках

  3. почистить (ресетнуть) остатки по всем лимитам для списка юзеров и опционально акциям

    в рабочем режиме входящий поток на этой ручке будет редким

апи (read-ручки):
  1. получить остатки по лимитам для юзера по заданному списку СКУ пример запроса клиента:
    { // request
        "user_id": "123",
        "sku": [
            "111",
            "222",
            "333"
        ]
    }
    
    пример ответа сервиса:
    { // response
        "user_id": "123",
        "sku": {
            "111": { // СКУ = 111
            "0": 3, // прямо сейчас может купить еще 3 единицы, если покупка вне акций
            "7": 9 // прямо сейчас может купить еще 9 единиц по 7-й акции
            },
            "222": {
            "0": 0, // лимит исчерпан, нужно ждать когда "уйдет" окно 
            "2": 1 // по 2-й акции можно купить одну единицу
            },
            "333": {
            "0": -1 // лимита на СКУ нет
            }
        }
    }
    
  2. получить остатки по всем лимитам для списка юзеров и опционально акциям

на старте сервиса входящий поток на первой ручке будет до 300 rps, в перспективе до 3-4К rps на второй - трафик незначительный

пример арифметики на счетчиках:
    в настройках есть два лимита для СКУ1
    marketing_action_id=0 (т.е. без акции): 30 единиц
    marketing_action_id=1 (акция на скидку):20 единиц

    в истории покупок юзера есть покупки СКУ1:
    marketing_action_id=0, 5 единиц
    marketing_action_id=1, 10 единиц
    marketing_action_id=2, 15 единиц (акция, не описанная в настройках)

    на запрос остатков по лимитам для этого юзера и СКУ1 возвращаем:
    marketing_action_id=0:  0 единиц: 30 - (5 + 10 + 15)
    marketing_action_id=1: 10 единиц: 20 - 10