32. Оптимизировать расчёт статистики по заказам клиентов
Условие задачи:
Дан Spring-сервис, который для активных пользователей рассчитывает статистику по заказам: количество заказов и их общую стоимость, после чего отправляет результат в Kafka.
Нужно провести code review: найти проблемы с Dependency Injection, работой с БД, транзакциями и производительностью, а затем предложить более эффективную реализацию, сохранив корректное поведение в том числе для пользователей без заказов.
Код:
@Component
@RequiredArgsConstructor
public class StatService {
@Autowired
private UserRepository userRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private StatRepository statRepository;
@Autowired
private KafkaService kafkaService;
public void calculate() {
List<User> users = userRepository.findAll().stream().filter(u -> u.isActive()).collect(Collectors.toList());
if (!users.isEmpty()) {
for(User user : users) {
List<Order> orders = orderRepository.findByUser(user.getId());
if (orders.isEmpty()) {
System.out.println(""У пользователя нет заказов"");
}
int totalOrders = 0;
int totalCost = 0;
for(Order order : orders) {
totalOrders++;
totalCost += order.getCost();
}
save(user.getId(), totalOrders, totalCost);
}
}
}
@Transactional
private void save(Long userId, int totalOrders, int totalCost) {
kafkaService.send(userId, totalOrders, totalCost, Instant.now());
}
Спойлеры к решению
Подсказки
💡 На каждого пользователя выполняется отдельный запрос заказов — проверь количество запросов при большом числе пользователей.
💡
@Transactional на private-методе, вызываемом из того же класса, не создаст транзакцию через Spring proxy.💡 Обрати внимание на
statRepository: используется ли он вообще?💡 При оптимизации не потеряй активных пользователей, у которых нет заказов.
Решение
Проблемы:
смешаны
@RequiredArgsConstructorи field injection через@Autowired;поля зависимостей не
final, поэтому@RequiredArgsConstructorздесь фактически бесполезен;statRepositoryобъявлен, но нигде не используется;загружаются все пользователи, а фильтрация по
activeвыполняется в памяти;возникает N+1: после запроса пользователей выполняется отдельный запрос заказов для каждого пользователя;
все заказы загружаются в Java только ради
COUNTиSUM;@Transactionalнаprivate save()не сработает через Spring AOP;более того,
save()ничего не сохраняет в БД — он только отправляет сообщение в Kafka;название
save()не соответствует фактическому поведению;System.out.println()лучше заменить логгером;пустой
if (!users.isEmpty())не нужен — цикл и так ничего не выполнит для пустого списка;totalCostвintможет быть неподходящим для денежных сумм или больших агрегатов;Instant.now()вызывается отдельно для каждого пользователя, хотя статистика может относиться к одному запуску расчёта;в показанном фрагменте отсутствует закрывающая
}классаStatService.
Как лучше исправить
Главная оптимизация — считать статистику сразу в БД, а не загружать пользователей и все их заказы по отдельности.
При этом важно сохранить текущее поведение: активный пользователь без заказов тоже получает статистику 0 / 0. Поэтому запрос должен использовать LEFT JOIN, а не обычный INNER JOIN.
Например, репозиторий может сразу вернуть проекцию:
public record UserOrderStat(
Long userId,
long totalOrders,
long totalCost
) {
}
public interface StatRepository {
List<UserOrderStat> calculateForActiveUsers();
}
Запрос по смыслу:
SELECT u.id,
COUNT(o.id),
COALESCE(SUM(o.cost), 0)
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.active = true
GROUP BY u.id
Тогда сервис становится значительно проще:
@Service
@RequiredArgsConstructor
public class StatService {
private final StatRepository statRepository;
private final KafkaService kafkaService;
public void calculate() {
Instant calculatedAt = Instant.now();
List<UserOrderStat> stats =
statRepository.calculateForActiveUsers();
for (UserOrderStat stat : stats) {
kafkaService.send(
stat.userId(),
stat.totalOrders(),
stat.totalCost(),
calculatedAt
);
}
}
}
Так вместо схемы:
1 запрос пользователей
+ N запросов заказов
можно получить статистику одним агрегирующим запросом.
@Transactional для простого вызова Kafka здесь не нужен. Если по требованиям статистику нужно одновременно сохранить в БД и отправить в Kafka, появляется другая задача: обычная DB-транзакция не делает эти две операции атомарными. Для надёжного сценария лучше сохранять статистику и outbox-запись в одной транзакции, а Kafka отправлять отдельным обработчиком.
Для стоимости стоит выбрать тип согласно модели денег: long подходит, если сумма хранится в минимальных денежных единицах, например копейках; для дробных денежных значений обычно используют BigDecimal.
Главное исправление — перенести фильтрацию и агрегацию в БД, избавиться от N+1 и не использовать фиктивную @Transactional вокруг отправки в Kafka.