cswork / 系统设计

第 49 章 / 共 57 章 · 约 9 分钟

LLD 案例:电影院选座购票

本页目录
  1. 需求澄清
  2. 核心类
  3. 用到的设计模式
  4. 核心代码
  5. 付款和锁座过期赛跑
  6. 放进数据库
  7. 扩展讨论
  8. 章节测验

电影选座和系统设计里的订票系统是同一个问题的两种问法。这一章停在类和代码层面:座位状态挂在哪,多个座位怎么“要么全占、要么全不占”,占座超时怎么释放。

需求澄清

功能需求

  1. 影院有多个影厅,每个影厅有固定的座位图,座位分普通座和优选座。
  2. 场次 = 电影 + 影厅 + 开始时间。用户选场次、看座位图。
  3. 一次最多选 10 个座位;选好后锁座 8 分钟,在这段时间内付款。
  4. 付款成功则出票;超时或取消则释放座位。
  5. 票价按座位类型和场次(比如晚场、周末)计算。

非功能需求

  • 同一场次的同一个座位不能卖给两个人。
  • 热门场次开售时会有大量用户同时抢同一批座位。

核心类

类职责
Cinema / Screen / Seat影院、影厅、物理座位(编号、类型),基本不变
Show一个场次:电影、开始时间、本场每个座位的状态;锁座、确认、释放
ShowSeat某个座位在这一场的状态:空闲、锁定中、已售,以及锁座编号和截止时间
Booking订单:用户、座位、金额、状态
电影选座类图:影院、影厅、场次与座位状态
滚动到此处后加载图表…
图表可横向滚动,展开后可缩放查看。
查看图表源码
classDiagram
  accTitle: 电影选座类图:影院、影厅、场次与座位状态
  class Cinema {
    -String id
    -String city
  }
  class Screen {
    -String name
    -List seats
  }
  class Seat {
    -String id
    -Category category
  }
  class Show {
    -Movie movie
    -Instant start
    -Map showSeats
    +hold(List ids, Duration ttl) String
    +confirm(String holdId)
    +release(String holdId)
  }
  class ShowSeat {
    -SeatState state
    -String holdId
    -Instant holdUntil
    +freeAt(Instant now) boolean
  }
  class Booking {
    -String id
    -String user
    -List seats
    -long amount
    -BookingStatus status
  }
  Cinema "1" *-- "many" Screen
  Screen "1" *-- "many" Seat
  Show --> Screen
  Show "1" *-- "many" ShowSeat
  ShowSeat --> Seat
  Booking --> Show

关键在 Seat 和 ShowSeat 分开:F7 这个座位今晚 7 点那场已售,9 点那场可能还空着。状态属于“座位 × 场次”,不属于座位本身。

用到的设计模式

  • 状态模式(轻量版):ShowSeat 的三个状态和转换规则:空闲 → 锁定 → 已售,锁定 → 空闲(取消、超时)。规则简单,用枚举就够了。
  • 策略模式:票价规则 PricingStrategy,按座位类型、时段、优惠码组合。
  • 门面:BookingService 串起锁座、调用支付、确认出票,Show 只负责座位状态。

核心代码

enum SeatState { AVAILABLE, HELD, BOOKED }

class ShowSeat {
    final Seat seat;
    SeatState state = SeatState.AVAILABLE;
    String holdId;
    Instant holdUntil;

    ShowSeat(Seat seat) { this.seat = seat; }

    // An expired hold counts as free: no clean-up job is needed for correctness.
    boolean freeAt(Instant now) {
        return state == SeatState.AVAILABLE || (state == SeatState.HELD && now.isAfter(holdUntil));
    }
}

class Show {
    private final Map<String, ShowSeat> seats;   // "F7" -> this show's state of seat F7
    private final Clock clock;

    Show(Map<String, ShowSeat> seats, Clock clock) { this.seats = seats; this.clock = clock; }

