Zum Inhalt springen
Java

readOnly, @Repository, @Modifying: was Annotationen am Spring-Data-Repository wirklich tun

Am Repository stehen selten mehr als drei Annotationen, und trotzdem entscheiden sie darüber, ob deine Änderung in der Datenbank landet. @Transactional(readOnly = true), @Repository und @Modifying sehen aus wie Deklarationen für den nächsten Leser. Zwei davon greifen tief in Hibernate ein, eine tut gar nichts.

Dieser Artikel geht den Weg an einem durchgehenden Beispiel entlang: ein nächtlicher Job, der abgelaufene Aktionen beenden soll und morgens meldet, dass alles gut lief, während im Shop weiter der Aktionspreis steht. Am Ende steht eine Lösung, die hält, plus die ehrliche Antwort darauf, wann sich der ganze Aufwand nicht lohnt.

Es geht hier ausschließlich um die Annotationen an der Repository-Schnittstelle. Die klassischen Proxy-Fallen von @Transactional (Selbstaufruf, checked Exceptions, LazyInitializationException) sind ein eigenes Thema und stehen in @Transactional in Spring: der Proxy und seine Fallen.

Inhalt

Die Szene: der Job, der nichts tut

Ein Onlineshop verkauft Werkzeug: Bohrmaschinen, Stichsägen, Akkuschrauber, dazu Zubehör und Ersatzteile. Aktionen laufen befristet. Jede Aktion hängt an einer Artikelnummer und hat ein Enddatum, dazu ein Kennzeichen, ob sie noch läuft. Nachts um Viertel nach drei läuft ein Job, der die abgelaufenen Aktionen beendet, damit der Akkuschrauber am Morgen wieder regulär ausgezeichnet ist. Das Fachliche daran ist überschaubar, das Entity entsprechend klein.

@Entity
public class PromoPrice {

    @Id
    @GeneratedValue
    private Long id;

    private String articleNumber;

    private LocalDate endsOn;

    private boolean running;

    protected PromoPrice() {
    }

    public boolean isRunning() {
        return running;
    }

    public void setRunning( boolean running ) {
        this.running = running;
    }

    public LocalDate getEndsOn() {
        return endsOn;
    }

    public String getArticleNumber() {
        return articleNumber;
    }
}

Das Repository sieht aus, wie Repositories in vielen Projekten aussehen. Drei Annotationen, alle guten Gewissens gesetzt.

@Repository
@Transactional(readOnly = true)
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {

    List<PromoPrice> findByRunningTrueAndEndsOnBefore( LocalDate cutoff );
}

Der Service ebenso. Die Klasse trägt readOnly = true, weil das im Projekt der Standard für Services ist und niemand mehr darüber nachdenkt.

@Service
@Transactional(readOnly = true)
public class PriceMaintenance {

    private static final Logger log = LoggerFactory.getLogger( PriceMaintenance.class );

    private final PromoPriceRepository promoPrices;

    public PriceMaintenance( PromoPriceRepository promoPrices ) {
        this.promoPrices = promoPrices;
    }

    @Scheduled(cron = "0 15 3 * * *")
    public void endExpiredPromotions() {
        List<PromoPrice> expired = promoPrices.findByRunningTrueAndEndsOnBefore( LocalDate.now() );

        for ( PromoPrice promo : expired ) {
            promo.setRunning( false );
        }

        log.info( "{} abgelaufene Aktionen beendet", expired.size() );
    }
}

Um 3:15 Uhr steht im Log genau das, was dort stehen soll:

2026-08-18 03:15:02  INFO  PriceMaintenance : 412 abgelaufene Aktionen beendet

In der Datenbank laufen weiterhin 412 Aktionen. Kein Fehler, kein Stacktrace, kein Rollback im Log. Die Anwendung ist überzeugt, ihre Arbeit getan zu haben, und der Shop verkauft weiter zum Aktionspreis.

Voraussetzungen und Versionsstand

Alles in diesem Artikel bezieht sich auf Spring Boot 4 mit Spring Data JPA 4 und Hibernate als Persistenzanbieter. Die beschriebenen Mechanismen sind älter als das: Das Durchreichen des Lese-Kennzeichens an die JDBC-Verbindung gibt es seit Spring Framework 4.1, die Weitergabe an die Hibernate-Session seit Spring Framework 5.1. Wer auf Spring Boot 3 sitzt, kann alles Folgende unverändert übernehmen.

