Blog

Chave primária int que vai estourar: migrar para bigint com a tabela em uso

Uma plataforma de rastreamento de entregas grava cada leitura de scanner, mudança de status e tentativa de entrega na tabela eventos_rastreio, criada há seis anos com id serial, ou seja, integer de 32 bits. Em uma revisão de rotina, alguém percebe que os ids da tela de suporte já passam de 1,8 bilhão. A sequência está em 1.868.004.113, 87% dos 2.147.483.647 que o tipo aceita, e consome 9,1 milhões de valores por dia: faltam 31 dias para todo INSERT falhar com "reached maximum value of sequence". A tabela tem 410 GB, recebe escrita 24 horas e o ALTER COLUMN TYPE bigint ensaiado em um clone segurou a tabela bloqueada por 6 horas e 10 minutos. Este artigo mostra por que o limite chega antes do que o count(*) sugere, quais caminhos existem e quanto cada um custa, o que precisa mudar na aplicação antes de tocar no banco, como trocar para bigint com coluna nova, gatilho, backfill em lotes e uma troca de 380 ms, e como garantir que a próxima tabela não chegue ao mesmo ponto.

2026-10-28 / Arquitetura / 15 min

01

Por que o int estoura antes do que o count(*) diz

O número que importa não é quantas linhas a tabela tem, e sim o último valor que a sequência entregou. Toda chamada a nextval() consome um valor para sempre: INSERT que deu rollback, INSERT que falhou por violação de restrição, ON CONFLICT DO NOTHING que descartou a linha e os valores pré-alocados por CACHE em conexões que morreram. Na eventos_rastreio, o count(*) mostrava 1,41 bilhão de linhas, porque eventos com mais de dois anos são apagados, enquanto a sequência já estava em 1,87 bilhão. Quem monitora pelo tamanho da tabela descobre o problema pelo erro.

-- Quanto cada sequencia ja consumiu do tipo da coluna que ela alimenta.
-- Olha a SEQUENCIA, nao o max(id) nem o count(*): rollback, insert que falhou
-- e CACHE consomem valores que nunca viram linha.
SELECT c.relname AS tabela,
       a.attname AS coluna,
       format_type(a.atttypid, a.atttypmod) AS tipo,
       s.last_value,
       round(100.0 * s.last_value /
             CASE a.atttypid WHEN 'int2'::regtype THEN 32767 ELSE 2147483647 END, 1) AS pct_usado
FROM pg_sequences s
JOIN pg_namespace ns ON ns.nspname = s.schemaname
JOIN pg_class seq ON seq.relname = s.sequencename AND seq.relnamespace = ns.oid
JOIN pg_depend d ON d.objid = seq.oid
                AND d.classid = 'pg_class'::regclass
                AND d.refclassid = 'pg_class'::regclass
JOIN pg_class c ON c.oid = d.refobjid
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.refobjsubid
WHERE a.atttypid IN ('int2'::regtype, 'int4'::regtype)
  AND s.last_value IS NOT NULL
ORDER BY pct_usado DESC;

--      tabela       | coluna | tipo    | last_value | pct_usado
-- ------------------+--------+---------+------------+-----------
--  eventos_rastreio | id     | integer | 1868004113 |      87.0
--  notificacoes     | id     | integer |  812330954 |      37.8

-- Quem guarda esse id com outro nome e tambem vai estourar: as chaves estrangeiras.
SELECT con.conrelid::regclass AS tabela_filha,
       a.attname AS coluna,
       format_type(a.atttypid, a.atttypmod) AS tipo
FROM pg_constraint con
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY (con.conkey)
WHERE con.contype = 'f'
  AND con.confrelid = 'eventos_rastreio'::regclass;

