Pular para o conteúdo
ForceTricks
Voltar ao blog

Governor Limits do Apex: Estratégias Reais para Orgs Corporativas

11 min de leitura
SérieApex em Orgs CorporativasParte 2 de 2
  1. 1Arquitetura de Triggers Apex: Handler Pattern e Quando Quebrá-lo
  2. 2Governor Limits do Apex: Estratégias Reais para Orgs Corporativas

O módulo do Trailhead sobre governor limits é correto. Ele te diz os números: 100 queries SOQL por transação, 50.000 registros retornados, 150 statements DML, 10 MB de heap. O que ele não diz é como esses limites interagem em um sistema onde um único evento de negócio toca 12 objetos, dispara uma cadeia de jobs assíncronos e processa 80.000 registros antes que o batch noturno termine.

Este post é sobre essa realidade.

O Limite de SOQL: Onde a Maioria das Orgs Grandes Realmente Quebra

O limite de 100 queries SOQL é o mais frequentemente atingido em orgs corporativas, e o modo de falha é quase sempre o mesmo: queries dentro de loops. Todo mundo sabe que não pode fazer isso. Mesmo assim acontece o tempo todo, porque em bases de código grandes o loop e a query estão a 15 camadas de abstração de distância.

O padrão que funciona em escala: uma camada de selector com contratos explícitos de bulkification.

public with sharing class AccountSelector {
    // Contrato: sempre recebe Set<Id>, sempre retorna Map.
    // Callers nunca emitem o próprio SOQL para Account — usam isto.
    public static Map<Id, Account> getByIds(Set<Id> ids) {
        return new Map<Id, Account>([
            SELECT Id, OwnerId, AccountNumber, BillingCountry, ExternalId__c
            FROM Account
            WHERE Id IN :ids
            WITH USER_MODE
        ]);
    }

    // Método separado para cada padrão de acesso — nunca um "getAll" genérico
    public static List<Account> getActiveByTerritory(String territory) {
        return [
            SELECT Id, OwnerId, AccountNumber
            FROM Account
            WHERE Territory__c = :territory
            AND Status__c = 'Active'
            WITH USER_MODE
        ];
    }
}

A segunda ferramenta para pressão de SOQL: pegar o problema no CI antes que ele chegue na produção. Limits.getQueries() chamado em pontos estratégicos do código te diz exatamente para onde o orçamento está indo. Em classes de teste, faça assert disso:

@isTest
static void bulkTest_processTwoHundredAccounts() {
    List<Account> accounts = TestFactory.createAccounts(200);
    insert accounts;

    Test.startTest();
    AccountService.processOwnershipRules(accounts);
    Test.stopTest();

    // Esta é a asserção que pega regressões antes da produção
    System.assert(
        Limits.getQueries() < 10,
        'Esperado < 10 queries SOQL, obteve: ' + Limits.getQueries()
    );
}

Um teste que processa 200 registros e faz assert na contagem de queries vai falhar no momento em que alguém introduzir uma query em loop em algum ponto downstream. Esse é o sinal que você quer no CI, não às 3 da manhã em produção.

Queueable vs Future vs Batch: O Framework de Decisão de Verdade

O conselho comum — "use Batch para operações em massa" — é incompleto. O framework melhor é: volume × dependência × requisitos de retry.

CenárioUse
Volume baixo (<200 registros), fire-and-forget, sem encadeamento@future
Volume baixo-médio, precisa encadear ou manter estadoQueueable
Volume alto (>10K registros), sem estado entre chunksBatch
Volume alto, estado complexo entre chunksCadeia de Queueable

A última linha é a que surpreende as pessoas. Quando você tem 500.000 registros para processar mas cada chunk precisa saber o resultado do anterior — agregando contagens de erros, gerenciando um cursor, construindo um log de sync com estado — o design stateless do Batch trabalha contra você. Você acaba serializando estado em um Custom Object a cada execute() e lendo de volta no próximo. Nesse ponto, uma cadeia de Queueables com gerenciamento explícito de estado é mais limpa.

