1 pontos por GN⁺ 2024-02-18 | 1 comentários | Compartilhar no WhatsApp

Minhas notas sobre o design do schema Postgres do GitLab

  • Ao analisar o schema Postgres do GitLab, quis comparar com o schema que eu mesmo projeto e aprender boas práticas na definição de schemas do GitLab.
  • O GitLab é uma plataforma DevOps de código aberto, uma alternativa ao GitHub e pode ser autohospedado.

Usar os tipos corretos de chave primária

  • Em um banco de dados pequeno isso pode não parecer importante, mas conforme ele cresce, a chave primária impacta o espaço de armazenamento, a velocidade de escrita e a velocidade de leitura.
  • O GitLab usa bigserial como tipo de chave primária em 380 das 573 tabelas, serial4 em 170 e nas 23 restantes usa chaves primárias compostas.

Uso de IDs internos e externos

  • É uma boa prática não expor a chave primária ao mundo externo.
  • O GitLab usa tanto IDs internos (id) quanto IDs externos (iid) em tabelas como issues, ci_pipelines, deployments e epics.

Uso de text e restrições CHECK para campos de texto

  • O schema do GitLab usa tanto character varying(n) quanto text, mas usa mais frequentemente o tipo text.
  • O tipo text não tem restrição de comprimento, então ele define restrições de tamanho com CHECK.

Convenções de nomenclatura

  • Todas as tabelas usam nomes no plural e usam prefixos de módulo para fornecer namespace.
  • Os nomes de tabela e coluna seguem o padrão snake_case.

Uso de fuso horário em timestamps

  • O GitLab usa tanto timestamp with timezone quanto timestamp without timezone.
  • Para operações do sistema usa timestamp without timezone, enquanto para ações de usuário usa timestamp with timezone.

Restrições de chave estrangeira

  • O GitLab usa restrições de chave estrangeira na maioria das tabelas, mas não as utiliza em algumas tabelas, como audit_events, abuse_reports, web_hooks_logs e spam_logs.

Particionamento de tabelas grandes

  • O GitLab particiona tabelas grandes que podem crescer para melhorar o desempenho de consultas.

Como dar suporte a casos de uso de busca com LIKE usando trigramas e gin_trgm_ops

  • O GitLab usa índices GIN (Generalized Inverted Index) para realizar buscas de forma eficiente.

Uso de jsonb

  • O schema do GitLab usa o tipo de dado jsonb em várias tabelas.

Dicas extras

  • Em tabelas editáveis, ele usa campos de auditoria como updated_at, enquanto em tabelas de log imutáveis esse campo não é usado.
  • Enums são armazenados como smallint em vez de character varying para economizar espaço.

Opinião do GN⁺:

  • O design do schema do GitLab oferece insights sobre design de banco de dados e traz lições importantes sobre otimização de schema para sistemas em larga escala.
  • Como o GitLab é open source, essas decisões de design de schema oferecem exemplos práticos que outros desenvolvedores podem aplicar em seus próprios projetos.
  • O que podemos aprender com o schema do GitLab é que escolhas de tipo de dado, estratégia de indexação, particionamento e uso de restrições de chave estrangeira precisam ser feitas com cuidado porque impactam significativamente desempenho e manutenção de banco de dados.

