Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.

Store & Counter

OpenSwoole adapters for cross-worker shared memory. Must be created before $app->run() so all forked workers inherit the same memory segment.

🗃️

Store — OpenSwoole\Table

Row-based shared memory with per-row spinlocks. Any worker can read/write any row concurrently. Iterate all rows across workers.

🔢

Counter — OpenSwoole\Atomic

Lock-free integer. Safe for concurrent increment/decrement from all workers. Useful for metrics, rate limiting, and request counting.

GET Store — set / get / count
// Before app->run():
Store::make('demo_table', 128, [
    'name'  => [Store::TYPE_STRING, 64],
    'score' => [Store::TYPE_INT,    4],
]);

// In any route (any worker):
Store::set('demo_table', 'user_1', ['name' => 'alice', 'score' => 100]);
$row = Store::get('demo_table', 'user_1');
// → ['name' => 'alice', 'score' => 100]

echo Store::count('demo_table'); // total rows across all workers
LIVE OUTPUT Click Run →
GET Store — atomic incr/decr
// Atomically increment a counter column
Store::set('demo_table', 'page_hits', ['score' => 0]);
$new = Store::incr('demo_table', 'page_hits', 'score');
// → 1 (atomic, safe under concurrent workers)
LIVE OUTPUT Click Run →
GET Counter — increment across requests
// Before app->run():
$requestCounter = new Counter(0);

// In any route:
$app->route('/demo/counter/increment', function() use ($requestCounter) {
    $new = $requestCounter->increment();
    return ['total_requests' => $new, 'pid' => getmypid()];
    // Every worker shares the same atomic integer
});
LIVE OUTPUT Click Run →

Store API reference

MethodReturns
Store::make($name, $maxRows, $columns)OpenSwoole\Table
Store::set($table, $key, $row)bool
Store::get($table, $key, $field?)array|mixed|false (BC: false on miss)
Store::getStrict($table, $key, $field?)mixed (null on miss — preferred for new code)
Store::del($table, $key)bool
Store::exists($table, $key)bool
Store::incr($table, $key, $col, $by=1)int (new value)
Store::decr($table, $key, $col, $by=1)int (new value)
Store::count($table)int
Store::table($name)OpenSwoole\Table (iterate with foreach)

Cache — general-purpose key-value with TTL

Tiered cache built on Store. Memory tier (fast, cross-worker) + file tier (persistent, survives restarts). No Redis needed for most apps.

Cache follows whichever Store backend is configured. Internally Cache::* delegates to Store::set/get/del/iterate against an internal __cache table. Flip the backend and Cache follows automatically:
  • Store::defaultBackend(Store::BACKEND_TABLE) → in-memory shared via OpenSwoole\Table (single-server, ns latency)
  • Store::defaultBackend(Store::BACKEND_REDIS) → cross-node Redis/Valkey
  • Store::defaultBackend(Store::BACKEND_TIERED) → L1 Table + L2 Redis (bounded-staleness fast read + cluster-wide truth)
  • Store::defaultBackend(Store::BACKEND_MEMCACHED) → flat KV cache over Memcached
The Cache file tier (.cache/ directory) is a SEPARATE layer that works alongside whichever Store backend you've picked — it persists across restarts; Store memory state doesn't.
// Before $app->run():
Cache::init();

// Anywhere (any worker):
Cache::set('user:42', $profileArray, ttl: 300);   // any PHP value, auto-serialized
$profile = Cache::get('user:42');                  // memory first, file fallback
Cache::has('user:42');                             // TTL-aware existence check
Cache::del('user:42');                             // removes from both tiers
Cache::flush();                                    // clear everything
MethodReturnsNotes
Cache::init($maxRows?, $cacheDir?, $gcIntervalMs?)voidCall before $app->run(). Defaults: 4096 rows, .cache/, 60s GC
Cache::set($key, $value, ttl: $seconds)boolWrite-through to both tiers. ttl: 0 = no expiry
Cache::get($key, $default?)mixedMemory first, file fallback. Returns $default on miss
Cache::del($key)boolRemoves from both tiers
Cache::has($key)boolChecks without deserializing. Respects TTL
Cache::flush()voidClears all entries from both tiers
Cache::count()intMemory tier count only
How it works: Values are serialized and written to both tiers. Memory tier uses Store (OpenSwoole\Table) — 8KB max per value, values larger than 8KB automatically spill to file-only. File tier writes to .cache/{hash}.cache with TTL header. Expired entries are cleaned lazily on read + a periodic GC sweep every 60s on worker 0.

