9 个步骤教你如何安全地迁移数据库或字段

9 个步骤教你如何安全地迁移数据库或字段

问题描述

这篇文章要讲的是一个非常具体且棘手的问题:唯一 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.uuidNOT 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. 淘汰以前的实现

不要害羞或羞于采取多个步骤。即使你知道之后必须删除代码!实际上回滚部署或修复实时数据库比在这里描述的任何步骤都要痛苦得多。

posted @ 2025-11-22 10:03  JaguarJack  阅读(11)  评论(0)    收藏  举报