1 comentários

 
GN⁺ 2024-02-18
Opiniões do Hacker News
  • Fico me perguntando por que é necessária a prática de não expor chaves primárias externamente. Se as requisições precisam ser autenticadas de qualquer forma, também não vejo que valor há em impedir a adivinhação de IDs.
    Se é possível fazer algo útil só com um ID adivinhado, sem autenticação nem autorização, então algo em outro lugar já está seriamente quebrado, e o foco deveria estar nisso, em vez de adicionar complexidade desnecessária ao schema. Pode haver algum valor em ocultar inteligência competitiva, como impedir que concorrentes estimem o número de clientes, mas não me parece que o GitLab tenha se importado muito com isso. A decisão do GitLab por id + iid provavelmente foi mais motivada por requisitos de desempenho de consultas do que por impedir a adivinhação de IDs internos

    • Certo, mas poder adivinhar IDs pode tornar uma vulnerabilidade de segurança terrível, ou muito pior
      Se você expõe UUIDs aos usuários, mesmo com a mesma vulnerabilidade, o invasor teria que acertar o UUID, o que é muito mais difícil e pode exigir uma fonte secundária. Mesmo que haja vazamento de dados, você ganha tempo para reagir e consegue estimar o volume vazado. Por outro lado, com IDs sequenciais, o problema escala imediatamente para uma exposição total e pode se tornar um grande incidente de notificação obrigatória a autoridades de proteção de dados. Isso é defesa em profundidade, algo que idealmente não deveria ser necessário, mas existem softwares terríveis que foram invadidos exatamente dessa forma
    • Como o texto também diz, isso está mais para inteligência competitiva do que para segurança. IDs simples com autoincremento revelam o número total de registros de uma tabela ou sua taxa de crescimento
      Se você expõe a chave primária id da tabela de issues, ela não começa em 1 quando um issue é criado em um projeto, então fica fácil estimar quantos issues existem no GitLab como um todo
    • A expressão teatro de segurança é usada em excesso. Segurança pode e deve ser composta por várias camadas, e, se uma camada como autenticação falhar, as demais não deveriam se tornar facilmente acessíveis
      Claro, se algo é possível só com um ID adivinhado, sem autenticação nem autorização, isso é mesmo um grande problema. Mas, se nesse ponto não houver outras camadas, o jogo já acabou. Bugs não avisam com antecedência, especialmente os sutis. Quando um bug desses aparecer, se pelo menos os IDs tiverem sido tornados imprevisíveis, você vai agradecer por ter evitado uma situação em que todas as contas de usuário do sistema ficassem facilmente acessíveis
    • Bugs também acontecem em controle de acesso. IDs imprevisíveis tornam muito mais difícil explorar parte desses bugs
      Naturalmente, o foco principal deve ser garantir primeiro um controle de acesso correto, mas IDs imprevisíveis podem fazer a diferença entre uma catástrofe terrível e um quase acidente. Se UUID não for adequado, também é possível usar IDs de banco de dados com autoincremento e criptografá-los; com uma camada de software apropriada, IDs criptografados funcionam quase automaticamente
    • É uma diferença terminológica pequena, mas isso parece ser mais corretamente chamado de inteligência competitiva do que “business intelligence”, que normalmente se refere ao uso de dados dentro da própria empresa. https://en.wikipedia.org/wiki/Competitive_intelligence
  • Entre os 128 milhões de repositórios públicos, a maioria é fork de outros repositórios e existe apenas para criar pull requests para o repositório principal, então provavelmente não terá issues, salvo engano
    Projetos pequenos de brinquedo ou rapidamente abandonados também provavelmente não têm issues, ou têm pouquíssimos. Certamente há projetos com centenas ou milhares de issues, mas a média entre todos os 128 milhões de repositórios deve ser bem baixa e provavelmente ficar abaixo do limite de 2 bilhões. Ainda assim, concordo que usar um tipo de 4 bytes nessa tabela — mais exatamente, 31 bits — é uma bomba-relógio para algumas organizações, incluindo github.com

    • Mesmo agora, ainda está abaixo do limite, com 362.107.148 repositórios e 818.516.506 issues e pull requests únicos
      https://play.clickhouse.com/play?user=play#U0VMRUNUIHVuaXEoc...
    • Acho que a decisão do GitHub de se afastar do Rails também foi influenciada, em parte, por uma grande limitação do ActiveRecord: a falta de suporte a chaves primárias compostas
      Um requisito básico como PRIMARY KEY(repo_id, issue_id) se torna desnecessariamente complexo dentro do ActiveRecord, e passa a exigir uma solução de contorno com uma chave única e uma chave primária separada para se adequar ao ActiveRecord, que exige uma única coluna de chave primária. Chaves primárias UUID também parecem uma solução de contorno, mas a restrição de unicidade do par (repo_id, issue_id) continua sendo necessária, aumentando o tamanho do banco de dados e o overhead. De forma mais ampla, a estrutura MVC monolítica do Ruby on Rails, baseada em uma única camada de modelos, controladores e views, também cria problemas de escalabilidade e manutenção à medida que a aplicação cresce; vejo MVC como algo que se encaixa melhor em uma estrutura modular ou baseada em componentes
    • Fico curioso se alguém sabe ao certo se o GitLab Cloud usa um banco de dados multi-tenant ou bancos de dados separados por usuário, cliente ou organização
      Produtos que oferecem tanto auto-hospedagem quanto nuvem muitas vezes preferiam bancos de dados por cliente. Isso porque a parte compartilhada da base de código fica muito mais simples ao usar as mesmas consultas, independentemente do tipo de hospedagem. Se for um banco de dados por cliente, quase nunca se chegaria perto desses limites de uso; se chegar, a auto-hospedagem provavelmente seria mais adequada
    • Essa bomba-relógio é uma bomba desarmada por uma migração de 11 segundos
    • É possível migrar uma chave primária de int para bigint. Exige alguma preparação e código customizado, mas pode ser feito sem downtime
      Em geral, estou gerenciando grandes migrações seguindo este procedimento, com pequenas adaptações: http://zemanta.github.io/2021/08/25/column-migration-from-in...
      Chaves estrangeiras, índices e restrições em geral tornam o processo difícil, mas não impossível. No meu caso, a migração dos dados levou algumas horas, mas não precisava ser rápida. Pelo que sei, o GitLab tem ferramentas para executar tarefas pós-upgrade, de modo que funcionem em qualquer ponto durante a atualização de versão
  • A discussão sobre o tamanho de armazenamento de colunas UUID é pouco convincente. Se a tabela tiver outras 5 colunas, a diferença entre 128 bits e 64 bits não é grande
    A preocupação mais importante é desempenho. UUIDv4 é amplamente suportado, mas, por ser totalmente aleatório, não é ideal para desempenho de índices. UUIDv7[0] se aproxima do Snowflake[1] e tem localidade temporal, mas as implementações são menos difundidas. Uma abordagem separada é usar bigserial e criptografar a chave: https://github.com/abevoelker/gfc64
    Mas esse método 1) não permite rotacionar o segredo e 2) uma vez vazado, qualquer pessoa consegue fazer uma estimativa de Fermi do tamanho da tabela. Separar IDs públicos de IDs internos é trabalhoso e, se o ID público for UUIDv4, também sacrifica desempenho. Vejo o UUIDv7 como a solução que atende ao maior número de requisitos
    [0]: https://uuid7.com/
    [1]: https://en.wikipedia.org/wiki/Snowflake_ID

    • O problema não é apenas o tamanho dessa única coluna, mas também todos os lugares em que esse id é usado como chave estrangeira e o tamanho dos índices necessários para essas colunas de chave estrangeira
      Basta pensar em um valor como um ID de usuário, que pode ser referenciado por dezenas ou centenas de chaves estrangeiras em todo o banco de dados
    • O problema é que as outras 5 colunas não estão indexadas
      Há três estágios no desempenho de banco de dados. 1) Tanto os índices quanto os dados cabem na memória. 2) Os índices cabem na memória, mas os dados não. 3) Nem os índices nem os dados cabem na memória. Se você está no 1, ótimo; caso contrário, precisa preservar o 2 a todo custo. Dobrar o tamanho dos índices torna isso mais difícil
    • Dá para pensar na chave primária de um banco de dados como um ponteiro básico, algo como typedef void*. O tamanho afeta o desempenho geral: uso de memória e disco, gargalos de throughput e até o tempo de CPU gasto comparando chaves nos loops internos mais profundos de joins e consultas
      Quando CPUs x86-64 surgiram, o impacto de desempenho da migração para ponteiros de 64 bits foi tão grande que x32/ilp32 foi criado; esse também é o motivo pelo qual o .NET ainda mantém “prefer 32-bit” como padrão. Usar UUIDs de 128 bits como chaves primárias de banco de dados é um erro terrível
    • UUIDv7 também não é uma solução universal. Em muitos casos, você não quer vazar o momento de criação de um recurso
      Por exemplo, você pode querer fazer upload de um vídeo um mês antes de publicá-lo, sem que o público saiba disso
    • Há outra variação dessa abordagem: https://pgxn.org/dist/permuteseq/
      Também é possível criptografar o valor ao exibi-lo em URLs, emails etc.: https://wiki.postgresql.org/wiki/Pseudo_encrypt
      Isso preserva boa parte das vantagens de índices sequenciais e ainda permite trocar a chave. Porém, se você trocar a chave, bookmarks quebram, links enviados em emails antigos deixam de funcionar e, na prática, fica um efeito semelhante a renomear tudo
  • É um detalhe pequeno, mas quero comentar a seção sobre text versus varchar
    O autor gasta muitas palavras tentando provar uma diferença de desempenho inexistente e depois conclui que “não há grande diferença de desempenho entre os dois tipos”. Esse assunto foi resolvido há muito tempo, e não é que a diferença “não seja grande”: ela “não existe”. A wiki do PostgreSQL[1] diz explicitamente para usar text a menos que haja um motivo muito bom, e a documentação[2] também afirma que “para muitos propósitos, character varying age como um domínio sobre text”; em uma caixa verde de dica, diz que “não há diferença de desempenho entre esses três tipos”. Portanto, o fato de o GitLab usar text na maior parte dos casos parece indicar que ele leu a documentação e projetou o schema para o PostgreSQL, em vez de criar um schema “portável” meia-boca
    [1] https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use...
    [2] https://www.postgresql.org/docs/current/datatype-character.h...

    • Na prática, quando é preciso migrar o schema para acompanhar uma mudança no comprimento das strings armazenadas, surge uma diferença de desempenho considerável
      Para mudar varchar(300) para varchar(200), é preciso reescrever todas as linhas; já atualizar uma restrição em uma coluna text é, basicamente, quase de graça, exigindo apenas uma varredura completa da tabela para verificar se os valores existentes satisfazem a nova restrição. O texto também diz que usar o tipo text com uma restrição CHECK facilita a evolução do schema, quando há validação de comprimento, em comparação com character varying ou varchar(n)
  • A afirmação de que chaves estrangeiras são caras é repetida com frequência, mas benchmarks são raros
    Há muitas formas de implementá-las mal, mas, de um jeito ou de outro, a integridade está sendo imposta em algum ponto da stack. Aproveitar o banco de dados, em vez de reimplementar isso, exige conhecimento e experimentação e, em geral, evita grandes incidentes

  • Fico curioso se alguém já organizou ou observou a diferença de desempenho entre GitLab e GitHub.
    Ambos são aplicativos baseados em Rails, mas, de modo geral, sinto que o tempo de carregamento das páginas do GitLab é péssimo em comparação com o GitHub.

    • Quando usei o GitLab alguns anos atrás, havia problemas graves de desempenho no lado do cliente em pull requests grandes. O GitHub também não é ideal, mas ainda lida com isso em um nível aceitável.
    • Comparar com o GitHub é parecido com comparar o Chrome com outros navegadores, até mesmo navegadores baseados em Chromium.
      Chrome e GitHub vão usar todo tipo de truque, mesmo que isso prejudique os usuários. Por exemplo, certa vez abri um diff de merge no GitHub da empresa e procurei com Ctrl F; como não havia resultado, fui avançando e vasculhando manualmente o histórico do Git. Só no centésimo diff descobri que o arquivo mais importante estava escondido lá no fundo. Provavelmente porque era mais conveniente para alguém bater métricas de carregamento de página e ser promovido.
    • O GitHub, exceto por duas indisponibilidades no ano passado, é em geral estável e normalmente bem rápido. Se não fosse, eu não usaria atalhos de teclado.
      Aqui há um texto de um ex-desenvolvedor que pode ajudar a entender a cultura do GitLab e seu descaso com desempenho: https://news.ycombinator.com/item?id=39303323
      Eu não uso o GitLab o suficiente para sentir problemas de desempenho, mas acho que esse texto pode ajudar.
  • Sempre tive curiosidade sobre o que significa o I extra em variáveis de CI como CI_PIPELINE_IID e CI_MERGE_REQUEST_IID.
    Eu imaginava que fosse uma escolha relacionada ao banco de dados, e este texto confirma isso.

  • Ao ver que “1 quintilhão equivale a 1.000.000.000 bilhões”, parece bem estranho que normalmente escolhamos apenas entre int32 e int64. Acho que deveria existir um tipo inteiro de 5 bytes com suporte a uma cardinalidade de cerca de 1 trilhão.

    • Se não for para empacotar os valores ao máximo, não faz sentido escolher um tamanho que não seja uma potência de 2.
  • Não usar IDs autoincrementais pode ser razoável, mas não vejo bem a vantagem de ter 2 IDs, um interno e outro externo.
    Isso aumenta o número de colunas e índices, sempre exige uma consulta primeiro, e não consigo imaginar um cenário de segurança em que você trocaria a chave interna sem trocar a externa. Estou deixando passar algo?

    • Quando se faz algo no nível do projeto, você já tem de qualquer forma as informações necessárias. Além disso, para o usuário, é mais amigável que as issues de cada projeto comecem em 1 do que em algum número como 2.700.300.571.725.
  • Dizem que usar o tipo UUID v4 nativo do PostgreSQL em vez de bigserial aumenta o tamanho da tabela em 25% e reduz a taxa de inserção para 25% da de bigserial; fico curioso por que UUIDv4 é tão ruim assim.
    UUID não é apenas um número de 128 bits? O custo de geração é absurdamente alto, ou o que acontece?

    • UUIDv4 é totalmente aleatório, e índices B-tree esperam valores “enviesados para a direita”, com uma ordem razoável.
      Por isso, a indexação de colunas UUIDv4 fica mais lenta, e isso motivou o desenvolvimento de UUIDv6 e UUIDv7.
    • O aumento de 25% no tamanho é correto, mas é um acréscimo linear pequeno e previsível de 8 bytes por linha. Comparado ao restante dos dados da linha, não é algo tão preocupante.
      O problema maior é a taxa de inserção. No caso de UUIDs, a taxa de inserção é limitada pela quantidade de RAM disponível. Com inteiros autoincrementais, não. Inteiros têm correlação com o tempo, mas UUID4 é aleatório; conforme a escala cresce, as características de desempenho mudam fundamentalmente. Em tabelas pequenas, a penalidade de inserção é quase desprezível, mas, quando o tamanho do índice B-tree chega ao limite da memória, o PostgreSQL não consegue manter toda a B-tree de UUID na memória e passa a depender da troca de páginas em disco. Inteiros autoincrementais usam a mesma página de índice para linhas próximas no tempo, então não precisam bater no disco sob a mesma carga. Ao chegar nessa escala, a diferença não é uma queda constante de 25%, mas um abismo de desempenho de 25 vezes; fora uma migração de schema, a única saída é comprar mais RAM.
    • Acho que é por causa da B-tree. B-trees e páginas funcionam melhor quando só a última página é muito usada.
      UUIDs geram muitas escritas não ordenadas, levando a inchaço de páginas.
    • Quando a ordem de classificação é distribuída aleatoriamente, a localidade de cache da B-tree piora. As inserções não vão para a última página; ficam espalhadas por todos os lados.
      A localidade das inserções em lote também fica ruim depois, na hora de consultar, obrigando a procurar registros relacionados de forma aleatória. No fim, paga-se o custo tanto no momento da inserção quanto depois, no momento das seleções.