10 万人抢 2000 个座位,电影票选座系统怎么防超卖?一文讲透大厂高频场景题 – Rain的Java大神实战圈

10 万人抢 2000 个座位,电影票选座系统怎么防超卖?一文讲透大厂高频场景题 – Rain的Java大神实战圈


100,000 people compete for a 2,000-seat movie ticket selection system, a high-frequency interview scene for a major company. Four-layer funnel architecture, Redis Lua atomic batch lock seat, MySQL optimistic lock + unique index cover; Bitmap compressed seat map, delayed message release lock, state machine guarantees idempotence, completely solves overbooking, multi-seat lock conflicts, cache consistency and other pain points, and can be recited directly in interviews.

A popular event during the Spring Festival, 100,000 people grabbed 2,000 seats in three seconds. This question appears in the interview. The test is not whether you can draw quickly or not, but the five hard skills: anti-overselling, atomicity, delayed tasks, idempotence, and cache consistency. I have combined the 7 versions of answering ideas into this article, explaining everything from architecture to code in one go. It is suitable for memorizing directly before interviews, and is also suitable for reference when working on flash sales and inventory systems.

1. Don’t rush to draw a picture: align the caliber first, then asking questions is a bonus.

The interviewer smiled and said, “Come on, design a seat selection and ticket purchasing system similar to Maoyan/Taopiaopiao.” 90% of people came up and drew the architecture diagram. In fact, the first step that gives you the most points is to ask rhetorical questions and ask clearly about the boundaries – if the boundaries are different, the plan will be completely different.

rhetorical question Typical answer Impact on design
User selection or system allocation? Users choose their own seats Must be accurate to seatIdinstead of deducting an inventory number
Scale and concurrency 2,000 seats per session, peak QPS 5w+ Typical read more and write less + strong concurrency
How many tickets can I buy in one session? Purchase limit is 4~6 tickets, support seated seats/couple seats One person, one lock + purchase limit verification + anti-scalper
How long does the lock seat last? 15 minutes The timeout release mechanism is just needed
Consistency requirements Never oversell, allow undersell I would rather refuse the order than repeat the order.
Payment form Third-party payment asynchronous callback Possible repeated and delayed callbacks, requiring a state machine
Whether to connect with third-party ticketing Yes (proprietary/theater system) Ticket issuance and refund must be idempotent + reconciliation

After asking this round, the outline of the system came out:Read more and write less, strong concurrency, and state has a life cycle. There is only one word for the core contradiction – oversold.

Key understanding: Tens of millions of users does not equal tens of millions of concurrent orders. The real pressure is “one event, hundreds of seats, and hundreds of thousands of requests at the moment of sale.” Therefore, the first principle of design is not to “carry all traffic”, butBlock out invalid requests as early as possible, so that the write concurrency that eventually falls into the database will converge to close to the actual number of seats.。

It is recommended to memorize this sentence, and the interviewer will basically nod when he hears it. Once the caliber is aligned, let’s look at how the architecture is layered – this architecture has a very vivid metaphor: a four-layer funnel.

2. Overall structure: four-layer funnel, the further back, the more “expensive” it becomes

The architecture is not a heap of components, but a funnel: requests go down layer by layer, with fewer and higher costs going down.

10 万人抢 2000 个座位,电影票选座系统怎么防超卖?一文讲透大厂高频场景题 – Rain的Java大神实战圈

Each layer does one thing:

  • gateway layer: Current limiting, authentication, risk control, idempotent verification, first block scalper scripts and invalid requests;
  • caching layer: Caffeine’s local cache supports second-level seat map reading, and Redis handles seat status determination, handling most concurrency;
  • Database layer: MySQL is the ultimate authority of data, with unique index + optimistic locking as the back-up;
  • message layer: Delay the message management timeout release for 15 minutes, and the transaction message/local message table management ticket issuance will eventually be consistent;
  • Reconciliation layer: Regularly compare Redis, MySQL, and third-party ticketing data to deal with various inconsistencies.

Summarize the design principles in one sentence:

Redis is used for high-speed decision-making, MySQL is used as the final authority, messages are ultimately consistent, and reconciliation is provided as a guarantee.

Some people may ask: Is it over-design to have so many layers? In this scenario, redundancy is justice. The cost of customer complaints and brand loss caused by one oversold is much higher than the cost of a few more machines.Architecturally, I would rather use three layers of redundancy in exchange for zero oversold.this account can be settled.

