Redis + PostgreSQL: оптимальная стратегия кэширования для БилингПро

Задача клиента: биллинг под нагрузкой 200K RPS

Компания БилингПро — разработчик биллинговой платформы для телеком-операторов — столкнулась с проблемой производительности при масштабировании. Их PostgreSQL 15 обслуживал 60 тысяч запросов в секунду, но прогнозируемый рост до 200K RPS к концу 2026 года требовал принципиально другого подхода.

Инфраструктура клиента на момент обращения:

  • PostgreSQL 15 — 3 реплики, база 280 ГБ, ~2500 таблиц
  • Серверы: Xeon Gold 6312U (48 vCores с HT), 128 ГБ RAM
  • Профиль нагрузки: 70% чтение, 20% обновление, 10% вставка
  • Критичные запросы: проверка баланса, тарификация, генерация счетов
  • Требования: strong consistency для финансовых операций

Вопрос клиента звучал так: «Нужен ли нам Redis перед PostgreSQL, или можно обойтись оптимизацией самой БД?» Мы провели полноценное исследование с бенчмарками, чтобы дать обоснованный ответ.

Бенчмарки: PostgreSQL, Redis, Memcached и Valkey

Первым делом мы развернули тестовый стенд, идентичный production-серверам клиента, и провели серию бенчмарков. Все тесты выполнялись на чистых ReadOnly point-select нагрузках (10 млн ключей × 256 байт) для установления потолка производительности каждой технологии.

Методология тестирования

Инструменты и условия:

  • sysbench — для PostgreSQL и MySQL (point-select workload)
  • memtier_benchmark — для Redis, Valkey и Memcached
  • redis-benchmark — дополнительно для Redis
  • Данные: 10M ключей × 256 байт = ~2.5 ГБ данных, полностью в RAM
  • Диск не участвует (appendonly off, save "")
# Тест Redis с 8 io-threads
redis-benchmark -h 127.0.0.1 -p 6379 -c 256 -n 10000000 \
  -t get -d 256 --threads 8

# Тест PostgreSQL (sysbench point-select)
sysbench oltp_point_select \
  --db-driver=pgsql \
  --pgsql-host=127.0.0.1 \
  --pgsql-port=5432 \
  --pgsql-db=bench \
  --table-size=10000000 \
  --tables=1 \
  --threads=64 \
  --time=300 \
  run

# Тест Memcached
memtier_benchmark -s 127.0.0.1 -p 11211 \
  --protocol=memcache_binary \
  --data-size=256 --key-maximum=10000000 \
  --threads=8 --clients=32 --requests=1000000 \
  --ratio=0:1

Результаты бенчмарков

Результаты на одном сервере (Xeon Gold 6312U, 128 ГБ RAM):

СистемаRPS (ReadOnly)Латентность p50Латентность p99CPU usage
Memcached~1 700 0000.15 мс0.45 мс85%
Valkey 7.2~1 000 0000.25 мс0.8 мс75%
Redis 7.2 (8 io-threads)300 000-400 0000.4 мс1.2 мс60%
Redis 7.2 (single-thread)~160 0000.6 мс2.1 мс25%
PostgreSQL 15 (optimized)~1 000 0000.3 мс1.5 мс90%

Ключевое открытие: оптимизированный PostgreSQL на чистом point-select workload показывал ~1M RPS — сопоставимо с Valkey и значительно больше, чем Redis. Но это лишь часть картины.

Бенчмарки с реальным профилем нагрузки

Чистый ReadOnly point-select — синтетика, не отражающая реальность. Мы повторили тесты с профилем нагрузки клиента (70/20/10):

СистемаRPS (mixed 70/20/10)Деградация от ReadOnly
PostgreSQL 15~180 000-82%
Redis 7.2 (8 io-threads)~280 000-25%
PostgreSQL + Redis cache~350 000

При смешанной нагрузке PostgreSQL деградировал на 82%, Redis — лишь на 25%. Кэш-слой стал необходимостью, а не опцией.

Архитектура решения: гибридная стратегия кэширования

Мы спроектировали трёхуровневую архитектуру кэширования, учитывающую требования strong consistency для финансовых данных:

