7. Stateless vs Stateful сервисы
Архитектура без состояния и управление данными
«Система с состоянием подобна человеку, который помнит каждое обещание, данное в беседе. Система без состояния — словно математик, требующий, чтобы каждое уравнение было абсолютно самодостаточным. Математика несравнимо легче рассадить за тысячу рабочих столов».
— Свободная интерпретация классического принципа Bell Labs
В предыдущей главе мы выяснили, что горизонтальное масштабирование (Scale Out) основано на способности распределять входящий трафик между десятками или сотнями независимых серверов. Однако за этим принципом стоит фундаментальное инженерное требование: любой входящий запрос должен успешно обрабатываться совершенно любым экземпляром сервиса в кластере.
Это свойство напрямую зависит от того, где именно живет состояние системы (State). Разделение архитектуры на Stateless (Без сохранения состояния) и Stateful (С сохранением состояния) — это один из ключевых водоразделов в System Design, определяющий отказоустойчивость, простоту обновлений и физическую нагрузку на рантайм Go.
В этой главе мы разберем анатомию обоих подходов, выясним, почему Go идеально предрасположен к созданию stateless-сервисов, и рассмотрим проверенные шаблоны выноса состояния во внешние надежные хранилища.
Определения
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Stateless["Stateless Архитектура (Идеальный Scale Out)"]
S1["Инстанс 1 (Go)"]
S2["Инстанс 2 (Go)"]
S3["Инстанс N (Go)"]
end
subgraph Stateful["Stateful Хранилища (Stateful Backends)"]
R[("Redis (Сессии, Кэш)")]
DB[("PostgreSQL (БД)")]
K[("Kafka (Потоки событий)")]
end
LB["Балансировщик нагрузки (Round Robin / Least Conn)"]
Client["Клиентский запрос"]:::entry
Client --> LB
LB --> S1
LB --> S2
LB --> S3
S1 --> R
S2 --> DB
S3 --> K
Stateless-сервис не сохраняет никакой информации о клиенте или сессии между последовательными вызовами. Каждый входящий запрос самодостаточен: он несет в себе все необходимые метаданные (токены аутентификации, идентификаторы ресурсов) для выполнения бизнес-логики. Такой сервис можно мгновенно остановить, перезапустить, обновить или размножить на сотню экземпляров — ни один клиент не заметит потери контекста.
Примеры: публичные REST/gRPC API, обработчики вебхуков, конвертеры медиафайлов.
Stateful-сервис удерживает изменяемую информацию между запросами. Это состояние критически важно для корректности бизнес-процесса: сессии пользователей, активные сокеты чатов, данные незавершенных финансовых транзакций или локальные индексы поиска. Аварийный перезапуск такого сервиса ведет к обрыву сессий и потере промежуточных данных.
Примеры: реляционные базы данных, брокеры сообщений, распределенные кэши, серверы сетевых игр реального времени.
Почему Stateless — основа горизонтального масштабирования
При горизонтальном масштабировании балансировщик (L4/L7) отправляет сетевые пакеты по алгоритмам Round Robin или Least Connections. Запрос №1 от пользователя может прийти на Инстанс 1, а запрос №2 через секунду — на Инстанс 5. В Stateless-архитектуре это тривиально: оба инстанса одинаково готовы к работе.
Если же сервис спроектирован как Stateful (хранит сессии в локальной памяти), архитектура вынуждена внедрять костыли в виде привязки сессий (Session Affinity / Sticky Sessions). Балансировщик обязан вычислять хэш от IP-клиента или проверять специальную cookie, направляя трафик строго на один и тот же сервер.
Почему Sticky Sessions — это архитектурный тупик:
- Каскадные отказы: При падении инстанса все привязанные к нему пользователи выбрасываются из системы.
- Неравномерное распределение (Load Imbalance): Десять «тяжелых» пользователей могут перегрузить один инстанс, пока остальные девять простаивают.
- Блокировка Rolling Updates: Нельзя плавно обновить кластер в Kubernetes без принудительного разрыва сессий пользователей.
Go и Stateless: естественная пара
Философия языка Go — явность, простота и механическая строгость — идеально согласуется со Stateless-парадигмой.
Request-scoped контекст
Пакет context — фундаментальная основа stateless-обработки в Go. Контекст зарождается в момент принятия HTTP-сокета, пронизывает все слои бизнес-логики и базы данных, неся таймауты, сигналы отмены и сквозные ID трассировки, специфичные для конкретного запроса. Как только обработчик завершает работу, контекст и все связанные с ним объекты утилизируются сборщиком мусора.
package handler
import (
"context"
"net/http"
)
type contextKey string
const requestIDKey contextKey = "request_id"
func generateRequestID() string {
return "req-12345"
}
func HandleOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Добавляем строго request-scoped метаданные в контекст
ctx = context.WithValue(ctx, requestIDKey, generateRequestID())
// Передаем контекст дальше в use case слой
_ = ctx
w.WriteHeader(http.StatusOK)
}
Отсутствие глобальных переменных по умолчанию
В идиоматичном Go-коде глобальное изменяемое состояние считается дурным тоном. Вместо опасных глобальных карт var globalCache map[string]User зависимости передаются явно в структуры через параметры конструктора:
package service
import (
"database/sql"
"github.com/redis/go-redis/v9"
)
type Service struct {
cache *redis.Client
db *sql.DB
}
func NewService(cache *redis.Client, db *sql.DB) *Service {
return &Service{cache: cache, db: db}
}
Каждый экземпляр микросервиса получает собственные независимые пулы сокетов к внешним базам данных, не сохраняя локального состояния в памяти.
Горутины и локальное состояние
Горутины внутри одного процесса делят общее адресное пространство. Соблазн использовать sync.Map или глобальные срезы для кэширования между вызовами часто подталкивает разработчиков к созданию неявных stateful-ловушек:
package badpractice
import (
"encoding/json"
"net/http"
"sync"
)
// ОПАСНО для горизонтально масштабируемого сервиса:
var localCache sync.Map
func Handler(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
if val, ok := localCache.Load(key); ok {
json.NewEncoder(w).Encode(val)
return
}
// ...
}
Такой сервис невозможно надежно масштабировать за балансировщиком: кэш инстанса А невидим инстансу Б. Пользователи будут наблюдать непредсказуемое мерцание данных в зависимости от того, на какой под смаршрутизируется очередной запрос.
Управление состоянием в Go: выносим наружу
Если бизнес-логике требуется хранить состояние, системный архитектор обязан вынести его в специализированные отказоустойчивые сервисы.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
App["Go Stateless Service"]:::data -->|"Сессии и кэш (TTL)"| Redis[("Redis")]
App -->|"Транзакции и история"| PG[("PostgreSQL")]
App -->|"Длительные процессы"| Temp[("Temporal.io / Очереди")]
1. Сессии пользователей → Redis
Вместо локальных карт map[sessionID]User сессия сохраняется в распределенном Redis с автоматическим временем жизни (TTL):
package session
import (
"context"
"encoding/json"
"errors"
"github.com/redis/go-redis/v9"
)
var ErrSessionNotFound = errors.New("сессия не найдена")
type User struct {
ID string `json:"id"`
Email string `json:"email"`
}
type SessionManager struct {
redis *redis.Client
}
func (s *SessionManager) GetUserFromSession(ctx context.Context, sessionID string) (*User, error) {
data, err := s.redis.Get(ctx, "session:"+sessionID).Bytes()
if err == redis.Nil {
return nil, ErrSessionNotFound
}
if err != nil {
return nil, err
}
var user User
if err := json.Unmarshal(data, &user); err != nil {
return nil, err
}
return &user, nil
}
2. Кэш → Redis или Memcached
Внешний кэш решает проблему согласованности между репликами приложения и сохраняет накопленные данные при плановом перезапуске подов в Kubernetes.
3. Длительные операции и workflow → Temporal или очереди
Если бизнес-процесс длится минуты, часы или дни (например, оплата заказа, ожидание подтверждения банком, доставка), ни в коем случае нельзя держать состояние в памяти Go-приложения:
- База данных: запись статуса процесса в таблицу
OrderProcess. - Оркестратор Temporal.io: движок распределенных workflow, гарантирующий детерминированное сохранение и восстановление состояния.
- Очереди сообщений (RabbitMQ, Kafka): асинхронные цепочки событий с отсроченной доставкой.
package process
import (
"context"
"database/sql"
"time"
)
// Хорошо: состояние процесса зафиксировано в СУБД
type OrderProcess struct {
ID string
Status string
CreatedAt time.Time
}
type Service struct {
db *sql.DB
}
func (s *Service) UpdateOrderStatus(ctx context.Context, orderID, status string) error {
_, err := s.db.ExecContext(ctx,
"UPDATE orders SET status = $1 WHERE id = $2", status, orderID)
return err
}
Mechanical Sympathy: физическая цена состояния
Хранение состояния в памяти процесса влечет прямые аппаратные последствия:
Память и Garbage Collector
Локальный кэш на миллионы записей раздувает кучу (Heap). При каждом цикле сборки мусора GC вынужден тратить такты процессора на обход миллионов долгоживущих указателей. При горизонтальном масштабировании память мультиплицируется: 10 подов по 2 ГБ кэша съедят 20 ГБ физической памяти нод. Вынос кэша в Redis оставляет кучу Go компактной, сводя паузы GC к микросекундам.
Cache Locality и процессорные кэши
Локальная память обеспечивает попадание в кэши L1/L2 процессора (задержка ~1–4 нс). Однако попытка синхронизировать этот локальный кэш между репликами через сетевые протоколы сжигает весь выигрыш. Сетевой раундтрип до Redis по локальной сети дата-центра занимает ~0.5–1 мс, что на три порядка быстрее дисковой базы данных и обеспечивает идеальную масштабируемость.
Системные вызовы и сеть
Если сервис сохраняет файлы (аватары пользователей, PDF-отчеты) на локальный диск сервера, горизонтальное масштабирование невозможно без использования распределенных файловых систем (NFS, EFS) или масштабируемого объектного хранилища (S3).
Когда Stateful оправдан
Не все компоненты обязаны быть stateless. Существуют фундаментальные классы систем, чье предназначение — надежно хранить состояние:
- Реляционные и распределенные базы данных (PostgreSQL, Cassandra) — реализуют алгоритмы консенсуса (Raft, Paxos), репликацию и дисковые WAL-журналы.
- Брокеры очередей (Kafka, RabbitMQ) — буферизуют терабайты сообщений на NVMe-дисках.
- Распределенные кэши (Redis Cluster) — управляют шардированием и партициями данных.
- Сетевые игровые серверы реального времени — где задержка в 5 мс недопустима, и физическое состояние матча рассчитывается в памяти сервера (а при отказе сбрасывается или восстанавливается из реплики).
Для таких сценариев в распределенных системах применяются строгие механизмы шардирования и репликации (31. Partitioning и Sharding, 32. Репликация. Leader Follower и Multi Leader).
Паттерны проектирования Stateless сервисов в Go
1. Dependency Injection через конструкторы
Все внешние ресурсы (клиенты баз данных, Redis, сетевые интерфейсы) внедряются явно при создании сервиса. Глобальное состояние исключено:
package app
import "github.com/redis/go-redis/v9"
type OrderRepository interface{}
type PaymentClient interface{}
type OrderService struct {
repo OrderRepository
payment PaymentClient
cache *redis.Client
}
func NewOrderService(repo OrderRepository, payment PaymentClient, cache *redis.Client) *OrderService {
return &OrderService{repo: repo, payment: payment, cache: cache}
}
2. Middleware для извлечения контекста запроса
Сессионные токены, роли и метаданные пользователя расшифровываются в Middleware и передаются строго внутри context.Context:
package auth
import (
"context"
"net/http"
)
type userContextKey string
const userKey userContextKey = "authenticated_user"
type User struct {
ID string
}
func extractUserFromToken(r *http.Request) (*User, error) {
return &User{ID: "usr-42"}, nil
}
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := extractUserFromToken(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
3. Бесшовный Graceful Shutdown
Использование http.Server.Shutdown() с контекстом таймаута гарантирует, что при выводе пода из кластера активные HTTP-транзакции завершатся корректно без обрыва клиентских соединений (как мы разбирали в 6. Вертикальное и горизонтальное масштабирование).
Связь с CAP-теоремой и распределенными системами
Выбор между stateless и stateful архитектурой напрямую соотносится с компромиссами CAP-теоремы (30. CAP теорема и реальные компромиссы):
- Stateless-сервисы тяготеют к высокой доступности (AP), так как любая нода взаимозаменяема.
- Stateful-хранилища (например, реляционные базы с синхронной репликацией) обязаны строго блюсти согласованность (CP), жертвуя доступностью при сетевых разделениях кластера.
Итог
- Stateless-сервисы не сохраняют данные о клиенте в локальной памяти. Они идеально подходят для горизонтального масштабирования, отказоустойчивы и не требуют сложных механизмов Sticky Sessions.
- Язык Go органически поощряет stateless-подход благодаря сквозному
context.Context, явному Dependency Injection и отсутствию глобальных мутаций. - Локальное состояние (in-memory maps) кажется заманчиво быстрым, но парализует горизонтальное масштабирование и перегружает Garbage Collector.
- Выносите долгоживущее состояние в специализированные хранилища (Redis, PostgreSQL, Temporal, S3), сохраняя вычислительный слой Go легким и эластичным.
Теперь, когда мы освоили фундамент системного масштабирования, пора обратиться к классической форме архитектуры, с которой начинается практически каждый стартап: Монолиту. В следующей лекции мы разберем, когда монолитная архитектура является оптимальным выбором, а когда превращается в непреодолимую проблему: 8. Монолит. Когда он хорош и когда становится проблемой.