在多租户(Multi-Tenant)应用或者对数据安全性要求极高的系统中,"如何安全、高效地隔离数据"一直是核心难题 。传统的做法是在业务层(如 Spring Boot、Node.js)的每一个 SQL 后面手动拼接 WHERE tenant_id = x 。
这种做法虽然直观,但极易因为漏写条件导致重大的数据越权泄露事故 。
PostgreSQL 提供的 RLS(Row-Level Security,行级安全) 机制,直接在数据库层解决了这个问题 。它把权限控制的防火墙下移到了引擎层,无论业务层怎么写 SQL,PG 都会自动帮我们守住底线 。
一、 RLS 的核心工作原理
简单来说,RLS 就是一个"隐式过滤器" 。
当你在某张表上开启了 RLS 并配置了策略(Policy)后,任何用户对该表的查询、修改操作,都会被 PostgreSQL 的查询重写器(Query Rewriter)拦截 。 
如上图所示,当应用层(或者不同的数据库 Role)发起一个简单的 SELECT * FROM clients; 时 :
-
PG 识别到当前执行 SQL 的用户身份 。
-
自动调取该表上针对该身份定义的 Policy 表达式(例如
salesperson_id = current_user) 。 -
隐式地将该表达式拼接到原 SQL 的
WHERE条件中 ,最终执行的其实是 :SELECT * FROM clients WHERE salesperson_id = '2';
这意味着,用户永远只能看到或修改他们"有权看到"的行 。
二、 RLS 核心语法与实战演练
下面我们以一个经典的多租户/销售管理系统为例,看看如何从零配置 RLS 。
1. 基础数据准备
首先,创建两名销售员工账号(数据库 Role),以及一张客户资产表 。
sql
-- 创建两个普通的数据库用户(模拟不同的销售员)
CREATE USER alice WITH PASSWORD 'password123';
CREATE USER bob WITH PASSWORD 'password123';
-- 创建客户资产表
CREATE TABLE client_assets (
id SERIAL PRIMARY KEY,
client_name VARCHAR(100),
asset_value NUMERIC,
owner_name VARCHAR(50) -- 归属的销售员
);
-- 插入测试数据
INSERT INTO client_assets (client_name, asset_value, owner_name) VALUES
('Tesla', 5000000, 'alice'),
('Apple', 9000000, 'alice'),
('Microsoft', 8000000, 'bob');
-- 允许用户查询和修改这张表
GRANT SELECT, INSERT, UPDATE, DELETE ON client_assets TO alice, bob;
2. 开启 RLS
注意: 默认情况下,建表后 RLS 是关闭的,任何有权限的用户都能看到全表数据 。我们需要显式开启它 。
sql
ALTER TABLE client_assets ENABLE ROW LEVEL SECURITY;
3. 创建安全策略(Policy)
现在,我们要实现:"销售员只能看到和操作属于自己的客户数据" 。
sql
CREATE POLICY asset_owner_policy ON client_assets
FOR ALL -- 覆盖 SELECT, INSERT, UPDATE, DELETE
TO alice, bob -- 应用于这两位用户
USING (owner_name = current_user); -- 核心过滤条件
-
USING子句:用于过滤表中现有的行(决定你能SELECT/UPDATE/DELETE哪些行)。 -
current_user:PG 内元函数,返回当前执行 SQL 的数据库用户名 。
4. 效果验证
我们切换到 alice 的身份来执行查询 :
sql
-- 切换到 alice 身份
SET ROLE alice;
-- 执行全表查询
SELECT * FROM client_assets;
执行结果:
| id | client_name | asset_value | owner_name |
|---|---|---|---|
| 1 | Tesla | 5000000 | alice |
| 2 | Apple | 9000000 | alice |
完美! 属于
bob的 Microsoft 数据直接被过滤掉了,没有报错,对alice来说就像不曾存在一样 。如果alice尝试修改bob的数据,受影响行数也会是 0 。
三、 高级进阶:配合 Web 应用的动态 RLS
上面的做法需要为每个员工建一个数据库用户 。但在实际的 Web 开发中,我们的应用(如 Java、Go、Python 后端)通常是通过一个统一的连接池用户 (如 my_app_user)连接数据库的 。
这时候该怎么做 RLS 隔离?
最佳实践:利用 GUC (全局配置变量)
PostgreSQL 允许我们在当前的事务会话(Session)中设置自定义变量 。我们可以利用这一点在应用层动态传递当前登录的 user_id 或 tenant_id 。
sql
-- 1. 创建策略:从会话变量 app.current_tenant_id 中读取当前租户
CREATE POLICY tenant_isolation_policy ON client_assets
FOR ALL
USING (owner_name = current_setting('app.current_tenant_id', true));
应用层伪代码流程:
每次从连接池捞出 Connection 后,在一个事务 中先执行设置变量,再执行业务 SQL : 使用 SET LOCAL 可以确保该变量只在当前事务中生效,事务结束后自动清空,不会污染连接池里的长连接 。
四、 Spring Boot 落地最佳工程实践
在构建基于 Spring Boot 的多租户应用时,如何优雅地将当前登录用户的租户信息(如 tenant_id)无感知地传递给 PostgreSQL 的会话变量(GUC),是实现动态行级安全(RLS)的关键 。
下面提供一套生产级、无侵入式的 Spring Boot + Spring Data JPA / MyBatis 的最佳工程实践方案 。
1. 核心架构设计
在 Spring Boot 中,为了确保每次数据库操作都能自动注入当前租户信息,我们需要解决两个核心问题 :
-
上下文传递 :使用
ThreadLocal存储当前请求的租户 ID(通常在拦截器中解析 JWT 获得) 。 -
连接池适配 :在数据源或数据库连接生命周期的事务开始时 ,自动执行
SET LOCAL app.current_tenant_id = 'xxx'。
2. 核心代码实现
A. 租户上下文持有者(TenantContext)
利用 ThreadLocal 在同一个线程(即同一次 HTTP 请求)中共享租户信息 。
java
package com.example.rls.config;
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static String getTenantId() {
return CURRENT_TENANT.get();
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
B. HTTP 请求拦截器(TenantInterceptor)
从请求头(例如 JWT 或自定义 Header)中提取租户 ID 并存入上下文,请求结束后务必清理,防止线程复用导致的数据串透 。
java
package com.example.rls.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 实际业务中应从 JWT Token 中解析
String tenantId = request.getHeader("X-Tenant-Id");
if (StringUtils.hasText(tenantId)) {
TenantContext.setTenantId(tenantId);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 关键点:必须清理 ThreadLocal,避免线程池污染
TenantContext.clear();
}
}
C. 自定义数据源代理:实现 RLS 变量自动注入
这是最关键 的一步 。如果我们手动在每个 Service 里面写 SET LOCAL,代码会非常臃肿 。通过重写 DataSource 的 getConnection() 方法,在每次获取连接并开启事务时,自动执行变量注入 。
java
package com.example.rls.config;
import org.springframework.jdbc.datasource.DelegatingDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
public class RlsAwareDataSource extends DelegatingDataSource {
public RlsAwareDataSource(DataSource delegate) {
super(delegate);
}
@Override
public Connection getConnection() throws SQLException {
Connection connection = super.getConnection();
setTenantContext(connection);
return connection;
}
@Override
public Connection getConnection(String username, String password) throws SQLException {
Connection connection = super.getConnection(username, password);
setTenantContext(connection);
return connection;
}
private void setTenantContext(Connection connection) throws SQLException {
String tenantId = TenantContext.getTenantId();
if (tenantId != null) {
// 使用 LOCAL 关键字,确保变量仅在当前事务(Transaction)中生效
// 事务提交或回滚后自动失效,完美契合连接池复用机制
try (Statement stmt = connection.createStatement()) {
stmt.execute(String.format("SET LOCAL app.current_tenant_id = '%s';", tenantId));
}
}
}
}
D. 注册配置类
将原生的数据源包装为我们自定义的 RlsAwareDataSource 。
java
package com.example.rls.config;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.sql.DataSource;
@Configuration
public class RlsWebConfig implements WebMvcConfigurer {
private final TenantInterceptor tenantInterceptor;
public RlsWebConfig(TenantInterceptor tenantInterceptor) {
this.tenantInterceptor = tenantInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantInterceptor).addPathPatterns("/**");
}
@Bean
@Primary
public DataSource dataSource(DataSourceProperties properties) {
// 创建原生连接池(如 HikariCP)
DataSource nativeDataSource = properties.initializeDataSourceBuilder().build();
// 用 RLS 代理类进行包装
return new RlsAwareDataSource(nativeDataSource);
}
}
3. 业务层使用示例(以 Spring Data JPA 为例)
得益于上层的无侵入设计,你的实体类、Repository 和 Service 不需要任何特殊的隔离逻辑,直接编写普通的 CRUD 即可 。
实体类与 Repository
java
package com.example.rls.entity;
import jakarta.persistence.*;
import lombok.Data;
@Data
@Entity
@Table(name = "client_assets")
public class ClientAsset {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String clientName;
private Long assetValue;
private String ownerName; // 对应 PG RLS 过滤的字段
}
java
package com.example.rls.repository;
import com.example.rls.entity.ClientAsset;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ClientAssetRepository extends JpaRepository<ClientAsset, Long> {
// 这里不需要写任何关于 tenant_id 或 owner_name 的过滤条件!
}
业务层声明事务
核心注意点 :由于我们依赖
SET LOCAL,所有的数据库读写操作必须包裹在声明式事务@Transactional中 。如果没有开启事务,SET LOCAL在执行完单条语句后就会立刻失效 。
java
package com.example.rls.service;
import com.example.rls.entity.ClientAsset;
import com.example.rls.repository.ClientAssetRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class AssetService {
private final ClientAssetRepository repository;
public AssetService(ClientAssetRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true) // 必须开启事务
public List<ClientAsset> getAllAssets() {
// 哪怕这里调用的是 findAll(),PostgreSQL 也会根据 RLS 策略自动过滤
return repository.findAll();
}
@Transactional
public ClientAsset createAsset(ClientAsset asset) {
return repository.save(asset);
}
}
五、 避坑指南:RLS 容易踩的四大深坑
RLS 虽然优雅,但在生产环境中如果不注意以下几点,可能会带来灾难性的后果 :
1. 表所有者(Owner)和超级用户不受限制
-
巨坑: 为什么我开启了 RLS,也写了 Policy,但是用
postgres用户或者建表的用户去查,依然能看到全表数据 ? -
原因 :PostgreSQL 默认规定:表的拥有者(Owner)和超级用户(Superuser)会自动绕过 RLS 策略 。
-
解法:如果你强制要求 Owner 也要遵守 RLS,必须执行 :
sql
ALTER TABLE client_assets FORCE ROW LEVEL SECURITY;
2. 性能刺客:在 USING 中写复杂的 Subquery
如果你的 Policy 写成这样:
sql
CREATE POLICY bad_policy ON orders
USING (tenant_id IN (SELECT id FROM tenants WHERE status = 'active'));
-
后果 :由于 RLS 会重写你的每一次查询,这意味着原表里的每一行数据在处理时,可能都会去触发执行一次里面的子查询(Subquery) 。如果数据量大,会导致全表扫描和严重的 CPU 暴涨 。
-
解法 :尽量避免在 Policy 内部关联复杂的物理表 。推荐配合上述的
current_setting()这种内存级变量,或者使用稳定的视图隔离 。
3. 外键约束(Foreign Key)引发的信息泄露
假设 orders 表开启了 RLS,用户 A 看不到订单 001 。但是 orders_items 表没有开 RLS 。如果用户 A 尝试往 orders_items 插入一条指向订单 001 的明细 :
-
数据库会去校验外键是否存在 。如果订单
001存在,校验通过;如果不存在,报错 。 -
风险 :用户 A 可以通过不断尝试插入外键,来试探/推测出他本没有权限看到的订单 ID 是否存在 。
4. 逻辑备份(pg_dump)的权限缺失与异步线程隐患
-
备份数据缺失风险 :如果备份数据的账号受到了 RLS 的限制,那么利用
pg_dump备份出来的数据库文件,只会包含该备份账号有权看到的数据 。-
后果:这会导致你的生产备份数据严重丢失而不自知 。
-
解法 :用于执行定时备份的数据库账号,必须是超级用户 或者拥有
BYPASSRLS属性 :ALTER USER backup_user BYPASSRLS;
-
-
Spring 异步处理(@Async)的隐患 :
ThreadLocal默认是不能跨线程传递的 。如果你的 Service 中使用了@Async异步多线程,或者使用了响应式编程(WebFlux),TenantContext会失效 。此时需要配置TaskDecorator以便在线程切换时复制ThreadLocal上下文 。 -
配合数据库默认值 :为了防止在
INSERT时漏掉租户字段,可以在 PostgreSQL 表结构中为该字段设置默认值 :ALTER TABLE client_assets ALTER COLUMN owner_name SET DEFAULT current_setting('app.current_tenant_id');这样,在 Spring Boot 中新增数据时,你甚至不需要手动为实体类设置ownerName,数据库会自动根据当前的会话变量将其填入 。
六、 总结
| 控制维度 | 传统业务层控制 | PostgreSQL RLS 控制 |
|---|---|---|
| 实现位置 | 后端代码(MyBatis/JPA/Sequelize等) | 数据库引擎层 |
| 漏写风险 | 高(代码一改,极易漏掉 WHERE) | 极低(全局生效,一劳永逸) |
| 性能损耗 | 无额外损耗 | 如果 Policy 复杂,可能有轻微重写损耗 |
| 适用场景 | 复杂多变、与外部系统频繁交互的权限系统 | 纯净的数据多租户隔离、合规性审计系统 |
PostgreSQL 的 RLS 为数据安全提供了一道坚固的底线防御 。在设计多租户、Saas 平台或财务医疗等敏感系统时,将 RLS 结合 Spring Boot 拦截器代理作为防漏写、防越权的"兜底"方案,是现代化架构中非常值得推崇的实践 。