┌──────────────────────────────────────────────────┐
│                   Приложение                      │
│  ┌────────────────┐  ┌─────────────────────────┐ │
│  │ Финансовые ops  │  │ Справочные/read-heavy   │ │
│  │ (балансы, транз)│  │ (тарифы, каталоги)      │ │
│  └───────┬────────┘  └──────────┬──────────────┘ │
│          │                      │                 │
│          ▼                      ▼                 │
│  ┌──────────────┐    ┌─────────────────────┐     │
│  │ PostgreSQL   │    │ Cache-aside (Redis)  │     │
│  │ (напрямую)   │    │ TTL: 60-300 сек      │     │
│  └──────────────┘    └──────────┬──────────┘     │
│                                 │ cache miss      │
│                                 ▼                 │
│                      ┌──────────────────┐        │
│                      │   PostgreSQL     │        │
│                      │   (read replica) │        │
│                      └──────────────────┘        │
└──────────────────────────────────────────────────┘

Категория 1: финансовые операции — без кэша

Для операций с балансами, транзакциями и расчётами мы сознательно не использовали кэш. Причина — strong consistency requirement. Любое расхождение баланса в кэше и БД для биллинговой системы недопустимо.

Вместо кэша мы оптимизировали PostgreSQL:

# postgresql.conf — оптимизация для point-select
shared_buffers = 32GB              # 25% RAM
effective_cache_size = 96GB        # 75% RAM
work_mem = 256MB
maintenance_work_mem = 2GB

# Connection pooling (pgbouncer)
[pgbouncer]
pool_mode = transaction
default_pool_size = 100
max_client_conn = 5000
server_check_query = SELECT 1

# Партиционирование таблицы транзакций по месяцам
CREATE TABLE transactions (
    id BIGSERIAL,
    account_id BIGINT NOT NULL,
    amount DECIMAL(15,2) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);

CREATE TABLE transactions_2026_01
    PARTITION OF transactions
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- и т.д. для каждого месяца

Категория 2: справочные данные — cache-aside с Redis

Для тарифных планов, каталогов услуг и конфигураций мы внедрили паттерн cache-aside:

# Конфигурация Redis для кэш-слоя
# redis.conf
bind 0.0.0.0
port 6379
protected-mode yes
requirepass ${REDIS_PASSWORD}

# Отключение персистентности (чистый кэш)
appendonly no
save ""

# Многопоточный I/O
io-threads 8
io-threads-do-reads yes

# Политика вытеснения
maxmemory 16gb
maxmemory-policy allkeys-lfu

# Оптимизация для кэш-сценария
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes

Пример реализации cache-aside на стороне приложения (Python):

import redis
import json
import psycopg2
from functools import wraps

redis_client = redis.Redis(
    host='redis-cache.internal',
    port=6379,
    password=os.environ['REDIS_PASSWORD'],
    decode_responses=True,
    socket_connect_timeout=2,
    socket_timeout=1,
    retry_on_timeout=True
)

def cached(ttl=120, key_prefix=''):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            cache_key = f"{key_prefix}:{func.__name__}:{hash(str(args)+str(kwargs))}"
            
            # 1. Пробуем получить из кэша
            try:
                cached_value = redis_client.get(cache_key)
                if cached_value:
                    return json.loads(cached_value)
            except redis.RedisError:
                # Redis недоступен — идём напрямую в БД
                pass
            
            # 2. Cache miss — запрос к PostgreSQL
            result = func(*args, **kwargs)
            
            # 3. Записываем в кэш
            try:
                redis_client.setex(
                    cache_key, 
                    ttl, 
                    json.dumps(result, default=str)
                )
            except redis.RedisError:
                pass  # Не ломаем основной flow при проблемах с кэшом
            
            return result
        return wrapper
    return decorator

@cached(ttl=300, key_prefix='tariff')
def get_tariff_plan(tariff_id: int) -> dict:
    with get_db_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT * FROM tariff_plans WHERE id = %s", 
                (tariff_id,)
            )
            return dict(cur.fetchone())

Инвалидация кэша: самая сложная часть

Классическая проблема кэширования — инвалидация. Мы реализовали три механизма, работающих совместно:

TTL-based expiry

Каждый ключ имеет TTL от 60 до 300 секунд в зависимости от частоты изменений данных:

  • Тарифные планы: TTL 300 сек (меняются раз в месяц)
  • Списки услуг: TTL 120 сек
  • Конфигурации: TTL 60 сек

Event-driven инвалидация через PostgreSQL LISTEN/NOTIFY

Для немедленной инвалидации при изменении данных мы использовали PostgreSQL LISTEN/NOTIFY:

-- Триггер на таблице тарифов
CREATE OR REPLACE FUNCTION notify_tariff_change()
RETURNS TRIGGER AS $$
BEGIN
    PERFORM pg_notify(
        'cache_invalidate',
        json_build_object(
            'table', TG_TABLE_NAME,
            'operation', TG_OP,
            'id', COALESCE(NEW.id, OLD.id)
        )::text
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tariff_change_trigger
    AFTER INSERT OR UPDATE OR DELETE ON tariff_plans
    FOR EACH ROW EXECUTE FUNCTION notify_tariff_change();
# Python-listener для инвалидации Redis
import select

def cache_invalidation_listener():
    conn = psycopg2.connect(DSN)
    conn.set_isolation_level(
        psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT
    )
    cur = conn.cursor()
    cur.execute("LISTEN cache_invalidate;")
    
    while True:
        if select.select([conn], [], [], 5) != ([], [], []):
            conn.poll()
            while conn.notifies:
                notify = conn.notifies.pop(0)
                payload = json.loads(notify.payload)
                pattern = f"{payload['table']}:*:{payload['id']}*"
                
                # Удаляем все ключи, связанные с изменённой записью
                for key in redis_client.scan_iter(match=pattern):
                    redis_client.delete(key)

Защита от thundering herd

При истечении TTL популярного ключа десятки инстансов одновременно обращаются к БД. Мы реализовали паттерн «probabilistic early expiration»:

# Вероятностное раннее обновление кэша
import random
import time

def get_with_early_refresh(key, ttl, fetch_func):
    value = redis_client.get(key)
    remaining_ttl = redis_client.ttl(key)
    
    if value and remaining_ttl > 0:
        # С вероятностью, растущей к концу TTL,
        # обновляем кэш фоновым запросом
        refresh_probability = max(0, 1 - (remaining_ttl / ttl))
        if random.random() < refresh_probability * 0.1:
            # Фоновое обновление через Celery task
            refresh_cache_task.delay(key, ttl)
        return json.loads(value)
    
    # Cache miss — обновляем синхронно
    # Используем Redis SETNX для lock
    lock_key = f"lock:{key}"
    if redis_client.set(lock_key, 1, nx=True, ex=5):
        result = fetch_func()
        redis_client.setex(key, ttl, json.dumps(result, default=str))
        redis_client.delete(lock_key)
        return result
    
    # Другой инстанс уже обновляет — ждём
    time.sleep(0.1)
    return get_with_early_refresh(key, ttl, fetch_func)

Redis Sentinel для отказоустойчивости кэша

Кэш-слой не должен быть единой точкой отказа. Мы развернули Redis Sentinel с 3 узлами:

# sentinel.conf
port 26379
sentinel monitor billing-cache 10.0.1.10 6379 2
sentinel down-after-milliseconds billing-cache 5000
sentinel failover-timeout billing-cache 10000
sentinel parallel-syncs billing-cache 1

# Конфигурация приложения для работы с Sentinel
# Python redis-py
from redis.sentinel import Sentinel

sentinel = Sentinel(
    [('10.0.1.20', 26379), ('10.0.1.21', 26379), ('10.0.1.22', 26379)],
    socket_timeout=0.5
)

# Автоматическое переключение на новый master
master = sentinel.master_for('billing-cache', socket_timeout=0.5)
slave = sentinel.slave_for('billing-cache', socket_timeout=0.5)

# Чтение — с реплики, запись — на master
result = slave.get('tariff:plan:42')
master.setex('tariff:plan:42', 300, json.dumps(data))

При отказе master-узла Sentinel выполняет автоматический failover за 5-10 секунд. В это время приложение прозрачно обращается к PostgreSQL напрямую.

Мониторинг кэш-слоя

Мы настроили comprehensive мониторинг эффективности кэша через Prometheus + Grafana:

# Prometheus redis_exporter метрики
- redis_commands_processed_total
- redis_keyspace_hits_total
- redis_keyspace_misses_total
- redis_connected_clients
- redis_used_memory_bytes
- redis_evicted_keys_total

# Кастомные метрики приложения
cache_hit_ratio = (
    redis_keyspace_hits_total 
    / (redis_keyspace_hits_total + redis_keyspace_misses_total)
) * 100

# Алерт: cache hit ratio ниже 80%
groups:
  - name: redis-cache-alerts
    rules:
      - alert: LowCacheHitRatio
        expr: |
          redis_keyspace_hits_total 
          / (redis_keyspace_hits_total + redis_keyspace_misses_total) 
          < 0.80
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Cache hit ratio ниже 80%: {{ $value | humanizePercentage }}"

Целевые показатели, которых мы достигли:

МетрикаЦелевое значениеФактическое
Cache hit ratio>85%91.3%
Redis p99 латентность<2 мс0.8 мс
Eviction rate<100/сек12/сек
Memory utilization<85%72%

Результаты внедрения

После 6 недель внедрения и 2 недель стабилизации биллинговая платформа БилингПро показала следующие результаты:

МетрикаДо внедренияПослеИзменение
Суммарный RPS60 000350 000++483%
Нагрузка на PostgreSQL60 000 QPS~9 000 QPS-85%
P50 латентность API8 мс1.2 мс-85%
P99 латентность API45 мс5 мс-89%
CPU PostgreSQL85-95%25-35%-60%
Серверов PostgreSQL33 (запас до 5x)

Платформа получила запас производительности, достаточный для роста на 2-3 года без добавления серверов БД. Подробнее о стратегиях кэширования для высоконагруженных систем — на itfresh.ru.

Когда кэш-слой не нужен: выводы из бенчмарков

Важный вывод нашего исследования: кэш-слой нужен не всегда. По результатам бенчмарков мы сформулировали критерии:

Кэш НЕ нужен, если:

  • Нагрузка преимущественно ReadOnly (95%+ чтение)
  • Запросы — point-select по первичному ключу
  • Требуется strong consistency для всех данных
  • RPS ниже порога одного сервера БД (~500K для PostgreSQL на хорошем железе)

Кэш необходим, если:

  • Смешанная нагрузка (read/write) при RPS выше 100K
  • Есть данные с разной частотой обновления (горячие vs холодные)
  • Допустима eventual consistency для части данных
  • Стоимость горизонтального масштабирования БД выше, чем стоимость Redis-кластера

Для БилингПро гибридный подход — strong consistency для финансов, cache-aside для справочников — оказался оптимальным балансом между производительностью и надёжностью.

Часто задаваемые вопросы

Memcached действительно быстрее в чистых GET-операциях (1.7M vs 400K RPS). Однако Redis предлагает критичные для биллинговой системы возможности: Sentinel для автоматического failover, persistence для warm restart, pub/sub для инвалидации кэша, поддержку сложных структур данных (sorted sets для рейтингов, hashes для профилей). Разница в 4x по RPS нерелевантна, когда один Redis-инстанс покрывает потребности с запасом.
Каждая реплика PostgreSQL — это полная копия базы данных (280 ГБ), требующая отдельного сервера с 128 ГБ RAM. Один Redis-инстанс с 16 ГБ памяти разгрузил PostgreSQL на 85%. Стоимость решения с Redis — ~$200/мес за сервер, стоимость 5 дополнительных реплик PostgreSQL — ~$5000/мес. При этом Redis обеспечивает субмиллисекундную латентность, недостижимую для РСУБД при смешанной нагрузке.
Для справочных данных мы используем трёхуровневую защиту: TTL-based expiry (гарантирует eventual consistency за N секунд), PostgreSQL LISTEN/NOTIFY (мгновенная инвалидация при изменении), probabilistic early refresh (предотвращает thundering herd). Для финансовых данных кэш не используется — запросы идут напрямую в PostgreSQL через pgbouncer.
Приложение спроектировано по принципу graceful degradation: при недоступности Redis все запросы идут напрямую в PostgreSQL. Это увеличивает нагрузку на БД, но не приводит к отказу. Sentinel автоматически восстанавливает Redis-кластер за 5-10 секунд. За 6 месяцев эксплуатации полный отказ Redis произошёл 1 раз (обновление ОС) и длился 8 секунд.
Да, начиная с Redis 6.0. Мы рекомендуем io-threads = количество ядер / 2, но не более 8. В наших бенчмарках 8 io-threads увеличили пропускную способность Redis с 160K до 400K RPS. Параметр io-threads-do-reads = yes также важен — без него многопоточность работает только для записи.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#redis кэширование#postgresql оптимизация#кэш-слой перед СУБД#cache-aside pattern#redis io-threads#proxysql кэширование#бенчмарки redis postgresql#highload кэширование
Комментарии 0

Оставить комментарий

загрузка...