Redis version
7.4.10
Redisson version
4.6.1
Redisson configuration
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedissonConfig {
public RedissonClient create() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
}
What is the Expected behavior?
Even after calling release(), RedissonRateLimiter.availablePermits() should return the number of currently available permits, and this value must never exceed the configured rate.
What is the Actual behavior?
When release() is called, availablePermits() sometimes returns a value greater than the configured rate.
Additional information
availablePermits() increases currentValue by the number of expired permits stored in the sorted set and returns the result. release(), on the other hand, increments currentValue without updating the sorted set. Because of this, when availablePermits() is called in an environment that also uses release(), the already-expired permits get counted into currentValue a second time. On top of that, since availablePermits() never re-caps currentValue against the configured rate, the returned value can end up larger than the rate.
@Override
public RFuture<Long> availablePermitsAsync() {
return commandExecutor.evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
"local rate = redis.call('hget', KEYS[1], 'rate');"
+ "local interval = redis.call('hget', KEYS[1], 'interval');"
+ "local type = redis.call('hget', KEYS[1], 'type');"
+ "assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')"
+ "local valueName = KEYS[2];"
+ "local permitsName = KEYS[4];"
+ "if type == '1' then "
+ "valueName = KEYS[3];"
+ "permitsName = KEYS[5];"
+ "end;"
+ "local currentValue = redis.call('get', valueName); "
+ "if currentValue == false then "
+ "redis.call('set', valueName, rate); "
+ "return rate; "
+ "else "
+ "local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[1]) - interval); "
+ "local released = 0; "
+ "for i, v in ipairs(expiredValues) do "
+ "local random, permits = struct.unpack('Bc0I', v);"
+ "released = released + permits;"
+ "end; "
+ "if released > 0 then "
+ "redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[1]) - interval); "
// Double-counting: permits already returned by release() are added again here, and unlike release() this has no clamp to rate.
+ "currentValue = tonumber(currentValue) + released; "
+ "redis.call('set', valueName, currentValue);"
+ "end;"
+ "return currentValue; "
+ "end;",
Arrays.asList(getRawName(), getValueName(), getClientValueName(), getPermitsName(), getClientPermitsName()),
System.currentTimeMillis());
}
@Override
public RFuture<Void> releaseAsync(long permits) {
if (permits < 0) {
throw new IllegalArgumentException("Permits amount can't be negative");
}
if (permits == 0) {
return CompletableFutureWrapper.completedNull();
}
return commandExecutor.evalWriteAsync(getRawName(), StringCodec.INSTANCE, RedisCommands.EVAL_VOID,
"local rate = redis.call('hget', KEYS[1], 'rate');"
+ "local interval = redis.call('hget', KEYS[1], 'interval');"
+ "local type = redis.call('hget', KEYS[1], 'type');"
+ "assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized');"
+ "local valueName = KEYS[2];"
+ "local permitsName = KEYS[4];"
+ "if type == '1' then "
+ " valueName = KEYS[3];"
+ " permitsName = KEYS[5];"
+ "end;"
+ "local currentValue = redis.call('get', valueName);"
+ "if currentValue == false then "
+ " currentValue = tonumber(rate);"
+ "else "
+ " currentValue = tonumber(currentValue);"
+ "end;"
+ "local newValue = currentValue + tonumber(ARGV[1]);"
+ "if newValue > tonumber(rate) then "
+ " newValue = tonumber(rate);"
+ "end;"
+ "redis.call('set', valueName, newValue);"
// the acquired permit's entry in permitsName is not removed here, so availablePermits() later counts it a second time.
+ "local keepAliveTime = redis.call('hget', KEYS[1], 'keepAliveTime');"
+ "if (keepAliveTime ~= false and tonumber(keepAliveTime) > 0) then "
+ " redis.call('pexpire', KEYS[1], keepAliveTime);"
+ " redis.call('pexpire', valueName, keepAliveTime);"
+ " redis.call('pexpire', permitsName, keepAliveTime);"
+ "else "
+ " local ttl = redis.call('pttl', KEYS[1]);"
+ " if ttl > 0 then "
+ " redis.call('pexpire', valueName, ttl);"
+ " redis.call('pexpire', permitsName, ttl);"
+ " end;"
+ "end;",
Arrays.asList(getRawName(), getValueName(), getClientValueName(), getPermitsName(), getClientPermitsName()), permits);
}
I reproduced this through an actual experiment.
Experiment conditions
- rate = 10
- interval = 1000ms
Under these conditions, I called tryAcquire(1) and then ran two separate experiments depending on whether release(1) was called afterward. The first experiment calls release(1): it assumes a scenario where a permit is acquired, and 100ms later the code realizes the permit is no longer needed and calls release(1). The experiment ran for 30 seconds, and the graph below shows the number of available permits along with the permit-consumption history currently stored in the sorted set, queried each time release(1) was called.
And the graph below shows the result when release(1) is not called, letting the permits recover automatically via the interval instead.
Comparing the two experiments, when relying solely on the interval-based automatic recovery without release(1) (the second graph), availablePermits() never exceeds rate=10. However, when release(1) is used (the first graph), it returns 11–12, exceeding the rate.
Below is the code used for the experiment.
public void run() throws InterruptedException {
RateLimiterConfig config = new RateLimiterConfig();
RRateLimiter limiter = config.create();
System.out.println("init permits = " + limiter.availablePermits());
long startNano = System.nanoTime();
long endAt = System.nanoTime() + SimulationConfig.DURATION_SEC * 1_000_000_000L;
long count = 0;
List<long[]> samples = new ArrayList<>();
while (System.nanoTime() < endAt) {
limiter.tryAcquire(1);
Thread.sleep(SimulationConfig.RELEASE_DELAY_MS);
limiter.release(1);
long permits = limiter.availablePermits();
long tMs = (System.nanoTime() - startNano) / 1_000_000;
long history = config.historySize();
samples.add(new long[]{tMs, permits, history});
count++;
}
writeJson(samples);
}
The fundamental fix would be to remove the permit's acquisition record from the sorted set when release() is called, but this requires identifying which acquisition is being returned. Since the current API only takes the number of permits to release and doesn't identify the target, it would require an API change — returning an ID on acquisition and passing that ID back on release.
An approach that avoids changing the API would be to have release() remove the oldest acquisition records first, without identifying specific ones. However, this approach has a problem: the permits the user intended to release and the ones actually removed can diverge, delaying the recovery of available capacity. For example, with interval=1000ms and rate=100, suppose 49 permits (A) are acquired at 0ms and 51 permits (B) at 999ms, and then 51 are released immediately: oldest-first removal wipes out A first, leaving 49 of B's permits in the sorted set. Those 49 don't expire until 1999ms, so even though the permits were just released, capacity equal to the rate isn't restored for roughly 2 seconds.
Is this behavior — where availablePermits() returns a value greater than the rate — intended? I can't come up with a way to solve this. So I'd like to hear the maintainers' thoughts on this issue and their judgment on which direction would be the appropriate solution.
If you're curious about the code used for the experiment, please refer to here.
https://github.com/likerhythm/mylab/tree/master/p01-redisson-ratelimiter-permitoverflow/src/main/java/org/example/p01redissonratelimiterpermitoverflow/nonspring_test
Thank you for your time and for maintaining this project.
Redis version
7.4.10
Redisson version
4.6.1
Redisson configuration
What is the Expected behavior?
Even after calling
release(),RedissonRateLimiter.availablePermits()should return the number of currently available permits, and this value must never exceed the configured rate.What is the Actual behavior?
When
release()is called,availablePermits()sometimes returns a value greater than the configured rate.Additional information
availablePermits()increasescurrentValueby the number of expired permits stored in the sorted set and returns the result.release(), on the other hand, incrementscurrentValuewithout updating the sorted set. Because of this, whenavailablePermits()is called in an environment that also usesrelease(), the already-expired permits get counted intocurrentValuea second time. On top of that, sinceavailablePermits()never re-capscurrentValueagainst the configured rate, the returned value can end up larger than the rate.I reproduced this through an actual experiment.
Experiment conditions
Under these conditions, I called
tryAcquire(1)and then ran two separate experiments depending on whetherrelease(1)was called afterward. The first experiment callsrelease(1): it assumes a scenario where a permit is acquired, and 100ms later the code realizes the permit is no longer needed and callsrelease(1). The experiment ran for 30 seconds, and the graph below shows the number of available permits along with the permit-consumption history currently stored in the sorted set, queried each timerelease(1)was called.And the graph below shows the result when
release(1)is not called, letting the permits recover automatically via the interval instead.Comparing the two experiments, when relying solely on the interval-based automatic recovery without
release(1)(the second graph),availablePermits()never exceedsrate=10. However, whenrelease(1)is used (the first graph), it returns 11–12, exceeding therate.Below is the code used for the experiment.
The fundamental fix would be to remove the permit's acquisition record from the sorted set when
release()is called, but this requires identifying which acquisition is being returned. Since the current API only takes the number of permits to release and doesn't identify the target, it would require an API change — returning an ID on acquisition and passing that ID back on release.An approach that avoids changing the API would be to have
release()remove the oldest acquisition records first, without identifying specific ones. However, this approach has a problem: the permits the user intended to release and the ones actually removed can diverge, delaying the recovery of available capacity. For example, with interval=1000ms and rate=100, suppose 49 permits (A) are acquired at 0ms and 51 permits (B) at 999ms, and then 51 are released immediately: oldest-first removal wipes out A first, leaving 49 of B's permits in the sorted set. Those 49 don't expire until 1999ms, so even though the permits were just released, capacity equal to the rate isn't restored for roughly 2 seconds.Is this behavior — where
availablePermits()returns a value greater than the rate — intended? I can't come up with a way to solve this. So I'd like to hear the maintainers' thoughts on this issue and their judgment on which direction would be the appropriate solution.If you're curious about the code used for the experiment, please refer to here.
https://github.com/likerhythm/mylab/tree/master/p01-redisson-ratelimiter-permitoverflow/src/main/java/org/example/p01redissonratelimiterpermitoverflow/nonspring_test
Thank you for your time and for maintaining this project.