Zeta is a configurable, high-performance, low-cost lightweight distributed cache and preheating framework, designed to solve cluster-wide distributed consistent caching problems for arbitrary sudden hotspot data at minimal cost.
Fully decouples business code from distributed coordination infrastructure via Redis and RabbitMQ.
End-to-end latency under default settings: ~300ms (P99).
Benchmarks:
peek~16M ops/s (pure Caffeine lookup, no side effects)get(L1 hit) ~15M ops/s (full path including TopK + Reporter)
Inspired by JD.com's hotkey project; algorithm support from Aegis.
Caution
Starting from v1.1.55, this project has been renamed to Zeta (formerly HotKey). Older versions (≤ v1.1.54) remain available but contain unfixed security vulnerabilities.
Configuration reference:
Quick Deploy YAML Templates
Local mode (App side) — just add the zeta dependency to run; uncomment optional features as needed
zeta:
# local parameters all use defaults, no explicit config needed
# —— Optional features, uncomment as needed ——
# Cross-instance cache sync (requires spring-boot-starter-amqp + spring-boot-starter-data-redis)
# sync:
# enabled: true
# Spring Cache integration (requires spring-boot-starter-cache)
# spring-cache:
# enabled: true
# Worker decision listener (requires spring-boot-starter-amqp + spring-boot-starter-data-redis)
# worker-listener:
# enabled: true
# sync:
# enabled: true # worker-listener depends on hotKeyRedisLoader Bean
# Consistent hashing is enabled by default (dynamic Worker routing via heartbeat)
# local:
# consistent-hashing:
# enabled: true # (already enabled by default)Single Worker (standalone node) — add spring-boot-starter-amqp
zeta:
worker:
enabled: true
routing:
app-name: myapp # 【Required】Must match App-side zeta.local.app-name
# Consistent hashing is enabled by default; Workers register via heartbeat
# Multi-Worker example: 3 machines, same app-name
# Consistent hashing routes keys to the correct Worker via heartbeat automatically
# No static sharding config needed — just add more machines
# Recommended: deploy local App first, then start WorkersCluster health threshold — when expected-worker-count: 0 (dynamic mode, default), min-alive-workers: 0 means 1 alive Worker is healthy. When expected-worker-count: N (fixed mode), uses majority formula N/2 + 1. Setting min-alive-workers overrides either mode. See docs/CONFIG.md for details.
All parameters: See CONFIG.md
Maven Central (no extra repository needed):
<dependency>
<groupId>io.github.hyshmily</groupId>
<artifactId>zeta</artifactId>
<version>1.1.55</version>
</dependency>JitPack:
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependency>
<groupId>io.github.hyshmily</groupId>
<artifactId>zeta</artifactId>
<version>1.1.55</version>
</dependency>GitHub Packages:
<repositories>
<repository>
<id>github</id>
<name>GitHub Packages</name>
<url>https://maven.pkg.github.com/hyshmily/zeta</url>
</repository>
</repositories>
<dependency>
<groupId>io.github.hyshmily</groupId>
<artifactId>zeta</artifactId>
<version>1.1.55</version>
</dependency>Important
Prerequisites: Redis + RabbitMQ
Pre-built images are hosted on GHCR.
Pull: Log in with a GitHub PAT that has read:packages scope:
echo $PAT | docker login ghcr.io -u hyshmily --password-stdinFull stack via docker compose (includes Redis + RabbitMQ):
docker compose -f worker/docker-compose.yml up -dScale multiple Worker instances:
docker compose -f worker/docker-compose.yml up -d --scale worker=3Run standalone (external Redis + RabbitMQ):
docker run -d --name zeta-worker -p 8080:8080 \
-e SPRING_RABBITMQ_HOST=rabbitmq \
-e SPRING_DATA_REDIS_HOST=redis \
-e ZETA_WORKER_ENABLED=true \
ghcr.io/hyshmily/zeta-worker:1.1.55Run JAR directly (no Docker):
mvn clean package -pl worker
java -jar worker/target/zeta-worker-1.1.55.jarDefault local configuration:
Feature configuration:
| Feature | How to Enable | Description |
|---|---|---|
| Redis L2 Cache | Add RedisTemplate Bean |
Two-level cache, L2 fallback |
| Cross-instance Sync | zeta.sync.enabled=true |
RabbitMQ-based cache invalidation |
| Worker Listener | zeta.worker-listener.enabled=true |
Receive HOT/COOL decisions from Worker |
| Worker Mode | zeta.worker.enabled=true |
Run a dedicated Worker node |
| Worker TopK Persist | zeta.worker.persistence.enabled=true |
Warm start from Redis after restart |
| Access Reporting | zeta.report.enabled=true (default) |
Report access counts to Worker |
| Reporter Self-Protection | zeta.local.reporter.enabled=true (default) |
BBR backpressure for Reporter flush |
| Spring Cache Integration | zeta.spring-cache.enabled=true |
@Cacheable / @CachePut / @CacheEvict fused with Zeta detection |
See CONFIG.md for the full property reference.
Note
Serialization: Zeta internally uses StringRedisTemplate. Value serialization is entirely up to the caller. Jackson (Spring Boot default, JSON) or Kryo (binary, maximum throughput) are recommended. JDK native serialization is not recommended.
Method Overview
| Category | Methods |
|---|---|
| Read | get, getWithSoftExpire, computeIfAbsent, computeIfAbsentWithSoftExpire, peek, peekAll |
| Write | putThrough, putLocal, invalidateAfterPut, refresh, refreshAll |
| Invalidate | invalidate, invalidateAllLocal, compareAndInvalidate |
| Atomic | compareAndSet, compareAndInvalidate |
| Fluent | read(key) → HotKeyReadQuery, write(key) → HotKeyWriteCommand |
| Introspection | peek, estimatedSize, stats, getLocalCache, isLocalHotKey, isWorkerHotKey, returnLocalHotKeys, returnWorkerHotKeys |
| Rule | addBlacklist, removeBlacklist, addWhitelist, removeWhitelist, evaluateRule, getAllRules, clearAllRules |
| Lock | tryLock, tryLockAndRun |
| Background | registerRefresh, updateRefresh, unregisterRefresh |
| Mode | isApp, isWorker, isAppOnly, isWorkerOnly |
Read Operations
@Autowired
private Zeta zeta;
// A. peek — L1 only, no hot key tracking
Optional<String> r = zeta.peek("user:123"); // returns Optional.empty() on L1 miss
// B. computeIfAbsent — simplified get (no Optional wrapper)
String val = zeta.computeIfAbsent("user:123", () -> redisTemplate.opsForValue().get("user:123"));
// C. get — two-level cache (Redis or any backend)
Optional<String> r = zeta.get("user:123", () -> redisTemplate.opsForValue().get("user:123"));
// D. getWithSoftExpire — soft expiration (stale-while-revalidate)
Optional<String> r = zeta.getWithSoftExpire("user:123", () -> redisTemplate.opsForValue().get("user:123"));
// E. Fluent read API + fallback chain
User user = zeta
.read("user:42")
.withPrimary(userRepo::findById)
.thenExecute(backupRepo::findById)
.withHardTtl(30_000)
.withSoftTtl(10_000)
.allowBroadcast()
.executeOrNull();Write Operations
// F. putThrough — write-through + broadcast
zeta.putThrough("user:123", newValue, () -> redisTemplate.opsForValue().set("user:123", newValue));
// G. invalidateAfterPut — mutate then invalidate (collection types)
zeta.invalidateAfterPut(key, () -> redisTemplate.opsForSet().add(key, members));
// H. putLocal — local write only, no broadcast, no version bump
zeta.putLocal("user:123", cachedValue, hardTtlMs, softTtlMs); // custom TTL
// I. refresh — local evict then load and cache
zeta.refresh("user:123", () -> loadUser(123), hardTtlMs, softTtlMs); // with TTL override
// J. Fluent write API
zeta.write("user:42").withHardTtl(30_000).putThrough(newValue, dbWriter);
zeta.write("user:42").invalidateAfterPut(dbMutation);
zeta.write("user:42").invalidate();Custom per-entry TTL
Zeta uses differentiated TTLs: hot keys and normal keys have independent defaults. Per-call overrides take effect on top.
| Key State | Hard TTL (Caffeine eviction) | Soft TTL (stale-while-revalidate) |
|---|---|---|
| Normal | default-hard-ttl-ms (5min) |
default-soft-ttl-ms (30s) |
| Hot | default-hot-hard-ttl-ms (1h) |
default-hot-soft-ttl-ms (5min) |
// 5 min hard TTL + 30s soft TTL
Optional<String> shopJson = zeta.get("shop:" + shopId,
() -> redisTemplate.opsForValue().get("shop:" + shopId),
TimeUnit.MINUTES.toMillis(5), TimeUnit.SECONDS.toMillis(30));
// 30s hard TTL, soft TTL uses default
zeta.putThrough("weather:" + city, weatherData,
() -> redisTemplate.opsForValue().set("weather:" + city, weatherData),
TimeUnit.SECONDS.toMillis(30), 0);
// registerRefresh / updateRefresh — scheduled background refresh (softTtlMs = interval)
zeta.registerRefresh("user:123", () -> loadUser(123), 300_000L, 60_000L); // every 60s
zeta.updateRefresh("user:123", () -> loadUser(123), 300_000L, 30_000L); // change to 30s
zeta.unregisterRefresh("user:123"); // stopNote
Cache avalanche protection: CacheExpireManager applies a uniform random offset via DelayUtil.computeTtlJitter() to every expiration timestamp (default ±5%). A 5-minute hard TTL actually expires between 4.75 ~ 5.25 minutes under the default offset. Controlled by zeta.local.ttl-jitter-ratio (ratio, default 0.05 = ±5%, 0 to disable).
Tip
Per-call TTL semantics: passing 0 uses the configured default for that key state. For pure logical expiration (hard TTL never evicts, soft expire only): pass hardTtlMs = Long.MAX_VALUE to getWithSoftExpire(key, reader, Long.MAX_VALUE, softTtlMs) — the entry permanently resides in Caffeine. This usage is explicitly supported by Caffeine's Expiry JavaDoc: "To indicate no expiration an entry may be given an excessively long period, such as Long.MAX_VALUE." (source)
Atomic Operations
CAS-style operations for lock-free conditional updates:
// compareAndSet — atomic swap if current value matches expected
boolean ok = zeta.compareAndSet("user:123", oldValue, newValue);
// compareAndInvalidate — invalidate only if current value matches expected
boolean ok = zeta.compareAndInvalidate("user:123", staleValue);Both operations are delegation-based: the caller is responsible for re-reading or re-writing after a successful CAS. There is no L2 lock — the guard is the L1 cache entry's current value at the time of call. Returns true if the condition matched and the operation was applied; false otherwise.
Worker Mode
Worker mode provides cluster-wide hotspot detection via dedicated nodes. App instances periodically report access counts; the Worker runs a sliding window + state machine pipeline and broadcasts HOT/COOL decisions back to all instances. State machine parameters (confirmCount, coolCount, preCoolGraceCount) can be adjusted at runtime via /actuator/hotkey/worker/state.
| Mode | worker.enabled |
Activated Beans |
|---|---|---|
| App-only | false (default) |
HotKeyCache, TopK, reporter, actuator, sync |
| Worker-only | true |
Worker only (no cache — get()/putThrough() throw HotKeyModeException) |
Worker Cluster Health: Set zeta.local.expected-worker-count to the expected number of Workers in production. When set >0, ClusterHealthView uses majority quorum (> expectedWorkerCount / 2) as the healthy Worker threshold; when 0 (default), the cluster is considered unhealthy until at least one heartbeat is received. This enables precise detection of partial Worker failures and graceful degradation decisions.
Worker TopK Persistence (Warm Start): When zeta.worker.persistence.enabled=true, the Worker periodically snapshots the TopK list to Redis. On restart, TopKPersistService loads the last snapshot and replays it into the HeavyKeeper sketch, reducing warmup from hours to seconds.
Spring Cache Integration
Enable zeta.spring-cache.enabled=true. Standard @Cacheable / @CachePut / @CacheEvict are automatically routed through Zeta's hotspot detection, soft expiration, and cross-instance sync.
| Annotation | Role on @Cacheable |
|---|---|
@HotKeyCacheTTL |
Override hard/soft TTL |
@HotKeyPreload |
Pre-inflate HeavyKeeper counts so known hot keys take effect immediately |
@Intercept |
Skip method body via trigger mode (IS_LOCAL_HOT/FORCE/QPS); degrades via @Intercept.fallback(), @Fallback, or peek() |
@Fallback |
Provide fallback value when blocked, intercepted, or on exception |
@NullCaching |
Opt into caching null return values (default true) |
@Broadcast |
Suppress cross-instance sync messages |
@Cacheable(cacheNames = "users", key = "#id")
@HotKeyCacheTTL(softTtlMs = 1000)
@Intercept @Fallback
public User getUser(Long id) { ... }
// QPS rate-limit interception
@Cacheable(cacheNames = "products", key = "#id")
@Intercept(trigger = InterceptTrigger.QPS, QPS = 500, fallback = "'throttled'")
@Fallback
public Product getProduct(String id) { ... }
// Hot key preloading
@Cacheable(cacheNames = "flash", key = "#id")
@HotKeyPreload(keys = {"item-001", "item-002"})
@Intercept
public String getFlashItem(String id) { ... }Requires spring-boot-starter-cache and spring-boot-starter-aop on the classpath.
Enable zeta.sync.enabled=true.
Enable zeta.sync.enabled=true to enable cross-instance rule synchronization. The rule system supports two actions:
| Action | Effect on matching keys |
|---|---|
BLOCK |
get() / getWithSoftExpire() throw HotKeyBlockedException; putThrough() skips |
ALLOW_NO_REPORT |
Process normally but skip Worker reporting (reduces noise from high-frequency keys) |
RuleMatcher.of(pattern, action) auto-detects the pattern:
| Pattern | Type | Matches |
|---|---|---|
"user:123" |
EXACT |
Exact key |
"temp:*" |
PREFIX |
Keys starting with temp: |
"order:*-detail" |
WILDCARD |
Glob-style (* / ?) match |
"regex:user:\\d+" |
REGEX |
Java regex |
- With Redis: Each
addRule()/removeRule()/clearRules()serializes the rule list toHotKeyConstants.REDIS_KEY_RULES("hotkey:rules"). On startup,RuleMatcher.initRules()loads from Redis. Changes are also broadcast viaTYPE_RULES_SYNC— peers callRuleMatcher.syncRules()for atomic replacement without triggering secondary broadcasts (loop-free). - Without Redis: Same operations are broadcast to all peers via the
CacheSyncPublisherfanout exchange. Each peer holds the full rule set in memory. - Manual broadcast:
zeta.broadcastAllLocalRulesManually()loads from Redis (if available) and re-broadcasts the current rule set to all peers.
Zeta provides two complementary monitoring mechanisms.
See MONITOR.md for the full response format and field descriptions.
See CONTEXT.md for domain terminology. Architecture Decision Records (ADRs) are maintained in docs/adr/.
Apache License 2.0