我试图理解Spring的缓存是如何工作的,特别是与事务和更多线程一起。
让我们的服务缓存其结果
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
}
}
现在考虑运行两个并行线程的测试。其中一个使用事务来保存值,第二个用于读取值。
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";
断言失败,因为service.find()
返回" initial"。这是因为第二个线程在第一个线程提交事务之前加载先前被驱逐的缓存。
结果是:
有没有Spring-way如何解决这个问题?
答案 0 :(得分:2)
好吧,在查看了上面的代码之后,它似乎是正确的,但是我认为与线程时序相关的细微差别很少会导致测试失败。即尽管您尝试正确协调线程(例如check-then-act
,read
和save
),但您的测试仍有可能存在竞争条件(例如main
)。
从技术上讲,具体而言,您的线程协调逻辑不保证JRE(与OS线程调度程序结合)对线程操作的交错将导致预期结果。
考虑以下内容......
让:
R == Reader Thread
S == Save Thread
M == Main Thread
然后可以进行以下交错的线程操作:
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.
首先,如您所知,Thread.start()
不会导致线程运行。 start()
向运行时发出信号,告知线程已“准备好”由OS调度和运行。您可以操纵线程优先级,但这不会有太大帮助,也无法解决您的竞争条件。
其次,您可以通过在阅读器中切换latch.await()
调用和latch.countDown()
来修复测试,并像这样保存线程......
Thread saveThread = new Thread(() -> {
...
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus status) {
service.save("test"); // evict all entries in cache
latch.countDown();
}
});
});
然后......
Thread readThread = new Thread(() -> {
latch.await();
service.find();
});
readThread.join();
但是,由于您在启动任何线程之前预加载缓存...
service.save("initial"); // initial state
assert service.find() == "initial"; // load cache
然后在service.find()
终止后继续致电saveThread
,readThread
没有任何意义,因为main
主题可以作为“读者”线。那么......
saveThread.join();
assert service.find() == "test";
同样,我并非100%确定这正是您所遇到的情况,但这是可能的。
我编写了一个类似的测试(基于您上面的测试代码)here。有一些差异。
首先,我使用了一个简单但优雅的并发测试框架MultithreadedTC,以便保持对线程的精确和精确控制。
其次,我使用了Spring的@Transactional
注释支持,而不是您在测试中所做的程序化事务管理。
最后,我使用嵌入式HSQL数据库(DataSource
)和DataSourcePlatformTransactionManager
来测试缓存上下文中的事务行为。 SQL初始化脚本在此处(schema)和此处(data)。
如果运行此测试,请务必在类路径上声明相应的依赖项。
此测试按预期传递,所以我会说 Spring的缓存抽象功能在缓存环境中正确运行,提供多个线程之间的正确协调。
还有其他一些事情要记住。
@CacheEvict
annotation是一种方法后调用操作(即“在”AOP建议之后,这是“默认”行为),这意味着只有在成功执行该方法后才会从缓存中驱逐条目。您可以通过在beforeInvocation
注释上指定@CacheEvict
属性来更改此行为。
将多种类型的建议组合到应用程序服务方法(例如,事务性或缓存)时,您可能需要指定建议执行的顺序以实现正确的应用程序行为。
请记住,如果多个线程调用相同的@Cacheable
方法,您可能需要使用sync
属性正确同步操作(有关详细信息,请参阅here) 。如果需要在可以同时调用的多个基于缓存的操作(例如@Cacheable
方法和@CacheEvict
方法)之间进行协调,则需要使用服务对象的监视器同步方法。
让我们看看,还有什么???
希望这有帮助!
-John