← Back to the library

daily EDITION · EPISODE — · 5:43

Redis Lock Expired: Why Seat 14C Sold Twice

Two buyers got a confirmation for seat 14C, and both servers say they held the Redis lock. Maya and Alex follow one paused server past its lock's expiry, decide where the second booking has to be refused, and ask what the lock is still for.

“Both held the lock?” A Redis lock beside two confirmation emails, Lena's and Omar's, both for seat 14C. Maya reacts in the Development Radio studio.

PRESS PLAY. PULL UP A CHAIR.

Watch the video edition on YouTube ↗

In this conversation

Maya and Alex follow one paused server past its Redis lock's expiry, move the guarantee into the Postgres write, and decide what the lock is still for.

TAKE IT INTO YOUR WEEK

One thing to try.

Pick one lock in your system and ask what breaks if two holders overlap. If the answer is money or a promise, make the write itself refuse the second holder (a conditional update or a fencing token), then force the overlap in a test by pausing the first holder past the lock's expiry.

Follow the thinking

The original references behind this conversation.

Read the timed transcript

00:00:00 — Alex
Hey, welcome to Development Radio. I'm Alex, and today's story is one concert seat that sold twice. With a lock on it.

00:00:08 — Maya
I'm Maya. The seat is 14C. Two people clicked Buy. Two people got a confirmation email. Same concert, same seat. Two emails, one seat. And both of our servers say they were holding the lock when they booked it.

00:00:23 — Alex
Which is the one thing that lock was there to prevent. So that's our question today. If the lock didn't stop the second booking, what should have?

00:00:31 — Maya
Let's find out before the doors open. We'll build the smallest version. A ticketing app with two API servers, A and B. Bookings live in Postgres. Before a server books a seat, it takes a lock for that seat in Redis: set the key only if nobody else has it, with a ten-second expiry. Then it checks the seat is free, writes the booking, sends the email, and releases the lock.

00:00:55 — Alex
The expiry is there so a crashed server can't hold 14C forever. That's a normal lock. So what did server A do with its ten seconds?

00:01:04 — Maya
Lena clicks Buy, and her request lands on A. A takes the lock and checks Postgres: 14C is free. Then A stops. Say a long garbage-collection pause, twelve seconds. At ten seconds, Redis expires the lock. Omar clicks Buy on server B. B takes the lock, sees 14C free, books it for Omar, sends his email and releases.

00:01:28 — Alex
And then A wakes up in the middle of its own sentence. It already checked the seat. From where A stands, no time has passed.

00:01:35 — Maya
So it writes Lena's booking over Omar's and sends her email. Postgres says Lena. Omar's inbox says Omar. On concert night, they both walk up to 14C. Every step was correct when it ran.

00:01:48 — Alex
That's the part that bothers me. Nothing failed loudly. The lock just ended during A's work, and nobody told A. Could A have noticed?

00:01:57 — Maya
My first instinct is to have A check, right before writing, that its value is still on the key in Redis. But the pause can land between that check and the write. Same problem, smaller window.

00:02:09 — Alex
Any check A makes is a fact about the past by the time its write lands. What about the expiry itself? Thirty seconds instead of ten?

00:02:17 — Maya
That moves the line. A thirty-one-second pause does the same thing. And if A crashes for real, 14C stays locked for up to thirty seconds while buyers wait. I don't love paying that for a guarantee we still don't get.

00:02:31 — Alex
And the part we were proud of doesn't save us either. We only delete the key if it still holds our value.

00:02:37 — Maya
That protects the lock. When A wakes up, it can't delete somebody else's lock by mistake. It does nothing for the seat. A's write to Postgres never asks Redis anything.

00:02:48 — Alex
Then let's ask who sees both bookings arrive. Not Redis. Not A.

00:02:53 — Maya
Postgres. So Postgres is the one that has to say no. Update seat 14C, set the buyer to Lena, only where the buyer is still empty. Omar's booking committed first, so A's update matches zero rows.

00:03:06 — Alex
So A gets an answer it didn't expect: zero rows. What does Lena see?

00:03:11 — Maya
Not a confirmation. Zero rows means the seat wasn't empty anymore. Someone else got 14C, so A skips the email and tells her the seat was just taken. She picks another seat. Disappointing, but true. Two confirmations for one seat is worse, because one of them is false.

00:03:29 — Alex
Redis's own lock documentation lands in the same place. If you care about correctness, use fencing tokens, and don't assume you still hold a lock just because your process is alive. Martin Kleppmann describes the token: each new lock comes with a bigger number, and storage rejects a write that carries an older one.

00:03:48 — Maya
Our empty-buyer condition does that job here, because the seat itself says whether it's taken. If the write had no rule like that, say replacing a whole document, that's where the token earns its keep.

00:04:01 — Alex
Here's my uncomfortable question, then. If Postgres is the referee now, what is the Redis lock still doing?

00:04:08 — Maya
For this booking? Not much. The conditional write is correct without it. Honestly, I'd be tempted to delete it.

00:04:15 — Alex
I'd keep it. Picture the on-sale. Half the room clicks 14C in the same second. With the lock, one request goes on to Postgres, and everyone else hears 'someone's booking that seat' straight from Redis. Without it, they all line up on that one row just to hear no.

00:04:32 — Maya
Fine. A bouncer, not a referee. It keeps the crowd off the row. It doesn't decide who gets the seat.

00:04:39 — Alex
That's the split I'd bring to a design review. Ask what breaks if two holders overlap. A little wasted work? A lock with an expiry is fine on its own. Two people holding seat 14C? The final check lives where the seat lives.

00:04:54 — Maya
How would we test that, though? A twelve-second pause won't show up on demand.

00:04:58 — Alex
My first booking page double-booked a neighborhood workshop. The organizer spent a Saturday apologizing. I had never once tried two bookings at the same moment.

00:05:08 — Maya
So this time we try it on purpose.

00:05:10 — Alex
On purpose. Hold server A right before its write, with a breakpoint or a test hook, until the lock expires. Let B book Omar. Release A. You want zero rows, no email for Lena, and Omar still in 14C.

00:05:26 — Maya
Then here's this week's move. Find one lock in your system and ask what breaks if two holders overlap. If it's money or a promise, make the write refuse the second one.

00:05:37 — Alex
Let the lock work the door. Let the write decide who gets seat 14C.

Download the transcript ↓