PAYATHON 2026

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。布局不规则时,则从数据库或配置中读取坐标。

页面加载和渲染流程

可以按以下流程处理:

  1. 加载剧场的布局定义。
  2. 从数据库批量读取所有座位的当前状态。
  3. 在内存中生成一份 seating plan 快照。
  4. 根据状态设置颜色或样式。
  5. 用户选择座位时,只更新被点击的座位。
  6. 提交预订时,由数据库重新验证并以原子方式锁定座位。

不要为 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 控件数量、并发预订和支付流程更值得投入设计精力。

备注:内容仅供参考。