Als Datenbank kommt PostgreSQL zum Einsatz. Das ist an einer Stelle wichtig, weil sich PostgreSQL bei Lese-Transaktionen anders verhält als etwa H2, und weil genau dieser Unterschied dafür sorgt, dass ein Fehler im Test nicht auftritt und in Produktion schon.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

Warum ein Repository überhaupt eine Transaktion hat

Bevor die Fehlersuche losgeht, lohnt ein Blick darauf, was Spring Data ohne jedes Zutun bereits mitbringt. Die Standardimplementierung hinter jedem JPA-Repository ist SimpleJpaRepository, und diese Klasse ist selbst transaktional annotiert. Auf Klassenebene steht @Transactional(readOnly = true), die schreibenden Methoden wie save oder delete überschreiben das mit einem einfachen @Transactional.

Daraus folgen zwei Dinge, die man kennen sollte.

Ein save funktioniert auch dann, wenn im Service niemand eine Transaktion geöffnet hat. Spring Data öffnet in dem Fall selbst eine, führt die Anweisung aus und committet. Das ist bequem und der Grund, warum viele Projekte lange ohne Transaktionen in der Serviceschicht auskommen, ohne dass etwas auffällt.

Wichtiger ist das zweite. Die Annotation deckt nur die Methoden ab, die SimpleJpaRepository selbst implementiert. Abgeleitete Query-Methoden wie findByRunningTrueAndEndsOnBefore und eigene @Query-Methoden implementiert diese Klasse nicht, dafür ist die Query-Maschinerie von Spring Data zuständig. Sie erben deshalb nichts.

Genau deshalb annotiert man die Schnittstelle. @Transactional(readOnly = true) am Interface holt die eigenen Query-Methoden in dieselbe Regelung, in der die geerbten Methoden ohnehin schon stehen. Der Nutzen ist real, die Annotation ist kein Zierrat.

@Transactional(readOnly = true)
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {

    List<PromoPrice> findByRunningTrueAndEndsOnBefore( LocalDate cutoff );
}

Dass Spring generell davon abrät, Schnittstellen mit @Transactional zu versehen, gilt hier nicht. Bei Spring-Data-Repositories ist das der vorgesehene Weg, weil die Schnittstelle die einzige Stelle ist, an der du überhaupt etwas annotieren kannst.

Was readOnly wirklich einschaltet

Der Name legt nahe, dass readOnly = true eine Zusicherung ist, ungefähr in der Art von final. Das ist es nicht. Es ist ein Kennzeichen, das an drei Stellen weitergereicht wird und an jeder etwas anderes bewirkt.

Zuerst trifft es die Hibernate-Session. Sie wird auf FlushMode.MANUAL gestellt. Damit entfällt das automatische Ausspülen vor Queries und beim Commit. Es gibt keinen Punkt mehr, an dem Hibernate die geladenen Entitäten mit ihrem Ausgangszustand vergleicht.

Als Zweites betrifft es den Zustands-Abzug. Seit Spring Framework 5.1 wird das Kennzeichen zusätzlich als defaultReadOnly an die Hibernate-Session gereicht. Damit verwirft Hibernate den beim Laden angelegten Abzug einer Entität sofort, statt ihn für die gesamte Lebensdauer des Persistenzkontexts aufzubewahren. Dieser Abzug ist im Normalbetrieb die Grundlage des Dirty Checking und liegt für jede geladene Entität als zweiter Satz Feldwerte im Speicher.

Das ist der eigentliche Gewinn, und er wächst mit der Ergebnismenge. Bei einer Liste mit fünf Treffern ist er nicht messbar. Bei einem Export über sechsstellige Datenmengen ist er der Unterschied zwischen einem ruhigen Heap und einem Job, der am Speicher scheitert.

Zuletzt die JDBC-Verbindung. Spring ruft Connection.setReadOnly(true) auf, sofern der Schalter prepareConnection am HibernateJpaDialect aktiv ist. Er ist seit Spring Framework 4.1 standardmäßig aktiv. Was der Treiber daraus macht, hängt an der Datenbank, und dieser Punkt hat einen eigenen Abschnitt weiter unten verdient.

Der Aufwand, den readOnly einspart, ist damit klar benannt: kein Vergleich, kein Ausspülen, kein zweiter Satz Feldwerte im Speicher. Was dabei verloren geht, steht im nächsten Abschnitt.

Die Auflösung: warum das UPDATE nie kam

