最近在开发一个多租户系统,技术栈是 Django+PostgreSQL,在开发的过程中,突然想尝试实现多租户数据隔离。于是请教了 AI,先是了解了几种解决方案,以及每种方案的优势和弊端,最后评估了代码改动量,发现还算简单之后,就开始动手实施。结果在实施过程中花费了很多时间,遇到了不少问题,最终暂时放弃了。今天把这个过程分享出来,给大家做个参考。
一、可选方案有哪些
1、每个租户一个独立的数据库
优势: 数据隔离性最强,租户之间完全独立,安全性最高;单个租户数据库故障不影响其他租户;可按租户灵活定制数据库配置和优化策略;便于按租户进行数据迁移和扩展。
劣势: 无法便捷地实现跨租户统计分析;数据库实例数量随租户增长线性增加,资源消耗大;需要管理大量数据库连接,运维成本高;租户间数据共享和关联查询困难。
2、所有租户共享一个数据库,通过 PostgreSQL schema 隔离
优势: 在同一数据库内实现逻辑隔离,可通过跨 schema 查询实现租户间数据统计;资源利用率高,连接池共享;数据库管理相对简单,备份恢复可统一处理;适合中小规模租户场景。
劣势: 隔离性弱于独立数据库方案;租户数据共享数据库资源,可能存在"吵闹邻居"问题;schema 数量过多时性能可能下降;需要额外的应用层逻辑保证 schema 切换正确性。
最终选择: 考虑到项目需要跨租户数据分析能力,且租户规模可控,选择方案 2 进行尝试。
二、实现思路
- 数据分层存储: 租户信息、用户信息等公共数据保存在公共 schema(如
common_schema),租户专属业务数据保存在各自的租户 schema(如tenant_xxx) - 动态切换 schema: 每次请求时携带 token,根据 token 识别租户身份,然后使用 Django 中间件动态设置 PostgreSQL 的
search_path,确保在正确的 schema 中查询数据
三、碰到的问题
1、schema 设置问题
在测试时,我使用 migrate 创建了两个 schema:
common_schema: 用于存储公共信息(租户、用户等)tenant_schema: 用于存储租户专属数据
在这个过程中,遇到了两个问题:
问题一: 表不存在错误
查询数据时报错提示表不存在,但数据库中表确实存在。原因是没有正确设置操作的 schema,PostgreSQL 默认会查询 public schema,而 public 中确实不存在该表。
解决方法: 使用 SET search_path TO {tenant_schema}, {common_schema} 设置 schema 搜索路径即可。
问题二: 查询 common_schema 数据返回错误结果
当通过 SET search_path TO {tenant_schema}, {common_schema} 设置多个 schema 时,PostgreSQL 会按顺序在每个 schema 中查找表:
- 如果在第一个 schema 中找到表,就直接使用,不会继续往后查找
- 所有 schema 都找不到才会报错
由于我的两个 schema 中的表完全一样(都执行了完整的 migrate),查询时会优先在 tenant_schema 中找到表并查询,导致无法查询到 common_schema 中的数据。
解决方法: 确保两个 schema 的表不重叠,公共表只存在于 common_schema,租户表只存在于 tenant_schema。
2、数据库 migrate 迁移问题
为了解决问题 1,我创建了两个脚本,分别用于迁移公共 schema 和租户 schema。
由于 python manage.py migrate 会迁移完整的数据库结构,所以脚本需要实现:
- 创建 schema
- 执行 migrate 迁移
- 删除不属于当前 schema 的表
这个方案的确解决了问题 1,但也带来了新的问题。
新问题: django_content_type 表缺失
多次迁移租户 schema 时会报错,提示 django_content_type 表不存在。因为我把这个表放在了公共 schema,租户 schema 中确实不存在。
但多次迁移是必需的——系统升级后数据库结构变更是常态。虽然可以直接通过 SQL 修改表结构,但执行过程不留痕,管理不便。使用 migrate 的好处是会记录执行历史,避免重复执行。
解决方案: 数据库路由
可以使用 Django 的数据库路由功能,把所有涉及 django_content_type 表的操作路由到公共 schema。
这个方案看似完美,但引入了新问题: 因为迁移时涉及的表分散在两个 schema,需要通过 SET search_path TO 同时设置两个 schema。这虽然解决了 django_content_type 表不存在的问题,但却让租户 schema 的创建完全失效(详见问题 3)。
数据库路由文档:https://docs.djangoproject.com/zh-hans/5.2/topics/db/multi-db/#automatic-database-routing
为什么执行 migrate 时会用到 django_content_type 表?:
因为 Django 会通过 migrate 自动维护 ContentType 系统,当你新建一个模型时,会在这个表中插入一条对应记录,所以每次执行 migrate 都会涉及到这个表的读写操作。
3、租户 schema 创建问题
问题 2 的解决方案(设置多个 schema 的 search_path)引入了问题 3。
在迁移时,Django 会查询 django_migrations 表来判断哪些迁移需要执行。当创建新的租户 schema 时:
- 租户 schema 刚创建,还不存在
django_migrations表 - PostgreSQL 按 search_path 顺序查找,会自动使用公共 schema 中的
django_migrations表 - 公共 schema 在创建时已经执行了所有迁移,表中已有完整的迁移记录
- Django 认为所有迁移都已执行,不会在租户 schema 中创建任何表
结果就是租户 schema 创建失败,无法正常使用。
可能的解决思路:
我最初尝试继续使用数据库路由解决,但没有成功。具体原因没有深入研究,猜测数据库路由可能只对用户创建的模型有效,对 Django 内置的迁移机制无效。
目前能想到的解决方法有:
分离迁移文件: 自定义 migrate 命令,在执行前判断哪些迁移属于公共 schema、哪些属于租户 schema,避免在公共 schema 中执行所有迁移
隔离迁移记录: 自定义 migrate 命令,在查询
django_migrations表时,动态设置search_path只查询当前租户 schema,确保迁移记录隔离
注意: 以上两个解决方案都未经验证,仅供参考。
四、总结
通过这次实践,我深刻体会到多租户数据隔离的复杂性。以上遇到的问题只是冰山一角,而且还有一个核心问题(租户 schema 创建)没有完全解决。
即使解决了当前的技术问题,后续还会面临更多挑战:
- 系统升级维护: 每次数据库结构变更,需要为所有租户 schema 执行迁移,维护成本成倍增加
- 数据备份恢复: 需要针对每个租户 schema 单独进行备份和恢复,无法简单地备份整个数据库
- 性能监控: 需要分别监控各个 schema 的性能指标,问题排查更加复杂
- 跨租户查询: 虽然可以跨 schema 查询,但复杂度和性能开销都会增加
建议:
如果业务场景对数据隔离的要求不是特别严格(如没有合规性要求、客户数据安全等级要求等),建议使用更简单的方案:
- 在表中增加
tenant_id字段进行逻辑隔离 - 通过应用层过滤保证数据安全
- 在数据库层面添加行级安全策略(Row Level Security)作为额外保障
只有在明确需要物理隔离,且有足够的技术资源投入时,才建议采用 schema 隔离方案。