Consistency semantics — what's atomic, what isn't

Store is shared memory, not a database. The atomicity guarantees are narrow and important to understand before reaching for it in production.

OperationAtomicityNotes
$table->set($key, $row) — single call updating multiple fields in one row Atomic at the C level (per-row spinlock) Readers see either the old row or the new row, never a partial update.
$table->get($key) / $table->get($key, 'field') ✅ Atomic read of one row Acquires the row lock briefly; safe under concurrent writes.
Two $table->set($key, ['a' => 1]) + $table->set($key, ['b' => 2]) calls on the same row Not transactional across calls Each call is atomic individually, but a reader between them sees a half-applied update. Combine into a single set() with both fields if order matters.
$table->incr($key, 'field', $by), $table->decr($key, 'field', $by) ✅ Atomic — no read-then-write race Use these for counters. Don't do get + set.
Counter (OpenSwoole\Atomic) — compareAndSet, increment, decrement, reset, get, set ✅ Lock-free atomic on a 64-bit signed integer For single-value cross-worker counters, prefer this over Store.

What happens on worker crash mid-write

The honest answer: it depends on how the worker died.

Crash typeEffect
Graceful shutdown — SIGTERM, including the max_request recycle ✅ Worker drains current request, releases all row locks normally, exits clean. Manager forks a fresh worker. No corruption.
SIGKILL / OOM kill / segfault mid-set() ⚠ The row's spinlock may be left held. OpenSwoole doesn't have robust mutex-on-holder-death recovery. Other workers will spin waiting on that row until the server is fully restarted. Rare in practice (single-row writes are nanoseconds) but possible under adversarial load.
Server hard kill (kill -9 on the master) Shared memory segment is destroyed entirely when the last attached process exits. Fresh segment on next start. No state survives, but no corruption either.
Rule of thumb: treat Store as a best-effort, fast, single-server cache, not as a database. For ACID needs (transactions, durability, multi-row consistency), use Postgres / MySQL / Redis with explicit transaction semantics. Store's job is to make < 5µs reads possible across workers — that's it.

Pluggable backends — Table / Redis / Tiered / Memcached

As of v0.2.39, Store and Counter are backend-agnostic. Four backends ship in-tree; pick by scope. Every Store::set/get/incr/count call works unchanged across all four.

use ZealPHP\Store;

// Recommended — type-safe enum (IDE autocomplete + refactor-safe):
Store::defaultBackend(Store::BACKEND_TABLE);                                // default
Store::defaultBackend(Store::BACKEND_REDIS, 'redis://cache.internal:6379'); // cross-node
Store::defaultBackend(Store::BACKEND_TIERED, [                              // ns reads + cross-node truth
    'url'                 => 'redis://cache.internal:6379',
    'l1_ttl'              => 5,                              // L1 freshness window (seconds)
    'invalidation_secret' => getenv('ZEALPHP_TIERED_INVALIDATION_SECRET') ?: null,
]);

// Bare strings still work for BC (also: ZEALPHP_STORE_BACKEND=redis|tiered env):
Store::defaultBackend(Store::BACKEND_REDIS);
Store::defaultBackend(Store::BACKEND_TIERED, 'redis://cache:6379');
BackendLatencyCross-nodePersistenceBounded growthWhen to pick
Store::BACKEND_TABLE (default) ~ns (lock-free shared memory) No — one OpenSwoole server No — volatile HARD: maxRows at make() Single-node hot path. Millions of ops/sec. 95% of apps.
Store::BACKEND_REDIS ~tens of µs local, ~ms cross-node Yes — any number of nodes Yes (Redis AOF/RDB) Server-side maxmemory + maxmemory-policy Horizontal scaling, persistent state, existing Redis infra.
Store::BACKEND_TIERED ~ns on L1 hit, ~ms on L1 miss (L2) Yes (via L2) Yes (via L2) L1 capped by Table; L2 via Redis policy Hot keys with ns reads + cross-node visibility for cold keys + tolerate l1_ttl-bounded staleness.
Store::BACKEND_MEMCACHED ~sub-ms local Yes — any number of nodes No — volatile Server-side max_memory LRU eviction Flat KV cache over an existing Memcached cluster; no pub/sub or Streams support.