Zurück zum Job. Die Schleife setzt running auf false, und zwar auf Entitäten, die sehr wohl im Persistenzkontext liegen und sehr wohl verwaltet werden. Der Setter läuft, das Feld im Speicher ändert sich tatsächlich. Nur schaut niemand mehr hin.

Das automatische Ausspülen ist abgeschaltet, also findet beim Commit kein Vergleich gegen den Ausgangszustand statt. Und weil der Ausgangszustand gar nicht mehr vorgehalten wird, gäbe es auch nichts zu vergleichen. Hibernate erzeugt kein UPDATE, weil Hibernate von der Änderung nichts weiß.

Kein Fehler ist dabei die schlimmste Eigenschaft. Ein Rollback würde im Log stehen. Eine Exception würde die Überwachung wecken. Hier passiert schlicht nichts, und der Job meldet Erfolg, weil expired.size() ja tatsächlich 412 ist.

Ohne readOnly
  laden             Abzug liegt im Speicher
  setRunning(false) Feld im Speicher geändert
  Commit            Vergleich gegen den Abzug, daraus folgt ein UPDATE

Mit readOnly
  laden             Abzug sofort verworfen
  setRunning(false) Feld im Speicher geändert
  Commit            kein Vergleich, also kein UPDATE

Ein zweiter Weg führt zum selben Ergebnis und wird oft mit diesem verwechselt. Hat der Service gar keine Transaktion, dann öffnet das Repository für die Abfrage eine eigene und schließt sie beim Verlassen der Methode wieder. Die zurückgegebenen Entitäten sind danach abgelöst. Ein Setter auf einer abgelösten Entität ist eine reine Speicheroperation, auch hier folgt kein UPDATE. Zwei verschiedene Ursachen, dasselbe Symptom, und beide verschwinden, sobald die Transaktionsgrenze bewusst gesetzt ist.

Auf PostgreSQL scheitert es lauter

Es liegt nahe zu hoffen, dass die Datenbank den Fall aus dem letzten Abschnitt abfängt. Sie tut es nicht, und der Grund ist derselbe wie dort: Wo Hibernate kein UPDATE erzeugt, kommt an der Datenbank auch nichts an, das sie ablehnen könnte. Der Nachtjob bleibt auf PostgreSQL genauso still wie überall sonst.

Was PostgreSQL abfängt, ist die andere Sorte Fehler, eine Schreib-Anweisung, die tatsächlich abgeschickt wird, während die Transaktion als lesend eröffnet wurde. Spring reicht das Lese-Kennzeichen wie beschrieben bis an die JDBC-Verbindung durch, und der PostgreSQL-Treiber wertet das aus.

Zuständig ist der Verbindungsparameter readOnlyMode mit drei möglichen Werten:

Wert Verhalten bei setReadOnly(true)
ignore Das Kennzeichen bleibt folgenlos
transaction Bei abgeschaltetem Autocommit schickt der Treiber BEGIN READ ONLY
always Wie transaction, bei aktivem Autocommit wird stattdessen die Sitzung auf lesend gestellt

Der Standardwert ist transaction. Autocommit ist unter einer Spring-Transaktion abgeschaltet. Die Transaktion beginnt also als ausdrücklich lesende Transaktion, und eine Anweisung, die darin schreiben will, scheitert an der Datenbank.

org.postgresql.util.PSQLException: ERROR: cannot execute UPDATE in a read-only transaction

Praktisch trifft das drei Wege: eine @Modifying-Anweisung ohne eigenes @Transactional, eine native Schreib-Abfrage, und ein ausdrückliches flush(). Alle drei schicken wirklich etwas los. So unangenehm die Ausnahme im ersten Moment aussieht, sie ist der freundlichere Ausgang. Ein Fehler, den die Überwachung sieht, ist einer stillen Nichtänderung deutlich vorzuziehen.

Es gibt allerdings eine Nebenwirkung, über die Teams regelmäßig stolpern: Auch ein pessimistisches Sperren über @Lock erzeugt ein SELECT ... FOR UPDATE, und PostgreSQL lehnt das in einer lesenden Transaktion ebenfalls ab. Eine Sperr-Abfrage darf also nicht unter einem geerbten readOnly = true laufen, obwohl sie fachlich nur liest.

Wichtig für die Testplanung: H2 verhält sich hier anders und lässt Schreibvorgänge in einer als lesend markierten Transaktion durch. Ein Test gegen H2 kann diesen Fehler also nicht finden. Das ist einer der Gründe, warum die Testdatenbank dieselbe sein sollte wie in Produktion.

