PAYATHON 2026

Doctrine 与 PostgreSQL 并发下如何确保每个用户仅一条 active row?

技术老齐

结论

最终约束应使用 PostgreSQL 的 partial unique index:

CREATE UNIQUE INDEX uniq_active_subscription_per_user
ON subscription (user_id)
WHERE is_active = true;

该索引可以在并发事务中保证每个 user_id 最多只有一条 active 记录。应用层查询、Symfony Validator 和 Doctrine 生命周期回调可以提前给出提示,但都有 race condition,无法单独保证数据一致性。

发生冲突时,需要捕获 flush() 或事务提交期间抛出的 UniqueConstraintViolationException。数据库事务回滚后,也不要继续使用出错的 EntityManager 及其 Unit of Work。在 Symfony 中,如果后续还需要访问数据库,应通过 ManagerRegistry::resetManager() 获取新的 EntityManager。

partial unique index 为什么能处理并发

假设两个事务同时执行以下操作:

  1. 查询当前用户是否已有 active 记录;
  2. 两边都没有查到;
  3. 两边分别插入一条 active 记录。

应用层检查无法保证这三个步骤不可分割,因此两个请求都可能通过检查。

PostgreSQL 会在写入阶段强制执行 partial unique index。两个事务同时插入时,其中一个事务可能需要等待另一个事务结束:

  • 先执行的事务提交后,另一个事务会收到 unique violation;
  • 先执行的事务回滚后,另一个事务可以继续写入。

这种异常属于正常的并发结果,应作为业务流程的一部分处理。

还需要确认 is_active 为 NOT NULL。如果该字段允许 NULL,partial index 只会覆盖明确等于 true 的记录:

ALTER TABLE subscription
ALTER COLUMN is_active SET NOT NULL;

通过 Doctrine Migration 创建索引

partial index 是 PostgreSQL 特有的功能,直接在 migration 中编写 SQL 通常更清楚,也能避开不同 Doctrine ORM、DBAL 版本对 schema diff 支持不一致的问题:

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20261027000100 extends AbstractMigration
{
    public function up(Schema $schema): void
    {
        $this->addSql(
            'CREATE UNIQUE INDEX uniq_active_subscription_per_user
             ON subscription (user_id)
             WHERE is_active = true'
        );
    }

    public function down(Schema $schema): void
    {
        $this->addSql(
            'DROP INDEX uniq_active_subscription_per_user'
        );
    }
}

创建索引前,应先清理已有的重复数据,否则 migration 会执行失败。可以先用下面的 SQL 检查:

SELECT user_id, COUNT(*)
FROM subscription
WHERE is_active = true
GROUP BY user_id
HAVING COUNT(*) > 1;

如果生产环境的数据量较大,还要评估创建索引时产生的锁。PostgreSQL 支持 CREATE UNIQUE INDEX CONCURRENTLY,但这条语句不能在事务块中运行。采用这种方式时,需要按照当前 Doctrine Migrations 版本提供的机制,将该 migration 设置为非事务执行。

捕获 Doctrine 的唯一约束异常

异常处理应覆盖整个事务边界:

use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;

final class SubscriptionService
{
    public function __construct(
        private ManagerRegistry $registry,
    ) {
    }

    public function createActiveSubscription(User $user): Subscription
    {
        /** @var EntityManagerInterface $em */
        $em = $this->registry->getManager();

        try {
            $subscription = $em->wrapInTransaction(
                function (EntityManagerInterface $em) use ($user): Subscription {
                    $subscription = new Subscription();
                    $subscription->setUser($user);
                    $subscription->setIsActive(true);

                    $em->persist($subscription);

                    return $subscription;
                }
            );

            return $subscription;
        } catch (UniqueConstraintViolationException $e) {
            // 后续仍需使用数据库时,换一个新的 EntityManager。
            $this->registry->resetManager();

            throw new ActiveSubscriptionAlreadyExists(
                '该用户已经存在 active subscription。',
                previous: $e,
            );
        }
    }
}

