Привет! Я сегодня хочу разобрать одну из самых мощных, но часто неправильно понимаемых архитектурных концепций — 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 в своих проектах? С какими сложностями столкнулись? Делитесь опытом в комментариях!
Автор: MrEx3cut0r