@Repository an der Schnittstelle ist wirkungslos

Ein kurzer Zwischenstopp bei der dritten Annotation, weil sie fast überall steht und nichts ausrichtet.

@Repository hat zwei Aufgaben. Sie macht eine Klasse für die Komponentensuche auffindbar, und sie meldet sie für die Übersetzung der Persistenz-Exceptions in Springs eigene DataAccessException-Hierarchie an, erledigt durch den PersistenceExceptionTranslationPostProcessor.

Beides ist bei einem Spring-Data-Repository schon erledigt, bevor die Annotation zum Zug käme. Die Schnittstelle wird über den Repository-Scan gefunden, nicht über die Komponentensuche, und die Übersetzung der Exceptions sitzt fest im erzeugten Proxy. Die Annotation ist an dieser Stelle also reine Gewohnheit.

@Repository                                   // wirkungslos
@Transactional(readOnly = true)               // wirksam
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {
}

Gebraucht wird sie weiterhin, sobald du eine Datenzugriffsklasse selbst schreibst, etwa einen Adapter, der direkt mit dem EntityManager oder mit JdbcClient arbeitet. Dort schaltet sie die Exception-Übersetzung tatsächlich ein.

@Repository
public class PromoPriceArchiveAdapter {

    private final JdbcClient jdbcClient;

    public PromoPriceArchiveAdapter( JdbcClient jdbcClient ) {
        this.jdbcClient = jdbcClient;
    }

    public int archiveUntil( LocalDate cutoff ) {
        return jdbcClient.sql( "insert into promo_price_archive select * from promo_price where ends_on < :cutoff" )
                .param( "cutoff", cutoff )
                .update();
    }
}

Löschen musst du die überflüssige Annotation nicht, sie kostet nichts. Nur verlassen solltest du dich nicht auf sie, denn sie ist nicht der Grund, warum dein Repository funktioniert.

Der zweite Versuch: @Modifying

Die naheliegende Reparatur ist, den Job nicht mehr über geladene Entitäten laufen zu lassen. Vierhundert Datensätze einzeln zu laden, nur um in jedem ein Kennzeichen umzulegen, ist ohnehin verschwenderisch. Eine einzige Anweisung reicht.

Dafür ist @Modifying da. Ohne diese Annotation versucht Spring Data, das Ergebnis einer @Query als Trefferliste abzuholen. Mit ihr läuft die Anweisung stattdessen über executeUpdate() und liefert die Anzahl der betroffenen Zeilen zurück.

@Transactional(readOnly = true)
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {

    List<PromoPrice> findByRunningTrueAndEndsOnBefore( LocalDate cutoff );

    @Modifying
    @Transactional
    @Query("update PromoPrice p set p.running = false where p.running = true and p.endsOn < :cutoff")
    int endExpired( @Param("cutoff") LocalDate cutoff );
}

Das zweite @Transactional an der Methode ist kein Versehen und keine doppelte Buchführung. Die Schnittstelle steht auf readOnly = true, und eine Annotation an der Methode gewinnt gegen die an der Klasse. Ohne sie liefe die schreibende Anweisung unter dem geerbten Lese-Kennzeichen, mit den Folgen aus dem vorigen Abschnitt.

Ein Blick auf die Anweisung lohnt sich auch fachlich. Die Bedingung p.running = true ist nicht überflüssig, obwohl sie fachlich nichts ändert. Sie sorgt dafür, dass ein zweiter Lauf keine bereits beendeten Zeilen anfasst, und dass der Rückgabewert die tatsächlich geänderte Anzahl meldet statt der Gesamtzahl abgelaufener Aktionen.

Der Job wird damit deutlich kürzer:

@Scheduled(cron = "0 15 3 * * *")
public void endExpiredPromotions() {
    int affected = promoPrices.endExpired( LocalDate.now() );
    log.info( "{} abgelaufene Aktionen beendet", affected );
}

Diesmal stimmt die Meldung mit der Datenbank überein. Der Job tut, was er soll. Der nächste Stolperstein liegt woanders.

Der veraltete Persistenzkontext

Der Job soll erweitert werden. Vor dem Lauf wird eine bestimmte Aktion geprüft, weil sie in einem Support-Fall auffällig war, und nach dem Lauf soll im Log stehen, wie es dieser Aktion ergangen ist.

