Session Service: як правильно будувати сесію у high-load казино
Управління сесіями в онлайн-казино — це не просто "зберігати токен у Redis". При навантаженні в десятки тисяч одночасних гравців кожна архітектурна помилка може призвести до втрати коштів, витоку даних або відмови сервісу. Розглянемо перевірені рішення.
Вимоги до session service в казино
Критичні вимоги:
- Безпека — неможливість підробки або викрадення сесії
- Масштабованість — мільйони активних сесій одночасно
- Продуктивність — перевірка сесії < 10ms
- Multi-device — один користувач на кількох пристроях
- Швидка інвалідація — logout/ban миттєво діють
- 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.
Питання? Діліться досвідом у коментарях! 🔐
Top comments (0)