Two table modes at make(): 'tracked' (default) keeps a membership SET so count() is O(1); 'ttl' supports per-key expiry but count() falls back to O(N) SCAN. Pick one per table. Mixing tracked + ttl throws at boot (v0.2.41 hardening — H1). The connection pool is per-worker (default 8 clients) — concurrent coroutines never share a socket.

Client lib: auto-detects phpredis (preferred when ext-redis is loaded) or predis (pure-PHP fallback, shipped as a dev dep). User code never imports a phpredis/predis symbol — the single ZealPHP\Store\RedisClient adapter is the only place either lib is referenced.

Backend scope — what "single process" really means

OpenSwoole\Table is shared memory allocated by the master process BEFORE fork, inherited by all worker processes of that server. So the Table backend is:

  • Shared across coroutines in one worker ✓
  • Shared across workers in one OpenSwoole server ✓ (the whole point)
  • NOT shared across two php app.php invocations on the same machine ✗ (different mmap segments)
  • NOT shared across machines

For cross-server state, use Redis or Tiered. For cross-tab/cross-tenant WS routing where state must survive node death, see /pubsub#ws-routing.

Memory math for the Table backend

maxRows is allocated UP FRONT at the OpenSwoole master fork, not lazily. Empirically (PHP 8.3 + OpenSwoole 22.x):

RowsSchemaAllocatedNotes
1,0241 × STRING(32)~290 KBHash overhead dominates at small sizes
1,000,0001 × STRING(32)~280 MBRoughly 280 B/row including overcommit + per-row mutex/metadata
PHP_INT_MAXanyOOM-killedNo artificial cap; the kernel decides

Rule of thumb: RAM ≈ maxRows × (4 × Σ column sizes + ~32 B/row). The 4× factor is OpenSwoole's open-addressed-hash overcommit. For Cache::init(4096) with the default 8 KB value column: ~130 MB reserved per worker server. When you'd say "tens of millions of rows", flip to Redis (server-side bound) or Tiered (L1 stays small, L2 grows).

Cache::init & the maxRows chokepoint

Cache::init($maxRows, $cacheDir, $gcIntervalMs, $ttlSeconds) behaves differently across backends — flagged at boot when misconfigured (v0.2.41 hardening):

  • Table backend: $maxRows is a HARD CAP. Cache spills oversize / overflow to the file tier automatically.
  • Redis backend: $maxRows has NO equivalent — Redis is a global KV store. Pair with $ttlSeconds for per-key auto-expiry, OR configure Redis-server maxmemory + maxmemory-policy allkeys-lru for cluster-wide bound. If you pass a non-default $maxRows without a TTL on the Redis backend, Cache::init() emits a one-line warning telling you so.
  • Tiered backend: L1 honours $maxRows (Table); L2 (Redis) ignores it as above.
// Recommended Redis-backed Cache config — bounded by per-key TTL:
Cache::init(
    maxRows:    4096,      // HARD CAP on Table; informational on Redis
    cacheDir:   '/var/cache',
    ttlSeconds: 3600,      // On Redis → mode='ttl' auto-expiry
);

// Read-through pattern — canonical helper (no more get-then-compute boilerplate):
$users = Cache::getOrCompute("users:active", fn() => DB::select(...), ttl: 60);
// First call computes + caches; subsequent calls within TTL short-circuit to the cached read.
// Null is cached as a valid value (sentinel-based miss detection).

Pub/sub + Streams (cross-node messaging)

Two public primitives on top of the Redis backend for cross-worker AND cross-host messaging. Both require Store::defaultBackend(Store::BACKEND_REDIS).

// Fire-and-forget pub/sub
App::subscribe('chat:room:42', function (string $payload, string $channel) {
    // runs in every worker that's registered; routes to your local fd map
});
$receivers = Store::publish('chat:room:42', json_encode($message));