    synchronized String hold(List<String> ids, Duration ttl) {
        if (ids.isEmpty() || ids.size() > 10) throw new IllegalArgumentException("Pick 1 to 10 seats");
        Instant now = clock.instant();
        List<ShowSeat> wanted = new ArrayList<>();
        for (String id : ids) {
            ShowSeat s = seats.get(id);
            if (s == null) throw new IllegalArgumentException("No seat " + id);
            if (!s.freeAt(now)) throw new SeatTakenException(id);   // all or nothing
            wanted.add(s);
        }
        String holdId = UUID.randomUUID().toString();
        for (ShowSeat s : wanted) {
            s.state = SeatState.HELD;
            s.holdId = holdId;
            s.holdUntil = now.plus(ttl);
        }
        return holdId;
    }

    synchronized void confirm(String holdId) {             // after payment succeeds
        Instant now = clock.instant();
        List<ShowSeat> mine = seats.values().stream().filter(s -> holdId.equals(s.holdId)).toList();
        boolean valid = !mine.isEmpty()
            && mine.stream().allMatch(s -> s.state == SeatState.HELD && !now.isAfter(s.holdUntil));
        if (!valid) throw new HoldExpiredException();        // the caller refunds
        mine.forEach(s -> s.state = SeatState.BOOKED);
    }

    synchronized void release(String holdId) {             // user cancelled or payment failed
        for (ShowSeat s : seats.values())
            if (holdId.equals(s.holdId) && s.state == SeatState.HELD) {
                s.state = SeatState.AVAILABLE;
                s.holdId = null;
            }
    }
}

几个细节:

  • 全有或全无:先检查所有座位都空闲,再一起锁定。用户选了 4 个座位,其中一个被别人抢先,就整体失败,让用户重新选,而不是悄悄锁住另外 3 个。
  • 一个场次一把锁:synchronized 锁的是 Show 对象,不同场次之间互不影响。多个座位在同一把锁里处理,不会出现两个请求各拿一部分座位互相等待的死锁。如果改成每个座位一把锁,就必须按座位编号排序后依次加锁。
  • 惰性过期:锁定中的座位过了截止时间,在 freeAt 里直接视为空闲。定时清理任务只是为了让座位图显示得更及时,正确性不依赖它。

付款和锁座过期赛跑

付款可能在第 8 分 30 秒才成功,此时锁座已经过期,座位甚至可能已被别人锁走。confirm 会检查每个座位是否仍属于这个锁座编号且未过期,不满足就抛出异常,由 BookingService 发起退款。和酒店预订一章的思路一样:付款成功不等于出票成功。

放进数据库

单机内存里用锁就够了;座位状态存在数据库里时,用一条条件更新完成“检查 + 锁定”:

UPDATE show_seats
SET state = 'HELD', hold_id = :hold, hold_until = :until
WHERE show_id = :show AND seat_id IN (:seats)
  AND (state = 'AVAILABLE' OR (state = 'HELD' AND hold_until < :now));
-- affected rows < number of seats: ROLLBACK, someone else got one of them

整个过程放在一个事务里,受影响的行数等于座位数才提交。

扩展讨论

  • 排队进场:热门场次开售时,在锁座之前加一个虚拟排队,限制同时进入选座页面的人数。
  • 座位推荐:给出“连在一起的 N 个最佳座位”,按排和到中线的距离打分。
  • 退票:开场前若干小时允许退票,座位从已售回到空闲,按规则退款。
  • 防止孤座:一些影院不允许选座后留下单个空位,可以在 hold 里加一个可插拔的校验规则。

动手实现(可判题):电影选座

章节测验

3 道选择题
  1. 座位的“已售/空闲”状态为什么挂在 ShowSeat 上,而不是 Seat 上?
  2. 用户一次选了 4 个座位,其中 1 个刚刚被别人锁定。hold 应该怎么做?
  3. 代码里没有清理过期锁座的定时任务,过期的座位为什么还能被别人买到?

章节讨论

正在加载讨论…