DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

Valkey 9.1's hash field TTL triples memory use for the pattern its own docs show

Valkey 9.0 added the ability to put a TTL on a single field inside a hash, rather than on the whole key. Valkey 9.1 followed up with HSETEX, which sets a field and its expiry in one round trip. The example in Valkey's own blog post is a per-user auth token:

HSETEX user:123 EX 900 FIELDS 1 auth_token "eyJhbGciOiJ..."
Enter fullscreen mode Exit fullscreen mode

That reads like a drop-in replacement for SET user:123:auth_token "..." EX 900. I ran both patterns against 20,000 keys and measured memory with INFO memory. The hash version used three times as much RAM as the plain string.

Setup

I ran Valkey 9.1.2 in Docker on a single host, nothing exotic:

$ docker run -d --name valkey9 -p 6399:6379 valkey/valkey:9.1
$ docker exec valkey9 valkey-server --version
Valkey server v=9.1.2 sha=00000000:0 malloc=jemalloc-5.3.0 bits=64
Enter fullscreen mode Exit fullscreen mode

All measurements below are the fastest of three runs unless noted, using redis-py 8.1.0 talking to the container over the exposed port. For the valkey-cli snippets I aliased valkey-cli to docker exec valkey9 valkey-cli so the commands read the same as they would against a local install.

The headline number

I built three versions of "one item, one expiring value" for 20,000 items: a hash per item with one field carrying a TTL (the pattern in the docs), a hash per item with no TTL, and a plain string key with EXPIRE.

Pattern Bytes used Bytes / key Encoding
Hash per item, field TTL set (HSETEX) 5,226,560 261.33 hashtable
Hash per item, no TTL (HSET) 1,754,720 87.74 listpack
Plain string + EXPIRE 1,733,680 86.68 n/a

Giving a single field a TTL costs almost exactly the same extra memory as switching the whole hash's encoding from listpack to hashtable. It is not a small tax on top of the hash: it roughly triples it, and it lands you back at the same memory cost as the string-based pattern the feature was meant to improve on, except worse, because now you are also paying the hash's own bookkeeping.

I checked encoding directly to confirm this was the cause:

$ valkey-cli DEL h3
$ valkey-cli HSET h3 x 1
$ valkey-cli OBJECT ENCODING h3
listpack
$ valkey-cli HEXPIRE h3 100 FIELDS 1 x
(integer) 1
$ valkey-cli OBJECT ENCODING h3
hashtable
Enter fullscreen mode Exit fullscreen mode

hash-max-listpack-entries was still the default 512 the whole time. It made no difference: the very first field TTL set on a hash forces it out of listpack and into hashtable, regardless of how few fields it has or how small they are. This is a known, tracked gap: Valkey issue #2618 describes exactly this and proposes encoding the expiry inside the listpack itself for small hashes, but as of 9.1.2 that has not landed.

The pattern that actually works

The docs' one-hash-per-item example is the wrong shape for this feature. It is meant for hashes that already hold several fields, where only some carry a TTL, not for a hash that exists solely to wrap one expiring value.

I tested that shape instead: one hash, 20,000 fields, each field set through HSETEX with its own TTL, against the same hash with the same 20,000 fields set without any TTL.

Pattern Bytes used Bytes / field Encoding
One hash, 20,000 fields, each with a TTL 1,563,728 78.19 hashtable
One hash, 20,000 fields, no TTL 1,095,056 54.75 hashtable

Both end up hashtable-encoded regardless, because 20,000 fields is well past the 512-entry listpack ceiling anyway. The difference here, 23.44 bytes per field, is the actual cost of tracking an expiry once you are already paying for hashtable encoding. That lines up with the 16-to-29-byte range the Valkey maintainers cite in the same GitHub issue. Used this way, the feature does what it says: cheap per-field TTLs on a shared structure.

The lesson is really about encoding, not about the TTL feature being expensive. If your hash already has enough fields to sit in hashtable encoding, field TTLs are close to free. If your hash is small enough to want listpack, adding a single TTL field takes that away from you completely.

Reads are not slower

Valkey's blog claims the tracking structure behind field TTLs does not degrade normal hash operations. I benchmarked plain HGET against a 5,000-field hash where no field had a TTL, then against an identical hash where every field did:

Hash HGET throughput Latency
No TTL on any field 4,885 ops/s 204.7 us
TTL on every field 4,889 ops/s 204.6 us

Three runs each, same spread. No measurable difference. This is the one claim in the marketing that held up exactly as stated. The per-op latency here is dominated by the network round trip through Docker's port mapping, not by anything server-side, but the comparison is apples to apples and the two numbers are indistinguishable.

What it refuses

A few error paths worth knowing before you hit them in production:

$ valkey-cli SET plainstring hello
OK
$ valkey-cli HEXPIRE plainstring 100 FIELDS 1 f1
(error) WRONGTYPE Operation against a key holding the wrong kind of value

$ valkey-cli HEXPIRE h5 -5 FIELDS 1 a
(error) ERR invalid expire time in 'hexpire' command

$ valkey-cli HEXPIRE h5 100 NX XX FIELDS 1 a
(error) ERR NX and XX, GT or LT options at the same time are not compatible

