Caching for Java apps that uses your existing PostgreSQL as the backend — no Redis needed.
Good fit: small-to-medium apps where PostgreSQL is already the primary database and Redis would be one more thing to run. Not a fit: millions of ops/sec, sub-millisecond latency, or multi-million-entry caches.
- Zero extra infrastructure — UNLOGGED tables + JSONB in the database you already have
- Spring Boot —
@Cacheable/@CacheEvictvia auto-configuration, Actuator health, Micrometer metrics - Quarkus — CDI injection and Mutiny API (programmatic only; it is a library jar, not a Quarkus extension)
- Cache-stampede protection —
getOrComputecoordinates cold loads across threads and JVMs with PostgreSQL advisory locks - Redis-parity operations —
increment/decrement,getAndDelete,getAndPut,persist,expireAt,getTtlInfo - Type-faithful values — entries record their Java type, so
@Cacheablereturns your class back, not aMap - TTL policies — ABSOLUTE (fixed) or SLIDING (reset on access), per cache or per entry
- And the rest — async API, batch ops, typed generic reads,
scanKeysstreaming, namespaces, event listeners, statistics, pattern eviction, background cleanup
PgCache deliberately reuses the application's DataSource; it does not create a
second connection pool. Framework dependencies and the PostgreSQL JDBC driver are
therefore provided, so choose the module that matches an application which already
owns those resources.
Use pgcache-spring in an application that already has a JDBC/JPA starter and the
PostgreSQL driver. For example, the application normally contains
spring-boot-starter-jdbc (or spring-boot-starter-data-jpa) and
org.postgresql:postgresql in addition to:
<dependency>
<groupId>io.github.hunghhdev</groupId>
<artifactId>pgcache-spring</artifactId>
<version>1.11.1</version>
</dependency>Auto-configuration backs off when any other CacheManager bean exists; in an
application combining cache providers, define the desired primary/composite manager
explicitly. @EnablePgCache below also enables Spring's cache interception.
@SpringBootApplication
@EnablePgCache
public class Application { ... }
@Service
public class UserService {
@Cacheable("users")
public User getUser(Long id) { return userRepository.findById(id); }
@CacheEvict("users")
public void deleteUser(Long id) { userRepository.deleteById(id); }
}# application.yml
pgcache:
default-ttl: PT1H
auto-create-table: true
single-flight-concurrency: 4
background-cleanup:
enabled: true
interval: PT30M
caches:
users:
ttl: PT2H
ttl-policy: SLIDINGUse pgcache-quarkus with io.quarkus:quarkus-cache and
io.quarkus:quarkus-jdbc-postgresql. PgCache injects the default Quarkus
DataSource; an application with only named data sources must provide its own
PgCacheStore/PgQuarkusCacheManager beans.
<dependency>
<groupId>io.github.hunghhdev</groupId>
<artifactId>pgcache-quarkus</artifactId>
<version>1.11.1</version>
</dependency>PgQuarkusHealthCheck is an injectable data provider, not an automatically registered
MicroProfile Health endpoint; expose a small @Readiness HealthCheck delegate when that
endpoint is required.
@ApplicationScoped
public class UserService {
@Inject
PgQuarkusCacheManager cacheManager;
public Uni<User> getUser(Long id) {
PgQuarkusCache cache = (PgQuarkusCache) cacheManager.getCache("users").get();
return cache.getAsync("user:" + id, key -> userRepository.findById(id));
}
}# application.properties
pgcache.default-ttl=PT1H
pgcache.auto-create-table=true
pgcache.single-flight-concurrency=4
pgcache.caches.users.ttl=PT2H
pgcache.caches.users.ttl-policy=SLIDINGUse pgcache-core, provide the PostgreSQL JDBC driver and an SLF4J binding at runtime,
and pass an existing pooled DataSource. The SLF4J API is included transitively; the
application still chooses the logging backend. PgCache borrows and closes individual
Connections; it never closes the DataSource.
<dependency>
<groupId>io.github.hunghhdev</groupId>
<artifactId>pgcache-core</artifactId>
<version>1.11.1</version>
</dependency>PgCacheStore cache = PgCacheStore.builder()
.dataSource(dataSource)
.build();
cache.put("user:123", user, Duration.ofHours(1));
Optional<User> user = cache.get("user:123", User.class);// Read-through with stampede protection: normally one caller (across threads
// and JVMs) runs the loader; fail-open/admission-limit paths may load directly.
// Spring's @Cacheable(sync = true) and Quarkus cache.get(...) route through this.
User user = cache.getOrCompute("user:42", User.class, Duration.ofMinutes(10),
() -> userRepository.findById(42));
// Atomic operations (Redis parity)
long views = cache.increment("views:page:1", 1); // INCR
Optional<Token> old = cache.getAndDelete("token:abc", Token.class); // GETDEL
cache.persist("session:1"); // PERSIST
cache.expireAt("report:daily", tomorrowMidnight); // EXPIREAT
// Typed generic reads
Optional<List<User>> users = cache.get("team:all", new TypeReference<List<User>>() {});
// Key scanning — batched, constant memory
for (String key : cache.scanKeys("user:%", 500)) { process(key); }
// Batch + pattern operations
cache.putAll(entries, Duration.ofHours(1));
cache.getAll(keys, User.class);
cache.evictByPattern("user:%");
// Async
cache.getAsync("user:123", User.class).thenAccept(opt -> ...);
// Statistics
cache.getStatistics().getHitRate();Isolated logical caches sharing one table — clear(), size(), getKeys() only see their own namespace:
PgCacheStore tenantA = PgCacheStore.builder().dataSource(ds).namespace("tenant_a").build();
PgCacheStore tenantB = PgCacheStore.builder().dataSource(ds).namespace("tenant_b").build();
tenantA.clear(); // tenant_b untouchedPgCacheStore.builder()
.dataSource(dataSource)
.addEventListener(new CacheEventListener() {
@Override public void onPut(String key, Object value) { log.info("Cached: {}", key); }
@Override public void onEvict(String key) { log.info("Evicted: {}", key); }
})
.build();In Spring Boot, just define a CacheEventListener bean — it's auto-detected.
Performance. PgCache is a database-backed cache, so latency and throughput depend on the application's pool, network, and PostgreSQL load. Benchmark it in the target environment; it is intended for practical moderate-load reuse of an existing database, not Redis-class sub-millisecond traffic.
UNLOGGED semantics. After a PostgreSQL crash (not a clean restart) the table is truncated — treat every read as a potential miss, as with any cache. UNLOGGED data is not replicated: point PgCache at the primary, never a read replica.
Database ownership. PgCache does not contain or manage a connection pool. Spring
and Quarkus reuse the framework's default DataSource; standalone callers provide one.
Do not create a pool per cache or per request. PgCache closes every borrowed connection
but never closes the application-owned DataSource.
Schema permissions. autoCreateTable=true is convenient for development and simple
deployments, but the database role needs CREATE on the target schema and ownership-like
rights to ALTER the table, create indexes, and delete rows during an old-schema migration.
It does not create a missing schema. With a least-privilege runtime role, provision the
current table with a migration/owner role using the
packaged default DDL,
then set auto-create-table=false; normal operation needs USAGE on the schema plus
SELECT, INSERT, UPDATE, and DELETE on the table. For a custom table name, replace
every pgcache_store occurrence in that DDL, including the index-name prefixes. Spring's
default pg_cache table is one such replacement.
Initialization. With autoCreateTable=true, core and the Quarkus producer
initialize/migrate the table when the store is built. Spring creates stores lazily when
a cache is first requested. Concurrent stores and application instances targeting the
same table serialize this DDL in PostgreSQL; no separate coordinator is required. A green
application startup still does not prove that its cache table permissions are correct.
The Spring Actuator health indicator performs a real
SELECT 1 through the configured DataSource even before a cache exists. After traffic
has initialized a store, health also verifies that store's table still exists. It does
not eagerly create dynamic caches/tables, write probe rows, or run COUNT queries;
creation/migration and table DML permissions are therefore validated by real cache
operations rather than a side-effecting health probe.
Error handling. Direct core reads (get, getAll, size, and similar operations)
throw PgCacheException on database failure. Coordinated read-through operations are
fail-open: core getOrCompute, Spring Cache.get(key, loader) / @Cacheable(sync=true),
Quarkus get(...), and Quarkus getAsync(...) run the loader and return its result
uncached when PostgreSQL is unavailable. A plain Spring cache read degrades to a miss,
but a later put still throws. Direct writes and invalidations throw; silently accepting
them could leave stale data live.
Micrometer metrics (auto-configured with pgcache-spring): pgcache.gets, pgcache.puts, pgcache.evictions, pgcache.size, pgcache.hit.rate.
Requirements. PostgreSQL 9.6 or newer. PostgreSQL 11+ uses a 64-bit advisory-lock hash; 9.6/10 use a compatible 32-bit hash, where the rare collision only serializes two unrelated cold keys. The minimum-compatible releases are upstream-EOL, so use a currently supported PostgreSQL release for production.
Generic depth. Values record their type so framework adapters can hand your class back rather than a Map. Generic nesting is recorded three levels deep: Map<String, List<User>> round-trips, a fourth level degrades to a raw type. When you need a deeper generic, ask for it explicitly — cache.get(key, new TypeReference<Map<String, List<Map<String, User>>>>() {}) — which always wins over the recorded type.
Single-flight and your pool. getOrCompute holds one connection while the loader
runs. Same-key callers are collapsed in-process first, and singleFlightConcurrency
(default 4) caps how many callers may hold a connection inside the cross-JVM lock at
once; the rest load directly. Keep that value at or below a quarter of your pool size.
Spring and Quarkus expose it as pgcache.single-flight-concurrency. Quarkus synchronous
get(...) uses this path; getAsync(...) stays non-blocking and does not hold an advisory
lock while waiting for its Uni, so it does not promise cross-JVM single-flight.
Serialization. Spring reuses a unique application ObjectMapper; Quarkus reuses an
injectable mapper when one exists (for example from quarkus-jackson). Core users can
pass .objectMapper(mapper). Otherwise PgCache uses a plain ObjectMapper, so Java time,
optional, and application-specific modules are not registered automatically.
Key encoding. Core keys are strings. The Spring and Quarkus adapters scope an object
key by cache name and persist its toString() value. Distinct objects with the same
string form therefore collide, and mutable/default identity-based string forms are not
safe across restarts. Prefer stable unique strings; for Spring annotations, use key = ...
or a custom KeyGenerator that returns one. Quarkus invalidateIf receives the persisted
string representation because original Java key objects are not stored.
Framework compatibility. CI verifies the Java 11 baseline on JDK 11/17/21, Spring Boot 2.6.13 and 3.2.12, and Quarkus 3.6.4. These are explicit compatibility anchors, not a promise for every framework release. Spring Boot 4 is not currently supported.
| Setting | Core | Spring | Quarkus |
|---|---|---|---|
| Table | pgcache_store |
pg_cache |
pgcache_store |
| Default TTL | permanent | 1 hour | permanent |
| Allow null | false | true | true |
| Auto-create/migrate | true | true | true |
| Background cleanup | off | off | on, every 30 minutes |
| Single-flight concurrency | 4 | 4 | 4 |
These differences are retained for compatibility. Set the values explicitly when the same behavior across modules matters. Expired rows never count as hits; background cleanup only controls when their physical rows are deleted.
Security note on value_type. The recorded type decides which class is instantiated
on read, and Jackson may call that class's constructor and setters. PgCache does not
enable Jackson default typing; if a supplied framework/application mapper enables it,
that mapper's security policy applies. Anyone who can write to the cache table can choose
the recorded target class, so protect it with the same trust boundary as application data.
The cache is emptied once on upgrade. Entries written before 1.10.0 carry no type information, so no read path can deserialize them into the caller's type; they are deleted when the schema is initialized. Expect a cold cache on first start.
Schema changes are applied automatically (value_type, plus ttl_policy/last_accessed on tables older than 1.2.0). Requires PostgreSQL 9.6+. Rolling back to 1.9.2 is safe — the added columns are nullable and the JSONB format is unchanged.
New APIs are additive: getAsStored, getOrComputeAsStored, singleFlightConcurrency. NullValueMarker.isMarker is deprecated.
Older upgrades and manual migration SQL are documented in CHANGELOG.md.
MIT License - see LICENSE for details.