Как хранить состояние в сервисе
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Как хранить состояние в сервисе
Это критически важный вопрос архитектуры. Состояние — это данные, которые меняются и должны быть доступны между запросами. Неправильное хранение состояния — основная причина багов в распределённых системах.
Принцип: Stateless Services
В микросервисной архитектуре сервис должен быть stateless (без состояния). Это означает:
- Каждый запрос независим
- Сервис не хранит данные между запросами
- Любой экземпляр сервиса может обработать любой запрос
Это позволяет:
- Масштабировать горизонтально (добавлять экземпляры)
- Лучше обрабатывать отказы
- Легче разворачивать обновления
Где хранить состояние?
1. Base case — не хранить в памяти сервиса!
ЭТО ПЛОХО:
@Service
public class BadUserService {
private Map<UUID, User> usersCache = new HashMap<>(); // ОПАСНО!
public User getUser(UUID id) {
// Если сервис перезагрузится — все потеряется
return usersCache.getOrDefault(id, null);
}
public void updateUser(UUID id, User user) {
usersCache.put(id, user); // Не синхронизирована с БД
}
}
Проблемы:
- При перезагрузке сервиса состояние теряется
- При горизонтальном масштабировании разные экземпляры имеют разные состояния
- Синхронизация состояния между экземплярами невозможна
2. Правильно — хранить в БД
ЭТО ХОРОШО:
@Service
public class UserService {
private final UserRepository userRepository; // БД — источник истины
public User getUser(UUID id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
public User updateUser(UUID id, UpdateUserRequest request) {
User user = getUser(id); // Читаем из БД
user.setName(request.getName());
user.setEmail(request.getEmail());
return userRepository.save(user); // Сохраняем в БД
}
}
Преимущества:
- Состояние сохраняется при перезагрузке
- Все экземпляры сервиса видят одно и то же состояние
- Масштабируется горизонтально
3. Session State (состояние сессии)
Для веб-приложений нужно хранить состояние пользователя между запросами.
ПЛОХА:
// Сессия в памяти
@Service
public class SessionManager {
private Map<String, SessionData> sessions = new HashMap<>();
public void createSession(String sessionId, SessionData data) {
sessions.put(sessionId, data); // Потеряется при перезагрузке
}
}
ХОРОШО — использовать Redis:
@Service
public class SessionService {
private final RedisTemplate<String, SessionData> redisTemplate;
private static final Duration SESSION_TIMEOUT = Duration.ofHours(1);
public void createSession(String sessionId, SessionData data) {
String key = "session:" + sessionId;
redisTemplate.opsForValue().set(key, data, SESSION_TIMEOUT);
}
public SessionData getSession(String sessionId) {
String key = "session:" + sessionId;
return (SessionData) redisTemplate.opsForValue().get(key);
}
}
Или с JWT токенами (лучший вариант):
@Service
public class JwtAuthService {
private final JwtTokenProvider tokenProvider;
public String generateToken(User user) {
// Все состояние в самом токене (закодировано и подписано)
return tokenProvider.generateToken(user);
}
public User validateAndGetUser(String token) {
// Декодируем токен, получаем данные
return tokenProvider.getUserFromToken(token);
}
}
Кэширование состояния
Иногда нужно кэшировать состояние для производительности, но правильно.
1. Кэш с инвалидацией
@Service
@EnableCaching
public class ProductService {
private final ProductRepository productRepository;
@Cacheable(value = "products", key = "#id")
public Product getProduct(UUID id) {
// Первый раз читаем из БД и кэшируем
return productRepository.findById(id).orElseThrow();
}
@CacheEvict(value = "products", key = "#id")
public Product updateProduct(UUID id, UpdateProductRequest request) {
Product product = getProduct(id);
product.setName(request.getName());
Product updated = productRepository.save(product);
// Кэш автоматически инвалидируется
return updated;
}
@CacheEvict(value = "products", allEntries = true)
public void clearAllCache() {
// Очищаем весь кэш если нужно
}
}
2. Распределённый кэш (Redis)
@Service
public class UserCacheService {
private final UserRepository userRepository;
private final RedisTemplate<String, User> redisTemplate;
private static final Duration CACHE_TTL = Duration.ofMinutes(30);
public User getUser(UUID id) {
String key = "user:" + id;
// Пытаемся получить из кэша
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// Если нет в кэше, читаем из БД
user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
// Сохраняем в кэш
redisTemplate.opsForValue().set(key, user, CACHE_TTL);
return user;
}
public User updateUser(UUID id, UpdateUserRequest request) {
User user = getUser(id); // Может быть из кэша
user.setName(request.getName());
User updated = userRepository.save(user);
// Инвалидируем кэш
String key = "user:" + id;
redisTemplate.delete(key);
return updated;
}
}
State Machines (конечные автоматы)
Для процессов с состояниями используйте State Machine, но храните состояние в БД:
// Статус заказа
public enum OrderStatus {
NEW, PROCESSING, SHIPPED, DELIVERED, CANCELLED
}
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status = OrderStatus.NEW;
@Version // Оптимистичная блокировка
private Long version;
}
@Service
public class OrderService {
private final OrderRepository orderRepository;
@Transactional
public void processOrder(UUID orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
// Проверяем текущее состояние
if (order.getStatus() != OrderStatus.NEW) {
throw new InvalidOrderStateException("Cannot process order in status: " + order.getStatus());
}
// Меняем состояние
order.setStatus(OrderStatus.PROCESSING);
orderRepository.save(order); // Сохраняем в БД
}
@Transactional
public void shipOrder(UUID orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
if (order.getStatus() != OrderStatus.PROCESSING) {
throw new InvalidOrderStateException("Can only ship orders in PROCESSING status");
}
order.setStatus(OrderStatus.SHIPPED);
orderRepository.save(order);
}
}
User Session State (для веб-приложений)
Spring Session с Redis
@Configuration
@EnableSpringHttpSession
public class HttpSessionConfig {
@Bean
public LettuceConnectionFactory connectionFactory() {
return new LettuceConnectionFactory();
}
}
// Использование
@RestController
public class UserController {
@PostMapping("/login")
public void login(@RequestBody LoginRequest request, HttpSession session) {
User user = authenticate(request);
session.setAttribute("userId", user.getId()); // Сохраняется в Redis
}
@GetMapping("/me")
public UserDTO getCurrentUser(HttpSession session) {
UUID userId = (UUID) session.getAttribute("userId"); // Читается из Redis
return new UserDTO(userService.getUser(userId));
}
}
Асинхронное состояние (Long-running processes)
Для длительных операций используйте Job/Task table:
@Entity
@Table(name = "async_jobs")
public class AsyncJob {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Enumerated(EnumType.STRING)
private JobStatus status = JobStatus.PENDING; // NEW, RUNNING, COMPLETED, FAILED
private String jobType;
private String payload; // JSON with job data
private String result; // JSON with result
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
@Service
public class AsyncJobService {
private final AsyncJobRepository jobRepository;
public UUID startJob(String jobType, String payload) {
AsyncJob job = new AsyncJob();
job.setJobType(jobType);
job.setPayload(payload);
job.setStatus(JobStatus.PENDING);
AsyncJob saved = jobRepository.save(job);
// Отправляем в очередь обработки (Kafka, RabbitMQ)
jobQueue.send(new JobMessage(saved.getId(), jobType, payload));
return saved.getId();
}
public AsyncJob getJobStatus(UUID jobId) {
return jobRepository.findById(jobId)
.orElseThrow(() -> new JobNotFoundException(jobId));
}
}
Правила хранения состояния
-
Основное правило: Состояние в БД, не в памяти
-
Кэширование: Кэшируйте для производительности, но с инвалидацией
-
Распределённое состояние: Используйте Redis для сессий и кэша
-
Транзакции: Используйте @Transactional для атомарности
-
Оптимистичная блокировка: Используйте @Version для конкурентного доступа
-
Асинхронные процессы: Храните статус в БД
Сравнение подходов
| Тип состояния | Где хранить | Инструмент | Timeout |
|---|---|---|---|
| Пользовательские данные | БД | PostgreSQL, MySQL | N/A |
| Кэш | Распределённый кэш | Redis, Memcached | 5-30 мин |
| Сессия | Распределённый кэш | Redis, Session Store | 30 мин - 1 день |
| Токен | Клиент (JWT) | JWT | 1 час - 1 день |
| Очередь задач | БД + очередь | Kafka, RabbitMQ | До обработки |
| Кэш BDD | In-memory + инвалидация | Spring Cache | По необходимости |
Заключение
Основной принцип: сервис должен быть stateless, состояние хранится в БД или Redis. Это обеспечивает масштабируемость, надёжность и простоту отладки распределённых систем.