Doctrine wrapInTransaction未持久化实体触发Duplicate entry异常
问题现象
- 运行环境:PHP 8.1 + Doctrine ORM v2.12.2 + MySQL 5.7.38
- 业务逻辑:
setValue方法内调用wrapInTransaction持久化AccountSettingModelImpl实体,单步调试确认实体字段全部填充正确,但事务执行后直接查库找不到对应记录 - 异常表现:
setValue被多次调用写入配置时,fetchSettingByKey查不到对应记录抛出SettingNotFoundException进入catch分支,Doctrine尝试新建同主键实体时触发Duplicate entry '1-rl.enabled' for key 'PRIMARY'主键重复错误 - 特殊规律:仅当整个PHP请求执行完成后,部分数据才会最终落库,
wrapInTransaction执行完毕后无法立即查询到持久化结果
相关代码
实体定义
/** * Class SettingModel * @package kor\classes\models * @Entity @Table(name="korAccountSettings") */ class AccountSettingModelImpl extends AbstractBaseModel implements AccountSettingModel { /** * @Id * @ManyToOne(targetEntity="AccountModelImpl") * @JoinColumn(name="asAccountId", referencedColumnName="aId") * @var AccountModelImpl */ private AccountModel $account; /** * @Id * @Column(name="asKey", type="text") * @var string */ private string $key; /** * @Column(name="asVal", type="text", nullable=true) * @var mixed */ private ?string $val; /** * @Column(name="asUpdated", type="datetime_immutable") * @var DateTime */ private DateTimeInterface $updated; }
setValue方法实现
public function setValue(string $sKey, $sVal) { Application::getEntityManager()->wrapInTransaction(function (EntityManager $entityManager) use ($sKey, $sVal) { $accountModel = $this->getAccountModel(); try { $settingModel = AccountSettingHelper::fetchSettingByKey($sKey, $accountModel); $settingModel->setVal($sVal); if(is_null($sVal) && $entityManager->contains($settingModel)) $entityManager->remove($settingModel); } catch (SettingNotFoundException $e) { $settingModel = Application::newEntity(AccountSettingModel::class); $settingModel->setAccount($accountModel); $settingModel->setKey($sKey); $settingModel->setVal($sVal); $entityManager->persist($settingModel); } }); return $sVal; }
异常堆栈
An exception occurred while executing a query: Duplicate entry '1-rl.enabled' for key 'PRIMARY' #0 OR\vendor\doctrine\dbal\src\Connection.php(1814): Doctrine\DBAL\Driver\API\MySQL\ExceptionConverter->convert() #1 OR\vendor\doctrine\dbal\src\Connection.php(1749): Doctrine\DBAL\Connection->handleDriverException() #2 OR\vendor\doctrine\dbal\src\Statement.php(184): Doctrine\DBAL\Connection->convertExceptionDuringQuery() #3 OR\vendor\doctrine\dbal\src\Statement.php(221): Doctrine\DBAL\Statement->execute() #4 OR\vendor\doctrine\orm\lib\Doctrine\ORM\Persisters\Entity\BasicEntityPersister.php(278): Doctrine\DBAL\Statement->executeStatement() #5 OR\vendor\doctrine\orm\lib\Doctrine\ORM\UnitOfWork.php(1129): Doctrine\ORM\Persisters\Entity\BasicEntityPersister->executeInserts() #6 OR\vendor\doctrine\orm\lib\Doctrine\ORM\UnitOfWork.php(426): Doctrine\ORM\UnitOfWork->executeInserts() #7 OR\vendor\doctrine\orm\lib\Doctrine\ORM\EntityManager.php(398): Doctrine\ORM\UnitOfWork->commit() #8 OR\vendor\doctrine\orm\lib\Doctrine\ORM\EntityManager.php(269): Doctrine\ORM\EntityManager->flush()
问题根因
不需要开启特殊配置,wrapInTransaction本身的逻辑就是闭包执行成功后自动调用flush()并提交事务,问题出在三个代码缺陷上:
- 主键字段类型定义非法:实体中作为复合主键之一的
key字段映射类型为text,MySQL InnoDB引擎不支持TEXT/BLOB类型直接作为主键,必须指定前缀长度,实际建表时会生成带前缀长度的隐式主键,Doctrine生成查询、插入语句时不会自动适配这个前缀规则,导致主键匹配失败,既无法正确查询到已存在的记录,也无法准确追踪实体的持久化状态。 - 查询实例不统一:
wrapInTransaction闭包已经传入了当前事务绑定的$entityManager实例,如果AccountSettingHelper::fetchSettingByKey内部是直接通过Application::getEntityManager()获取新的实例、或者直接写原生SQL走独立连接查询,就无法命中当前EntityManager的一级缓存,也读不到当前事务内未提交的变更。 - 时间字段无自动填充逻辑:
updated字段标记为非空、类型为datetime_immutable,但代码中没有看到给该字段赋值的逻辑,即使其他字段填充正确,该字段为空也会导致SQL执行失败,部分场景下PHP请求结束时的隐式flush可能触发了字段的默认值填充,才会出现"请求结束才落库"的错觉。
修复方案
- 修改主键字段定义:把
asKey的列类型从text改为指定长度的string类型,对应MySQL的varchar,长度根据配置键的实际长度设置即可,比如128或255:
/** * @Id * @Column(name="asKey", type="string", length=128) * @var string */ private string $key;
修改后同步更新数据库表结构,将korAccountSettings表的asKey字段改为varchar(128),确保复合主键(asAccountId, asKey)定义合法。
- 统一EntityManager实例:修改
AccountSettingHelper::fetchSettingByKey方法,支持传入EntityManager参数,调用时传入闭包内的$entityManager实例,优先使用Repository的findOneBy方法按复合主键查询,命中一级缓存,避免跨实例查询导致的状态不一致。 - 补全时间字段自动填充:给实体类添加生命周期回调,在实体创建、更新时自动给
updated字段赋值,避免非空字段为空导致SQL执行失败。 - 若需要在闭包内执行完持久化操作后立即让后续查询读到变更,可以在
persist()/remove()操作后显式调用$entityManager->flush(),wrapInTransaction不会重复执行无变更的flush操作。
内容的提问来源于stack exchange,提问作者NueFox
相关产品推荐
相关产品推荐

