Batch
Escopo: SQL. Visão transversal: shared/platform/database.md.
Escrever muitos registros de uma vez pede uma abordagem diferente de escrever um. Na inserção, mandar mil linhas num comando só evita mil idas e voltas pela rede, e cada ida e volta custa mais do que a gravação em si.
Na atualização e na exclusão em massa, o problema é outro: o lock. Um DELETE que apaga milhões de linhas trava essas linhas do começo ao fim da transação, e todo mundo que precisar delas fica esperando, às vezes por minutos. Quebrando o trabalho em lotes de mil, cada COMMIT libera o lock e deixa as outras operações passarem entre um lote e o próximo.
Conceitos fundamentais
| Conceito | O que é |
|---|---|
| batch (lote) | Conjunto de operações enviadas juntas para reduzir round trips com o banco |
| multi-row INSERT (INSERT de múltiplas linhas) | INSERT ... VALUES (...), (...), (...); uma única instrução insere várias linhas |
| chunked update (atualização em pedaços) | Dividir um UPDATE grande em lotes de tamanho fixo para liberar lock entre commits |
| bulk load (carga em massa) | Mecanismo nativo do SGBD: COPY (PostgreSQL), BULK INSERT (SQL Server) |
| transaction lock (bloqueio de transação) | Lock mantido pela transação enquanto modifica linhas; lotes menores reduzem contenção |
| round trip (ida e volta) | Latência de uma requisição cliente-banco; lotes amortizam o custo por linha |
| MERGE (mesclar) | Comando que faz INSERT ou UPDATE conforme a chave existe ou não; útil para upsert em lote |
Um INSERT com várias linhas de uma vez
O INSERT aceita vários blocos de VALUES separados por vírgula. Mil linhas viram um comando, e a aplicação faz uma viagem até o banco em vez de mil.
O SQL Server tem um limite de 1.000 linhas por INSERT, então uma carga maior se quebra em blocos desse tamanho.
❌ Ruim: um INSERT por linha, um round trip por registro
INSERT INTO Players (Id, Name, Position, TeamId) VALUES (1, 'Alice', 'GK', @TeamId);
INSERT INTO Players (Id, Name, Position, TeamId) VALUES (2, 'Bob', 'CB', @TeamId);
INSERT INTO Players (Id, Name, Position, TeamId) VALUES (3, 'Carol', 'ST', @TeamId);
✅ Bom: um INSERT com múltiplos VALUES
INSERT INTO Players
(
Id,
Name,
Position,
TeamId
)
VALUES
(1, 'Alice', 'GK', @TeamId),
(2, 'Bob', 'CB', @TeamId),
(3, 'Carol', 'ST', @TeamId);
Quando os dados vêm de outra tabela, INSERT ... SELECT é preferível: uma operação, sem
construção de lista de VALUES no código.
✅ Bom: INSERT ... SELECT de tabela de origem
INSERT INTO Players
(
Id,
Name,
Position,
TeamId
)
SELECT
ExternalPlayers.ExternalId,
ExternalPlayers.FullName,
ExternalPlayers.Position,
FootballTeams.Id
FROM
ExternalPlayers
JOIN
FootballTeams ON FootballTeams.ExternalId = ExternalPlayers.TeamExternalId
WHERE
ExternalPlayers.IsVerified = 1; -- verified only
DELETE em lotes
O DELETE TOP (@ChunkSize) apaga até mil linhas por vez, dentro de um WHILE que repete enquanto houver o que apagar. O SET @RowsDeleted = @@ROWCOUNT logo abaixo lê quantas linhas o comando anterior apagou, e é isso que encerra o laço quando o resultado chega a zero.
O tamanho do lote é um ajuste: mil linhas costumam ser um bom começo, e vale medir no seu banco.
❌ Ruim: DELETE único em tabela grande: lock de longa duração
-- bloqueia Players pela duração inteira da operação
DELETE FROM
Players
WHERE
Players.IsActive = 0 AND
Players.InactivatedAt < @CutoffDate;
✅ Bom: DELETE em lotes com TOP + WHILE: lock liberado a cada commit
DECLARE @ChunkSize INT = 1000;
DECLARE @RowsDeleted INT = 1;
WHILE @RowsDeleted > 0
BEGIN
DELETE TOP (@ChunkSize) FROM
Players
WHERE
Players.IsActive = 0 AND
Players.InactivatedAt < @CutoffDate;
SET @RowsDeleted = @@ROWCOUNT;
END;
UPDATE em lotes
O UPDATE em massa segue a mesma estrutura do DELETE: TOP (@ChunkSize) dentro de um WHILE, com o @@ROWCOUNT decidindo quando parar.
Existe uma armadilha aqui. O WHERE precisa excluir as linhas que o lote anterior já alterou, senão o mesmo conjunto de linhas volta a ser selecionado a cada volta e o laço nunca termina. No exemplo, Players.IsActive = 1 cumpre esse papel: a linha atualizada deixa de casar com o filtro.
❌ Ruim: UPDATE único em tabela grande
-- deactivate players from dissolved teams: pode modificar milhões de linhas
UPDATE
Players
SET
Players.IsActive = 0,
Players.InactivatedAt = GETUTCDATE()
FROM
Players
JOIN
FootballTeams ON FootballTeams.Id = Players.TeamId
WHERE
FootballTeams.IsActive = 0 AND
Players.IsActive = 1;
✅ Bom: UPDATE TOP + WHILE: lotes de tamanho fixo
DECLARE @ChunkSize INT = 1000;
DECLARE @RowsUpdated INT = 1;
WHILE @RowsUpdated > 0
BEGIN
UPDATE TOP (@ChunkSize)
Players
SET
Players.IsActive = 0,
Players.InactivatedAt = GETUTCDATE()
FROM
Players
JOIN
FootballTeams ON FootballTeams.Id = Players.TeamId
WHERE
FootballTeams.IsActive = 0 AND
Players.IsActive = 1;
SET @RowsUpdated = @@ROWCOUNT;
END;
Staging table: valide antes de entrar na tabela real
A staging table (tabela de entrada) é uma tabela intermediária, sem constraint nenhuma, que recebe o dado bruto do parceiro exatamente como ele veio. A validação acontece depois, com queries contra ela.
Sem esse passo, a carga vai direto para a tabela de produção e a primeira linha inválida derruba a operação inteira, no meio. Com a staging, você carrega tudo, descobre em uma query quais linhas têm time inexistente ou posição inválida, separa essas e promove só as boas.
❌ Ruim: inserir dados externos diretamente na tabela de produção sem validação
-- dados brutos do parceiro entram direto: posições inválidas ou times inexistentes quebram no FK
INSERT INTO Players
(
Id,
Name,
Position,
TeamId
)
SELECT
ExternalPlayers.ExternalId,
ExternalPlayers.FullName,
ExternalPlayers.Position, -- pode conter valores fora do domínio
ExternalPlayers.TeamId -- pode referenciar time inexistente
FROM
ExternalPlayers;
✅ Bom: staging → validar → inserir apenas registros válidos
-- Etapa 1: receber dados brutos na staging
SELECT
ExternalPlayers.ExternalId,
ExternalPlayers.FullName,
ExternalPlayers.Position,
ExternalPlayers.TeamExternalId,
CAST(1 AS BIT) AS IsValid
INTO
#PlayerImportStaging
FROM
ExternalPlayers;
-- Etapa 2: marcar registros com posição fora do domínio
UPDATE
#PlayerImportStaging
SET
#PlayerImportStaging.IsValid = 0
WHERE
#PlayerImportStaging.Position NOT IN ('GK', 'CB', 'RB', 'LB', 'CM', 'AM', 'ST', 'LW', 'RW');
-- Etapa 3: marcar registros com time inexistente
UPDATE
#PlayerImportStaging
SET
#PlayerImportStaging.IsValid = 0
WHERE
NOT EXISTS (
SELECT 1
FROM FootballTeams
WHERE FootballTeams.ExternalId = #PlayerImportStaging.TeamExternalId
);
-- Etapa 4: inserir apenas os válidos na tabela de produção
INSERT INTO Players
(
Id,
Name,
Position,
TeamId
)
SELECT
#PlayerImportStaging.ExternalId,
#PlayerImportStaging.FullName,
#PlayerImportStaging.Position,
FootballTeams.Id
FROM
#PlayerImportStaging
JOIN
FootballTeams ON FootballTeams.ExternalId = #PlayerImportStaging.TeamExternalId
WHERE
#PlayerImportStaging.IsValid = 1; -- valid records only
DROP TABLE #PlayerImportStaging;
DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.