public with sharing class SyncChainQueueable implements Queueable {
    private Id lastRecordId;
    private Integer processedCount;
    private Integer errorCount;
    private static final Integer CHUNK_SIZE = 2000;

    public SyncChainQueueable(Id lastRecordId, Integer processed, Integer errors) {
        this.lastRecordId = lastRecordId;
        this.processedCount = processed;
        this.errorCount = errors;
    }

    public void execute(QueueableContext ctx) {
        // Paginação por cursor (keyset): avança pelo Id em vez de OFFSET
        List<Account> chunk = (lastRecordId == null)
            ? [
                SELECT Id, ExternalId__c, SyncStatus__c
                FROM Account
                WHERE SyncStatus__c = 'Pending'
                ORDER BY Id
                LIMIT :CHUNK_SIZE
              ]
            : [
                SELECT Id, ExternalId__c, SyncStatus__c
                FROM Account
                WHERE SyncStatus__c = 'Pending' AND Id > :lastRecordId
                ORDER BY Id
                LIMIT :CHUNK_SIZE
              ];

        if (chunk.isEmpty()) {
            SyncRunLog__c.finalize(processedCount, errorCount);
            return;
        }

        Integer chunkErrors = AccountSyncService.process(chunk);

        // Encadeia o próximo queueable — cursor avança pelo último Id do chunk
        if (!Test.isRunningTest()) {
            System.enqueueJob(new SyncChainQueueable(
                chunk[chunk.size() - 1].Id,
                processedCount + chunk.size(),
                errorCount + chunkErrors
            ));
        }
    }
}

Por que cursor e não OFFSET? O reflexo natural é paginar com LIMIT :CHUNK_SIZE OFFSET :offset, incrementando o offset a cada chunk. Isso quebra em produção: o SOQL limita OFFSET a 2.000 linhas — assim que offset passa de 2000 a query lança NUMBER_OUTSIDE_VALID_RANGE, e num dataset de 500 mil registros você bate nesse teto no segundo chunk. Além disso, OFFSET faz o banco varrer e descartar todas as linhas anteriores a cada página, ficando mais lento a cada avanço. A paginação por cursor (keyset) resolve os dois: você ordena por Id, guarda o último Id processado e filtra Id > :lastRecordId no próximo chunk — sem teto de linhas e com custo constante por página.

O outro limite a observar: o Salesforce permite até 50 Queueables encadeados em um único contexto de transação. Para datasets muito grandes, você ainda pode precisar de Batch para evitar bater no limite de profundidade da cadeia.

Heap Limit: Onde Arquitetos Consistentemente Subestimam

O limite de heap de 10 MB parece generoso até você entender o que o consome silenciosamente.

A serialização de estado em Queueable é o culpado mais comum. Quando você passa dados entre instâncias de Queueable via variáveis de instância, o Salesforce serializa o grafo inteiro de objetos para armazená-lo entre execuções. Uma List<SObject> com 5.000 registros × 30 campos × média de 50 bytes por valor = ~7,5 MB de heap só com o payload de estado. Some o working set dentro do execute() e você passou do limite.

A correção: nunca passe registros entre Queueables. Passe IDs, re-consulte dentro do execute().

// Errado: serializa o conjunto inteiro de registros no estado do Queueable
public class BadQueueable implements Queueable {
    private List<Account> accountsToProcess; // isso é serializado

    public BadQueueable(List<Account> accounts) {
        this.accountsToProcess = accounts; // ~7 MB já consumidos aqui
    }
}

// Correto: passa IDs, consulta fresco dentro do execute
public class GoodQueueable implements Queueable {
    private Set<Id> accountIds; // um Set<Id> para 5000 registros ≈ 200 KB

    public GoodQueueable(Set<Id> ids) {
        this.accountIds = ids;
    }

    public void execute(QueueableContext ctx) {
        List<Account> accounts = [
            SELECT Id, ExternalId__c, SyncStatus__c, OwnerId
            FROM Account
            WHERE Id IN :accountIds
        ];
        // processa accounts
    }
}

Roundtrips de JSON são a segunda armadilha. JSON.serialize() + JSON.deserialize() em uma lista grande de SObjects aloca memória para o objeto original, a string serializada e a cópia desserializada simultaneamente. Em uma lista de 10.000 registros, isso pode consumir brevemente 3× o heap em estado estacionário. Profile com System.debug(Limits.getHeapSize()) antes e depois das chamadas de serialização.

CPU Time: O Limite Que Só Aparece Sob Carga de Produção

O limite de CPU time (10.000 ms síncrono) é o que parece estar tudo bem em sandbox e explode em produção. Por quê? Porque sandboxes não têm a mesma densidade de triggers. Em produção:

  • Seu trigger dispara
  • O trigger do pacote gerenciado dispara
  • Um Flow roda no mesmo registro
  • Um Process Builder (legado) também dispara
  • Um Platform Event é publicado dentro da transação
  • Outro Flow roda como subscriber do PE

Cada um desses consome CPU time dentro do mesmo orçamento de 10.000 ms. Nenhum deles enxerga o consumo do outro. A transação falha não porque algum componente seja lento, mas porque o agregado de cinco componentes "rápidos" excede o orçamento.

A abordagem diagnóstica: System.debug(Limits.getCpuTime()) no início e fim de cada seção lógica. Em uma org complexa, você quer isso nos handlers de trigger durante runs de profiling. A saída diz qual componente está consumindo o orçamento de forma desproporcional.

