Привет, Хабр! Я сегодня хочу разобрать одну из самых мощных, но часто неправильно понимаемых архитектурных концепций — CQRS. Если вы уже переросли уровень «просто писать CRUD» и задумываетесь о том, как строить системы, которые будут масштабироваться и оставаться производительными — эта статья для вас.
Почему CRUD иногда не работает
Давайте начнем с классики. Представьте, что вы делаете любой современный сервис — соцсеть, маркетплейс, трекер задач. У вас есть сущность Пользователь, Заказ, Пост. Стандартный подход:
// Типичный REST-контроллер @RestController @RequestMapping("/api/orders") public class OrderController { @GetMapping("/{id}") public Order getOrder(@PathVariable Long id) { return orderRepository.findById(id); } @PostMapping public Order createOrder(@RequestBody Order order) { return orderRepository.save(order); } @PutMapping("/{id}") public Order updateOrder(@PathVariable Long id, @RequestBody Order order) { order.setId(id); return orderRepository.save(order); } }
Кажется, всё логично? Пока у вас 100 пользователей — да. Но давайте рассмотрим реальные проблемы, которые возникают на практике:
Проблема 1: Разные требования к чтению и записи
Запись (Command) должна быть консистентной, транзакционной, валидной
Чтение (Query) должно быть быстрым, можно кэшировать, можно денормализовать
В CRUD вы используете одну и ту же модель для всего. Это как пытаться готовить ужин и есть его с одной тарелки — неудобно и медленно.
Проблема 2: Блокировки и конкуренция
Представьте: 1000 пользователей одновременно смотрят свои заказы (SELECT), и 10 пользователей в этот момент создают новые заказы (INSERT/UPDATE). В PostgreSQL при определенных условиях чтения могут блокироваться на запись и наоборот.
Проблема 3: Сложные агрегации
Когда вам нужно показать дашборд с метриками «среднее время доставки», «конверсия по категориям», «динамика продаж», вы пишете монструозные SQL-запросы с 5-7 JOIN'ами. Они убивают производительность.
CQRS: Разделяй и властвуй
CQRS (Command Query Responsibility Segregation) — это принцип разделения модели на две:
Command модель — для изменения состояния
Query модель — для чтения данных
Реальный пример: система заказов
Давайте разберем на конкретном примере, как CQRS решает наши проблемы.
Шаг 1: Разделяем команды и запросы
Command стор (пишем):
// Command - создание заказа public class CreateOrderCommand { private UUID orderId; private UUID customerId; private List<OrderItem> items; private Address shippingAddress; private PaymentMethod paymentMethod; } // Обработчик команды @Service @Transactional public class CreateOrderHandler { public void handle(CreateOrderCommand command) { // Валидация бизнес-правил if (command.getItems().isEmpty()) { throw new ValidationException("Заказ не может быть пустым"); } Customer customer = customerRepository .findById(command.getCustomerId()) .orElseThrow(() -> new CustomerNotFoundException()); // Проверка лимитов if (customer.hasTooManyPendingOrders()) { throw new BusinessRuleException("Слишком много активных заказов"); } // Создаем агрегат Order order = new Order( command.getOrderId(), command.getCustomerId(), command.getItems(), OrderStatus.CREATED ); // Сохраняем в базу команд orderRepository.save(order); // Публикуем событие eventPublisher.publish(new OrderCreatedEvent( order.getId(), order.getCustomerId(), order.getTotalAmount() )); } }
Query стор (читаем):
// Специализированная модель для чтения @Entity @Table(name = "order_summaries") public class OrderSummary { @Id private UUID orderId; private UUID customerId; private String customerName; // Денормализовано! private BigDecimal totalAmount; private String status; private LocalDateTime createdAt; private LocalDateTime updatedAt; // Никакой бизнес-логики, только геттеры } // Репозиторий для быстрого чтения @Repository public interface OrderSummaryRepository extends JpaRepository<OrderSummary, UUID> { // Простые, быстрые запросы List<OrderSummary> findByCustomerId(UUID customerId); @Query("SELECT o FROM OrderSummary o WHERE " + "o.status = :status AND o.createdAt >= :since") List<OrderSummary> findRecentByStatus( @Param("status") String status, @Param("since") LocalDateTime since ); }
Шаг 2: Синхронизация данных
Как данные попадают из Command базы в Query базу? Есть несколько подходов:
1. Event Sourcing + Projections (самый чистый):
// Событие public class OrderCreatedEvent { private UUID orderId; private UUID customerId; private List<OrderItem> items; private BigDecimal totalAmount; } // Projection (проекция) @Component public class OrderSummaryProjection { @EventHandler public void on(OrderCreatedEvent event) { // Читаем дополнительные данные Customer customer = customerRepository .findById(event.getCustomerId()); // Создаем денормализованную запись OrderSummary summary = new OrderSummary(); summary.setOrderId(event.getOrderId()); summary.setCustomerId(event.getCustomerId()); summary.setCustomerName(customer.getName()); // Денормализация! summary.setTotalAmount(event.getTotalAmount()); summary.setStatus("CREATED"); summary.setCreatedAt(LocalDateTime.now()); orderSummaryRepository.save(summary); } @EventHandler public void on(OrderStatusChangedEvent event) { // Обновляем только нужное поле orderSummaryRepository.updateStatus( event.getOrderId(), event.getNewStatus() ); } }
2. Change Data Capture (CDC) — проще для старта:
-- Используем PostgreSQL logical replication CREATE PUBLICATION order_publication FOR TABLE orders; -- Или используем Debezium для автоматического -- отслеживания изменений и отправки в Kafka
3. Dual-write (самый простой, но менее надежный):
// В обработчике команды пишем в обе базы public void handle(CreateOrderCommand command) { // 1. Пишем в command базу orderRepository.save(order); // 2. Сразу пишем в query базу orderSummaryRepository.save(createSummary(order)); // Проблема: что если вторая запись упадет? }
Шаг 3: Преимущества, которые мы получаем
Масштабируемость чтения отдельно от записи
Query базу можно реплицировать сколько угодно
Можно использовать разные СУБД: PostgreSQL для команд, Elasticsearch для поиска, Cassandra для временных рядов
Оптимизированные модели
-- Вместо сложного JOIN'а: SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id JOIN order_items i ON o.id = i.order_id WHERE o.status = 'PENDING' AND c.country = 'RU' -- Имеем готовую денормализованную таблицу: SELECT * FROM order_summaries WHERE status = 'PENDING' AND customer_country = 'RU' -- В 10-100 раз быстрее!
Разные команды разработки могут работать параллельно
Команда "оплаты" работает с Command стороной
Команда "аналитики" работает с Query стороной
Меньше конфликтов в коде
Паттерны, которые работают с CQRS
Event Sourcing: полный аудит системы
Если CQRS — это разделение, то Event Sourcing — это хранение. Вместо хранения текущего состояния, мы храним все события, которые к нему привели.
// Агрегат с Event Sourcing public class OrderAggregate { private UUID id; private OrderState state; private List<DomainEvent> changes = new ArrayList<>(); public void createOrder(UUID orderId, UUID customerId, List<OrderItem> items) { apply(new OrderCreatedEvent(orderId, customerId, items)); } public void confirmPayment(UUID paymentId) { if (state.status != OrderStatus.CREATED) { throw new IllegalStateException("Заказ не в том статусе"); } apply(new OrderPaidEvent(id, paymentId)); } private void apply(DomainEvent event) { // 1. Обновляем состояние this.state = applyEvent(state, event); // 2. Сохраняем событие changes.add(event); } // Восстановление состояния из событий public static OrderAggregate recreateFromEvents(List<DomainEvent> events) { OrderAggregate aggregate = new OrderAggregate(); for (DomainEvent event : events) { aggregate.state = aggregate.applyEvent(aggregate.state, event); } return aggregate; } }
Saga для распределенных транзакций
В микросервисной архитектуре CQRS часто сочетается с Saga-паттерном:
// Saga для процесса "Оформление заказа" @Component public class OrderSaga { @StartSaga @SagaEventHandler(associationProperty = "orderId") public void handle(OrderCreatedEvent event) { // 1. Резервируем товары на складе commandGateway.send(new ReserveStockCommand( event.getOrderId(), event.getItems() )); } @SagaEventHandler(associationProperty = "orderId") public void handle(StockReservedEvent event) { // 2. Если товары зарезервированы, создаем платеж commandGateway.send(new CreatePaymentCommand( event.getOrderId(), event.getTotalAmount() )); } @SagaEventHandler(associationProperty = "orderId") public void handle(PaymentCompletedEvent event) { // 3. Если оплата прошла, подтверждаем заказ commandGateway.send(new ConfirmOrderCommand( event.getOrderId() )); // Завершаем сагу SagaLifecycle.end(); } }
Когда НЕ нужно использовать CQRS
CQRS — не серебряная пуля. Он добавляет сложности:
Eventual Consistency: данные в Query стороне немного отстают (секунды, иногда минуты)
Сложность отладки: теперь у вас два источника данных
Overhead: для простых CRUD-приложений это избыточно
Используйте CQRS когда:
У вас высоконагруженное приложение
Требования к чтению и записи сильно различаются
Нужны сложные отчеты или аналитика
Команда готова к дополнительной сложности
Не используйте когда:
Делаете MVP или простой CRUD
В команде нет опыта работы с distributed systems
Строгая консистентность критически важна
Практический совет: начинайте постепенно
Не нужно переписывать всю систему сразу. Начните с самого проблемного места:
Выделите один bounded context (например, "Отчеты" или "Поиск")
Реализуйте CQRS только для него
Используйте простой Dual-write для старта
Добавьте Event Sourcing позже, если понадобится
// Постепенное внедрение - начинаем с одного модуля @Configuration @Profile("!cqrs") // Старый подход для остальной системы public class TraditionalConfig { @Bean public OrderService orderService() { return new TraditionalOrderService(); } } @Configuration @Profile("cqrs") // Новый подход для модуля отчетов public class CqrsConfig { @Bean public OrderQueryService orderQueryService() { return new CqrsOrderQueryService(); } }
Итог
CQRS — это не фреймворк и не библиотека. Это архитектурный принцип, который помогает справиться со сложностью. Когда вы разделяете ответственность, вы получаете:
Производительность: оптимизированные модели под конкретные задачи
Масштабируемость: независимое масштабирование чтения и записи
Гибкость: возможность использовать разные технологии
Поддержку: более чистый и понятный код
Но помните: каждая архитектурная решение — это trade-off. CQRS дает производительность и масштабируемость, но забирает простоту и немедленную консистентность.
С чего начать практику? Возьмите свой пет-проект, найдите в нем место, где чтение и запись конфликтуют (например, лента новостей + создание постов), и попробуйте разделить их. Сначала будет сложно, но когда вы увидите, как легко теперь масштабировать чтение отдельно от записи — вы поймете всю мощь этого подхода.
А вы использовали CQRS в своих проектах? С какими сложностями столкнулись? Делитесь опытом в комментариях!
