Minhas anotações sobre o design do schema Postgres do GitLab (2022)
(shekhargulati.com)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
bigserialcomo tipo de chave primária em 380 das 573 tabelas,serial4em 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 comoissues,ci_pipelines,deploymentseepics.
Uso de text e restrições CHECK para campos de texto
- O schema do GitLab usa tanto
character varying(n)quantotext, mas usa mais frequentemente o tipotext. - O tipo
textnão tem restrição de comprimento, então ele define restrições de tamanho comCHECK.
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 timezonequantotimestamp without timezone. - Para operações do sistema usa
timestamp without timezone, enquanto para ações de usuário usatimestamp 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_logsespam_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
jsonbem 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
smallintem vez decharacter varyingpara 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
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 + iidprovavelmente foi mais motivada por requisitos de desempenho de consultas do que por impedir a adivinhação de IDs internosSe 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
Se você expõe a chave primária
idda 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 todoClaro, 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
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
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
https://play.clickhouse.com/play?user=play#U0VMRUNUIHVuaXEoc...
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 componentesProdutos 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
intparabigint. Exige alguma preparação e código customizado, mas pode ser feito sem downtimeEm 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
bigseriale criptografar a chave: https://github.com/abevoelker/gfc64Mas 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
idé usado como chave estrangeira e o tamanho dos índices necessários para essas colunas de chave estrangeiraBasta 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
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
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 consultasQuando 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
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
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
textversusvarcharO 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
texta menos que haja um motivo muito bom, e a documentação[2] também afirma que “para muitos propósitos,character varyingage como um domínio sobretext”; 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 usartextna 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...
Para mudar
varchar(300)paravarchar(200), é preciso reescrever todas as linhas; já atualizar uma restrição em uma colunatexté, 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 tipotextcom uma restriçãoCHECKfacilita a evolução do schema, quando há validação de comprimento, em comparação comcharacter varyingouvarchar(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.
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.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_IIDeCI_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
int32eint64. Acho que deveria existir um tipo inteiro de 5 bytes com suporte a uma cardinalidade de cerca de 1 trilhão.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?
Dizem que usar o tipo UUID v4 nativo do PostgreSQL em vez de
bigserialaumenta o tamanho da tabela em 25% e reduz a taxa de inserção para 25% da debigserial; 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?
Por isso, a indexação de colunas UUIDv4 fica mais lenta, e isso motivou o desenvolvimento de UUIDv6 e UUIDv7.
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.
UUIDs geram muitas escritas não ordenadas, levando a inchaço de páginas.
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.