HotKey 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.
Configuration reference:
Quick Deploy YAML Templates
Local mode (App side) — just add the hotkey dependency to run; uncomment optional features as needed
hotkey:
# 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
hotkey:
worker:
enabled: true
routing:
app-name: myapp # 【Required】Must match App-side hotkey.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>hotkey</artifactId>
<version>1.1.54</version>
</dependency>JitPack:
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependency>
<groupId>io.github.hyshmily</groupId>
<artifactId>hotkey</artifactId>
<version>1.1.54</version>
</dependency>GitHub Packages:
<repositories>
<repository>
<id>github</id>
<name>GitHub Packages</name>
<url>https://maven.pkg.github.com/hyshmily/hotkey</url>
</repository>
</repositories>
<dependency>
<groupId>io.github.hyshmily</groupId>
<artifactId>hotkey</artifactId>
<version>1.1.54</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 hotkey-worker -p 8080:8080 \
-e SPRING_RABBITMQ_HOST=rabbitmq \
-e SPRING_DATA_REDIS_HOST=redis \
-e HOTKEY_WORKER_ENABLED=true \
ghcr.io/hyshmily/hotkey-worker:1.1.54Run JAR directly (no Docker):
mvn clean package -pl worker
java -jar worker/target/hotkey-worker-1.1.54.jarDefault local configuration:
Feature configuration:
| Feature | How to Enable | Description |
|---|---|---|
| Redis L2 Cache | Add RedisTemplate Bean |
Two-level cache, L2 fallback |
| Cross-instance Sync | hotkey.sync.enabled=true |
RabbitMQ-based cache invalidation |
| Worker Listener | hotkey.worker-listener.enabled=true |
Receive HOT/COOL decisions from Worker |
| Worker Mode | hotkey.worker.enabled=true |
Run a dedicated Worker node |
| Worker TopK Persist | hotkey.worker.persistence.enabled=true |
Warm start from Redis after restart |
| Access Reporting | hotkey.report.enabled=true (default) |
Report access counts to Worker |
| Reporter Self-Protection | hotkey.local.reporter.enabled=true (default) |
BBR backpressure for Reporter flush |
| Spring Cache Integration | hotkey.spring-cache.enabled=true |
@Cacheable / @CachePut / @CacheEvict fused with HotKey detection |
See CONFIG.md for the full property reference.
Note
Serialization: HotKey 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.
Read Operations
@Autowired
private HotKey hotKey;
// A. peek — L1 only, no hot key tracking
Optional<String> r = hotKey.peek("user:123"); // returns Optional.empty() on L1 miss
// B. computeIfAbsent — simplified get (no Optional wrapper)
String val = hotKey.computeIfAbsent("user:123", () -> redisTemplate.opsForValue().get("user:123"));
// C. get — two-level cache (Redis or any backend)
Optional<String> r = hotKey.get("user:123", () -> redisTemplate.opsForValue().get("user:123"));
// D. getWithSoftExpire — soft expiration (stale-while-revalidate)
Optional<String> r = hotKey.getWithSoftExpire("user:123", () -> redisTemplate.opsForValue().get("user:123"));
// E. Fluent read API + fallback chain
User user = hotKey
.read("user:42")
.withPrimary(userRepo::findById)
.thenExecute(backupRepo::findById)
.withHardTtl(30_000)
.withSoftTtl(10_000)
.allowBroadcast()
.executeOrNull();Write Operations
// F. putThrough — write-through + broadcast
hotKey.putThrough("user:123", newValue, () -> redisTemplate.opsForValue().set("user:123", newValue));
// G. putBeforeInvalidate — mutate then invalidate (collection types)
hotKey.putBeforeInvalidate(key, () -> redisTemplate.opsForSet().add(key, members));
// H. putLocal — local write only, no broadcast, no version bump
hotKey.putLocal("user:123", cachedValue, hardTtlMs, softTtlMs); // custom TTL
// I. evictLocal — evict from local cache only, no broadcast, no version bump
hotKey.evictLocal("user:123"); // single key
// J. refresh — local evict then load and cache
hotKey.refresh("user:123", () -> loadUser(123), hardTtlMs, softTtlMs); // with TTL override
// K. Fluent write API
hotKey.write("user:42").withHardTtl(30_000).putThrough(newValue, dbWriter);
hotKey.write("user:42").putBeforeInvalidate(dbMutation);
hotKey.write("user:42").invalidate();Custom per-entry TTL
HotKey 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 = hotKey.get("shop:" + shopId,
() -> redisTemplate.opsForValue().get("shop:" + shopId),
TimeUnit.MINUTES.toMillis(5), TimeUnit.SECONDS.toMillis(30));
// 30s hard TTL, soft TTL uses default
hotKey.putThrough("weather:" + city, weatherData,
() -> redisTemplate.opsForValue().set("weather:" + city, weatherData),
TimeUnit.SECONDS.toMillis(30), 0);
// registerRefresh / updateRefresh — scheduled background refresh (softTtlMs = interval)
hotKey.registerRefresh("user:123", () -> loadUser(123), 300_000L, 60_000L); // every 60s
hotKey.updateRefresh("user:123", () -> loadUser(123), 300_000L, 30_000L); // change to 30s
hotKey.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 hotkey.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)
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 hotkey.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 hotkey.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 hotkey.spring-cache.enabled=true. Standard @Cacheable / @CachePut / @CacheEvict are automatically routed through HotKey'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 hotkey.sync.enabled=true.
Enable hotkey.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:
hotKey.broadcastAllLocalRulesManually()loads from Redis (if available) and re-broadcasts the current rule set to all peers.
HotKey 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