@Transactional
public void maintenanceWithProbe( long probeId ) {
    PromoPrice probe = promoPrices.findById( probeId ).orElseThrow();

    int affected = promoPrices.endExpired( LocalDate.now() );

    log.info( "{} Aktionen beendet, Probe-Aktion läuft noch: {}", affected, probe.isRunning() );
}

Die Ausgabe widerspricht sich selbst:

2026-08-18 03:15:02  INFO  PriceMaintenance : 412 Aktionen beendet, Probe-Aktion läuft noch: true

Die Aktion ist abgelaufen, sie steckt in den 412 geänderten Zeilen, und in der Datenbank steht running = false. Im Speicher steht weiterhin true.

Der Grund ist die Natur einer solchen Anweisung. Ein Bulk-Update geht direkt an die Datenbank und am Persistenzkontext vorbei. Hibernate hat keine Möglichkeit zu wissen, welche der verwalteten Entitäten davon betroffen waren, denn dazu müsste es die WHERE-Bedingung im Speicher nachvollziehen. Also lässt es sie unangetastet. Jeder weitere Zugriff auf probe liefert brav den Stand von vor dem Update, und zwar ohne erneute Abfrage, weil die Entität im Persistenzkontext liegt.

Die Annotation kennt für beide Richtungen einen Schalter, und beide stehen standardmäßig auf false.

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("update PromoPrice p set p.running = false where p.running = true and p.endsOn < :cutoff")
int endExpired( @Param("cutoff") LocalDate cutoff );

flushAutomatically = true spült vor der Anweisung aus. Das ist nötig, wenn im selben Ablauf vorher eine Entität geändert wurde, die von der WHERE-Bedingung getroffen werden könnte. Ohne das Ausspülen kennt die Datenbank diese Änderung noch nicht, arbeitet also auf einem Stand, den die Anwendung bereits verlassen hat.

clearAutomatically = true leert den Persistenzkontext nach der Anweisung. Der nächste Zugriff auf probe löst dann eine frische Abfrage aus und liefert den richtigen Wert.

Der Preis dafür wird gern übersehen. Leeren löst alle verwalteten Entitäten ab, nicht nur die betroffenen. Alles, was im selben Ablauf vorher geladen und geändert und noch nicht ausgespült wurde, ist danach verloren. Deshalb gehören die beiden Schalter zusammen, und deshalb ist ein Bulk-Update in der Mitte einer längeren fachlichen Bearbeitung ein schlechter Platz. Am saubersten läuft eine solche Anweisung in einer eigenen, kurzen Transaktion, in der außer ihr nichts passiert.

Was ein Bulk-Update sonst noch übergeht

Der veraltete Persistenzkontext ist der auffälligste Punkt, aber nicht der einzige. Eine Anweisung, die an Hibernates Zustandsverwaltung vorbeigeht, geht auch an allem vorbei, was daran hängt. Das ist die logische Folge und genau der Preis für die Geschwindigkeit.

Lebenszyklus-Rückrufe laufen nicht. Weder @PreUpdate noch @PostUpdate, und bei einer löschenden Anweisung auch kein @PreRemove. Wer Änderungsstempel oder Prüfspuren über solche Rückrufe pflegt, verliert sie an dieser Stelle stillschweigend. Dasselbe gilt für Hibernates Ereignis-Zuhörer.

Kaskaden greifen ebenfalls nicht. Ein delete from PromoPrice p where ... löscht Aktionspreise, aber keine abhängigen Zeilen, auch wenn die Beziehung mit cascade = REMOVE deklariert ist. Steht in der Datenbank ein Fremdschlüssel, scheitert die Anweisung. Steht dort keiner, bleiben Waisen zurück, und das fällt oft erst Monate später auf.

Auch die Versionsspalte bleibt liegen. Ein Feld mit @Version wird nicht hochgezählt, es sei denn, du zählst es in der Anweisung selbst hoch. Damit hält eine parallel laufende Bearbeitung ihre Kopie weiterhin für aktuell, und die optimistische Sperre schlägt nicht an, obwohl sich die Zeile geändert hat.

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("update PromoPrice p set p.running = false, p.version = p.version + 1 "
     + "where p.running = true and p.endsOn < :cutoff")
int endExpired( @Param("cutoff") LocalDate cutoff );

