Redis for Reservation Systems: Seat Locking with SET NX PX and Auto Expiry

1. Overview

In ticketing systems for movies, flights, or restaurant reservations, users are typically given a temporary hold on their seat for a few minutes to complete payment. If payment is not completed within that time, the seat is automatically released for others. This article guides you through using Redis’s SET key value NX PX command to lock seats atomically, enable automatic expiry, and safely release or extend locks using Lua scripts. By the end, you’ll have a working SeatHoldService for Spring Boot 3.x, complete with tests using Testcontainers.

2. The Seat Hold Problem

Two customers almost simultaneously click to select seat E7 for screening 42. The system must guarantee three things:

  • Only one person can hold the seat.
  • If that person abandons it, the seat is automatically released after a period of time, say 5 minutes.
  • Only the person who holds the seat can release or extend it.

You could implement this using a database with SELECT ... FOR UPDATE and a held_until column. This approach works, but requires a background job to clean up expired records. Large write volumes during peak booking times can also easily congest the database. Redis solves this more elegantly because it provides two built-in features: atomic “set-if-not-exists” operations and keys that automatically expire via TTL.

3. How SET NX PX Works

Try it directly with redis-cli:

redis-cli SET seat-hold:{show:42}:E7 "token-alice" NX PX 300000
redis-cli SET seat-hold:{show:42}:E7 "token-bob" NX PX 300000
redis-cli PTTL seat-hold:{show:42}:E7
OK
(nil)
(integer) 299412

What each part means:

  • NX: set only if the key doesn’t exist yet. Bob’s second command returns nil because Alice already holds the seat.
  • PX 300000: set TTL to 300,000 milliseconds, which is 5 minutes. After the time expires, Redis automatically deletes the key.
  • The value token-alice is a hold token used to identify who holds the seat. Later sections will use this token to safely release the lock.

The key point is that NX and PX are part of a single command, so they’re executed atomically. The old approach of SETNX followed by PEXPIRE has a risk: if the application crashes between the two commands, the key persists forever and the seat remains locked indefinitely.

The {show:42} part in the key is a hash tag. In Redis Cluster, all keys with the same hash tag are placed in the same slot. This enables you to run Lua scripts on multiple seats of the same screening, as shown in section 7.

4. Spring Boot Configuration

Add the Spring Data Redis dependency. Lettuce is the default client:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Configure the connection:

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=2s

Spring Boot automatically creates a StringRedisTemplate bean. Both keys and values are strings, which fits this use case perfectly.

5. Holding Seats with setIfAbsent

@Service
public class SeatHoldService {

    private final StringRedisTemplate redis;

    public SeatHoldService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    static String key(long showId, String seatId) {
        return "seat-hold:{show:%d}:%s".formatted(showId, seatId);
    }

    public Optional<String> hold(long showId, String seatId, Duration ttl) {
        String token = UUID.randomUUID().toString();
        Boolean ok = redis.opsForValue().setIfAbsent(key(showId, seatId), token, ttl);
        return Boolean.TRUE.equals(ok) ? Optional.of(token) : Optional.empty();
    }

    public boolean isHeld(long showId, String seatId) {
        return Boolean.TRUE.equals(redis.hasKey(key(showId, seatId)));
    }
}

Explanation:

  • setIfAbsent(key, value, Duration) generates the command SET key value NX PX <ms>. If ttl is an even number of seconds, Spring might use EX instead of PX, but the meaning remains the same.
  • Each seat hold generates a new UUID as a token. This token is returned to the client, for example in the response of a POST /holds API endpoint. The client sends the token back when confirming payment or canceling.
  • setIfAbsent returns a Boolean that may be null when running in a pipeline or transaction. Therefore, the code compares using Boolean.TRUE.equals(ok) instead of unboxing directly.

Example usage in a controller:

@PostMapping("/shows/{showId}/seats/{seatId}/hold")
public ResponseEntity<Map<String, String>> hold(@PathVariable long showId,
                                                @PathVariable String seatId) {
    return seatHoldService.hold(showId, seatId, Duration.ofMinutes(5))
            .map(token -> ResponseEntity.ok(Map.of("holdToken", token)))
            .orElseGet(() -> ResponseEntity.status(HttpStatus.CONFLICT).build());
}

