🎯 OBJECTIF
Comprendre comment :
@Scheduled contre l'exécution simultanée avec AtomicBoolean🧠 MODÈLE MENTAL
Un job Spring Boot n'est pas juste une méthode @Scheduled. C'est un traitement long, faillible, relançable, potentiellement concurrent — et qui peut être exécuté plusieurs fois par accident (redéploiement, scaling, crash en cours de route). Sans chunking, un job qui charge 50 000 lignes en une transaction peut saturer la heap et rollbacker tout le travail. Sans idempotence, relancer ce job corrompt les données. Sans reprise sur checkpoint, chaque crash repart de zéro.
Ces quatre concepts — chunking, pagination, idempotence, reprise — ne sont pas des optimisations. Ce sont les conditions minimales pour qu'un job soit fiable en production. Les retirer un par un, c'est introduire un bug silencieux qui se déclenchera en prod sous charge ou en cas de redéploiement inattendu.
Par défaut, @Scheduled utilise un seul thread. Si le job précédent n'est pas terminé quand le trigger suivant se déclenche, les deux s'exécutent en parallèle sur le même pod.
AtomicBoolean.compareAndSet(false, true) est une opération atomique : elle vérifie et modifie en une seule étape sans race condition. Si le job tourne déjà, le nouvel appel est skippé proprement.
@Slf4j
@Service
@RequiredArgsConstructor
public class MoveAllStocksJob {
private final AtomicBoolean running = new AtomicBoolean(false);
@Scheduled(cron = "0 */1 * * * *")
public void run() {
if (!running.compareAndSet(false, true)) { // ✓ atomique — pas de race condition
log.info("Job skipped: already running");
return;
}
try {
// traitement...
} finally {
running.set(false); // ✓ libération garantie même en cas d'exception
}
}
}java🚨 AtomicBoolean = protection locale JVM uniquement
En multi-pods Kubernetes, chaque pod a sa propre JVM et son propre AtomicBoolean. Deux pods peuvent exécuter le même job simultanément. Pour une protection distribuée, utiliser ShedLock (verrou DB ou Redis).
Charger et traiter tous les items en une seule transaction présente trois risques : saturation mémoire Hibernate (Persistence Context qui gonfle), transaction trop longue (locks prolongés, risque de timeout), et rollback global en cas d'erreur sur le dernier item.
Le chunking traite les items par lots courts — 100 à 500 items selon le poids — avec un commit après chaque lot. Si le job crash au chunk 5, les chunks 1 à 4 sont déjà committés et ne sont pas retraités.
@Service
@RequiredArgsConstructor
public class StockChunkProcessor {
private final EntityManager em;
private final StockRepository stockRepository;
@Transactional // ✓ une transaction par chunk, pas pour tout le job
public Long processNextChunk(String locationId, long lastId, int chunkSize) {
List<StockPo> chunk = stockRepository.fetchChunk(
locationId, lastId, PageRequest.of(0, chunkSize)
);
if (chunk.isEmpty()) return null;
for (StockPo stock : chunk) {
stock.setStatus(MOVED); // ✓ écriture idempotente
stock.setLastUpdatedTime(OffsetDateTime.now());
}
em.flush(); // ✓ force le SQL avant de vider le contexte
em.clear(); // ✓ libère la mémoire Hibernate entre chunks
return chunk.get(chunk.size() - 1).getId();
}
}java🔑 Conclusion clé
flush() + clear() après chaque chunk est obligatoire : sans clear(), Hibernate conserve toutes les entités en mémoire dans le Persistence Context et finit par provoquer un OOM sur les gros volumes.
La pagination OFFSET est simple mais dangereuse sur les grands volumes : OFFSET 10000 LIMIT 100 force la DB à lire et ignorer 10 000 lignes avant de retourner les 100 suivantes. Plus l'offset grandit, plus la requête est lente.
La pagination keyset (id > lastId) exploite directement l'index primaire — coût constant quelle que soit la position dans le dataset.
// ❌ OFFSET — coût croissant, instable si des lignes sont insérées entre deux pages
SELECT * FROM stock OFFSET 10000 LIMIT 100;
// ✓ KEYSET — coût constant, stable, idéal pour les batch jobs
SELECT * FROM stock
WHERE location_id = :locationId
AND id > :lastId
ORDER BY id ASC
LIMIT 100;java@Query("""
select s from StockPo s
where s.locationId = :locationId
and s.id > :lastId
order by s.id asc
""")
List<StockPo> fetchChunk(String locationId, Long lastId, Pageable pageable);javaUn job peut être relancé après crash, re-déclenché par un redéploiement ou exécuté en double par un bug de scheduling. L'idempotence garantit que relancer le job plusieurs fois produit le même résultat que le lancer une seule fois.
// ❌ Pas idempotent — exécuté 2 fois = quantité doublée
stock.setQuantity(stock.getQuantity() + delta);
// ✓ Idempotent — exécuté N fois = même résultat
stock.setStatus(MOVED);
// ✓ Idempotent via vérification préalable
if (stock.getStatus() != MOVED) {
process(stock);
}java🚨 L'idempotence est non négociable
Sans idempotence, tout crash + reprise = données corrompues. Ce n'est pas une optimisation à ajouter plus tard — elle doit être conçue dès le départ car elle impacte le modèle de données.
Un job interrompu doit pouvoir reprendre là où il s'est arrêté, pas depuis le début. La solution standard : une table job_checkpoint qui persiste le dernier ID traité après chaque chunk.
@Entity
@Table(name = "job_checkpoint")
public class JobCheckpoint {
@Id
private String jobName;
private Long lastId = 0L;
private OffsetDateTime updatedAt;
}java@Scheduled(cron = "0 */1 * * * *")
public void run() {
if (!running.compareAndSet(false, true)) return;
try {
String jobName = "MOVE_ALL_STOCKS:LILLE-01";
JobCheckpoint checkpoint = checkpointRepository
.findById(jobName)
.orElse(new JobCheckpoint(jobName, 0L));
long lastId = checkpoint.getLastId();
while (true) {
Long newLastId = chunkProcessor.processNextChunk(
"LILLE-01", lastId, CHUNK_SIZE
);
if (newLastId == null) break; // ✓ plus de données à traiter
checkpoint.setLastId(newLastId); // ✓ checkpoint après chaque chunk
checkpoint.setUpdatedAt(OffsetDateTime.now());
checkpointRepository.save(checkpoint);
lastId = newLastId;
}
} catch (Exception e) {
log.error("Job failed", e);
} finally {
running.set(false);
}
}java🔑 Conclusion clé
Le checkpoint est sauvegardé après chaque chunk, pas à la fin du job. Si le job crash au chunk 5, il reprend au chunk 6 — pas depuis le début. Sans ça, un job de 10 000 chunks qui crash au chunk 9 999 recommence tout.
| Concept | Règle | Sans ça |
|---|---|---|
| AtomicBoolean | compareAndSet dans try/finally |
Double exécution simultanée sur le même pod |
| Chunking | 100-500 items/chunk, flush() + clear() |
OOM Hibernate, transactions longues |
| Pagination keyset | id > lastId ORDER BY id |
Requêtes lentes sur grands volumes |
| Idempotence | Écrire l'état final, pas un delta | Corruption sur relance |
| Checkpoint | Sauvegarder lastId après chaque chunk |
Reprise depuis zéro à chaque crash |
⚡ TL;DR — chaque concept en une ligne
AtomicBoolean ✓ Verrou local JVM qui empêche deux exécutions simultanées du même job sur un pod. ⚠ Ne protège pas contre l'exécution parallèle sur plusieurs pods — utiliser ShedLock en multi-instances.
Chunking
✓ Découpe le traitement en lots courts avec une transaction par chunk — mémoire et durée de lock maîtrisées.
⚠ Sans flush() + clear() entre chunks, Hibernate accumule les entités en mémoire et finit en OOM.
Pagination keyset
✓ id > lastId exploite l'index primaire — coût constant quelle que soit la taille du dataset.
⚠ OFFSET est instable sur grands volumes et devient de plus en plus lent à mesure que la page avance.
Idempotence
✓ Le job peut être exécuté N fois sans changer le résultat final — essentiel pour les reprises après crash.
⚠ Un delta (quantity += 10) n'est pas idempotent — toujours écrire l'état final attendu.
Checkpoint ✓ Persist le dernier ID traité après chaque chunk — permet de reprendre exactement où le job s'est arrêté. ⚠ Sauvegarder le checkpoint à la fin du job plutôt qu'après chaque chunk annule l'intérêt de la reprise.
🎓 À retenir
@Scheduled n'est pas un job — c'est un déclencheur — la logique de chunking, d'idempotence et de reprise vit dans des services séparés. Mettre tout dans la méthode @Scheduled rend le job intestable et non réutilisable.ThreadPoolTaskScheduler n'est pas @Async — il permet à plusieurs jobs distincts de tourner en parallèle, mais chaque job reste synchrone dans son thread. Ne pas l'utiliser comme substitut à @Async pour du parallélisme métier interne au job.@Scheduled, @EnableScheduling, TaskScheduler