// Reliable variant via Redis Streams (at-least-once via consumer groups)
App::subscribeReliable('orders', function (string $payload, string $id, string $stream): bool {
    $ok = processOrder($payload);
    return $ok; // true → XACK; false/throw → leave pending
});
$messageId = Store::publishReliable('orders', json_encode($order));
PrimitiveLatencyDurabilityDeliveryWhen to pick
Store::publish ~0.5 ms loopback None (fire-and-forget) Best-effort — lost during subscriber reconnect window Cache invalidation, WebSocket fan-out, presence beats — drops are tolerable, speed matters.
Store::publishReliable ~1–2 ms (XADD + ACK) AOF/RDB-backed At-least-once via consumer groups Command/event sourcing, work queues, must-not-drop business events.
Driver choice (both validated as of v0.2.40). Both phpredis (preferred when ext-redis is loaded) and predis (pure-PHP fallback) SUBSCRIBE loops yield correctly under OpenSwoole\Runtime::HOOK_ALL — the production default in coroutine mode. Empirical results (spike doc):
  • predis: 760 ops/sec aggregate, 0.40 ms PUBLISH receive median.
  • phpredis: 775 ops/sec aggregate, 0.23 ms PUBLISH receive median, ~2× faster on hot CRUD per-op (11 ms vs 23 ms for 50-RTT batches).
Pick phpredis when you can — it’s faster across the board. Force predis only if you can’t install ext-redis:
Store::defaultBackend(Store::BACKEND_REDIS, [
    'url'    => 'redis://cache:6379/0',
    'prefer' => Store::PREFER_PREDIS,   // or Phpredis (default when ext loaded)
]);
// Or via env: ZEALPHP_REDIS_PREFER=predis
One nuance to remember: phpredis SUBSCRIBE blocks the worker WITHOUT HOOK_ALL (C-side socket read). HOOK_ALL is on by default in coroutine mode (App::superglobals(false)); if you’ve disabled it explicitly, force predis for subscribers OR re-enable HOOK_ALL.

Receiver count semantics: Store::publish delivers ONE copy to every worker (across every node) running a matching subscriber. So 32 workers per node × 2 nodes = receivers: 64 for one PUBLISH. That's correct Redis pub/sub — matches the cross-server WebSocket routing pattern where each worker owns a subset of fds.

Atomic Lua — Store::eval()

For multi-step operations that must be atomic (read-modify-write, conditional set-membership), run a Lua script server-side. Available on the Redis / Tiered backend (throws on Table).

// Keys are raw/absolute (like publish); values pass as KEYS/ARGV — never
// interpolated into the script body. Returns whatever the script returns.
$n = Store::eval(
    "local v = redis.call('INCR', KEYS[1])\n" .
    "if v == 1 then redis.call('SADD', KEYS[2], ARGV[1]) end\n" .
    "return v",
    keys: ['room:42:count', 'room:42:servers'],
    args: [$serverId],
);
Cross-node fan-out (roadmap). The W×N receiver count above — every worker on every node gets every message — is being reduced toward N by a per-node pub/sub aggregator (one SUBSCRIBE per node, re-fanning to local workers) plus WebSocket room targeting (publish only to nodes that actually hold members). Step B1 — the per-room server-set (WSRouter::roomServers(), maintained race-free via Store::eval()) — has landed as the additive groundwork; targeted routing (B2) and the aggregator (A1) are opt-in increments. Design + rollout plan: cross-node-fanout.md.

Live demo — this very server

Each button below fires a real HTTP request against the running ZealPHP instance. Output panel shows the JSON the server returned. Most useful with ZEALPHP_STORE_BACKEND=redis; on the default Table backend the pub/sub buttons surface a clean StoreException error in JSON.

Try it

Each click rolls a fresh random message so you can see the receiver count + message ID change.

Click a button above to fire a request. The response JSON will land here.

Endpoints: /demo/store-roundtrip, /demo/pubsub/publish, /demo/pubsub/publish-reliable, /demo/pubsub/log. See /pubsub for the multi-tab walkthrough.

Redis Cluster / Sentinel

For HA topologies that span multiple Redis nodes (Cluster) or use a failover monitor (Sentinel), drive the driver with a pre-wired Predis\Client instead of a URL string. Predis natively supports both topologies via its constructor parameters — ZealPHP’s adapter accepts the prebuilt client and uses it as-is for the connection pool.

use Predis\Client as PredisClient;
use ZealPHP\Store\{RedisBackend, RedisConnectionPool, PredisDriver};

