本文提供無停機遷移數據庫唯一ID的9步安全方案。核心分三階段:首先封裝ID類型實現新舊字段共存,其次通過功能開關逐步切換至UUID並監控性能,最後清理舊ID字段。關鍵要點包括解耦原始類型、回填數據、功能開關控制及索引優化,確保可隨時回滾。

這篇文章要講的是一個非常具體且棘手的問題:唯一 ID 遷移

現在有一個實體 User,由 User::$id 標識,看起來像這樣:

final class User
{
  public function __construct(
    public int $id,
  ) {}
}

訪問它的數據的方式是通過一個名為 UserRepository 的倉儲接口。這裏提供一個簡單的 SQLite 實現:

interface UserRepository
{
  /**
   * @throws  UserNotFoundException
   */
  public function findById(
    int $id
  ): User;
}

final class SqliteUserRepository
  implements UserRepository
{
  public function findById(
    int $id
  ): User {
    $sql = "...";
    $stmt = $this->pdo->prepare($sql);
    $stmt->execute([
      'id' => $id,
    ]);
    // ...
  }
}

非常簡單的設置。

現在假設你的團隊決定,出於安全原因,用户的唯一標識符不應再是整數,而應該採用 UUID。

當然,不允許停機。
原文鏈接 9 個步驟教你如何安全地遷移數據庫或字段

解決方案

為了絕對安全,將分三個階段實施:

  1. 讓兩個 ID 共存
  2. 實戰測試 UUID 實現
  3. 淘汰以前的整數 ID 實現

分階段進行的主要原因是,測試不足以確保一切都按預期工作。這個字段可能被其他作業通過 API 使用,或者我現在甚至無法想象的東西。

所以以防萬一,我希望能夠在任何時刻安全地回滾到以前的實現。

假設這裏描述的每一步都有適當的測試覆蓋,理想情況下是在重構發生之前。

步驟 1 - 從原始類型解耦

無論你接下來做什麼,沒有這個都不容易!User 類中的那個 int 原始類型就是在要求爆炸發生。

如果你想從整數平滑過渡到 UUID,最好的辦法是首先將代碼與原始類型解耦。一種方法是封裝你的原始類型。我將創建一個名為 UserId 的類,讓代碼依賴它而不是 int

final class UserId
{
  public function __construct(
    public int $id,
  ) {}

  public function getId(): int
  {
    return $this->id;
  }
}

final class User
{
  public function __construct(
    public UserId $id,
  ) {}
}

interface UserRepository
{
  /**
   * @throws  UserNotFoundException
   */
  public function findById(
    UserId $id
  ): User;
}

上面的代碼應該會使重構稍微容易一些。當調用 getId() 時,UserId 仍然返回 int,但這沒關係!最重要的是,我們的代碼依賴於 UserId—— 一個我們控制的類型 —— 而不是原始整數 —— 我們根本無法控制它。

現在只需覆蓋所有現有代碼以使用 UserId 而不是 int $id

final class SqliteUserRepository
  implements UserRepository
{
  public function findById(
    UserId $id
  ): User {
    $sql = "...";
    $stmt = $this->pdo->prepare($sql);
    $stmt->execute([
      'id' => $id->getId(),
    ]);
    // ...
  }
}

到這裏,什麼都沒有改變。感覺可以安全地合併和部署,不應該有任何東西會崩潰。順便説一句,測試幫助很大。一定要進行測試!

步驟 2 - 讓兩個字段共存

現在確保可以向 users 表添加一個新字段。這樣就可以了:

sqlite> ALTER TABLE `users` ADD `uuid` VARCHAR;

現在它既不能是 NOT NULL 也不能是 UNIQUE,因為每個現有記錄的值都將是 NULL

回到 UserId 類,確保它現在的實現中有 uuid

final class UserId
{
  public function __construct(
    public int $id,
    public ?UuidInterface $uuid,
  ) {}

  public function getId(): int
  {
    return $this->id;
  }

  public function getUuid(): ?UUidInterface
  {
    return $this->uuid;
  }
}

它仍然是可空的,因為,嗯,它在數據庫中是 null!

現在需要確保發生兩件事:

  1. 每個現有記錄都將有一個非空的 uuid;並且
  2. 每個新記錄都將已經帶有填充的 uuid

當看到在任何給定時刻,users.uuid 永遠不會是 NULL 時,則認為兩者在數據庫層都很好地共存。

步驟 3 - 確保每個新記錄都有 UUID

在你的系統中,某個地方存儲着 Users。需要確保在它發生的任何地方,UUID 字段都將被填充。

所以給定這個舊的實現:

public function insert(
  User $user
): void {
  // insert into ...
}

只需用 UUID 生成來修補它,應該就沒問題了:

public function insert(
  User $user
): void {
  $id = $user->id;

  if ($id->uuid === null) {
    $id->uuid = Uuid::uuid4();
  }

  // insert into ...
}

個人強烈建議你用測試覆蓋這個 IF 語句,以防你遺漏了導入或類似的東西。除此之外,不應該引入其他迴歸。

每個新記錄現在應該都有正確填充的 users.uuid

