
Jornada para o Analytics Realtime - Um Lakehouse Streaming-First
O primeiro artigo desta série chegou a uma conclusão que incomoda quem espera uma resposta mais simples: ferramentas de Lakehouse não são, por si só, enablers de analytics em tempo real. Suportar updates frequentes não é o mesmo que sustentá-los em latência subsecond — o custo reaparece como commits, snapshots, arquivos pequenos e compactação.
Essa conclusão não elimina o Lakehouse da equação. Uma plataforma de analytics em tempo real ainda vai precisar de uma camada onde os dados mudam e ficam persistidos de forma durável. A pergunta deste artigo é mais específica: dado que vamos apoiar a arquitetura em algum formato de Lakehouse, qual deles tem o modelo mental mais alinhado a um workload que muda continuamente?
Duas naturezas de tabela
O lakehouse tradicional funciona bem quando o fluxo é previsível:
dados → processamento → lakehouse → consulta
Esse fluxo pressupõe um padrão de escrita específico: acréscimo. Mas nem toda tabela se comporta assim, e a diferença importa mais do que parece.
Tabelas append-only
Uma tabela append-only (Append Table, no vocabulário do Paimon; Log Table, no do Fluss) só aceita registros novos — nunca modifica ou remove uma linha já escrita. Um log de eventos, um clickstream, um histórico de transações já fechadas: cada fato novo vira uma linha nova.
É para esse padrão que o lakehouse tradicional foi otimizado desde o início: arquivos colunares grandes, particionamento e snapshots imutáveis funcionam bem quando a única operação é “acrescentar”, porque acrescentar nunca exige reescrever o que já existe. Iceberg, Delta Lake e Hudi lidam bem com esse caso.
Tabelas Primary Key
Uma Primary Key Table é definida por uma chave primária e aceita insert, update e delete diretamente sobre uma linha já existente. Um pedido no e-commerce, uma sessão de usuário, um saldo de conta: qualquer entidade cujo estado é observado via Change Data Capture (CDC) não se comporta como um log que só cresce. Ela se comporta como um estado que é reescrito continuamente.
É exatamente esse padrão que quebra a previsibilidade do fluxo tradicional. Para entender por que, vale ver o que acontece com uma única linha ao longo do tempo.
Considere o registro de um cliente, observado via CDC a partir da tabela clientes de um sistema transacional. Em vez de tratar essas mudanças como um bloco abstrato de “INSERT/UPDATE/DELETE”, vale acompanhar o que acontece a cada momento — o payload que chega em cada evento e onde exatamente mora o problema.
Os diagramas e payloads desta seção são modelos conceituais de uma timeline, não a representação literal de uma ferramenta de CDC específica como Debezium.
1. Criado
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#000000",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart TD
subgraph timeline[" "]
direction LR
A["t0 · INSERT"] --> B["t1 · UPDATE"] --> C["t2 · UPDATE"] --> D["t3 · DELETE"]
end
timeline ~~~ EA["cliente_id: 8821<br/>nome: Marina Costa<br/>email: [email protected]<br/>plano: trial<br/>endereço: Av. Paulista, 1000 — São Paulo/SP"]
classDef active fill:#FFF4DB,stroke:#B45309,color:#000000,stroke-width:2px;
classDef inactive fill:#F7F7F5,stroke:#D6D3D1,color:#000000,stroke-width:1px;
classDef detail fill:#EFF6FF,stroke:#2563EB,color:#000000,stroke-width:2px,stroke-dasharray:5 3;
classDef invisible fill:none,stroke:none;
class A active;
class B,C,D inactive;
class EA detail;
class timeline invisible;
t0: o cliente se cadastra na plataforma. Até aqui, o comportamento é indistinguível de uma tabela append-only — uma linha nova nasce.
2. Atualizado
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#000000",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart TD
subgraph timeline[" "]
direction LR
A["t0 · INSERT"] --> B["t1 · UPDATE"] --> C["t2 · UPDATE"] --> D["t3 · DELETE"]
end
timeline ~~~ EB["cliente_id: 8821<br/>plano: trial → pro"]
classDef active fill:#FFF4DB,stroke:#B45309,color:#000000,stroke-width:2px;
classDef inactive fill:#F7F7F5,stroke:#D6D3D1,color:#000000,stroke-width:1px;
classDef detail fill:#EFF6FF,stroke:#2563EB,color:#000000,stroke-width:2px,stroke-dasharray:5 3;
classDef invisible fill:none,stroke:none;
class A,C,D inactive;
class B active;
class EB detail;
class timeline invisible;
t1: o cliente faz upgrade do plano trial para o plano pago. A linha de t0 não desaparece do histórico de eventos — mas, para quem pergunta “qual é o plano desse cliente hoje”, ela deixou de valer sozinha.
3. Atualizado de novo
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#000000",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart TD
subgraph timeline[" "]
direction LR
A["t0 · INSERT"] --> B["t1 · UPDATE"] --> C["t2 · UPDATE"] --> D["t3 · DELETE"]
end
timeline ~~~ EC["cliente_id: 8821<br/>endereço: Av. Paulista, 1000 → Rua das Acácias, 120"]
classDef active fill:#FFF4DB,stroke:#B45309,color:#000000,stroke-width:2px;
classDef inactive fill:#F7F7F5,stroke:#D6D3D1,color:#000000,stroke-width:1px;
classDef detail fill:#EFF6FF,stroke:#2563EB,color:#000000,stroke-width:2px,stroke-dasharray:5 3;
classDef invisible fill:none,stroke:none;
class A,B,D inactive;
class C active;
class EC detail;
class timeline invisible;
t2: o cliente atualiza o endereço de cobrança. Agora existem três eventos no histórico para a mesma chave — e só o mais recente descreve o estado atual do cliente.
4. Removido
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#000000",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart TD
subgraph timeline[" "]
direction LR
A["t0 · INSERT"] --> B["t1 · UPDATE"] --> C["t2 · UPDATE"] --> D["t3 · DELETE"]
end
timeline ~~~ ED["cliente_id: 8821"]
classDef active fill:#FFF4DB,stroke:#B45309,color:#000000,stroke-width:2px;
classDef inactive fill:#F7F7F5,stroke:#D6D3D1,color:#000000,stroke-width:1px;
classDef detail fill:#EFF6FF,stroke:#2563EB,color:#000000,stroke-width:2px,stroke-dasharray:5 3;
classDef invisible fill:none,stroke:none;
class A,B,C inactive;
class D active;
class ED detail;
class timeline invisible;
t3: o cliente solicita a exclusão da conta, e a linha é removida da tabela clientes. O estado atual, para essa chave, deixa de existir — mas os quatro eventos que passaram por ela continuam registrados no histórico.
Reconstruindo a fotografia
Uma tabela append-only resolveria essa sequência sem esforço: gravaria os quatro eventos como quatro linhas novas, ponto final. O problema aparece na pergunta seguinte: dado esse histórico, qual é o estado atual do cliente agora?
Responder isso exige ordenar os quatro eventos pela chave (cliente_id=8821) e pelo tempo, aplicando cada um sobre o resultado do anterior até chegar a uma fotografia única — nesse caso, “cliente excluído, nenhuma linha ativa”. Fazer essa reconstrução a cada consulta, reprocessando o histórico inteiro, não escala — e é exatamente esse problema que motiva os mecanismos das Primary Key Tables, tema do restante deste artigo.
Isso leva à pergunta que o artigo tenta responder: os formatos de lakehouse atuais foram igualmente projetados para esse tipo de workload?
Suportar streaming vs. ser streaming-first
Todos os formatos de Lakehouse mais discutidos hoje podem entrar em um pipeline streaming. Isso não significa que compartilhem o mesmo modelo mental — e a diferença aparece com clareza quando se olha para como o armazenamento guarda os dados e o que acontece quando alguém tenta ler essas mudanças de volta como um stream.
Formatos como o Iceberg organizam a tabela como uma sequência de fotografias completas — cada snapshot descreve, de forma simplificada, o conjunto de arquivos que representa a tabela inteira naquele momento. Para ver o que isso significa na prática, volte à sequência do cliente 8821 da seção anterior e acompanhe o que acontece a cada evento.
Os diagramas desta seção são modelos conceituais do modelo de snapshots, simplificados para o argumento — não a representação literal de como o Iceberg (ou qualquer ferramenta específica) funciona por dentro.
1. INSERT chega
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#1F2937",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart TD
subgraph timeline[" "]
direction LR
S1["t0 · INSERT"] --> S2["t1 · UPDATE"] --> S3["t2 · UPDATE"] --> S4["t3 · DELETE"]
end
timeline ~~~ SD1["cliente 8821 · plano: trial"]
classDef active fill:#FFF4DB,stroke:#B45309,color:#000000,stroke-width:2px;
classDef inactive fill:#F7F7F5,stroke:#D6D3D1,color:#000000,stroke-width:1px;
classDef detail fill:#EFF6FF,stroke:#2563EB,color:#000000,stroke-width:2px,stroke-dasharray:5 3;
classDef invisible fill:none,stroke:none;
class S1 active;
class S2,S3,S4 inactive;
class SD1 detail;
class timeline invisible;
O cliente 8821 entra como um novo arquivo de dados. Esse arquivo é registrado em um manifesto — um índice que lista quais arquivos pertencem à tabela, com estatísticas sobre eles —, e um snapshot inicial (t0) é publicado apontando pra esse manifesto. O arquivo de metadados da tabela é atualizado para apontar pra esse snapshot como o mais recente. Se alguém consultar a tabela agora, a engine de consulta lê o metadado, descobre que o snapshot corrente é t0, abre o manifesto correspondente e lê o único arquivo de dados existente. Até aqui, o comportamento é indistinguível de uma tabela append-only.
De forma simplificada, os arquivos geridos ficam assim:
data/
data-0001.parquet # cliente_id=8821 · plano=trial
metadata/
manifest-0001.avro # lista os arquivos válidos do snapshot t0
snap-t0.avro # snapshot t0 → aponta pro manifest-0001
v1.metadata.json # metadado da tabela: snapshot corrente é t0
2. Primeiro UPDATE chega
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#1F2937",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart TD
subgraph timeline[" "]
direction LR
S1["t0 · INSERT"] --> S2["t1 · UPDATE"] --> S3["t2 · UPDATE"] --> S4["t3 · DELETE"]
end
timeline ~~~ SD2["cliente 8821 · plano: trial → pro"]
classDef active fill:#FFF4DB,stroke:#B45309,color:#000000,stroke-width:2px;
classDef inactive fill:#F7F7F5,stroke:#D6D3D1,color:#000000,stroke-width:1px;
classDef detail fill:#EFF6FF,stroke:#2563EB,color:#000000,stroke-width:2px,stroke-dasharray:5 3;
classDef invisible fill:none,stroke:none;
class S1,S3,S4 inactive;
class S2 active;
class SD2 detail;
class timeline invisible;
O upgrade de plano gera um novo arquivo de dados com a versão atualizada da linha, mais uma marcação indicando que a versão anterior deixou de valer. Um novo manifesto é escrito referenciando esse conjunto atualizado de arquivos, um novo snapshot (t1) é publicado, e o metadado da tabela passa a apontar para t1 como snapshot corrente. Uma consulta feita agora reconstrói o estado combinando os arquivos válidos do snapshot corrente com as marcações de invalidação, chegando à linha com plano=pro — mas nada nesse processo registra que “o campo plano mudou de trial pra pro”. Para saber isso, um consumidor downstream precisaria comparar o snapshot t1 com o t0 e inferir a diferença: o snapshot em si registra “como a tabela ficou agora”, não “o que mudou”.
data/
data-0001.parquet # órfão: fora do snapshot corrente, mas ainda em disco
data-0002.parquet # versão nova · plano=pro
metadata/
snap-t1.avro # snapshot corrente → só data-0002 é lido
3. Segundo UPDATE chega
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#1F2937",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart TD
subgraph timeline[" "]
direction LR
S1["t0 · INSERT"] --> S2["t1 · UPDATE"] --> S3["t2 · UPDATE"] --> S4["t3 · DELETE"]
end
timeline ~~~ SD3["cliente 8821 · endereço atualizado"]
classDef active fill:#FFF4DB,stroke:#B45309,color:#000000,stroke-width:2px;
classDef inactive fill:#F7F7F5,stroke:#D6D3D1,color:#000000,stroke-width:1px;
classDef detail fill:#EFF6FF,stroke:#2563EB,color:#000000,stroke-width:2px,stroke-dasharray:5 3;
classDef invisible fill:none,stroke:none;
class S1,S2,S4 inactive;
class S3 active;
class SD3 detail;
class timeline invisible;
O padrão se repete: a atualização do endereço gera mais um arquivo de dados, mais uma marcação de invalidação sobre a versão anterior, mais um manifesto, e um novo snapshot (t2) — com o metadado da tabela atualizado mais uma vez para apontar pra ele.
Aqui aparece um problema adicional: snapshots intermediários costumam ser descartados por política de retenção ou reescritos por compactação, para controlar custo de armazenamento e de metadata. Se o snapshot t1 não estiver mais disponível quando alguém for auditar o histórico, a transição “trial → pro” simplesmente deixa de existir para quem só tem acesso aos snapshots atuais.
data/
data-0001.parquet # órfão
data-0002.parquet # órfão desde t2 · endereço mudou de novo
data-0003.parquet # versão nova · plano=pro, endereço atualizado
metadata/
snap-t1.avro # ainda existe, mas candidato a expirar por retenção
snap-t2.avro # snapshot corrente → só data-0003 é lido
4. DELETE chega
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#1F2937",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart TD
subgraph timeline[" "]
direction LR
S1["t0 · INSERT"] --> S2["t1 · UPDATE"] --> S3["t2 · UPDATE"] --> S4["t3 · DELETE"]
end
timeline ~~~ SD4["cliente 8821 · removido"]
classDef active fill:#FFF4DB,stroke:#B45309,color:#000000,stroke-width:2px;
classDef inactive fill:#F7F7F5,stroke:#D6D3D1,color:#000000,stroke-width:1px;
classDef detail fill:#EFF6FF,stroke:#2563EB,color:#000000,stroke-width:2px,stroke-dasharray:5 3;
classDef invisible fill:none,stroke:none;
class S1,S2,S3 inactive;
class S4 active;
class SD4 detail;
class timeline invisible;
O delete gera uma marcação final de invalidação — sem necessidade de um novo arquivo de dados, já que não há uma nova versão da linha para escrever. Mais um manifesto, mais um snapshot (t3), mais uma atualização do metadado da tabela. Uma leitura agora não encontra mais nenhuma versão válida da linha do cliente 8821 no snapshot t3: ela simplesmente não aparece no resultado. Comparado ao snapshot anterior, um consumidor consegue inferir que a linha sumiu — mas, sem os snapshots intermediários, já perdeu o histórico dos dois updates que aconteceram no meio do caminho.
data/
data-0001.parquet # órfão
data-0002.parquet # órfão
data-0003.parquet # órfão desde t3 · linha marcada como removida, sem novo arquivo
metadata/
snap-t3.avro # snapshot corrente — nenhum arquivo válido pra cliente_id=8821
O que fica evidente
Nesse modelo, reconstruir “o que mudou” depende de comparar estados sucessivos — e essa comparação só funciona se os estados intermediários ainda existirem. É um modelo pensado para responder “qual é o estado da tabela agora”, não “o que aconteceu com essa linha ao longo do tempo”. Ler a tabela como um stream contínuo de mudanças não é o comportamento padrão desse modelo: é uma reconstrução que alguém precisa fazer por fora, e que fica mais frágil quanto mais eventos e mais tempo passam entre uma leitura e outra.
Isso deixa uma pergunta em aberto: existe uma estrutura de armazenamento em que cada mudança já nasce registrada como um evento — sem depender de comparação entre estados nem de reter snapshots indefinidamente? É o que a próxima seção explica.
Como funcionaria com uma LSM
A pergunta que fechou a seção anterior tem uma resposta: uma Log-Structured Merge Tree (LSM Tree) — a mesma família de estrutura usada por bancos como Cassandra e RocksDB pra sustentar escritas frequentes sem reescrever tudo a cada mudança. Em vez de guardar fotografias do estado final, a LSM guarda a sequência de operações que chegaram, organizada por chave: cada insert, update ou delete entra como um evento distinto, sem depender de comparar dois estados ou reter snapshots inteiros.
Isso ainda deixa um problema: atualizar arquivos colunares grandes a cada mudança não é grátis — um Parquet de centenas de megabytes não foi pensado pra ser reescrito a cada UPDATE. É esse custo que a LSM Tree administra: escritas recentes chegam como novos arquivos num nível raso da árvore (L0), reorganizados depois em sorted runs mais profundos por compaction. O trade-off é explícito, não uma eficiência gratuita — mais sorted runs encarecem a leitura, compactar demais atrasa a escrita — e costuma ser um parâmetro configurável, não uma constante fixa da estrutura.
Volte de novo à sequência do cliente 8821 — a mesma usada no modelo de snapshots — e acompanhe o que a LSM Tree faz com cada evento.
Os diagramas e a listagem de arquivos desta seção são modelos conceituais, simplificados para o argumento — não a representação literal do layout interno do Paimon.
1. INSERT chega
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#1F2937",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart TD
subgraph timeline[" "]
direction LR
O1["t0 · INSERT"] --> O2["t1 · UPDATE"] --> O3["t2 · UPDATE"] --> O4["t3 · DELETE"]
end
timeline ~~~ OD1["cliente 8821 · plano: trial"]
classDef active fill:#FFF4DB,stroke:#B45309,color:#000000,stroke-width:2px;
classDef inactive fill:#F7F7F5,stroke:#D6D3D1,color:#000000,stroke-width:1px;
classDef detail fill:#EFF6FF,stroke:#2563EB,color:#000000,stroke-width:2px,stroke-dasharray:5 3;
classDef invisible fill:none,stroke:none;
class O1 active;
class O2,O3,O4 inactive;
class OD1 detail;
class timeline invisible;
O insert do cliente 8821 entra como um novo arquivo em L0, o nível mais raso da árvore, dentro do bucket responsável por essa chave. Ao mesmo tempo, a operação já é emitida no changelog — não como consequência de uma leitura futura, mas como parte do próprio caminho de escrita.
bucket-03/
L0/
data-0001.parquet # insert · cliente_id=8821 · plano=trial
changelog/
chg-0001 # insert · plano=trial
2. Primeiro UPDATE chega
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#1F2937",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart TD
subgraph timeline[" "]
direction LR
O1["t0 · INSERT"] --> O2["t1 · UPDATE"] --> O3["t2 · UPDATE"] --> O4["t3 · DELETE"]
end
timeline ~~~ OD2["cliente 8821 · plano: trial → pro"]
classDef active fill:#FFF4DB,stroke:#B45309,color:#000000,stroke-width:2px;
classDef inactive fill:#F7F7F5,stroke:#D6D3D1,color:#000000,stroke-width:1px;
classDef detail fill:#EFF6FF,stroke:#2563EB,color:#000000,stroke-width:2px,stroke-dasharray:5 3;
classDef invisible fill:none,stroke:none;
class O1,O3,O4 inactive;
class O2 active;
class OD2 detail;
class timeline invisible;
O upgrade de plano entra como mais um arquivo em L0 — não como a reescrita do arquivo anterior. É só mais uma operação, associada à mesma chave. O changelog já registra essa mudança assim que ela acontece: chg-0002 diz, sem ambiguidade, que o plano virou pro. Não é preciso comparar nada com o que veio antes.
bucket-03/
L0/
data-0001.parquet # insert · plano=trial
data-0002.parquet # update · plano=pro
changelog/
chg-0001 # insert · plano=trial
chg-0002 # update · plano=pro
3. Segundo UPDATE chega
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#1F2937",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart TD
subgraph timeline[" "]
direction LR
O1["t0 · INSERT"] --> O2["t1 · UPDATE"] --> O3["t2 · UPDATE"] --> O4["t3 · DELETE"]
end
timeline ~~~ OD3["cliente 8821 · endereço atualizado"]
classDef active fill:#FFF4DB,stroke:#B45309,color:#000000,stroke-width:2px;
classDef inactive fill:#F7F7F5,stroke:#D6D3D1,color:#000000,stroke-width:1px;
classDef detail fill:#EFF6FF,stroke:#2563EB,color:#000000,stroke-width:2px,stroke-dasharray:5 3;
classDef invisible fill:none,stroke:none;
class O1,O2,O4 inactive;
class O3 active;
class OD3 detail;
class timeline invisible;
A atualização do endereço chega como uma terceira operação. Com L0 acumulando arquivos, a compaction entra em ação: data-0001 e data-0002 são mesclados num sorted run mais profundo, consolidando as duas primeiras operações num único registro — plano=pro. O novo arquivo de L0 (com o endereço atualizado) ainda não foi compactado.
bucket-03/
L0/
data-0003.parquet # update · endereço novo
L1 (sorted run)/
run-0001.parquet # consolidado: plano=pro (resultado da compaction de data-0001 + data-0002)
changelog/
chg-0001
chg-0002
chg-0003 # update · endereço novo
A compaction reorganiza fisicamente os arquivos, mas não apaga nada do changelog — chg-0001, chg-0002 e chg-0003 continuam lá, na ordem em que aconteceram, independentemente de quando ou como a árvore decide compactar.
4. DELETE chega
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#1F2937",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart TD
subgraph timeline[" "]
direction LR
O1["t0 · INSERT"] --> O2["t1 · UPDATE"] --> O3["t2 · UPDATE"] --> O4["t3 · DELETE"]
end
timeline ~~~ OD4["cliente 8821 · removido"]
classDef active fill:#FFF4DB,stroke:#B45309,color:#000000,stroke-width:2px;
classDef inactive fill:#F7F7F5,stroke:#D6D3D1,color:#000000,stroke-width:1px;
classDef detail fill:#EFF6FF,stroke:#2563EB,color:#000000,stroke-width:2px,stroke-dasharray:5 3;
classDef invisible fill:none,stroke:none;
class O1,O2,O3 inactive;
class O4 active;
class OD4 detail;
class timeline invisible;
O delete entra como a quarta operação, do mesmo jeito que as três anteriores — mais um arquivo em L0, marcando a chave cliente_id=8821 como removida. Uma leitura feita agora combina L0 com os sorted runs mais profundos e não encontra mais um registro válido para essa chave.
bucket-03/
L0/
data-0004.parquet # delete · cliente_id=8821
L1 (sorted run)/
run-0001.parquet # plano=pro, endereço antigo (ainda não reflete o delete)
changelog/
chg-0001
chg-0002
chg-0003
chg-0004 # delete
A diferença central em relação ao modelo de snapshots não é sutil: em nenhum momento foi preciso comparar o estado atual com um estado anterior pra saber o que mudou. As quatro entradas do changelog — insert, update, update, delete — já existiam, uma a uma, no instante em que cada operação foi escrita. Ler isso como stream não é uma reconstrução; é continuar consumindo algo que a tabela já estava produzindo.
O padrão que aparece nesses quatro passos não é exclusivo de uma ferramenta: é assim que qualquer LSM Tree se comporta, por construção. Vale reter esse vocabulário — sorted runs, níveis, compaction — porque é um conceito que esta série deve retomar mais adiante, com mais profundidade, quando o tema for como servir consultas sobre o dado mais recente com latência ainda menor.
Apache Paimon: o Lakehouse que usa esse modelo
É sobre essa estrutura que o Apache Paimon organiza suas Primary Key Tables. Cada primary key define o conjunto de colunas único por registro, e cada bucket — a partição interna da tabela — é uma árvore LSM independente, recebendo a cada checkpoint do Flink o mesmo caminho de L0 e compaction descrito na seção anterior.
O Paimon lida com esse trade-off oferecendo modos de tabela diferentes: o padrão, merge-on-read, prioriza a escrita e paga o custo do merge na leitura; copy-on-write inverte essa prioridade, reescrevendo mais dados a cada mudança pra manter a leitura simples.
Isso só faz sentido como um conjunto de três peças, não como conceitos isolados:
- Primary Key define qual é a “linha atual” para cada chave.
- LSM Tree explica como updates frequentes são absorvidos sem reescrever tudo a cada mudança, e como a compaction reconcilia múltiplas versões de um mesmo registro.
- Changelog Producer explica como essas mudanças fluem para os consumidores downstream, fechando o ciclo descrito nas seções anteriores.
Na prática, essa combinação muda o papel da tabela num pipeline. Um desenho comum trata a tabela como destino final:
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#1F2937",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart LR
classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
classDef active fill:#FFF4DB,stroke:#B45309,color:#1F2937,stroke-width:2px;
K[Kafka] --> F[Flink] --> L[Lakehouse]
class K,F muted;
class L active;
O modelo do Paimon é diferente:
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#F7F7F5",
"primaryTextColor": "#1F2937",
"primaryBorderColor": "#D6D3D1",
"tertiaryColor": "#FFF4DB",
"lineColor": "#6B7280",
"clusterBkg": "#EEEDE8",
"clusterBorder": "#D6D3D1",
"fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
"fontSize": "15px"
},
"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 30, "padding": 8}
}}%%
flowchart LR
classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
classDef active fill:#FFF4DB,stroke:#B45309,color:#1F2937,stroke-width:2px;
K[Kafka] --> F1[Flink] --> P1[Paimon] --> F2[Flink] --> P2[Paimon]
class K,F1,F2 muted;
class P1,P2 active;
Aqui, a tabela Paimon não é só destino — ela é estado materializado, fonte para consultas e fonte incremental de mudanças para a próxima transformação. O Paimon documenta esse objetivo explicitamente: “streaming write can continuously produce the latest changes for streaming read” — um segundo job Flink pode consumir de volta exatamente o que mudou, sem reprocessar tudo, e alimentar outro sistema.
É esse encadeamento — tabela como participante ativo, não só como arquivo final — que explica por que o Paimon se encaixa naturalmente em uma arquitetura streaming.
O limite do Paimon
Este artigo mostrou por que o Paimon é, entre os formatos avaliados, o mais alinhado a um workload de mudanças contínuas: Primary Key, LSM Tree e Changelog Producer formam um conjunto desenhado para receber e propagar mutação continuamente.
Mas, no final da cadeia, o Paimon documenta com clareza onde os dados efetivamente residem: “Por baixo do capô, o Paimon armazena os arquivos colunares em filesystem ou object store”. Changelog, updates eficientes e leitura incremental não mudam esse destino final: o dado persistido continua sendo arquivo em storage durável.
Isso importa porque storage durável — mesmo bem otimizado, mesmo com LSM, mesmo com changelog — carrega latências e padrões de acesso diferentes dos exigidos por uma resposta subsecond consistente. Receber mudanças continuamente e produzir changelog resolve o problema de manter um streaming lakehouse atualizado. Não resolve, por si só, o problema de responder uma consulta subsecond sobre esse mesmo estado.
Streaming ingestion não é a mesma coisa que real-time serving.
O critério prático que fica deste artigo: ao avaliar uma arquitetura de tempo real, vale a pena separar explicitamente onde os dados mudam e são persistidos de forma durável de onde as consultas são respondidas com a latência que o caso de uso exige. Um formato de Lakehouse streaming-first resolve bem a primeira pergunta. Não resolve sozinho a segunda.
Isso deixa uma pergunta em aberto para o resto da série: se conseguimos manter um lakehouse atualizado continuamente, como disponibilizamos esses dados para consultas com latência realmente subsecond?
Fontes principais
- Apache Paimon — Primary Key Table Overview
- Apache Paimon — Table Mode (LSM)
- Apache Paimon — Changelog Producer
- Apache Paimon — Compaction
- Apache Paimon — Concepts Overview
- Apache Iceberg — Table Specification
- Apache Iceberg — Spark Structured Streaming
- Delta Lake — Streaming Reads and Writes
- Delta Lake — Change Data Feed
- Apache Hudi — Table & Query Types
- Apache Hudi — Concepts
- Apache Hudi — Compaction
- Apache Hudi — Comparing Merge-on-Read implementations