Theatre Seat booking 应采用哪种内存 Data Structure?
结论
对于固定为 26 行、每行最多 100 个座位的剧场,用二维数组保存当前座位状态就很合适:
SeatStatus[,] seats = new SeatStatus[26, 100];
SeatStatus 可以定义为底层类型为 byte 的枚举。2600 个座位的原始状态数据大约只占 2.6 KB,不值得为此引入复杂的位压缩。
需要注意的是,数组只保存”当前状态”。实际系统应该把三类数据分开:
- 座位布局:行号、座位号、屏幕坐标、过道和不可用区域。
- 座位状态:空闲、临时保留、已付款等。
- 预订记录:订单、客户、价格、保留期限和付款信息。
WPF 和网页端都可以根据布局与状态动态生成座位图,不必为每个剧场制作包含大量固定控件的静态页面。这个系统真正需要关注的不是数组性能,而是数据库中的并发预订和过期释放。
为什么二维数组已经足够高效
系统总共只有:
26 × 100 = 2600
个座位。五种状态理论上只需 3 bit:
ceil(log2(5)) = 3
即使每个座位直接占用一个 byte,整个状态数组也只有约 2600 字节。与页面控件、HTTP 请求、数据库连接和图片资源相比,这点内存基本可以忽略。
位压缩还能继续缩小内存,但会增加不少麻烦:
- 每次读取和修改都要进行位运算。
- 调试时不够直观。
- 并发修改和序列化更复杂。
- 对于 2600 个座位,节省的内存没有实际意义。
因此,这里使用 byte 枚举比 bit field 更合适。
推荐的数据模型
先定义清晰的座位状态:
public enum SeatStatus : byte
{
Empty = 0,
ReservedNotPaid = 1,
ReservedNotPaidByMember = 2,
Paid = 3,
PaidMemberRate = 4
}
再用二维数组保存某一时刻的座位图快照:
public sealed class SeatingPlan
{
private const int RowCount = 26;
private const int SeatsPerRow = 100;
private readonly SeatStatus[,] _statuses =
new SeatStatus[RowCount, SeatsPerRow];
public SeatStatus GetStatus(char row, int seatNumber)
{
return _statuses[GetRowIndex(row), GetSeatIndex(seatNumber)];
}
public void SetStatus(char row, int seatNumber, SeatStatus status)
{
_statuses[GetRowIndex(row), GetSeatIndex(seatNumber)] = status;
}
private static int GetRowIndex(char row)
{
char normalized = char.ToUpperInvariant(row);
if (normalized is < 'A' or > 'Z')
throw new ArgumentOutOfRangeException(nameof(row));
return normalized - 'A';
}
private static int GetSeatIndex(int seatNumber)
{
if (seatNumber is < 1 or > SeatsPerRow)
throw new ArgumentOutOfRangeException(nameof(seatNumber));
return seatNumber - 1;
}
}
使用时可以按业务中的座位号直接访问:
var plan = new SeatingPlan();
plan.SetStatus('J', 56, SeatStatus.ReservedNotPaid);
plan.SetStatus('J', 57, SeatStatus.ReservedNotPaid);
plan.SetStatus('J', 58, SeatStatus.ReservedNotPaid);
SeatStatus status = plan.GetStatus('J', 56);
由于数组下标从零开始,J56 对应的实际下标是:
rowIndex = 'J' - 'A' = 9
seatIndex = 56 - 1 = 55
不要把布局信息硬编码进状态数组
如果剧场始终是完整的 26×100 矩形,二维数组足以表达所有座位。但实际的 seating plan 往往还包括:
- 过道
- 舞台
- 轮椅位
- 包厢
- 缺失座位
- 不规则行
- 不同宽度的区域
- 视觉上需要偏移的座位
这些布局信息不适合用特殊的座位状态来表示。更合理的设计是为每个实际座位保存布局元数据:
public sealed record SeatDefinition(
long SeatId,
char Row,
int Number,
double X,
double Y,
string Section,
bool IsBookable);
运行时将布局与状态组合成 UI 所需的模型:
public sealed record SeatViewModel(
long SeatId,
string Label,
double X,
double Y,
SeatStatus Status);
各模型的职责如下:
SeatDefinition很少变化,可以缓存。SeatStatus从数据库读取。SeatViewModel负责当前页面的显示。
布局很规则时,可以根据行列计算 X 和 Y。布局不规则时,则从数据库或配置中读取坐标。
页面加载和渲染流程
可以按以下流程处理:
- 加载剧场的布局定义。
- 从数据库批量读取所有座位的当前状态。
- 在内存中生成一份 seating plan 快照。
- 根据状态设置颜色或样式。
- 用户选择座位时,只更新被点击的座位。
- 提交预订时,由数据库重新验证并以原子方式锁定座位。
不要为 2600 个座位分别查询数据库。应该一次批量读取,例如:
SELECT
SeatId,
Status,
ReservationId,
ReservedUntil
FROM SeatAvailability
WHERE PerformanceId = @PerformanceId;
应用层再把查询结果映射到数组,或映射到以 SeatId 为键的字典中。
数组还是 Dictionary
如果每一行都固定包含 1 到 100 号座位,二维数组最简单:
SeatStatus[,] statuses = new SeatStatus[26, 100];
如果布局不规则,或者数据库中的 SeatId 才是稳定标识,可以改用字典:
Dictionary<long, SeatStatus> statusesBySeatId = new();
两者也可以配合使用:
SeatId用于数据库操作和预订。- 行号、座位号用于显示。
- 二维数组用于快速生成规则布局。
- 字典用于关联数据库返回的状态。
不要把 "J56" 当作数据库中的唯一业务标识。演出厅布局可能发生调整,而且解析字符串容易遇到大小写、补零和格式差异。数据库应该使用稳定的 SeatId,J56 只作为展示名称。
WPF 中的渲染方式
2600 个座位对 WPF 来说不算多,但如果每个座位都使用复杂的 UserControl,加载和布局仍可能产生较明显的开销。
规则布局可以使用 ItemsControl 和轻量模板:
<ItemsControl ItemsSource="{Binding Seats}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}" />
<Setter Property="Canvas.Top" Value="{Binding Y}" />
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button
Width="18"
Height="18"
Padding="0"
ToolTip="{Binding Label}"
Background="{Binding StatusBrush}"
Command="{Binding DataContext.SelectSeatCommand,
RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
如果界面需要频繁缩放、拖动,或者要在同一屏幕中显示更多图形元素,可以考虑:
- 自定义
FrameworkElement,在OnRender中统一绘制。 - 使用
DrawingVisual。 - 将静态背景与动态座位状态分层绘制。
静态 WPF 页面可以用作布局模板,但不建议手工预建 2600 个固定控件。布局变化后,这种页面维护起来很麻烦。通过数据绑定动态生成座位,更便于复用和测试。
Web 端的渲染方式
网页端可以根据布局和交互方式选择:
- CSS Grid:适合规则的矩形布局。
- SVG:适合不规则布局、缩放和单个座位点击。
- Canvas:适合大量图元和高频重绘,但点击命中与无障碍支持需要自行处理。
对于 2600 个座位,SVG 通常能较好地兼顾开发和交互需求。每个座位可以使用一个带 data-seat-id 的图形元素:
<svg id="seating-plan" viewBox="0 0 2200 700">
<rect
data-seat-id="12345"
data-status="empty"
x="420"
y="240"
width="16"
height="16"
rx="3">
</rect>
</svg>
状态变化时,只需修改对应元素的 class,不必重新生成整个页面:
function updateSeatStatus(seatId, status) {
const seat = document.querySelector(
`[data-seat-id="${CSS.escape(String(seatId))}"]`
);
if (!seat) {
return;
}
seat.dataset.status = status;
seat.className.baseVal = `seat seat--${status}`;
}
即使每次都重新渲染全部 2600 个座位,这通常也不是系统的主要瓶颈。不过,局部更新可以减少闪烁,并保留用户当前选择的座位和缩放位置。
预订状态不能只存在于内存中
内存数组只是一份显示快照,不能用来最终判断座位是否可订。300 名内部用户和公共网站用户可能同时选择同一个座位,因此即使在提交前查询一次,仍然会出现竞态条件:
用户 A 查询:J56 可用
用户 B 查询:J56 可用
用户 A 写入预订
用户 B 也写入预订
数据库必须通过事务、条件更新或唯一约束,保证同一场次的同一座位只能存在一个有效预订。
常见做法之一是使用条件更新:
UPDATE SeatAvailability
SET
Status = @ReservedStatus,
ReservationId = @ReservationId,
ReservedUntil = @ReservedUntil
WHERE PerformanceId = @PerformanceId
AND SeatId = @SeatId
AND (
Status = @EmptyStatus
OR (
Status IN (@ReservedStatus, @MemberReservedStatus)
AND ReservedUntil < @Now
)
);
应用程序必须检查受影响的行数:
if (affectedRows != 1)
{
throw new SeatNoLongerAvailableException(seatId);
}
预订 J56、J57、J58 时,应该在同一个事务中尝试锁定全部座位。如果其中任何一个失败,可以根据业务规则选择:
- 整组回滚,并提示用户重新选择。
- 保留成功锁定的座位,再让用户确认其余选择。
多数剧场预订更适合整组回滚,以免客户最终拿到不连续的座位。
数据库还应该为 (PerformanceId, SeatId) 建立唯一约束或唯一索引。具体 SQL 取决于数据库产品和表结构,但原则不变:最终一致性必须由持久化层保证,不能依赖某个 WPF 客户端或 Web 服务器中的内存状态。
临时保留和支付状态
临时保留记录至少应该包含:
ReservationId
PerformanceId
SeatId
Status
ReservedUntil
CustomerId
PriceType
支付成功后,在事务中把状态改为 Paid 或 PaidMemberRate。如果支付失败或超过 ReservedUntil,座位可以重新进入可预订状态。
释放过期座位不一定要等待清理任务。查询和条件更新时,可以直接把超过 ReservedUntil 的临时保留视为可用,后台任务再负责清理数据。所有服务器都应该采用统一的时间基准,例如 UTC,避免不同机器的本地时间影响判断。
状态设计上的一个改进
现有五种状态混合了两个不同概念:
- 预订阶段:
Empty、Reserved、Paid - 价格或客户类型:普通、会员
业务扩展后,可能还会出现员工票、儿童票和优惠票。继续为每种组合增加枚举值,很快就会变得难以维护。
更灵活的方式是分别保存这两个概念:
public enum BookingState : byte
{
Empty = 0,
Reserved = 1,
Paid = 2
}
public enum RateType : byte
{
Standard = 0,
Member = 1
}
座位状态可以相应地表示为:
public sealed record SeatBookingState(
BookingState State,
RateType? RateType,
Guid? ReservationId,
DateTimeOffset? ReservedUntil);
如果业务规则确定始终只有当前五种状态,使用单一的 SeatStatus : byte 会更简单。如果票价类型以后可能增加,把预订状态和价格类型拆开会更容易维护。
最终建议
用 SeatStatus[26, 100] 保存规则座位图的内存快照完全可行。实现简单,访问速度快,内存占用也几乎可以忽略。
系统设计还应包括:
- 使用稳定的
SeatId关联数据库记录。 - 分开建模布局、状态和订单信息。
- 一次性批量读取座位状态。
- WPF 使用数据绑定或轻量的自定义绘制。
- Web 端优先考虑 SVG 或 CSS Grid。
- 只更新状态发生变化的座位。
- 在数据库事务中以原子方式检查并保留座位。
- 使用
ReservedUntil处理支付失败和超时。 - 不要为了节省几 KB 内存而引入位压缩。
在这个规模下,数据结构不会成为主要的性能风险。数据库往返、UI 控件数量、并发预订和支付流程更值得投入设计精力。
备注:内容仅供参考。
