Skip to content

Solving a Redis Race Condition Without Distributed Locking

Published: June 24, 2026

In Minichat, each user’s read position in a chatroom is tracked by a value called lastReadMessageId. This value is used to calculate unread message counts and display read receipts, and it gets updated every time a user opens a chatroom. For fast response times, it is cached in Redis rather than written directly to the database, and periodically synced to the DB through a Write-Back architecture.

The concurrency issue emerged from how lastReadMessageId is updated. The process reads the current value (GET), compares it with the new message ID, and writes the new value only if it is larger (SET). Because these are separate Redis commands, another request can slip in between — and the result breaks.

TimeRequest A (message 150)Request B (message 120)
T1GET → current value 100
T2GET → current value 100
T3150 > 100 → SET 150
T4120 > 100 → SET 120

Request B compares against the snapshot it read at T2 (100), unaware that the value was already updated to 150 at T3. The final value becomes 120, which no longer reflects the user’s actual read position.

At first, I treated this as a classic race condition.

Two requests could read the same value, compare it independently, and overwrite each other’s updates. Wrapping the GET → compare → SET sequence in a distributed lock seemed like the obvious solution.

// Redisson distributed lock approach
public void updateLastReadMessage(Long userId, Long chatId, Long newMessageId) {
String key = String.format("lastRead:user:%d:chat:%d", userId, chatId);
String lockKey = "lock:" + key;
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock();
String current = redisTemplate.opsForValue().get(key);
if (current == null || newMessageId > Long.parseLong(current)) {
redisTemplate.opsForValue().set(key, String.valueOf(newMessageId));
redisTemplate.opsForSet().add("lastRead:dirty_keys", key);
}
} finally {
lock.unlock();
}
}

dirty set? : To avoid overloading the DB with high-frequency writes, changed keys are collected in Redis first. A background scheduler periodically reads from this set and batch-syncs them to the database (Write-Back architecture).

This approach would solve the race condition. But before deciding to synchronize every request, I stopped and asked a different question.

What exactly is this data?

Am I protecting an arbitrary state update? Or am I protecting something with stronger guarantees?

Analyzing the Data — When a Lock Isn’t Necessary

Section titled “Analyzing the Data — When a Lock Isn’t Necessary”

Minichat generates message IDs using the Snowflake algorithm. Because the timestamp occupies the most significant bits, IDs increase monotonically over time—even when multiple servers generate them independently.

That changed how I looked at lastReadMessageId.

It is not an arbitrary state that moves back and forth. It only moves forward.

Once I looked at it through that lens, the problem stopped being a synchronization problem.

It became a max-value maintenance problem.

The intermediate values no longer mattered. The only invariant was that the stored value should never decrease.

That means the real requirement was not preventing concurrent access. It was making compare-and-update atomic.

Trade-off — Distributed Lock vs Lua Script

Section titled “Trade-off — Distributed Lock vs Lua Script”

With the problem reframed as a max operation, two options were on the table.

A distributed lock guarantees correctness regardless of data characteristics. Any structure wrapped in a critical section is safe. But every request goes through lock acquisition, contention, and release.

A Lua Script eliminates lock overhead entirely — but only works because the data is monotonically increasing. If that invariant ever breaks, the entire strategy becomes invalid.

CriterionDistributed LockLua Script
ApplicabilityAny data patternMonotonic data only
ExecutionLock acquire/releaseSingle EVALSHA
Correctness guaranteeEnsured by lockEnsured by data invariant
RiskDeadlock, lock contentionBreaks if ID design changes

I traded generality for a solution that leveraged the invariant of the data model.

Redis executes Lua Scripts atomically on a single thread. By bundling GET → compare → SET into one script, atomicity is achieved without any lock.

-- KEYS[1]: lastRead:user:{userId}:chat:{chatId}
-- KEYS[2]: lastRead:dirty_keys (dirty set for Write-Back)
-- ARGV[1]: new message ID
local current = redis.call('GET', KEYS[1])
if not current or tonumber(ARGV[1]) > tonumber(current) then
redis.call('SET', KEYS[1], ARGV[1])
redis.call('SADD', KEYS[2], KEYS[1])
return 1
end
return 0
AspectRedisson Distributed LockLua Script
Network round trips5+ (LOCK+GET+SET+SADD+UNLOCK)1 (EVALSHA)
Lock contentionWaits if another request holds the lockNone
Deadlock riskExists (process crash before TTL expires)None
Atomicity mechanismMutual exclusion via lockSerial execution on Redis single thread

Lua Script is not a free optimization. While a Lua Script is running, Redis cannot process any other commands. During script execution, Redis dedicates its single thread to that script. To keep the script as short as possible, the script contains only O(1) operations — GET, compare, SET, and SADD.

Benchmark — Validating the Structural Difference

Section titled “Benchmark — Validating the Structural Difference”

The decision was driven by data analysis, not performance measurement. But I ran a benchmark to confirm the difference in execution characteristics.

Test Setup

  • Total requests: 10,000
  • Concurrent threads: 1 / 10 / 50 / 100
  • Lettuce (Spring Data Redis default) + Redisson

All requests compete for a single key — worst-case lock contention.

MethodThreadsThroughputAvg(ms)P95(ms)P99(ms)
Lua Script1–10028k → 210k0.03→0.470.04→0.670.05→0.83
Redisson Lock1–100~6k0.17→15.780.21→1080.35→234

Requests spread across multiple keys — closer to production conditions.

MethodThreadsThroughputAvg(ms)P95(ms)P99(ms)
Lua Script1–10035k → 208k0.03→0.470.04→0.610.05→0.67
Redisson Lock1–1006k → 15k0.15→6.620.17→140.19→21

The gap was most pronounced under single-key contention. Lock-based approaches saw P95/P99 latency spike dramatically as contention increased, while Lua Script scaled roughly linearly with thread count. Under distributed keys, lock performance improved — but the structural overhead from additional round trips remained.

Same Pattern, Different Layer — Conditional UPDATE

Section titled “Same Pattern, Different Layer — Conditional UPDATE”

Values updated in Redis are eventually synced to the database through Write-Back. The same race condition can occur at the DB layer, so the same pattern was applied.

UPDATE user_chat
SET last_read_message_id = ?
WHERE user_id = ?
AND chat_id = ?
AND (last_read_message_id IS NULL OR last_read_message_id < ?)

The WHERE last_read_message_id < ? clause serves the same role as the Lua Script’s if tonumber(ARGV[1]) > tonumber(current). Because Snowflake IDs guarantee monotonic increase, the same rule maintains correctness regardless of which layer applies it.

LayerDataUpdate Strategy
RedislastReadMessageIdLua Script (conditional compare-and-set)
DBlast_read_message_idConditional UPDATE

I traded generality for a strategy that depends on the data model’s invariant. Because Snowflake IDs guarantee monotonic increase, a lock was never the right tool for this problem. The same compare-and-set rule — “update only if the new value is greater” — carried from Redis through to the database, without synchronization at any layer.