The skeleton is there, and next comes the flesh and blood—the data model. Many people will overturn this step.

3. Data model: static templates and dynamic states must be separated

Let’s start with a pitfall: don’t stuff seats into a big JSON field. Changing the layout of the theater and changing the status of ticket sales will fight each other, and there is no place to add indexes. This is how several online accidents I have seen come about.

The correct posture is to split it in half:Static templates are almost unchanged and can be cached for a long time; dynamic states are written frequently and are hot spots.。

-- 1. 影厅座位模板(静态:一个影厅一份,几乎不变)
CREATE TABLE hall_seat_template (
    id        BIGINT PRIMARY KEY,
    hall_id   BIGINT NOT NULL,
    seat_no   VARCHAR(16) NOT NULL,      -- 'A01'
    row_no    INT, col_no INT,           -- 行 / 列
    x INT, y INT,                        -- 前端渲染坐标
    seat_type TINYINT DEFAULT 0,         -- 0普通 1VIP 2情侣 3无障碍 4维修
    group_id  BIGINT DEFAULT NULL,       -- 情侣座分组,选座时校验组完整性
    UNIQUE KEY uk_hall_seat (hall_id, seat_no)
);

-- 2. 场次座位状态(动态:每个场次 x 每个座位一条,热点表)
CREATE TABLE show_seat (
    id               BIGINT PRIMARY KEY,
    show_id          BIGINT NOT NULL,
    seat_no          VARCHAR(16) NOT NULL,
    status           TINYINT NOT NULL DEFAULT 0,   -- 0可选 1锁定 2已售
    lock_user_id     BIGINT DEFAULT NULL,
    order_no         VARCHAR(32) DEFAULT NULL,
    lock_expire_time DATETIME DEFAULT NULL,
    version          INT NOT NULL DEFAULT 0,
    UNIQUE KEY uk_show_seat (show_id, seat_no)     -- 防超卖的最后一道防线
);

-- 3. 订单
CREATE TABLE ticket_order (
    order_no VARCHAR(32) PRIMARY KEY,
    user_id  BIGINT NOT NULL,
    show_id  BIGINT NOT NULL,
    seat_nos VARCHAR(255),
    status   TINYINT NOT NULL DEFAULT 0,           -- 0待支付 1已支付 2已关闭
    UNIQUE KEY uk_idem (user_id, show_id, seat_nos) -- 防多端重复下单
);

Three design points are worth mentioning separately:

① The status must follow the game. In the same theater, the seat status of different shows is independent, so the status must be hung in show_id Below, it cannot be placed on the movie theater seat template.show_seat There is one record for each seat in each event, and the seats (physical presence) and events (business dimensions) are completely decoupled.

② The unique index is the last straw to prevent oversold. uk_show_seat (show_id, seat_no) This means that even if the cache and code are all lost, the database will ensure that there is only one record for each seat in a session, and the second person cannot get in at all.

③ Special seats must be modeled in advance. Seat for couples and barrier-free seats seat_type Mark, hang another one on the couple seat group_idcheck the grouping integrity when selecting seats to prevent splitting and selling of couple seats. on the order form uk_idem It is responsible for blocking multiple terminal repeated orders submitted by mobile phone + PC at the same time.

Once there is a table, someone must take care of the status. The state machine in the next section is something that will make the interviewer’s eyes light up.

4. Seat state machine: the soul of the entire system

The state flow of the seat is actually a finite state machine. Locking is an intermediate state and must be time-limited – otherwise if the user chooses not to pay, the seat will be “nailed”.

image

With an iron rule:

Any state migration goes UPDATE ... WHERE status = ? AND version = ?,If the number of affected rows is not equal to the expected number, the operation fails.. This article is not only the foundation of idempotence, but also the last gate to prevent oversold.

Don’t underestimate this trick. Repeated payment callbacks and repeated MQ consumption will be harmless as long as the state machine UPDATE returns row 0.

Once the state machine is determined, the main link will be naturally derived. But before looking at the code, let me ask a question: The user selects 4 seats at a time, and when he locks the second seat, he finds that it has been robbed. What should he do? The intermediate state of being half locked is the most troublesome thing.

5. Core lock seat link: lock in front to keep concurrency out of the database

The complete lock seat link looks like this:

image

