Skip to content

Redis Sorted Set

Redis sorted set — fixing Option B's read path

Option B gives clean writes but no sort order. A Redis sorted set sits in front of the DB and answers "top K conversations" without touching the DB at all.


The gap Option B leaves

With SK = conv_id, rows never move — writes are clean. But the DB has no way to return conversations sorted by recency. To find Alice's top 20 most recent conversations you'd have to fetch all 500 and sort in memory — 250B DB reads per day.

The fix: maintain a sorted index separately, in Redis. The DB stores the full conversation data. Redis stores only the sort order.


The sorted set structure

Redis sorted set:
  key:    inbox:alice
  member: conv_id
  score:  last_message_timestamp (unix ms)

Every conversation Alice has is a member. The score is the timestamp of the last message in that conversation. Redis keeps members sorted by score automatically — highest score (most recent) at the top.

The score is timestamp only — not unread count or anything else. The sorted set answers one question: which conversations are most recent? Unread count, last message preview, avatar — all of that lives in the DB row and gets fetched once you know which conversations to show.


Write path — message arrives

Bob sends Alice a message:

→ Write 1: UPDATE conversations[alice][conv_bob]
           SET last_ts = 9:55am, last_message = "on my way", unread_count += 1
           (DynamoDB — durable, attribute update, no tombstone)

→ Write 2: ZADD inbox:alice 1705312500000 conv_bob
           (Redis — updates score in place if conv_bob already exists)

Two writes. Both cheap. The Redis ZADD either inserts conv_bob as a new member or updates its score — no delete, no tombstone.


Read path — Alice opens inbox

Step 1: ZREVRANGE inbox:alice 0 19
        → Redis returns top 20 conv_ids, sorted by score descending
        → runs entirely in RAM, O(log N + 20)

Step 2: Batch fetch from DynamoDB
        GET conversations WHERE PK = alice AND SK IN [conv_bob, conv_carol, ...]
        → 20 rows, each with last_message, last_ts, unread_count, avatar

Step 3: Return to client
        → client renders inbox

Two steps. Redis answers the sort question instantly from RAM. DB serves exactly 20 rows — no waste.


What this looks like end to end

                    Option B alone          Option B + Redis
─────────────────────────────────────────────────────────────────
Write cost          1 attribute update      1 DB update + 1 Redis ZADD
Tombstones          zero                    zero
Inbox read          fetch all 500 rows      ZREVRANGE → fetch 20 rows
DB reads/day        250B                    10B (500M users × 20 rows)
Sort done by        app server (memory)     Redis (in place)

The extra Redis write on every message send is the price. The payoff is inbox load goes from 500 DB reads to 20.


Why unread_count is not the score

Sorted sets have one score per member. If unread_count were the score, conversations would be ordered by how many unread messages they have — not by recency. Alice's inbox would show the conversation with the most unread messages at the top, not the most recent one.

Unread count is an attribute — it belongs in the DB row where it can be incremented and reset independently. The score is purely for sort order.

Interview framing

"Option B's stable rows fix the write path but leave no sort order. A Redis sorted set fills that gap — member is conv_id, score is last_message_timestamp. On every message send, ZADD updates the score in place. On inbox load, ZREVRANGE returns the top 20 conv_ids from RAM in microseconds, then a batch DB fetch gets the full row data for exactly those 20 conversations. No tombstones. No wasted DB reads."