如何使用 Prisma 模型构建高并发票务预订系统 使用 Prisma 简单方法 乐观并发控制 (OCC) ZenStack 方法

2025-06-07

如何使用 Prisma 构建高并发票务预订系统

使用 Prisma 建模

简单的方法

乐观并发控制(OCC)

ZenStack 方法

如果你有机会去英国,想体验一些独特的东西,我推荐你去观看一场英超联赛。你将体验到传统足球独特的激情和文化。

不过,获得门票需要一些努力,因为目前门票很难买到。最可靠的方法是通过球队的官方网站预订。偶尔,你可能会幸运地找到可用的门票,例如下面显示的门票:

预订

但是一旦你点击它,它就消失了😂

票已售出

我相信你可能在一些机票预订系统中遇到过类似的情况。让我们看看如何以最小的努力实现一个这样的系统。

您可以在以下 GitHub 仓库中找到所有使用的代码:

https://github.com/jiashengguo/ticket-booking-prisma-example

使用 Prisma 建模

Prisma是一种现代 Typescript ORM,它采用模式优先的方法并为您的数据库操作生成完全类型安全的客户端。

可以使用以下两个模型简化预订系统:



model Seat {
    id Int @id @default(autoincrement())
    userId Int?
    claimedBy User? @relation(fields: [userId], references: [id])
}

model User {
    id Int @id @default(autoincrement())
    name String?
    seat Seat[]
}


Enter fullscreen mode Exit fullscreen mode

假设我们有 1000 个席位和 1200 个用户。您可以使用以下命令填充数据库:



npm run seed


Enter fullscreen mode Exit fullscreen mode

简单的方法

直观的做法是找到第一个可用的座位,并将该座位分配给用户。代码如下:



async function bookSeat(userId: number) {
    // Find the first available seat
    const availableSeat = await client.seat.findFirst({
        where: {
            claimedBy: null,
        },
        orderBy: [{ id: "asc" }],
    });

    if (!availableSeat) {
        throw new Error(`Oh no! all seats are booked.`);
    }
    // Claim the seat
    await client.seat.update({
        data: {
            userId,
        },
        where: {
            id: availableSeat.id,
        },
    });
}


Enter fullscreen mode Exit fullscreen mode

然而,它有一个重大缺陷。假设预订开始后,所有 1200 人都会在 10 秒内立即尝试预订座位。这在像阿森纳对阵曼联这样的英超大型足球比赛中很常见。为了模拟这种情况,请在函数开头添加以下代码bookSeat



// Stimulate random booking time between 10s
await new Promise((resolve) => setTimeout(resolve, Math.random() * 10000));


Enter fullscreen mode Exit fullscreen mode

然后,在所有图书请求完成后,让我们查询数据库以查看实际已申请的座位数:



async function demonstrateLostUpdate() {
    let updatedCount = 0;
    let promises = [];
    for (let i = 0; i < 1200; i++) {
        promises.push(bookSeat(i));
    }

        await Promise.allSettled(promises)
        .then((values) => {
           updatedCount = values.filter((x) => x.status === "fulfilled").length;
        })
        .catch((err) => {
            console.error(err.message);
        });

    // Detect lost-updates
    const actualCount = await client.seat.count({
        where: {
            NOT: { claimedBy: null },
        },
    });
    console.log({
        successUpdatedCall: updatedCount,
        actualUpdatedCount: actualCount,
    });
    process.exit();
}


Enter fullscreen mode Exit fullscreen mode

您可以通过运行它npm run simple,您将看到如下结果:



{ successUpdatedCallCount: 1200, actualUpdatedCount: 863 }


Enter fullscreen mode Exit fullscreen mode

💡如果使用 sqlite,由于某些请求超时,成功更新调用可能会少于 1200。

结果明显是错误的:

  1. 座位数量仅有1000个,但1200个请求电话全部成功。
  2. 实际更新次数与成功更新调用次数不匹配。

