租户识别必须在请求早期完成,通过kernel.request监听器解析子域名等标识并动态创建带租户前缀的db连接,禁用连接缓存,使用自定义connectionfactory确保隔离。

租户识别必须在请求早期完成
数据库级多租户不是靠 Doctrine 的实体监听器或 Repository 层后置过滤实现的——那只是应用层伪装,无法防止跨租户查询、权限绕过或事务污染。真正的隔离要求在 Connection 建立时就绑定租户上下文,否则 doctrine.dbal.connection 会复用同一个连接池,所有租户共享同一套连接和事务状态。
常见错误是把租户 ID 存在 $request->attributes 或 Session 里再动态切换 DB name,这会导致:同一请求中多个 Doctrine 查询可能命中不同数据库;事务跨库失败;连接池泄漏(因为 Doctrine 默认按 DSN 缓存连接,而 DSN 变了但连接没销毁)。
- 正确做法是在内核请求生命周期的最早可插点处(如
kernel.request事件监听器)解析租户标识,并立即替换当前Connection实例,或使用连接工厂动态生成带租户前缀的 DSN - 租户标识来源优先级应为:子域名 > 请求头(如
X-Tenant-ID) > 路由参数(不推荐用于生产,易被伪造) - 必须禁用 Doctrine 的连接缓存:在
doctrine.dbal.connections.*.options中设置['persistent' => false],并确保每次请求都新建连接(或至少重建Connection对象)
用 Doctrine 的 Connection Factory 动态构造 DSN
硬编码多个连接配置(doctrine.dbal.connections.tenant_a, tenant_b…)不可扩展,且无法应对租户动态增删。应该让 Doctrine 在运行时根据租户 ID 构造 DSN,而不是预定义 N 个连接。
核心是重写 Doctrine\DBAL\Connection 的实例化逻辑,通过自定义 ConnectionFactory 注入租户上下文:
class TenantAwareConnectionFactory extends ConnectionFactory
{
private TenantResolverInterface $tenantResolver;
<pre class="brush:php;toolbar:false;">public function __construct(TenantResolverInterface $tenantResolver, Configuration $configuration)
{
parent::__construct($configuration);
$this->tenantResolver = $tenantResolver;
}
public function createConnection(array $params, ...): Connection
{
$tenantId = $this->tenantResolver->resolve();
$params['dbname'] = 'app_' . $tenantId; // 或拼接 schema 名(PostgreSQL)
return parent::createConnection($params, ...);
}}
然后在服务容器中替换默认工厂:
# config/services.yaml
services:
Doctrine\DBAL\ConnectionFactory:
class: App\Doctrine\TenantAwareConnectionFactory
arguments:
- '@App\Doctrine\TenantResolver'
- '@doctrine.dbal.configuration'
- MySQL 场景下,
dbname指向独立数据库;PostgreSQL 则建议用search_path+ schema 隔离,此时需在params中添加'options' => [PDO::ATTR_EMULATE_PREPARES => true]并确保连接后执行SET search_path TO tenant_abc - 切勿在 DSN 中直接拼接
dbname=app_{tenant}—— 必须通过ConnectionFactory控制,否则 Doctrine 的连接池仍会缓存原始 DSN - 该方案下
doctrine:schema:create等命令失效,需改用租户感知的迁移命令(见下节)
迁移和 Schema 管理必须按租户执行
Doctrine 的 doctrine:migrations:migrate 默认操作全局连接,无法区分租户。若所有租户共用一套 migration 版本表(如 doctrine_migration_versions),一个租户升级会阻塞其他租户,且版本状态混乱。
解决方案是让每个租户拥有独立的 doctrine_migration_versions 表(MySQL)或独立 schema 下的同名表(PostgreSQL),并通过迁移配置动态指定目标:
- 在
migrations.php中返回闭包,从TenantResolver获取当前租户 ID,再构建EntityManager和Connection实例 - 执行迁移时显式传入租户上下文:
php bin/console doctrine:migrations:migrate --tenant=acme,并在命令中手动切换连接 - 更稳妥的做法是剥离迁移逻辑:用脚本遍历租户列表,对每个租户单独调用
php bin/console doctrine:migrations:migrate --em=tenant_em,其中tenant_em是按租户动态注册的 EntityManager 服务
注意:doctrine:schema:update --force 绝对禁用——它不走 migration 流程,会直接 ALTER 所有表,极易破坏租户间结构一致性。
事务和连接生命周期容易被忽略的细节
租户连接不是“一次请求一换”就万事大吉。以下三点在高并发或长事务场景下极易出错:
- Doctrine 的
UnitOfWork不感知租户,如果一个请求中混用多个租户的实体(比如通过非租户字段关联查出其他租户数据),flush()会把它们全发到当前租户连接,导致数据写入错库 - 连接未显式关闭时,PHP-FPM 进程复用可能导致下一个请求继承上一个租户的连接(尤其在 CLI 或 Swoole 环境);必须在
kernel.terminate中调用$connection->close() - 读写分离时,租户连接必须同时控制主从路由:不能让租户 A 的写操作打到租户 B 的从库(常见于用
ReplicaConnection但未按租户分组)
最隐蔽的问题是 Doctrine 的二级缓存(如 Redis)默认不带租户前缀,缓存键冲突会导致租户 A 查到租户 B 的实体。所有缓存键必须强制包含租户 ID,例如 tenant_{id}_entity_{class}_{id}。