It can be broken down into six steps:

  1. The user submits a seat selection request, and the front-end brings the generated clientToken;
  2. The backend first performs idempotent verification, and then verifies the legality of the seats (belonging to the event and idle status);
  3. Redis + Lua batch atomic lock basesuccess will only be considered if all seats are locked successfully;
  4. The lock is successful: the database CAS is logged in, a “pending payment” reservation order is generated, and a 15-minute delay message is sent;
  5. Lock failure: directly returns “seat occupied” without touching the database at all;
  6. Payment is successful: the state machine advances the seat to sold; delay message expiration check, release the lock and roll back the seat without payment.

The design idea of ​​the entire link is just four words:lock front. Before writing to the database, first use Redis’s atomic operation to occupy the seat, so that the database can only process requests that are actually likely to succeed.

Let’s enter the hard code section. Each point above has a corresponding implementation.

6. Core code hard goods

6.1 Redis Lua Atomic Lock Block: Solve “All or Nothing” at once

Why must we use Lua? You may choose 4 seats for an order, and you must “either occupy them all or not occupy them all.” Each SET will appear in an intermediate state with half of the lock; and there is a concurrency window between the “checking” and “locking” steps. Redis single-threaded execution of Lua scripts is atomic and can be completed in one RTT. No requests can be inserted between checking and locking.

-- KEYS: 每个座位的锁 key,seat:lock:{showId}:{seatId}
-- ARGV[1] = lockToken(订单号或用户ID,用于校验锁归属)
-- ARGV[2] = 过期秒数(15 分钟 = 900)

-- 第一步:先校验全部座位
for i, key in ipairs(KEYS) do
    local holder = redis.call('GET', key)
    if holder ~= false then
        -- 座位被占,返回具体冲突的是哪一个
        return {0, key}
    end
end

-- 第二步:全部通过,批量写入
-- 脚本在 Redis 单线程中原子执行,不存在中间状态
for i, key in ipairs(KEYS) do
    redis.call('SET', key, ARGV[1], 'EX', tonumber(ARGV[2]))