这是因为该代码存在“重复预订问题”——两个人可能会预订相同的座位:

  1. 3A座位返回Sorcha(findFirst
  2. 3A 座位归还给 Ellen ( findFirst)
  3. 3A 席位由 Sorcha 占据(update
  4. Ellen 声称拥有 3A 号座位(update - 覆盖了 Sorcha 的声称)

尽管 Sorcha 已成功预订座位,但系统最终仍会存储 Ellen 的申请。

本质上,这是数据库中并发的“读-修改-写”问题。解决这个问题最直接的方法是使用数据库锁。然而,虽然锁定本身并不坏,但在高并发环境中,即使你只是短时间锁定单个行,也可能导致意想不到的后果。

另一方面,避免在具有大量并发请求的应用程序中锁定可以使应用程序更能适应负载并且整体上更具可扩展性。

让我们看看如何实现这一点。

乐观并发控制(OCC)

如果能够检测到记录在读取和写入之间发生了变化,我们可以抛出错误并导致当前请求失败。这被称为乐观并发控制 (OCC) 模型,用于在不依赖锁定的情况下处理单个实体上的并发操作。

为了实现这一点,我们需要添加一个并发令牌(时间戳或版本字段)。让我们Version在模型中添加一个字段Seat



model Seat {
    id Int @id @default(autoincrement())
    userId Int?
    claimedBy User? @relation(fields: [userId], references: [id])
    version Int
}


Enter fullscreen mode Exit fullscreen mode

接下来我们检查一下version更新前的字段:



// Only mark the seat as claimed if the availableSeat.version
// matches the version we're updating. Additionally, increment the
// version when we perform this update so all other clients trying
// to book this same seat will have an outdated version.
await client.seat.update({
    data: {
        userId: userId,
        version: {
            increment: 1,
        },
    },
    where: {
        id: availableSeat.id,
        // This version field is the key
        // only claim seat if in-memory version matches 
        // database version, indicating that the field has not 
        // been updated
        version: availableSeat.version,
    },
});


Enter fullscreen mode Exit fullscreen mode

现在两个人不可能预订同一个座位:

  1. 座位 3A 返回 Sorcha(version 为 0)
  2. 座位 3A 归还给 Ellen(version 为 0)
  3. 座位 3A 被 Sorcha 认领(version 增加至 1,预订成功)
  4. Ellen 已认领 3A 座位(内存 version (0) 与数据库 (1) 不匹配 version - 预订失败)

您可以通过运行修改后的版本来验证npm run occ



{ successUpdatedCallCount: 824, actualUpdatedCount: 824 }


Enter fullscreen mode Exit fullscreen mode

结果表明,只有 824 个座位被认领。这意味着 376 人(1200-824)会看到开头显示的“票已售罄”页面。虽然这没什么大不了的,但受影响的人可以刷新页面,选择另一张票,希望不再看到“票已售罄”的页面。😂

实际上,这种方法在 Prisma 的官方文档中有详细说明。您可以在那里找到更多详细信息。

https://www.prisma.io/docs/guides/performance-and-optimization/prisma-client-transactions-guide#optimistic-concurrency-control

ZenStack 方法

虽然这种方法已经很简洁了,但务必记住在过滤器version中添加检查。幸运的是, ZenStackwhere的访问策略可以简化这一操作。您可以在模型中添加一条策略规则updateSeat



model Seat {
    id Int @id @default(autoincrement())
    userId Int?
    claimedBy User? @relation(fields: [userId], references: [id])
    version Int

    @@allow("read", true)
    @@allow("update", future().version == 1)
}


Enter fullscreen mode Exit fullscreen mode

然后代码可以简化为:



await client.seat.update({
        data: {
            userId: userId,
            version: {
                increment: 1,
            },
        },
        where: {
            id: availableSeat.id,
        },
});


Enter fullscreen mode Exit fullscreen mode

您可以通过运行来验证结果npm run zen



{ updatedCountByUpdateMany: 587, actualUpdatedCount: 587 }


Enter fullscreen mode Exit fullscreen mode

如果您对其内部工作原理感兴趣,请查看我们的 GitHub 复制版:

https://github.com/zenstackhq/zenstack

文章来源:https://dev.to/zenstack/how-to-build-a-high-concurrency-ticket-booking-system-with-prisma-184n
PREV
如何在 Vercel 上托管 RESTful API
NEXT
📄 开源促销备忘单提供 8 种语言版本