Subsequent requests receive 409 Conflict. The frontend can use this to display “Seat already selected” to the user.

6. Safely Releasing and Extending Locks with Lua

6.1. Why Not Use DEL Directly

Consider the following scenario:

  1. Alice holds the seat with a 5-minute TTL.
  2. Alice gets stuck on the payment page past 5 minutes, the key automatically expires.
  3. Bob successfully holds seat E7.
  4. Alice clicks “Cancel”, the server calls DEL. Bob’s seat is deleted.

You must verify the token before deleting. If you use GET followed by DEL in two separate commands, the key can still expire and change owners between them. Lua scripts run atomically on Redis, eliminating this window.

6.2. Release and Extend

@Service
public class SeatHoldService {

    private static final RedisScript<Long> RELEASE = new DefaultRedisScript<>("""
            if redis.call('GET', KEYS[1]) == ARGV[1] then
              return redis.call('DEL', KEYS[1])
            end
            return 0
            """, Long.class);

    private static final RedisScript<Long> EXTEND = new DefaultRedisScript<>("""
            if redis.call('GET', KEYS[1]) == ARGV[1] then
              return redis.call('PEXPIRE', KEYS[1], ARGV[2])
            end
            return 0
            """, Long.class);

    // ... constructor, key(), hold(), isHeld() as above

    public boolean release(long showId, String seatId, String token) {
        Long n = redis.execute(RELEASE, List.of(key(showId, seatId)), token);
        return n != null && n == 1L;
    }

    public boolean extend(long showId, String seatId, String token, Duration ttl) {
        Long n = redis.execute(EXTEND, List.of(key(showId, seatId)),
                token, String.valueOf(ttl.toMillis()));
        return n != null && n == 1L;
    }
}

Explanation:

  • KEYS[1] is the seat key, ARGV[1] is the token sent by the client. The script only DEL or PEXPIRE if the current value matches the token.
  • The script returns 1 on success, 0 if the token doesn’t match or the key has expired.
  • DefaultRedisScript automatically computes the SHA1 and calls EVALSHA. If Redis hasn’t cached the script, Spring automatically falls back to EVAL. Declare scripts as static final to avoid recreating them on every call.

extend is useful when a user moves to the payment gateway and you want to add 2 more minutes. If extend returns false, you know for sure the seat is lost and need to tell the user to choose again.

7. Holding Multiple Seats at Once

A group of friends books three seats: E7, E8, E9. The requirement is to hold all three or none at all. Calling hold three times and rolling back on failure works, but it’s verbose and there’s a window where others see partial states. A Lua script handles this more elegantly:

private static final RedisScript<Long> HOLD_ALL = new DefaultRedisScript<>("""
        for _, k in ipairs(KEYS) do
          if redis.call('EXISTS', k) == 1 then return 0 end
        end
        for _, k in ipairs(KEYS) do
          redis.call('SET', k, ARGV[1], 'PX', ARGV[2])
        end
        return 1
        """, Long.class);

public Optional<String> holdAll(long showId, List<String> seatIds, Duration ttl) {
    String token = UUID.randomUUID().toString();
    List<String> keys = seatIds.stream().map(s -> key(showId, s)).toList();
    Long n = redis.execute(HOLD_ALL, keys, token, String.valueOf(ttl.toMillis()));
    return n != null && n == 1L ? Optional.of(token) : Optional.empty();
}

The first loop checks if any seat is already held. If not, the second loop sets all seats with the same token and TTL. The entire script runs atomically, so no request can sneak in between.

Thanks to the hash tag {show:42}, all keys for the same screening are in the same slot. Without the hash tag, Redis Cluster returns a CROSSSLOT error when the script touches multiple keys.

8. Writing Tests with Testcontainers

Add test dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Tests for the main behaviors:

