← Назад к вопросам
Как будешь осуществлять получение выписки, которую ожидает пользователь: асинхронно или нет
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 требований. Всегда начинай с простого (синхронно), а затем добавляй асинхронность когда появятся проблемы с производительностью.