A friend recently came to me with a problem.
They had designed a feature to track whether each user had read the latest posts in each category (a tab in the app).
The first design looked simple: store a row in MySQL for every (user_id, category_id)
pair and update it whenever something changed.
But in production, things broke badly.
The initial design
Their schema looked like this:
category_user_read (
user_id BIGINT,
category_id BIGINT,
read BOOLEAN,
PRIMARY KEY (user_id, category_id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
The logic was:
When a user opened a category, update their row with
read = true
.When a new post was added, update all rows in that category with
read = false
.
Simple enough, right?
Where it failed
In practice, this design was a database bottleneck:
- Every page view triggered an update.
- Every new post triggered a mass update across potentially millions of rows.
- InnoDB placed exclusive row locks for each update.
- The foreign key to users meant even more locking, since InnoDB had to check referential integrity.
With thousands of concurrent users, the table essentially turned into a global contention point.
Clarified requirements
After digging deeper, I asked the team what the product really needed. It turned out the requirement was much simpler:
- Just show a red dot on the tab if the user hasn’t read the category yet.
- No need to track the exact “last read post ID”.
- The data isn’t critical — it can tolerate some loss.
- But it must be fast and able to scale with millions of users.
- That clarification changed everything about the design.
The Redis Bitmap approach
I suggested they throw away the heavy MySQL design and move the state into Redis bitmaps:
Think of it as an array of 0 and 1 values, where each position in the array corresponds to a user_id.
- One bitmap per category.
- We simply use the user_id number as the index in the bit array.
- This makes the implementation extremely simple.
- The trade-off is that if user IDs are sparse (e.g. max ID = 10M but only 1M active users), the bitmap may be larger than necessary.
- But in practice, this overhead is small compared to the simplicity gained.
Example:
SETBIT category:{category_id}:read {user_id} 1
GETBIT category:{category_id}:read {user_id}
- When a user opens a category → set their bit to 1.
- When a new post is added → Simply delete the bitmap key for that category
DEL category:{category_id}:read
Result & scalibility
- Blazing fast: Bitmap operations are constant time (O(1)), with no locking overhead.
- Tiny memory footprint:
- 1 million users → 1 million bits → ~122 KB.
- Even with tens of millions of users, the memory cost stays in MBs.
- Scales naturally: No matter how many users open categories concurrently, Redis handles it without contention.
Top comments (0)