#7238·redisson

[Improvement] Enable Lua script caching for RLock on single-node (and no-replica) deployments

Author: shawnplayCreated Jul 2, 2026Updated Aug 21, 2026

Summary

RLock (and every subclass of RedissonBaseLock) always routes its Lua scripts through CommandBatchService, which unconditionally disables the script cache (isEvalCacheActive() returns false). As a result, even when useScriptCache(true) is configured, every lock/unlock/renew operation sends the full Lua script body to Redis instead of using EVALSHA.

On single-node deployments (and any deployment with no available replicas) the batch wrapper provides no functional benefit at all — the WAIT command that motivates the batch is never appended — yet it still forces full-script transmission and pays the extra allocation cost of building a throwaway CommandBatchService.

This issue proposes letting locks take the direct, script-cache-enabled path when there is nothing to synchronize.

Environment

  • Redisson version: 3.17.7 (verified in source; the relevant code is unchanged in later releases as of writing)
  • Deployment: single Redis node (useSingleServer())
  • Config: config.setUseScriptCache(true)

Current behavior

RedissonBaseLock overrides evalWriteAsync to always wrap the eval in a CommandBatchService:

java
// org/redisson/RedissonBaseLock.java
protected <T> RFuture<T> evalWriteAsync(String key, Codec codec, RedisCommand<T> evalCommandType,
                                        String script, List<Object> keys, Object... params) {
    MasterSlaveEntry entry = commandExecutor.getConnectionManager().getEntry(getRawName());
    int availableSlaves = (entry != null) ? entry.getAvailableSlaves() : 0;

    CommandBatchService executorService = createCommandBatchService(availableSlaves);
    RFuture<T> result = executorService.evalWriteAsync(key, codec, evalCommandType, script, keys, params);
    ...
}

private CommandBatchService createCommandBatchService(int availableSlaves) {
    if (commandExecutor instanceof CommandBatchService) {
        return (CommandBatchService) commandExecutor;
    }
    BatchOptions options = BatchOptions.defaults()
                                       .syncSlaves(availableSlaves, 1, TimeUnit.SECONDS);
    return new CommandBatchService(commandExecutor, options);
}

CommandBatchService disables the script cache regardless of configuration:

java
// org/redisson/command/CommandBatchService.java
@Override
protected boolean isEvalCacheActive() {
    return false;
}

whereas the direct path honors it:

java
// org/redisson/command/CommandAsyncService.java
protected boolean isEvalCacheActive() {
    return getConnectionManager().getCfg().isUseScriptCache();
}

So the EVALSHA branch in CommandAsyncService.evalAsync(...) is never reached for locks.

Why the batch is used at all

The batch exists so a WAIT command can be appended after the lock script and sent in the same pipeline, to confirm the lock write has been replicated to N replicas (guards against lost locks on master failover):

java
// org/redisson/command/CommandBatchService.java (executeAsync)
if (this.options.getSyncSlaves() > 0) {
    for (Entry entry : commands.values()) {
        BatchCommandData<?, ?> waitCommand = new BatchCommandData(RedisCommands.WAIT,
                new Object[] { getSyncSlaves(), getSyncTimeout() }, index.incrementAndGet());
        entry.getCommands().add(waitCommand);
    }
}

The problem on single-node / no-replica deployments

When there are no replicas, availableSlaves == 0, therefore syncSlaves == 0, therefore:

  • No WAIT command is ever appended — the batch carries a single EVAL and nothing else.
  • The default BatchOptions (IN_MEMORY, non-atomic) does not wrap a single command in MULTI/EXEC, so the on-the-wire bytes and the single round-trip are identical to what a direct CommandAsyncService eval would produce.

The only observable differences from the direct path are therefore pure downsides:

  1. The script cache is force-disabled, so every lock operation transmits the full script body instead of a EVALSHA digest.
  2. A throwaway CommandBatchService (with its command/connection maps) is allocated per lock call.

In other words, on single-node deployments the batch wrapper delivers zero functional value while defeating a feature the user explicitly enabled (useScriptCache(true)).

Impact

  • setUseScriptCache(true) is silently ineffective for all locking primitives (RLock, RFairLock, read/write locks, RSpinLock, and RMultiLock via its sub-locks).
  • Lock-heavy workloads pay repeated full-script transmission (client → server bandwidth) and per-call batch object allocation, both avoidable in the no-replica case.

Proposed improvement

When there is nothing to synchronize (availableSlaves == 0) and the current executor is not already a CommandBatchService, take the direct, script-cache-aware path instead of allocating a batch:

java
protected <T> RFuture<T> evalWriteAsync(String key, Codec codec, RedisCommand<T> evalCommandType,
                                        String script, List<Object> keys, Object... params) {
    MasterSlaveEntry entry = commandExecutor.getConnectionManager().getEntry(getRawName());
    int availableSlaves = (entry != null) ? entry.getAvailableSlaves() : 0;

    // Fast path: nothing to sync and not already inside a batch/transaction.
    // Use the direct executor so EVALSHA / useScriptCache applies.
    if (availableSlaves == 0 && !(commandExecutor instanceof CommandBatchService)) {
        return commandExecutor.evalWriteAsync(key, codec, evalCommandType, script, keys, params);
    }

    // Existing batch path (WAIT-based slave sync) unchanged.
    CommandBatchService executorService = createCommandBatchService(availableSlaves);
    ...
}

This preserves the WAIT/syncSlaves semantics whenever replicas exist and keeps the existing behavior inside batches/transactions, while restoring EVALSHA usage for the common single-node case.

Notes / discussion points

  • Correctness is unchanged in the fast path: with no replicas, no WAIT was ever emitted, and a single-command non-atomic batch is not wrapped in MULTI/EXEC, so the fast path is semantically identical to the current behavior — only the eval encoding (EVALSHA vs full EVAL) differs.
  • An alternative (less invasive) approach: have CommandBatchService.isEvalCacheActive() return the configured value when the batch contains a single command and no WAIT is planned. This is trickier because the decision happens at enqueue time, before executeAsync appends WAIT; the executor-level fast path above is cleaner.
  • The real-world gain is modest (round-trip count is unchanged; it saves the script body bytes and one batch allocation per lock op), but it removes a case where an explicitly enabled configuration option (useScriptCache) is silently ignored, which is surprising to users.

Reproduction

java
Config config = new Config();
config.setUseScriptCache(true);
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);

RLock lock = redisson.getLock("demo");
lock.lock();
lock.unlock();
// Observe (e.g. via MONITOR): the lock script is sent as a full EVAL every time,
// never as EVALSHA, despite useScriptCache(true).