Bucket4j: Backends
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
A distributed Bucket4j bucket needs a store, and Bucket4j 8.21.0 ships a module for almost every store a Java service is
likely to have: Redis through five clients, six relational databases, nine data grids and caches, MongoDB, Couchbase, Memcached
and Cassandra. All of them produce the same ProxyManager<K> described on
the distributed page; they differ in artifact, builder, how
atomicity is achieved, whether async and expiry work, and what you must do yourself (create a table, put jars on grid nodes,
register a serializer).
It was written against Bucket4j 8.21.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21 and verified on 4 October 2026.
The Redis and PostgreSQL examples, a single-node Redis Cluster, the JCache provider of Caffeine and the Caffeine module were
run against 8.21.0 (Testcontainers for Redis 7 and PostgreSQL 17). The other backends are described from the 8.21.0 sources
and the reference and were not run, because each needs its own server; their builder signatures were read from the 8.21.0
tag rather than copied from the reference. Add the bucket4j_jdk17-bom import so the
module versions stay aligned (see the overview).
The backend table
Every artifact id below is bucket4j_jdk17-<name>, version 8.21.0. "Async" is the value of isAsyncModeSupported() and
"Expiry" the value of isExpireAfterWriteSupported(), read from the 8.21.0 sources; where a backend has several builders the
values differ per builder.
| Backend | Artifact | Builder entry point | Atomicity | Async | Expiry | Reference |
|---|---|---|---|---|---|---|
Redis, Lettuce |
|
|
CAS (Lua |
yes |
yes |
|
Redis, Redisson |
|
|
CAS |
yes |
yes |
|
Redis, Jedis |
|
|
CAS |
no |
yes |
|
Redis, Vert.x |
|
|
CAS |
yes |
yes |
|
Redis or Valkey, GLIDE |
|
|
CAS |
yes |
yes |
|
PostgreSQL |
|
|
row lock, advisory lock |
no |
yes (manual cleanup) |
|
MySQL, MariaDB |
|
|
row lock |
no |
yes (manual cleanup) |
|
Oracle, SQL Server, DB2 |
|
|
row lock |
no |
yes (manual cleanup) |
|
JCache (JSR 107) |
|
|
entry processor |
no |
no |
|
Hazelcast |
|
|
entry processor, lock, CAS |
yes, no, no |
yes, yes, no |
|
Apache Ignite 2 |
|
|
entry processor, server job, CAS |
yes |
no |
|
Apache Ignite 3 |
|
|
server job in a |
yes |
no |
|
Infinispan |
|
|
entry processor; server task |
yes; no |
yes; yes |
|
Oracle Coherence |
|
|
entry processor |
yes |
yes |
|
Apache Geode |
|
|
CAS with a cache transaction; function |
no |
no |
|
MongoDB |
|
|
CAS |
async module only |
yes |
|
Couchbase |
|
|
CAS (document CAS) |
yes |
yes |
|
Memcached |
|
|
CAS ( |
yes |
yes |
|
Apache Cassandra |
|
|
CAS (lightweight transactions) |
yes |
yes |
|
Caffeine (in-process) |
|
|
Per-key atomic |
yes |
yes |
none (README only) |
Four facts about the artifacts. The Redis modules always come in pairs: bucket4j_jdk17-redis-common plus one client module;
the aggregator bucket4j_jdk17-redis stopped at 8.14.0 and is not a dependency to use. The bucket4j_jdk17-bom manages every
module in the table except infinispan-15, infinispan-all, hazelcast-all and the legacy redis aggregator, so declare an
explicit version for infinispan-15. Modules that arrived recently: Vert.x in 8.17.0, Couchbase in 8.18.0, Geode in 8.20.0,
and Cassandra, Memcached, Ignite 3, Infinispan 15, Coherence 21 and the BOM in 8.21.0 (first versions on Maven Central). And
several older constructors are deprecated in favour of these builders (for example those of CaffeineProxyManager and
InfinispanProxyManager).
Which atomicity model applies determines what you must configure. Entry-processor and server-job backends execute Bucket4j code
on the grid members, so the jars (and, for Hazelcast, a serializer; for Infinispan, the ProtoStream context initializer) must be
there. Compare-and-swap backends run entirely in the client and need nothing on the server, at the price of retries on hot
keys. Row-lock backends need a table. Caffeine is none of these: it runs each command inside cache.asMap().compute(key, …),
which is atomic per key within the JVM, so there is no read-compare-swap, no conflict and no retry, and maxRetries and
retryStrategy have no effect on it.
Redis
Redis is the default choice when the service already has one. Add bucket4j_jdk17-redis-common and exactly one client module.
Concurrent access is handled by compare-and-swap: the client reads the state, computes the new one and replaces it with a Lua
script run by EVAL that checks the stored value is unchanged (details and a sequence diagram on
the distributed page). It is a conditional write, not a Lua
implementation of the token bucket, so a hot key loses races and retries.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-bom</artifactId>
<version>8.21.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-redis-common</artifactId>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-lettuce</artifactId>
</dependency>
</dependencies>
Source: Bucket4j reference: Redis and Lettuce.
Building the manager
Bucket4jLettuce.casBasedBuilder has an overload for a RedisClient or a RedisClusterClient, and others for connections and
async command objects. The RedisClient and RedisClusterClient overloads return ProxyManager<byte[]>: the key is raw bytes,
because Redis keys are binary. Applications work with strings, so withMapper converts. Spring Boot does not create a
RedisClient bean (it builds a LettuceConnectionFactory), so define one, reading the address from spring.data.redis.* (see
configuration and profiles and
Spring Boot Redis as a cache).
RedisClient redisClient = RedisClient.create(RedisURI.create(host, port));
ProxyManager<String> proxyManager = Bucket4jLettuce.casBasedBuilder(redisClient)
.expirationAfterWrite(ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(5)))
.build()
.withMapper(key -> key.getBytes(StandardCharsets.UTF_8));
BucketProxy bucket = proxyManager.getProxy("api:alice", () -> freePlan);
Run against a Redis 7 container, a shared quota of 10 consumed from two such managers admitted exactly 10 calls. Redis
expiry works through the TTL written with every state (psetex), so expirationAfterWrite needs no cleanup job. The reference
shows a ClientSideConfig variant with withClientSideConfig; use the builder methods instead.
Redis Cluster and the other clients
For Redis Cluster pass a RedisClusterClient; the same chain works and the key is mapped the same way. Against a single-node
cluster in a container the builder connected and enforced a 3-token limit. Jedis accepts a JedisCluster, Redisson a
CommandAsyncExecutor of a cluster configuration, Vert.x and GLIDE their cluster-capable clients. All five Redis modules
support cluster. Choose the client the application already uses, so the service keeps one connection pool and one set of
timeouts; Jedis is the only one without an asynchronous API in Bucket4j.
RedisClusterClient clusterClient = RedisClusterClient.create("redis://redis-cluster:7000");
ProxyManager<String> proxyManager = Bucket4jLettuce.casBasedBuilder(clusterClient)
.build()
.withMapper(key -> key.getBytes(StandardCharsets.UTF_8));
Source: Bucket4j reference: Lettuce; Bucket4jLettuce.java at 8.21.0 for the
overloads. The replication, Sentinel and Cluster behaviour of Redis itself is in
replication, Sentinel and Cluster; with replicas and asynchronous
replication, a failover can lose the most recent state, which RECONSTRUCT turns into a full bucket.
JDBC
The six JDBC modules share one design: the bucket state is a binary column in a table that you create, one SELECT … FOR
UPDATE (or an advisory lock) in a transaction makes the update atomic, and there is no asynchronous mode, so
asAsync() throws UnsupportedOperationException. They are the right backend when a database is the only shared
infrastructure, at the price of one transaction per consumption and of cleanup being your job.
| Database | DDL |
|---|---|
PostgreSQL |
|
MySQL, MariaDB |
|
Oracle |
|
SQL Server |
|
DB2 |
|
When an expiration strategy is configured the manager also writes an expires_at column (a BIGINT, or NUMBER on Oracle),
so the table needs it: … state BYTEA, expires_at BIGINT. For the PostgreSQL advisory-lock manager with expiry the
reference adds a fourth column, explicit_lock BIGINT. Source:
Bucket4j reference: JDBC integrations and the page of each database.
Flyway migration
Bucket4j never creates the table. With Spring Boot the natural place is a Flyway migration (Boot 4 uses spring-boot-starter-flyway
plus the database module, for example flyway-database-postgresql).
CREATE TABLE IF NOT EXISTS bucket (
id BIGINT PRIMARY KEY,
state BYTEA,
expires_at BIGINT
);
Source: Bucket4j reference: PostgreSQL.
Building the manager, names and key types
@Bean // the concrete type also exposes removeExpired(...) for the cleanup job below
PostgreSQLSelectForUpdateBasedProxyManager<Long> postgresProxyManager(DataSource dataSource) {
return Bucket4jPostgreSQL.selectForUpdateBasedBuilder(dataSource)
.expirationAfterWrite(ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
.build();
}
The defaults are table bucket, columns id, state and expires_at, and a Long key. The builder methods table(…),
idColumn(…), stateColumn(…) and expiresAtColumn(…) rename them, addCustomColumn(…) adds a computed column, and
primaryKeyMapper(…) changes the key type through PrimaryKeyMapper (LONG, INT, STRING, or your own implementation).
Changing the key type changes the builder’s type parameter, so the chain must keep the returned builder.
// CREATE TABLE IF NOT EXISTS user_buckets(user_id VARCHAR PRIMARY KEY, state_bytes BYTEA);
ProxyManager<String> proxyManager = Bucket4jPostgreSQL.selectForUpdateBasedBuilder(dataSource)
.table("user_buckets")
.idColumn("user_id")
.stateColumn("state_bytes")
.primaryKeyMapper(PrimaryKeyMapper.STRING)
.build();
BucketProxy bucket = proxyManager.getProxy("alice@example.org", () -> threeADay);
An advisory-lock variant (advisoryLockBasedBuilder(dataSource), backed by pg_advisory_xact_lock, which locks an
application-defined 64-bit key for the transaction) is available when locking the row is not wanted. Both PostgreSQL managers
were run: three managers on one data source shared a quota of 1000 across 12 worker threads trying 100 times each,
and exactly 1000 calls were admitted.
Source: Bucket4j reference: PostgreSQL; Bucket4jPostgreSQL.java and
PrimaryKeyMapper.java at 8.21.0.
Cleaning up expired rows
A database has no TTL. When an expiration strategy is set the manager fills expires_at but never deletes anything, so a
scheduled job must. The select-for-update managers implement ExpiredEntriesCleaner: removeExpired(int batchSize) deletes up
to that many expired rows in one transaction and returns the count. removeExpired is not on the ProxyManager interface, so the
job needs the bean typed as PostgreSQLSelectForUpdateBasedProxyManager<Long> (or ExpiredEntriesCleaner), as declared above.
private static final int MAX_TO_REMOVE_IN_ONE_TRANSACTION = 1_000;
private static final int THRESHOLD_TO_CONTINUE_REMOVING = 50;
@Scheduled(cron = "0 30 4 * * *") // needs @EnableScheduling
void removeExpiredBuckets() {
int removed;
do {
removed = postgresProxyManager.removeExpired(MAX_TO_REMOVE_IN_ONE_TRANSACTION);
} while (removed > THRESHOLD_TO_CONTINUE_REMOVING);
}
With a TTL of 200 ms and five buckets created, removeExpired(1000) returned 5 after the TTL had passed. In a deployment with
several replicas, run the job on one of them (a scheduler lock) or tolerate concurrent cleaners, which only contend on the
deletes. Source: Bucket4j reference: JDBC integrations and
ExpiredEntriesCleaner.java at 8.21.0.
Data grids and caches
JCache (bucket4j_jdk17-jcache) works with any provider that implements entry processors by the JSR 107 rules. The
reference is explicit that checking this is your job: many implementations are not certified, and one that loses concurrent
writes silently breaks the limit; it ships a compatibility test to run against your provider. The manager is
Bucket4jJCache.entryProcessorBasedBuilder(Cache<K, byte[]>), async is not supported (JCache has no async API) and neither
is Bucket4j expiry (configure it on the cache). Run against the JCache provider of Caffeine, a bucket of 3 admitted 3 and
refused the fourth. The Caffeine JCache provider is a local cache, so this proves the API, not distribution.
Cache<String, byte[]> cache = cacheManager.createCache("buckets",
new MutableConfiguration<String, byte[]>().setTypes(String.class, byte[].class));
ProxyManager<String> proxyManager = Bucket4jJCache.entryProcessorBasedBuilder(cache).build();
Hazelcast (hazelcast, or hazelcast-4 for the 4.x line) has three builders: entry processor (async, expiry),
lockBasedBuilder (expiry, no async) and casBasedBuilder (neither). Bucket4j classes are serialized with Java
serialization unless you register its custom serializers (HazelcastProxyManager.addCustomSerializers, or for a standalone
cluster the Bucket4j jars on every node plus the bucket4j.hazelcast.serializer.type_id_base setting).
Apache Ignite 2 (ignite) exists because JCache has no async API. Bucket4jIgnite.thickClient() and thinClient()
.clientComputeBasedBuilder(…) need the Bucket4j jars on every cluster node; thinClient().casBasedBuilder(…) does not,
but has higher latency. Expiry is configured on the cache, not through Bucket4j. Ignite 3 (ignite3) is a separate
module for an incompatible product: it needs a table with specific upper-case columns that you create, runs each command as a
compute job colocated with the key inside a SERIALIZABLE transaction, and batches concurrent requests for the same key so a
hot key does not turn into a storm of rollbacks.
Infinispan (infinispan, or infinispan-15): entryProcessorBasedBuilder(ReadWriteMap) for embedded caches and
hotrodClientBasedBuilder(RemoteCache) for a remote server. The Hot Rod builder returns HotrodInfinispanProxyManager<K>, which
the reference types as InfinispanProxyManager<K>. Since Infinispan 10 the default ProtoStream marshaller needs the Bucket4j
context initializer registered (Bucket4jProtobufContextInitializer). Oracle Coherence (coherence, coherence-21) uses
entryProcessorBasedBuilder(NamedCache<K, byte[]>) and offers POF serialization for the Bucket4j classes.
Apache Geode (geode) is compare-and-swap, not entry-processor based, because a Geode function does not serialize
concurrent invocations for one key; the client guards its write with a cache transaction and retries on a commit conflict.
functionBasedBuilder(Region) moves the loop onto the member that owns the key to save round trips under contention. Neither
supports async or Bucket4j expiry.
MongoDB, Couchbase, Memcached, Cassandra
These four are compare-and-swap managers built on the store’s own conditional write, and all four support expiry through the store’s TTL.
-
MongoDB: two modules sharing
bucket4j_jdk17-mongo-common,mongodb-syncfor the synchronous driver (no async) andmongodb-asyncfor the reactive streams driver (async). The builders take aMongoCollection<Document>and keyStringby default, with an overload taking aMapper<K>for other key types. -
Couchbase:
compareAndSwapBasedBuilderover aCollectionor anAsyncCollection, using the document CAS value; the state is binary document content and expiry is the document TTL. -
Memcached: through the Folsom client; Memcached cannot run scripts, so Bucket4j uses
getsandcas. Its API isCompletionStagebased, so async works. -
Cassandra: through the 4.x driver, using lightweight transactions. The reference warns they are expensive: each successful conditional write needs four round trips between Paxos participants, and contention on one partition key makes it worse. A reasonable choice for a team that already runs Cassandra, not a replacement for Redis on a hot key; combine with request optimizations.
Vert.x and GLIDE
Both are Redis clients, covered by the Redis section: Bucket4jVertx.casBasedBuilder(Redis) for Vert.x applications
and Bucket4jGlide.casBasedBuilder(BaseClient) for Valkey GLIDE, with ProxyManager<byte[]> keys, async and expiry.
Caffeine
bucket4j_jdk17-caffeine has no section in the 8.21.0 reference (it is described only in the README). It keeps the "remote"
state in a Caffeine cache inside the JVM, which makes it useful for tests and for single-instance services that want the
ProxyManager API (including expiry and async) without a store. It is not distributed. Use Bucket4jCaffeine.builderFor(…);
the CaffeineProxyManager constructors, which the Spring Cloud Gateway documentation still shows, are deprecated.
ProxyManager<String> proxyManager = Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder().maximumSize(10_000))
.expirationAfterWrite(ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(1)))
.build();
Against it isAsyncModeSupported() and isExpireAfterWriteSupported() are both true. Source: Bucket4jCaffeine.java at 8.21.0.
DynamoDB
There is no DynamoDB module in the 8.21.0 repository and no section in the reference. Maven Central holds
com.bucket4j:bucket4j-dynamodb-sdk-v1 and bucket4j_jdk8-dynamodb-sdk-v1, both ending at 8.6.0 (October 2023) and built for
version 1 of the AWS SDK, with no jdk17 build. Treat them as legacy: they are not in the BOM and are not maintained
alongside the current releases. On AWS use ElastiCache for Redis (the Redis section) or an RDS database (the JDBC section).
Choosing a backend
Start from what the service already runs, because a new stateful dependency for rate limiting is the most expensive option.
| Situation | Choice |
|---|---|
A Redis or Valkey is already in the stack |
Redis through the client the app uses; Lettuce is the Spring Boot default. Needs no table and no job; expiry is automatic. |
Only a relational database is shared |
PostgreSQL, MySQL, MariaDB, Oracle, SQL Server or DB2. Create the table with Flyway, schedule |
A data grid already holds state (Hazelcast, Infinispan, Coherence, Ignite) |
The grid’s entry-processor builder; mind the classpath and serialization of the members |
Reactive stack |
A backend with async support: Lettuce, Redisson, Vert.x, GLIDE, MongoDB async, Couchbase, Memcached, Cassandra, Ignite, Infinispan entry processor, Coherence, Hazelcast entry processor |
One instance, or tests |
Caffeine; the same API, nothing to run |
A single hot key under heavy load |
An entry-processor or server-job backend (one round trip) or the batching optimization; compare-and-swap loses races |
Lowest latency to the limit check |
Server-side backends and Redis in the same availability zone; a database over a network costs a transaction |
Durability matters less than for most data: a lost bucket under RECONSTRUCT becomes a full bucket, which for most limits is
an acceptable failure; for limits where it is not, choose a store with replication and THROW_BUCKET_NOT_FOUND_EXCEPTION.
Whatever the choice, plan the failure mode (fail open or closed) as described in
the production checklist, and test the backend with Testcontainers
as in testing Bucket4j.
Testing Redis and PostgreSQL with Testcontainers
The examples above were run with Testcontainers 2.x. Two managers built on the same container stand in for two replicas.
@Container
static final GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@Test
void redisSharedQuotaAcrossTwoInstances() {
RedisClient client = RedisClient.create(RedisURI.create(redis.getHost(), redis.getMappedPort(6379)));
ProxyManager<byte[]> a = Bucket4jLettuce.casBasedBuilder(client).build();
ProxyManager<byte[]> b = Bucket4jLettuce.casBasedBuilder(client).build();
byte[] key = "quota:isbn".getBytes(StandardCharsets.UTF_8);
long consumed = 0;
for (int i = 0; i < 6; i++) {
consumed += a.builder().build(key, () -> quota10).tryConsume(1) ? 1 : 0;
consumed += b.builder().build(key, () -> quota10).tryConsume(1) ? 1 : 0;
}
assertThat(consumed).isEqualTo(10); // 12 attempts, 10 admitted
client.shutdown();
}
@Container
static final PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");
@Test
void postgresSharedQuotaAcrossTwoInstances() throws Exception {
PGSimpleDataSource ds = new PGSimpleDataSource();
ds.setUrl(postgres.getJdbcUrl());
ds.setUser(postgres.getUsername());
ds.setPassword(postgres.getPassword());
Flyway.configure().dataSource(ds).locations("classpath:db/migration").load().migrate();
ProxyManager<Long> a = Bucket4jPostgreSQL.selectForUpdateBasedBuilder(ds).build();
ProxyManager<Long> b = Bucket4jPostgreSQL.selectForUpdateBasedBuilder(ds).build();
long consumed = 0;
for (int i = 0; i < 6; i++) {
consumed += a.builder().build(1L, () -> quota10).tryConsume(1) ? 1 : 0;
consumed += b.builder().build(1L, () -> quota10).tryConsume(1) ? 1 : 0;
}
assertThat(consumed).isEqualTo(10);
}
Both pass against 8.21.0. PostgreSQLContainer is in org.testcontainers.postgresql in Testcontainers 2.x, and on Spring
Boot 4 the Flyway auto-configuration lives in org.springframework.boot.flyway.autoconfigure. The Spring Boot Testcontainers
setup is in
integration tests with Testcontainers. Source:
Bucket4j reference: JDBC integrations.
Pitfalls
| Pitfall | What happens | Fix |
|---|---|---|
Adding |
The aggregator is stale (last 8.14.0) and brings no client |
Use |
Mixing module versions |
Serialization or API mismatches between core and backend |
Import |
Forgetting to create the JDBC table |
The first request fails |
Flyway migration with the DDL of your database, including |
Expiry configured, no cleanup job on JDBC |
Rows accumulate; the table grows without bound |
Schedule |
|
|
Pick a backend with async for WebFlux, or run blocking calls on a bounded pool |
Expiry on JCache, Geode, Ignite or Hazelcast CAS |
The builder refuses the strategy |
Configure expiry in the store, or choose a backend that supports it |
Grid members without the Bucket4j jars |
Entry processors fail to deserialize |
Put |
JCache provider that is not entry-processor atomic |
Writes are lost and the limit is exceeded |
Run the reference’s compatibility test against the provider |
Hot key on Cassandra or compare-and-swap in general |
Retries and latency grow with contention |
Use batching, or an entry-processor backend |
Using the reference’s |
The first does not exist on 8.21.0 builders, the second is deprecated |
Builder methods and |
What changed
-
8.11.1 renamed the artifacts to
bucket4j_jdk17-*; JDK 11 builds stopped at 8.15.0. -
8.17.0 to 8.21.0 added Vert.x (8.17.0), Couchbase (8.18.0), Geode (8.20.0), and Cassandra, Memcached, Ignite 3, Infinispan 15, Coherence 21 and the BOM (8.21.0), according to Maven Central.
-
The 8.21.0 release notes list the compare-and-swap retry mechanism, Redis Sentinel through Lettuce and Geode support, and fixes for the exception stack trace and MySQL gap-lock deadlocks.
-
The
redis,hazelcast-allandinfinispan-allaggregators did not get 8.21.0 builds. -
The full discrepancy table (Hot Rod type, async example, Caffeine, JDK 11 matrix) is on migration and what’s changed.
References
-
Bucket4j reference: Redis, how Compare&Swap works in Redis, Lettuce, Redisson, Jedis, Vert.x, GLIDE
-
Bucket4j reference: JDBC integrations, PostgreSQL, MySQL, MariaDB, Oracle, SQL Server, DB2
-
JCache, Hazelcast, Ignite, Ignite 3, Infinispan, Coherence, Geode
-
MongoDB sync, MongoDB async, Couchbase, Memcached, Cassandra
-
Bucket4j repository at 8.21.0 (the
Bucket4j<Backend>builder classes,AbstractJdbcProxyManagerBuilder,ExpiredEntriesCleaner,Bucket4jCaffeine)