0% completed
2.2 String Counters, Objects, and Locks
On This Page
Counters
Why Strings Make Good Counters
Fixed Window Rate Limiting
Other Counter Patterns
Counter Caveats
Serialized Objects
Caching Whole Objects
Choosing a Format
The Limits of the Whole-Object Approach
Cache Patterns Using Strings
Flags and Small State
Feature Flags and Switches
Locks and Idempotency Keys
Sessions and Tokens
Bitmaps as Packed Flags
Pitfalls
Using This in an Interview
FAQs
Key Takeaways
Most string keys in a production deployment do one of three jobs: they count, they cache, or they coordinate. The previous lesson showed the commands behind all three. A string is a binary-safe value, SET combines a condition, an expiry, and the write in one atomic command, and INCR and INCREX change a number without any lock in the application.
This lesson applies those commands to the same product catalog. It counts views of product 981 and limits how often user 42 can call the API, caches the product as a serialized object, and uses short-lived strings as flags, locks, sessions, and one-time tokens.
Counters
Why Strings Make Good Counters
A counter needs three things: atomic increments, fast reads, and optional expiry. Strings provide all three, and a counter stored with the int encoding costs only the key overhead plus a few bytes. Common uses are page views, likes, ID generation, and per-user event counts.
Fixed Window Rate Limiting
The simplest rate limiter builds a key from the client and the current time window, such as rate:42:1430 for user 42 in the minute that starts at 14:30, and counts requests in it. Suppose the limit is 100 requests a minute. Since Redis 8.8, one command increments the counter, refuses to go past 100, and sets a 60-second TTL only when the window's key is first created:
redis> INCREX rate:42:1430 BYINT 1 UBOUND 100 EX 60 ENX 1) (integer) 1 2) (integer) 1
The second reply value is the increment that was applied. When the counter already holds 100, the reply is 100 and 0, which means the request is rejected and the counter is unchanged. ENX sets the expiry only if the key has none, so later requests in the same minute do not push the window's end further out.
Older versions need two commands, INCR and then EXPIRE, and the pair is not atomic: if the client dies between them, the counter never expires and the user stays blocked. Two fixes work. You can send INCR and EXPIRE key 60 NX (the NX option on EXPIRE arrived in Redis 7.0) inside a MULTI/EXEC transaction, or run both commands in a short Lua script.
Every fixed window has a boundary effect. A client can send 100 requests at the end of one minute and 100 more at the start of the next, so it briefly reaches twice the limit. Sliding windows built on sorted sets, or token buckets in scripts, smooth this out at a higher cost in memory and CPU. The Distributed Rate Limiting lesson compares these designs.
Other Counter Patterns
INCR on a sequence key generates unique, ordered IDs. At very high rates that one key receives every write, so each client can instead reserve a block of IDs with INCRBY seq 1000 and hand them out locally.
Inventory control needs a decrement that never goes below zero. Since Redis 8.8, INCREX stock:981 BYINT -1 LBOUND 0 does this atomically: when stock is 0 it returns an applied increment of 0 and leaves the value alone. On older versions, DECR followed by a check for a negative result and a compensating INCR works, but other clients can briefly see a negative value, so a Lua script that checks and decrements in one step is the safer choice.
A counter that receives a very high write rate can be split across several keys, such as views:product:981:0 to views:product:981:7, with each write going to one key chosen at random and each read summing all of them.
Counter Caveats
Counters are only as durable as the persistence settings. By default Redis keeps only periodic snapshots (RDB), so a crash can lose every increment since the last snapshot, which can be several minutes of updates. With the append-only file (AOF) turned on at its default of one fsync per second, a crash loses about the last second of increments. A counter also drifts from the source of truth if the cache is rebuilt. For counts that must be exact and durable, treat Redis as the fast front end and reconcile with the database periodically.
Serialized Objects
Caching Whole Objects
A very common pattern stores an entire object as one string, serialized as JSON, MessagePack, or protobuf, under a key such as product:981, with a TTL. A read is one GET followed by deserialization in the client. The approach is simple and fast, and it fits well when the object is almost always read and written as a unit.
Choosing a Format
JSON is readable and supported everywhere, but it is larger and slower to parse. MessagePack and protobuf are more compact and faster to decode. Compressing large values with LZ4 or zstd saves memory and network bandwidth at some CPU cost.
Version the format, either with a version segment in the key such as product:v2:981 or with a field in the payload. Otherwise a deploy that changes the format can read old cached values that the new code cannot parse.
The Limits of the Whole-Object Approach
Updating one field of a cached object means reading, deserializing, changing, serializing, and writing the whole value. That wastes bandwidth and CPU, and it creates a race: if two writers do it at the same time, the second write replaces the first writer's change. A hash avoids both problems, because it supports field-level reads, writes, and atomic field increments, and it stores small objects compactly.
A reasonable guideline is to use strings for objects that are replaced as a whole and rarely change in parts, and hashes for objects whose fields change independently. The Hashes lesson compares the two in detail.
Cache Patterns Using Strings
In cache-aside, the application reads the key first. On a miss, it loads the object from the database and writes it with SET product:981 <json> EX 300, as the picture below shows step by step.
When a popular key expires, many requests can miss at the same moment and all query the database together, which is called a cache stampede. Three defenses are common: add random jitter to TTLs so keys do not expire together, take a short lock with SET lock:product:981 <token> NX EX 5 so only one caller rebuilds the value, or serve the old value while one caller refreshes it. Negative caching stores a short-lived marker for "not found" results, so repeated lookups for missing products do not reach the database each time.
Flags and Small State
Feature Flags and Switches
A string holding a short value such as on, off, or a percentage works as a feature flag or kill switch, and every service can read it with one GET. A change is visible on the next read. If services keep a local copy, you can publish a message with Pub/Sub so each instance knows to refresh it.
Locks and Idempotency Keys
SET lock:restock:981 <token> NX PX 30000 acquires a lock that expires automatically after 30 seconds if the holder dies. The value must be a token unique to this client and this attempt, so that release can check ownership. Since Redis 8.4, DELEX lock:restock:981 IFEQ <token> deletes the key only if it still holds that token. On earlier versions, a short Lua script performs the same check and delete. A plain DEL is unsafe, because if the lock already expired and another client took it, DEL removes the other client's lock. Valkey 9.0 offers its own command for this, DELIFEQ.
The same pattern implements idempotency keys: the first request sets the key with NX and proceeds, and a duplicate request finds the key already present. A lock on a single instance is adequate when it only prevents duplicate work. Strict mutual exclusion across failures, failovers, and process pauses needs more care, which the Distributed Locks lesson covers.
Sessions and Tokens
A session ID maps to serialized session data or to a user ID, with a TTL that moves forward on each request through GETEX key EX 1800 or an EXPIRE after each read. One-time tokens, such as password reset codes, use GETDEL, so a code cannot be used twice even if two requests arrive at the same moment.
Bitmaps as Packed Flags
When flags belong to integer IDs, such as whether user number N was active today, a string used as a bitmap stores one bit per ID. One hundred million flags then take 100,000,000 / 8 bytes, which is 12.5 MB.
Pitfalls
- Read-modify-write races. A sequence of separate commands that reads, changes, and writes a value can interleave with other clients. Use the INCR family, INCREX, a Lua script, or a transaction with WATCH.
- Keys without expiry. Every disposable string needs a TTL, or memory grows slowly until it runs out.
- Assuming durability. Counters and cached objects can be lost in a crash, depending on persistence settings.
Using This in an Interview
Present strings as the building block for counters, cached objects, locks, and tokens, each with a TTL set in the same command as the write. Then name the limits: a fixed window can briefly allow twice the limit at its boundary, and counters are only as durable as the persistence settings.
How do you release a lock safely? Store a unique token and delete only if the value still matches, with DELEX key IFEQ token on Redis 8.4 or later, or a Lua script on older versions.
How do you stop a cache stampede on a popular key? Add random jitter to TTLs, or let one caller rebuild the value under a short SET ... NX EX lock while the others serve the old value.
FAQs
Should I store objects as JSON strings or hashes?
Use a string when you read and replace the whole object. Use a hash when fields change independently, when you need partial reads, or when you want atomic field operations and compact storage for small objects.
How do I make a counter that resets every minute?
Put the minute in the key and use INCREX key BYINT 1 EX 60 ENX on Redis 8.8 or later. On older versions, run INCR and EXPIRE key 60 NX inside MULTI and EXEC, or use a short Lua script.
Key Takeaways
- Serialize objects deliberately. Choose a compact, versioned format, add TTLs with jitter, and prefer hashes when fields change independently.
- Flags, locks, tokens, and idempotency keys are small strings with TTLs. Use unique tokens and an ownership check (DELEX IFEQ or a script) for locks, and GETDEL for one-time values.
- Counters are only as durable as the persistence settings. A crash can lose recent increments, so reconcile counts that must be exact with the database.
Reading Progress
0%
On This Page
Counters
Why Strings Make Good Counters
Fixed Window Rate Limiting
Other Counter Patterns
Counter Caveats
Serialized Objects
Caching Whole Objects
Choosing a Format
The Limits of the Whole-Object Approach
Cache Patterns Using Strings
Flags and Small State
Feature Flags and Switches
Locks and Idempotency Keys
Sessions and Tokens
Bitmaps as Packed Flags
Pitfalls
Using This in an Interview
FAQs
Key Takeaways