Некоторые особенности реализации
- 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 дней
апи (ручки):
- задать лимиты для списка СКУ, пример:
{ "111": { // sku "0": { // лимит на покупки вне акций "limit": 10, "sec": 14 * 86400 // 14 days }, "7": { // лимит на 7-ю акцию "limit": 5, "sec": 7 * 86400 } }, "222": [ "0": { "limit": 50, "sec": 30 * 86400 } ] }
- получить лимиты по списку СКУ и опционально акциям
- удалить лимиты по списку СКУ и опционально акциям (+удалить счетчики остатков у юзеров)
в рабочем режиме входящий поток на этих ручках будет редким и потенциально небольшим (веб-морда или интеграция)
счетчики остатков
для каждого юзера считаем его покупки по всем СКУ, а не только тех, что есть в настройках.
####ограничения:
- кол-во активных (покупающих) юзеров в типичное окно времени - 10М
- среднее кол-во покупаемых СКУ на активного юзера за типичное окно времени - 7-10
- ИТОГО ожидаемое кол-во активных счетчиков - 100М
апи (write-ручки):
для ведения счетчиков в сервис будет направлено два потока данных:
-
покупки, состоящие из:
- user_id (int32), order_id (int32), order_ts (секунды, int64)
- и массив СКУ, входящих в этот заказ [sku, marketing_action_id, qty (кол-во единиц)]
-
отмены и возвраты ранее купленных товаров, которые должны "плюсовать" расход лимитов и состоящие из:
- user_id (int32), order_id (int32), return_ts (секунды, int64)
- и массив возвращаемых/отменяемых СКУ [sku, qty (кол-во единиц)] - marketing_action_id отсутствует!
в рабочем режиме входящий поток на этих ручках будет до 15 rps, основной трафик на покупках
-
почистить (ресетнуть) остатки по всем лимитам для списка юзеров и опционально акциям
в рабочем режиме входящий поток на этой ручке будет редким
апи (read-ручки):
- получить остатки по лимитам для юзера по заданному списку СКУ
пример запроса клиента:
пример ответа сервиса:
{ // 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 // лимита на СКУ нет } } }
- получить остатки по всем лимитам для списка юзеров и опционально акциям
на старте сервиса входящий поток на первой ручке будет до 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