Go中悲观锁、乐观锁+2PC实现分布式事务

在本文中,我将在基本的酒店预订系统中使用 Go 实现2PC(两阶段提交),并使用悲观锁定和乐观锁定。在此系统中,我们将重点创建预订流程并将使用 PostgreSQL 数据库。您可以在这里找到源代码!

为了更好地理解问题及其解决方案,我们首先从单体架构(只有一个数据库)开始,然后是微服务架构(多个数据库)。让我们开始吧!

我们有两个表,分别命名为room_type_inventory和reservation

  • room_type_inventory表包括酒店房间预订和房间。其数据模型有一些要点。首先,用户在给定的酒店预订一种房间类型(标准间、大床房、两居室等),并且 仅在用户入住时给出房间号。其次,我们为每个日期保留一行。通过这种方式,管理给定日期范围的预订和查询变得很容易。
  • reservation预订表包括用户预订数据。它的领域是不言自明的。状态表示预订状态。它可以是pending、、、和。cancelledpaidrefundedrejected

在整体中实施创建预订
使用room_type_inventory和预订表的事务。可以在这里找到实现。

1、如果用户多次点击“创建预订”按钮怎么办?如果不解决这个问题,至少会被预约两次。
我们必须在这里实现一些幂等性(多次应用操作总是产生相同的结果)机制!

这个问题有几个解决方案。其中之一是将reservation_id作为主键。这样,如果用户尝试插入具有相同预订 ID 的多条记录,则会收到pq: 重复键值违反唯一约束“reservation_pk”错误。

2、多个用户尝试同时预订同一个房间怎么办?
为了更好地理解这个问题,假设数据库隔离级别是不可序列化的,并且由于ACID需要数据库事务,因此它们必须独立于其他事务来完成其任务。结果,会出现锁竞争条件。

我们需要在这里实现一些锁机制。有几种技术。我们将探讨其中两个。悲观锁和乐观锁。

悲观锁定
悲观并发策略预先锁定资源。服务请求访问资源;如果它可用,应用程序将锁定它,并且在应用程序释放资源之前,可能发生的所有其他访问都将变得不可用。假设资源不可用(另一个使用者或线程已经锁定了该资源)。在这种情况下,访问将失败,应用程序将等待释放锁或操作失败。

为了在PostgreSQL中实现悲观锁,我们可以使用FOR UPDATE语句。我们需要做的就是锁定我们想要更改的行。

checkInventoryQuery := `
   SELECT date, total_inventory, total_reserved
   FROM room_type_inventory
   WHERE hotel_id = $1 and room_type_id = $2 AND date between $3 AND $4
   FOR UPDATE`

可以在这里找到实现。

我们还可以通过外部工具(如 Consul 或 ZooKeeper)来实现分布式锁的悲观并发。

不幸的是,悲观锁定并不是万能的灵丹妙药。让我们回顾一下它的优点和缺点。
优点

  • 通过一次仅允许一个业务事务访问数据来防止并发业务事务之间的冲突。
  • 当发生冲突的可能性很高或冲突的代价不可接受时,这是适当的。

缺点
  • 可能会发生死锁。您必须实现无死锁,例如提供锁定时间或实现死锁检测机制。
  • 如果事务锁定资源,则会影响数据库性能,长期可扩展性可能会受到损害。

乐观并发控制
乐观并发策略假设不存在并发,并在发生并发时采取行动。通常,不涉及锁定;应用程序的流程在不同步的情况下运行。当应用程序要保留更改时,它会验证自操作开始以来是否发生了任何更改。如果是,应用程序将中止或重试该操作。

乐观锁与冲突检测有关,而悲观锁与冲突预防有关。

乐观锁通常将其冲突检测基于数据的版本标记。这可以是时间戳或顺序计数器。我使用room_type_inventory表中的整数版本列来实现它。首先,我们可以通过Select查询获取最新的版本值;其次,用它来更新查询的 where 子句。