O caminho óbvio, ALTER TABLE eventos_rastreio ALTER COLUMN id TYPE bigint, reescreve a tabela inteira, porque cada linha muda de tamanho físico, e reconstrói todos os índices. Durante esse tempo ele segura ACCESS EXCLUSIVE, o bloqueio que impede até SELECT. Em uma tabela de poucos gigabytes isso é um minuto em uma janela; em 410 GB foram 6 horas e 10 minutos no clone, com o disco precisando de espaço para duas cópias. E ele não resolve sozinho: a coluna evento_id em eventos_anexos, que referencia esse id, também é integer e estoura no mesmo dia.

02

Os caminhos possíveis e quanto cada um custa

CaminhoBloqueioNeste casoQuando faz sentido
ALTER COLUMN TYPE bigint diretoACCESS EXCLUSIVE durante toda a reescrita6 h 10 de tabela parada no cloneTabelas de poucos GB ou quando a janela é aceita
Tabela nova, cópia e troca de nomeCurto, só no finalDias de cópia e o dobro do discoQuando a tabela já vai ser reorganizada ou particionada junto
Coluna nova, gatilho, backfill e trocaMilissegundos na troca7 dias de calendário, 380 ms de bloqueioTabela grande, em uso contínuo, com prazo de semanas
Sequência em valores negativosNenhumSegundosTorniquete quando faltam horas ou poucos dias; não resolve

O torniquete merece explicação, porque é o que salva quando o problema é descoberto tarde demais. ALTER SEQUENCE eventos_rastreio_id_seq MINVALUE -2147483648 RESTART WITH -2147483648 devolve mais 2,1 bilhões de valores sem tocar na tabela: a sequência sobe de -2.147.483.648 até -1 antes de colidir com os ids existentes. O preço é alto e escondido: ORDER BY id deixa de significar ordem de criação, paginação por cursor em id quebra, validações do tipo id > 0 rejeitam linhas novas e qualquer integração que trata id como sem sinal falha. É uma forma de ganhar meses para fazer a migração certa, não uma alternativa a ela.

Com 31 dias de margem, o caminho escolhido foi a coluna nova. A ideia é a mesma do padrão expandir e contrair: criar id_novo bigint ao lado de id, manter as duas iguais para tudo que for escrito a partir de agora, copiar o passado em lotes pequenos, construir índice e restrições sem bloquear e, no fim, trocar os nomes em uma transação que só mexe em catálogo. A tabela nunca fica bloqueada por mais do que alguns milissegundos.

03

Antes do banco: a aplicação precisa aceitar bigint

A parte do banco é mecânica; o que derruba a migração é o que está fora dele. No momento da troca, a coluna id passa a ser int8, e cada camada que lê esse valor precisa estar pronta para números acima de 2.147.483.647 antes que o primeiro deles exista. Isso vem primeiro no cronograma, com deploy próprio, porque é a parte que não dá para desfazer depressa.

// db.js
// Depois da troca, a coluna id passa a ser int8. O node-postgres entrega int8
// como string, para nao perder precisao acima de 2^53: o mesmo id que hoje chega
// como 1868004113 passaria a chegar como "2147490001", e todo === entre id do
// banco e id da rota quebraria em silencio. Ids desta base levariam milhoes de
// anos para passar de 2^53, entao convertemos para number e falhamos alto se a
// premissa um dia quebrar. Este deploy sai ANTES da migracao: ele tambem muda
// count(*) e sum() de string para number, o que precisa ser testado com calma.
import pg from 'pg';

pg.types.setTypeParser(pg.types.builtins.INT8, (valor) => {
  const numero = Number(valor);
  if (!Number.isSafeInteger(numero)) {
    throw new RangeError(`int8 fora do intervalo seguro do JavaScript: ${valor}`);
  }
  return numero;
});

