Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions content/develop/use-cases/cache-aside/java-jedis/DemoServer.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.RedisClient;

import java.io.IOException;
import java.io.InputStream;
Expand All @@ -27,15 +26,15 @@
* <p>Usage:</p>
*
* <pre>{@code
* javac -cp jedis-5.2.0.jar RedisCache.java MockPrimaryStore.java DemoServer.java
* java -cp .:jedis-5.2.0.jar DemoServer --port 8080 --redis-host localhost --redis-port 6379
* javac -cp jedis-8.0.2.jar:commons-pool2-2.13.1.jar:slf4j-api-1.7.36.jar:gson-2.14.0.jar:json-20260719.jar:redis-authx-core-0.1.1-beta2.jar RedisCache.java MockPrimaryStore.java DemoServer.java
* java -cp .:jedis-8.0.2.jar:commons-pool2-2.13.1.jar:slf4j-api-1.7.36.jar:gson-2.14.0.jar:json-20260719.jar:redis-authx-core-0.1.1-beta2.jar DemoServer --port 8080 --redis-host localhost --redis-port 6379
* }</pre>
*/
public class DemoServer {

private static RedisCache cache;
private static MockPrimaryStore primary;
private static JedisPool jedisPool;
private static RedisClient jedis;

public static void main(String[] args) {
int port = 8080;
Expand Down Expand Up @@ -67,14 +66,14 @@ public static void main(String[] args) {
}

try {
jedisPool = new JedisPool(new JedisPoolConfig(), redisHost, redisPort);
jedisPool.getResource().close();
jedis = RedisClient.create(redisHost, redisPort);
jedis.ping();
} catch (Exception e) {
System.err.printf("Failed to connect to Redis at %s:%d: %s%n", redisHost, redisPort, e.getMessage());
System.exit(1);
}

cache = new RedisCache(jedisPool, "cache:product:", ttl, 2000, 25);
cache = new RedisCache(jedis, "cache:product:", ttl, 2000, 25);
primary = new MockPrimaryStore(primaryLatencyMs);

try {
Expand Down
90 changes: 42 additions & 48 deletions content/develop/use-cases/cache-aside/java-jedis/RedisCache.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.Transaction;
import redis.clients.jedis.AbstractTransaction;
import redis.clients.jedis.RedisClient;
import redis.clients.jedis.exceptions.JedisException;

/**
Expand All @@ -31,7 +30,7 @@ public class RedisCache {

private static final SecureRandom RANDOM = new SecureRandom();

private final JedisPool pool;
private final RedisClient jedis;
private final String prefix;
private final int ttl;
private final int lockTtlMs;
Expand All @@ -41,18 +40,18 @@ public class RedisCache {
private final AtomicLong misses = new AtomicLong();
private final AtomicLong stampedesSuppressed = new AtomicLong();

public RedisCache(JedisPool pool) {
this(pool, "cache:product:", 30, 2000, 25);
public RedisCache(RedisClient jedis) {
this(jedis, "cache:product:", 30, 2000, 25);
}

public RedisCache(JedisPool pool, String prefix, int ttl, int lockTtlMs, int waitPollMs) {
if (pool == null) {
throw new IllegalArgumentException("pool is required");
public RedisCache(RedisClient jedis, String prefix, int ttl, int lockTtlMs, int waitPollMs) {
if (jedis == null) {
throw new IllegalArgumentException("jedis is required");
}
if (ttl < 1) {
throw new IllegalArgumentException("ttl must be at least 1 second");
}
this.pool = pool;
this.jedis = jedis;
this.prefix = (prefix == null || prefix.isEmpty()) ? "cache:product:" : prefix;
this.ttl = ttl;
this.lockTtlMs = lockTtlMs;
Expand Down Expand Up @@ -82,9 +81,7 @@ public Result get(String entityId, Function<String, Map<String, String>> loader)
Map<String, String> cached;
long startedNs = System.nanoTime();

try (Jedis jedis = pool.getResource()) {
cached = jedis.hgetAll(cacheKey);
}
cached = jedis.hgetAll(cacheKey);
double redisLatencyMs = (System.nanoTime() - startedNs) / 1_000_000.0;

if (cached != null && !cached.isEmpty()) {
Expand All @@ -99,9 +96,7 @@ public Result get(String entityId, Function<String, Map<String, String>> loader)

/** Delete the cached entry for entityId. */
public boolean invalidate(String entityId) {
try (Jedis jedis = pool.getResource()) {
return jedis.del(cacheKey(entityId)) == 1L;
}
return jedis.del(cacheKey(entityId)) == 1L;
}

/**
Expand All @@ -113,13 +108,17 @@ public boolean invalidate(String entityId) {
public boolean updateField(String entityId, String field, String value) {
String cacheKey = cacheKey(entityId);
while (true) {
try (Jedis jedis = pool.getResource()) {
jedis.watch(cacheKey);
if (!jedis.exists(cacheKey)) {
jedis.unwatch();
// WATCH is tied to one connection, so open a transaction that
// holds it until EXEC instead of calling watch() on the client.
// Read through the same transaction: borrowing a second pooled
// connection while holding this one can deadlock a small pool.
try (AbstractTransaction tx = jedis.transaction(false)) {
tx.watch(cacheKey);
if (!tx.exists(cacheKey).get()) {
tx.unwatch();
return false;
}
Transaction tx = jedis.multi();
tx.multi();
tx.hset(cacheKey, field, value);
tx.expire(cacheKey, ttl);
List<Object> result = tx.exec();
Expand All @@ -133,9 +132,7 @@ public boolean updateField(String entityId, String field, String value) {

/** Remaining TTL in seconds, or -2/-1 per Redis semantics. */
public long ttlRemaining(String entityId) {
try (Jedis jedis = pool.getResource()) {
return jedis.ttl(cacheKey(entityId));
}
return jedis.ttl(cacheKey(entityId));
}

/** Hit/miss/stampede counters with the derived hit rate. */
Expand Down Expand Up @@ -166,29 +163,28 @@ private Map<String, String> loadWithSingleFlight(
String lockKey = lockKey(entityId);
String token = randomToken();

try (Jedis jedis = pool.getResource()) {
Object acquired = jedis.eval(
ACQUIRE_LOCK_SCRIPT,
List.of(lockKey),
List.of(token, Integer.toString(lockTtlMs)));
if (asInt(acquired) == 1) {
try {
Map<String, String> record = loader.apply(entityId);
if (record == null) {
return null;
}
Transaction tx = jedis.multi();
Object acquired = jedis.eval(
ACQUIRE_LOCK_SCRIPT,
List.of(lockKey),
List.of(token, Integer.toString(lockTtlMs)));
if (asInt(acquired) == 1) {
try {
Map<String, String> record = loader.apply(entityId);
if (record == null) {
return null;
}
try (AbstractTransaction tx = jedis.multi()) {
tx.del(cacheKey);
tx.hset(cacheKey, record);
tx.expire(cacheKey, ttl);
tx.exec();
return record;
} finally {
try {
jedis.eval(RELEASE_LOCK_SCRIPT, List.of(lockKey), List.of(token));
} catch (JedisException ignored) {
// Lock will expire on its own.
}
}
return record;
} finally {
try {
jedis.eval(RELEASE_LOCK_SCRIPT, List.of(lockKey), List.of(token));
} catch (JedisException ignored) {
// Lock will expire on its own.
}
}
}
Expand All @@ -202,11 +198,9 @@ private Map<String, String> loadWithSingleFlight(
Thread.currentThread().interrupt();
return null;
}
try (Jedis jedis = pool.getResource()) {
Map<String, String> cached = jedis.hgetAll(cacheKey);
if (cached != null && !cached.isEmpty()) {
return cached;
}
Map<String, String> cached = jedis.hgetAll(cacheKey);
if (cached != null && !cached.isEmpty()) {
return cached;
}
}
return loader.apply(entityId);
Expand Down
66 changes: 30 additions & 36 deletions content/develop/use-cases/cache-aside/java-jedis/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,11 @@ The `RedisCache` class wraps the cache-aside operations
([source](RedisCache.java)):

```java
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.RedisClient;

JedisPool pool = new JedisPool(new JedisPoolConfig(), "localhost", 6379);
RedisClient jedis = RedisClient.create("localhost", 6379);
MockPrimaryStore primary = new MockPrimaryStore(150);
RedisCache cache = new RedisCache(pool);
RedisCache cache = new RedisCache(jedis);

// Read through the cache.
RedisCache.Result result = cache.get("p-001", primary::read);
Expand Down Expand Up @@ -101,9 +100,7 @@ public Result get(String entityId, Function<String, Map<String, String>> loader)
Map<String, String> cached;
long startedNs = System.nanoTime();

try (Jedis jedis = pool.getResource()) {
cached = jedis.hgetAll(cacheKey);
}
cached = jedis.hgetAll(cacheKey);
double redisLatencyMs = (System.nanoTime() - startedNs) / 1_000_000.0;

if (cached != null && !cached.isEmpty()) {
Expand Down Expand Up @@ -152,35 +149,32 @@ private Map<String, String> loadWithSingleFlight(
String lockKey = lockKey(entityId);
String token = randomToken();

try (Jedis jedis = pool.getResource()) {
Object acquired = jedis.eval(
ACQUIRE_LOCK_SCRIPT,
List.of(lockKey),
List.of(token, Integer.toString(lockTtlMs)));
if (asInt(acquired) == 1) {
try {
Map<String, String> record = loader.apply(entityId);
if (record == null) return null;
Transaction tx = jedis.multi();
Object acquired = jedis.eval(
ACQUIRE_LOCK_SCRIPT,
List.of(lockKey),
List.of(token, Integer.toString(lockTtlMs)));
if (asInt(acquired) == 1) {
try {
Map<String, String> record = loader.apply(entityId);
if (record == null) return null;
try (AbstractTransaction tx = jedis.multi()) {
tx.del(cacheKey);
tx.hset(cacheKey, record);
tx.expire(cacheKey, ttl);
tx.exec();
return record;
} finally {
jedis.eval(RELEASE_LOCK_SCRIPT, List.of(lockKey), List.of(token));
}
return record;
} finally {
jedis.eval(RELEASE_LOCK_SCRIPT, List.of(lockKey), List.of(token));
}
}

stampedesSuppressed.incrementAndGet();
long deadline = System.nanoTime() + (long) lockTtlMs * 1_000_000L;
while (System.nanoTime() < deadline) {
Thread.sleep(waitPollMs);
try (Jedis jedis = pool.getResource()) {
Map<String, String> cached = jedis.hgetAll(cacheKey);
if (cached != null && !cached.isEmpty()) return cached;
}
Map<String, String> cached = jedis.hgetAll(cacheKey);
if (cached != null && !cached.isEmpty()) return cached;
}
return loader.apply(entityId);
}
Expand All @@ -194,9 +188,7 @@ When a write hits the primary, the application invalidates the cache key. The ne

```java
public boolean invalidate(String entityId) {
try (Jedis jedis = pool.getResource()) {
return jedis.del(cacheKey(entityId)) == 1L;
}
return jedis.del(cacheKey(entityId)) == 1L;
}
```

Expand All @@ -210,13 +202,15 @@ Because each record is stored as a hash, the cache helper can also update a sing
public boolean updateField(String entityId, String field, String value) {
String cacheKey = cacheKey(entityId);
while (true) {
try (Jedis jedis = pool.getResource()) {
jedis.watch(cacheKey);
if (!jedis.exists(cacheKey)) {
jedis.unwatch();
// WATCH is tied to one connection, so open a transaction that
// holds it until EXEC.
try (AbstractTransaction tx = jedis.transaction(false)) {
tx.watch(cacheKey);
if (!tx.exists(cacheKey).get()) {
tx.unwatch();
return false;
}
Transaction tx = jedis.multi();
tx.multi();
tx.hset(cacheKey, field, value);
tx.expire(cacheKey, ttl);
List<Object> result = tx.exec();
Expand Down Expand Up @@ -268,10 +262,10 @@ A local demo server is included to show the cache-aside layer in action
([source](DemoServer.java)):

```bash
javac -cp jedis-5.0.0.jar:commons-pool2-2.12.1.jar:slf4j-api-1.7.36.jar:gson-2.9.1.jar:json-20230618.jar \
javac -cp jedis-8.0.2.jar:commons-pool2-2.13.1.jar:slf4j-api-1.7.36.jar:gson-2.14.0.jar:json-20260719.jar:redis-authx-core-0.1.1-beta2.jar \
RedisCache.java MockPrimaryStore.java DemoServer.java

java -cp .:jedis-5.0.0.jar:commons-pool2-2.12.1.jar:slf4j-api-1.7.36.jar:gson-2.9.1.jar:json-20230618.jar \
java -cp .:jedis-8.0.2.jar:commons-pool2-2.13.1.jar:slf4j-api-1.7.36.jar:gson-2.14.0.jar:json-20260719.jar:redis-authx-core-0.1.1-beta2.jar \
DemoServer --port 8080 --redis-host localhost --redis-port 6379
```

Expand Down Expand Up @@ -333,9 +327,9 @@ In this demo, a missing record returns `null` and nothing is cached. In a real s

The lock TTL needs to be longer than the worst-case primary read latency so a slow loader does not lose the lock midway. The unique token in `RELEASE_LOCK_SCRIPT` ensures the original caller does not delete someone else's lock if its lock has expired.

### Tune the JedisPool
### Tune the connection pool

The demo uses a default `JedisPoolConfig`. In production, set `setMaxTotal()` and `setMaxIdle()` to match your concurrency profile and server-side `maxclients`. Wrap pool acquisition in try-with-resources so connections are returned even on exceptions.
The demo uses the default jedis settings of `RedisClient.create()`. In production, build the client with `RedisClient.builder()` and a `ConnectionPoolConfig`, and set `setMaxTotal()` and `setMaxIdle()` to match your concurrency profile and server-side `maxclients`. Wrap transactions and pipelines in try-with-resources so their connections return to the jedis even on exceptions. An open transaction or pipeline holds a pooled connection, so send any reads it needs through the transaction or pipeline itself, not through the client.

### Namespace cache keys in shared Redis deployments

Expand Down
Loading
Loading