wrapInTransaction() 会在回调成功后执行 flush(),然后提交事务,因此需要在它的外层捕获异常。unique violation 发生后,当前 PostgreSQL 事务已经进入失败状态,不能忽略异常并在同一个事务中继续执行 SQL。

没有使用 wrapInTransaction() 时,也要把 flush() 放在 try 中:

$connection = $em->getConnection();
$connection->beginTransaction();

try {
    $em->persist($subscription);
    $em->flush();

    $connection->commit();
} catch (\Throwable $e) {
    if ($connection->isTransactionActive()) {
        $connection->rollBack();
    }

    $em->close();

    throw $e;
}

Doctrine 会自动执行哪些回滚和关闭操作,取决于事务 API 及其版本。只要 flush() 已经失败,就不要再让原 EntityManager 处理后续工作。

为什么回滚后不能直接继续使用原 EntityManager

数据库回滚只会恢复数据库状态,EntityManager 内部可能仍保留以下内容:

  • 等待写入或状态不确定的实体;
  • 已生成 ID、但数据库中并不存在的实体;
  • 与数据库实际状态不一致的 managed entity;
  • 已关闭的 EntityManager。

clear() 只能分离当前管理的实体,无法重新打开已经关闭的 EntityManager。处理方式取决于后续流程:

  • 当前请求准备直接返回冲突响应时,无需专门恢复 EntityManager;
  • 当前进程还需要访问数据库时,使用 ManagerRegistry::resetManager();
  • reset 后,原 EntityManager 管理的对象都应视为 detached,需要重新查询。

不要修改原对象后,再调用同一个 EntityManager 的 flush()。

如何区分是哪一个唯一约束失败

UniqueConstraintViolationException 只能说明发生了唯一性冲突,而表上可能存在多个唯一索引。如果有多个候选约束,不能把所有这类异常都转换成“已有 active subscription”。

可以通过以下方式缩小范围:

  1. 让事务只执行目标业务写入,减少可能触发的约束;
  2. 检查底层 PostgreSQL 异常提供的 constraint/index name;
  3. 无法确认约束名称时保留原异常,避免误判为业务冲突。

SQLSTATE 23505 只能表明发生了 unique violation,无法单独确定违反了哪个索引。不同 DBAL 和 PostgreSQL 驱动版本暴露错误字段的方式可能不同,应根据项目使用的具体版本实现判断逻辑。

切换 active 记录时要注意 flush 顺序

业务操作需要“停用旧记录并启用新记录”时,不能假设 Doctrine 一定会先执行 UPDATE,再执行 INSERT。Unit of Work 有自己的提交顺序。单次 flush() 可能先插入新的 active 记录,导致唯一索引冲突。

可以在同一个事务中分两次 flush:

$em->wrapInTransaction(
    function (EntityManagerInterface $em) use ($old, $new): void {
        $old->setIsActive(false);
        $em->flush();

        $new->setIsActive(true);
        $em->persist($new);
        $em->flush();
    }
);

两次 flush 仍处于同一个数据库事务中。如果后续操作失败,前面的停用操作也会一起回滚。

多个请求经常竞争同一个用户时,还可以先对 User 行加悲观写锁:

use Doctrine\DBAL\LockMode;

$user = $em->find(
    User::class,
    $userId,
    LockMode::PESSIMISTIC_WRITE,
);

悲观锁必须在事务中使用,而且所有修改 active 状态的代码路径都要遵循相同的锁定约定。partial unique index 仍应保留,用来防止遗漏的写入路径破坏约束。

Doctrine 校验可以保留,但不能代替索引

应用层校验可以在大多数普通请求中提前返回更友好的错误,但它只能改善用户体验:

  • Doctrine 查询和后续写入之间仍有时间窗口;
  • Symfony UniqueEntity 等校验器无法保证“检查并写入”的原子性;
  • 生命周期回调运行在各自的应用进程内,无法为多个请求提供共同的互斥保证。

通常由数据库 partial unique index 保证数据一致性,应用层预检查负责改善提示。并发冲突由事务外层捕获 unique violation 处理,失败后不再复用原 EntityManager。

备注:内容仅供参考。