一套系统同时服务多家客户,每家客户看不到别人的数据,还能各自配置、各自扩容、各自退租——这就是多租户。听起来简单,真正落地时你会发现,难点从来不是”加个 tenant_id 字段”,而是从这一刻起,系统的每一个查询、每一条索引、每一次统计、每一次数据导出都被这个字段绑住了。
三种隔离形态
隔离方案的选择,本质是在隔离强度和单位成本之间做取舍。
| 形态 | 隔离强度 | 单租户成本 | 运维复杂度 | 适用客户 |
|---|---|---|---|---|
| 独立数据库 | 最强,物理隔离 | 最高,每租户一套实例 | 高,实例数随租户数线性增长 | 大客户、强合规、需单独备份恢复 |
| 共享库 + 独立 Schema | 中等,逻辑隔离 | 中等 | 中等,迁移需逐 schema 执行 | 中型客户、需要一定的数据边界 |
| 共享表 + 租户 ID | 最弱,靠应用层保证 | 最低 | 低,一套 schema 一套迁移脚本 | 长尾客户、海量小租户 |
一个容易被忽略的事实:隔离强度不只是”安全”问题,还直接影响故障半径。独立数据库方案下,某个租户跑出慢查询把连接打满,影响范围就是它自己;共享表方案下,这条慢查询会拖垮所有租户。反过来,独立数据库意味着一次 schema 变更要在几百个实例上逐个执行,任何一个实例失败都会让发布卡住。
共享库独立 schema 是个折中,但它有个性命攸关的隐含条件:连接必须绑定到正确的 schema。很多团队用连接池加 SET search_path(或 MySQL 的 USE)来切换,一旦切换逻辑漏在某个异步任务或后台线程里,数据就写错了地方,而且这种错误往往要过很久才被发现。
租户 ID 作为强制过滤条件
选了共享表方案,tenant_id 就成了系统的安全带。问题是这条安全带必须”系不上就开不了车”,而不是靠每个开发者自觉。
ORM 层全局过滤。 主流 ORM 都支持全局作用域,在实体上注册一次,所有查询自动带上条件。
# SQLAlchemy 全局过滤示例
from sqlalchemy import event
from sqlalchemy.orm import Session
@event.listens_for(Session, "do_orm_execute")
def add_tenant_filter(execute_state):
if execute_state.is_select and not execute_state.is_column_load:
tenant_id = get_current_tenant()
if tenant_id is None:
raise RuntimeError("缺少租户上下文,拒绝执行查询")
execute_state.statement = execute_state.statement.options(
with_loader_criteria(
TenantScopedModel,
lambda cls: cls.tenant_id == tenant_id,
include_aliases=True,
)
)
注意那段 raise。租户上下文缺失时宁可报错也不要放行——放行意味着”查全表”,是静默的越权漏洞。
SQL 审计兜底。 ORM 拦不住原生 SQL、报表查询、数据同步脚本。在测试环境挂一个 SQL 拦截器,扫描所有执行语句,凡是命中租户表却没有 tenant_id 条件的直接让测试失败。
TENANT_TABLES = {"orders", "invoices", "users", "projects"}
def audit_sql(sql: str):
normalized = sql.lower()
for table in TENANT_TABLES:
if table in normalized and "tenant_id" not in normalized:
raise AssertionError(f"查询 {table} 缺少 tenant_id 条件: {sql}")
越权风险的另一半是纵向越权。 光有 tenant_id 不够,还要防止同一租户内的 A 用户改 B 用户的数据。资源级鉴权应在数据访问层统一做,而不是散落在每个 controller 里手写判断。
索引与数据倾斜
共享表方案下,所有索引都必须以 tenant_id 打头,否则单租户查询会退化成全表扫描。
-- 租户内按时间查订单:tenant_id 必须在最左列
CREATE INDEX idx_orders_tenant_created
ON orders (tenant_id, created_at DESC);
-- 租户内唯一性约束也要带 tenant_id
CREATE UNIQUE INDEX uk_orders_tenant_code
ON orders (tenant_id, order_code);
第二个索引常被遗漏。全局唯一的订单号在单租户内唯一就够了,但只在 order_code 上建唯一索引,两个租户用了同样编号就会互相冲突。
吵闹邻居(noisy neighbor) 是共享表绕不开的问题。数据量分布往往极度不均衡:经验上一个大客户的数据量可能是几百个小客户的总和。后果有几个层面:
- 大租户的查询扫描行数远多于小租户,即便有索引,缓存命中率也会被它冲垮;
- 定时任务按租户循环处理时,大租户的执行时间会拖长整个批次,后面的租户全部延后;
- 单表统计信息(如 PostgreSQL 的 ANALYZE、MySQL 的 InnoDB 采样)会被大租户的分布带偏,导致优化器给小租户选错执行计划。
缓解手段按侵入性从低到高排列:给任务循环加分片和错峰,让大租户单独跑一批;在连接池层面按租户做并发配额;对极端大客户做声明式分区,把它的 tenant_id 划到独立物理分区;再往上就是给它一个独立库——也就是混合隔离模式。
混合模式是生产环境里的常态:大客户独立库,中小企业共享表。代价是代码必须同时支持两种部署形态,连接池路由、迁移脚本、备份策略都要写两套。这个复杂度要在早期想清楚。
跨租户查询与统计
“所有租户的订单总额是多少”——这在共享表下是一条 SQL,在独立库下是跨几百个数据源的分布式查询。
跨租户统计不该走在线库,运营统计应放到离线或准实时链路:
-- 按租户汇总的物化视图,定时刷新
CREATE MATERIALIZED VIEW mv_tenant_daily_stats AS
SELECT
tenant_id,
DATE(created_at) AS stat_date,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY tenant_id, DATE(created_at);
-- 增量刷新优于全量重建
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_tenant_daily_stats;
独立库场景下,各库的汇总结果通过 CDC 或定时抽取汇聚到数据仓库再统一计算。跨租户统计对一致性的要求通常是”分钟级甚至小时级足够”,若业务方坚持秒级,先确认是不是伪需求。
租户级限流与配额
多租户系统里,限流不只是”保护系统”,还是”公平分配”。一个租户的重试风暴会让所有人一起变慢。
// 基于 Redis 的租户级令牌桶
func Allow(tenantID string, cost int) (bool, error) {
key := fmt.Sprintf("rl:tenant:%s", tenantID)
script := redis.NewScript(`
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or ARGV[1])
local ts = tonumber(redis.call('HGET', KEYS[1], 'ts') or ARGV[3])
local now = tonumber(ARGV[3])
local rate = tonumber(ARGV[2])
tokens = math.min(tonumber(ARGV[1]), tokens + (now - ts) * rate)
if tokens < tonumber(ARGV[4]) then
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
return 0
end
tokens = tokens - tonumber(ARGV[4])
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], 3600)
return 1
`)
// capacity, rate, now, cost
return script.Run(ctx, rdb, []string{key}, 100, 10, time.Now().Unix(), cost).Bool()
}
配额(quota)和限流(rate limit)是两件事:限流管瞬时速率,配额管总量,比如”本月最多创建 5000 个工单”。配额计数要在业务事务内累加,用数据库一行记录加行锁比用计数器可靠,因为它天然支持回滚。
-- 事务内扣减配额,行锁保证并发安全
UPDATE tenant_quota
SET used = used + 1
WHERE tenant_id = ? AND quota_type = 'ticket' AND used < limit_value;
-- 影响行数为 0 表示配额已用完
归档与退租删除
租户数据的生命周期末端最容易被低估。
归档。 冷数据留在主库会让备份时间、索引体积、迁移耗时全部膨胀。归档要注意两点:归档后仍要能查,归档表结构要和主表兼容或提供回查接口;归档操作本身要可中断、可续跑,一次性 DELETE 一千万行会把 binlog 和主从延迟打爆。
-- 分批归档,每批限制行数,批间留出让主从同步的时间
DELETE FROM orders
WHERE tenant_id = ? AND created_at < ?
ORDER BY id
LIMIT 1000;
退租删除。 合同要求”删除全部数据”,实现起来比想象中难。除了主业务表,还有:对象存储里的附件、搜索引擎索引、缓存、消息队列积压消息、日志(可能含 PII)、数据仓库副本、备份快照。
备份里的删除是个死结——不可能为删一个租户重写全量备份。常见做法是约定保留周期(例如滚动覆盖 90 天),在合同里写明,配合备份加密和密钥销毁做逻辑删除。
真正的删除还要做级联一致性检查:建一份”待删除对象清单”,逐项执行并留痕,最后全库扫描确认无残留。
SELECT table_name, COUNT(*)
FROM (
SELECT 'invoices' AS table_name, id FROM invoices WHERE tenant_id = 't_123'
UNION ALL
SELECT 'attachments', id FROM attachments WHERE tenant_id = 't_123'
UNION ALL
SELECT 'audit_logs', id FROM audit_logs WHERE tenant_id = 't_123'
) t
GROUP BY table_name;
如何按客户规模选型
可以按三条线来划:
- 战略客户 / 强合规行业(金融、医疗、政务):独立数据库,甚至独立集群。他们往往要自己审计、要指定地域、要单独的 SLA,这些只有物理隔离能给。
- 中大型商业客户:共享库独立 schema,或者共享表加资源配额。判断依据是数据量级——单租户数据量在主库总量中占比超过一个显著比例(经验上百分之几这个量级)时,就该考虑分出去了。
- 长尾 / 自助注册客户:共享表。数量大、单量小、付费低,成本敏感度最高。
还有一条横向原则:把隔离级别做成租户的属性,而不是系统的常量。路由层根据租户配置决定连哪个库、走哪张表,这样”客户长大了换个库”是一次配置变更加一次数据迁移,而不是一次架构重构。
迁移本身要准备双写和校验:新旧库同时写一段时间,对比两边数据一致性,再切读,最后停写旧库。这套流程在任何一次隔离级别调整中都躲不掉,早点把它工具化。
Comments (0)
No comments yet — be the first.
Please Sign in to join the discussion.