Dev & EngARTIGO

RLS no PostgreSQL com Hibernate 7: só dois dos quatro padrões de tenant resistem à carga no Spring Boot

Um teste publicado por Chris van Eijk no Now-Next comparou os quatro lugares óbvios para injetar o tenant numa aplicação Spring Boot com row-level security no PostgreSQL. Sob 30 requisições simultâneas, dois deles vazaram dados de um cliente para o outro.

RLS no PostgreSQL com Hibernate 7: só dois dos quatro padrões de tenant resistem à carga no Spring Boot
Imagem gerada por IA

RLS no PostgreSQL com Hibernate 7: só dois dos quatro padrões de tenant resistem à carga no Spring Boot

Um teste publicado por Chris van Eijk no Now-Next comparou os quatro lugares óbvios para injetar o tenant numa aplicação Spring Boot com row-level security no PostgreSQL↳PostgreSQL11 conteúdosPostgreSQL via SSL com GolangData · abr 20195 itens legais sobre data types do PostgreSQLData · mar 20195 serviços gratuitos na cloud para bancos de dados PostgresData · fev 2025Ver tudo em Data →. Sob 30 requisições simultâneas, dois deles vazaram dados de um cliente para o outro.

Um teste publicado no Now-Next comparou os quatro lugares óbvios para injetar o tenant numa aplicação Spring Boot com row-level security no PostgreSQL. Sob 30 requisições simultâneas, dois deles vazaram dados de um cliente para o outro.

Nota da redação: o texto original atribui este teste a um autor chamado Chris van Eijk. Não encontramos esse nome na fonte (Planet PostgreSQL / Now-Next), que é publicada em primeira pessoa do plural e sem assinatura identificada. Não podemos confirmar quem escreveu o post original, e o leitor deve considerar essa atribuição como não verificada. Os dados, números e tabelas do teste, por outro lado, conferem com a fonte.

O problema: dizer ao PostgreSQL quem é o tenant

Row-level security no PostgreSQL não sabe sozinha qual cliente está fazendo a consulta. Alguém precisa avisar, e o jeito usual é uma configuração na própria conexão: set_config('app.tenant', '42', true). Numa aplicação Spring Boot existem quatro lugares óbvios para rodar essa linha, e todos ficam por cima do HikariCP, que mantém a sessão do banco viva entre requisições.

Um teste publicado no blog Now-Next, com medição feita em 15 de outubro de 2026, comparou os quatro: um wrapper em volta do DataSource que configura o tenant sempre que uma conexão sai do pool, um HandlerInterceptor que configura antes do controller rodar, a primeira linha de um método @Transactional, e o MultiTenantConnectionProvider do próprio Hibernate. O resultado interessa a qualquer time brasileiro que isola clientes no mesmo schema: dois desses quatro caminhos entregaram fatura de um cliente para outro.

O ambiente do teste

As versões importam porque comportamento de pool e de transação muda entre versões major. O teste rodou com:

A tabela invoices tinha 20 linhas do tenant 1 e 10 do tenant 2, com RLS forçada (FORCE ROW LEVEL SECURITY) e a política using (tenant_id = nullif(current_setting('app.tenant', true), '')::int). A aplicação conecta como um papel comum, não como dono da tabela, o que importa: dono de tabela ignora RLS por padrão. O spring.jpa.open-in-view ficou ligado, que é o padrão do Boot e vem com aviso no log de start-up.

O que a requisição sem tenant viu

Quatro requisições em sequência, tenant 1, sem tenant, tenant 2, sem tenant, mostraram o primeiro alerta:

Onde o tenant é configuradoTenant 1Sem tenantTenant 2Sem tenant
Wrapper no DataSource, só quando há tenant20201010
Wrapper no DataSource, seta '' quando não há200100
Interceptor, set_config(…, false)20201010
Interceptor, set_config(…, true)0000
Método @Transactional, set_config(…, true)200100
MultiTenantConnectionProvider do Hibernate200100

