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:

RepositoryCommitChangeReadOnlyCacheConcurrencyStrategy

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

In Hibernate 4, 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.

In Hibernate 5, READ_ONLY entities are write-through when using a SEQUENCE or a TABLE generator, while they are read-through for IDENTITY generator.

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.

If you enjoyed this article, I bet you are going to love my book as well.

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.

If you liked this article, you might want to subscribe to my newsletter too.

Advertisements

7 thoughts on “How does Hibernate READ_ONLY CacheConcurrencyStrategy work

  1. Unfortunately hibernate 2nd level cache performs quite poorly in my experience. AFAIK, entities are cached in dehydrated state – as arrays of primitives – and have to be reconstructed in every Session. That, along with cost of constructing hibernate entity proxies, will dominate cpu time in a system that does a lot of reads within short transactions (for example a Web service). One is better off caching read only data using POJOs – for example DTOs constructed from Hibernate entities – and doing copy-on-read if needed.

  2. Hi Vlad,

    It looks like READ_ONLY concurrency strategy acts as a write-through with SEQUENCE id generator. When I tested with 5.0.4 version of Hibernate, I noticed this behavior wherein inserting an entity is made available through SLC for subsequent reads immediately, similar to READ_WRITE.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s