DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

Redis 8.12 RC2's SEXPIRE command cuts a set membership check from two round trips to one

A Redis set has always had one expiry for the whole key. If you wanted one member of a set to disappear on its own, say a session token in a set of active sessions, or a temporary grant in a set of permissions, you built a side index yourself: a sorted set with the expiry as the score, checked on every read, swept by a cron job. Redis 8.12, currently at release candidate 2, adds that directly to the set type. I built it from source, since there's no Docker image for an RC yet, and measured what changes when the server does this instead of your application code.

The new commands are SEXPIRE, SPEXPIRE, SEXPIREAT, SPEXPIREAT to set an expiry on specific members, SPERSIST to clear it, and STTL, SPTTL, SEXPIRETIME, SPEXPIRETIME to read it back. There's no official command reference page for any of them yet; I pieced the syntax together from COMMAND DOCS and the release notes.

The headline number

The actual value of this feature isn't in setting the expiry. It's in checking membership afterwards. Under the old pattern, "is this member still valid" means SISMEMBER plus a ZSCORE lookup against your own expiry index, compared against the current time client-side. Under the new one, SISMEMBER alone already reflects the TTL, because the server has removed the member once it expired.

I populated two 2,000-member sets, gave every member the same TTL, then ran 2,000 validity checks against each pattern, three times:

run old pattern (SISMEMBER + ZSCORE) new pattern (SISMEMBER alone) difference
1 997.7 ms 475.8 ms 52.3% faster
2 889.0 ms 444.6 ms 50.0% faster
3 994.3 ms 423.6 ms 57.4% faster

That's one round trip removed from every single read, and it held steady across all three runs: roughly half the time, every time. On loopback, where a round trip is already cheap, that's still a 50% cut. Over a real network to a managed Redis instance, removing a round trip matters more, not less, though I only measured loopback here and I'm not going to claim a number I didn't run.

The number that argues against it

Setting the expiry doesn't get any faster. I benchmarked writing the TTL itself, 2,000 members, three runs, old pattern being a plain ZADD into a side index versus the new SEXPIRE call:

run old pattern (ZADD to a side index) new pattern (SEXPIRE) difference
1 497.2 ms 506.7 ms 1.9% slower
2 430.0 ms 438.9 ms 2.1% slower
3 483.7 ms 599.7 ms 24.0% slower

SEXPIRE was never faster to call than a plain ZADD, and in one run it was a quarter slower. That's not surprising once you know what it has to do: find the member inside the set's internal structure, possibly convert the set's whole encoding to accommodate per-member metadata, and schedule it for the active-expiry cycle. A ZADD into an unrelated sorted set does none of that. The saving in this feature is entirely on the read side, where you stop needing the side index at all. If your workload only ever writes expiring members and never re-checks them, this feature costs you a little and gives nothing back.

What it costs

Nobody mentions this part. A set that has never had a member TTL stores members as compactly as possible: a plain intset for small sets of integers, a listpack for small sets of short strings. The moment any member gets a TTL, the set needs a separate expiry slot for every member, TTL or not, and the encoding changes to carry that.

For a listpack-encoded set of short strings, the conversion to listpackex cost 42-75% more memory, worse for smaller sets since the per-entry overhead is proportionally larger:

set size before after SEXPIRE on one member overhead
10 members 70 bytes 122 bytes +74.3%
50 members 270 bytes 402 bytes +48.9%
100 members 521 bytes 753 bytes +44.5%
128 members 689 bytes 977 bytes +41.8%

That's expected, if undocumented. What I didn't expect: a small intset, 300 plain integers, has no TTL-aware equivalent at all. Giving one member a TTL doesn't convert it to some integer-specific expiring encoding, it jumps straight to a full hashtable:

encoding before: intset, 636 bytes
encoding after SEXPIRE on one member: hashtable, 10388 bytes
delta: +9752 bytes (+1533.3%)
Enter fullscreen mode Exit fullscreen mode

A 15-times memory increase, for giving one out of 300 integers an expiry. And it doesn't come back. I called SPERSIST to remove the TTL again, and the set stayed on listpackex/hashtable rather than reverting to listpack/intset:

> SPERSIST t MEMBERS 1 a
(integer) 1
> OBJECT ENCODING t
"listpackex"
Enter fullscreen mode Exit fullscreen mode

If you're going to use this on a set that's mostly stable and rarely touched by expiry, know that touching it once is permanent, memory-wise, until the key is rewritten some other way.

What it refuses

SEXPIRE won't take a negative TTL, which is a real departure from the key-level EXPIRE command, where a negative or past expiry deletes the key immediately:

> SEXPIRE myset -1 MEMBERS 1 x
(error) ERR invalid expire time, must be >= 0
Enter fullscreen mode Exit fullscreen mode

If you want to delete a member immediately, you still use SREM; SEXPIRE with 0 works and deletes it on the next expiry pass, but a negative value is simply rejected. It also refuses to run on the wrong type:

