How does Hibernate READ_ONLY CacheConcurrencyStrategy work
Introduction
As I previously explained, enterprise caching requires diligence. Because data is duplicated between the database (system of record) and the caching layer, we need to make sure the two separate data sources don’t drift apart.
If the cached data is immutable (neither the database nor the cache are able modify it), we can safely cache it without worrying of any consistency issues. Read-only data is always a good candidate for application-level caching, improving read performance without having to relax consistency guarantees.
Read-only second-level caching
For testing the read-only second-level cache strategy, we going to use the following domain model:
The Repository is the root entity, being the parent of any Commit entity. Each Commit has a list of Change components (embeddable value types).
All entities are cached as read-only elements:
@org.hibernate.annotations.Cache( usage = CacheConcurrencyStrategy.READ_ONLY )
Persisting entities
The read-only second-level cache uses a read-through caching strategy, entities being cached upon fetching.
doInTransaction(session -> { Repository repository = new Repository("Hibernate-Master-Class"); session.persist(repository); });
When an entity is persisted only the database contains a copy of this entity. The system of record is passed to the caching layer when the entity gets fetched for the first time.
@Test public void testRepositoryEntityLoad() { LOGGER.info("Read-only entities are read-through"); doInTransaction(session -> { Repository repository = (Repository) session.get(Repository.class, 1L); assertNotNull(repository); }); doInTransaction(session -> { LOGGER.info("Load Repository from cache"); session.get(Repository.class, 1L); }); }
This test generates the output:
--Read-only entities are read-through SELECT readonlyca0_.id AS id1_2_0_, readonlyca0_.NAME AS name2_2_0_ FROM repository readonlyca0_ WHERE readonlyca0_.id = 1 --JdbcTransaction - committed JDBC Connection --Load Repository from cache --JdbcTransaction - committed JDBC Connection
Once the entity is loaded into the second-level cache, any subsequent call will be served by the cache, therefore bypassing the database.
Updating entities
Read-only cache entries are not allowed to be updated. Any such attempt ends up in an exception being thrown:
@Test public void testReadOnlyEntityUpdate() { try { LOGGER.info("Read-only cache entries cannot be updated"); doInTransaction(session -> { Repository repository = (Repository) session.get(Repository.class, 1L); repository.setName( "High-Performance Hibernate" ); }); } catch (Exception e) { LOGGER.error("Expected", e); } }
Running this test generates the following output:
--Read-only cache entries cannot be updated SELECT readonlyca0_.id AS id1_2_0_, readonlyca0_.NAME AS name2_2_0_ FROM repository readonlyca0_ WHERE readonlyca0_.id = 1 UPDATE repository SET NAME = 'High-Performance Hibernate' WHERE id = 1 --JdbcTransaction - rolled JDBC Connection --ERROR Expected --java.lang.UnsupportedOperationException: Can't write to a readonly object
Because read-only cache entities are practically immutable it’s good practice to attribute them the Hibernate specific @Immutable annotation.
Deleting entities
Read-only cache entries are removed when the associated entity is deleted as well:
@Test public void testReadOnlyEntityDelete() { LOGGER.info("Read-only cache entries can be deleted"); doInTransaction(session -> { Repository repository = (Repository) session.get(Repository.class, 1L); assertNotNull(repository); session.delete(repository); }); doInTransaction(session -> { Repository repository = (Repository) session.get(Repository.class, 1L); assertNull(repository); }); }
Generating the following output:
--Read-only cache entries can be deleted SELECT readonlyca0_.id AS id1_2_0_, readonlyca0_.NAME AS name2_2_0_ FROM repository readonlyca0_ WHERE readonlyca0_.id = 1; DELETE FROM repository WHERE id = 1 --JdbcTransaction - committed JDBC Connection SELECT readonlyca0_.id AS id1_2_0_, readonlyca0_.NAME AS name2_2_0_ FROM repository readonlyca0_ WHERE readonlyca0_.id = 1; --JdbcTransaction - committed JDBC Connection
The remove entity state transition is enqueued by PersistenceContext, and at flush time, both the database and the second-level cache will delete the associated entity record.
Collection caching
The Commit entity has a collection of Change components.
@ElementCollection @CollectionTable( name="commit_change", joinColumns=@JoinColumn(name="commit_id") ) private List<Change> changes = new ArrayList<>();
Although the Commit entity is cached as a read-only element, the Change collection is ignored by the second-level cache.
@Test public void testCollectionCache() { LOGGER.info("Collections require separate caching"); doInTransaction(session -> { Repository repository = (Repository) session.get(Repository.class, 1L); Commit commit = new Commit(repository); commit.getChanges().add( new Change("README.txt", "0a1,5...") ); commit.getChanges().add( new Change("web.xml", "17c17...") ); session.persist(commit); }); doInTransaction(session -> { LOGGER.info("Load Commit from database"); Commit commit = (Commit) session.get(Commit.class, 1L); assertEquals(2, commit.getChanges().size()); }); doInTransaction(session -> { LOGGER.info("Load Commit from cache"); Commit commit = (Commit) session.get(Commit.class, 1L); assertEquals(2, commit.getChanges().size()); }); }
Running this test generates the following output:
--Collections require separate caching SELECT readonlyca0_.id AS id1_2_0_, readonlyca0_.NAME AS name2_2_0_ FROM repository readonlyca0_ WHERE readonlyca0_.id = 1; INSERT INTO commit (id, repository_id) VALUES (DEFAULT, 1); INSERT INTO commit_change (commit_id, diff, path) VALUES (1, '0a1,5...', 'README.txt'); INSERT INTO commit_change (commit_id, diff, path) VALUES (1, '17c17...', 'web.xml'); --JdbcTransaction - committed JDBC Connection --Load Commit from database SELECT readonlyca0_.id AS id1_2_0_, readonlyca0_.NAME AS name2_2_0_ FROM repository readonlyca0_ WHERE readonlyca0_.id = 1; SELECT changes0_.commit_id AS commit_i1_0_0_, changes0_.diff AS diff2_1_0_, changes0_.path AS path3_1_0_ FROM commit_change changes0_ WHERE changes0_.commit_id = 1 --JdbcTransaction - committed JDBC Connection --Load Commit from cache SELECT changes0_.commit_id AS commit_i1_0_0_, changes0_.diff AS diff2_1_0_, changes0_.path AS path3_1_0_ FROM commit_change changes0_ WHERE changes0_.commit_id = 1 --JdbcTransaction - committed JDBC Connection
Although the Commit entity is retrieved from the cache, the Change collection is always fetched from the database. Since the Changes are immutable too, we would like to cache them as well, to save unnecessary database round-trips.
Enabling Collection cache support
Collections are not cached by default, and to enable this behavior, we have to annotate them with the a cache concurrency strategy:
@ElementCollection @CollectionTable( name="commit_change", joinColumns=@JoinColumn(name="commit_id") ) @org.hibernate.annotations.Cache( usage = CacheConcurrencyStrategy.READ_ONLY ) private List<Change> changes = new ArrayList<>();
Re-running the previous test generate the following output:
--Collections require separate caching SELECT readonlyca0_.id AS id1_2_0_, readonlyca0_.NAME AS name2_2_0_ FROM repository readonlyca0_ WHERE readonlyca0_.id = 1; INSERT INTO commit (id, repository_id) VALUES (DEFAULT, 1); INSERT INTO commit_change (commit_id, diff, path) VALUES (1, '0a1,5...', 'README.txt'); INSERT INTO commit_change (commit_id, diff, path) VALUES (1, '17c17...', 'web.xml'); --JdbcTransaction - committed JDBC Connection --Load Commit from database SELECT readonlyca0_.id AS id1_2_0_, readonlyca0_.NAME AS name2_2_0_ FROM repository readonlyca0_ WHERE readonlyca0_.id = 1; SELECT changes0_.commit_id AS commit_i1_0_0_, changes0_.diff AS diff2_1_0_, changes0_.path AS path3_1_0_ FROM commit_change changes0_ WHERE changes0_.commit_id = 1 --JdbcTransaction - committed JDBC Connection --Load Commit from cache --JdbcTransaction - committed JDBC Connection
Once the collection is cached, we can fetch the Commit entity along with all its Changes without hitting the database.
Conclusion
Read-only entities are safe for caching and we can load an entire immutable entity graph using the second-level cache only. Because the cache is read-through, entities are cached upon being fetched from the database. The read-only cache is not write-through because persisting an entity only materializes into a new database row, without propagating to the cache as well.
- Code available on GitHub.
Reference: | How does Hibernate READ_ONLY CacheConcurrencyStrategy work from our JCG partner Vlad Mihalcea at the Vlad Mihalcea’s Blog blog. |