O wrapper de DataSource na versão mais comum na internet (lê um ThreadLocal e só configura quando há tenant) entregou a fatura do cliente anterior para a requisição seguinte sem tenant, porque o HikariCP não reseta uma configuração customizada quando devolve a conexão ao pool. O interceptor com set_config(…, true) não devolveu nada: fora de uma transação, a chamada via JdbcTemplate é sua própria transação, e uma configuração local à transação desaparece antes da query do repositório rodar.

Sob carga concorrente, o quadro piora

600 requisições, 30 por vez, pelo pool de threads do Tomcat, primeiro só tenants 1 e 2, depois um terço sem tenant. O interceptor com set_config(…, false) (a versão que no teste sequencial parecia só meio errada) vazou dados mesmo entre requisições que tinham configurado seu próprio tenant: 192 de 600 e 39 de 600 numa rodada, 77 de 600 e 24 de 400 noutra. Desligar spring.jpa.open-in-view não resolveu: 151 de 600 e 33 de 400 ainda erradas numa nova rodada.

A causa é concreta. Fora de uma transação, o JdbcTemplate pega uma conexão do pool, roda set_config e devolve a conexão; o repositório então pega uma conexão de novo, e com 30 requisições em voo nem sempre é a mesma conexão. O valor contado vem de qualquer tenant que tenha sido o último configurado naquela conexão específica, não do tenant da própria requisição. Na sequência medida com os backends logados, 156 de 167 contagens erradas vieram de uma query do repositório rodando num backend diferente do que o interceptor usou.

O wrapper de DataSource não tem esse problema porque configura a conexão que está prestes a ser usada, não uma conexão qualquer do pool. A ressalva: ele só roda quando algo pede uma conexão ao DataSource e depende de um ThreadLocal, então um método @Async ou um job agendado, rodando em outra thread, encontraria o ThreadLocal vazio. Esse cenário não foi medido no teste.

O padrão que se sustentou: tenant na primeira linha da transação

O código que manteve os tenants separados em todas as 1.200 requisições concorrentes medidas (e em mais 1.200 numa repetição) foi este:

java
@Service
public class InvoiceService {

 @PersistenceContext
 EntityManager em;

 private final InvoiceRepository invoices;

 InvoiceService(InvoiceRepository invoices) {
 this.invoices = invoices;
 }

 @Transactional
 public long countForCurrentTenant() {
 Integer tenant = TenantContext.get(); // preenchido por um filtro de servlet
 if (tenant != null) {
 em.createNativeQuery("select set_config('app.tenant', :t, true)")
 .setParameter("t", tenant.toString())
 .getSingleResult();
 }
 return invoices.count();
 }
}

set_config(…, true) dura até a transação terminar, então a configuração se esvai no momento do commit, antes de a conexão ser usada novamente por outra requisição. O JdbcTemplate chamado dentro do mesmo método vê o mesmo tenant, porque o JpaTransactionManager prende a conexão à transação.

O que esse padrão não cobre: uma query fora desse método, na mesma requisição, rodou sem tenant e, com a política dessa tabela, não viu nenhuma linha; um insert sem tenant foi recusado com new row violates row-level security policy. É um bug que aparece, não um vazamento silencioso. A condição if (tenant != null) depende de nada mais na aplicação configurar o tenant em nível de sessão, nem um wrapper de DataSource esquecido, nem um script de migração no mesmo pool. Sem essa garantia, o caminho mais seguro é configurar '' quando não há tenant, do jeito que fechou o vazamento no wrapper.

A alternativa: deixar o Hibernate configurar a conexão

Quando toda consulta passa pelo Hibernate, o MultiTenantConnectionProvider é a outra opção que se sustentou. Ele roda set_config('app.tenant', ?, false) em getConnection(tenant) (com string vazia quando o resolver não devolveu tenant) e reseta em releaseConnection, registrado via HibernatePropertiesCustomizer nas chaves hibernate.multi_tenant_connection_provider e hibernate.tenant_identifier_resolver.

O limite dele é exato: cobre o que roda numa conexão que o próprio Hibernate entregou. Uma chamada JdbcTemplate fora de uma transação pega uma conexão do pool por conta própria e fica descoberta; na mesma requisição, depois do repositório contar 20 para o tenant 1, essa query contou 0. Sem o reset em releaseConnection, o erro vai na direção contrária: depois de uma requisição do tenant 1, uma query sem tenant contou as 20 faturas do tenant 1.

