聚合查询在多租户系统中易跨租户且性能差,根本原因在于WHERE未在数据库层强制绑定租户上下文、索引缺失或设计不当;PostgreSQL须启用RLS并配策略,MySQL无RLS需应用层强控,SQL Server需用TVF;tenant_id必须为复合索引最左前缀,物化视图不支持会话变量,报表宽表同样需租户字段与索引。

聚合查询在多租户系统里既容易漏租户过滤,又极易因全表扫描拖垮性能——关键不是“能不能加WHERE”,而是“WHERE加在哪一层、由谁保证、索引是否跟得上”。
为什么GROUP BY一用就跨租户?
常见错误是把聚合逻辑写在视图或中间层,但没强制绑定租户上下文。比如定义v_tenant_stats视图时只写SELECT tenant_id, COUNT(*) FROM orders GROUP BY tenant_id,结果调用方直接SELECT * FROM v_tenant_stats,查出所有租户数据;更糟的是有人绕过视图直查orders表,连tenant_id都不带。
- PostgreSQL 中必须启用
ENABLE ROW LEVEL SECURITY并配CREATE POLICY,否则 RLS 不生效,视图和函数都拦不住基表直查 - MySQL 完全不支持 RLS,视图里硬写
WHERE tenant_id = @current_tenant没用——变量必须每次查询前手动SET,连接池复用时大概率失效 - SQL Server 要用
SECURITY_POLICY+ 内联表值函数(TVF),不能依赖视图
聚合慢的真正瓶颈不在GROUP BY,而在tenant_id索引缺失
即使加了 WHERE tenant_id = ?,如果表上只有 (created_at) 或 (status) 单列索引,优化器仍可能走全表扫描。聚合性能取决于“能快速定位到某租户全部行”的能力。
- 复合索引必须以
tenant_id为最左前缀,例如CREATE INDEX idx_orders_tenant_status ON orders (tenant_id, status) - 分区表场景下,按
tenant_idRANGE 分区后,GROUP BY status会自动剪枝到单个分区,比索引还快 - 物化视图(如 PostgreSQL 的
CREATE MATERIALIZED VIEW)不能用CURRENT_SETTING('app.tenant_id')——它不支持会话变量,必须为每个租户建独立物化视图或改用定时刷新的宽表
如何让COUNT/DISTINCT/SUM自动带上租户约束?
靠 ORM 拦截或 SQL 解析器(如 JSqlParser)自动注入 WHERE tenant_id = ? 是主流做法,但聚合函数有特殊风险:DISTINCT 和窗口函数容易绕过拦截逻辑。
- MyBatis 拦截器需覆盖
Executor.update和Executor.query,但对SELECT COUNT(DISTINCT user_id)这类嵌套表达式,解析器可能误判字段归属,建议统一收口到 DAO 方法,禁止裸写聚合 SQL - PostgreSQL 中可封装为 TVF:
CREATE FUNCTION tenant_order_summary(tenant_id UUID) RETURNS TABLE(total NUMERIC, avg_amount NUMERIC) AS $$ SELECT SUM(amount), AVG(amount) FROM orders WHERE orders.tenant_id = $1 $$ LANGUAGE sql STABLE - Kysely 用户可用
WithSchemaPlugin,但注意它只重写表名,不处理聚合子句——SELECT COUNT(*) FROM users会被转成SELECT COUNT(*) FROM tenant_123.users,安全;但若手写SELECT COUNT(*) FROM users u JOIN tenants t ON u.tenant_id = t.id,就可能漏过滤
最易被忽略的一点:报表类查询常走宽表或汇总表,这些表往往没加 tenant_id 字段或没建对应索引,一旦漏掉租户条件,就是全库扫描。别假设“只是查统计,影响不大”——它可能锁住整个分区或阻塞其他租户的写入。

