checkInventoryQuery := `
   SELECT date, total_inventory, total_reserved, version
   FROM room_type_inventory
   WHERE hotel_id = $1 and room_type_id = $2 AND date between $3 AND $4`

updateInventoryQuery := `
  UPDATE room_type_inventory
  SET total_reserved = total_reserved + 1, version = version + 1 
  WHERE hotel_id = $1 AND room_type_id = $2 AND date between $3 AND $4 
    AND version = $5`


您可以在这里找到实现。
乐观锁定模式的好处之一是您可以对其进行扩展,以便客户端在 If-Not-Match 或 X-Expected-Version 标头中传递他们期望的版本号。在某些应用程序中,这有助于帮助客户端确保他们不会根据过时的信息发送更新请求。

让我们回顾一下它的优点和缺点。
优点

  • 它在并发机会较低的环境中表现出色。它们不涉及锁定,并且通常需要在检测到并发时重试该过程。

缺点
  • 在高并发环境中它会降低性能。

拥有一个数据库并使用带有几个并发控制的事务是很好的。我们如何在分布式环境(例如微服务中的多个数据库)中管理这个问题呢?我们来看看吧


在微服务中实施创建预订
我们有两个数据库:库存和预订。

  • 库存数据库只有一张表room_type_inventory,其字段与上述相同。
  • 预订数据库只有一张表:reservation,其字段与上述相同。

在这种架构中,处理数据一致性是非常具有挑战性的。如果我们保留了一些库存但无法插入任何预定数据库怎么办?我们必须解决这些失败案例。

有几种解决微服务环境中数据不一致的解决方案:2PC(两阶段提交)和Saga (编排和编排变体)。
在本文中,我使用PostgreSQL   prepared transaction  功能实现 2PC。

2PC(两阶段提交)
两阶段提交是一种用于实现跨多个节点的原子事务提交的算法,即确保所有节点都提交或所有节点中止。

我们可以通过悲观锁或乐观锁来实现 2PC。

在深入实现之前,我们先看看如何使用 PostgreSQL 中的准备好的事务功能。首先,您需要将max_prepared_transactions配置传递到 PostgreSQL 服务器。之后,您可以使用,例如

BEGIN;
UPDATE room_type_inventory
SET total_reserved = total_reserved + 1
WHERE hotel_id = 100 AND room_type_id = 1 AND date between '2023-10-12' AND '2023-10-13';
PREPARE TRANSACTION 'transaction-id';

COMMIT PREPARED 'transaction-id'; # to commit update transaction
ROLLBACK PREPARED 'transaction-id'; # to rollback update transaction

BEGIN;
INSERT INTO reservation (reservation_id, hotel_id, room_type_id, start_date, end_date, status)
VALUES ('65ac63c6-0f65-42c2-a05c-78a1ac9a384a', '100', '1', '2023-10-12', '2023-10-13', 'pending_pay');
PREPARE TRANSACTION 'transaction-create-id';

COMMIT PREPARED 'transaction-create-id'; # to commit insert transaction
ROLLBACK PREPARED 'transaction-create-id'; # to rollback insert transaction

还可以查询pg_prepared_xacts表中的活备living-prepared交易:

SELECT * from pg_prepared_xacts;

在实施步骤中,为了简单起见,我在同一个应用程序中连接了两个不同的数据库。

func (d *defaultService) CreateReservation(ctx context.Context, reservation *Reservation) error {
   inventoryTxID := uuid.NewString()
   reservationTxID := uuid.NewString()

   _, err := d.inventoryService.UpdatePrepared(ctx, reservation, inventoryTxID)
   if err != nil {
      d.inventoryService.RollbackPrepared(ctx, inventoryTxID)
      return err
   }

   if err = d.reservationService.CreatePrepared(ctx, reservation, reservationTxID); err != nil {
      d.inventoryService.RollbackPrepared(ctx, inventoryTxID)
      return err
   }

   d.inventoryService.CommitPrepared(ctx, inventoryTxID)
   d.reservationService.CommitPrepared(ctx, reservationTxID)

   return nil
}

源码可以找到悲观乐观的实现。