[Improvement] Enable Lua script caching for RLock on single-node (and no-replica) deployments
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:
// 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:
// org/redisson/command/CommandBatchService.java
@Override
protected boolean isEvalCacheActive() {
return false;
}whereas the direct path honors it:
// 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):
// 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
WAITcommand is ever appended — the batch carries a singleEVALand nothing else. - The default
BatchOptions(IN_MEMORY, non-atomic) does not wrap a single command inMULTI/EXEC, so the on-the-wire bytes and the single round-trip are identical to what a directCommandAsyncServiceeval would produce.
The only observable differences from the direct path are therefore pure downsides:
- The script cache is force-disabled, so every lock operation transmits the full script
body instead of a
EVALSHAdigest. - 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, andRMultiLockvia 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:
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
WAITwas ever emitted, and a single-command non-atomic batch is not wrapped inMULTI/EXEC, so the fast path is semantically identical to the current behavior — only the eval encoding (EVALSHAvs fullEVAL) differs. - An alternative (less invasive) approach: have
CommandBatchService.isEvalCacheActive()return the configured value when the batch contains a single command and noWAITis planned. This is trickier because the decision happens at enqueue time, beforeexecuteAsyncappendsWAIT; 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
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).Source: redisson/redisson