共享数据库+租户字段隔离适合快速启动,通过tenant_id字段和Doctrine SQL过滤器实现轻量级隔离,但需注意字段显式关联、过滤器全覆盖、索引优化。

共享数据库+租户字段隔离适合快速启动
如果你的多租户应用刚起步,租户数量少、数据量不大、且对隔离性要求不高,直接在每张核心表加 tenant_id 字段是最轻量的选择。Doctrine 的 SQL 过滤器(TenantFilter)能自动为查询注入 WHERE tenant_id = ? 条件,避免手动拼写漏掉。
但要注意:
- 所有需要租户隔离的实体必须显式关联
tenant_id字段,并在TenantFilter::addFilterConstraint()中统一处理 - 原生 SQL 查询、DQL 子查询、
count()聚合等场景容易绕过过滤器,需额外验证 - 索引必须包含
tenant_id列(如(tenant_id, created_at)),否则大表下性能会断崖式下降
RequestStackContext 是多租户资源路径的默认解法
当租户通过子目录(/tenant1/)、子域(tenant1.example.com)或请求头区分时,RequestStackContext 能自动从当前 Request 提取 getBasePath() 和 isSecure(),确保 asset() 生成的 URL 前缀正确。
别用 NullContext 替代——它返回空 basePath,会导致 CSS/JS 路径 404,尤其在部署到非根路径(如 Nginx 的 location /app/)时必现。
配置上只需确保:
-
framework.assets.context设为request_stack(Symfony 6.2+ 默认) - 禁用
framework.assets.base_urls的硬编码值,否则会覆盖上下文动态路径 - 前端构建产物中不要写死
/build/这类绝对路径,改用 Twig 的{{ asset('build/app.js') }}
独立数据库模式需要手动切换 EntityManager
每个租户独享一个数据库时,不能靠 Doctrine 的 filter 实现隔离——那是行级逻辑隔离,而这里是物理库级隔离。你得在请求进入时就切换 EntityManager 实例。
典型做法是:
- 用事件订阅器监听
kernel.request,解析租户标识(子域/路由参数/请求头) - 根据租户查出对应数据库连接配置,调用
$container->get('doctrine')->resetManager()或自定义ManagerRegistry实现动态注册 - 所有 Repository 必须通过
$this->getEntityManager()获取当前租户的 EM,不能依赖默认的default_entity_manager
这个方案运维成本高,但数据泄露风险最低;迁移脚本也得按租户分批执行,doctrine:migrations:migrate 不再是全局命令。
租户上下文必须贯穿整个请求生命周期
很多人只在 Controller 或 Service 层处理租户识别,结果在 Console 命令、异步消息(Messenger)、缓存键生成、日志上下文里丢失租户信息,导致数据混写或日志无法归因。
真正可靠的方案是把租户 ID 注入到容器作用域中:
- 创建
TenantContext服务,用RequestStack或SecurityTokenStorage提取租户标识 - 所有依赖租户的 Service 都通过构造函数注入该服务,而非每次临时获取
- Console 命令需支持
--tenant=xxx参数,并在configure()中声明 - 缓存键必须包含
tenant_id(如"product_list_{$tenantId}_{$locale}"),否则缓存污染不可避免
最易被忽略的是 Doctrine 的 UnitOfWork 状态——如果一个 Entity 在租户 A 的 EM 中加载,又被错误地 flush 到租户 B 的 EM,数据就错位了。务必确保 EM 实例和租户上下文严格绑定。


