Und der Zwischenspeicher zweiter Stufe erfährt nichts davon. Ist für die Entität ein Second-Level-Cache eingeschaltet, liefert er nach dem Bulk-Update weiterhin die alten Werte, und zwar über die Transaktion hinaus. Das trifft dann auch andere Benutzer.

Nichts davon spricht gegen Bulk-Updates. Es spricht dagegen, sie beiläufig einzustreuen. Eine Anweisung, die vierhundert Zeilen in einem Rutsch ändert, verdient eine bewusste Entscheidung und einen Kommentar im Code, warum der Weg über die Entitäten hier nicht gegangen wird.

Der Wendepunkt: drei Aufrufe, drei Transaktionen

Der Job wächst weiter, wie Jobs das tun. Nach dem Beenden der Aktionen sollen die betroffenen Katalogeinträge zur Neuindizierung vorgemerkt und ein Protokolleintrag geschrieben werden. Drei Repository-Aufrufe, sauber getrennt, jeder für sich getestet.

@Service
@Transactional(readOnly = true)
public class PriceMaintenance {

    @Scheduled(cron = "0 15 3 * * *")
    public void nightlyPriceMaintenance() {
        LocalDate today = LocalDate.now();

        int affected = promoPrices.endExpired( today );
        catalogEntries.flagForReindex( today );
        auditEntries.save( new AuditEntry( "price-maintenance", affected, today ) );
    }
}

Die Methode selbst hat keine schreibende Transaktion. Sie erbt readOnly = true von der Klasse, und die drei Aufrufe bringen ihre eigene Transaktion jeweils selbst mit, weil sie an der Methode annotiert sind oder von SimpleJpaRepository erben.

Damit ist jeder Aufruf für sich atomar und die Methode als Ganzes gar nicht.

Aufruf 1   promoPrices.endExpired          BEGIN ... COMMIT
Aufruf 2   catalogEntries.flagForReindex   BEGIN ... FEHLER, ROLLBACK
Aufruf 3   auditEntries.save               nie erreicht

Ergebnis   Aktionen beendet, Suchindex wirbt weiter damit, kein Protokolleintrag

Fällt der zweite Aufruf aus, sind die Aktionen bereits beendet und committet. Der Suchindex wirbt weiter mit einem Preis, den der Shop nicht mehr gibt, und das Protokoll fehlt. Der Datenbestand ist in einem Zustand, den es fachlich nicht geben darf, und kein einzelner Aufruf hat etwas falsch gemacht.

Das ist der Punkt, an dem sich die eigentliche Frage stellt. Alle Annotationen am Repository beschreiben, wie eine einzelne Abfrage läuft. Keine von ihnen beschreibt, was eine fachliche Einheit ist. Das kann sie auch nicht, denn das Repository weiß nichts davon, in welchem Zusammenhang es aufgerufen wird.

Die vollständige Lösung

Die Grenze gehört dorthin, wo die Fachlichkeit sitzt. Das Repository behält seine Annotationen, sie sind weiterhin richtig, sie sind nur nicht ausreichend.

@Transactional(readOnly = true)
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {

    List<PromoPrice> findByRunningTrueAndEndsOnBefore( LocalDate cutoff );

    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Transactional
    @Query("update PromoPrice p set p.running = false, p.version = p.version + 1 "
         + "where p.running = true and p.endsOn < :cutoff")
    int endExpired( @Param("cutoff") LocalDate cutoff );
}

Der Service setzt die Klammer. readOnly = true bleibt der Standard für die Klasse, weil die meisten Methoden lesen, und die schreibende Methode hebt ihn ausdrücklich auf.

@Service
@Transactional(readOnly = true)
public class PriceMaintenance {

    private static final Logger log = LoggerFactory.getLogger( PriceMaintenance.class );

    private final PromoPriceRepository promoPrices;
    private final CatalogRepository catalogEntries;
    private final AuditRepository auditEntries;

    public PriceMaintenance( PromoPriceRepository promoPrices,
                             CatalogRepository catalogEntries,
                             AuditRepository auditEntries ) {
        this.promoPrices = promoPrices;
        this.catalogEntries = catalogEntries;
        this.auditEntries = auditEntries;
    }

    @Scheduled(cron = "0 15 3 * * *")
    @Transactional
    public void nightlyPriceMaintenance() {
        LocalDate today = LocalDate.now();

        int affected = promoPrices.endExpired( today );
        catalogEntries.flagForReindex( today );
        auditEntries.save( new AuditEntry( "price-maintenance", affected, today ) );

        log.info( "{} abgelaufene Aktionen beendet", affected );
    }
}