@SpringBootTest
@Testcontainers
class SeatHoldServiceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Autowired SeatHoldService service;
    @Autowired StringRedisTemplate template;

    @BeforeEach
    void clean() {
        template.getConnectionFactory().getConnection().serverCommands().flushAll();
    }

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() {
        Optional<String> alice = service.hold(42, "E7", Duration.ofMinutes(5));
        Optional<String> bob = service.hold(42, "E7", Duration.ofMinutes(5));

        assertThat(alice).isPresent();
        assertThat(bob).isEmpty();
    }

    @Test
    void holdExpiresAutomatically() throws InterruptedException {
        service.hold(42, "E7", Duration.ofMillis(200));
        Thread.sleep(400);

        assertThat(service.isHeld(42, "E7")).isFalse();
        assertThat(service.hold(42, "E7", Duration.ofMinutes(5))).isPresent();
    }

    @Test
    void staleTokenCannotReleaseSomeoneElsesHold() {
        String aliceToken = service.hold(42, "E7", Duration.ofMillis(100)).orElseThrow();
        await(200);
        String bobToken = service.hold(42, "E7", Duration.ofMinutes(5)).orElseThrow();

        assertThat(service.release(42, "E7", aliceToken)).isFalse();
        assertThat(service.isHeld(42, "E7")).isTrue();
        assertThat(service.release(42, "E7", bobToken)).isTrue();
    }

    @Test
    void holdAllIsAllOrNothing() {
        service.hold(42, "E8", Duration.ofMinutes(5));

        assertThat(service.holdAll(42, List.of("E7", "E8", "E9"), Duration.ofMinutes(5))).isEmpty();
        assertThat(service.isHeld(42, "E7")).isFalse();
        assertThat(service.isHeld(42, "E9")).isFalse();
    }

    private static void await(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

@ServiceConnection(name = "redis") tells Spring Boot that this container is Redis. Spring Boot automatically configures the host and port to point to the container. The test staleTokenCannotReleaseSomeoneElsesHold recreates the exact scenario from section 6.1. Alice’s stale token cannot delete Bob’s lock.

You can also test actual contention with multiple threads:

@Test
void hundredConcurrentRequestsYieldExactlyOneWinner() throws Exception {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<Future<Optional<String>>> results = IntStream.range(0, 100)
                .mapToObj(i -> executor.submit(() -> service.hold(42, "E7", Duration.ofMinutes(5))))
                .toList();

        long winners = 0;
        for (var f : results) if (f.get().isPresent()) winners++;
        assertThat(winners).isEqualTo(1);
    }
}

The test uses Java 21’s virtual threads to fire 100 concurrent requests. The result always has exactly one winner.

9. Common Mistakes and Limitations

Note: Redis locks are only a “soft” layer of protection. When confirming a booking, you still need a unique constraint on (show_id, seat_id) in the booking table. Redis can lose data during failover because replicas receive data asynchronously. Without the constraint, two people could both be confirmed for the same seat.

Other common mistakes:

  • Using SETNX followed by separate EXPIRE. If the application crashes between the two commands, the lock never expires. Always use SET ... NX PX in a single command.
  • Releasing with DEL without checking the token. You’ll accidentally delete someone else’s lock as in the scenario from section 6.1.
  • Confirming a booking without verifying the lock. Before writing the booking, call a script to verify the token still matches, or use release and only write if it returns true. If payment succeeded but the lock is gone, you need a refund flow.
  • TTL too short or too long. Too short and users lose the seat while entering card details. Too long and seats are “phantom-occupied” during peak times. Keep TTL short and use extend at each step.
  • Forgetting hash tags in Cluster mode. Multi-key scripts will fail with CROSSSLOT.
  • Relying on application clocks to calculate hold expiry. Let Redis manage the TTL. If the UI needs to display a countdown, read PTTL instead of calculating it yourself.

When not to use this approach:

  • When you need absolute guarantees at the lock layer, for example with financial resources that lack database constraints behind them. A single Redis instance with asynchronous replication doesn’t provide that guarantee.
  • When traffic is low and the database is sufficient. A simple held_until column with a unique constraint might be simpler than operating an additional Redis instance.
  • When hold state must be persisted long-term for reconciliation. Redis keys auto-delete, so you need to log events separately.

10. Conclusion

The SET key value NX PX command gives you an atomic seat lock that self-expires in a single call. Combined with random tokens and Lua scripts, you can safely release or extend locks without accidentally deleting someone else’s. You can also hold multiple seats in an all-or-nothing fashion. Use hash tags when running Redis Cluster. Finally, always use a database with a unique constraint as the final protection layer when confirming bookings.

redisspring-bootdistributed-lockjavaconcurrency