Interesting question, since it touches many aspects of performance engineering, concurrent programming and especially consistency via performance trade offs.
It is possible to enable caching in Hibernate and configure write behind caching this is, for example, possible with EHCache. However, there is still considerable overhead, since Hibernate and the database is designed for transactional workloads and consistency.
I am going to present a potentially optimal solution based on cache2k. I do leverage some feature in cache2k that are not present in other caches, however, some of the techniques I use here can be used with other cache implementations as well like EHCache, Guava Cache, Caffeine or a JCache/JSR107 that supports store by reference.
Since you run on a single server, working with in memory data is most efficient. Writes can be skipped or delayed, since you do not have transactional data like bank accounts. In case of a server crash it is tolerable to loose a tiny bit of status updates. It always is a trade off between update performance and potential data loss in case of a crash.
You can hold the current status in a map or cache and then update the
existing object. Example:
class Gate {
final AtomicInteger health = new AtomicInteger(100);
}
Cache<Id, Gate> cache = ....;
void decreaseHealth(Id id, int damage) {
Gate gate = cache.get(id);
gate.health.addAndGet(-damage);
}
I present additional code that interacts with the database later and focus on the in memory update first.
If you use a map instead of a cache, you need to use a map that is thread safe, like ConcurrentHashMap
.
Since the changes may happen concurrently you need to use a method to atomically update the health. Above I used AtomicInteger
. Another possibility is the atomic updater or var handles. If you just update a single value, this is approach is most efficient, since it translates to a single CAS operation on the hardware. If you update multiple values in the object, use locks, synchronized
or an atomic operation on the cache/map entry. Example:
class Gate {
int health = 100;
// ....
}
cache.asMap().compute(id, (unused, gate) -> {
gate.health -= damage;
if (gate.health == 0) {
// more changes to the object if destroyed totally
}
return gate;
});
Here is an idea of a working solution based of cache2k that does schedule write behind in case a major change happened.
// mocks
class Id {}
Gate readFromDb() { return null; }
void writeDb(Gate g) { }
class Gate {
final AtomicInteger health = new AtomicInteger(100);
volatile boolean writeScheduled = false;
final AtomicInteger persistentHealth = new AtomicInteger(100);
boolean isDirty() {
return persistentHealth.get() != health.get();
}
}
Cache<Id, Gate> cache =
new Cache2kBuilder<Id, Gate>() {}
.loader((id, l, cacheEntry) -> {
if (cacheEntry == null) { return readFromDb(); }
Gate gate = cacheEntry.getValue();
return gate;
})
.addListener((CacheEntryExpiredListener<Id, Gate>) (cache, cacheEntry) -> {
writeIfModified(cacheEntry.getValue());
})
.refreshAhead(true)
.keepDataAfterExpired(true)
.expireAfterWrite(5, TimeUnit.MINUTES)
.loaderExecutor(Executors.newFixedThreadPool(30))
.build();
void writeIfModified(Gate gate) {
if (!gate.isDirty()) { return; }
int persistentHealth = gate.health.get();
writeDb(gate);
gate.writeScheduled = false;
gate.persistentHealth.set(persistentHealth);
}
long writeBehindDelayMillis = 500;
int changePercentage = 10;
public void decreaseHealth(Id id, int damage) {
Gate gate = cache.get(id);
int persistentHealth = gate.persistentHealth.get();
int newHealth = gate.health.addAndGet(-damage);
if (!gate.writeScheduled) {
int percentage = persistentHealth * 10 / 100;
if (newHealth > persistentHealth + percentage ||
newHealth < persistentHealth - percentage) {
cache.invoke(id, entry -> {
entry.setValue(entry.getValue());
entry.setExpiryTime(entry.getStartTime() + writeBehindDelayMillis);
entry.getValue().writeScheduled = true;
return null;
});
}
}
}
This operates the cache in read through mode, so a cache.get()
triggers the initial database load. Further more, it uses expiry and refresh ahead to schedule a delayed write, if needed. That is a bit tricky, since the usual and "documented" use case of refresh ahead is a different one. If interested I can explain the detailed mechanics in a blog post. It gets a bit too heavy for a Stack Overflow answer.
Of course, you can use some of the ideas with other cache implementations as well.
One final note: In case map compute is used for atomicy, operations may block if a concurrent synchronous database write is going on. Either do asynchronous writes or use different locking for updates.
It still would be interesting to do a performance comparison to the write back approach via a JPA cache.