export const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  application_name: 'api-rastreio',
});
  • Drivers: o node-postgres entrega int8 como string; o código acima converte para number com verificação. O mesmo deploy muda o retorno de count(*) e sum(), que também são int8, então comparações como total === '0' precisam ser procuradas antes.
  • Clientes tipados: o aplicativo Android guardava o id em Int, o serviço de faturamento em Go usava int32 e um contrato protobuf declarava int32 evento_id. Todos foram para 64 bits antes do D3; em Java e Kotlin, Int estoura em 2.147.483.647 exatamente como o Postgres.
  • Eventos e CDC: o schema Avro publicado pelo Debezium mudou de int para long, uma promoção que o Avro aceita para leitores atualizados. Os consumidores foram atualizados antes, porque um leitor com schema antigo não lê long.
  • Busca e analytics: o mapeamento do índice de busca tinha id como integer, que não aceita valores acima de 2^31. Foi reindexado como long. No data warehouse, a coluna já era INT64.
  • Banco: views que selecionam id, funções SQL com parâmetro integer e colunas de outras tabelas que guardam o id sem chave estrangeira, como entregas.ultimo_evento_id. Uma busca por "evento" com tipo integer no information_schema.columns encontrou três.

04

Coluna nova, gatilho e backfill sem inchar a tabela

ADD COLUMN sem DEFAULT só registra a coluna no catálogo e termina em milissegundos, mas precisa de um bloqueio curto. Se houver uma transação longa aberta na tabela, o ALTER fica na fila e todas as consultas que chegam depois esperam atrás dele: é assim que um comando instantâneo derruba a API. O lock_timeout de 2 segundos troca esse risco por uma retentativa. Os gatilhos garantem que tudo que for inserido a partir de agora já nasce com a cópia, e só depois deles o backfill começa.

-- 1. Colunas novas sem DEFAULT: so altera o catalogo, nao reescreve a tabela.
--    O lock_timeout impede que o ALTER fique na fila atras de uma transacao longa
--    travando todas as consultas que chegarem depois dele. Se estourar, tente de novo.
SET lock_timeout = '2s';
ALTER TABLE eventos_rastreio ADD COLUMN id_novo bigint;
ALTER TABLE eventos_anexos ADD COLUMN evento_id_novo bigint;

-- 2. Gatilhos: toda linha inserida daqui em diante ja nasce com a copia.
--    O DEFAULT nextval() e aplicado antes do BEFORE INSERT, entao NEW.id existe.
--    UPDATE OF id dispara so quando o proprio id muda: o backfill, que altera
--    apenas id_novo, nao paga o custo do gatilho.
CREATE FUNCTION eventos_rastreio_copia_id() RETURNS trigger
LANGUAGE plpgsql AS $
BEGIN
  NEW.id_novo := NEW.id;
  RETURN NEW;
END;
$;

CREATE TRIGGER eventos_rastreio_copia_id
  BEFORE INSERT OR UPDATE OF id ON eventos_rastreio
  FOR EACH ROW EXECUTE FUNCTION eventos_rastreio_copia_id();

CREATE FUNCTION eventos_anexos_copia_evento_id() RETURNS trigger
LANGUAGE plpgsql AS $
BEGIN
  NEW.evento_id_novo := NEW.evento_id;
  RETURN NEW;
END;
$;

CREATE TRIGGER eventos_anexos_copia_evento_id
  BEFORE INSERT OR UPDATE OF evento_id ON eventos_anexos
  FOR EACH ROW EXECUTE FUNCTION eventos_anexos_copia_evento_id();

-- 3. O backfill vai criar uma versao nova de cada linha. Autovacuum agressivo
--    nesta tabela durante a migracao, para o espaco morto ser reaproveitado
--    pelos lotes seguintes em vez de dobrar o tamanho do arquivo.
ALTER TABLE eventos_rastreio SET (
  autovacuum_vacuum_scale_factor = 0,
  autovacuum_vacuum_threshold = 5000000,
  autovacuum_vacuum_cost_limit = 2000
);
// backfill.js
// Copia a coluna int para a bigint em lotes por faixa de chave, com pausa entre
// lotes e freio por atraso de replica. Pode ser interrompido e retomado: linhas
// ja copiadas nao sao tocadas de novo (destino IS NULL) e o progresso vai para o log.
// Rode DEPOIS de criar os gatilhos: o max lido aqui cobre tudo que existia antes
// deles, e o que entrar depois ja chega copiado.
// Uso: node backfill.js eventos_rastreio [chave_inicial]
import pg from 'pg';