Jetzt öffnet die Service-Methode die Transaktion, und die drei Repository-Aufrufe laufen darin mit, weil die Standard-Weitergabe REQUIRED eine vorhandene Transaktion übernimmt statt eine neue zu öffnen. Fällt der zweite Aufruf aus, rollt alles zurück. Der Datenbestand kennt nur zwei Zustände: vor der Wartung oder nach der Wartung.

Ein Detail bleibt zu beachten, weil es die häufigste Anschlussfrage ist. Die Weitergabe REQUIRED übernimmt eine bestehende Transaktion mitsamt ihrem Lese-Kennzeichen. Eine Methode mit einfachem @Transactional, die aus einer lesenden Transaktion heraus aufgerufen wird, hebt das Kennzeichen also nicht auf. Sie hängt sich an die bestehende Transaktion und schreibt trotzdem nicht. Deshalb steht @Transactional hier an der äußersten Methode, nicht irgendwo weiter innen.

Die Annotationen auf einen Blick

Annotation Wo sie hingehört Was sie bewirkt Was sie nicht kann
@Repository Nur an selbst geschriebenen Datenzugriffsklassen Komponentensuche, Übersetzung der Persistenz-Exceptions An einer Spring-Data-Schnittstelle ohne jede Wirkung
@Transactional(readOnly = true) An der Repository-Schnittstelle als Standard Kein automatisches Ausspülen, kein Zustands-Abzug, Lese-Kennzeichen an der Verbindung Verhindert Schreibvorgänge nicht, sie verschwinden nur stillschweigend
@Transactional an der Methode An jeder schreibenden Repository-Methode Hebt das Lese-Kennzeichen der Schnittstelle auf Hebt es nicht auf, wenn bereits eine lesende Transaktion läuft
@Modifying An jeder @Query, die schreibt Führt die Anweisung über executeUpdate() aus Aktualisiert den Persistenzkontext nicht von selbst
@Modifying(clearAutomatically = true) Wenn nach der Anweisung weitergearbeitet wird Leert den Persistenzkontext danach Löst dabei alle verwalteten Entitäten ab, nicht nur die betroffenen
@Modifying(flushAutomatically = true) Wenn vorher Entitäten geändert wurden Spült vor der Anweisung aus Ersetzt keine bewusste Reihenfolge im Ablauf

Gehört readOnly ans Repository oder an den Service?

An beide Stellen, und aus unterschiedlichen Gründen.

Am Repository ist es ein Sicherheitsnetz. Es sorgt dafür, dass eine Abfrage, die versehentlich ohne umgebende Transaktion aufgerufen wird, trotzdem als Lesevorgang läuft und ihre Sparsamkeit behält. Es dokumentiert außerdem an der Schnittstelle, welche Methoden lesen und welche schreiben, und diese Information ist genau dort am nützlichsten.

Am Service ist es die fachliche Aussage. Eine Service-Methode, die eine Auswertung zusammenstellt und dabei sieben Repositories befragt, soll als eine lesende Transaktion laufen, nicht als sieben. Das ist der größere Hebel, denn dort entscheidet sich, wie viele Verbindungen aus dem Pool geholt werden und wie viele Abfragen denselben Datenstand sehen.

Die Reihenfolge in der Praxis: erst die Grenze im Service setzen, dann das Repository annotieren. Wer es umgekehrt macht, hat ein sparsames Repository in einer Anwendung ohne Transaktionsgrenzen, und das ist die Kombination aus der Szene ganz oben.

Wann readOnly nichts bringt

Der ehrliche Teil. readOnly = true ist kein Schalter, den man überall anbringt und der überall etwas bringt.

Bei kleinen Ergebnismengen ist der Gewinn nicht messbar. Wer eine Entität über den Schlüssel lädt und ein Feld ausliest, spart einen Vergleich und einen zweiten Satz Feldwerte. Das ist Rauschen. Der Nutzen entsteht bei Listen, Auswertungen und Exporten, also dort, wo viele Entitäten gleichzeitig im Persistenzkontext liegen.

Projektionen und DTO-Abfragen profitieren ebenfalls kaum, weil dabei ohnehin keine verwalteten Entitäten entstehen. Es gibt keinen Zustands-Abzug, der eingespart werden könnte. Wer wirklich nur lesen will und die Datenmenge groß ist, fährt mit einer Projektion meist besser als mit readOnly auf vollen Entitäten.

