一、先搞懂啥是多租户,为啥要纠结架构?

很多做SaaS(软件即服务)的开发者都碰到过这个问题:比如做一个给多家小公司用的客户管理系统,总不能给每家公司单独装一套系统、单独搭一个数据库吧?太费钱太麻烦。于是就有了“多租户”——就是让多家公司(也就是租户)共用一套系统,但各自的数据、权限又得分开,不能互相看。

但怎么让租户数据分开?目前最常用的两个路子,就是“每个租户单独建一个Schema”,或者“所有租户共用一套表,给每条数据加个‘租户是谁’的标识”。这俩路子各有各的坑,很多人选的时候乱选,后来要么查数据慢、要么权限控不住、要么迁数据哭死。今天就把这俩路子掰碎了说,教你怎么选。

二、两种核心架构的具体玩法,带真实例子

先把两种架构的玩法说清楚,不然光说概念太抽象。所有例子用Python+PostgreSQL,提前说清楚技术栈:Python 3.9 + PostgreSQL 14 + psycopg2-binary(PostgreSQL的Python连接库)。

2.1 第一种:每个租户单独建Schema

先讲第一种:给每个租户单独建一个Schema。啥是Schema?可以理解成PostgreSQL里的“大文件夹”,每个Schema里可以有自己的表、视图、函数,互不干扰。比如租户A的所有表都在tenant_a这个Schema里,租户B的都在tenant_b里,互相看不见。

举个完整的例子,比如要给两个租户(腾讯和阿里)建系统,系统里有客户表、订单表。 第一步:先连数据库建基础连接,再给每个租户建Schema:

# 导入PostgreSQL连接库
import psycopg2
from psycopg2 import sql

# 数据库基础连接配置(注意:这里要连的是PostgreSQL的默认数据库,比如postgres)
BASE_DB_CONFIG = {
    "dbname": "postgres",
    "user": "admin",
    "password": "123456",
    "host": "localhost",
    "port": "5432"
}

# 1. 先连到基础数据库,给两个租户建Schema
def create_tenant_schemas(tenant_names):
    conn = psycopg2.connect(**BASE_DB_CONFIG)
    conn.autocommit = True  # 建Schema不用事务,直接提交
    cursor = conn.cursor()
    for tenant in tenant_names:
        # 用sql模块防止SQL注入,把租户名转成安全的SQL标识符
        schema_name = sql.Identifier(f"tenant_{tenant.lower()}")
        # 执行建Schema的SQL
        cursor.execute(sql.SQL("CREATE SCHEMA IF NOT EXISTS {};").format(schema_name))
        # 给这个Schema建客户表和订单表
        # 建客户表:id、客户名、联系方式
        cursor.execute(sql.SQL("""
            CREATE TABLE IF NOT EXISTS {}.customer (
                id SERIAL PRIMARY KEY,
                name VARCHAR(100) NOT NULL,
                contact VARCHAR(50)
            );
        """).format(schema_name))
        # 建订单表:id、客户id、订单金额、下单时间
        cursor.execute(sql.SQL("""
            CREATE TABLE IF NOT EXISTS {}.order (
                id SERIAL PRIMARY KEY,
                customer_id INT REFERENCES {}.customer(id),
                amount NUMERIC(10,2) NOT NULL,
                create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            );
        """).format(schema_name, schema_name))
    cursor.close()
    conn.close()

# 调用函数,给腾讯、阿里建Schema
create_tenant_schemas(["tencent", "alibaba"])

第二步:给每个租户建单独的数据库用户,只能访问自己的Schema,不能碰别人的:

# 给租户建单独的用户,限制只能访问自己的Schema
def create_tenant_users(tenant_names):
    conn = psycopg2.connect(**BASE_DB_CONFIG)
    conn.autocommit = True
    cursor = conn.cursor()
    for tenant in tenant_names:
        tenant_lower = tenant.lower()
        # 用户名比如tenant_tencent,密码和用户名一致(实际项目要加密)
        user_name = sql.Identifier(f"tenant_{tenant_lower}")
        password = sql.Literal(f"tenant_{tenant_lower}")  # Literal转成字符串值,防止注入
        # 执行建用户的SQL
        cursor.execute(sql.SQL("CREATE USER {} WITH PASSWORD {};").format(user_name, password))
        # 给这个用户授权:只能访问自己的Schema,不能碰其他Schema
        schema_name = sql.Identifier(f"tenant_{tenant_lower}")
        cursor.execute(sql.SQL("GRANT ALL PRIVILEGES ON SCHEMA {} TO {};").format(schema_name, user_name))
        # 还要给表授权,因为默认用户不能访问其他Schema里的表
        cursor.execute(sql.SQL("GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA {} TO {};").format(schema_name, user_name))
    cursor.close()
    conn.close()

# 调用函数建用户
create_tenant_users(["tencent", "alibaba"])

第三步:租户自己连自己的Schema操作数据,比如腾讯的用户连进去,只能看到自己的客户表:

# 腾讯的用户连接自己的Schema,只能操作自己的表
def tencent_operate_data():
    # 连接配置里的用户是tenant_tencent,只能访问自己的Schema
    TENCENT_DB_CONFIG = {
        "dbname": "postgres",
        "user": "tenant_tencent",
        "password": "tenant_tencent",
        "host": "localhost",
        "port": "5432"
    }
    conn = psycopg2.connect(**TENCENT_DB_CONFIG)
    cursor = conn.cursor()
    # 因为用户的默认搜索路径是自己的Schema,所以直接写表名就行,不用加前缀
    cursor.execute("INSERT INTO customer (name, contact) VALUES ('腾讯员工张三', '13800138000');")
    cursor.execute("SELECT * FROM customer;")
    print(cursor.fetchall())  # 只会输出腾讯自己的客户,看不到阿里的
    conn.commit()
    cursor.close()
    conn.close()

# 调用测试
tencent_operate_data()

2.2 第二种:共享表加租户标识

再讲第二种:所有租户共用一套表,给每条数据加一个“租户ID”的字段,用来区分是谁的数据。比如客户表、订单表都加一个tenant_id的字段,所有租户的数据都存在这两张表里,查的时候只查自己的tenant_id就行。

还是用刚才的例子,腾讯和阿里共用表: 第一步:建共享表,加tenant_id字段:

# 先连基础数据库建共享表
def create_shared_tables():
    conn = psycopg2.connect(**BASE_DB_CONFIG)
    conn.autocommit = True
    cursor = conn.cursor()
    # 建共享客户表,加tenant_id字段
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS shared_customer (
            id SERIAL PRIMARY KEY,
            name VARCHAR(100) NOT NULL,
            contact VARCHAR(50),
            tenant_id VARCHAR(50) NOT NULL  -- 租户标识,比如'tencent'、'alibaba'
        );
    """)
    # 建共享订单表,加tenant_id字段
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS shared_order (
            id SERIAL PRIMARY KEY,
            customer_id INT REFERENCES shared_customer(id),
            amount NUMERIC(10,2) NOT NULL,
            create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            tenant_id VARCHAR(50) NOT NULL
        );
    """)
    # 给tenant_id加索引,不然查的时候慢(重点!不加索引的话多了查不动)
    cursor.execute("CREATE INDEX idx_customer_tenant ON shared_customer(tenant_id);")
    cursor.execute("CREATE INDEX idx_order_tenant ON shared_order(tenant_id);")
    cursor.close()
    conn.close()

# 调用建表
create_shared_tables()

第二步:租户操作数据的时候,必须带上自己的tenant_id

# 腾讯的用户操作共享表,必须加tenant_id
def tencent_operate_shared_data():
    # 这里用的是普通用户,所有租户都连同一个数据库,用同一个用户(也可以每个租户单独用户,后面说)
    SHARED_DB_CONFIG = {
        "dbname": "postgres",
        "user": "shared_user",  # 提前建的普通用户,能访问共享表
        "password": "shared_pass",
        "host": "localhost",
        "port": "5432"
    }
    conn = psycopg2.connect(**SHARED_DB_CONFIG)
    cursor = conn.cursor()
    # 插入数据必须加tenant_id
    cursor.execute("""
        INSERT INTO shared_customer (name, contact, tenant_id) 
        VALUES ('腾讯员工张三', '13800138000', 'tencent');
    """)
    # 查询自己的数据,必须加tenant_id的条件
    cursor.execute("SELECT * FROM shared_customer WHERE tenant_id = 'tencent';")
    print(cursor.fetchall())  # 只会输出tenant_id是tencent的客户
    conn.commit()
    cursor.close()
    conn.close()

# 调用测试
tencent_operate_shared_data()

这里有个坑:如果开发者不小心漏加了tenant_id的条件,比如直接写SELECT * FROM shared_customer;,就会把所有租户的数据都查出来,这就炸了。

三、核心对比:从三个维度看怎么选

现在把两种架构放在一起比,从三个最核心的维度来分析,帮你做决策。

3.1 租户间查询隔离:谁更安全?

单独Schema的隔离性

单独Schema的隔离性是天生的。刚才的例子里,腾讯的用户只能访问自己的Schema,连阿里的Schema名都看不见,更别说查数据了。哪怕有个黑客拿到了腾讯的数据库账号,他也只能操作腾讯自己的数据,碰不到别人的。

而且PostgreSQL的Schema是严格隔离的,不同Schema里的表名可以重名,不会冲突。比如腾讯的customer表和阿里的customer表,是完全独立的,哪怕结构一样,数据也完全分开。

共享表加标识的隔离性

共享表的隔离性是“软隔离”,全靠开发者自己控制。刚才的例子里,所有数据都在同一张表里,要是开发者写SQL的时候漏了tenant_id的条件,就会把所有数据查出来;要是不小心把tenant_id写错了,还会把别人的数据改了。

那有没有办法补救?比如用PostgreSQL的行级权限(RLS),给表加规则,让用户只能看到自己的tenant_id的数据。比如给shared_customer表加规则:

-- 给shared_customer表加行级权限,只有自己的tenant_id能访问
ALTER TABLE shared_customer ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_access ON shared_customer FOR ALL USING (tenant_id = current_user);