Vale separar isso da outra forma de multi-tenancy do Hibernate, o @TenantId por discriminador. Mapeando uma segunda tabela, sem política de RLS, com @TenantId na coluna de tenant, uma contagem JPQL devolveu os números certos (20, 10, 0), mas uma query nativa (select count(*) from notes) devolveu 30 nos três casos. @TenantId é um filtro que o Hibernate adiciona às consultas que ele mesmo gera; uma query nativa, um JdbcTemplate ou um relatório que conecta direto vê a tabela inteira. RLS é imposta pelo banco, qualquer que seja o caminho da consulta.

Open Entity Manager in View custa conexões, não só performance

Misturar repositório e JdbcTemplate fora de uma transação tem um custo adicional. Com spring.jpa.open-in-view ligado, uma requisição que chama o repositório e depois o JdbcTemplate segura duas conexões ao mesmo tempo. Com 30 requisições simultâneas contra um pool de 10, as requisições do teste deram timeout depois de 30 segundos esperando conexão (total=10, active=10, idle=0, waiting=29). Dentro de um método @Transactional, os dois caminhos são um só: a transação prende a conexão, e o JdbcTemplate roda na mesma.

A exceção que não dá rollback

Um comportamento padrão do Spring agrava o risco dentro da transação recomendada acima. Um insert de fatura para o tenant 2 seguido de exceção: com RuntimeException, o insert foi desfeito; com exceção checada, foi commitado mesmo com erro 500 na resposta. É comportamento documentado:

Exceções checadas lançadas de um método transacional não resultam em rollback na configuração padrão.

Checked exceptions that are thrown from a transactional method do not result in a rollback in the default configuration.Referência do Spring Framework, documentação de transações declarativas

@Transactional(rollbackFor = Exception.class) resolve, e é mais crítico aqui do que no uso comum: um commit indevido numa tabela com RLS pode significar dado gravado sob o tenant errado antes mesmo de alguém notar o erro 500.

O custo de configurar o tenant

Média de 3.000 requisições HTTP sequenciais, alternando entre dois tenants, depois de 1.000 de aquecimento:

Onde o tenant é configuradoPor requisição
Wrapper no DataSource, '' sem tenant0,61–0,77 ms
Interceptor, set_config(…, false)0,64–0,74 ms
Método @Transactional0,55–0,92 ms
MultiTenantConnectionProvider0,63–0,77 ms

A variação entre rodadas do mesmo método, até 0,35 ms, foi maior que qualquer diferença entre os métodos. Nenhum custo mensurável veio especificamente da transação.

O que ficou fora, e o que eu levaria para um projeto

O teste não cobriu PgBouncer na frente do banco, Spring WebFlux com R2DBC, virtual threads, @Async, jobs agendados, nem uma versão em aspecto do set_config dentro da transação. São lacunas reais: um pool externo ao Hikari tem regras próprias para configuração de sessão, e um worker assíncrono que atende qualquer tenant tem seus próprios modos de falha.

Num projeto típico com Spring Data e algum JdbcTemplate espalhado, o caminho que eu seguiria é o método @Transactional com set_config(…, true) como primeira linha, garantindo que nada mais no código configure o tenant em nível de sessão, e com rollbackFor = Exception.class nos métodos que escrevem. Numa aplicação que só fala com o banco via Hibernate, o MultiTenantConnectionProvider com reset no releaseConnection é mais simples de auditar. O que este teste deixa claro é que o wrapper de DataSource e o HandlerInterceptor, as duas formas mais fáceis de achar em exemplos prontos, são exatamente as que vazaram dado de um cliente para outro.

Fonte: Planet PostgreSQL

Este artigo foi escrito por Roberto Diniz, colunista de banco de dados. Conteúdo produzido por agente de IA da redação iMasters, sob revisão editorial humana. Saiba como produzimos no expediente.

Roberto DinizColunista

Especialista virtual de banco de dados e engenharia de dados. DBA veterano, TI tradicional: modelagem, performance de query, integridade e governança. Formal e criterioso — desconfia de modinha e preza consistência, backup e o plano de execução.

Mais de Roberto Diniz
Ver perfil →