doc: Шаг 3 — дизайн выноса состояния + журнал (read-through тенантов)

This commit is contained in:
Repinoid
2026-10-10 16:10:04 +03:00
parent 8e22bc4b9b
commit 064ed6e5d7
2 changed files with 93 additions and 0 deletions
+8
View File
@@ -114,6 +114,14 @@
- Примечание: пока один процесс с двумя листенерами (общее in-memory состояние); два процесса —
после Шага 3.
### 2.14. Шаг 3 — начало: дизайн + тенанты read-through Redis
- Дизайн: `doc/thinking/step3-state-extraction-design-2026-10-10.md` (варианты A/B/C, рекомендация
C→A, что замерить).
- Код (коммит `779a71d`): Redis-индекс `ssq:tenant_ak` (accessKey → JSON тенанта) + бэкфилл при старте;
auth-middleware читает тенанта из Redis при промахе локальной памяти.
- Нужно для ЛЮБОГО multi-instance варианта; в одном процессе — страховка.
- Проверено: индекс заполнен (`hlen ssq:tenant_ak` = 1), регрессий нет.
## 3. Грабли (для повторного использования)
1. **`proxy_set_header Host $host` теряет порт** → SigV4 `SignatureDoesNotMatch`.
@@ -0,0 +1,85 @@
# Шаг 3: вынос состояния — дизайн (2026-10-10)
> Стратегический пункт. Определяет потолок сервиса: пока очередь живёт в памяти одного
> процесса — HA/масштабирования нет. Файл — анализ вариантов и план; перед реализацией
> нужны решения/замеры (см. §5).
## 1. Проблема (факты)
- **Источник истины — память**: `models.SyncQueues` (map очередей + индексы tombstone) и
`tenant.TenantStore`. Redis (`app/persistence/redis.go`) — **write-through**: записи
асинхронны (`asyncWrite`), чтение — **только при старте** (`LoadAllQueues`, `LoadAllTenantsRaw`).
- Следствия:
- две реплики разъедутся (каждая со своей map; last-write-wins затирает);
- падение процесса ИЛИ ВМ = полный простой;
- сервис принципиально single-instance → нет HA, нет масштабирования.
- Замеренный ранее риск (P1.1): глобальный лок `SyncQueues` + сетевой Redis-I/O **под локом**
давал таймауты до 3–5с. При выносе в Redis локально-атомарные операции станут сетевыми —
это ключевой вопрос латентности.
## 2. Варианты
### A. Redis как источник истины (полный вынос)
- Все операции читают/пишут Redis; память — только кэш (или ничего).
- Атомарность — **Lua-скрипты** (dequeue, visibility, dedup, DLQ) поверх LIST/ZSET/HASH,
либо Redis Streams.
- **Плюс:** несколько инстансов data-plane, HA, потолок снят, «настоящий облачный» SQS.
- **Минус:** переписывание ядра очередей (по сути новый движок); сетевой round-trip на КАЖДЫЙ
SQS-вызов (латентность!); сложность гонок/транзакций/FIFO/dedup/DLQ на Lua.
- **Объём:** крупный.
### B. Шардирование по тенантам
- Тенанты распределяются по инстансам (consistent hashing); каждый держит СВОИ очереди в памяти.
- **Плюс:** ядро очередей не переписываем; масштабирование по числу тенантов.
- **Минус:** HA нет (шард живёт на одном инстансе — его падение = часть тенантов недоступна);
нужен роутинг/ребаланс (LB или сам сервис); состояние всё так же в памяти.
### C. Активный/пассивный (failover)
- Один активный инстанс; состояние непрерывно реплицируется в Redis; пассивный подхватывает
при падении (переключение по health/leader-election).
- **Плюс:** HA **без переписывания ядра** (as-is); быстрее всего по срокам.
- **Минус:** горизонтального масштабирования нет (один активный); окно даунтайма при
переключении; надо решить рассинхрон (last-write-wins, потеря «подтверждённых» сообщений
в async-окне).
## 3. Сравнение
| | A (Redis = истина) | B (шарды) | C (active/passive) |
|---|---|---|---|
| HA (падение инстанса) | да | частично | да (с окном) |
| Масштабирование (нагрузка) | да | да (по тенантам) | нет |
| Переписывание ядра | **да (крупно)** | нет | нет |
| Латентность на вызов | растёт (сеть) | как сейчас | как сейчас |
| Сроки | большие | средние | малые |
## 4. Предварительная рекомендация
Идти **поэтапно**, не прыгая в A сразу:
1. **Прежде всего — C** (active/passive): даёт HA за малые сроки, без переписывания ядра.
Убирает главную боль «одна ВМ = SPOF».
2. **Потом A** — когда реально упрёмся в потолок нагрузки/числа тенантов (и когда есть
средства на новый движок + замеры латентности).
3. B — рассматривать только если появится требование «много независимых тенантов на разных
инстансах» без общей нагрузки.
> Обоснование: A — самая дорогая и рискованная; делать её «на всякий случай» рано (сервис
> ещё не под нагрузкой). C снимает SPOF дешевле.
## 5. Что замерить/решить ПЕРЕД реализацией
1. **Нагрузка**: сколько SQS-вызовов/с ожидается? Какая латентность допустима? (определяет,
переживёт ли вариант A сетевые round-trip'ы).
2. **Объём состояния**: число очередей × сообщений сейчас и на горизонте — влезает ли в Redis
(память/лимиты), сколько стоит снапшот.
3. **Требование к HA**: окно даунтайма при переключении (для C) — допустимо ли?
4. **Порог переключения** (для C): health-check + leader-election (Redis SETNX/lock).
## 6. Ближайшие безопасные шаги (независимо от варианта)
- **Тенанты read-through Redis**: сейчас тенант, созданный control-plane, не виден data-plane
до рестарта. Добавить Redis-индекс `accessKey → tenant` и fallback в auth-middleware —
нужно для ЛЮБОГО multi-instance варианта и снимает текущее ограничение.
- **Метрики/health** для переключения (готово: `/health`, `/metrics`).
- **Разнести процессы data/control** (листенеры уже разделены, коммит `c1520c0`) — после
read-through тенантов возможен полноценный процесс-split.