> SET strkey hello
> SEXPIRE strkey 100 MEMBERS 1 x
(error) WRONGTYPE Operation against a key holding the wrong kind of value
Enter fullscreen mode Exit fullscreen mode

And a nonexistent key or nonexistent member both return -2 from STTL, same as the hash-field TTL commands that came before this.

Under concurrency

I fired fifty concurrent SEXPIRE calls at the same member from fifty threads, each setting a different TTL. No errors, no corruption, and the final TTL matched whichever call the server happened to execute last, since Redis serialises command execution regardless of how many clients are connected. This isn't really a test of the feature so much as a reminder that the usual Redis guarantee holds here too: nothing about per-member TTLs introduces a new race inside the server. The race that matters is the one on your side, between reading a member and acting on it, and that hasn't changed.

A documented claim, checked

I couldn't find a documented statement about whether SINTERSTORE and SUNIONSTORE preserve member TTLs on the destination set, so I checked directly. They do:

> SADD s1 x y z
> SEXPIRE s1 100 MEMBERS 1 x
> SINTERSTORE dest s1
> STTL dest MEMBERS 1 x
(integer) 100
Enter fullscreen mode Exit fullscreen mode

The TTL on x carried through to dest with the same remaining time. I'd half expected a store operation to rebuild the set fresh and drop per-member metadata the way some operations silently reset a counter or a type. It doesn't.

Watching it in production

INFO stats has two new counters: expired_subkeys and expired_subkeys_active. INFO keyspace grew a subexpiry field showing how many keys in the database currently have at least one member or field with a TTL. I set a short TTL on two members of a five-member set, never read the key again, and watched the counters change on their own:

keyspace right after:  subexpiry=1
expired_subkeys_active before: 3
(... 3 second wait, no access to the key at all ...)
expired_subkeys_active after:  5
keyspace after:  subexpiry=0
Enter fullscreen mode Exit fullscreen mode

That confirms the background active-expiry cycle genuinely sweeps expired set members without anything ever reading the key, which matters if your workload sets TTLs on keys it then rarely touches. Without that active sweep, memory for expired-but-unread members would sit around until something happened to read the key and trigger lazy cleanup.

What I got wrong on the way

I tried to check whether SINTERSTORE preserves TTLs by running SINTERSTORE dest 1 s1, copying the numkeys argument style from SINTERCARD. The result came back as an empty set every time, with or without a TTL anywhere near it. I nearly wrote that down as "SINTERSTORE silently drops everything when any source member has a TTL", which would have been a dramatic finding and also completely wrong.

SINTERSTORE doesn't take a numkeys argument at all; its syntax is just SINTERSTORE destination key [key ...]. My 1 was being read as a literal key named "1", which doesn't exist, so the intersection was always empty, TTL or no TTL. Running the same broken command against a set with zero TTL activity produced the identical empty result, which is what gave it away: a real TTL-related bug wouldn't also break on a completely untouched set.

Run it yourself

There's no Docker image for 8.12 RC2 yet, so building from source is the fastest path:

git clone --depth 1 --branch 8.12-rc2 https://github.com/redis/redis.git
cd redis && make -j4 BUILD_TLS=no
./src/redis-server --daemonize yes --save "" --port 6379
Enter fullscreen mode Exit fullscreen mode

The commands behind the numbers above, run against that server:

redis-cli SADD myset a b c
redis-cli SEXPIRE myset 100 MEMBERS 1 a
redis-cli STTL myset MEMBERS 1 a        # 100
redis-cli STTL myset MEMBERS 1 b        # -1, no TTL set
redis-cli OBJECT ENCODING myset         # listpackex, once any member has a TTL
Enter fullscreen mode Exit fullscreen mode

The two timing benchmarks, with redis-py:

import redis, time

r = redis.Redis(port=6379)
N = 2000

r.sadd('newset', *[f'm{i}' for i in range(N)])
for i in range(N):
    r.execute_command('SEXPIRE', 'newset', 600, 'MEMBERS', 1, f'm{i}')

r.sadd('oldset', *[f'm{i}' for i in range(N)])
now = time.time()
for i in range(N):
    r.zadd('oldset:idx', {f'm{i}': now + 600})

t0 = time.perf_counter()
for i in range(N):
    r.sismember('newset', f'm{i}')
print('native:', time.perf_counter() - t0)

t0 = time.perf_counter()
for i in range(N):
    r.sismember('oldset', f'm{i}')
    r.zscore('oldset:idx', f'm{i}')
print('side-index:', time.perf_counter() - t0)
Enter fullscreen mode Exit fullscreen mode

If you're maintaining a side index purely to expire individual set members, and you check membership more often than you set it, moving to SEXPIRE on 8.12 removes a round trip from every read for roughly double the read throughput, at the cost of a permanent jump in the set's memory footprint and a write path that's no faster than what you had before. Check which of those two costs you more before switching, and don't bother if you're only ever setting expiries and never checking them back.

Top comments (0)