← Назад к Alexander Popov

Алертер, коннекторы и правила: как мы научили систему меняться без релизов

2025-09-16Alexander Popov

Чтобы продукты не «застывали» между релизами, мы перенесли ключевые настройки в данные. Ниже — как это устроено и зачем так жить.


Проблема

Банальные вещи — ключи, токены, URL, прайс-сетки, когорты, бизнес-правила — меняются чаще, чем выпускаются версии.
Релиз ради правки процента скидки — дорого и рискованно.


Идея

  • Динамические коннекторы (секреты/URLs/прайсы) лежат в Redis/PG.
  • Правила (кохортная логика, фильтры, лимиты) — это данные (YAML/JSON) в Redis Hash.
  • Глобальное read-only состояние держим в atomic.Value: меняем весь снапшот атомарно, без гонок.
  • Environment-expansion — только для строк, без «магии» внутри структур.
  • Singleton-лоадеры с sync.RWMutex: один источник истины и безопасные обновления.

Упрощённый эскиз загрузчика (Go)

package config

import (
    "context"
    "sync"
    "sync/atomic"
)

type DynamicConfig struct {
    Data map[string]any
}

var (
    dyn atomic.Value // хранит DynamicConfig
    mu  sync.RWMutex
)

type Source interface {
    Fetch(ctx context.Context) ([]byte, error) // Redis HGETALL / PG query
}

func GetConfig() DynamicConfig {
    if v := dyn.Load(); v != nil {
        return v.(DynamicConfig)
    }
    return DynamicConfig{Data: map[string]any{}}
}

func LoadAndSet(ctx context.Context, src Source) error {
    mu.Lock()
    defer mu.Unlock()

    raw, err := src.Fetch(ctx)
    if err != nil {
        return err
    }

    data := parse(raw)         // JSON/YAML → map[string]any
    expandEnvInPlace(data)     // подстановка $VARS только в строках

    dyn.Store(DynamicConfig{Data: data}) // атомарная замена снапшота
    return nil
}

Идея: меняем данные — меняется поведение. Релиз не нужен.


Правила из Redis без релиза

  • Храним Hash: ключ — текстовый rule_id, значение — YAML/JSON с полями is_active, timeframe, body, name.
  • Лоадер собирает срез правил и отдаёт иммутабельный снапшот воркерам.
  • Воркеры ничего не мутируют, только читают актуальный снапшот.

Почему это стабильно

  • Нет «чтения посередине обновления»: atomic.Store меняет указатель целиком.
  • Воркеры держат актуальную ссылку и не ждут глобальных блокировок.
  • Обновления конфигов не требуют рестарта/релиза.

Наблюдаемость и безопасность

  • Гистограммы lat/err для всех внешних вызовов, трассировки по задачам Asynq/Kafka.
  • Алерты по SLO (ошибка/латентность), а не «по ощущениям».
  • Secrets — только в секрет-хранилище, доступы по минимальным привилегиям.
  • Аудит изменений: кто/что/когда — писаем событие и видим в дашбордах.

Инциденты, которые сделали нас лучше

  • S3/Auth: «invalid semicolon…» — отказались от «умных» клиентов, сделали явный сигнинг/энкодинг запросов.
  • Import cycles (Go): вынесли зависимости в интерфейсы и адаптеры, разорвали круг.
  • DISTINCT в window-функциях (Postgres): переписали на CTE + явный GROUP BY, удержали производительность индексами.

Как это помогает продуктам

  • Calendar / Tools — справочники и источники обновляются мгновенно.
  • Nutcrackers — правила и лимиты включаются без релиза.
  • Open Academy (Nuts Farm) — цены/когорты/эксперименты меняем данными, метрики и биллинг видны в Grafana.

Как работаем с клиентами и партнёрами

  • Совместный бэклог и weekly-демо.
  • Feature flags, превью-окружения, откаты одной кнопкой.
  • Отчётность: бизнес-KPI + инженерные метрики в одном дашборде.

Вывод

Мы сделали систему, которая меняется быстрее релизов. Это снижает риск, ускоряет эксперименты и делает продукты устойчивее.