Bancos colunares
Armazenamento orientado a colunas para escrita em volume e para consultas analíticas.
O código desta página foi executado nas cinco linguagens contra um ClickHouse 26. As medições do nível Conceito usaram o mesmo ClickHouse, um PostgreSQL 18 e um Cassandra 5.
Gravar em lotes e consultar agregados, em cada linguagem
O ClickHouse tem uma interface HTTP: o SQL vai na URL e os dados no corpo do pedido. Por isso, as cinco versões usam só a biblioteca padrão da linguagem, sem driver. Cada uma cria a tabela, grava 100 mil eventos em lotes de 10 mil, no formato TabSeparated, e consulta a soma por tipo.
const URL_CH = process.env.CLICKHOUSE_URL!; // ex.: http://clickhouse:8123
const cabecalhos = {
"X-ClickHouse-User": "default",
"X-ClickHouse-Key": process.env.CLICKHOUSE_PASSWORD!,
};
const TIPOS = ["visita", "carrinho", "compra", "busca"];
// Pela interface HTTP: o SQL vai na URL, os dados no corpo (sempre POST para gravar)
async function executar(sql: string, corpo = ""): Promise<string> {
const resposta = await fetch(`${URL_CH}/?query=${encodeURIComponent(sql)}`,
{ method: "POST", headers: cabecalhos, body: corpo });
if (!resposta.ok) throw new Error(await resposta.text());
return resposta.text();
}
// Colunar gosta de lotes grandes: cada INSERT vira uma parte no disco
async function inserirEmLotes(total: number, tamanhoLote: number) {
for (let inicio = 0; inicio < total; inicio += tamanhoLote) {
const linhas: string[] = [];
for (let i = inicio; i < Math.min(inicio + tamanhoLote, total); i++) {
const ts = new Date(Date.UTC(2025, 0, 1) + i * 1000)
.toISOString().slice(0, 19).replace("T", " ");
linhas.push(`${ts}\t${TIPOS[i % 4]}\t${i % 5000}\t${((i % 1000) / 100).toFixed(2)}`);
}
await executar("INSERT INTO eventos_app FORMAT TabSeparated", linhas.join("\n") + "\n");
}
}
await executar(`CREATE OR REPLACE TABLE eventos_app (ts DateTime, tipo LowCardinality(String),
cliente_id UInt32, valor Decimal(12, 2)) ENGINE = MergeTree ORDER BY (tipo, ts)`);
await inserirEmLotes(100_000, 10_000);
console.log(await executar(
"SELECT tipo, count(), sum(valor) FROM eventos_app " +
"GROUP BY tipo ORDER BY tipo FORMAT TabSeparated"));import os
import urllib.parse
import urllib.request
from datetime import datetime, timedelta
URL_CH = os.environ["CLICKHOUSE_URL"] # ex.: http://clickhouse:8123
CABECALHOS = {
"X-ClickHouse-User": "default",
"X-ClickHouse-Key": os.environ["CLICKHOUSE_PASSWORD"],
}
TIPOS = ["visita", "carrinho", "compra", "busca"]
def executar(sql: str, corpo: bytes = b"") -> str:
"""Pela interface HTTP: o SQL vai na URL, os dados no corpo (sempre POST para gravar)."""
url = f"{URL_CH}/?query={urllib.parse.quote(sql)}"
pedido = urllib.request.Request(url, data=corpo, headers=CABECALHOS, method="POST")
with urllib.request.urlopen(pedido) as resposta:
return resposta.read().decode()
def inserir_em_lotes(total: int, tamanho_lote: int) -> None:
"""Colunar gosta de lotes grandes: cada INSERT vira uma parte no disco."""
base = datetime(2025, 1, 1)
for inicio in range(0, total, tamanho_lote):
linhas = [
f"{base + timedelta(seconds=i):%Y-%m-%d %H:%M:%S}\t{TIPOS[i % 4]}"
f"\t{i % 5000}\t{i % 1000 / 100:.2f}"
for i in range(inicio, min(inicio + tamanho_lote, total))
]
executar("INSERT INTO eventos_app FORMAT TabSeparated", ("\n".join(linhas) + "\n").encode())
executar("""CREATE OR REPLACE TABLE eventos_app (ts DateTime, tipo LowCardinality(String),
cliente_id UInt32, valor Decimal(12, 2)) ENGINE = MergeTree ORDER BY (tipo, ts)""")
inserir_em_lotes(100_000, 10_000)
print(executar(
"SELECT tipo, count(), sum(valor) FROM eventos_app "
"GROUP BY tipo ORDER BY tipo FORMAT TabSeparated"))package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
var tipos = []string{"visita", "carrinho", "compra", "busca"}
// executar usa a interface HTTP: o SQL vai na URL, os dados no corpo (sempre POST para gravar)
func executar(sql, corpo string) (string, error) {
endereco := os.Getenv("CLICKHOUSE_URL") + "/?query=" + url.QueryEscape(sql)
pedido, _ := http.NewRequest(http.MethodPost, endereco, strings.NewReader(corpo))
pedido.Header.Set("X-ClickHouse-User", "default")
pedido.Header.Set("X-ClickHouse-Key", os.Getenv("CLICKHOUSE_PASSWORD"))
resposta, err := http.DefaultClient.Do(pedido)
if err != nil {
return "", err
}
defer resposta.Body.Close()
texto, _ := io.ReadAll(resposta.Body)
if resposta.StatusCode != http.StatusOK {
return "", fmt.Errorf("%s", texto)
}
return string(texto), nil
}
// inserirEmLotes manda lotes grandes: cada INSERT vira uma parte no disco
func inserirEmLotes(total, tamanhoLote int) error {
base := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
for inicio := 0; inicio < total; inicio += tamanhoLote {
var linhas strings.Builder
for i := inicio; i < min(inicio+tamanhoLote, total); i++ {
ts := base.Add(time.Duration(i) * time.Second).Format(time.DateTime)
fmt.Fprintf(&linhas, "%s\t%s\t%d\t%.2f\n", ts, tipos[i%4], i%5000, float64(i%1000)/100)
}
_, err := executar("INSERT INTO eventos_app FORMAT TabSeparated", linhas.String())
if err != nil {
return err
}
}
return nil
}
func main() {
_, err := executar(`CREATE OR REPLACE TABLE eventos_app (ts DateTime, tipo LowCardinality(String),
cliente_id UInt32, valor Decimal(12, 2)) ENGINE = MergeTree ORDER BY (tipo, ts)`, "")
if err != nil {
panic(err)
}
if err := inserirEmLotes(100_000, 10_000); err != nil {
panic(err)
}
resultado, err := executar("SELECT tipo, count(), sum(valor) FROM eventos_app "+
"GROUP BY tipo ORDER BY tipo FORMAT TabSeparated", "")
fmt.Print(resultado, err, "\n")
}import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class Eventos {
static final String URL_CH = System.getenv("CLICKHOUSE_URL"); // ex.: http://clickhouse:8123
static final String[] TIPOS = {"visita", "carrinho", "compra", "busca"};
static final HttpClient HTTP = HttpClient.newHttpClient();
// Pela interface HTTP: o SQL vai na URL, os dados no corpo (sempre POST para gravar)
static String executar(String sql, String corpo) throws Exception {
var pedido = HttpRequest.newBuilder(
URI.create(URL_CH + "/?query=" + URLEncoder.encode(sql, StandardCharsets.UTF_8)))
.header("X-ClickHouse-User", "default")
.header("X-ClickHouse-Key", System.getenv("CLICKHOUSE_PASSWORD"))
.POST(HttpRequest.BodyPublishers.ofString(corpo))
.build();
var resposta = HTTP.send(pedido, HttpResponse.BodyHandlers.ofString());
if (resposta.statusCode() != 200) throw new RuntimeException(resposta.body());
return resposta.body();
}
// Colunar gosta de lotes grandes: cada INSERT vira uma parte no disco
static void inserirEmLotes(int total, int tamanhoLote) throws Exception {
var base = LocalDateTime.of(2025, 1, 1, 0, 0);
var formato = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
for (int inicio = 0; inicio < total; inicio += tamanhoLote) {
var linhas = new StringBuilder();
for (int i = inicio; i < Math.min(inicio + tamanhoLote, total); i++) {
linhas.append(base.plusSeconds(i).format(formato)).append('\t')
.append(TIPOS[i % 4]).append('\t')
.append(i % 5000).append('\t')
.append(String.format("%.2f", (i % 1000) / 100.0)).append('\n');
}
executar("INSERT INTO eventos_app FORMAT TabSeparated", linhas.toString());
}
}
public static void main(String[] args) throws Exception {
executar("""
CREATE OR REPLACE TABLE eventos_app (ts DateTime, tipo LowCardinality(String),
cliente_id UInt32, valor Decimal(12, 2))
ENGINE = MergeTree ORDER BY (tipo, ts)""", "");
inserirEmLotes(100_000, 10_000);
System.out.print(executar("SELECT tipo, count(), sum(valor) FROM eventos_app "
+ "GROUP BY tipo ORDER BY tipo FORMAT TabSeparated", ""));
}
}using System.Globalization;
using System.Text;
var urlCh = Environment.GetEnvironmentVariable("CLICKHOUSE_URL"); // ex.: http://clickhouse:8123
var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-ClickHouse-User", "default");
var senha = Environment.GetEnvironmentVariable("CLICKHOUSE_PASSWORD");
http.DefaultRequestHeaders.Add("X-ClickHouse-Key", senha);
string[] tipos = ["visita", "carrinho", "compra", "busca"];
// Pela interface HTTP: o SQL vai na URL, os dados no corpo (sempre POST para gravar)
async Task<string> Executar(string sql, string corpo = "")
{
var resposta = await http.PostAsync($"{urlCh}/?query={Uri.EscapeDataString(sql)}",
new StringContent(corpo, Encoding.UTF8));
var texto = await resposta.Content.ReadAsStringAsync();
if (!resposta.IsSuccessStatusCode) throw new Exception(texto);
return texto;
}
// Colunar gosta de lotes grandes: cada INSERT vira uma parte no disco
async Task InserirEmLotes(int total, int tamanhoLote)
{
var base_ = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
for (var inicio = 0; inicio < total; inicio += tamanhoLote)
{
var linhas = new StringBuilder();
for (var i = inicio; i < Math.Min(inicio + tamanhoLote, total); i++)
linhas.Append(CultureInfo.InvariantCulture,
$"{base_.AddSeconds(i):yyyy-MM-dd HH:mm:ss}\t{tipos[i % 4]}\t{i % 5000}\t")
.Append(CultureInfo.InvariantCulture, $"{(i % 1000) / 100m:0.00}\n");
await Executar("INSERT INTO eventos_app FORMAT TabSeparated", linhas.ToString());
}
}
await Executar("""
CREATE OR REPLACE TABLE eventos_app (ts DateTime, tipo LowCardinality(String),
cliente_id UInt32, valor Decimal(12, 2)) ENGINE = MergeTree ORDER BY (tipo, ts)
""");
await InserirEmLotes(100_000, 10_000);
Console.Write(await Executar(
"SELECT tipo, count(), sum(valor) FROM eventos_app " +
"GROUP BY tipo ORDER BY tipo FORMAT TabSeparated"));O resultado, nas cinco linguagens
As cinco versões gravaram os 100 mil eventos em 10 lotes e devolveram o mesmo resultado:
busca 25000 125250
carrinho 25000 124750
compra 25000 125000
visita 25000 124500Os 10 lotes viraram 10 partes no disco, que o ClickHouse junta em segundo plano. No teste do nível Conceito, gravar linha a linha criou uma parte por linha e levou mil vezes mais.
O que muda de uma linguagem para outra
| Cliente HTTP | Escapar o SQL na URL | Data no formato do ClickHouse | |
|---|---|---|---|
| TypeScript | fetch | encodeURIComponent | toISOString() cortado e sem o T |
| Python | urllib.request | urllib.parse.quote | f"{data:%Y-%m-%d %H:%M:%S}" |
| Go | net/http | url.QueryEscape | time.DateTime |
| Java | java.net.http.HttpClient | URLEncoder.encode | DateTimeFormatter |
| C# | HttpClient | Uri.EscapeDataString | yyyy-MM-dd HH:mm:ss |
Dois cuidados que o teste mostrou: comandos que alteram dados precisam ir por POST, porque o ClickHouse trata pedidos GET como somente leitura, e números decimais precisam ser formatados com ponto, independentemente do idioma do sistema (por isso o CultureInfo.InvariantCulture no C#).
Para aplicações maiores, os drivers oficiais (@clickhouse/client, clickhouse-connect, clickhouse-go, o driver JDBC e o ClickHouse.Client) acrescentam pool de conexões, formatos binários e inserções assíncronas. A interface HTTP mostra o que eles fazem por baixo.
O esquema do teste
CREATE TABLE eventos (
ts DateTime,
tipo LowCardinality(String), -- poucos valores distintos: guardado como dicionário
cliente_id UInt32,
valor Decimal(12, 2)
)
ENGINE = MergeTree
ORDER BY (tipo, ts); -- a ordem física é o índice principalCom a tabela ordenada por tipo e ts, o índice esparso do MergeTree permite à consulta das compras de março pular os blocos de outros tipos e de outras datas. No teste, ela respondeu em 9 ms, contra 128 ms da soma que percorre a tabela inteira.
Lista de revisão
Escolha
- O volume e a frequência dos relatórios justificam um banco analítico, ou resumos e uma réplica bastam?
- O banco de linha larga foi escolhido por escrita enorme e consultas pela chave, e não por moda?
Gravação
- Os dados chegam ao banco analítico em lotes, e nunca linha a linha?
- Há um caminho definido da fonte da verdade até o banco analítico (exportação, CDC ou eventos)?
- Os dados são tratados como imutáveis, com correções como exceção?
Modelagem
- A tabela analítica é larga, com a ordem pelas colunas mais filtradas?
- Os tipos são os menores possíveis, com
LowCardinalitynas colunas de poucos valores? - No Cassandra, cada tabela atende a uma consulta, com partições de tamanho limitado?
- Ninguém usa
ALLOW FILTERINGem produção, nem conta com erro de chave duplicada?
Prompts para aprofundar
Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.
Alimentar o banco analítico em lotes
Quero gravar os eventos da minha aplicação num banco analítico sem derrubá-lo com inserções pequenas. Pergunte qual linguagem, qual banco analítico e de onde vêm os eventos. Vamos em etapas: 1. Juntar os eventos em lotes, por quantidade ou por tempo. 2. Gravar cada lote com uma inserção só, com novas tentativas e sem duplicar o lote. 3. Usar uma fila entre a aplicação e o banco analítico, se fizer sentido. 4. Medir o número de partes e o atraso dos dados. 5. Testar o que acontece se o banco analítico ficar fora do ar.
Migrar relatórios para o ClickHouse
Quero mover relatórios pesados do PostgreSQL para o ClickHouse. Pergunte quais são os relatórios, as tabelas envolvidas e o volume. Me ajude a: 1. Desenhar as tabelas largas no ClickHouse, com a ordem e os tipos certos. 2. Escolher como manter os dados sincronizados com o PostgreSQL. 3. Reescrever as consultas dos relatórios. 4. Comparar os resultados nos dois bancos antes de trocar. 5. Medir os tempos antes e depois.
Revisar o uso de um banco colunar
Vou colar abaixo o código que grava e consulta um banco colunar (ClickHouse, BigQuery, Cassandra ou outro) e o esquema das tabelas. Revise. Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho: 1. As gravações são feitas em lotes? 2. Há atualizações ou exclusões frequentes? 3. As tabelas estão ordenadas ou particionadas pelas consultas? 4. Os tipos são os menores possíveis? 5. No Cassandra: as consultas usam a chave de partição, e há ALLOW FILTERING? 6. O banco está sendo usado para consultas de um registro só, que outro banco resolveria melhor? Para cada problema, descreva o efeito em produção e mostre a correção. Código e esquema: [cole aqui]