// Lista fechada: os nomes entram no SQL, entao nunca vem de entrada externa.
const ALVOS = {
  eventos_rastreio: { chave: 'id', origem: 'id', destino: 'id_novo' },
  eventos_anexos: { chave: 'id', origem: 'evento_id', destino: 'evento_id_novo' },
};
const LOTE = 20_000;
const PAUSA_MS = 150;
const ATRASO_MAX_BYTES = 256 * 1024 * 1024;

const [tabela, retomarDe = '0'] = process.argv.slice(2);
const alvo = ALVOS[tabela];
if (!alvo) throw new Error(`tabela fora da lista: ${tabela}`);

const client = new pg.Client({
  connectionString: process.env.DATABASE_URL,
  application_name: 'backfill-bigint',
});
const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function atrasoReplica() {
  const { rows } = await client.query(`
    SELECT COALESCE(MAX(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)), 0)::text AS atraso
    FROM pg_stat_replication`);
  return Number(rows[0].atraso);
}

async function main() {
  await client.connect();
  // Lote que espera linha travada pela aplicacao desiste em vez de empilhar.
  await client.query("SET statement_timeout = '60s'");

  const { rows } = await client.query(`SELECT max(${alvo.chave})::text AS max FROM ${tabela}`);
  const max = Number(rows[0].max ?? 0);
  let inicio = Number(retomarDe);
  let lotes = 0;
  let copiadas = 0;
  const t0 = Date.now();

  while (inicio <= max) {
    const fim = inicio + LOTE - 1;
    const { rowCount } = await client.query(
      `UPDATE ${tabela} SET ${alvo.destino} = ${alvo.origem}
       WHERE ${alvo.chave} BETWEEN $1 AND $2 AND ${alvo.destino} IS NULL`,
      [inicio, fim],
    );
    copiadas += rowCount;
    lotes += 1;

    if (lotes % 100 === 0) {
      const porSegundo = Math.round(copiadas / ((Date.now() - t0) / 1000));
      console.log(JSON.stringify({ tabela, ate: fim, copiadas, porSegundo,
        pct: ((fim / max) * 100).toFixed(2) }));
    }

    // Replica atrasada vira leitura velha para quem le dela: o backfill espera.
    while ((await atrasoReplica()) > ATRASO_MAX_BYTES) {
      console.log(JSON.stringify({ tabela, ate: fim, freio: 'atraso de replica' }));
      await esperar(5000);
    }

    inicio = fim + 1;
    await esperar(PAUSA_MS);
  }

  console.log(JSON.stringify({ tabela, fim: true, copiadas }));
  await client.end();
}

main().catch(async (erro) => {
  console.error(erro);
  await client.end().catch(() => {});
  process.exit(1);
});

Três detalhes fazem o backfill não virar o incidente. Primeiro, lotes por faixa de chave primária, não por LIMIT com OFFSET, para cada UPDATE usar o índice e tocar só páginas vizinhas. Segundo, o índice da coluna nova só é criado depois do backfill: sem índice em id_novo e com espaço livre na página, o Postgres faz HOT update e não precisa gravar entrada nova em nenhum índice. Como as exclusões de retenção deixavam espaço livre em boa parte das páginas, cerca de um terço das atualizações saiu como HOT, e o autovacuum agressivo devolveu espaço para os lotes seguintes. Terceiro, o freio por atraso de réplica: cada lote gera WAL, e uma réplica de leitura 30 segundos atrasada mostra para o usuário um pacote que ainda não saiu. Com lotes de 20 mil linhas e pausa de 150 ms, o pai levou 41 horas e o freio parou o processo 37 vezes, quase sempre durante o pico da tarde.

