Using Valkey or Redis as a Primary Database in Java
Almost every tutorial you will read puts Valkey or Redis in front of a relational database. Requests check the cache, fall through to Postgres on a miss, and the cache holds nothing that cannot be rebuilt. That is the common deployment, and for most applications it is the right one.
It is not, however, a limit of the technology. Valkey and Redis can hold the truth. Teams run session stores, feature stores, order books, and device state as Redis in-memory database deployments with no relational system behind them at all. Using Redis as a database rather than a cache is a supported configuration, not a hack. What changes is that a set of properties you could previously ignore become requirements.
This article covers what those requirements are, how to model and query data as a system of record from Java using Redisson, and — just as importantly — when you should not do this.
What Changes When Valkey or Redis Is the System of Record
Three things, and everything else in this article follows from them.
Durability stops being optional. As a cache, losing the dataset costs you a latency spike while it refills. As a database, losing the dataset means losing data. Snapshotting and append-only logging move from nice-to-have to load-bearing, and the fsync policy you choose defines your worst-case loss window. Our guide to Redis persistence covers RDB and AOF in detail; configure it before anything else here matters.
Eviction must be off. This is the failure mode that catches teams out, and it gets its own section below.
You lose the fallback. A cache bug is recoverable because the authoritative copy still exists elsewhere. Once Valkey is the only copy, a bad migration or an errant FLUSHDB is permanent. Backups and replication stop being operational hygiene and become part of the application's correctness story.
If those three do not sound acceptable for your data, stop here and use it as a cache instead. That is not a lesser outcome; it is the correct one for most workloads.
Turn Off Eviction First
By default, most managed Valkey and Redis services ship with an eviction policy that silently discards keys when memory fills. On a cache that is exactly what you want. On a system of record it is silent data loss, and because it only triggers under memory pressure, it will not show up in testing.
# On a cache - evict the least recently used key when memory fills
maxmemory-policy allkeys-lru
# On a system of record — refuse writes instead of discarding data
maxmemory-policy noeviction
With noeviction, writes fail with an OOM error once maxmemory is reached rather than quietly deleting something. That is the behaviour you want: a loud failure your monitoring can catch, not a quiet one your users discover months later. See cache eviction for how each policy behaves under pressure.
Verify it on the running server rather than trusting your provisioning template:
valkey-cli CONFIG GET maxmemory-policy
# 1) "maxmemory-policy"
# 2) "noeviction"
The same applies to TTLs. A time to live on a cache entry is good hygiene; a TTL on a record you consider authoritative is a scheduled deletion. Use RMap rather than RMapCache for this data, precisely because the former has no per-entry expiry to set by accident.
Modelling Data Without a Schema
The usual objection to a Java in-memory database built on Valkey is that you lose the domain model — that you end up hand-assembling hashes and parsing strings back into objects. Redisson's Live Object Service removes that problem by mapping annotated POJOs onto stored records directly.
@REntity
public class Order {
@RId
private String id;
private String customerId;
private BigDecimal total;
private String status;
private Instant createdAt;
// getters and setters
}
The object is then persisted and retrieved as an object, with field access translating into operations on the underlying hash:
RLiveObjectService service = redisson.getLiveObjectService();
Order order = new Order();
order.setId("ord-1001");
order.setCustomerId("cust-42");
order.setTotal(new BigDecimal("149.99"));
order.setStatus("PENDING");
order.setCreatedAt(Instant.now());
order = service.persist(order);
// from any other JVM connected to the same cluster
Order found = service.get(Order.class, "ord-1001");
found.setStatus("SHIPPED"); // written through immediately
The important detail is that found is a proxy, not a snapshot. Setting a field issues a write; reading one issues a read. There is no session to flush and no risk of two instances holding divergent copies of the same record. Our walkthrough of Redisson's Live Object Service goes deeper on cascading, expiry, and object references.
Querying Without the Primary Key
The second objection is that a key-value store only lets you fetch by key, which is fine until someone asks for every pending order over $100. Both Valkey and Redis support secondary indexing, and Redisson exposes it through RSearch.
RSearch search = redisson.getSearch();
search.createIndex("idx:orders",
IndexOptions.defaults()
.on(IndexType.HASH)
.prefix(List.of("Order:")),
FieldIndex.tag("customerId"),
FieldIndex.tag("status"),
FieldIndex.numeric("total"));
SearchResult result = search.search("idx:orders",
"@status:{PENDING} @total:[100 +inf]",
QueryOptions.defaults().limit(0, 50));
Indexes are maintained as records change, so queries stay consistent with writes without an application-side reindex step. This is what makes the difference between using Valkey as a keyed store and using it as a database — see searching data stored in Redis on Java for aggregations, sorting, and full-text options.
Atomicity Across Keys
Individual commands are atomic, but a business operation rarely maps to a single command. Decrementing inventory and recording an order must either both happen or neither. Redisson provides transactions with rollback across multiple objects:
RTransaction transaction = redisson.createTransaction(TransactionOptions.defaults());
RMap<String, Integer> inventory = transaction.getMap("inventory");
RMap<String, String> orders = transaction.getMap("orders");
Integer remaining = inventory.get("sku-9");
if (remaining == null || remaining < 1) {
transaction.rollback();
throw new OutOfStockException("sku-9");
}
inventory.put("sku-9", remaining - 1);
orders.put("ord-1001", "PENDING");
transaction.commit();
Redisson locks the keys a transaction touches, so the read-check-write above is safe against a concurrent decrement of the same SKU. It is not a substitute for thinking about contention: a hot key serialises every writer behind one lock, and under real load an atomic addAndGet with a negative-result check usually scales better than a transaction.
Either way this is a stronger guarantee than the native MULTI/EXEC block, which queues commands and executes them together but offers no rollback if one fails. The distinction, and the Java specifics, are covered in our guide to managing transactions in Valkey and Redis on Java.
Capacity Planning
As a cache, running out of memory is self-correcting — eviction trims the working set and the hit rate dips. As a database, with eviction disabled, running out of memory means writes stop. Size accordingly. This is also where the question of which is the fastest in-memory database stops being useful: at these latencies the bottleneck is your network round trip and your data model, not the store.
Budget for more than the dataset itself. Replication buffers grow when a replica falls behind. Snapshotting forks the process, and copy-on-write means memory use can rise sharply during a write-heavy save. Fragmentation adds overhead on top. A working rule is to plan for the dataset at no more than roughly half of available RAM, then measure your own used_memory_rss against used_memory under realistic write load rather than trusting the ratio.
When one node is no longer enough, Redis Cluster shards the keyspace across several. Redisson handles topology changes transparently, so application code written against a single node continues to work unchanged — but note that multi-key transactions are constrained to a single hash slot once you shard, which may affect the transaction boundaries you designed above.
Replication and Failover
A single node holding your only copy of the data is not a database, it is an outage waiting for a hardware fault. Replication is the other half of durability: persistence protects you from a restart, replicas protect you from losing the machine.
Both Valkey and Redis replicate asynchronously by default. The primary acknowledges a write to your application before the replica has it, which means a failover can lose the last few milliseconds of writes. For a cache that is irrelevant. For a Valkey database holding orders or balances it is a decision you should make deliberately rather than inherit. The WAIT command lets you block until a given number of replicas have acknowledged a write, trading latency for a narrower loss window on the operations where it matters:
// wait for at least 1 replica to acknowledge, timing out after 500ms
BatchOptions options = BatchOptions.defaults()
.syncSlaves(1, 500, TimeUnit.MILLISECONDS);
RBatch batch = redisson.createBatch(options);
batch.getMap("orders").putAsync("ord-1001", "PENDING");
batch.execute();
Automatic failover comes from either Sentinel, which monitors a single dataset and promotes a replica when the primary fails, or from Cluster, which handles failover per shard. Redisson discovers topology changes and reconnects without application involvement, but the choice between the two belongs to your capacity plan rather than your client configuration.
When Not to Do This
Being straightforward about the limits is what separates an engineering decision from a marketing one. Use a relational database instead when:
- You need ad-hoc joins across entities. Secondary indexes answer known query shapes well. They are not a substitute for a query planner working over arbitrary joins.
- Your workload is analytical. Scanning years of history to compute aggregates is what column stores are for. Keeping that history in RAM is an expensive way to solve a problem someone else already solved.
- Regulatory requirements dictate a durability model. If an auditor requires synchronous commit to durable storage before acknowledgement, an
everysecfsync policy will not satisfy them, andalwayswill cost you the throughput that made this attractive. - The dataset is very large and uniformly accessed. RAM costs roughly thirty to fifty times more per gigabyte than enterprise NVMe. A hot subset in memory is economical; tens of terabytes is not.
- Your team has no operational experience with it. Making something the system of record means someone has to know how it fails at three in the morning.
Migrating From Cache to System of Record
Nobody should do this in one step. The realistic path starts with data that Valkey already effectively owns even if the schema pretends otherwise — session state, counters, rate-limit windows, leaderboards, device presence. That data is usually written to Redis first and mirrored to a relational table nobody reads, which means the migration is largely an exercise in deleting the mirror.
From there, extend to entities whose access pattern is genuinely key-based and whose consistency requirements you understand. Keep the relational database for whatever needs joins and history. A hybrid arrangement, where the in-memory database owns the hot transactional path and a disk-backed system owns reporting, is a more common end state than a full replacement — and it is usually the better architecture rather than a compromise.
Valkey and Redis will not be the right system of record for most of your data. But for the subset where latency is a functional requirement and the access pattern is known in advance, treating them as a real database rather than a cache in front of one removes an entire tier from the architecture — along with the synchronisation bugs that tier tends to produce. With Redisson, the Java side of that looks like ordinary objects, queries, and transactions rather than a protocol to hand-roll.