第 49 章 / 共 57 章 · 约 9 分钟
LLD 案例:电影院选座购票
电影选座和系统设计里的订票系统是同一个问题的两种问法。这一章停在类和代码层面:座位状态挂在哪,多个座位怎么“要么全占、要么全不占”,占座超时怎么释放。
需求澄清
功能需求
- 影院有多个影厅,每个影厅有固定的座位图,座位分普通座和优选座。
- 场次 = 电影 + 影厅 + 开始时间。用户选场次、看座位图。
- 一次最多选 10 个座位;选好后锁座 8 分钟,在这段时间内付款。
- 付款成功则出票;超时或取消则释放座位。
- 票价按座位类型和场次(比如晚场、周末)计算。
非功能需求
- 同一场次的同一个座位不能卖给两个人。
- 热门场次开售时会有大量用户同时抢同一批座位。
核心类
| 类 | 职责 |
|---|---|
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里加一个可插拔的校验规则。