Session Service: як правильно будувати сесію у high-load казино

Session Service: як правильно будувати сесію у high-load казино

Управління сесіями в онлайн-казино — це не просто “зберігати токен у Redis”. При навантаженні в десятки тисяч одночасних гравців кожна архітектурна помилка може призвести до втр…


This content originally appeared on DEV Community and was authored by Maksim

Session Service: як правильно будувати сесію у high-load казино

Управління сесіями в онлайн-казино — це не просто "зберігати токен у Redis". При навантаженні в десятки тисяч одночасних гравців кожна архітектурна помилка може призвести до втрати коштів, витоку даних або відмови сервісу. Розглянемо перевірені рішення.

Вимоги до session service в казино

Критичні вимоги:

  1. Безпека — неможливість підробки або викрадення сесії
  2. Масштабованість — мільйони активних сесій одночасно
  3. Продуктивність — перевірка сесії < 10ms
  4. Multi-device — один користувач на кількох пристроях
  5. Швидка інвалідація — logout/ban миттєво діють
  6. Audit trail — історія всіх сесій для compliance

Статистика реального казино:

Concurrent users: 50,000
Sessions per second: 500-1,000 new sessions
Session checks per second: 100,000-500,000
Average session duration: 45 minutes
Peak load multiplier: 3x (вечір п'ятниці)

JWT vs Opaque Tokens: що обрати?

JWT (JSON Web Tokens)

Переваги:

  • ✅ Stateless — не потребує БД для перевірки
  • ✅ Швидка верифікація (тільки криптографія)
  • ✅ Містить claims (user_id, roles, permissions)
  • ✅ Можна використовувати в мікросервісах

Недоліки:

  • ❌ Неможливо інвалідувати до expire
  • ❌ Розмір токену (200-500 bytes)
  • ❌ Всі дані публічні (base64)
  • ❌ Складніше rotate secrets

Opaque Tokens

Переваги:

  • ✅ Повний контроль (можна інвалідувати)
  • ✅ Компактні (32-64 bytes)
  • ✅ Дані зберігаються на сервері
  • ✅ Легше rotate

Недоліки:

  • ❌ Потребує lookup в БД/cache
  • ❌ Додаткова latency
  • ❌ Потребує синхронізацію між серверами

Гібридний підхід (рекомендовано):

type HybridSession struct {
    // Opaque token для клієнта
    Token string

    // JWT для внутрішнього використання (мікросервіси)
    InternalJWT string

    // Дані сесії
    UserID      string
    DeviceID    string
    IP          string
    CreatedAt   time.Time
    ExpiresAt   time.Time
    LastSeenAt  time.Time
}

Архітектура Session Service

┌─────────────┐
│   Client    │
└──────┬──────┘
       │ Session Token
       ▼
┌─────────────────────────────────┐
│      API Gateway                │
│  (Token Validation)             │
└────────┬────────────────────────┘
         │
         ▼
┌─────────────────────────────────┐
│    Session Service              │
│  ┌──────────┐   ┌────────────┐ │
│  │  Redis   │   │ PostgreSQL │ │
│  │ (cache)  │   │  (store)   │ │
│  └──────────┘   └────────────┘ │
└─────────────────────────────────┘

Schema для PostgreSQL:

CREATE TABLE sessions (
    id UUID PRIMARY KEY,
    token VARCHAR(64) UNIQUE NOT NULL,
    user_id UUID NOT NULL,
    device_id VARCHAR(255),
    device_type VARCHAR(50),
    ip_address INET NOT NULL,
    user_agent TEXT,
    country VARCHAR(2),
    created_at TIMESTAMP DEFAULT NOW(),
    expires_at TIMESTAMP NOT NULL,
    last_seen_at TIMESTAMP DEFAULT NOW(),
    status VARCHAR(20) DEFAULT 'active',
    metadata JSONB,

    CONSTRAINT sessions_expires_check CHECK (expires_at > created_at)
);

CREATE INDEX idx_sessions_user ON sessions(user_id, status);
CREATE INDEX idx_sessions_token ON sessions(token) WHERE status = 'active';
CREATE INDEX idx_sessions_expires ON sessions(expires_at) WHERE status = 'active';
CREATE INDEX idx_sessions_device ON sessions(device_id) WHERE status = 'active';

-- Таблиця для аудиту
CREATE TABLE session_events (
    id BIGSERIAL PRIMARY KEY,
    session_id UUID NOT NULL,
    event_type VARCHAR(50) NOT NULL,
    ip_address INET,
    user_agent TEXT,
    metadata JSONB,
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_session_events_session ON session_events(session_id, created_at);
CREATE INDEX idx_session_events_type ON session_events(event_type, created_at);

Реалізація Session Service

Генерація secure tokens:

type TokenGenerator struct {
    entropy *rand.Rand
}

func NewTokenGenerator() *TokenGenerator {
    return &TokenGenerator{
        entropy: rand.New(rand.NewSource(time.Now().UnixNano())),
    }
}

func (tg *TokenGenerator) GenerateToken() string {
    // 32 bytes = 256 bits entropy
    b := make([]byte, 32)
    if _, err := io.ReadFull(rand.Reader, b); err != nil {
        panic(err) // Should never happen
    }

    // Base64 URL-safe encoding
    return base64.RawURLEncoding.EncodeToString(b)
}

// Приклад токену: "x7KpL9mN4vQ8wR2tY5jH6gF3dS1aZ0"

Створення сесії:

type SessionService struct {
    db          *sql.DB
    cache       *redis.Client
    tokenGen    *TokenGenerator
    config      SessionConfig
}

type SessionConfig struct {
    DefaultTTL        time.Duration // 24 години
    MaxSessionsPerUser int          // 5 пристроїв
    InactivityTimeout time.Duration // 30 хвилин
}

func (ss *SessionService) CreateSession(
    ctx context.Context,
    req *CreateSessionRequest,
) (*Session, error) {
    // 1. Перевірка ліміту сесій
    if err := ss.checkSessionLimit(ctx, req.UserID); err != nil {
        return nil, err
    }

    // 2. Генерація токену
    token := ss.tokenGen.GenerateToken()

    // 3. Створення сесії
    session := &Session{
        ID:         generateUUID(),
        Token:      token,
        UserID:     req.UserID,
        DeviceID:   req.DeviceID,
        DeviceType: req.DeviceType,
        IP:         req.IP,
        UserAgent:  req.UserAgent,
        Country:    req.Country,
        CreatedAt:  time.Now(),
        ExpiresAt:  time.Now().Add(ss.config.DefaultTTL),
        LastSeenAt: time.Now(),
        Status:     StatusActive,
    }

    // 4. Збереження в БД
    if err := ss.saveSessionToDB(ctx, session); err != nil {
        return nil, err
    }

    // 5. Кешування в Redis
    if err := ss.cacheSession(ctx, session); err != nil {
        log.Error("Failed to cache session", "error", err)
        // Не критично - продовжуємо
    }

    // 6. Логування події
    ss.logSessionEvent(ctx, session.ID, EventTypeCreated, req.IP, req.UserAgent)

    // 7. Очистка старих сесій
    go ss.cleanupOldSessions(context.Background(), req.UserID)

    return session, nil
}

Валідація сесії (hot path):

func (ss *SessionService) ValidateSession(
    ctx context.Context,
    token string,
) (*Session, error) {
    // 1. Спроба прочитати з Redis (99% випадків)
    session, err := ss.getSessionFromCache(ctx, token)
    if err == nil {
        // Оновлюємо last_seen асинхронно
        go ss.updateLastSeen(context.Background(), session.ID)
        return session, nil
    }

    if err != redis.Nil {
        log.Error("Redis error", "error", err)
    }

    // 2. Fallback на БД
    session, err = ss.getSessionFromDB(ctx, token)
    if err != nil {
        if err == sql.ErrNoRows {
            return nil, ErrInvalidToken
        }
        return nil, err
    }

    // 3. Перевірки
    if session.Status != StatusActive {
        return nil, ErrSessionInactive
    }

    if time.Now().After(session.ExpiresAt) {
        ss.expireSession(ctx, session.ID)
        return nil, ErrSessionExpired
    }

    // Перевірка inactivity timeout
    if time.Since(session.LastSeenAt) > ss.config.InactivityTimeout {
        ss.expireSession(ctx, session.ID)
        return nil, ErrSessionInactive
    }

    // 4. Відновлюємо в кеші
    ss.cacheSession(ctx, session)

    return session, nil
}

Redis caching стратегія:

func (ss *SessionService) cacheSession(
    ctx context.Context,
    session *Session,
) error {
    // Серіалізація
    data, err := json.Marshal(session)
    if err != nil {
        return err
    }

    // Ключ: "session:{token}"
    key := fmt.Sprintf("session:%s", session.Token)

    // TTL = до expire сесії
    ttl := time.Until(session.ExpiresAt)
    if ttl < 0 {
        return nil // Вже expired
    }

    // Збереження в Redis
    return ss.cache.Set(ctx, key, data, ttl).Err()
}

func (ss *SessionService) getSessionFromCache(
    ctx context.Context,
    token string,
) (*Session, error) {
    key := fmt.Sprintf("session:%s", token)

    data, err := ss.cache.Get(ctx, key).Bytes()
    if err != nil {
        return nil, err
    }

    var session Session
    if err := json.Unmarshal(data, &session); err != nil {
        return nil, err
    }

    return &session, nil
}

Multi-device support

Стратегії для кількох пристроїв:

1. Необмежена кількість (не рекомендовано):

// Дозволяємо будь-яку кількість активних сесій
// Ризик: один акаунт = багато користувачів

2. Ліміт на користувача (рекомендовано):

func (ss *SessionService) checkSessionLimit(
    ctx context.Context,
    userID string,
) error {
    count, err := ss.getActiveSessionCount(ctx, userID)
    if err != nil {
        return err
    }

    if count >= ss.config.MaxSessionsPerUser {
        // Варіант A: відхилити новий логін
        return ErrTooManySessions

        // Варіант B: видалити найстарішу сесію
        // if err := ss.removeOldestSession(ctx, userID); err != nil {
        //     return err
        // }
    }

    return nil
}

3. Один пристрій = одна сесія:

func (ss *SessionService) CreateOrUpdateSession(
    ctx context.Context,
    req *CreateSessionRequest,
) (*Session, error) {
    // Знайти існуючу сесію для цього пристрою
    existing, _ := ss.getSessionByDevice(ctx, req.UserID, req.DeviceID)

    if existing != nil {
        // Оновлюємо існуючу
        existing.Token = ss.tokenGen.GenerateToken()
        existing.ExpiresAt = time.Now().Add(ss.config.DefaultTTL)
        existing.LastSeenAt = time.Now()

        ss.updateSession(ctx, existing)
        return existing, nil
    }

    // Створюємо нову
    return ss.CreateSession(ctx, req)
}

Синхронізація між пристроями:

// WebSocket event для інвалідації сесії
type SessionInvalidatedEvent struct {
    SessionID string
    UserID    string
    Reason    string
}

func (ss *SessionService) InvalidateSession(
    ctx context.Context,
    sessionID string,
    reason string,
) error {
    // 1. Оновлення в БД
    if err := ss.expireSession(ctx, sessionID); err != nil {
        return err
    }

    // 2. Видалення з Redis
    session, _ := ss.getSession(ctx, sessionID)
    if session != nil {
        key := fmt.Sprintf("session:%s", session.Token)
        ss.cache.Del(ctx, key)
    }

    // 3. Повідомлення всіх пристроїв користувача
    if session != nil {
        ss.eventBus.Publish(SessionInvalidatedEvent{
            SessionID: sessionID,
            UserID:    session.UserID,
            Reason:    reason,
        })
    }

    return nil
}

Rate Limiting на рівні сесії

Token bucket algorithm:

type SessionRateLimiter struct {
    cache *redis.Client
}

func (srl *SessionRateLimiter) CheckRateLimit(
    ctx context.Context,
    sessionID string,
    limit int,
    window time.Duration,
) error {
    key := fmt.Sprintf("ratelimit:session:%s", sessionID)

    // Lua script для атомарної перевірки
    script := `
        local current = redis.call('INCR', KEYS[1])
        if current == 1 then
            redis.call('EXPIRE', KEYS[1], ARGV[2])
        end
        if current > tonumber(ARGV[1]) then
            return 0
        end
        return 1
    `

    result, err := srl.cache.Eval(
        ctx,
        script,
        []string{key},
        limit,
        int(window.Seconds()),
    ).Int()

    if err != nil {
        return err
    }

    if result == 0 {
        return ErrRateLimitExceeded
    }

    return nil
}

// Використання:
func (api *APIHandler) HandleRequest(w http.ResponseWriter, r *http.Request) {
    session := r.Context().Value("session").(*Session)

    // 100 requests per minute
    if err := api.rateLimiter.CheckRateLimit(
        r.Context(),
        session.ID,
        100,
        time.Minute,
    ); err != nil {
        http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
        return
    }

    // Обробка запиту...
}

Sliding window rate limiting:

func (srl *SessionRateLimiter) CheckSlidingWindow(
    ctx context.Context,
    sessionID string,
    limit int,
    window time.Duration,
) error {
    key := fmt.Sprintf("ratelimit:sliding:%s", sessionID)
    now := time.Now().UnixNano()
    windowStart := now - window.Nanoseconds()

    pipe := srl.cache.Pipeline()

    // Видалити старі записи
    pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprint(windowStart))

    // Додати поточний запит
    pipe.ZAdd(ctx, key, redis.Z{
        Score:  float64(now),
        Member: now,
    })

    // Порахувати кількість запитів
    pipe.ZCard(ctx, key)

    // Встановити TTL
    pipe.Expire(ctx, key, window)

    cmds, err := pipe.Exec(ctx)
    if err != nil {
        return err
    }

    count := cmds[2].(*redis.IntCmd).Val()
    if count > int64(limit) {
        return ErrRateLimitExceeded
    }

    return nil
}

Lazy Session Reconstruction

Для оптимізації пам'яті не зберігаємо всі дані в сесії.

type MinimalSession struct {
    Token     string
    UserID    string
    ExpiresAt time.Time
}

type FullSession struct {
    MinimalSession
    User      *User      // Lazy loaded
    Wallet    *Wallet    // Lazy loaded
    Permissions []string // Lazy loaded
}

func (ss *SessionService) GetFullSession(
    ctx context.Context,
    token string,
) (*FullSession, error) {
    // 1. Отримати мінімальну сесію
    minimal, err := ss.ValidateSession(ctx, token)
    if err != nil {
        return nil, err
    }

    full := &FullSession{
        MinimalSession: MinimalSession{
            Token:     minimal.Token,
            UserID:    minimal.UserID,
            ExpiresAt: minimal.ExpiresAt,
        },
    }

    // 2. Lazy load користувача (тільки якщо потрібно)
    // full.User, _ = ss.userService.GetUser(ctx, minimal.UserID)

    return full, nil
}

// В API middleware завжди використовуємо мінімальну версію
func (api *API) AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := extractToken(r)

        // Тільки валідація токену - без додаткових даних
        session, err := api.sessionService.ValidateSession(r.Context(), token)
        if err != nil {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        // Додаємо мінімальну сесію в контекст
        ctx := context.WithValue(r.Context(), "session", session)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Захист від підбору токенів

1. Достатня ентропія:

// 32 bytes = 256 bits = 2^256 можливих комбінацій
// Це 10^77 комбінацій - більше ніж атомів у Всесвіті
const TokenSize = 32

2. Rate limiting на валідацію:

func (ss *SessionService) ValidateSessionWithRateLimit(
    ctx context.Context,
    token string,
    ip string,
) (*Session, error) {
    // Ліміт: 10 невдалих спроб на IP за хвилину
    key := fmt.Sprintf("failed_auth:%s", ip)

    count, _ := ss.cache.Incr(ctx, key).Result()
    if count == 1 {
        ss.cache.Expire(ctx, key, time.Minute)
    }

    if count > 10 {
        // Блокуємо IP на 15 хвилин
        ss.cache.Expire(ctx, key, 15*time.Minute)
        return nil, ErrTooManyAttempts
    }

    session, err := ss.ValidateSession(ctx, token)
    if err != nil {
        return nil, err
    }

    // Успішна валідація - очищаємо лічильник
    ss.cache.Del(ctx, key)

    return session, nil
}

3. Моніторинг підозрілої активності:

type SecurityMonitor struct {
    alerter *Alerter
}

func (sm *SecurityMonitor) CheckSuspiciousActivity(
    ctx context.Context,
    ip string,
) {
    // Перевірка в останні 5 хвилин
    stats, err := sm.getIPStats(ctx, ip, 5*time.Minute)
    if err != nil {
        return
    }

    // Алерт якщо багато невдалих спроб
    if stats.FailedAttempts > 50 {
        sm.alerter.SendAlert(Alert{
            Level:   AlertCritical,
            Message: fmt.Sprintf("Possible brute force from IP: %s", ip),
            Metadata: map[string]interface{}{
                "ip":              ip,
                "failed_attempts": stats.FailedAttempts,
                "time_window":     "5m",
            },
        })
    }

    // Алерт якщо підозріло багато різних user_id
    if stats.UniqueUserIDs > 20 {
        sm.alerter.SendAlert(Alert{
            Level:   AlertWarning,
            Message: fmt.Sprintf("Suspicious activity from IP: %s", ip),
            Metadata: map[string]interface{}{
                "ip":              ip,
                "unique_users":    stats.UniqueUserIDs,
            },
        })
    }
}

Session cleanup та експірація

Background worker для очистки:

type SessionCleaner struct {
    db     *sql.DB
    cache  *redis.Client
    ticker *time.Ticker
}

func (sc *SessionCleaner) Start(ctx context.Context) {
    sc.ticker = time.NewTicker(5 * time.Minute)

    go func() {
        for {
            select {
            case <-ctx.Done():
                sc.ticker.Stop()
                return
            case <-sc.ticker.C:
                sc.cleanup(ctx)
            }
        }
    }()
}

func (sc *SessionCleaner) cleanup(ctx context.Context) {
    // 1. Експірувати старі сесії в БД
    result, err := sc.db.ExecContext(ctx, `
        UPDATE sessions
        SET status = 'expired'
        WHERE status = 'active'
          AND (expires_at < NOW() 
               OR last_seen_at < NOW() - INTERVAL '30 minutes')
    `)

    if err != nil {
        log.Error("Failed to expire sessions", "error", err)
        return
    }

    affected, _ := result.RowsAffected()
    if affected > 0 {
        log.Info("Expired sessions", "count", affected)
    }

    // 2. Видалити дуже старі записи (старіші 30 днів)
    sc.db.ExecContext(ctx, `
        DELETE FROM sessions
        WHERE created_at < NOW() - INTERVAL '30 days'
    `)

    // 3. Очистка Redis (scan + delete)
    // Це робить сам Redis через TTL
}

Performance benchmarks

Operation                    | Latency (p50) | Latency (p99) | QPS
-----------------------------|---------------|---------------|--------
ValidateSession (cache hit)  | 0.8ms         | 2.1ms         | 50,000
ValidateSession (cache miss) | 4.2ms         | 12.5ms        | 10,000
CreateSession                | 5.1ms         | 15.3ms        | 5,000
InvalidateSession            | 2.3ms         | 7.8ms         | 8,000

Висновок

Ефективний Session Service для high-load казино вимагає:

Гібридний підхід — Redis для швидкості + PostgreSQL для надійності

Secure tokens — 256 bits ентропії мінімум

Rate limiting — захист від brute force

Multi-device — продумана стратегія для кількох пристроїв

Lazy loading — мінімум даних у hot path

Моніторинг — виявлення аномалій в реальному часі

Graceful degradation — робота навіть при падінні Redis

Правильна архітектура дозволяє обробляти 100,000+ RPS на сесіях з латентністю < 5ms.

Питання? Діліться досвідом у коментарях! 🔐


This content originally appeared on DEV Community and was authored by Maksim


Print Share Comment Cite Upload Translate Updates
APA

Maksim | Sciencx (2025-11-25T18:15:12+00:00) Session Service: як правильно будувати сесію у high-load казино. Retrieved from https://www.scien.cx/2025/11/25/session-service-%d1%8f%d0%ba-%d0%bf%d1%80%d0%b0%d0%b2%d0%b8%d0%bb%d1%8c%d0%bd%d0%be-%d0%b1%d1%83%d0%b4%d1%83%d0%b2%d0%b0%d1%82%d0%b8-%d1%81%d0%b5%d1%81%d1%96%d1%8e-%d1%83-high-load-%d0%ba%d0%b0%d0%b7/

MLA
" » Session Service: як правильно будувати сесію у high-load казино." Maksim | Sciencx - Tuesday November 25, 2025, https://www.scien.cx/2025/11/25/session-service-%d1%8f%d0%ba-%d0%bf%d1%80%d0%b0%d0%b2%d0%b8%d0%bb%d1%8c%d0%bd%d0%be-%d0%b1%d1%83%d0%b4%d1%83%d0%b2%d0%b0%d1%82%d0%b8-%d1%81%d0%b5%d1%81%d1%96%d1%8e-%d1%83-high-load-%d0%ba%d0%b0%d0%b7/
HARVARD
Maksim | Sciencx Tuesday November 25, 2025 » Session Service: як правильно будувати сесію у high-load казино., viewed ,<https://www.scien.cx/2025/11/25/session-service-%d1%8f%d0%ba-%d0%bf%d1%80%d0%b0%d0%b2%d0%b8%d0%bb%d1%8c%d0%bd%d0%be-%d0%b1%d1%83%d0%b4%d1%83%d0%b2%d0%b0%d1%82%d0%b8-%d1%81%d0%b5%d1%81%d1%96%d1%8e-%d1%83-high-load-%d0%ba%d0%b0%d0%b7/>
VANCOUVER
Maksim | Sciencx - » Session Service: як правильно будувати сесію у high-load казино. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/11/25/session-service-%d1%8f%d0%ba-%d0%bf%d1%80%d0%b0%d0%b2%d0%b8%d0%bb%d1%8c%d0%bd%d0%be-%d0%b1%d1%83%d0%b4%d1%83%d0%b2%d0%b0%d1%82%d0%b8-%d1%81%d0%b5%d1%81%d1%96%d1%8e-%d1%83-high-load-%d0%ba%d0%b0%d0%b7/
CHICAGO
" » Session Service: як правильно будувати сесію у high-load казино." Maksim | Sciencx - Accessed . https://www.scien.cx/2025/11/25/session-service-%d1%8f%d0%ba-%d0%bf%d1%80%d0%b0%d0%b2%d0%b8%d0%bb%d1%8c%d0%bd%d0%be-%d0%b1%d1%83%d0%b4%d1%83%d0%b2%d0%b0%d1%82%d0%b8-%d1%81%d0%b5%d1%81%d1%96%d1%8e-%d1%83-high-load-%d0%ba%d0%b0%d0%b7/
IEEE
" » Session Service: як правильно будувати сесію у high-load казино." Maksim | Sciencx [Online]. Available: https://www.scien.cx/2025/11/25/session-service-%d1%8f%d0%ba-%d0%bf%d1%80%d0%b0%d0%b2%d0%b8%d0%bb%d1%8c%d0%bd%d0%be-%d0%b1%d1%83%d0%b4%d1%83%d0%b2%d0%b0%d1%82%d0%b8-%d1%81%d0%b5%d1%81%d1%96%d1%8e-%d1%83-high-load-%d0%ba%d0%b0%d0%b7/. [Accessed: ]
rf:citation
» Session Service: як правильно будувати сесію у high-load казино | Maksim | Sciencx | https://www.scien.cx/2025/11/25/session-service-%d1%8f%d0%ba-%d0%bf%d1%80%d0%b0%d0%b2%d0%b8%d0%bb%d1%8c%d0%bd%d0%be-%d0%b1%d1%83%d0%b4%d1%83%d0%b2%d0%b0%d1%82%d0%b8-%d1%81%d0%b5%d1%81%d1%96%d1%8e-%d1%83-high-load-%d0%ba%d0%b0%d0%b7/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.