Jan Krakora
Jan Krakora

Reputation: 2610

Wrong Spring cache state when using transaction and parallel threads

I'm trying to understand how Spring's caching works, especially together with transactions and more threads.

Let's have a service caching its results

public class ServiceWithCaching {

    @Cacheable(value="my-cache")
    public String find() {
        ...load from DB
    }

    @CacheEvict(value="my-cache", allEntries=true)
    public void save(String value) {
        ...save to DB
    }
}

Now consider a test that runs two parallel threads. One of them use a transaction to save a value, the second one reads a value.

service.save("initial");             // initial state
assert service.find() == "initial";  // load cache

CountDownLatch latch = new CountDownLatch(1);

Thread saveThread = new Thread(() -> {

    TransactionDefinition transactionDefinition = new DefaultTransactionDefinition();
    TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager, transactionDefinition);
    transactionTemplate.execute(new TransactionCallbackWithoutResult() {

        @Override
        protected void doInTransactionWithoutResult(TransactionStatus status) {
            service.save("test"); // evict cache
            latch.await();
        }
    });

});
saveThread.start();

Thread readThread = new Thread(() -> {
    service.find(); // load cache
    latch.countDown();
});
readThread.start();

saveThread.join();
assert service.find() == "test";

The assert fails because service.find() returns "initial". This is because the second thread loads previously evicted cache before the first thread commit the transaction.

The result is:

Is there any Spring-way how to solve this problem?

Upvotes: 3

Views: 2259

Answers (1)

John Blum
John Blum

Reputation: 7991

Well, after reviewing your code above, it would seem correct, but there are few subtleties related to thread timing that I believe is causing your test to fail. I.e. your test has possible race condition(s) (e.g. check-then-act) despite your attempt to coordinate the threads (i.e. read, save and main) properly.

Technically, and specifically, your thread coordination logic does not guarantee that the interleaving of the threads' actions by the JRE (combined with the OS thread scheduler) will lead to the expected result.

Consider the following...

Let:

R == Reader Thread
S == Save Thread
M == Main Thread

Then the following interleaving of thread operations is possible:

T0.  M @ S.start()

T1.  M @ R.start()

T2.  S @ transactionTemplate.execute() // Starts a (local) Transaction context

T3.  S @ txCallback.doInTransactionWithoutResult()

T4.  S @ cache.evict() // Evicts all entries

T5.  S @ service.save("test")

T6.  S @ db.insert(..) // block call to the DB    

T7.  R @ server.find()

T8.  R @ cache.get() // results in cache miss due to eviction in T4

T9.  R @ db.load(key) // loads "initial" since TX in T6 has not committed yet

T10. R @ cache.put(key, "initial");

T11. R @ latch.countDown()

T12. S @ db.insert(..) // returns updateCount == 1

T13. S @ tx.commit();

T14. S @ latch.await();  // proceeds

T15. M @ saveThread.join() // waits for S to terminate, then...

T16. M @ assert service.find() == "test" // cache hit; returns "initial"; assertion fails.

First, as you know, Thread.start() does not cause a thread to run. start() signals to the runtime that the thread is "ready" to be scheduled and ran by the OS. You can manipulate thread priorities, but that is not going to help much, nor solve your race condition.

Second, you may be able to fix your test by switching the latch.await() call and latch.countDown() in your reader and save threads like so...

Thread saveThread = new Thread(() -> {
    ...
    transactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus status) {
            service.save("test"); // evict all entries in cache
            latch.countDown();
        }
    });
});

And then...

Thread readThread = new Thread(() -> {
  latch.await();
  service.find();
});

readThread.join();

However, since you pre-load the cache before starting any threads...

service.save("initial");             // initial state
assert service.find() == "initial";  // load cache

And then proceed to call service.find() after the saveThread terminates, there is not really any point for the readThread since the main thread can serve as the "reader" thread. So then...

saveThread.join();
assert service.find() == "test";

Again, I am not 100% certain this is exactly what happening in your case, but it is possible.

I have coded a similar test (based on your test code above) here. There are a few differences.

First, I made use of a simple, but elegant concurrent testing framework called MultithreadedTC in order to maintain exact and precise control over the threads.

Second, I used Spring's @Transactional annotation support rather than programmatic transaction management as you have done in your test.

Finally, I used an embedded HSQL database (DataSource) along with the DataSourcePlatformTransactionManager to test the transactional behavior in a caching context. The SQL initialization scripts are here (schema) and here (data).

Be sure to declare the appropriate dependencies on your classpath if you run this test.

This test passed as expected, so I would say Spring's Cache Abstraction functions correctly in the context of caching, providing things are coordinated properly between multiple threads.

There are few other things to keep in mind.

  1. The @CacheEvict annotation is a post-method invocation operation (i.e. "after" AOP advice, which is "default" behavior) meaning it will evict entries from the cache only upon successful execution of the method. You can change this behavior by specifying the beforeInvocation attribute on the @CacheEvict annotation.

  2. When combining multiple types of advice to a application service method (e.g. Transactional or Caching) you may need to specify the order in which the advice executes to achieve proper application behavior.

  3. Keep in mind if multiple threads are calling the same @Cacheable method, you may need to properly synchronize the operation using the sync attribute (see here for more details). If you need to coordinate between multiple cache-based operations (for example an @Cacheable method and @CacheEvict method) that may be called concurrently, then you will need to synchronize the methods using service object's monitor.

  4. Let's see, what else???

Hope this helps!

-John

Upvotes: 3

Related Questions