Bei pessimistischen Sperren ist es sogar schädlich, wie oben beschrieben. Eine Methode mit @Lock darf nicht unter einem geerbten Lese-Kennzeichen laufen.

Am schwersten wiegt, dass readOnly keine Zusicherung ersetzt. Wer sicherstellen will, dass ein Codepfad nicht schreibt, braucht getrennte Datenbankbenutzer mit unterschiedlichen Rechten oder eine eigene Datenquelle auf eine Leseinstanz. Eine Annotation leistet das nicht. Sie ist eine Sparmaßnahme mit angenehmer Nebenwirkung, keine Zugriffskontrolle.

FAQ

Brauche ich @Repository an meiner Spring-Data-Schnittstelle?
Nein. Die Schnittstelle wird über den Repository-Scan gefunden, und die Übersetzung der Persistenz-Exceptions erledigt der erzeugte Proxy. Nötig ist die Annotation nur an selbst geschriebenen Datenzugriffsklassen.

Warum brauche ich @Transactional zusätzlich zu @Modifying?
@Modifying sagt nur, wie die Anweisung ausgeführt wird, es öffnet keine Transaktion. Steht an der Schnittstelle readOnly = true, muss die Methode das ausdrücklich aufheben, sonst läuft die schreibende Anweisung unter dem Lese-Kennzeichen.

Warum sehe ich meine Änderung nach einem Bulk-Update nicht?
Weil die Anweisung am Persistenzkontext vorbeigeht und die dort liegenden Entitäten unverändert bleiben. @Modifying(clearAutomatically = true) leert den Kontext danach, allerdings mitsamt allen anderen verwalteten Entitäten.

Verhindert readOnly = true das Schreiben?
Auf Anwendungsebene nicht. Änderungen an Entitäten verschwinden stillschweigend, weil kein Vergleich stattfindet und deshalb gar keine Anweisung entsteht. Sobald tatsächlich eine Schreib-Anweisung abgeschickt wird, etwa aus @Modifying ohne eigenes @Transactional, scheitert sie auf PostgreSQL an der Datenbank, weil der Treiber die Transaktion als lesend eröffnet.

Warum funktioniert mein Test, aber in Produktion kommt ein Fehler?
Sehr wahrscheinlich, weil der Test gegen H2 läuft und Produktion gegen PostgreSQL. H2 lässt Schreibvorgänge in einer als lesend markierten Transaktion durch, PostgreSQL nicht. Teste gegen dieselbe Datenbank, die du auch betreibst.

Kann ich das Lese-Kennzeichen in einer inneren Methode aufheben?
Mit der Standard-Weitergabe REQUIRED nicht. Die innere Methode übernimmt die laufende Transaktion samt Kennzeichen. Entweder setzt du die Grenze weiter außen, oder du öffnest mit REQUIRES_NEW bewusst eine eigene Transaktion, was eine zweite Verbindung aus dem Pool kostet.

Wie viele Transaktionen laufen, wenn mein Service keine Grenze setzt?
So viele, wie du Repository-Aufrufe machst. Jeder bringt seine eigene mit. Für eine einzelne Abfrage ist das in Ordnung, für eine fachliche Einheit aus mehreren Schritten nicht.

Fazit

Die Annotationen an der Repository-Schnittstelle beantworten eine einzige Frage: wie eine einzelne Abfrage läuft. @Transactional(readOnly = true) macht Lesevorgänge sparsam und Schreibvorgänge unsichtbar. @Modifying macht aus einer Query eine Anweisung und aus dem Persistenzkontext eine Fehlerquelle, wenn danach weitergearbeitet wird. @Repository macht an dieser Stelle gar nichts.

Was keine von ihnen beantwortet, ist die Frage, wo eine fachliche Einheit anfängt und aufhört. Diese Grenze setzt der Service, und wenn sie fehlt, hilft die sorgfältigste Annotation am Repository nicht weiter.

Der nächste Schritt ist klein und lohnt sich sofort: Geh die Service-Klassen durch, die mehr als einen Repository-Aufruf in einer Methode haben, und prüfe, ob dort eine Transaktion geöffnet wird. Wo keine ist, hast du genau die Konstruktion aus dem Beispiel oben, nur ohne den nächtlichen Job, der es sichtbar macht.

Quellen

Alle Code-Beispiele sind eigene und auf Spring Boot 4 mit PostgreSQL ausgeführt.

$ lang DE EN ES