意思是:当前登录的用户(比如tenant_tencent)只能看到tenant_id等于自己用户名的数据。这样哪怕开发者漏加条件,也查不到别人的数据。但行级权限有个问题:会拖慢查询速度,尤其是数据量很大的时候,而且规则写复杂了容易出bug。

3.2 连接管理:谁更省心?

单独Schema的连接管理

单独Schema的连接管理很麻烦。因为每个租户要建单独的数据库用户,每个用户对应自己的Schema。比如有1000个租户,就要建1000个用户,每个用户的连接配置都不一样。

还有个问题:PostgreSQL的连接数是有限制的,默认最多能同时连100个连接。要是每个租户的系统都要连数据库,100个租户同时在线就会炸。当然可以用连接池(比如PgBouncer)来缓解,但连接池的配置也会变复杂,每个租户的用户都要加进去。

共享表加标识的连接管理

共享表的连接管理很简单。所有租户都用同一个数据库用户,连同一个数据库,连接配置只有一套。比如1000个租户,只要一套连接配置就行,连接池的配置也简单,不用考虑租户的差异。

举个例子,SaaS系统的后端服务,只要维护一套数据库连接,不管哪个租户来访问,都用这个连接,只要在查询的时候加上tenant_id的条件就行。

3.3 迁移成本:谁更痛苦?

单独Schema的迁移成本

单独Schema的迁移成本非常高。比如你后来想把某个租户的数据迁到另一个数据库,就要把整个Schema导出来,再导到新数据库里。要是租户很多,每个租户的Schema结构不一样(比如有的租户加了自己的表、改了字段),迁移的时候还要单独处理每个Schema,容易出错。

还有个问题:要是你想做全局的统计,比如统计所有租户的总订单金额,单独Schema的话,就要把每个租户的order表都查一遍,再把结果加起来,写SQL的时候要拼接每个Schema的表名,很麻烦,性能也差。

共享表加标识的迁移成本

共享表的迁移成本很低。所有数据都在同一张表里,迁移的时候只要导整个表就行,不用考虑租户的差异。做全局统计也很简单,直接写一个SQL就行:

-- 统计所有租户的总订单金额
SELECT SUM(amount) FROM shared_order;

要是想迁某个租户的数据,只要把tenant_id等于这个租户的数据导出来就行,很方便。

四、两种架构的适用场景

现在总结一下,两种架构分别适合什么情况:

4.1 单独Schema适合的场景

  1. 租户数据隐私要求极高:比如做医疗、金融的SaaS,客户的数据不能有任何泄露的风险,哪怕是开发者不小心写错SQL也不能出问题。
  2. 租户数量少:比如只有几十上百个租户,连接管理的压力不大,不用考虑连接数不够的问题。
  3. 租户的业务差异大:比如有的租户需要加自己的表、改自己的字段,单独Schema可以让每个租户的结构不一样,互相不影响。

4.2 共享表加标识适合的场景

  1. 租户数量多:比如有几千几万个租户,连接管理简单,不用建很多用户,也不用考虑连接数的问题。
  2. 租户数据隐私要求不高:比如做普通的客户管理系统、项目管理系统,只要开发者注意加tenant_id的条件就行,大不了加个行级权限兜底。
  3. 需要经常做全局统计:比如要统计所有租户的总用户数、总订单金额,共享表做起来很简单。

五、实际项目中的坑和注意事项

不管选哪种架构,都有一些坑要注意:

5.1 单独Schema的坑

  1. 不要给超级用户授权:给租户的用户授权的时候,不要给超级用户权限,只能给访问自己Schema的权限,不然租户可以碰其他Schema。
  2. 不要忘记给表授权:建完Schema后,一定要给表授权,不然租户用户连自己的表都访问不了。
  3. 全局查询要注意性能:做全局统计的时候,尽量用PostgreSQL的UNION ALL来拼接多个Schema的查询,不要一个一个查,不然性能很差。

5.2 共享表加标识的坑

  1. 一定要给tenant_id加索引:要是数据量很大,没有索引的话,查询速度会非常慢,甚至会拖垮整个数据库。
  2. 一定要在所有SQL里加tenant_id的条件:可以把加tenant_id的逻辑封装成公共方法,比如写一个函数,所有查询都调用这个函数加条件,避免漏加。
  3. 敏感数据要加密:要是有敏感数据(比如密码、银行卡号),要加密存储,哪怕tenant_id的条件漏了,别人也看不到明文。

六、总结

选架构没有绝对的对错,只有适合不适合。如果你的项目是医疗、金融,租户数量少,就选单独Schema;如果是普通的SaaS,租户数量多,需要经常做全局统计,就选共享表加标识。

当然也有混合架构,比如给隐私要求高的租户用单独Schema,其他租户用共享表,但这种架构维护起来比较麻烦,适合有一定技术能力的团队。

最后再提醒一句:架构选好了,后面的开发、维护才会省心;要是一开始选了不适合的架构,后面改起来会非常痛苦,所以一定要根据自己的实际情况来选。