$ valkey-cli HEXPIRE h5 100 FIELDS 0
(error) ERR wrong number of arguments for 'hexpire' command
Enter fullscreen mode Exit fullscreen mode

HEXPIRE on a field that does not exist, or on a hash that does not exist, returns -2 rather than an error, matching plain TTL's convention. HEXPIRE ... GT against a field that has no TTL (infinite, by convention) is a documented no-op: it returns 0 and leaves the field persistent, because "greater than infinite" can never be true.

Concurrency does not change the picture

I ran 10 threads, 5,000 HSETEX calls each, pipelined in batches of 500, against three layouts: one hash key per thread, one shared hash key for all ten threads, and 5,000 independent string keys per thread with SET ... EX.

Layout Throughput
One hash per thread 64,800-68,900 ops/s
All threads on one shared hash 61,700-68,000 ops/s
Independent string keys 61,900-62,600 ops/s

Across three runs each, the numbers overlap within noise. I expected the single shared hash to show contention against the per-thread hashes. It did not, because Valkey executes commands on one thread regardless of how many client connections are pushing at it; there is no per-key lock to contend over in the first place. If you were worried that concentrating expiring fields into one big hash creates a hot-key bottleneck under load, this test did not find one at this scale.

Field TTLs survive a restart, and expire on schedule

I set a field TTL, forced a background save, and restarted the container outright rather than trusting DEBUG RELOAD (which this image disables by default under enable-debug-command no):

$ valkey-cli HTTL persisthash FIELDS 1 tok
(integer) 292
$ docker restart valkey9
$ valkey-cli HTTL persisthash FIELDS 1 tok
(integer) 288
Enter fullscreen mode Exit fullscreen mode

The TTL survived the RDB round trip and kept counting down rather than resetting, which is the behaviour you want and not something I'd have bet on without checking.

For timing, I set 2,000 fields in one hash to a 2-second TTL and polled HLEN every 200ms:

t=2.01s hlen=2000
t=2.21s hlen=0
Enter fullscreen mode Exit fullscreen mode

All 2,000 fields disappeared in the same 200ms polling window, not gradually. INFO stats backed this up:

expired_fields:2000
expire_cycle_cpu_milliseconds:3
Enter fullscreen mode Exit fullscreen mode

That is the number to watch in production, not a guess I made. There is no expired_subkeys stat, despite what you might expect from the naming used elsewhere in Redis-family docs; the counter is called expired_fields.

What I got wrong on the way

My first pass at measuring expiry precision checked INFO stats for a field called expired_subkeys, because that's the name I'd seen used informally when this feature was being discussed. It does not exist. r.info('stats').get('expired_subkeys') silently returned None both before and after 2,000 fields expired, and for a few minutes I nearly wrote down "no visible counter for field expiry" as a finding. The actual field is expired_fields, sitting right there in the same INFO stats block. Grepping the raw output instead of asking for one named key by guesswork caught it. It's a good reminder that a None from a stats API is not evidence of absence, it's evidence you guessed the wrong key.

Run it yourself

Start Valkey 9.1 and confirm the encoding transition directly:

docker run -d --name valkey9 -p 6399:6379 valkey/valkey:9.1
docker exec valkey9 valkey-cli HSET h a 1
docker exec valkey9 valkey-cli OBJECT ENCODING h        # listpack
docker exec valkey9 valkey-cli HEXPIRE h 100 FIELDS 1 a
docker exec valkey9 valkey-cli OBJECT ENCODING h        # hashtable
Enter fullscreen mode Exit fullscreen mode

The memory comparison, using redis-py (pip install redis):

import redis, time

r = redis.Redis(host='localhost', port=6399, decode_responses=True)
N = 20000

def used_mem():
    return int(r.info('memory')['used_memory'])

r.flushall(); time.sleep(0.3)
base = used_mem()
pipe = r.pipeline(transaction=False)
for i in range(N):
    pipe.execute_command("HSETEX", f"h:ttl:{i}", "EX", "600", "FIELDS", "1", "tok", "x" * 20)
pipe.execute(); time.sleep(0.3)
print("hash-per-item w/ field TTL:", (used_mem() - base) / N, "bytes/key")

r.flushall(); time.sleep(0.3)
base = used_mem()
pipe = r.pipeline(transaction=False)
for i in range(N):
    pipe.set(f"s:ttl:{i}", "x" * 20, ex=600)
pipe.execute(); time.sleep(0.3)
print("string + EXPIRE:", (used_mem() - base) / N, "bytes/key")
Enter fullscreen mode Exit fullscreen mode

On my run this printed 262.4 bytes/key for the hash version against 85.6 for the string version, matching the table above within normal run-to-run variance.

What to do with this

If you are storing one expiring value per logical item, the string-plus-EXPIRE pattern Valkey has always had is still cheaper than the new hash field TTL commands, because a lone TTL field forces the whole hash into hashtable encoding. Reach for HEXPIRE/HSETEX when you already have a multi-field hash and only some of its fields need to expire; that is the case where the per-field overhead is a genuinely small 16-to-29 bytes and the feature earns its keep. Before you migrate a per-user token cache to the pattern shown in the release notes, run the OBJECT ENCODING check above against your own field sizes and counts, because the answer depends entirely on how many fields already live in that hash.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.