end
return {1, #KEYS}

Java side call:

@Service
public class SeatLockService {

    private static final DefaultRedisScript LOCK_SCRIPT =
            new DefaultRedisScript<>(LOCK_LUA, List.class);

    /**
     * 批量锁座:原子操作,要么全部成功,要么全部失败
     */
    public boolean batchLockSeats(Long showId, List seatIds,
                                  String lockToken, int lockSeconds) {
        List keys = seatIds.stream()
                .map(id -> "seat:lock:" + showId + ":" + id)
                .collect(Collectors.toList());

        List> result = redisTemplate.execute(
                LOCK_SCRIPT, keys, lockToken, String.valueOf(lockSeconds));

        return result != null && Long.valueOf(1L).equals(result.get(0));
    }
}

Technical Highlights

  • Unlocked: Without introducing distributed locks, Redis executes Lua’s natural atoms in a single thread, eliminating one RTT and lock waiting, and not forgetting to unlock and lock renewal worries;
  • Verify everything first, then write everything: For multi-seat orders, either all of them are locked or none of them is locked, leaving no semi-finished products;
  • The lock comes with TTL: The service downtime lock will also automatically expire, preventing deadlock from the root;
  • Lock value storage ownership identification: Store the order number or user ID in the lock, verify ownership when unlocking, and only delete your own lock to avoid accidentally deleting a new lock that someone else has just locked.

Here are two more advanced techniques, which will obviously put you at a higher level during the interview:

  • Expired locks can be preempted: If it is found that the seat is locked but has expired during verification, it will be directly considered available for preemption. In this way, seats that are “occupied without paying” can be reclaimed by latecomers without having to wait for cleaning tasks;
  • Stock counter quick interception: Maintain a remaining ticket key outside of Lua. If there are insufficient remaining tickets, a failure will be returned directly. It can block 90% of invalid traffic. The Lua script does not need to be traversed every time.

By the way, if the business is so simple that you only choose a single seat, one sentence SET key token NX EX 900 That’s enough, SETNX plus expiration time is a single command to ensure atomicity. But when it fails, remember to make a second judgment: the key may have been locked by you before (retry scenario), compare the lock value, and directly release it if it is locked by you.

6.2 Optimistic database lock-in: Redis has the final say, DB has the final say

Some people may ask: If Redis is blocked, why do we need a database? because Redis master-slave switching may lose locksthe cache is just an accelerator, not the final authority.

The database layer relies on one SQL:

UPDATE show_seat
   SET status = 1,
       lock_user_id = #{userId},
       order_no     = #{orderNo},
       lock_expire_time = DATE_ADD(NOW(), INTERVAL 15 MINUTE),
       version = version + 1
 WHERE show_id = #{showId}
   AND seat_no IN (...)
   AND status = 0;          -- CAS 条件:只有可选才能被锁

The essence of optimistic locking is AND status = 0: Only “optional” seats can be changed to “locked” to naturally prevent concurrency. The Java side determines whether the number of affected rows is equal to the number of seats. If not, it means someone has dropped out first. Coupled with the previous unique index, double insurance.

The main process code that connects Redis and the database is as follows, focusing on idempotence and compensation:

@Service
public class SeatService {

    @Transactional(rollbackFor = Exception.class)
    public SeatLockResult lockSeats(Long showId, List seatIds,
                                    Long userId, String clientToken) {
        // ① 幂等:同一个 clientToken 只产生一个业务结果
        //    前端连点、网关重试、弱网重发全部挡住
        IdemRecord idem = idemMapper.selectByToken(clientToken);
        if (idem != null && idem.isSuccess()) {
            return idem.getResult();          // 重复请求直接返回原结果
        }
        idemMapper.insertIgnore(clientToken, userId);   // 唯一索引兜底

        // ② Redis Lua 原子锁座:挡住 99% 的并发请求
        boolean locked = seatLockService.batchLockSeats(
                showId, seatIds, clientToken, 900);
        if (!locked) {
            return SeatLockResult.fail("手慢了,座位被抢走了");
        }

        try {
            // ③ 数据库乐观锁落库:影响行数必须等于座位数
            int rows = seatMapper.casLock(showId, seatIds, userId);
            if (rows != seatIds.size()) {
                throw new BizException("座位状态异常,请重选");
            }
            // ④ 生成待支付订单 + 发 15 分钟延迟消息
            Order order = orderService.createPreOrder(showId, seatIds, userId);
            mqProducer.sendDelay(new SeatReleaseMsg(order), 15, TimeUnit.MINUTES);
            return SeatLockResult.success(order);
        } catch (Exception e) {
            // ⑤ 补偿:任何失败都要回滚 Redis 锁,不留孤儿锁
            seatLockService.batchUnlockSeats(showId, seatIds, clientToken);
            throw e;
        }
    }
}

There are two life-saving experiences hidden here, and I have actually used them:

Reminder for stepping on pitfalls

  1. Compensatory rollback must be written. The Redis lock was successful but the database failed to be dropped into the database. If Redis is not rolled back, an orphan lock will occur – the seats are locked in the cache, but there are no orders in the database, and the user cannot select it. After a long investigation, the reason cannot be found. Any abnormal path must reverse Redis.
  2. Idempotence relies on unique indexes, not code.。insertIgnore Cooperate (user_id, client_token) Unique indexes count. Relying on the writing method of “check first and then insert”, there is a concurrency window between the check and the insertion, and there are still two orders with two consecutive clicks.

Double insurance logic: Redis is down, database unique index + status = 0 Conditions block; the database is slow and Redis blocks large traffic.If any layer fails, it will not be oversold.

6.3 Seat map rendering: Bitmap compresses 100KB into 250 bytes

The seating chart is the big one with more reading and less writing. 2000 seats are returned using JSON, a request starts at 100KB, and QPS 5w directly breaks the bandwidth. Take a look at a set of comparisons:

plan 2000 data volume Review
JSON array About 100 KB bandwidth killer
Compressed string About 2 KB Fairly good
Bitmap 250 bytes 400 times smaller

The implementation is also very simple:

// 座位图:2000 个座位 = 2000 bit = 250 字节,一次 GET 拿走
public String getSeatMap(Long showId) {
    byte[] bitmap = redisTemplate.opsForValue().get("seat:map:" + showId);
    return Base64.getEncoder().encodeToString(bitmap);   // 前端解码渲染
}

// 标记已售:idx = 行号 * 列数 + 列号
public void markSold(Long showId, List seatIndexes) {
    for (Integer idx : seatIndexes) {
        redisTemplate.opsForValue().setBit("seat:map:" + showId, idx, true);
    }
}

There is also a comparison table for storage selection. When asked during the interview, “Why don’t you check MySQL directly?”:

plan advantage shortcoming
MySQL row lock reliable Concurrent lock competition is serious and performance is poor
pure Redis BitMap Extremely fast, memory operations Need to be durable
BitMap + MQ asynchronous database drop High concurrency + reliability The architecture is slightly complex

If you want to express more status, you can upgrade to 2bit encoding:00=Unavailable,01=optional,10=Lock,11=Sold. A 300-seat theater only requires 75 bytes. One GET to retrieve the entire theater is much better than checking the database 300 times.

Bitmap is used for display and Hash + Lua is used for judgment.——Bitmap is responsible for “reading quickly”. The seat status details (lock token, expiration time, version number) are put into Hash, and the seat lock decision is made through Hash + Lua. Separate responsibilities and don’t mix them.

The key design of Redis can refer to this table:

Key type use
show:{showId}:seat Hash Seat details: status, lockToken, expiration time, version
show:{showId}:bitmap Bitmap 2bit/seat, one GET returns the entire theater seating map
show:{showId}:locker:{userId} Set One person, one lock + purchase limit verification
show:{showId}:stock String The number of remaining votes will only be used for quick interception, but not the final decision.

The reading path of the seat map for hot events also requires multiple levels of caching:

@Cacheable(cacheNames = "seatMap", key = "#showId", sync = true)
public SeatMapVO seatMap(Long showId) {
    byte[] bits = redisBitmapService.getAll(showId);     // 一次 GET 拿全厅
    return SeatMapVO.decode(bits, hallLayout(showId));
}
// 读路径:Caffeine(5~30s 随机 TTL)→ Redis(分钟级随机 TTL)→ DB
// 写路径:Cache Aside,先更新数据库,再删缓存

sync = true Let only one request for the same key penetrate to the back layer (single flight), cache breakdown is solved; random TTL prevents avalanche; write path adopts Cache Aside mode——Update the database first, then delete the cache. The seat status is ultimately determined by the database, and the cache is only for access acceleration. This priority needs to be distinguished.

6.4 Timeout release: mainly delayed messages, regular scanning to find out the truth

If the user does not pay after locking the seat, the seat must be returned. This part is a typical “looks simple, but is full of pitfalls”.

The main plan uses delayed messages, which are delivered when the order is successfully placed (the main process has been sent previously), and the consumer side performs double verification:

@Component
@RocketMQMessageListener(topic = "SEAT_RELEASE_TOPIC",
                         consumerGroup = "seat-release-group")
public class SeatReleaseConsumer implements RocketMQListener {

    @Override
    public void onMessage(SeatReleaseMsg msg) {
        // 先查幂等:订单已支付或已关闭,直接跳过
        Order order = orderMapper.selectByOrderNo(msg.getOrderNo());
        if (order == null || order.getStatus() != OrderStatus.UNPAID) {
            return;
        }
        // CAS 关单:只有 待支付 → 已关闭 能成功,重复消费天然被挡
        int rows = orderMapper.casUpdateStatus(order.getOrderNo(), UNPAID, CLOSED);
        if (rows == 0) return;

        // 释放座位:带 lock_user_id 条件,只释放本订单锁的座位
        seatMapper.releaseByOrder(order.getShowId(), order.getOrderNo(),
                                  order.getUserId());
        seatLockService.batchUnlockSeats(order.getShowId(), order.getSeatIds(),
                                         order.getLockToken());
        cacheService.evictSeatMap(order.getShowId());
    }
}

Two details are worth noting:

  • Check payment status before releasing: MQ may make repeated purchases, and paid orders must be skipped directly, otherwise the paid seats will be released and sold for a second time;
  • Release SQL band lock_user_id condition: Only release the seats you have locked to prevent accidentally releasing the seats that others have just re-locked.
UPDATE show_seat
   SET status = 0, lock_user_id = NULL, order_no = NULL
 WHERE show_id = #{showId}
   AND order_no = #{orderNo}
   AND lock_user_id = #{userId};   -- 只释放自己锁的座位

Delayed messages may also be lost (MQ failure, consumption abnormality), so a scheduled task is required to scan every 5 minutes. status = 1 AND lock_expire_time < NOW() Dirty data is forced to be released:

@Scheduled(fixedDelay = 5_000)
public void releaseExpiredSeats() {
    // 按实例取模分片扫描,避免多实例重复处理
    List expired = seatMapper.scanExpired(instanceId, SHARD_TOTAL, 500);
    for (ShowSeat seat : expired) {
        // 带版本号校验:防止误释放"释放后又被别人买走"的座位
        int n = seatMapper.releaseWithVersion(
                seat.getShowId(), seat.getSeatId(), seat.getVersion());
        if (n == 1) {
            seatLockService.unlock(seat.getShowId(), seat.getSeatId(),
                                   seat.getLockToken());
        }
    }
}

The last pitfall must be reminded separately. Many people have fallen into it online:

Never use Redis’s keyspace notification (expiration event) to release seats!
The official documentation is very clear: Expired eventsNot guaranteed to trigger immediately when TTL returns to zero(Lazy deletion + progressive recycling), and events will not be broadcast to all nodes in Cluster mode. Use it for release = delayed release + leaky release.
The correct answer is the three insurances:Redis TTL makes the seat “look gone” on the front end, database expire_at Scheduled scanning makes seats “really put back”, delayed messages are accelerated in the middle。

By the way, if you are worried about the accidental release of “the user is paying but the lock has expired”, you can add a watchdog to renew the lock; but a more stable approach is to include the version number and user verification when releasing, so as to block accidental damage at the SQL layer.

6.5 Payment callback and ticket issuance: state machine is the best idempotent

The payment channel will retry up to 10 times, and the callbacks may be repeated or out of order (failure is received first, then success is received). An idempotent one-line CAS is enough:

public void handlePayNotify(PayNotify notify) {
    // 先验签、再解密(如微信支付 AES-256-GCM),然后才进业务
    // CAS 状态机:只有 待支付 → 已支付 能成功,天然幂等
    int rows = orderMapper.casUpdateStatus(notify.getOrderNo(), UNPAID, PAID);
    if (rows == 0) {
        return;   // 重复回调或状态不允许,直接吞掉,给渠道回成功
    }
    seatMapper.confirmSold(notify.getShowId(), notify.getSeatIds()); // 座位 1 → 2
    cacheService.evictSeatMap(notify.getShowId());
    mqProducer.sendTxMessage(ISSUE_TICKET_TOPIC, notify.getOrderNo());
}

Note two points:Don’t write “check first and then change”there is a concurrency window between checking and modifying, which depends on the atomicity of state machine UPDATE; the callback interface must return quickly. If it takes a long time, the channel will think it has timed out and will try again.

After the payment is successful, ticket issuance is a cross-system action that deals with the third-party ticketing system. It cannot be inserted into the payment transaction. Use transaction messages or local message tables for final consistency:

@Component
@RocketMQMessageListener(topic = ISSUE_TICKET_TOPIC, consumerGroup = "ticket-cg")
public class TicketIssueConsumer implements RocketMQListener {

    @Override
    public void onMessage(TicketMsg msg) {
        TicketTask task = ticketTaskMapper.selectByOrderNo(msg.getOrderNo());
        if (task.getStatus() == TaskStatus.SUCCESS) return;   // 幂等短路
        try {
            // 用 orderNo 做幂等键,重试绝不会重复出票
            String code = thirdPartyTicketing.issue(msg.getOrderNo(), task.getSeatIds());
            ticketTaskMapper.markSuccess(task.getId(), code);
        } catch (Exception e) {
            if (task.getRetryCount() >= 3) {
                ticketTaskMapper.toManual(task.getId());   // 进人工差错池 + 触发自动退款
            } else {
                ticketTaskMapper.nextRetry(task.getId(), backoff(task.getRetryCount()));
            }
        }
    }
}

Exponential backoff retry, 3 failures will be entered into the manual error pool and automatically refunded——Users must never be left in a hanging state of “the money has been paid but the ticket has not been issued”. Transaction messages only guarantee that the message will be sent out. The consistency of the consumer side still depends on its own idempotence. Don’t count on middleware.

6.6 Real-time seat map: WebSocket push

If multiple people are watching a show at the same time, and others have locked their seats, the change must be seen immediately, otherwise the user experience will be “Why is the seat I just ordered gone?” Push using WebSocket:

@Component
@ServerEndpoint("/ws/seat/{showId}")
public class SeatWebSocket {

    private static final ConcurrentHashMap> SESSIONS =
            new ConcurrentHashMap<>();

    @OnOpen
    public void onOpen(Session session, @PathParam("showId") Long showId) {
        SESSIONS.computeIfAbsent(showId, k -> ConcurrentHashMap.newKeySet())
                .add(session);
    }

    @OnClose
    public void onClose(Session session, @PathParam("showId") Long showId) {
        Set sessions = SESSIONS.get(showId);
        if (sessions != null) sessions.remove(session);
    }

    /** 座位状态变更时,推送给同场次所有在线用户 */
    public static void broadcast(Long showId, Long seatId, int status) {
        Set sessions = SESSIONS.getOrDefault(showId, Set.of());
        String msg = String.format("{\"seatId\":%d,\"status\":%d,\"ts\":%d}",
                seatId, status, System.currentTimeMillis());
        for (Session s : sessions) {
            try {
                if (s.isOpen()) s.getBasicRemote().sendText(msg);
            } catch (IOException e) {
                sessions.remove(s);
            }
        }
    }
}

Remember to add one more step when deploying multiple instances: after the seat status changes, it is broadcast to other instances through Redis Pub/Sub, and each instance pushes the connection it holds. The front end partially refreshes the seat map after receiving the push, and the user does not need to refresh it manually.

After talking about the code, let’s summarize all the possible overturns in this system – this table is worth saving before the interview.

7. Summary table of technical difficulties

# difficulty essential question solution
1 High concurrency oversold Two people lock the same seat at the same time Redis Lua Atomic Judgment → Database status CAS → unique index, three layers of protection
2 Multi-seat partial success Choose 4 seats and lock 2 Lua first verifies everything and then writes everything, all success or failure; if it fails, it only rolls back its own lock.
3 Redis succeeds but DB fails Orphan lock, two-layer status inconsistency try-catch compensation rollback; reconciliation task scan “Redis has lock, DB has no order”
4 No release will be issued if payment is not made within the time limit. Seats were occupied maliciously Delayed message main + database expire_at Scan at regular intervals without using keyspace events
5 Lock timed out and released by mistake The user is paying, but the lock expires Watchdog renewal; released with version number / lock_user_id check
6 Duplicate submission/connect dots Generate multiple orders with one click Front button grayed out and anti-shake + clientToken Idempotent table + unique index
7 Duplicate/out of order payment callbacks Channels can retry up to 10 times State machine atomic UPDATE, rows=0 will directly return success;outTradeNo only
8 MQ message repeated consumption Paid orders are released Consumer idempotence: check order status first, then update conditions
9 Ticket issuance failed and pending The money was paid but the ticket was not issued. Transaction message/local message table + exponential backoff retry + automatic refund after 3 failures + manual error pool
10 Traffic impact of hot spots QPS penetrates the database at the moment of sale Seat data is preheated in advance and entered into Redis + gateway limits traffic by session + front-end queuing + Redis sharding, segmented by seat if necessary / SKIP LOCKED
11 Seat map bandwidth explosion The full amount of JSON pulled each time is 100KB+ Bitmap compressed to 250 bytes + push only incremental changes + CDN / local cache
12 Cache breakdown/penetration/avalanche Hotspot key failure penetrates the database sync=true Solo flight + null cache + random TTL + local cache degradation
13 Scalpers maliciously occupy seats Script batch lock base Current limit (IP / uid / device) + purchase limit + verification code + risk control asynchronous scoring, not entering the main link
14 Cache and database consistency Inconsistent double writing and confusing seat status Cache Aside: Update the database first and then delete the cache. The database is the final authority.

This table basically covers all the questioning directions of this question. If you are preparing for an interview recently, you can follow me first. This series will continue to dismantle high-frequency scenario questions from major manufacturers. The next article is the same classic flash sale system. The idea is in the same vein as the “funnel + pocket” of this article. It will be easier to understand if you read it in succession.

8. The interviewer’s follow-up question: Answering these is the high score line

The real watershed lies in questioning. Pick the 8 most frequent ones and give you the key points for answering the questions.

Q1: Why not use Redisson distributed lock to lock the seat?
Because Redis has already given me the atomicity I want. Distributed locks solve the problem of “who can enter the critical section”, while Lua directly solves the problem of “who gets the seat”, one less RTT, one less lock wait, and the problems of unlocking and lock renewal are not forgotten. I only use distributed locks for low-frequency coordination: initializing session inventory, administrator lock seats, and full-site compensation migration.

Q2: What about Redlock?
Redisson has officially put the old RedLock The object is marked deprecated, redirected RLock + Replica synchronization check + Direction of fencing token. More importantly, locks cannot solve repeated submissions and dirty writes.Idempotence ultimately relies on unique keys and state machines. So I won’t take Redlock for this question.

Q3: What should I do if Redis crashes?
Downgrade plan: hot spots are switched to database optimistic locking CAS + strong current limiting, sacrificing experience to ensure accuracy; the seat map reads the local Caffeine; after Redis is restored, it is fully loaded and rebuilt from MySQL. Just one principle:Never let “Redis not available = cannot sell tickets”, and never let “Redis not available = oversold”。

Q4: What should I do if I lock 3 cards at a time and the third one is stolen?
In Lua, all are verified first and then all are written, either all three are locked or one is not locked. If the database CAS stage fails, Lua must be executed to reverse the lock and put back the 2 cards that have been locked in Redis——The compensation path must be written. This is the most likely place to be asked.。

Q5: How about hundreds of thousands of people grabbing popular movies at the same time?
The front-end adds a queuing page (token bucket), the back-end uses Sentinel to limit traffic by session, Redis fragments by session to break up hot spots, and in extreme cases, a “pre-sale + queuing” strategy is implemented.

Q6: How to ensure eventual consistency?
Reconciliation task: Compare the seat status of Redis and MySQL every 5 minutes. If there is any inconsistency, MySQL shall prevail; at the same time, scan the orphan locks “Redis has a lock and the database has no single” to clean up. Orders, payments, and third-party ticketing must also be reconciled regularly, and any errors will be entered into the compensation pool.

Q7: Can I leave a “single empty seat”?
It can be made into a rule engine: no single seats are allowed, priority is given to connecting seats, and seats for couples are not sold separately. But remember, this type of rule is placed inSeat selection verification layerdo not enter the main link of the lock seat, and do not slow down the hotspot path for the sake of experience rules.

Q8: Can QPS figures be reported?
I don’t memorize figures like “Lua 100,000/database 2,000” that are passed down from the Internet. They fluctuate greatly depending on machine specifications, script complexity, and index conditions. I’ll sign upArchitecture convergence ratio: “When 100,000 QPS comes in, the Lua layer blocks 99%, and the write concurrency dropped to the database converges to a few hundred, which is equal to the real number of seats.” This statement can withstand questioning.

In addition to asking questions, there are a few extra points that can be proactively thrown out:

  • State machine closing: Only legal jumps are allowed, guaranteed by enumeration + CAS, and any wild paths are prohibited;
  • Grayscale and pressure measurement: Use JMeter to press 100,000 QPS for hot spots in advance to verify the bottlenecks of Lua scripts and database connection pools;
  • Observability: Lock seat success rate, timeout release amount, database conflict rate management, problems can be located in seconds;
  • Seat recommendation: According to the “Golden Movie Viewing Area” rating, priority is given to recommending the middle and rear seats, which will also increase the seat rate;
  • Peak cutting means: RateLimiter current limit + request queuing + seat segment lock to reduce lock competition.

The interviewer’s scoring criteria are actually very clear:

  • passing line: Can answer “distributed lock to prevent oversold”;
  • good line: Can answer “Lua atomic lock multi-seat + delayed message release”;
  • High score line: A complete closed loop of “Redis supports concurrency + database optimistic lock double insurance + idempotent CAS + reconciliation guarantee”.

9. Summary

Looking back at this system, there are actually three lines:

  • Hot reading: Multi-level cache + Bitmap seat map to solve the problem of “multiple people watching”;
  • Hot topics to write: Redis Lua lock-free pre-deduction + MySQL version CAS double-layer adjudication to solve “multiple people grabbing”;
  • eventual consensus: Transaction message drives ticket issuance, state machine maintains idempotence, delayed message + scheduled scan tube timeout, reconciliation complete.

Here are two more implementation suggestions. First,Phased implementation: For ordinary sessions, it is enough to use database optimistic locking first. If hot spots appear in the stress test, then Lua preemption and sharding are used. Introducing Redis withholding prematurely is paying the price of consistency for non-existent concurrency, which is not cost-effective. Second, if you want to dig deeper into hot row processing, you can go to the MySQL official blog.SKIP LOCKED / NOWAIT The example uses the ticket booking scenario; Redis’ official Eval intro and Keyspace Notifications documents also recommend reading the original text and not reciting second-hand blogs.

After all, the seat selection system isA variation of the inventory deduction problem: Seat lock is not a question of “which lock to use”, but a set of resource status control protocols. Finally, I will give you a mantra that I blurted out during the interview:

Read the cache, rely on Lua for locking, rely on messages for timeout, optimistic locking, and reconciliation for insurance.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *