← Назад к вопросам

Как будешь осуществлять получение выписки, которую ожидает пользователь: асинхронно или нет

3.0 Senior🔥 101 комментариев
#REST API и микросервисы

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Асинхронное vs синхронное получение выписки: выбор архитектуры

Получение выписки — это типичный пример задачи, где выбор синхронности критичен. Рассмотрю оба подхода и когда выбирать каждый.

1. Синхронный подход: быстрый и простой

Сценарий: пользователь ждёт выписку в течение разумного времени (< 3 сек).

@RestController
@RequestMapping("/api/v1/statements")
@RequiredArgsConstructor
public class StatementController {
    private final StatementService statementService;
    
    @GetMapping("/{accountId}")
    public ResponseEntity<StatementDTO> getStatement(
            @PathVariable Long accountId,
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate) {
        
        // Синхронно получаем выписку
        Statement statement = statementService.getStatement(accountId, fromDate, toDate);
        return ResponseEntity.ok(new StatementDTO(statement));
    }
}

@Service
@RequiredArgsConstructor
public class StatementService {
    private final TransactionRepository transactionRepository;
    private final StatementRepository statementRepository;
    private final CacheManager cacheManager;
    
    private static final int TIMEOUT_SECONDS = 5;
    
    // Синхронный метод с таймаутом
    public Statement getStatement(Long accountId, LocalDate fromDate, LocalDate toDate) {
        // 1. Проверяем кэш
        String cacheKey = getCacheKey(accountId, fromDate, toDate);
        Statement cached = (Statement) cacheManager.getCache("statements")
            .get(cacheKey, () -> null);
        
        if (cached != null) {
            return cached;
        }
        
        // 2. Проверяем БД (может быть уже готова)
        Optional<Statement> existing = statementRepository.findByAccountIdAndPeriod(
            accountId, fromDate, toDate
        );
        
        if (existing.isPresent()) {
            cacheManager.getCache("statements").put(cacheKey, existing.get());
            return existing.get();
        }
        
        // 3. Генерируем на лету (быстро, если данные не очень большие)
        try {
            Statement statement = generateStatement(accountId, fromDate, toDate);
            cacheManager.getCache("statements").put(cacheKey, statement);
            return statement;
        } catch (TimeoutException e) {
            throw new StatementGenerationTimeoutException(
                "Statement generation exceeded " + TIMEOUT_SECONDS + " seconds", e
            );
        }
    }
    
    private Statement generateStatement(Long accountId, LocalDate fromDate, LocalDate toDate) {
        List<Transaction> transactions = transactionRepository
            .findByAccountIdAndDateBetweenWithTimeout(
                accountId, fromDate, toDate, TIMEOUT_SECONDS
            );
        
        Statement statement = new Statement();
        statement.setAccountId(accountId);
        statement.setFromDate(fromDate);
        statement.setToDate(toDate);
        statement.setTransactions(transactions);
        statement.setGeneratedAt(Instant.now(UTC));
        
        return statement;
    }
    
    private String getCacheKey(Long accountId, LocalDate fromDate, LocalDate toDate) {
        return accountId + ":" + fromDate + ":" + toDate;
    }
}

2. Асинхронный подход: масштабируемый и надёжный

Сценарий: выписка генерируется долго (> 10 сек), большие объёмы данных.

@RestController
@RequestMapping("/api/v1/statements")
@RequiredArgsConstructor
public class StatementController {
    private final StatementService statementService;
    
    // 1. Пользователь инициирует генерацию
    @PostMapping("/{accountId}/generate")
    public ResponseEntity<StatementRequestDTO> generateStatement(
            @PathVariable Long accountId,
            @RequestBody StatementRequestDTO request) {
        
        StatementRequest statementRequest = statementService.initiateStatementGeneration(
            accountId, request.getFromDate(), request.getToDate()
        );
        
        return ResponseEntity
            .accepted() // 202 Accepted
            .header("Location", "/api/v1/statements/requests/" + statementRequest.getId())
            .body(new StatementRequestDTO(statementRequest));
    }
    
    // 2. Пользователь проверяет статус
    @GetMapping("/requests/{requestId}")
    public ResponseEntity<StatementRequestDTO> getStatementStatus(
            @PathVariable String requestId) {
        
        StatementRequest request = statementService.getStatementRequest(requestId);
        return ResponseEntity.ok(new StatementRequestDTO(request));
    }
    
    // 3. Когда готова — скачивает
    @GetMapping("/requests/{requestId}/download")
    public ResponseEntity<byte[]> downloadStatement(@PathVariable String requestId) {
        StatementRequest request = statementService.getStatementRequest(requestId);
        
        if (!StatementRequestStatus.COMPLETED.equals(request.getStatus())) {
            throw new StatementNotReadyException("Statement is not ready yet");
        }
        
        byte[] data = statementService.downloadStatement(requestId);
        
        return ResponseEntity.ok()
            .header("Content-Disposition", "attachment; filename=statement.pdf")
            .contentType(MediaType.APPLICATION_PDF)
            .body(data);
    }
}

@Entity
@Table(name = "statement_requests")
@Data
public class StatementRequest {
    @Id
    private String id; // UUID
    
    private Long accountId;
    private LocalDate fromDate;
    private LocalDate toDate;
    
    @Enumerated(EnumType.STRING)
    private StatementRequestStatus status; // PENDING, PROCESSING, COMPLETED, FAILED
    
    private Instant createdAt;
    private Instant completedAt;
    private String errorMessage;
    private String filePath; // где сохранена выписка
}

public enum StatementRequestStatus {
    PENDING,
    PROCESSING,
    COMPLETED,
    FAILED
}

@Service
@RequiredArgsConstructor
public class StatementService {
    private final StatementRequestRepository requestRepository;
    private final StatementAsyncProcessor asyncProcessor;
    private final NotificationService notificationService;
    
    // Инициирует асинхронный процесс
    public StatementRequest initiateStatementGeneration(
            Long accountId, LocalDate fromDate, LocalDate toDate) {
        
        String requestId = UUID.randomUUID().toString();
        
        StatementRequest request = new StatementRequest();
        request.setId(requestId);
        request.setAccountId(accountId);
        request.setFromDate(fromDate);
        request.setToDate(toDate);
        request.setStatus(StatementRequestStatus.PENDING);
        request.setCreatedAt(Instant.now(UTC));
        
        requestRepository.save(request);
        
        // Отправляем на обработку (очень быстро, не блокируемся)
        asyncProcessor.processStatement(requestId);
        
        return request;
    }
    
    public StatementRequest getStatementRequest(String requestId) {
        return requestRepository.findById(requestId)
            .orElseThrow(() -> new StatementRequestNotFoundException(requestId));
    }
    
    public byte[] downloadStatement(String requestId) {
        StatementRequest request = getStatementRequest(requestId);
        return Files.readAllBytes(Paths.get(request.getFilePath()));
    }
}

@Component
@RequiredArgsConstructor
public class StatementAsyncProcessor {
    private final StatementRequestRepository requestRepository;
    private final TransactionRepository transactionRepository;
    private final StatementPdfGenerator pdfGenerator;
    private final NotificationService notificationService;
    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    
    // Обработка в отдельном потоке
    @Async
    public void processStatement(String requestId) {
        StatementRequest request = requestRepository.findById(requestId).get();
        
        try {
            // 1. Обновляем статус
            request.setStatus(StatementRequestStatus.PROCESSING);
            requestRepository.save(request);
            
            // 2. Получаем данные из БД (может быть долго)
            List<Transaction> transactions = transactionRepository.findByAccountIdAndDateBetween(
                request.getAccountId(),
                request.getFromDate(),
                request.getToDate()
            );
            
            // 3. Генерируем PDF (может быть долго)
            byte[] pdfData = pdfGenerator.generate(request, transactions);
            
            // 4. Сохраняем файл
            String filePath = saveStatementFile(requestId, pdfData);
            
            // 5. Обновляем статус
            request.setStatus(StatementRequestStatus.COMPLETED);
            request.setCompletedAt(Instant.now(UTC));
            request.setFilePath(filePath);
            requestRepository.save(request);
            
            logger.info("Statement {} completed", requestId);
            
            // 6. Уведомляем пользователя
            notificationService.notifyStatementReady(request.getAccountId(), requestId);
            
        } catch (Exception e) {
            logger.error("Failed to process statement {}", requestId, e);
            
            // Сохраняем ошибку
            request.setStatus(StatementRequestStatus.FAILED);
            request.setErrorMessage(e.getMessage());
            requestRepository.save(request);
            
            // Уведомляем пользователя об ошибке
            notificationService.notifyStatementFailed(request.getAccountId(), requestId);
        }
    }
    
    private String saveStatementFile(String requestId, byte[] pdfData) throws IOException {
        String fileName = requestId + ".pdf";
        Path path = Paths.get("/var/statements/", fileName);
        return Files.write(path, pdfData).toString();
    }
}

3. Гибридный подход: выбор в зависимости от размера

@Service
@RequiredArgsConstructor
public class SmartStatementService {
    private final TransactionRepository transactionRepository;
    private final StatementService statementService;
    private final AsyncStatementProcessor asyncProcessor;
    
    private static final int SYNC_THRESHOLD = 10000; // если < 10k записей, синхронно
    
    public StatementResponse getStatement(Long accountId, LocalDate fromDate, LocalDate toDate) {
        // 1. Считаем количество записей
        long count = transactionRepository.countByAccountIdAndDateBetween(
            accountId, fromDate, toDate
        );
        
        // 2. Если мало — генерируем синхронно
        if (count < SYNC_THRESHOLD) {
            Statement statement = statementService.generateStatement(
                accountId, fromDate, toDate
            );
            return StatementResponse.ofImmediate(statement);
        }
        
        // 3. Если много — генерируем асинхронно
        String requestId = asyncProcessor.startGeneration(accountId, fromDate, toDate);
        return StatementResponse.ofAsync(requestId);
    }
}

// DTO ответа
@Data
public class StatementResponse {
    private boolean async; // true если асинхронно
    private Statement statement; // если sync
    private String requestId; // если async
    private String statusUrl; // если async
    
    public static StatementResponse ofImmediate(Statement statement) {
        StatementResponse response = new StatementResponse();
        response.setAsync(false);
        response.setStatement(statement);
        return response;
    }
    
    public static StatementResponse ofAsync(String requestId) {
        StatementResponse response = new StatementResponse();
        response.setAsync(true);
        response.setRequestId(requestId);
        response.setStatusUrl("/api/v1/statements/requests/" + requestId);
        return response;
    }
}

4. Таблица сравнения

АспектСинхронныйАсинхронный
Время генерации< 3 секможет быть любое
Сложностьнизкаясредняя-высокая
UXпростой, знакомыйнужна polling/push
Масштабируемостьограниченнаяхорошая
Использование памятина запросконтролируемое
Надёжностьзависит от БДможно retry
Примерымаленькие выпискибольшие отчёты

5. Best Practices

// 1. Добавь timeout к синхронным операциям
public Statement getStatement(Long accountId, LocalDate fromDate, LocalDate toDate) {
    try {
        return statementRepository.findWithTimeout(
            accountId, fromDate, toDate, 5, TimeUnit.SECONDS
        );
    } catch (TimeoutException e) {
        // Переводим на асинхронный режим
        throw new StatementGenerationTimeoutException();
    }
}

// 2. Кэшируй результаты
@Cacheable(value = "statements", key = "#accountId + ':' + #fromDate + ':' + #toDate")
public Statement getStatement(Long accountId, LocalDate fromDate, LocalDate toDate) {
    // ...
}

// 3. Уведомляй пользователя
notificationService.sendEmail(
    user.getEmail(),
    "Your statement is ready",
    "Click here to download: " + downloadUrl
);

// 4. Логируй время генерации
Instant start = Instant.now();
// генерируем
long duration = Duration.between(start, Instant.now()).toMillis();
metrics.recordStatementGenerationTime(duration);

// 5. Добавь мониторинг
if (duration > 30_000) {
    alertService.sendAlert("Statement generation took " + duration + "ms");
}

Рекомендация

Используй гибридный подход:

  • Синхронно для маленьких выписок (< 10k записей)
  • Асинхронно для больших выписок и тяжёлых отчётов
  • Кэширование для частых запросов
  • Таймауты везде, где может быть задержка
  • Уведомления чтобы пользователь знал когда готово

Главное правило: выбор зависит от объёма данных, частоты запросов и SLA требований. Всегда начинай с простого (синхронно), а затем добавляй асинхронность когда появятся проблемы с производительностью.