// === Cluster (3-node, key-slot routing) ===
$cluster = new PredisClient(
    ['tcp://node1:7000', 'tcp://node2:7000', 'tcp://node3:7000'],
    ['cluster' => 'redis'],
);
// Wire as the Store backend (pool of 1 because Cluster manages its own connections):
$backend = new RedisBackend(
    new RedisConnectionPool(
        url: 'unused-for-cluster',
        size: 1,
        opts: ['prefer' => Store::PREFER_PREDIS],   // phpredis Cluster needs RedisCluster — future v0.2.41
    ),
);
// Or simpler — bypass the URL+pool layer:
$driver = new PredisDriver($cluster);

// === Sentinel (master/slave failover) ===
$sentinel = new PredisClient(
    ['tcp://sentinel1:26379', 'tcp://sentinel2:26379'],
    ['replication' => 'sentinel', 'service' => 'mymaster'],
);
$driver = new PredisDriver($sentinel);

First-class Store-facade integration (a Store::clusterBackend() / Store::sentinelBackend() helper) is on the v0.2.41 roadmap. Today's path is to construct the Predis\Client + PredisDriver directly and inject into a RedisBackend. phpredis users wanting Cluster/Sentinel should currently use predis (set ZEALPHP_REDIS_PREFER=predis); the RedisCluster phpredis class needs a separate driver shape.

Production hardening (v0.2.41)

A senior-eng review of the v0.2.39/v0.2.40 Redis backend surface flagged 3 critical + 10 medium issues — all closed in this pass. Each gets a brief description + the opt-in recipe. Default behaviour is unchanged; nothing on this page is a BC break.

C3 — TLS via rediss://

Cross-region or untrusted-network deployments need encrypted Redis connections. Both drivers now recognise rediss:// (and the tls:// alias). verify_peer + verify_peer_name are on by default.

Store::defaultBackend(Store::BACKEND_REDIS, 'rediss://cache.prod:6380/0');
// or  ZEALPHP_REDIS_URL=rediss://cache.prod:6380/0

Bare redis:// (no host) is now rejected at parse time — surfaces misconfig at boot instead of after silently defaulting to 127.0.0.1.

C1 — WSRouter FD-reuse race fix

The previous cross-server WS routing pattern was vulnerable to a FD-reuse race: when client A's onClose was lost (kernel reaped the fd, OpenSwoole reassigned it to client B), an in-flight publish to A could land on B's connection — a cross-tenant data-leakage vector.

The fix is a per-connection 16-byte hex nonce stored in ws_owner.conn_id + WSRouter::$localFds[clientId]['conn_id']. WSRouter::sendToClient() carries the nonce in the publish payload; the subscriber on the owning server verifies it matches the local conn_id before pushing. Stale entries with the same fd are evicted on own() (the fd-coherence invariant).

No API change for callers — WSRouter::own($clientId, $fd) still works exactly as before; it now returns the generated conn_id for tests + debug.

C2 — HMAC-signed L1 invalidation

TieredBackend publishes invalidation messages on {prefix}:__l1_invalidate:{table}. Before this fix, anyone with Redis write access could forge invalidations and DoS L1 across the cluster. Now: optional shared secret signs every outbound message; receivers verify before evicting.

// Same secret on every node:
$tiered = new TieredBackend(
    l1: new TableBackend(),
    l2: new RedisBackend(/* … */),
    invalidationSecret: getenv('ZEALPHP_TIERED_INVALIDATION_SECRET') ?: null,
);
// Or via env: ZEALPHP_TIERED_INVALIDATION_SECRET=<32-byte-random-hex>

No secret set → trust mode (any peer message accepted; preserves the v0.2.40 default). When set, messages without a matching truncated HMAC-SHA256 are silently dropped + elog'd at warn level. Same secret on every node, or peers will reject each other's invalidations.

H1 — tracked + TTL combo throws at make()

Pre-v0.2.41 silently ignored TTL on tracked-mode tables (the membership SET would have drifted). Now it throws at make() with a clear message — fail fast at boot, not silently after the first expiry.

H2 — Store::getStrict() for new code

The legacy Store::get() returns false on miss for BC. New code that wants ??-style fallbacks safely with stored falsy values (0, '', '0') uses the strict variant — returns null on miss.

// Legacy — keep using === false to detect misses:
$row = Store::get('users', $id);
if ($row === false) { /* miss */ }

// New code — null on miss, value otherwise:
$row = Store::getStrict('users', $id);
$hits = Store::getStrict('users', $id, 'hits') ?? 0;   // 0-stored value preserved