步驟 4 - 為舊記錄回填 UUID 字段

這可以用腳本完成。如果你使用遷移框架,可能也會非常簡單。

現在只需要獲取所有 uuid 為 null 的用户並填充它們。類似這樣就可以完成:

$users = getUsersWithEmptyUuid();
foreach ($users as $user) {
  $user->id->uuid = Uuid::uuid4();
  updateUser($user);
}

上面的代碼並不能代表每個代碼庫,但我想你明白了。

步驟 5 - 確保一切正常運行

不要急於切換實現。一定要確保系統正常運行,並且在再運行系統幾個小時後,users.uuid 不會是 NULL

只有當你 100% 確定 users.uuid 在此表中永遠不會是 NULL 時,才進入下一步。

步驟 6 - 更新 UserRepository 以使用 UUID

看來現在已經可以切換到新的 UUID 實現了。但不建議盲目地切換到新實現。

謹慎總比後悔好,對吧?首先確保用功能開關保護代碼。用以下內容更新 SqliteUserRepository

final class SqliteUserRepository
    implements UserRepository
{
  public function findById(UserId $id): User
  {
    if (
      isFeatureFlagActive('enableNewUsersUuidImplementation')
    ) {
      // 新實現,使用 Uuid
      $sql = "...";
      $stmt = $this->pdo->prepare($sql);
      $stmt->execute([
        'uuid' => (string) $id->getUuid(),
      ]);
      // ...
    } else {
      // 舊實現,使用整數 $id
      $sql = "...";
      $stmt = $this->pdo->prepare($sql);
      $stmt->execute([
        'id' => $id->getId(),
      ]);
      // ...
    }
  }
}

長話短説:如果請求的功能已啓用,isFeatureFlagActive() 返回 TRUE,否則返回 FALSE。它可以基於配置、數據庫條目或環境變量。這在這裏不相關。

重要的是,你可以更改 isFeatureFlagActive() 的返回值,而無需重新部署代碼。這樣你就可以安全地回滾到以前的實現,沒有太多摩擦。

步驟 7 - 部署、啓用和監控

首先部署它,確保 isFeatureFlagActive() 始終返回 FALSE,這樣就會選擇原始實現。

然後將 isFeatureFlagActive() 切換為返回 TRUE,這樣就會選擇新實現 —— 同樣,這可以通過數據庫記錄、環境變量、SaaS 工具或你喜歡的任何東西來完成。

哦不!出問題了!網站突然變得超級慢!!

關閉你的功能開關,這樣 isFeatureFlagActive() 將再次返回 FALSE

事情似乎又恢復正常了。回到你的 IDE,試着弄清楚發生了什麼。也許做一些點擊測試和調試來理解是什麼導致它如此緩慢。

最終你會意識到你沒有索引 users.uuid 列,所以由於你的巨大表,查詢它變得超級慢。儘快修復它!

步驟 8 - 使 UUID 唯一併建立索引

由於使用的是 SQLite 實現,這裏是應該完成此操作的代碼片段:

sqlite> CREATE UNIQUE INDEX `users_uuid_uq` ON `users`(`uuid`);

理想情況下,你還應該使 users.uuid 為 NOT NULL,但我跳過了它,因為它需要更多的 SQLite 步驟,這些步驟與我想在這裏演示的內容無關。

好了,現在應該沒問題了。將你的更改傳播到生產環境,看看功能開關的代碼現在表現如何。

一切都好,對吧?是時候清理了。

步驟 9 - 清理你的數字 ID

既然東西已經部署並經過實戰測試,是時候清理以前的數字 id 字段了。

無論你是刪除實際字段還是隻是不在代碼中使用它,這都是項目決策 —— 什麼不是呢?

但最終你的 SqliteUserRepository 會看起來像這樣:

final class SqliteUserRepository
    implements UserRepository
{
  public function findById(
    UserId $id
  ): User {
    $sql = "...";
    $stmt = $this->pdo->prepare($sql);
    $stmt->execute([
      'uuid' => (string) $id->getUuid(),
    ]);
    // ...
  }
}

插入記錄的函數現在也值得一些關愛。讓我們刪除以前的 IF 語句:

...

public function insert(
  User $user
): void {
  $user->id->uuid = Uuid::uuid4();

  // insert logic
}

...

如果你決定也從數據庫中刪除數字 id,必須確保 UserId 代碼也被清理,並刪除 $id 屬性:

final class UserId
{
  public function __construct(
    public UuidInterface $uuid,
  ) {}

  public function getUuid(
  ): UuidInterface {
    return $this->uuid;
  }
}

因為現在 UUID 完全沒有理由為空,也從 $uuid 屬性中刪除了問號。現在你的系統是安全的!

總結

當然,事情可能因項目而異,但歸根結底,你將執行所描述技術的某種變體。

這適用於幾乎任何依賴數據的實現更改。只需記住三個階段:

  1. 讓兩個實現共存
  2. 實戰測試新實現
  3. 淘汰以前的實現

不要害羞或羞於採取多個步驟。即使你知道之後必須刪除代碼!實際上回滾部署或修復實時數據庫比在這裏描述的任何步驟都要痛苦得多。