05

Índice, NOT NULL sem varredura e a troca em milissegundos

Com o backfill terminado, falta deixar a coluna nova pronta para ser chave primária sem nenhum passo que bloqueie por muito tempo. O índice único é criado com CONCURRENTLY. O NOT NULL entra em duas etapas: um CHECK NOT VALID, instantâneo, e um VALIDATE, que varre a tabela com um bloqueio que não impede leitura nem escrita. Desde o Postgres 12, o SET NOT NULL encontra esse CHECK validado e não varre de novo. O VALIDATE serve também como auditoria: se o backfill tivesse pulado uma faixa, ele falharia aqui, antes da troca, e não depois.

-- 4. Depois do backfill: estatistica da coluna nova e indices sem travar escrita.
--    CREATE INDEX CONCURRENTLY que falha deixa um indice INVALID para tras:
--    confira pg_index.indisvalid e, se for o caso, DROP INDEX CONCURRENTLY e repita.
ANALYZE eventos_rastreio (id_novo);
CREATE UNIQUE INDEX CONCURRENTLY eventos_rastreio_id_novo_uidx ON eventos_rastreio (id_novo);
CREATE INDEX CONCURRENTLY eventos_anexos_evento_id_novo_idx ON eventos_anexos (evento_id_novo);

-- 5. NOT NULL em duas etapas. NOT VALID e instantaneo; VALIDATE varre a tabela com
--    SHARE UPDATE EXCLUSIVE, que nao bloqueia leitura nem escrita. A partir do
--    Postgres 12, SET NOT NULL usa esse CHECK validado e nao varre de novo.
--    O VALIDATE e tambem a prova de que o backfill nao pulou nenhuma linha.
ALTER TABLE eventos_rastreio
  ADD CONSTRAINT id_novo_not_null CHECK (id_novo IS NOT NULL) NOT VALID;
ALTER TABLE eventos_rastreio VALIDATE CONSTRAINT id_novo_not_null;
ALTER TABLE eventos_anexos
  ADD CONSTRAINT evento_id_novo_not_null CHECK (evento_id_novo IS NOT NULL) NOT VALID;
ALTER TABLE eventos_anexos VALIDATE CONSTRAINT evento_id_novo_not_null;

-- 6. A troca. Tudo aqui so mexe em catalogo: o ACCESS EXCLUSIVE dura milissegundos.
--    Rodar com psql -v ON_ERROR_STOP=1 e repetir se cair no lock_timeout.
BEGIN;
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '30s';
LOCK TABLE eventos_rastreio, eventos_anexos IN ACCESS EXCLUSIVE MODE;

-- Os gatilhos referenciam os nomes antigos e quebrariam depois do RENAME.
DROP TRIGGER eventos_rastreio_copia_id ON eventos_rastreio;
DROP TRIGGER eventos_anexos_copia_evento_id ON eventos_anexos;

-- A FK depende do indice da PK antiga: sai primeiro, volta no fim.
ALTER TABLE eventos_anexos DROP CONSTRAINT eventos_anexos_evento_id_fkey;

-- Pai: PK nova sobre o indice que ja existe.
ALTER TABLE eventos_rastreio DROP CONSTRAINT eventos_rastreio_pkey;
ALTER TABLE eventos_rastreio ALTER COLUMN id_novo SET NOT NULL;
ALTER TABLE eventos_rastreio DROP CONSTRAINT id_novo_not_null;
ALTER TABLE eventos_rastreio
  ADD CONSTRAINT eventos_rastreio_pkey PRIMARY KEY USING INDEX eventos_rastreio_id_novo_uidx;

