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 为什么能处理并发
假设两个事务同时执行以下操作:
- 查询当前用户是否已有 active 记录;
- 两边都没有查到;
- 两边分别插入一条 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”。
可以通过以下方式缩小范围:
- 让事务只执行目标业务写入,减少可能触发的约束;
- 检查底层 PostgreSQL 异常提供的 constraint/index name;
- 无法确认约束名称时保留原异常,避免误判为业务冲突。
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。
备注:内容仅供参考。