A resposta arquitetural: mova qualquer coisa não-crítica para async. Se a pressão de CPU vem de um trigger fazendo trabalho que não precisa acontecer sincronamente, jogue para um Queueable. O Queueable ganha seu próprio orçamento async de 60.000 ms de CPU.

Platform Events Dentro de Transações: O Limite Que Ninguém Planeja

Dois limites governam Platform Events publicados a partir de Apex:

  1. Limite de publish por transação: 150 chamadas de EventBus.publish() por transação (mesmo limite de DML statements).
  2. Alocação diária do event bus: varia por edição, tipicamente 250.000 eventos/dia para Enterprise edition.

O limite por transação é o que pega arquitetos desprevenidos. Se seu trigger publica um evento por registro, e o trigger dispara em um batch de 200 registros, você bateu 200 chamadas de publish — excedendo o limite de 150 DML statements.

A correção: agregue eventos antes de publicar.

public with sharing class AccountTriggerHandler extends TriggerHandler {
    public override void afterUpdate() {
        List<Account_Synced__e> events = new List<Account_Synced__e>();

        for (Account acc : (List<Account>) Trigger.new) {
            Account old = (Account) Trigger.oldMap.get(acc.Id);
            if (acc.ExternalSyncStatus__c != old.ExternalSyncStatus__c) {
                events.add(new Account_Synced__e(
                    AccountId__c = acc.Id,
                    NewStatus__c = acc.ExternalSyncStatus__c,
                    Timestamp__c = System.now()
                ));
            }
        }

        if (!events.isEmpty()) {
            List<Database.SaveResult> results = EventBus.publish(events);
            // EventBus.publish() conta como UMA operação DML independente do tamanho da lista
            // mas o limite de 150 publish calls ainda se aplica ao número de chamadas publish()
        }
    }
}

Chamar EventBus.publish(list) com uma lista de 200 eventos conta como uma chamada de publish, não 200. A abordagem de lista é como você fica dentro dos limites quando publica em escala.

Desenhando um Sync Noturno de 10M Registros: Trade-offs Reais

O padrão teórico para um sync noturno de 10M registros:

  1. Batch Apex, chunks de 2.000 registros → 5.000 chamadas de execute()
  2. Cada chunk chama uma API externa via callout
  3. Resultados gravados de volta no Salesforce via DML

Os problemas que aparecem em escala:

  • Callouts a partir do Batch: cada execute() pode fazer callouts, mas o Batch em si é limitado pelos limites padrão de callout por contexto de execute(). Se a API externa tem rate limits, o Batch não sabe disso — vai martelar a API e falhar.
  • Limites diários de API: 10M registros / 2.000 por batch = 5.000 chamadas de Batch execute(). Se cada uma chama uma API externa, são 5.000 callouts. A maioria dos sistemas externos não suporta isso. O desenho correto: insira em massa os IDs em um objeto de staging, depois processe o staging com chamadas em bulk que enviam milhares de IDs por requisição.
  • O escopo de "dentro dos limites": heap (2.000 registros × contagem de campos), SOQL (camada de selector, não inline), DML (um update por chunk, não por registro), CPU (sem lógica complexa por registro — vetorize operações sobre a lista).

A resposta honesta: um sync de 10M registros que fica dentro de todos os limites simultaneamente exige desenhar a API do sistema externo junto com a arquitetura Salesforce. Uma chamada REST por registro para um sistema externo é estruturalmente incompatível com processamento em bulk no Apex. Se a API externa só suporta requisições por registro, o sync precisa de uma camada middleware (MuleSoft, uma Lambda, ou Heroku) que agrega a saída em bulk do Salesforce em chamadas individualmente limitadas.

Conclusão Prática

  • Se você não tem asserções de limite de SOQL nas classes de teste → adicione nas 10 services mais críticas neste sprint; vão pegar regressões imediatamente
  • Se está escolhendo Batch para um sync com estado → avalie cadeias de Queueable primeiro; a complexidade de serialização do Batch com estado costuma ser pior
  • Se está passando listas de SObject entre instâncias de Queueable → troque por passar IDs; exaustão de heap em estado de Queueable é silenciosa até a produção
  • Se está publicando Platform Events por registro → use uma lista e chame EventBus.publish(list) uma vez por contexto de trigger
  • Se um sync de 10M registros está no seu roadmap → o desenho da API externa é parte da arquitetura Salesforce; desenhe os dois juntos

Segundo post da série "Apex em Orgs Corporativas". Próximo: SOQL em escala — matemática de seletividade, padrões para LDV e o que o query optimizer está realmente fazendo.

Dúvidas sobre estratégias de limites na sua org? Me encontre no LinkedIn.

Compartilhar
Gabriel Cruz Ferreira

Gabriel Cruz Ferreira

Salesforce Architect · 15x Certified · Rota para CTA

Este post foi útil?