-- Sequencia muda de tipo e de dono. A coluna antiga perde DEFAULT e NOT NULL,
-- senao todo INSERT novo falharia por nao preencher id_int_antigo.
ALTER TABLE eventos_rastreio ALTER COLUMN id DROP DEFAULT;
ALTER TABLE eventos_rastreio ALTER COLUMN id DROP NOT NULL;
ALTER SEQUENCE eventos_rastreio_id_seq AS bigint MAXVALUE 9223372036854775807;
ALTER TABLE eventos_rastreio RENAME COLUMN id TO id_int_antigo;
ALTER TABLE eventos_rastreio RENAME COLUMN id_novo TO id;
ALTER TABLE eventos_rastreio ALTER COLUMN id SET DEFAULT nextval('eventos_rastreio_id_seq');
ALTER SEQUENCE eventos_rastreio_id_seq OWNED BY eventos_rastreio.id;

-- Filha: mesma troca, sem PK envolvida.
ALTER TABLE eventos_anexos ALTER COLUMN evento_id_novo SET NOT NULL;
ALTER TABLE eventos_anexos DROP CONSTRAINT evento_id_novo_not_null;
ALTER TABLE eventos_anexos ALTER COLUMN evento_id DROP NOT NULL;
ALTER TABLE eventos_anexos RENAME COLUMN evento_id TO evento_id_int_antigo;
ALTER TABLE eventos_anexos RENAME COLUMN evento_id_novo TO evento_id;

-- Views ficam presas ao numero da coluna, nao ao nome: depois do RENAME elas
-- passariam a ler id_int_antigo. CREATE OR REPLACE nao aceita mudar o tipo da
-- coluna da view, entao e DROP e CREATE aqui dentro, com os GRANTs de volta.
DROP VIEW eventos_recentes;
CREATE VIEW eventos_recentes AS
  SELECT id, entrega_id, tipo, criado_em
  FROM eventos_rastreio
  WHERE criado_em > now() - interval '7 days';
GRANT SELECT ON eventos_recentes TO painel_suporte;

-- FK de volta, apontando para a PK bigint, sem validar agora (validar varre a filha).
ALTER TABLE eventos_anexos ADD CONSTRAINT eventos_anexos_evento_id_fkey
  FOREIGN KEY (evento_id) REFERENCES eventos_rastreio (id) NOT VALID;
COMMIT;

-- Fora da transacao, com SHARE UPDATE EXCLUSIVE: leitura e escrita continuam.
ALTER TABLE eventos_anexos VALIDATE CONSTRAINT eventos_anexos_evento_id_fkey;
ALTER TABLE eventos_rastreio RESET (autovacuum_vacuum_scale_factor,
  autovacuum_vacuum_threshold, autovacuum_vacuum_cost_limit);

A transação da troca executa mais de vinte comandos, e nenhum deles lê dados: PRIMARY KEY USING INDEX reaproveita o índice pronto, SET NOT NULL usa o CHECK validado, RENAME e mudança de tipo da sequência são catálogo. No ensaio com tráfego reproduzido ela levou 380 ms, e na produção, a primeira tentativa caiu no lock_timeout por causa de um relatório aberto havia 40 segundos e a segunda passou. A chave estrangeira volta NOT VALID e é validada depois, fora da transação, porque validar varre eventos_anexos inteira.

Linha do tempo da migração de eventos_rastreio (31 dias de margem no dia 0).

  D0        detecção              sequência em 87%, 9,1 milhões de ids por dia
            |
  D1-D2     aplicação             parser de int8, clientes int32, Avro e mapeamento do índice de busca
            |
  D3        ADD COLUMN + gatilhos 12 ms cada, uma retentativa por lock_timeout
            |
  D3-D5     backfill do pai       1,41 bilhão de linhas em 41 h; freio de réplica acionado 37 vezes
            |
  D5        backfill da filha     380 milhões de linhas em 9 h
            |
  D6        índices e VALIDATE    2 h 50 + 48 min concorrentes; CHECK validado em 52 min
            |
  D7        troca                 380 ms de ACCESS EXCLUSIVE; p99 da API sem alteração visível
            |
  D7        VALIDATE da FK        1 h 20 com leitura e escrita normais
            |
  D14       DROP das colunas int  colunas antigas removidas depois de uma semana sem uso