H3 — pipelined bulk ops + UNLINK

Store::mget, Store::mset, and Store::clear on the Redis backend now run in a single MULTI/EXEC pipeline instead of N sequential round-trips. clear() uses UNLINK (non-blocking, Redis 4.0+) so multi-second clears on 10k+-row tables drop to sub-second.

No API change — existing call sites get the speedup automatically.

H4 — Circuit breaker (opt-in graceful degradation)

When Redis is degraded, every Store call previously hit the 5-second pool acquire timeout. The CircuitBreakerBackend decorator adds three-state fail-fast: closed (normal) → open (skip primary) → half-open (one probe). Reads can fall back to a Table backend; writes throw (no fallback semantics for writes — they'd diverge on recovery).

// Opt in via the connection opts:
Store::defaultBackend(Store::BACKEND_REDIS, [
    'url'      => 'redis://cache:6379',
    'on_error' => 'fallback_table',   // wraps with CircuitBreakerBackend
    'breaker'  => [
        'failure_threshold'   => 5,   // failures within window to trip
        'failure_window_sec'  => 10,  // sliding window
        'open_seconds'        => 30,  // cooldown before half-open probe
    ],
]);

// Inspect state for ops dashboards:
$b = Store::defaultBackend();
echo $b->state();  // 'closed' | 'open' | 'half-open'

Default (no opt) — no decoration, throws on Redis down (the v0.2.40 behaviour). Reads use the fallback when OPEN; writes always surface the failure to the caller.

H5 — Store::stats() operational visibility

Per-worker counter snapshot — pool acquires, pool acquire timeouts, pool clients created. Pub/sub instances expose pubsub_reconnects_total, pubsub_messages_received_total, pubsub_handler_errors_total via RedisPubSub::stats(). Wire to your monitoring of choice (Prometheus, statsd, plain log line).

// Plumb into a health endpoint:
$app->route('/health/store', fn() => Store::stats());
// → ["pool_acquires_total":1247, "pool_clients_created_total":8, ...]

H6 + H7 — boot-time advisories

  • H6 (ping): when ZEALPHP_STORE_BACKEND=redis, App::run() PINGs the pool once at boot. Failure → loud error_log in the master before workers fork. Misconfigured URL surfaces immediately, not after the first request 5 seconds in.
  • H7 (HOOK_ALL + phpredis): phpredis SUBSCRIBE blocks the worker without OpenSwoole\Runtime::HOOK_ALL — the production default. If you've explicitly disabled HOOK_ALL AND have App::subscribe handlers registered AND phpredis is the resolved driver, boot emits a warning telling you to either re-enable HOOK_ALL or set ZEALPHP_REDIS_PREFER=predis.

H8 — TieredBackend::existsCached()

The strict exists() always hits L2 for consistency. existsCached() returns true when L1 has a fresh entry (within $l1Ttl); otherwise defers to L2. Use on hot paths where "probably exists" + $l1Ttl-bounded staleness is acceptable — saves a Redis round-trip.

H9 + H10 — operational debug

  • H9: PhpredisDriver::close() exceptions now route through elog at debug level instead of being swallowed silently — diagnosable disconnect bugs.
  • H10: RedisPubSub takes an optional $maxAttempts param (default 0 = unlimited, preserving the existing eventually-reconnect behaviour). Set to N>0 to fail loudly after N consecutive reconnect cycles — useful for CI workers that should crash if Redis disappears.

Full review notes + the original risk-by-risk mapping: docs/architecture/2026-05-23-redis-backend-review.md.

When to use Redis / Valkey

Store and Cache cover most single-server apps. Here's when you'll need an external cache.

Built-in Cache is great for

  • Single-server deployments (most apps)
  • Caching API responses, config, computed values
  • Rate limiting and request counting
  • Session-adjacent data (preferences, feature flags)
  • Apps with < 100k cache entries

Move to Redis / Valkey when you need

  • Multi-server shared state — Cache is per-server only
  • Large datasets — memory tier caps at 4096 rows, 8KB/value
  • Pub/Sub messaging — no built-in publish/subscribe between workers or servers
  • Data structures — sorted sets, streams, Lua scripting
  • Crash-safe persistence — Redis AOF/RDB vs best-effort files
  • Eviction policies — no LRU/LFU, full table spills to file
  • Transactions — no MULTI/EXEC, per-row spinlocks only