06

Depois da troca: limpar, validar e não repetir

  1. Acompanhe por uma semana o primeiro id acima de 2.147.483.647 passando por todas as camadas: API, aplicativo, eventos, busca e relatórios. No caso, ele apareceu 31 dias depois do D0, como previsto, e a única falha foi um script interno de exportação em Python que fazia struct.pack('i', id).
  2. Remova as colunas antigas com ALTER TABLE DROP COLUMN, que é só catálogo e termina em milissegundos. O espaço de 4 bytes por linha não volta de imediato; ele é reaproveitado à medida que as linhas são reescritas, o que é aceitável.
  3. Transforme a consulta de detecção em alerta: aviso em 50% e página em 75% para qualquer coluna int2 ou int4 alimentada por sequência. Na mesma varredura apareceu notificacoes em 37,8%, que foi migrada com calma no mês seguinte.
  4. Mude o padrão: tabela nova usa bigint GENERATED ALWAYS AS IDENTITY, e o lint de migrações rejeita serial e integer em chave primária. Os 4 bytes extras por linha custam muito menos do que a próxima migração com prazo.

FAQ

Perguntas frequentes

E se a coluna for identity, e não serial?

A sequência de uma coluna identity pertence à coluna e não pode ser simplesmente transferida para outra com OWNED BY. Na transação da troca, leia o último valor, rode ALTER TABLE ... ALTER COLUMN id DROP IDENTITY na coluna antiga e, já com a coluna nova renomeada e NOT NULL, rode ALTER TABLE ... ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (START WITH ultimo_valor + 1000). Os dois comandos são de catálogo. A margem de mil cobre valores pré-alocados por CACHE que ainda estejam em conexões abertas.

Já que vou mexer na chave, não vale trocar para UUID?

São problemas diferentes. O int estourando é falta de capacidade, e bigint resolve isso para sempre com 8 bytes. Trocar para UUID muda o tipo em toda chave estrangeira, em todos os clientes e contratos, dobra o tamanho de cada índice que carrega a chave e, com UUID v4, espalha as inserções pelo índice inteiro. Se o motivo real é não expor ids sequenciais, adicione uma coluna de identificador público, de preferência UUID v7, sem mexer na chave interna.

E se faltarem só horas, e não semanas?

Primeiro, o torniquete: reinicie a sequência em -2.147.483.648, o que dá mais 2,1 bilhões de valores sem tocar na tabela. Antes, confirme em minutos que o código não depende de id positivo nem de ordenação por id, e aceite que algumas telas vão ordenar errado por um tempo. Com a margem recuperada, faça a migração completa descrita aqui. Sem nenhuma das duas coisas, quando a sequência atingir o máximo, todo INSERT na tabela falha, e a aplicação para de registrar o que mais importa para ela.

A troca de tipo é catálogo; o trabalho está em volta dela

Uma chave primária integer alimentada por sequência estoura pelo último valor entregue, não pelo número de linhas, e o ALTER COLUMN TYPE que parece resolver segura a tabela bloqueada por horas em qualquer volume relevante. A migração sem parada transforma a mudança em etapas que nunca bloqueiam: aplicação pronta para int8 primeiro, coluna nova e gatilhos, backfill em lotes com freio de réplica, índice concorrente, NOT NULL validado sem varredura e uma troca de nomes de 380 ms, com a chave estrangeira validada depois. O que exige atenção está fora do banco, em drivers, clientes de 32 bits, schemas de eventos e índices de busca, e em detalhes como views presas ao número da coluna e a coluna antiga que perde o NOT NULL. Com a consulta de detecção virando alerta e bigint como padrão, a próxima tabela não chega a 87%.